File size: 6,287 Bytes
beeea66 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | import inspect
import pyomo.environ as pyo
from typing import Any, Dict
from .base_solver import BaseSolver
class ILPSolver(BaseSolver):
"""
Exact MAPF solver via Pyomo ILP (hard constraints, no penalties). Works
with either GridILPBuilder or GraphILPBuilder β this class never branches
on grid vs. graph itself, it just asks the builder for vars_per_time and
local_index(v) to translate a solved Pyomo variable into a flat index.
Solves the whole time horizon in a single shot β no windowing, no
sampling β then packs the result into the same flat (robot, time,
position) variable index QUBO solvers use, so decode_path() and every
other BaseSolver post-processing helper work unchanged.
"""
def __init__(
self,
pyomo_solver_name="appsi_highs",
normalize_scale=0,
num_reads=1,
max_corrections=0,
verbose_level=2,
time_limit=30,
**kwargs,
):
"""
Args:
time_limit: Max solve time in seconds (default 30). Nothing else
in this codebase bounds ILP's runtime β unlike QAOA's fixed
opt_steps budget, branch-and-bound can otherwise run
arbitrarily long as problems scale up. If the limit is hit
before HiGHS proves optimality, it returns its best
incumbent so far (termination_condition reflects this β
check it before trusting `energy` as the true optimum).
"""
super().__init__(
solver="ilp",
normalize_scale=normalize_scale,
num_reads=num_reads,
max_corrections=max_corrections,
verbose_level=verbose_level,
pyomo_solver_name=pyomo_solver_name,
time_limit=time_limit, # forwarded so to_dict()/the manifest records it too
**kwargs,
)
self.pyomo_solver_name = pyomo_solver_name
self.time_limit = time_limit
def solve(self, builder, optimization=False, preprocess=True) -> Dict[str, Any]:
"""
Solve the ILP model held by builder.
Args:
builder: GridILPBuilder or GraphILPBuilder instance
optimization: Accepted for interface compatibility; unused (no
variational step for an exact ILP solve).
preprocess: When True (default), builder.build() fixes x[a, v, t]
to 0 for every BFS-from-start-unreachable (v, t) β see
BaseILPBuilder.build(). When False, only the per-robot active
time window is fixed; the full free-cell set stays open.
Always rebuilds (even if builder.model already exists) so
this flag reliably takes effect regardless of any prior
build() call β no windowing/sampling either way, ILP solves
the whole horizon exactly in one shot.
Returns:
Dictionary containing solution, energy, and raw response
"""
builder.build(preprocess=preprocess)
pyomo_solver = pyo.SolverFactory(self.pyomo_solver_name)
if "timelimit" in inspect.signature(pyomo_solver.solve).parameters:
# APPSI-backed solvers (appsi_highs, appsi_cbc, ...) β Pyomo's
# LegacySolverInterface.solve() replaces self.config with a fresh
# default config on every call and only reads time_limit from
# this kwarg, so setting pyomo_solver.config.time_limit ahead of
# time (the obvious-looking approach) is silently discarded.
results = pyomo_solver.solve(builder.model, timelimit=self.time_limit)
else:
# Plain legacy plugin backends (cbc, glpk, ...) β no shared
# kwarg; option name varies by solver (cbc wants 'seconds',
# glpk wants 'tmlim'). 'time_limit' covers the common case but
# isn't universal for every possible backend.
pyomo_solver.options["time_limit"] = self.time_limit
results = pyomo_solver.solve(builder.model)
problem = builder.problem
robot_nums = problem.get_robot_nums()
T = problem.T
vars_per_time = builder.vars_per_time
total_vars = vars_per_time * T * problem.num_robots
solution = {idx: 0 for idx in range(total_vars)}
for a in builder.model.A:
robot_num = robot_nums[a]
robot_offset = robot_num * (vars_per_time * T)
for t in builder.model.T:
for v in builder.model.V:
if pyo.value(builder.model.x[a, v, t]) > 0.5:
solution[robot_offset + t * vars_per_time + builder.local_index(v)] = 1
break
self.logger.standard(
f"ILP solve complete: {results.solver.termination_condition}, "
f"energy={pyo.value(builder.model.obj)}"
)
# Write the solved paths back onto problem.robots β QUBO solvers do
# this as a side effect of their windowed loop (builder.update_problem());
# ILP has no windowing loop, so it has to happen explicitly here. Callers
# (BenchmarkRunner, qubo_cli.py's output printing) read robot.path directly.
path = self.decode_path(solution, problem)
num_to_id = {num: rid for rid, num in robot_nums.items()}
for robot_num, coords in self.get_robot_paths(path).items():
robot = problem.robots[num_to_id[robot_num]]
robot.path = coords
robot.current_position = coords[-1][:2]
robot.active = False
return {
"solution": solution,
"energy": pyo.value(builder.model.obj),
"raw_response": results,
"metadata": {
"termination_condition": str(results.solver.termination_condition),
"solver_config": self.to_dict(),
# Single-entry list, not a per-window loop like QUBO's β ILP
# solves the whole horizon in one shot. Wrapped in a list so
# BenchmarkRunner.run_build()'s existing window_stats
# aggregation (benchmark.py) picks it up unchanged.
"window_stats": [builder.bfs_stats],
},
}
|