inire/inire/tests/test_pathfinder.py
2026-03-29 20:35:58 -07:00

421 lines
14 KiB
Python

import pytest
from inire.geometry.collision import CollisionEngine
from inire.geometry.components import Bend90, Straight
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.outcomes import RoutingOutcome
from inire.router.pathfinder import PathFinder, RoutingResult
from inire.router.session import (
create_routing_session_state,
prepare_routing_session_state,
run_routing_iteration,
)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list:
path = []
curr = start
dilation = clearance / 2.0
for kind, value in steps:
if kind == "B":
comp = Bend90.generate(curr, 5.0, width, value, dilation=dilation)
else:
comp = Straight.generate(curr, value, width, dilation=dilation)
path.append(comp)
curr = comp.end_port
return path
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
assert len(results) == 2
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0
def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
# Force a crossing by setting low iterations and low penalty
pf = PathFinder(context, max_iterations=1, base_congestion_penalty=1.0, warm_start=None)
# Net 1: (0, 25) -> (100, 25) Horizontal
# Net 2: (50, 0) -> (50, 50) Vertical
netlist = {
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
# Both should be invalid because they cross
assert not results["net1"].is_valid
assert not results["net2"].is_valid
assert results["net1"].collisions > 0
assert results["net2"].collisions > 0
def test_prepare_routing_session_state_builds_warm_start_and_sorts_nets(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
calls: list[tuple[str, list[str]]] = []
cleared: list[bool] = []
def fake_build(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
order: str,
) -> dict[str, list]:
calls.append((order, list(netlist.keys())))
return {"warm": []}
monkeypatch.setattr(PathFinder, "_build_greedy_warm_start_paths", lambda self, netlist, net_widths, order: fake_build(netlist, net_widths, order))
monkeypatch.setattr(AStarContext, "clear_static_caches", lambda self: cleared.append(True))
netlist = {
"short": (Port(0, 0, 0), Port(10, 0, 0)),
"long": (Port(0, 0, 0), Port(40, 10, 0)),
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
}
state = create_routing_session_state(
pf,
netlist,
{net_id: 2.0 for net_id in netlist},
store_expanded=False,
iteration_callback=None,
shuffle_nets=False,
sort_nets="longest",
initial_paths=None,
seed=None,
)
prepare_routing_session_state(pf, state)
assert calls == [("longest", ["short", "long", "mid"])]
assert cleared == [True]
assert state.initial_paths == {"warm": []}
assert state.all_net_ids == ["long", "mid", "short"]
def test_run_routing_iteration_updates_results_and_invokes_callback(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
callback_results: list[dict[str, RoutingResult]] = []
def fake_route_once(
net_id: str,
start: Port,
target: Port,
width: float,
iteration: int,
initial_paths: dict[str, list] | None,
store_expanded: bool,
needs_self_collision_check: set[str],
) -> tuple[RoutingResult, RoutingOutcome]:
_ = (start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check)
result = RoutingResult(
net_id,
[],
net_id == "net1",
int(net_id == "net2"),
reached_target=True,
outcome="completed" if net_id == "net1" else "colliding",
)
return result, result.outcome
monkeypatch.setattr(
PathFinder,
"_route_net_once",
lambda self, net_id, start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check: fake_route_once(
net_id,
start,
target,
width,
iteration,
initial_paths,
store_expanded,
needs_self_collision_check,
),
)
state = create_routing_session_state(
pf,
{"net1": (Port(0, 0, 0), Port(10, 0, 0)), "net2": (Port(0, 10, 0), Port(10, 10, 0))},
{"net1": 2.0, "net2": 2.0},
store_expanded=True,
iteration_callback=lambda iteration, results: callback_results.append(dict(results)),
shuffle_nets=False,
sort_nets=None,
initial_paths={"seeded": []},
seed=None,
)
outcomes = run_routing_iteration(pf, state, iteration=0)
assert outcomes == {"net1": "completed", "net2": "colliding"}
assert set(state.results) == {"net1", "net2"}
assert callback_results and set(callback_results[0]) == {"net1", "net2"}
assert state.results["net1"].is_valid
assert not state.results["net2"].is_valid
assert state.results["net2"].outcome == "colliding"
def test_run_routing_iteration_timeout_finalizes_tree(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
finalized: list[bool] = []
monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: finalized.append(True))
state = create_routing_session_state(
pf,
{"net1": (Port(0, 0, 0), Port(10, 0, 0))},
{"net1": 2.0},
store_expanded=False,
iteration_callback=None,
shuffle_nets=False,
sort_nets=None,
initial_paths={},
seed=None,
)
state.start_time = 0.0
state.session_timeout = 0.0
result = run_routing_iteration(pf, state, iteration=0)
assert result is None
assert finalized == [True]
def test_route_all_retries_partial_paths_across_iterations(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context, max_iterations=3, warm_start=None, refine_paths=False)
calls: list[int] = []
class FakeComponent:
def __init__(self, start_port: Port, end_port: Port) -> None:
self.start_port = start_port
self.end_port = end_port
def fake_route_astar(
start: Port,
target: Port,
width: float,
*,
context: AStarContext,
metrics: object,
net_id: str,
bend_collision_type: str,
return_partial: bool,
store_expanded: bool,
skip_congestion: bool,
self_collision_check: bool,
node_limit: int,
) -> list[FakeComponent]:
_ = (
width,
context,
metrics,
net_id,
bend_collision_type,
return_partial,
store_expanded,
skip_congestion,
self_collision_check,
node_limit,
)
calls.append(len(calls))
if len(calls) == 1:
return [FakeComponent(start, Port(5, 0, 0))]
return [FakeComponent(start, target)]
monkeypatch.setattr("inire.router.pathfinder.route_astar", fake_route_astar)
monkeypatch.setattr(type(pf.path_state), "install_path", lambda self, net_id, path: None)
monkeypatch.setattr(type(pf.path_state), "remove_path", lambda self, net_id: None)
monkeypatch.setattr(
type(pf.path_state),
"verify_path_report",
lambda self, net_id, path: basic_evaluator.collision_engine.verify_path_report(net_id, []),
)
monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: None)
results = pf.route_all({"net": (Port(0, 0, 0), Port(10, 0, 0))}, {"net": 2.0})
assert calls == [0, 1]
assert results["net"].reached_target
assert results["net"].is_valid
assert results["net"].outcome == "completed"
def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None:
bounds = (0, -50, 100, 50)
def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[10.0])
return engine, PathFinder(context, refine_paths=refine_paths)
base_engine, base_pf = build_pathfinder(refine_paths=False)
base_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
base_engine.lock_net("netA")
base_result = base_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
refined_engine, refined_pf = build_pathfinder(refine_paths=True)
refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
refined_engine.lock_net("netA")
refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
assert base_result.is_valid
assert refined_result.is_valid
assert refined_bends < base_bends
assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path)
def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None:
bounds = (0, 0, 100, 100)
netlist = {
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
}
net_widths = {net_id: 2.0 for net_id in netlist}
def build_pathfinder(*, refine_paths: bool) -> PathFinder:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0])
return PathFinder(context, base_congestion_penalty=1000.0, refine_paths=refine_paths)
base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths)
refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths)
for net_id in ("vertical_up", "vertical_down"):
base_result = base_results[net_id]
refined_result = refined_results[net_id]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
assert base_result.is_valid
assert refined_result.is_valid
assert refined_bends < base_bends
def test_refine_path_handles_same_orientation_lateral_offset() -> None:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
pf = PathFinder(context, refine_paths=True)
start = Port(0, 0, 0)
width = 2.0
path = _build_manual_path(
start,
width,
engine.clearance,
[
("B", "CCW"),
("S", 10.0),
("B", "CW"),
("S", 20.0),
("B", "CW"),
("S", 10.0),
("B", "CCW"),
("S", 10.0),
("B", "CCW"),
("S", 5.0),
("B", "CW"),
],
)
target = path[-1].end_port
refined = pf._refine_path("net", start, target, width, path)
assert target == Port(60, 15, 0)
assert sum(1 for comp in path if comp.move_type == "Bend90") == 6
assert sum(1 for comp in refined if comp.move_type == "Bend90") == 4
assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path)
def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
pf = PathFinder(context, refine_paths=True)
start = Port(0, 0, 0)
width = 2.0
path = _build_manual_path(
start,
width,
engine.clearance,
[
("B", "CCW"),
("S", 10.0),
("B", "CW"),
("S", 20.0),
("B", "CW"),
("S", 10.0),
("B", "CCW"),
("S", 10.0),
("B", "CCW"),
("S", 5.0),
("B", "CW"),
("B", "CCW"),
("S", 10.0),
],
)
target = path[-1].end_port
refined = pf._refine_path("net", start, target, width, path)
assert target == Port(65, 30, 90)
assert sum(1 for comp in path if comp.move_type == "Bend90") == 7
assert sum(1 for comp in refined if comp.move_type == "Bend90") == 5
assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path)