File size: 6,743 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 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | from typing import Any, Dict
from .base_solver import BaseSolver
from .cbs_algorithm import ConflictBasedSearch
class CBSSolver(BaseSolver):
"""
Classical MAPF baseline via Conflict-Based Search (CBS): space-time A*
per robot (cbs_algorithm.SpaceTimeAStar) as the low-level solver, with a
high-level constraint-tree search resolving conflicts by branching. Works
with either GridCBSBuilder or GraphCBSBuilder. This class never branches on grid vs. graph itself,
it just asks the builder for vars_per_time, local_index(v), and
get_robot_start_goal(robot_id) to translate between CBS's plain-graph-node-id paths and the same flat
(robot, time, position) index QUBO/ILP solvers use, so decode_path() and
every other BaseSolver post-processing helper work unchanged.
"""
def __init__(
self,
node_limit=5000,
time_limit=60,
normalize_scale=0,
num_reads=1,
max_corrections=0,
verbose_level=2,
**kwargs,
):
"""
Args:
node_limit: Max constraint-tree nodes to expand (default 5000).
Safety cap so a pathological conflict-branching case can't
hang a sweep β mirrors ILPSolver's time_limit in spirit.
time_limit: Max wall-clock seconds (default 60). Whichever of
node_limit/time_limit is hit first stops the search β but
unlike ILPSolver's HiGHS time_limit, this does NOT return a
best-incumbent-so-far: CBS has no such concept (see
ConflictBasedSearch.solve()'s docstring β every node it
pops either has zero conflicts, and gets returned
immediately, or at least one, and there's no "good but
unproven" state in between). A budget cutoff here means an
empty solution and energy=inf, not a worse-but-valid answer.
"""
super().__init__(
solver="cbs",
normalize_scale=normalize_scale,
num_reads=num_reads,
max_corrections=max_corrections,
verbose_level=verbose_level,
node_limit=node_limit, # forwarded so to_dict()/the manifest records these too
time_limit=time_limit,
**kwargs,
)
self.node_limit = node_limit
self.time_limit = time_limit
def solve(self, builder, optimization=False, preprocess=True) -> Dict[str, Any]:
"""
Solve the MAPF problem held by builder via CBS.
Args:
builder: GridCBSBuilder or GraphCBSBuilder instance
optimization: Accepted for interface compatibility; unused (CBS
is exact/deterministic search, no variational step).
preprocess: When True (default), builder.build() computes
forward+backward BFS reachability per robot and CBS's
low-level search only considers those cells β see
BaseCBSBuilder.build(). When False, the search considers
every free cell at every step. Always rebuilds (even if
builder.graph already exists) so this flag reliably takes
effect, same reasoning as ILPSolver.solve().
Returns:
Dictionary containing solution, energy, and raw response
"""
builder.build(preprocess=preprocess)
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
robots_meta = {}
for robot_id, robot in problem.robots.items():
start_node, goal_node = builder.get_robot_start_goal(robot_id)
robots_meta[robot_id] = {
"start": start_node,
"goal": goal_node,
"start_time": robot.start_time,
"deadline": robot.start_time + robot.T - 1,
}
cbs = ConflictBasedSearch(
builder.graph, node_limit=self.node_limit, time_limit=self.time_limit
)
paths, meta = cbs.solve(robots_meta, legal_cells=builder.legal_cells)
solution = {idx: 0 for idx in range(total_vars)}
for robot_id, path in paths.items():
robot_num = robot_nums[robot_id]
robot_offset = robot_num * (vars_per_time * T)
for node_id, t in path:
solution[robot_offset + t * vars_per_time + node_id] = 1
# Sum-of-costs: each robot's steps away from goal (arrival_time -
# start_time), not padded path length
if len(paths) < problem.num_robots:
energy = float("inf") # some robot has no path at all
else:
energy = sum(
self._robot_cost(
path, robots_meta[rid]["goal"], robots_meta[rid]["start_time"]
)
for rid, path in paths.items()
)
self.logger.standard(
f"CBS solve complete: {meta['termination_condition']}, "
f"energy={energy}, nodes_expanded={meta['nodes_expanded']}"
)
# Write the solved paths back onto problem.robots β QUBO solvers do
# this as a side effect of their windowed loop; CBS has no windowing
# loop, so it has to happen explicitly here, same as ILPSolver.
decoded = 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(decoded).items():
if not coords:
continue # robot has no path (infeasible) β leave its state untouched
robot = problem.robots[num_to_id[robot_num]]
robot.path = coords
robot.current_position = coords[-1][:2]
robot.active = False
return {
"solution": solution,
"energy": energy,
"raw_response": meta,
"metadata": {
"termination_condition": meta["termination_condition"],
"solver_config": self.to_dict(),
# Single-entry list, not a per-window loop β CBS 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],
},
}
@staticmethod
def _robot_cost(path, goal, start_time):
t_arrive = path[-1][1]
for node, t in reversed(path):
if node != goal:
break
t_arrive = t
return t_arrive - start_time
|