From f2b2bf22f9f96e061ef5d5a66d64b387c5b9e2e8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 29 Mar 2026 15:46:37 -0700 Subject: [PATCH 1/6] Add refinement by default --- examples/03_locked_paths.png | Bin 73527 -> 65072 bytes inire/router/pathfinder.py | 231 ++++++++++++++++++++++++++++----- inire/tests/test_pathfinder.py | 95 ++++++++++++++ 3 files changed, 294 insertions(+), 32 deletions(-) diff --git a/examples/03_locked_paths.png b/examples/03_locked_paths.png index d767df93276e33e5ece68343026ef212e8cf323e..687dad6aad6d507bf1353f267c4535de1b2de61d 100644 GIT binary patch delta 31562 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≶DP$a&IOhxChuZOuje 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 delta 40084 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 None: self.context = context self.metrics = metrics if metrics is not None else AStarMetrics() @@ -158,6 +158,11 @@ class PathFinder: all_dilated.extend([p.buffer(dilation) for p in res.geometry]) return all_geoms, all_dilated + def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]: + ports = [start] + ports.extend(comp.end_port for comp in path) + return ports + def _to_local(self, start: Port, point: Port) -> tuple[int, int]: dx = point.x - start.x dy = point.y - start.y @@ -169,6 +174,112 @@ class PathFinder: return -dx, -dy return -dy, dx + def _to_local_xy(self, start: Port, x: float, y: float) -> tuple[float, float]: + dx = float(x) - start.x + dy = float(y) - start.y + if start.r == 0: + return dx, dy + if start.r == 90: + return dy, -dx + if start.r == 180: + return -dx, -dy + return -dy, dx + + def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]: + min_x = float(min(start.x, target.x)) + min_y = float(min(start.y, target.y)) + max_x = float(max(start.x, target.x)) + max_y = float(max(start.y, target.y)) + for comp in path: + bounds = comp.total_bounds + min_x = min(min_x, bounds[0]) + min_y = min(min_y, bounds[1]) + max_x = max(max_x, bounds[2]) + max_y = max(max_y, bounds[3]) + return (min_x - pad, min_y - pad, max_x + pad, max_y + pad) + + def _candidate_side_extents( + self, + start: Port, + target: Port, + window_path: list[ComponentResult], + net_width: float, + radius: float, + ) -> list[float]: + local_dx, local_dy = self._to_local(start, target) + if local_dx < 4.0 * radius - 0.01: + return [] + + local_points = [self._to_local(start, start)] + local_points.extend(self._to_local(start, comp.end_port) for comp in window_path) + min_side = float(min(point[1] for point in local_points)) + max_side = float(max(point[1] for point in local_points)) + + positive_anchors: set[float] = set() + negative_anchors: set[float] = set() + direct_extents: set[float] = set() + + if max_side > 0.01: + positive_anchors.add(max_side) + direct_extents.add(max_side) + if min_side < -0.01: + negative_anchors.add(min_side) + direct_extents.add(min_side) + if local_dy > 0: + positive_anchors.add(float(local_dy)) + elif local_dy < 0: + negative_anchors.add(float(local_dy)) + + collision_engine = self.cost_evaluator.collision_engine + pad = 2.0 * radius + collision_engine.clearance + net_width + query_bounds = self._window_query_bounds(start, target, window_path, pad) + x_min = min(0.0, float(local_dx)) - 0.01 + x_max = max(0.0, float(local_dx)) + 0.01 + + for obj_id in collision_engine.static_index.intersection(query_bounds): + bounds = collision_engine.static_geometries[obj_id].bounds + local_corners = ( + self._to_local_xy(start, bounds[0], bounds[1]), + self._to_local_xy(start, bounds[0], bounds[3]), + self._to_local_xy(start, bounds[2], bounds[1]), + self._to_local_xy(start, bounds[2], bounds[3]), + ) + obs_min_x = min(pt[0] for pt in local_corners) + obs_max_x = max(pt[0] for pt in local_corners) + if obs_max_x < x_min or obs_min_x > x_max: + continue + obs_min_y = min(pt[1] for pt in local_corners) + obs_max_y = max(pt[1] for pt in local_corners) + positive_anchors.add(obs_max_y) + negative_anchors.add(obs_min_y) + + for obj_id in collision_engine.dynamic_index.intersection(query_bounds): + _, poly = collision_engine.dynamic_geometries[obj_id] + bounds = poly.bounds + local_corners = ( + self._to_local_xy(start, bounds[0], bounds[1]), + self._to_local_xy(start, bounds[0], bounds[3]), + self._to_local_xy(start, bounds[2], bounds[1]), + self._to_local_xy(start, bounds[2], bounds[3]), + ) + obs_min_x = min(pt[0] for pt in local_corners) + obs_max_x = max(pt[0] for pt in local_corners) + if obs_max_x < x_min or obs_min_x > x_max: + continue + obs_min_y = min(pt[1] for pt in local_corners) + obs_max_y = max(pt[1] for pt in local_corners) + positive_anchors.add(obs_max_y) + negative_anchors.add(obs_min_y) + + for anchor in tuple(positive_anchors): + if anchor > max(0.0, float(local_dy)) - 0.01: + direct_extents.add(anchor + pad) + for anchor in tuple(negative_anchors): + if anchor < min(0.0, float(local_dy)) + 0.01: + direct_extents.add(anchor - pad) + + return sorted(direct_extents, key=lambda value: (abs(value), value)) + def _build_same_orientation_dogleg( self, start: Port, @@ -178,17 +289,25 @@ class PathFinder: side_extent: float, ) -> list[ComponentResult] | None: local_dx, local_dy = self._to_local(start, target) - if abs(local_dy) > 0 or local_dx < 4.0 * radius - 0.01: + if local_dx < 4.0 * radius - 0.01 or abs(side_extent) < 0.01: return None side_abs = abs(side_extent) - side_length = side_abs - 2.0 * radius - if side_length < self.context.config.min_straight_length - 0.01: + first_straight = side_abs - 2.0 * radius + second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) + if first_straight < -0.01 or second_straight < -0.01: + return None + min_straight = self.context.config.min_straight_length + if 0.01 < first_straight < min_straight - 0.01: + return None + if 0.01 < second_straight < min_straight - 0.01: return None forward_length = local_dx - 4.0 * radius if forward_length < -0.01: return None + if 0.01 < forward_length < min_straight - 0.01: + return None first_dir = "CCW" if side_extent > 0 else "CW" second_dir = "CW" if side_extent > 0 else "CCW" @@ -198,9 +317,9 @@ class PathFinder: curr = start for direction, straight_len in ( - (first_dir, side_length), + (first_dir, first_straight), (second_dir, forward_length), - (second_dir, side_length), + (second_dir, second_straight), (first_dir, None), ): bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) @@ -217,6 +336,68 @@ class PathFinder: return None return path + def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]: + ports = self._path_ports(start, path) + windows: list[tuple[int, int]] = [] + min_radius = min(self.context.config.bend_radii, default=0.0) + + for window_size in range(len(path), 0, -1): + for start_idx in range(0, len(path) - window_size + 1): + end_idx = start_idx + window_size + window = path[start_idx:end_idx] + bend_count = sum(1 for comp in window if comp.move_type == "Bend90") + if bend_count < 4: + continue + window_start = ports[start_idx] + window_end = ports[end_idx] + if window_start.r != window_end.r: + continue + local_dx, _ = self._to_local(window_start, window_end) + if local_dx < 4.0 * min_radius - 0.01: + continue + windows.append((start_idx, end_idx)) + return windows + + def _try_refine_window( + self, + net_id: str, + start: Port, + net_width: float, + path: list[ComponentResult], + start_idx: int, + end_idx: int, + best_cost: float, + ) -> tuple[list[ComponentResult], float] | None: + ports = self._path_ports(start, path) + window_start = ports[start_idx] + window_end = ports[end_idx] + window_path = path[start_idx:end_idx] + collision_engine = self.cost_evaluator.collision_engine + + best_path: list[ComponentResult] | None = None + best_candidate_cost = best_cost + + for radius in self.context.config.bend_radii: + side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius) + for side_extent in side_extents: + replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) + if replacement is None: + continue + candidate_path = path[:start_idx] + replacement + path[end_idx:] + if self._has_self_collision(candidate_path): + continue + is_valid, collisions = collision_engine.verify_path(net_id, candidate_path) + if not is_valid or collisions != 0: + continue + candidate_cost = self._path_cost(candidate_path) + if candidate_cost + 1e-6 < best_candidate_cost: + best_candidate_cost = candidate_cost + best_path = candidate_path + + if best_path is None: + return None + return best_path, best_candidate_cost + def _refine_path( self, net_id: str, @@ -225,41 +406,27 @@ class PathFinder: net_width: float, path: list[ComponentResult], ) -> list[ComponentResult]: - if not path or start.r != target.r: + if not path: return path bend_count = sum(1 for comp in path if comp.move_type == "Bend90") - if bend_count < 5: - return path - - side_extents = [] - local_points = [self._to_local(start, start)] - local_points.extend(self._to_local(start, comp.end_port) for comp in path) - min_side = min(point[1] for point in local_points) - max_side = max(point[1] for point in local_points) - if min_side < -0.01: - side_extents.append(float(min_side)) - if max_side > 0.01: - side_extents.append(float(max_side)) - if not side_extents: + if bend_count < 4: return path best_path = path best_cost = self._path_cost(path) - collision_engine = self.cost_evaluator.collision_engine - for radius in self.context.config.bend_radii: - for side_extent in side_extents: - candidate = self._build_same_orientation_dogleg(start, target, net_width, radius, side_extent) - if candidate is None: + for _ in range(3): + improved = False + for start_idx, end_idx in self._iter_refinement_windows(start, best_path): + refined = self._try_refine_window(net_id, start, net_width, best_path, start_idx, end_idx, best_cost) + if refined is None: continue - is_valid, collisions = collision_engine.verify_path(net_id, candidate) - if not is_valid or collisions != 0: - continue - candidate_cost = self._path_cost(candidate) - if candidate_cost + 1e-6 < best_cost: - best_cost = candidate_cost - best_path = candidate + best_path, best_cost = refined + improved = True + break + if not improved: + break return best_path diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 252a96e..f6923c4 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,6 +1,7 @@ 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 @@ -16,6 +17,20 @@ def basic_evaluator() -> CostEvaluator: 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) @@ -116,3 +131,83 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: 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) From 0c432bd22983b38303a55c874675bafbbc9fceac Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 29 Mar 2026 18:27:03 -0700 Subject: [PATCH 2/6] more fixes and refactoring --- DOCS.md | 7 +- examples/05_orientation_stress.png | Bin 94169 -> 95487 bytes examples/06_bend_collision_models.png | Bin 86097 -> 85921 bytes examples/06_bend_collision_models.py | 57 ++- examples/08_custom_bend_geometry.png | Bin 76727 -> 62621 bytes examples/08_custom_bend_geometry.py | 47 +- examples/README.md | 4 +- inire/geometry/components.py | 110 ++++- inire/router/astar.py | 40 +- inire/router/config.py | 10 +- inire/router/cost.py | 30 +- inire/router/path_state.py | 54 +++ inire/router/pathfinder.py | 547 +++++------------------- inire/router/refiner.py | 345 +++++++++++++++ inire/router/session.py | 146 +++++++ inire/router/visibility.py | 76 ++-- inire/tests/example_scenarios.py | 76 ++-- inire/tests/test_astar.py | 73 ++++ inire/tests/test_components.py | 45 +- inire/tests/test_example_performance.py | 4 +- inire/tests/test_pathfinder.py | 137 +++++- 21 files changed, 1202 insertions(+), 606 deletions(-) create mode 100644 inire/router/path_state.py create mode 100644 inire/router/refiner.py create mode 100644 inire/router/session.py diff --git a/DOCS.md b/DOCS.md index d83b018..60ebdc6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -9,7 +9,6 @@ The `AStarContext` stores the configuration and persistent state for the A* sear | Parameter | Type | Default | Description | | :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | | `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `snap_size` | `float` | 5.0 | Grid size (µm) for expansion moves. Larger values speed up search. | | `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. | | `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. | | `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). | @@ -17,7 +16,7 @@ The `AStarContext` stores the configuration and persistent state for the A* sear | `sbend_offsets` | `list[float] \| None` | `None` (Auto) | Lateral offsets for parametric S-bends. | | `bend_penalty` | `float` | 250.0 | Flat cost added for every 90-degree bend. | | `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. | -| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | +| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"` (an 8-point conservative arc proxy). | | `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide for clipped models. | | `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. | @@ -53,6 +52,7 @@ The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion | :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | | `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | | `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| +| `refine_paths` | `bool` | `True` | Run the post-route path simplifier that removes unnecessary bend ladders when it finds a valid lower-cost replacement. | --- @@ -84,7 +84,7 @@ The `greedy_h_weight` is your primary lever for search performance. ### Avoiding "Zig-Zags" If the router produces many small bends instead of a long straight line: 1. Increase `bend_penalty` (e.g., set to `100.0` or higher). -2. Ensure `straight_lengths` includes larger values like `25.0` or `100.0`. +2. Increase available `bend_radii` if larger turns are physically acceptable. 3. Decrease `greedy_h_weight` closer to `1.0`. ### Visibility Guidance @@ -92,6 +92,7 @@ The router can bias straight stop points using static obstacle corners. - **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space. - **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner. - **`"off"`**: Disables visibility-derived straight candidates entirely. +The arbitrary-point visibility scan remains available for diagnostics, but the router hot path intentionally uses the exact-corner / tangent-corner forms only. ### Handling Congestion In multi-net designs, if nets are overlapping: diff --git a/examples/05_orientation_stress.png b/examples/05_orientation_stress.png index 94bab94e88f9841f6b633a476ffb59dd051d0e32..b750c6ae8629441098cf225fabb980a675ec787a 100644 GIT binary patch literal 95487 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 efI?xA literal 94169 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 IbH?}oKiI9^!Tet@c>Fvntm)FGhN`$9muH zK6k$R;!kt2v(>0bJ+(+Z_I}ksXc5!RgE{D+3AZJ6}190 z>g`=!SD2fU_D)~f$1UWdDqD@-U0&rQy!^v_FIpu{z6ILZW#Sfy zY8@BO-daUCB>81{ii;OfbxryivcfR}F;;{KA%o61j~T~cbDTbp1xJC9hNbF*>s4bF z1SFs74%*0(L(8AN+z(mi%({NoFCG4P^ix*C%1ZxC?ar*^a^>Oj)oJp1x?nwLWYKFg z$4KE|(ZiV+4wDJOUQ39#_rKaMFQP)m$q{yjy%SH9=Ep~NqF9aab|fYm^th78d{GAv#~VXCpTbuK_wuzanWP|S_miZhwUB4E8Bx`eEuS{x zM#=0Fd&G^cwxy7<1E!WFm`#bq@^lnk!*JKqz1u&`p~g8GDddmBYjWB6HEzbl(C<1G zJdMr<3eOoj(^fTTjjJL&mfm;!wJ+hpe^J4QlMngZIec<$cljIv9d5^ReU%HVBR zB%3Z@b)b_JSF6V$Bl72Z!#tq7~XeSYT#U5iESw9ljTOC0>dVfFN)mSp7mWQnk~ zt2$e$^uk^PLRkZ!#mIVOyNAZ=3wyUFv3-`;l&M-^{>tYq+-4c7<_(n|F?pmHz8lz6`Q+ zqmDbO$V!dE>1d9K74O!Tu8$eFJ$RYUJpR^Mf7Aq%*=;#*hYj6*nNN4qAKO3WB`-d7p-GB8PVWBat`U5NZTd!s^QkI!p}!m9eA(x z%#*xYC1>^_@|hJ++!xHw6+5d3on(989_=otv~FCc%W*Aw$&H0@aKTG(`iUWAX8EoX zpWbcqg^4O_pr7B5F-ICAGfZ4%62BQo3L2WGe4SQ!lPU!J~eTTcXSWYSAVvd#GatmgenmEN=B%wZU5t@=K95S*2A4=xXX23?C z3E8~5{M~aw{@VdY)QltimJOCDO<us?@D?3Z(6Ejf?wE*n4_25)0y8>JCD3NC9Y^vz(WGEwdA?PKvvE=-G+OCs

l#Yjy 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 W<>A<-PSn>L>_5COd+*X8L;nrw?%i$x diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index 0df49ea..8c3c06a 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -11,6 +11,8 @@ def _route_scenario( bend_collision_type: str, netlist: dict[str, tuple[Port, Port]], widths: dict[str, float], + *, + bend_clip_margin: float | None = None, ) -> dict[str, RoutingResult]: problem = RoutingProblem( bounds=bounds, @@ -21,6 +23,7 @@ def _route_scenario( search=SearchOptions( bend_radii=(10.0,), bend_collision_type=bend_collision_type, + bend_clip_margin=bend_clip_margin, ), objective=ObjectiveWeights( bend_penalty=50.0, @@ -49,7 +52,14 @@ def main() -> None: print("Routing Scenario 2 (BBox)...") res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0}) print("Routing Scenario 3 (Clipped BBox)...") - res_clipped = _route_scenario(bounds, obstacles, "clipped_bbox", netlist_clipped, {"clipped_model": 2.0}) + res_clipped = _route_scenario( + bounds, + obstacles, + "clipped_bbox", + netlist_clipped, + {"clipped_model": 2.0}, + bend_clip_margin=1.0, + ) all_results = {**res_arc, **res_bbox, **res_clipped} all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} diff --git a/examples/07_large_scale_routing.py b/examples/07_large_scale_routing.py index ea69812..92098fb 100644 --- a/examples/07_large_scale_routing.py +++ b/examples/07_large_scale_routing.py @@ -3,17 +3,12 @@ import time from shapely.geometry import box from inire import ( - CongestionOptions, - DiagnosticsOptions, NetSpec, - ObjectiveWeights, Port, - RoutingOptions, RoutingProblem, RoutingResult, - SearchOptions, - route, ) +from inire.router._stack import build_routing_stack from inire.utils.visualization import plot_expanded_nodes, plot_routing_results @@ -45,12 +40,15 @@ def main() -> None: static_obstacles=tuple(obstacles), clearance=6.0, ) + from inire import CongestionOptions, DiagnosticsOptions, ObjectiveWeights, RoutingOptions, SearchOptions + options = RoutingOptions( search=SearchOptions( node_limit=2_000_000, bend_radii=(50.0,), sbend_radii=(50.0,), greedy_h_weight=1.5, + bend_clip_margin=10.0, ), objective=ObjectiveWeights( unit_length_cost=0.1, @@ -61,48 +59,59 @@ def main() -> None: max_iterations=15, base_penalty=100.0, multiplier=1.4, + net_order="shortest", shuffle_nets=True, seed=42, ), diagnostics=DiagnosticsOptions(capture_expanded=True), ) + stack = build_routing_stack(problem, options) + evaluator = stack.evaluator + finder = stack.finder + metrics = finder.metrics iteration_stats: list[dict[str, int]] = [] def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None: successes = sum(1 for result in current_results.values() if result.is_valid) total_collisions = sum(result.collisions for result in current_results.values()) + total_nodes = metrics.nodes_expanded print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") + new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) + evaluator.greedy_h_weight = new_greedy + print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}") iteration_stats.append( { "Iteration": iteration, "Success": successes, "Congestion": total_collisions, + "Nodes": total_nodes, } ) + metrics.reset_per_route() print(f"Routing {len(netlist)} nets through 200um bottleneck...") start_time = time.perf_counter() - run = route(problem, options=options, iteration_callback=iteration_callback) + results = finder.route_all(iteration_callback=iteration_callback) end_time = time.perf_counter() print(f"Routing took {end_time - start_time:.4f}s") print("\n--- Iteration Summary ---") - print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8}") - print("-" * 30) + print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}") + print("-" * 43) for stats in iteration_stats: - print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8}") + print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8} | {stats['Nodes']:<10}") - success_count = sum(1 for result in run.results_by_net.values() if result.is_valid) + success_count = sum(1 for result in results.values() if result.is_valid) print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.") - for net_id, result in run.results_by_net.items(): + for net_id, result in results.items(): if not result.is_valid: print(f" FAILED: {net_id}, collisions={result.collisions}") else: print(f" {net_id}: SUCCESS") - fig, ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist=netlist) - plot_expanded_nodes(list(run.expanded_nodes), ax=ax) + fig, ax = plot_routing_results(results, list(obstacles), bounds, netlist=netlist) + plot_expanded_nodes(list(finder.accumulated_expanded_nodes), ax=ax) fig.savefig("examples/07_large_scale_routing.png") print("Saved plot to examples/07_large_scale_routing.png") diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index 5acd82e..25e715b 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,50 +1,65 @@ from shapely.geometry import Polygon -from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route +from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._router import PathFinder +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap from inire.utils.visualization import plot_routing_results -def _run_request( - bounds: tuple[float, float, float, float], - bend_collision_type: object, - net_id: str, - start: Port, - target: Port, -) -> dict[str, RoutingResult]: - problem = RoutingProblem( - bounds=bounds, - nets=(NetSpec(net_id, start, target, width=2.0),), - ) - options = RoutingOptions( - search=SearchOptions( - bend_radii=(10.0,), - bend_collision_type=bend_collision_type, - sbend_radii=(), - ), - objective=ObjectiveWeights( - bend_penalty=50.0, - sbend_penalty=150.0, - ), - congestion=CongestionOptions(use_tiered_strategy=False), - ) - return route(problem, options=options).results_by_net - - def main() -> None: print("Running Example 08: Custom Bend Geometry...") bounds = (0, 0, 150, 150) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + metrics = AStarMetrics() start = Port(20, 20, 0) target = Port(100, 100, 90) print("Routing with standard arc...") - results_std = _run_request(bounds, "arc", "custom_bend", start, target) + results_std = PathFinder( + AStarContext( + evaluator, + RoutingProblem( + bounds=bounds, + nets=(NetSpec("custom_bend", start, target, width=2.0),), + ), + RoutingOptions( + search=SearchOptions(bend_radii=(10.0,), sbend_radii=()), + congestion=CongestionOptions(max_iterations=1), + ), + ), + metrics=metrics, + ).route_all() - custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) - print("Routing with custom bend geometry...") - results_custom = _run_request(bounds, custom_poly, "custom_model", start, target) + print("Routing with custom collision model...") + results_custom = PathFinder( + AStarContext( + evaluator, + RoutingProblem( + bounds=bounds, + nets=(NetSpec("custom_model", start, target, width=2.0),), + ), + RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type=custom_poly, + sbend_radii=(), + ), + congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False), + ), + ), + metrics=AStarMetrics(), + use_tiered_strategy=False, + ).route_all() all_results = {**results_std, **results_custom} fig, _ax = plot_routing_results( diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 0ff297f..1aeb152 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -26,7 +26,7 @@ def main() -> None: bend_penalty=50.0, sbend_penalty=150.0, ), - congestion=CongestionOptions(warm_start_enabled=False), + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ) print("Routing with a deliberately tiny node budget (should return a partial path)...") diff --git a/examples/README.md b/examples/README.md index 94e15f6..cfea579 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,9 +18,9 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting `inire` supports multiple collision models for bends, allowing a trade-off between search speed and geometric accuracy: * **Arc**: High-fidelity geometry (Highest accuracy). * **BBox**: Simple axis-aligned bounding box (Fastest search). -* **Clipped BBox**: A balanced 8-point conservative polygonal approximation of the arc (Optimal performance). +* **Clipped BBox**: A balanced model that clips the corners of the AABB to better fit the arc (Optimal performance). -Example 08 also demonstrates a custom polygonal bend geometry. Custom polygons are defined in bend-local coordinates around the bend center, mirrored for CW bends, and rotated with the bend orientation before being placed. The example uses a 6-point Manhattan 90-degree bend with the same width as the normal waveguide, and that polygon now serves as both the routed geometry and the search-time collision shape. +Example 08 also demonstrates a custom polygonal bend geometry. It uses a centered `20x20` box as a custom bend collision model. ![Custom Bend Geometry](08_custom_bend_geometry.png) diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 693f16e..714ef55 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -134,7 +134,21 @@ def _get_arc_polygons( return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] -def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: +def _clip_bbox_legacy( + cxy: tuple[float, float], + radius: float, + width: float, + ts: tuple[float, float], + clip_margin: float, +) -> Polygon: + arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0] + minx, miny, maxx, maxy = arc_poly.bounds + bbox_poly = box(minx, miny, maxx, maxy) + shrink = min(clip_margin, max(radius, width)) + return bbox_poly.buffer(-shrink, join_style=2) if shrink > 0 else bbox_poly + + +def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: """Return a conservative 8-point polygonal proxy for the arc. The polygon uses 4 points along the outer edge and 4 along the inner edge. @@ -165,6 +179,18 @@ def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[ return Polygon(numpy.concatenate((outer_points, inner_points), axis=0)) +def _clip_bbox( + cxy: tuple[float, float], + radius: float, + width: float, + ts: tuple[float, float], + clip_margin: float | None, +) -> Polygon: + if clip_margin is not None: + return _clip_bbox_legacy(cxy, radius, width, ts, clip_margin) + return _clip_bbox_polygonal(cxy, radius, width, ts) + + def _transform_custom_collision_polygon( collision_poly: Polygon, cxy: tuple[float, float], @@ -186,6 +212,7 @@ def _apply_collision_model( width: float, cxy: tuple[float, float], ts: tuple[float, float], + clip_margin: float | None = None, rotation_deg: float = 0.0, mirror_y: bool = False, ) -> list[Polygon]: @@ -194,7 +221,7 @@ def _apply_collision_model( if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": - clipped = _clip_bbox(cxy, radius, width, ts) + clipped = _clip_bbox(cxy, radius, width, ts, clip_margin) return [clipped if not clipped.is_empty else box(*arc_poly.bounds)] return [box(*arc_poly.bounds)] @@ -254,11 +281,11 @@ class Bend90: direction: Literal["CW", "CCW"], sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", + clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) sign = 1 if direction == "CCW" else -1 - uses_custom_geometry = isinstance(collision_type, Polygon) center_local = numpy.array((0.0, sign * radius)) end_local = numpy.array((radius, sign * radius)) @@ -278,27 +305,24 @@ class Bend90: width, (float(center_xy[0]), float(center_xy[1])), ts, + clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign < 0), ) - physical_geometry = collision_polys if uses_custom_geometry else arc_polys + physical_geometry = arc_polys if dilation > 0: - if uses_custom_geometry: - dilated_physical_geometry = [poly.buffer(dilation) for poly in collision_polys] - dilated_collision_geometry = dilated_physical_geometry - else: - dilated_physical_geometry = _get_arc_polygons( - (float(center_xy[0]), float(center_xy[1])), - radius, - width, - ts, - sagitta, - dilation=dilation, - ) - dilated_collision_geometry = ( - dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] - ) + dilated_physical_geometry = _get_arc_polygons( + (float(center_xy[0]), float(center_xy[1])), + radius, + width, + ts, + sagitta, + dilation=dilation, + ) + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + ) else: dilated_physical_geometry = physical_geometry dilated_collision_geometry = collision_polys @@ -325,13 +349,13 @@ class SBend: width: float, sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", + clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") sign = 1 if offset >= 0 else -1 - uses_custom_geometry = isinstance(collision_type, Polygon) theta = numpy.arccos(1.0 - abs(offset) / (2.0 * radius)) dx = 2.0 * radius * numpy.sin(theta) theta_deg = float(numpy.degrees(theta)) @@ -361,6 +385,7 @@ class SBend: width, (float(c1_xy[0]), float(c1_xy[1])), ts1, + clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign < 0), )[0], @@ -371,24 +396,21 @@ class SBend: width, (float(c2_xy[0]), float(c2_xy[1])), ts2, + clip_margin=clip_margin, rotation_deg=float(start_port.r), mirror_y=(sign > 0), )[0], ] - physical_geometry = geometry if uses_custom_geometry else actual_geometry + physical_geometry = actual_geometry if dilation > 0: - if uses_custom_geometry: - dilated_physical_geometry = [poly.buffer(dilation) for poly in geometry] - dilated_collision_geometry = dilated_physical_geometry - else: - dilated_physical_geometry = [ - _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], - _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], - ] - dilated_collision_geometry = ( - dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] - ) + dilated_physical_geometry = [ + _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], + _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], + ] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + ) else: dilated_physical_geometry = physical_geometry dilated_collision_geometry = geometry diff --git a/inire/model.py b/inire/model.py index 20d71fe..5100899 100644 --- a/inire/model.py +++ b/inire/model.py @@ -43,6 +43,7 @@ class SearchOptions: bend_radii: tuple[float, ...] = (50.0, 100.0) sbend_radii: tuple[float, ...] = (10.0,) bend_collision_type: BendCollisionModel = "arc" + bend_clip_margin: float | None = None visibility_guidance: VisibilityGuidance = "tangent_corner" def __post_init__(self) -> None: diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index 594a970..ff075cd 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -69,6 +69,7 @@ def process_move( net_width, params[1], collision_type=coll_type, + clip_margin=config.bend_clip_margin, dilation=self_dilation, ) else: @@ -78,6 +79,7 @@ def process_move( params[1], net_width, collision_type=coll_type, + clip_margin=config.bend_clip_margin, dilation=self_dilation, ) except ValueError: diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index f63fe43..6bf2b37 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: @dataclass(frozen=True, slots=True) class SearchRunConfig: bend_collision_type: BendCollisionModel + bend_clip_margin: float | None node_limit: int return_partial: bool = False store_expanded: bool = False @@ -39,6 +40,7 @@ class SearchRunConfig: search = options.search return cls( bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type, + bend_clip_margin=search.bend_clip_margin, node_limit=search.node_limit if node_limit is None else node_limit, return_partial=return_partial, store_expanded=store_expanded, diff --git a/inire/router/_seed_materialization.py b/inire/router/_seed_materialization.py index b63cda9..f370db6 100644 --- a/inire/router/_seed_materialization.py +++ b/inire/router/_seed_materialization.py @@ -24,6 +24,7 @@ def materialize_path_seed( current = start dilation = clearance / 2.0 bend_collision_type = search.bend_collision_type + bend_clip_margin = search.bend_clip_margin for segment in seed.segments: if isinstance(segment, StraightSeed): @@ -35,6 +36,7 @@ def materialize_path_seed( net_width, segment.direction, collision_type=bend_collision_type, + clip_margin=bend_clip_margin, dilation=dilation, ) elif isinstance(segment, SBendSeed): @@ -44,6 +46,7 @@ def materialize_path_seed( segment.radius, net_width, collision_type=bend_collision_type, + clip_margin=bend_clip_margin, dilation=dilation, ) else: diff --git a/inire/router/cost.py b/inire/router/cost.py index 73fa121..c4b62c3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -57,6 +57,14 @@ class CostEvaluator: def default_weights(self) -> ObjectiveWeights: return self._search_weights + @property + def greedy_h_weight(self) -> float: + return self._greedy_h_weight + + @greedy_h_weight.setter + def greedy_h_weight(self, value: float) -> None: + self._greedy_h_weight = float(value) + def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights: return self._search_weights if weights is None else weights diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index 2e74ec1..06619c4 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -270,7 +270,12 @@ def run_example_06() -> ScenarioOutcome: _build_evaluator(bounds, obstacles=obstacles), {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, {"clipped_model": 2.0}, - {"bend_radii": [10.0], "bend_collision_type": "clipped_bbox", "use_tiered_strategy": False}, + { + "bend_radii": [10.0], + "bend_collision_type": "clipped_bbox", + "bend_clip_margin": 1.0, + "use_tiered_strategy": False, + }, ), ] @@ -323,9 +328,11 @@ def run_example_07() -> ScenarioOutcome: "node_limit": 2000000, "bend_radii": [50.0], "sbend_radii": [50.0], + "bend_clip_margin": 10.0, "max_iterations": 15, "base_penalty": 100.0, "multiplier": 1.4, + "net_order": "shortest", "capture_expanded": True, "shuffle_nets": True, "seed": 42, @@ -333,7 +340,10 @@ def run_example_07() -> ScenarioOutcome: ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: - _ = idx, current_results + _ = current_results + new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) + evaluator.greedy_h_weight = new_greedy + metrics.reset_per_route() t0 = perf_counter() results = pathfinder.route_all(iteration_callback=iteration_callback) @@ -345,27 +355,27 @@ def run_example_08() -> ScenarioOutcome: bounds = (0, 0, 150, 150) netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"custom_bend": 2.0} - custom_model = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) - standard_evaluator = _build_evaluator(bounds) - custom_evaluator = _build_evaluator(bounds) + custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) + evaluator = _build_evaluator(bounds) t0 = perf_counter() results_std = _build_pathfinder( - standard_evaluator, + evaluator, bounds=bounds, nets=_net_specs(netlist, widths), bend_radii=[10.0], sbend_radii=[], - use_tiered_strategy=False, + max_iterations=1, metrics=AStarMetrics(), ).route_all() results_custom = _build_pathfinder( - custom_evaluator, + evaluator, bounds=bounds, nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), bend_radii=[10.0], bend_collision_type=custom_model, sbend_radii=[], + max_iterations=1, use_tiered_strategy=False, metrics=AStarMetrics(), ).route_all() @@ -386,7 +396,7 @@ def run_example_09() -> ScenarioOutcome: widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1}, ) t0 = perf_counter() results = pathfinder.route_all() @@ -397,7 +407,7 @@ def run_example_09() -> ScenarioOutcome: SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( ("example_01_simple_route", run_example_01), ("example_02_congestion_resolution", run_example_02), - ("example_03_locked_routes", run_example_03), + ("example_03_locked_paths", run_example_03), ("example_04_sbends_and_radii", run_example_04), ("example_05_orientation_stress", run_example_05), ("example_06_bend_collision_models", run_example_06), diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 9ac3c98..2708a56 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -102,6 +102,19 @@ def test_bend_collision_models() -> None: res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc") assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0]) + # 3. Legacy clip-margin mode should still be available when explicitly requested. + res_clipped_margin = Bend90.generate( + start, + radius, + width, + direction="CCW", + collision_type="clipped_bbox", + clip_margin=1.0, + ) + assert len(res_clipped_margin.collision_geometry[0].exterior.coords) - 1 == 4 + assert abs(res_clipped_margin.collision_geometry[0].area - 81.0) < 1e-6 + assert res_clipped_margin.collision_geometry[0].area > res_clipped.collision_geometry[0].area + def test_custom_bend_collision_polygon_uses_local_transform() -> None: custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) @@ -122,17 +135,16 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None: expected = shapely_translate(expected, center_xy[0], center_xy[1]) assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6 - assert result.physical_geometry[0].symmetric_difference(expected).area < 1e-6 -def test_custom_bend_collision_polygon_keeps_collision_and_physical_geometry_aligned() -> None: +def test_custom_bend_collision_polygon_only_overrides_search_geometry() -> None: custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0) - assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area < 1e-6 + assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area > 1e-6 assert result.dilated_collision_geometry is not None assert result.dilated_physical_geometry is not None - assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area < 1e-6 + assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area > 1e-6 def test_sbend_collision_models() -> None: diff --git a/inire/tests/test_cost.py b/inire/tests/test_cost.py index e6f73c4..6eef3bc 100644 --- a/inire/tests/test_cost.py +++ b/inire/tests/test_cost.py @@ -40,6 +40,18 @@ def test_cost_calculation() -> None: assert h_away >= h_90 +def test_greedy_h_weight_is_mutable() -> None: + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=(0, 0, 50, 50)) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=10.0) + + assert evaluator.greedy_h_weight == 1.5 + evaluator.greedy_h_weight = 1.2 + assert evaluator.greedy_h_weight == 1.2 + assert abs(evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) - 72.0) < 1e-6 + + def test_danger_map_kd_tree_and_cache() -> None: # Test that KD-Tree based danger map works and uses cache bounds = (0, 0, 1000, 1000) diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index de9e9f2..7f8517b 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -13,28 +13,28 @@ RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" PERFORMANCE_REPEATS = 3 REGRESSION_FACTOR = 1.5 -# Baselines are measured from the current code path without plotting. +# Baselines are measured from clean 6a28dcf-style runs without plotting. BASELINE_SECONDS = { "example_01_simple_route": 0.0035, "example_02_congestion_resolution": 0.2666, - "example_03_locked_routes": 0.2304, + "example_03_locked_paths": 0.2304, "example_04_sbends_and_radii": 1.8734, "example_05_orientation_stress": 0.5630, "example_06_bend_collision_models": 5.2382, "example_07_large_scale_routing": 1.2081, - "example_08_custom_bend_geometry": 0.9848, + "example_08_custom_bend_geometry": 4.2111, "example_09_unroutable_best_effort": 0.0056, } EXPECTED_OUTCOMES = { "example_01_simple_route": {"total_results": 1, "valid_results": 1, "reached_targets": 1}, "example_02_congestion_resolution": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, - "example_03_locked_routes": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, + "example_03_locked_paths": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_04_sbends_and_radii": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_07_large_scale_routing": {"total_results": 10, "valid_results": 10, "reached_targets": 10}, - "example_08_custom_bend_geometry": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, + "example_08_custom_bend_geometry": {"total_results": 2, "valid_results": 1, "reached_targets": 2}, "example_09_unroutable_best_effort": {"total_results": 1, "valid_results": 0, "reached_targets": 0}, } diff --git a/inire/tests/test_example_regressions.py b/inire/tests/test_example_regressions.py new file mode 100644 index 0000000..1a56cd2 --- /dev/null +++ b/inire/tests/test_example_regressions.py @@ -0,0 +1,184 @@ +import pytest +from shapely.geometry import Polygon, box + +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + Port, + RoutingOptions, + RoutingProblem, + SearchOptions, + route, +) +from inire.router._stack import build_routing_stack +from inire.seeds import Bend90Seed, PathSeed, StraightSeed +from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics + + +EXPECTED_OUTCOMES = { + "example_01_simple_route": (1, 1, 1), + "example_02_congestion_resolution": (3, 3, 3), + "example_03_locked_paths": (2, 2, 2), + "example_04_sbends_and_radii": (2, 2, 2), + "example_05_orientation_stress": (3, 3, 3), + "example_06_bend_collision_models": (3, 3, 3), + "example_07_large_scale_routing": (10, 10, 10), + "example_08_custom_bend_geometry": (2, 1, 2), + "example_09_unroutable_best_effort": (1, 0, 0), +} + + +@pytest.mark.parametrize(("name", "run"), SCENARIOS, ids=[name for name, _ in SCENARIOS]) +def test_examples_match_legacy_expected_outcomes(name: str, run) -> None: + outcome = run() + assert outcome[1:] == EXPECTED_OUTCOMES[name] + + +def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: + bounds = (-20, -20, 170, 170) + obstacles = ( + Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]), + Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]), + Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]), + ) + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("clipped_model", Port(10, 20, 0), Port(90, 40, 90), width=2.0),), + static_obstacles=obstacles, + ) + common_kwargs = { + "objective": ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0), + "congestion": CongestionOptions(use_tiered_strategy=False), + } + no_margin = route( + problem, + options=RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type="clipped_bbox", + ), + **common_kwargs, + ), + ).results_by_net["clipped_model"] + legacy_margin = route( + problem, + options=RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type="clipped_bbox", + bend_clip_margin=1.0, + ), + **common_kwargs, + ), + ).results_by_net["clipped_model"] + + assert no_margin.is_valid + assert legacy_margin.is_valid + assert legacy_margin.as_seed() != no_margin.as_seed() + assert legacy_margin.as_seed() == PathSeed( + ( + StraightSeed(5.0), + Bend90Seed(10.0, "CW"), + Bend90Seed(10.0, "CCW"), + StraightSeed(45.0), + Bend90Seed(10.0, "CCW"), + StraightSeed(30.0), + ) + ) + + +def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None: + bounds = (0, 0, 500, 300) + obstacles = ( + box(220, 0, 280, 100), + box(220, 200, 280, 300), + ) + netlist = { + "net_00": (Port(30, 130, 0), Port(470, 60, 0)), + "net_01": (Port(30, 140, 0), Port(470, 120, 0)), + "net_02": (Port(30, 150, 0), Port(470, 180, 0)), + "net_03": (Port(30, 160, 0), Port(470, 240, 0)), + } + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + static_obstacles=obstacles, + clearance=6.0, + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=200000, + bend_radii=(30.0,), + sbend_radii=(30.0,), + greedy_h_weight=1.5, + bend_clip_margin=10.0, + ), + objective=ObjectiveWeights( + unit_length_cost=0.1, + bend_penalty=100.0, + sbend_penalty=400.0, + ), + congestion=CongestionOptions( + max_iterations=6, + base_penalty=100.0, + multiplier=1.4, + net_order="shortest", + shuffle_nets=True, + seed=42, + ), + diagnostics=DiagnosticsOptions(capture_expanded=False), + ) + stack = build_routing_stack(problem, options) + evaluator = stack.evaluator + finder = stack.finder + weights: list[float] = [] + + def iteration_callback(iteration: int, current_results: dict[str, object]) -> None: + _ = current_results + new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) + evaluator.greedy_h_weight = new_greedy + weights.append(new_greedy) + finder.metrics.reset_per_route() + + results = finder.route_all(iteration_callback=iteration_callback) + + assert weights == [1.46] + assert evaluator.greedy_h_weight == 1.46 + assert all(result.is_valid for result in results.values()) + assert all(result.reached_target for result in results.values()) + + +def test_example_08_custom_box_restores_legacy_collision_outcome() -> None: + bounds = (0, 0, 150, 150) + netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} + widths = {"custom_bend": 2.0} + evaluator = _build_evaluator(bounds) + + standard = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + bend_radii=[10.0], + sbend_radii=[], + max_iterations=1, + metrics=AStarMetrics(), + ).route_all() + custom = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + bend_radii=[10.0], + bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), + sbend_radii=[], + max_iterations=1, + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() + + assert standard["custom_bend"].is_valid + assert standard["custom_bend"].reached_target + assert not custom["custom_model"].is_valid + assert custom["custom_model"].reached_target + assert custom["custom_model"].collisions == 2

x= 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 delta 47398 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 J-afNm{|BC$@L>P| diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index 324743b..e135118 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -2,21 +2,44 @@ from shapely.geometry import Polygon from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics +from inire.router.astar import AStarContext from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results +def _route_scenario( + bounds: tuple[float, float, float, float], + obstacles: list[Polygon], + bend_collision_type: str, + netlist: dict[str, tuple[Port, Port]], + widths: dict[str, float], + *, + bend_clip_margin: float = 10.0, +) -> dict[str, object]: + engine = CollisionEngine(clearance=2.0) + for obstacle in obstacles: + engine.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=bounds) + danger_map.precompute(obstacles) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + context = AStarContext( + evaluator, + bend_radii=[10.0], + bend_collision_type=bend_collision_type, + bend_clip_margin=bend_clip_margin, + ) + return PathFinder(context, use_tiered_strategy=False).route_all(netlist, widths) + + def main() -> None: print("Running Example 06: Bend Collision Models...") # 1. Setup Environment # Give room for 10um bends near the edges bounds = (-20, -20, 170, 170) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) # Create three scenarios with identical obstacles # We'll space them out vertically @@ -25,34 +48,26 @@ def main() -> None: obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) obstacles = [obs_arc, obs_bbox, obs_clipped] - for obs in obstacles: - engine.add_static_obstacle(obs) - danger_map.precompute(obstacles) - - # We'll run three separate routers since collision_type is a router-level config - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - - # Scenario 1: Standard 'arc' model (High fidelity) - context_arc = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc") netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} - - # Scenario 2: 'bbox' model (Conservative axis-aligned box) - context_bbox = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox") netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} - - # Scenario 3: 'clipped_bbox' model (Balanced) - context_clipped = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0) netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} # 2. Route each scenario print("Routing Scenario 1 (Arc)...") - res_arc = PathFinder(context_arc, use_tiered_strategy=False).route_all(netlist_arc, {"arc_model": 2.0}) + res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0}) print("Routing Scenario 2 (BBox)...") - res_bbox = PathFinder(context_bbox, use_tiered_strategy=False).route_all(netlist_bbox, {"bbox_model": 2.0}) + res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0}) print("Routing Scenario 3 (Clipped BBox)...") - res_clipped = PathFinder(context_clipped, use_tiered_strategy=False).route_all(netlist_clipped, {"clipped_model": 2.0}) + res_clipped = _route_scenario( + bounds, + obstacles, + "clipped_bbox", + netlist_clipped, + {"clipped_model": 2.0}, + bend_clip_margin=1.0, + ) # 3. Combine results for visualization all_results = {**res_arc, **res_bbox, **res_clipped} diff --git a/examples/08_custom_bend_geometry.png b/examples/08_custom_bend_geometry.png index 72560e38e69db44557316045a6f41363a03b3618..48c2e5c51088fda4e5ca73ba1720ef33744362d1 100644 GIT binary patch literal 62621 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 literal 76727 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 diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index 81331be..faff701 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -2,26 +2,27 @@ from shapely.geometry import Polygon from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics, route_astar +from inire.router.astar import AStarContext, AStarMetrics from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results +def _route_with_context( + context: AStarContext, + metrics: AStarMetrics, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> dict[str, object]: + return PathFinder(context, metrics, use_tiered_strategy=False).route_all(netlist, net_widths) + + def main() -> None: print("Running Example 08: Custom Bend Geometry...") # 1. Setup Environment bounds = (0, 0, 150, 150) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) # 2. Define Netlist netlist = { @@ -29,22 +30,26 @@ def main() -> None: } net_widths = {"custom_bend": 2.0} + def build_context(bend_collision_type: object = "arc") -> tuple[AStarContext, AStarMetrics]: + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + return AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=bend_collision_type, sbend_radii=[]), AStarMetrics() + # 3. Route with standard arc first print("Routing with standard arc...") - results_std = pf.route_all(netlist, net_widths) + context_std, metrics_std = build_context() + results_std = _route_with_context(context_std, metrics_std, netlist, net_widths) - # 4. Define a custom 'trapezoid' bend model - # (Just for demonstration - we override the collision model during search) - # Define a custom centered 20x20 box - custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) + # 4. Define a custom Manhattan 90-degree bend proxy in bend-local coordinates. + # The polygon origin is the bend center. It is mirrored for CW bends and + # rotated with the bend orientation before being translated into place. + custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) - print("Routing with custom collision model...") - # Override bend_collision_type with a literal Polygon - context_custom = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=custom_poly, sbend_radii=[]) - metrics_custom = AStarMetrics() - results_custom = PathFinder(context_custom, metrics_custom, use_tiered_strategy=False).route_all( - {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0} - ) + print("Routing with custom bend geometry...") + context_custom, metrics_custom = build_context(custom_poly) + results_custom = _route_with_context(context_custom, metrics_custom, {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}) # 5. Visualize all_results = {**results_std, **results_custom} diff --git a/examples/README.md b/examples/README.md index 208f127..94e15f6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,7 +18,9 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting `inire` supports multiple collision models for bends, allowing a trade-off between search speed and geometric accuracy: * **Arc**: High-fidelity geometry (Highest accuracy). * **BBox**: Simple axis-aligned bounding box (Fastest search). -* **Clipped BBox**: A balanced model that clips the corners of the AABB to better fit the arc (Optimal performance). +* **Clipped BBox**: A balanced 8-point conservative polygonal approximation of the arc (Optimal performance). + +Example 08 also demonstrates a custom polygonal bend geometry. Custom polygons are defined in bend-local coordinates around the bend center, mirrored for CW bends, and rotated with the bend orientation before being placed. The example uses a 6-point Manhattan 90-degree bend with the same width as the normal waveguide, and that polygon now serves as both the routed geometry and the search-time collision shape. ![Custom Bend Geometry](08_custom_bend_geometry.png) diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 9e755cf..36b425b 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Literal import numpy +from shapely.affinity import rotate as shapely_rotate +from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box @@ -135,11 +137,51 @@ def _get_arc_polygons( def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon: - arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0] - minx, miny, maxx, maxy = arc_poly.bounds - bbox_poly = box(minx, miny, maxx, maxy) - shrink = min(clip_margin, max(radius, width)) - return bbox_poly.buffer(-shrink, join_style=2) if shrink > 0 else bbox_poly + """Return a conservative 8-point polygonal proxy for the arc. + + The polygon uses 4 points along the outer edge and 4 along the inner edge. + The outer edge is a circumscribed polyline and the inner edge is an + inscribed polyline, so the result conservatively contains the true arc. + `clip_margin` is kept for API compatibility but is not used by this proxy. + """ + del clip_margin + + cx, cy = cxy + sample_count = 4 + angle_span = abs(float(ts[1]) - float(ts[0])) + if angle_span < TOLERANCE_ANGULAR: + return box(*_get_arc_polygons(cxy, radius, width, ts)[0].bounds) + + segment_half_angle = numpy.radians(angle_span / (2.0 * (sample_count - 1))) + cos_half = max(float(numpy.cos(segment_half_angle)), 1e-9) + + inner_radius = max(0.0, radius - width / 2.0) + outer_radius = radius + width / 2.0 + tolerance = max(1e-3, radius * 1e-4) + conservative_inner_radius = max(0.0, inner_radius * cos_half - tolerance) + conservative_outer_radius = outer_radius / cos_half + tolerance + + angles = numpy.radians(numpy.linspace(ts[0], ts[1], sample_count)) + cos_a = numpy.cos(angles) + sin_a = numpy.sin(angles) + + outer_points = numpy.column_stack((cx + conservative_outer_radius * cos_a, cy + conservative_outer_radius * sin_a)) + inner_points = numpy.column_stack((cx + conservative_inner_radius * cos_a[::-1], cy + conservative_inner_radius * sin_a[::-1])) + return Polygon(numpy.concatenate((outer_points, inner_points), axis=0)) + + +def _transform_custom_collision_polygon( + collision_poly: Polygon, + cxy: tuple[float, float], + rotation_deg: float, + mirror_y: bool, +) -> Polygon: + poly = collision_poly + if mirror_y: + poly = shapely_scale(poly, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0)) + if rotation_deg % 360: + poly = shapely_rotate(poly, rotation_deg, origin=(0.0, 0.0), use_radians=False) + return shapely_translate(poly, cxy[0], cxy[1]) def _apply_collision_model( @@ -150,9 +192,11 @@ def _apply_collision_model( cxy: tuple[float, float], clip_margin: float, ts: tuple[float, float], + rotation_deg: float = 0.0, + mirror_y: bool = False, ) -> list[Polygon]: if isinstance(collision_type, Polygon): - return [shapely_translate(collision_type, cxy[0], cxy[1])] + return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)] if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": @@ -211,6 +255,7 @@ class Bend90: ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) sign = 1 if direction == "CCW" else -1 + uses_custom_geometry = isinstance(collision_type, Polygon) center_local = numpy.array((0.0, sign * radius)) end_local = numpy.array((radius, sign * radius)) @@ -231,6 +276,8 @@ class Bend90: (float(center_xy[0]), float(center_xy[1])), clip_margin, ts, + rotation_deg=float(start_port.r), + mirror_y=(sign < 0), ) proxy_geometry = None @@ -248,8 +295,12 @@ class Bend90: dilated_actual_geometry = None dilated_geometry = None if dilation > 0: - dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation) - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + if uses_custom_geometry: + dilated_actual_geometry = [poly.buffer(dilation) for poly in collision_polys] + dilated_geometry = dilated_actual_geometry + else: + dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation) + dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] return ComponentResult( geometry=collision_polys, @@ -258,7 +309,7 @@ class Bend90: move_type="Bend90", dilated_geometry=dilated_geometry, proxy_geometry=proxy_geometry, - actual_geometry=arc_polys, + actual_geometry=collision_polys if uses_custom_geometry else arc_polys, dilated_actual_geometry=dilated_actual_geometry, ) @@ -279,6 +330,7 @@ class SBend: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") sign = 1 if offset >= 0 else -1 + uses_custom_geometry = isinstance(collision_type, Polygon) theta = numpy.arccos(1.0 - abs(offset) / (2.0 * radius)) dx = 2.0 * radius * numpy.sin(theta) theta_deg = float(numpy.degrees(theta)) @@ -301,8 +353,28 @@ class SBend: arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0] actual_geometry = [arc1, arc2] geometry = [ - _apply_collision_model(arc1, collision_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0], - _apply_collision_model(arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], + _apply_collision_model( + arc1, + collision_type, + radius, + width, + (float(c1_xy[0]), float(c1_xy[1])), + clip_margin, + ts1, + rotation_deg=float(start_port.r), + mirror_y=(sign < 0), + )[0], + _apply_collision_model( + arc2, + collision_type, + radius, + width, + (float(c2_xy[0]), float(c2_xy[1])), + clip_margin, + ts2, + rotation_deg=float(start_port.r), + mirror_y=(sign > 0), + )[0], ] proxy_geometry = None @@ -315,11 +387,15 @@ class SBend: dilated_actual_geometry = None dilated_geometry = None if dilation > 0: - dilated_actual_geometry = [ - _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], - _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], - ] - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + if uses_custom_geometry: + dilated_actual_geometry = [poly.buffer(dilation) for poly in geometry] + dilated_geometry = dilated_actual_geometry + else: + dilated_actual_geometry = [ + _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], + _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], + ] + dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] return ComponentResult( geometry=geometry, @@ -328,6 +404,6 @@ class SBend: move_type="SBend", dilated_geometry=dilated_geometry, proxy_geometry=proxy_geometry, - actual_geometry=actual_geometry, + actual_geometry=geometry if uses_custom_geometry else actual_geometry, dilated_actual_geometry=dilated_actual_geometry, ) diff --git a/inire/router/astar.py b/inire/router/astar.py index 44e59da..796775f 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -11,6 +11,7 @@ from inire.constants import TOLERANCE_LINEAR from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port from inire.router.config import RouterConfig, VisibilityGuidanceMode +from inire.router.refiner import component_hits_ancestor_chain from inire.router.visibility import VisibilityManager if TYPE_CHECKING: @@ -118,8 +119,11 @@ class AStarContext: bend_clip_margin=bend_clip_margin, visibility_guidance=visibility_guidance, ) - self.cost_evaluator.config = self.config - self.cost_evaluator._refresh_cached_config() + self.cost_evaluator.apply_routing_costs( + bend_penalty=self.config.bend_penalty, + sbend_penalty=self.config.sbend_penalty, + bend_radii=self.config.bend_radii, + ) self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) self.move_cache_rel: dict[tuple, ComponentResult] = {} @@ -160,9 +164,7 @@ def route_astar( if metrics is None: metrics = AStarMetrics() metrics.reset_per_route() - - if bend_collision_type is not None: - context.config.bend_collision_type = bend_collision_type + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type context.cost_evaluator.set_target(target) open_set: list[AStarNode] = [] @@ -212,6 +214,7 @@ def route_astar( context, metrics, congestion_cache, + effective_bend_collision_type, max_cost=max_cost, skip_congestion=skip_congestion, self_collision_check=self_collision_check, @@ -338,10 +341,12 @@ def expand_moves( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], + bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any | None = None, max_cost: float | None = None, skip_congestion: bool = False, self_collision_check: bool = False, ) -> None: + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type cp = current.port prev_move_type, prev_straight_length = _previous_move_metadata(current) dx_t = target.x - cp.x @@ -380,6 +385,7 @@ def expand_moves( "S", (int(round(proj_t)),), skip_congestion, + bend_collision_type=effective_bend_collision_type, max_cost=max_cost, self_collision_check=self_collision_check, ) @@ -433,6 +439,7 @@ def expand_moves( "S", (length,), skip_congestion, + bend_collision_type=effective_bend_collision_type, max_cost=max_cost, self_collision_check=self_collision_check, ) @@ -463,6 +470,7 @@ def expand_moves( "B", (radius, direction), skip_congestion, + bend_collision_type=effective_bend_collision_type, max_cost=max_cost, self_collision_check=self_collision_check, ) @@ -504,6 +512,7 @@ def expand_moves( "SB", (offset, radius), skip_congestion, + bend_collision_type=effective_bend_collision_type, max_cost=max_cost, self_collision_check=self_collision_check, ) @@ -522,11 +531,12 @@ def process_move( move_class: Literal["S", "B", "SB"], params: tuple, skip_congestion: bool, + bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any, max_cost: float | None = None, self_collision_check: bool = False, ) -> None: cp = parent.port - coll_type = context.config.bend_collision_type + coll_type = bend_collision_type coll_key = id(coll_type) if isinstance(coll_type, shapely.geometry.Polygon) else coll_type self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 @@ -565,7 +575,7 @@ def process_move( params[0], net_width, params[1], - collision_type=context.config.bend_collision_type, + collision_type=coll_type, clip_margin=context.config.bend_clip_margin, dilation=self_dilation, ) @@ -575,7 +585,7 @@ def process_move( params[0], params[1], net_width, - collision_type=context.config.bend_collision_type, + collision_type=coll_type, clip_margin=context.config.bend_clip_margin, dilation=self_dilation, ) @@ -660,18 +670,8 @@ def add_node( congestion_cache[cache_key] = total_overlaps if self_collision_check: - curr_p = parent - new_tb = result.total_bounds - while curr_p and curr_p.parent: - ancestor_res = curr_p.component_result - if ancestor_res: - anc_tb = ancestor_res.total_bounds - if new_tb[0] < anc_tb[2] and new_tb[2] > anc_tb[0] and new_tb[1] < anc_tb[3] and new_tb[3] > anc_tb[1]: - for p_anc in ancestor_res.geometry: - for p_new in result.geometry: - if p_new.intersects(p_anc) and not p_new.touches(p_anc): - return - curr_p = curr_p.parent + if component_hits_ancestor_chain(result, parent): + return penalty = 0.0 if move_type == "SB": diff --git a/inire/router/config.py b/inire/router/config.py index 7a49a2f..aac6264 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -13,17 +13,11 @@ class RouterConfig: """Configuration parameters for the A* Router.""" node_limit: int = 1000000 - # Sparse Sampling Configuration max_straight_length: float = 2000.0 - num_straight_samples: int = 5 min_straight_length: float = 5.0 - - # Offsets for SBends (None = automatic grid-based selection) + sbend_offsets: list[float] | None = None - - # Deprecated but kept for compatibility during refactor - straight_lengths: list[float] = field(default_factory=list) - + bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0]) sbend_radii: list[float] = field(default_factory=lambda: [10.0]) snap_to_target_dist: float = 1000.0 diff --git a/inire/router/cost.py b/inire/router/cost.py index b4aa53e..94aafe3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np @@ -63,19 +63,23 @@ class CostEvaluator: self._target_cos = 1.0 self._target_sin = 0.0 + def apply_routing_costs( + self, + *, + bend_penalty: float, + sbend_penalty: float, + bend_radii: list[float], + ) -> None: + self.config.bend_penalty = bend_penalty + self.config.sbend_penalty = sbend_penalty + self.config.min_bend_radius = min(bend_radii) if bend_radii else 50.0 + self._refresh_cached_config() + def _refresh_cached_config(self) -> None: - if hasattr(self.config, "min_bend_radius"): - self._min_radius = self.config.min_bend_radius - elif hasattr(self.config, "bend_radii") and self.config.bend_radii: - self._min_radius = min(self.config.bend_radii) - else: - self._min_radius = 50.0 - if hasattr(self.config, "unit_length_cost"): - self.unit_length_cost = self.config.unit_length_cost - if hasattr(self.config, "greedy_h_weight"): - self.greedy_h_weight = self.config.greedy_h_weight - if hasattr(self.config, "congestion_penalty"): - self.congestion_penalty = self.config.congestion_penalty + self._min_radius = self.config.min_bend_radius + self.unit_length_cost = self.config.unit_length_cost + self.greedy_h_weight = self.config.greedy_h_weight + self.congestion_penalty = self.config.congestion_penalty def set_target(self, target: Port) -> None: self._target_x = target.x diff --git a/inire/router/path_state.py b/inire/router/path_state.py new file mode 100644 index 0000000..ea26287 --- /dev/null +++ b/inire/router/path_state.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from inire.geometry.collision import CollisionEngine + from inire.geometry.components import ComponentResult + + +class PathStateManager: + __slots__ = ("collision_engine",) + + def __init__(self, collision_engine: CollisionEngine) -> None: + self.collision_engine = collision_engine + + def extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: + all_geoms = [] + all_dilated = [] + for res in path: + all_geoms.extend(res.geometry) + if res.dilated_geometry: + all_dilated.extend(res.dilated_geometry) + else: + dilation = self.collision_engine.clearance / 2.0 + all_dilated.extend([poly.buffer(dilation) for poly in res.geometry]) + return all_geoms, all_dilated + + def install_path(self, net_id: str, path: list[ComponentResult]) -> None: + all_geoms, all_dilated = self.extract_geometry(path) + self.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + + def stage_path_as_static(self, path: list[ComponentResult]) -> list[int]: + obj_ids: list[int] = [] + for res in path: + geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry + dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry + for index, poly in enumerate(geoms): + dilated = dilated_geoms[index] if dilated_geoms else None + obj_ids.append(self.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated)) + return obj_ids + + def remove_static_obstacles(self, obj_ids: list[int]) -> None: + for obj_id in obj_ids: + self.collision_engine.remove_static_obstacle(obj_id) + + def remove_path(self, net_id: str) -> None: + self.collision_engine.remove_path(net_id) + + def verify_path(self, net_id: str, path: list[ComponentResult]) -> tuple[bool, int]: + return self.collision_engine.verify_path(net_id, path) + + def finalize_dynamic_tree(self) -> None: + self.collision_engine.dynamic_tree = None + self.collision_engine._ensure_dynamic_tree() diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index cc80566..0b8ed1a 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -1,16 +1,19 @@ from __future__ import annotations import logging -import math -import random -import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Callable, Literal -import numpy - -from inire.geometry.components import Bend90, Straight from inire.router.astar import AStarMetrics, route_astar +from inire.router.refiner import PathRefiner, has_self_collision +from inire.router.path_state import PathStateManager +from inire.router.session import ( + create_routing_session_state, + finalize_routing_session_results, + prepare_routing_session_state, + refine_routing_session_results, + run_routing_iteration, +) if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -29,7 +32,6 @@ class RoutingResult: collisions: int reached_target: bool = False - class PathFinder: __slots__ = ( "context", @@ -41,6 +43,8 @@ class PathFinder: "accumulated_expanded_nodes", "warm_start", "refine_paths", + "refiner", + "path_state", ) def __init__( @@ -62,13 +66,15 @@ class PathFinder: self.use_tiered_strategy = use_tiered_strategy self.warm_start = warm_start self.refine_paths = refine_paths + self.refiner = PathRefiner(context) + self.path_state = PathStateManager(context.cost_evaluator.collision_engine) self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] @property def cost_evaluator(self) -> CostEvaluator: return self.context.cost_evaluator - def _perform_greedy_pass( + def _build_greedy_warm_start_paths( self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float], @@ -104,299 +110,36 @@ class PathFinder: if not path: continue greedy_paths[net_id] = path - for res in path: - geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry - dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry - for i, poly in enumerate(geoms): - dilated = dilated_geoms[i] if dilated_geoms else None - obj_id = self.cost_evaluator.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated) - temp_obj_ids.append(obj_id) + temp_obj_ids.extend(self.path_state.stage_path_as_static(path)) self.context.clear_static_caches() - for obj_id in temp_obj_ids: - self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) + self.path_state.remove_static_obstacles(temp_obj_ids) return greedy_paths def _has_self_collision(self, path: list[ComponentResult]) -> bool: - for i, comp_i in enumerate(path): - tb_i = comp_i.total_bounds - for j in range(i + 2, len(path)): - comp_j = path[j] - tb_j = comp_j.total_bounds - if tb_i[0] < tb_j[2] and tb_i[2] > tb_j[0] and tb_i[1] < tb_j[3] and tb_i[3] > tb_j[1]: - for p_i in comp_i.geometry: - for p_j in comp_j.geometry: - if p_i.intersects(p_j) and not p_i.touches(p_j): - return True - return False + return has_self_collision(path) def _path_cost(self, path: list[ComponentResult]) -> float: - total = 0.0 - bend_penalty = self.context.config.bend_penalty - sbend_penalty = self.context.config.sbend_penalty - for comp in path: - total += comp.length - if comp.move_type == "Bend90": - radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0 - if radius > 0: - total += bend_penalty * (10.0 / radius) ** 0.5 - else: - total += bend_penalty - elif comp.move_type == "SBend": - total += sbend_penalty - return total + return self.refiner.path_cost(path) - def _extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: - all_geoms = [] - all_dilated = [] - for res in path: - all_geoms.extend(res.geometry) - if res.dilated_geometry: - all_dilated.extend(res.dilated_geometry) - else: - dilation = self.cost_evaluator.collision_engine.clearance / 2.0 - all_dilated.extend([p.buffer(dilation) for p in res.geometry]) - return all_geoms, all_dilated + def _install_path(self, net_id: str, path: list[ComponentResult]) -> None: + self.path_state.install_path(net_id, path) - def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]: - ports = [start] - ports.extend(comp.end_port for comp in path) - return ports - - def _to_local(self, start: Port, point: Port) -> tuple[int, int]: - dx = point.x - start.x - dy = point.y - start.y - if start.r == 0: - return dx, dy - if start.r == 90: - return dy, -dx - if start.r == 180: - return -dx, -dy - return -dy, dx - - def _to_local_xy(self, start: Port, x: float, y: float) -> tuple[float, float]: - dx = float(x) - start.x - dy = float(y) - start.y - if start.r == 0: - return dx, dy - if start.r == 90: - return dy, -dx - if start.r == 180: - return -dx, -dy - return -dy, dx - - def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]: - min_x = float(min(start.x, target.x)) - min_y = float(min(start.y, target.y)) - max_x = float(max(start.x, target.x)) - max_y = float(max(start.y, target.y)) - for comp in path: - bounds = comp.total_bounds - min_x = min(min_x, bounds[0]) - min_y = min(min_y, bounds[1]) - max_x = max(max_x, bounds[2]) - max_y = max(max_y, bounds[3]) - return (min_x - pad, min_y - pad, max_x + pad, max_y + pad) - - def _candidate_side_extents( - self, - start: Port, - target: Port, - window_path: list[ComponentResult], - net_width: float, - radius: float, - ) -> list[float]: - local_dx, local_dy = self._to_local(start, target) - if local_dx < 4.0 * radius - 0.01: - return [] - - local_points = [self._to_local(start, start)] - local_points.extend(self._to_local(start, comp.end_port) for comp in window_path) - min_side = float(min(point[1] for point in local_points)) - max_side = float(max(point[1] for point in local_points)) - - positive_anchors: set[float] = set() - negative_anchors: set[float] = set() - direct_extents: set[float] = set() - - if max_side > 0.01: - positive_anchors.add(max_side) - direct_extents.add(max_side) - if min_side < -0.01: - negative_anchors.add(min_side) - direct_extents.add(min_side) - if local_dy > 0: - positive_anchors.add(float(local_dy)) - elif local_dy < 0: - negative_anchors.add(float(local_dy)) - - collision_engine = self.cost_evaluator.collision_engine - pad = 2.0 * radius + collision_engine.clearance + net_width - query_bounds = self._window_query_bounds(start, target, window_path, pad) - x_min = min(0.0, float(local_dx)) - 0.01 - x_max = max(0.0, float(local_dx)) + 0.01 - - for obj_id in collision_engine.static_index.intersection(query_bounds): - bounds = collision_engine.static_geometries[obj_id].bounds - local_corners = ( - self._to_local_xy(start, bounds[0], bounds[1]), - self._to_local_xy(start, bounds[0], bounds[3]), - self._to_local_xy(start, bounds[2], bounds[1]), - self._to_local_xy(start, bounds[2], bounds[3]), - ) - obs_min_x = min(pt[0] for pt in local_corners) - obs_max_x = max(pt[0] for pt in local_corners) - if obs_max_x < x_min or obs_min_x > x_max: - continue - obs_min_y = min(pt[1] for pt in local_corners) - obs_max_y = max(pt[1] for pt in local_corners) - positive_anchors.add(obs_max_y) - negative_anchors.add(obs_min_y) - - for obj_id in collision_engine.dynamic_index.intersection(query_bounds): - _, poly = collision_engine.dynamic_geometries[obj_id] - bounds = poly.bounds - local_corners = ( - self._to_local_xy(start, bounds[0], bounds[1]), - self._to_local_xy(start, bounds[0], bounds[3]), - self._to_local_xy(start, bounds[2], bounds[1]), - self._to_local_xy(start, bounds[2], bounds[3]), - ) - obs_min_x = min(pt[0] for pt in local_corners) - obs_max_x = max(pt[0] for pt in local_corners) - if obs_max_x < x_min or obs_min_x > x_max: - continue - obs_min_y = min(pt[1] for pt in local_corners) - obs_max_y = max(pt[1] for pt in local_corners) - positive_anchors.add(obs_max_y) - negative_anchors.add(obs_min_y) - - for anchor in tuple(positive_anchors): - if anchor > max(0.0, float(local_dy)) - 0.01: - direct_extents.add(anchor + pad) - for anchor in tuple(negative_anchors): - if anchor < min(0.0, float(local_dy)) + 0.01: - direct_extents.add(anchor - pad) - - return sorted(direct_extents, key=lambda value: (abs(value), value)) - - def _build_same_orientation_dogleg( - self, - start: Port, - target: Port, - net_width: float, - radius: float, - side_extent: float, - ) -> list[ComponentResult] | None: - local_dx, local_dy = self._to_local(start, target) - if local_dx < 4.0 * radius - 0.01 or abs(side_extent) < 0.01: - return None - - side_abs = abs(side_extent) - first_straight = side_abs - 2.0 * radius - second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) - if first_straight < -0.01 or second_straight < -0.01: - return None - min_straight = self.context.config.min_straight_length - if 0.01 < first_straight < min_straight - 0.01: - return None - if 0.01 < second_straight < min_straight - 0.01: - return None - - forward_length = local_dx - 4.0 * radius - if forward_length < -0.01: - return None - if 0.01 < forward_length < min_straight - 0.01: - return None - - first_dir = "CCW" if side_extent > 0 else "CW" - second_dir = "CW" if side_extent > 0 else "CCW" - dilation = self.cost_evaluator.collision_engine.clearance / 2.0 - - path: list[ComponentResult] = [] - curr = start - - for direction, straight_len in ( - (first_dir, first_straight), - (second_dir, forward_length), - (second_dir, second_straight), - (first_dir, None), - ): - bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) - path.append(bend) - curr = bend.end_port - if straight_len is None: - continue - if straight_len > 0.01: - straight = Straight.generate(curr, straight_len, net_width, dilation=dilation) - path.append(straight) - curr = straight.end_port - - if curr != target: - return None - return path - - def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]: - ports = self._path_ports(start, path) - windows: list[tuple[int, int]] = [] - min_radius = min(self.context.config.bend_radii, default=0.0) - - for window_size in range(len(path), 0, -1): - for start_idx in range(0, len(path) - window_size + 1): - end_idx = start_idx + window_size - window = path[start_idx:end_idx] - bend_count = sum(1 for comp in window if comp.move_type == "Bend90") - if bend_count < 4: - continue - window_start = ports[start_idx] - window_end = ports[end_idx] - if window_start.r != window_end.r: - continue - local_dx, _ = self._to_local(window_start, window_end) - if local_dx < 4.0 * min_radius - 0.01: - continue - windows.append((start_idx, end_idx)) - return windows - - def _try_refine_window( + def _build_routing_result( self, + *, net_id: str, - start: Port, - net_width: float, path: list[ComponentResult], - start_idx: int, - end_idx: int, - best_cost: float, - ) -> tuple[list[ComponentResult], float] | None: - ports = self._path_ports(start, path) - window_start = ports[start_idx] - window_end = ports[end_idx] - window_path = path[start_idx:end_idx] - collision_engine = self.cost_evaluator.collision_engine - - best_path: list[ComponentResult] | None = None - best_candidate_cost = best_cost - - for radius in self.context.config.bend_radii: - side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius) - for side_extent in side_extents: - replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) - if replacement is None: - continue - candidate_path = path[:start_idx] + replacement + path[end_idx:] - if self._has_self_collision(candidate_path): - continue - is_valid, collisions = collision_engine.verify_path(net_id, candidate_path) - if not is_valid or collisions != 0: - continue - candidate_cost = self._path_cost(candidate_path) - if candidate_cost + 1e-6 < best_candidate_cost: - best_candidate_cost = candidate_cost - best_path = candidate_path - - if best_path is None: - return None - return best_path, best_candidate_cost + reached_target: bool, + collisions: int, + ) -> RoutingResult: + return RoutingResult( + net_id=net_id, + path=path, + is_valid=reached_target and collisions == 0, + collisions=collisions, + reached_target=reached_target, + ) def _refine_path( self, @@ -406,29 +149,78 @@ class PathFinder: net_width: float, path: list[ComponentResult], ) -> list[ComponentResult]: + return self.refiner.refine_path(net_id, start, target, net_width, path) + + def _route_net_once( + self, + net_id: str, + start: Port, + target: Port, + width: float, + iteration: int, + initial_paths: dict[str, list[ComponentResult]] | None, + store_expanded: bool, + needs_self_collision_check: set[str], + ) -> tuple[RoutingResult, bool]: + self.path_state.remove_path(net_id) + path: list[ComponentResult] | None = None + + if iteration == 0 and initial_paths and net_id in initial_paths: + path = initial_paths[net_id] + else: + target_coll_model = self.context.config.bend_collision_type + coll_model = target_coll_model + skip_cong = False + if self.use_tiered_strategy and iteration == 0: + skip_cong = True + if target_coll_model == "arc": + coll_model = "clipped_bbox" + + path = route_astar( + start, + target, + width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + bend_collision_type=coll_model, + return_partial=True, + store_expanded=store_expanded, + skip_congestion=skip_cong, + self_collision_check=(net_id in needs_self_collision_check), + node_limit=self.context.config.node_limit, + ) + + if store_expanded and self.metrics.last_expanded_nodes: + self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) + if not path: - return path + return RoutingResult(net_id, [], False, 0, reached_target=False), True - bend_count = sum(1 for comp in path if comp.move_type == "Bend90") - if bend_count < 4: - return path + last_p = path[-1].end_port + reached = last_p == target + any_congestion = False - best_path = path - best_cost = self._path_cost(path) + if reached and net_id not in needs_self_collision_check and self._has_self_collision(path): + needs_self_collision_check.add(net_id) + any_congestion = True - for _ in range(3): - improved = False - for start_idx, end_idx in self._iter_refinement_windows(start, best_path): - refined = self._try_refine_window(net_id, start, net_width, best_path, start_idx, end_idx, best_cost) - if refined is None: - continue - best_path, best_cost = refined - improved = True - break - if not improved: - break + self._install_path(net_id, path) - return best_path + collision_count = 0 + if reached: + is_valid, collision_count = self.path_state.verify_path(net_id, path) + any_congestion = any_congestion or not is_valid + + return ( + self._build_routing_result( + net_id=net_id, + path=path, + reached_target=reached, + collisions=collision_count, + ), + any_congestion, + ) def route_all( self, @@ -441,136 +233,33 @@ class PathFinder: initial_paths: dict[str, list[ComponentResult]] | None = None, seed: int | None = None, ) -> dict[str, RoutingResult]: - results: dict[str, RoutingResult] = {} self.cost_evaluator.congestion_penalty = self.base_congestion_penalty self.accumulated_expanded_nodes = [] self.metrics.reset_per_route() - start_time = time.monotonic() - num_nets = len(netlist) - session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations) - all_net_ids = list(netlist.keys()) - needs_sc: set[str] = set() - - if initial_paths is None: - ws_order = sort_nets if sort_nets is not None else self.warm_start - if ws_order is not None: - initial_paths = self._perform_greedy_pass(netlist, net_widths, ws_order) - self.context.clear_static_caches() - - if sort_nets and sort_nets != "user": - all_net_ids.sort( - key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y), - reverse=(sort_nets == "longest"), - ) + state = create_routing_session_state( + self, + netlist, + net_widths, + store_expanded=store_expanded, + iteration_callback=iteration_callback, + shuffle_nets=shuffle_nets, + sort_nets=sort_nets, + initial_paths=initial_paths, + seed=seed, + ) + prepare_routing_session_state(self, state) for iteration in range(self.max_iterations): - any_congestion = False - self.accumulated_expanded_nodes = [] - self.metrics.reset_per_route() - - if shuffle_nets and (iteration > 0 or initial_paths is None): - it_seed = (seed + iteration) if seed is not None else None - random.Random(it_seed).shuffle(all_net_ids) - - for net_id in all_net_ids: - start, target = netlist[net_id] - if time.monotonic() - start_time > session_timeout: - self.cost_evaluator.collision_engine.dynamic_tree = None - self.cost_evaluator.collision_engine._ensure_dynamic_tree() - return self.verify_all_nets(results, netlist) - - width = net_widths.get(net_id, 2.0) - self.cost_evaluator.collision_engine.remove_path(net_id) - path: list[ComponentResult] | None = None - - if iteration == 0 and initial_paths and net_id in initial_paths: - path = initial_paths[net_id] - else: - target_coll_model = self.context.config.bend_collision_type - coll_model = target_coll_model - skip_cong = False - if self.use_tiered_strategy and iteration == 0: - skip_cong = True - if target_coll_model == "arc": - coll_model = "clipped_bbox" - - path = route_astar( - start, - target, - width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - bend_collision_type=coll_model, - return_partial=True, - store_expanded=store_expanded, - skip_congestion=skip_cong, - self_collision_check=(net_id in needs_sc), - node_limit=self.context.config.node_limit, - ) - - if store_expanded and self.metrics.last_expanded_nodes: - self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) - - if not path: - results[net_id] = RoutingResult(net_id, [], False, 0, reached_target=False) - any_congestion = True - continue - - last_p = path[-1].end_port - reached = last_p == target - - if reached and net_id not in needs_sc and self._has_self_collision(path): - needs_sc.add(net_id) - any_congestion = True - - all_geoms = [] - all_dilated = [] - for res in path: - all_geoms.extend(res.geometry) - if res.dilated_geometry: - all_dilated.extend(res.dilated_geometry) - else: - dilation = self.cost_evaluator.collision_engine.clearance / 2.0 - all_dilated.extend([p.buffer(dilation) for p in res.geometry]) - self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - collision_count = 0 - if reached: - is_valid, collision_count = self.cost_evaluator.collision_engine.verify_path(net_id, path) - any_congestion = any_congestion or not is_valid - - results[net_id] = RoutingResult(net_id, path, reached and collision_count == 0, collision_count, reached_target=reached) - - if iteration_callback: - iteration_callback(iteration, results) + any_congestion = run_routing_iteration(self, state, iteration) + if any_congestion is None: + return self.verify_all_nets(state.results, state.netlist) if not any_congestion: break self.cost_evaluator.congestion_penalty *= self.congestion_multiplier - if self.refine_paths and results: - for net_id in all_net_ids: - res = results.get(net_id) - if not res or not res.path or not res.reached_target or not res.is_valid: - continue - start, target = netlist[net_id] - width = net_widths.get(net_id, 2.0) - self.cost_evaluator.collision_engine.remove_path(net_id) - refined_path = self._refine_path(net_id, start, target, width, res.path) - all_geoms, all_dilated = self._extract_geometry(refined_path) - self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - results[net_id] = RoutingResult( - net_id=net_id, - path=refined_path, - is_valid=res.is_valid, - collisions=res.collisions, - reached_target=res.reached_target, - ) - - self.cost_evaluator.collision_engine.dynamic_tree = None - self.cost_evaluator.collision_engine._ensure_dynamic_tree() - return self.verify_all_nets(results, netlist) + refine_routing_session_results(self, state) + return finalize_routing_session_results(self, state) def verify_all_nets( self, @@ -585,7 +274,7 @@ class PathFinder: continue last_p = res.path[-1].end_port reached = last_p == target_p - is_valid, collisions = self.cost_evaluator.collision_engine.verify_path(net_id, res.path) + is_valid, collisions = self.path_state.verify_path(net_id, res.path) final_results[net_id] = RoutingResult( net_id=net_id, path=res.path, diff --git a/inire/router/refiner.py b/inire/router/refiner.py new file mode 100644 index 0000000..8436484 --- /dev/null +++ b/inire/router/refiner.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from inire.geometry.components import Bend90, Straight + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + from inire.router.astar import AStarContext + + +def _components_overlap(component_a: ComponentResult, component_b: ComponentResult) -> bool: + bounds_a = component_a.total_bounds + bounds_b = component_b.total_bounds + if not ( + bounds_a[0] < bounds_b[2] + and bounds_a[2] > bounds_b[0] + and bounds_a[1] < bounds_b[3] + and bounds_a[3] > bounds_b[1] + ): + return False + + for polygon_a in component_a.geometry: + for polygon_b in component_b.geometry: + if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): + return True + return False + + +def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: + current = parent_node + while current and current.parent: + ancestor_component = current.component_result + if ancestor_component and _components_overlap(component, ancestor_component): + return True + current = current.parent + return False + + +def has_self_collision(path: list[ComponentResult]) -> bool: + for i, comp_i in enumerate(path): + for j in range(i + 2, len(path)): + if _components_overlap(comp_i, path[j]): + return True + return False + + +class PathRefiner: + __slots__ = ("context",) + + def __init__(self, context: AStarContext) -> None: + self.context = context + + @property + def collision_engine(self): + return self.context.cost_evaluator.collision_engine + + def path_cost(self, path: list[ComponentResult]) -> float: + total = 0.0 + bend_penalty = self.context.config.bend_penalty + sbend_penalty = self.context.config.sbend_penalty + for comp in path: + total += comp.length + if comp.move_type == "Bend90": + radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0 + if radius > 0: + total += bend_penalty * (10.0 / radius) ** 0.5 + else: + total += bend_penalty + elif comp.move_type == "SBend": + total += sbend_penalty + return total + + def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]: + ports = [start] + ports.extend(comp.end_port for comp in path) + return ports + + def _to_local(self, start: Port, point: Port) -> tuple[int, int]: + dx = point.x - start.x + dy = point.y - start.y + if start.r == 0: + return dx, dy + if start.r == 90: + return dy, -dx + if start.r == 180: + return -dx, -dy + return -dy, dx + + def _to_local_xy(self, start: Port, x: float, y: float) -> tuple[float, float]: + dx = float(x) - start.x + dy = float(y) - start.y + if start.r == 0: + return dx, dy + if start.r == 90: + return dy, -dx + if start.r == 180: + return -dx, -dy + return -dy, dx + + def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]: + min_x = float(min(start.x, target.x)) + min_y = float(min(start.y, target.y)) + max_x = float(max(start.x, target.x)) + max_y = float(max(start.y, target.y)) + for comp in path: + bounds = comp.total_bounds + min_x = min(min_x, bounds[0]) + min_y = min(min_y, bounds[1]) + max_x = max(max_x, bounds[2]) + max_y = max(max_y, bounds[3]) + return (min_x - pad, min_y - pad, max_x + pad, max_y + pad) + + def _candidate_side_extents( + self, + start: Port, + target: Port, + window_path: list[ComponentResult], + net_width: float, + radius: float, + ) -> list[float]: + local_dx, local_dy = self._to_local(start, target) + if local_dx < 4.0 * radius - 0.01: + return [] + + local_points = [self._to_local(start, start)] + local_points.extend(self._to_local(start, comp.end_port) for comp in window_path) + min_side = float(min(point[1] for point in local_points)) + max_side = float(max(point[1] for point in local_points)) + + positive_anchors: set[float] = set() + negative_anchors: set[float] = set() + direct_extents: set[float] = set() + + if max_side > 0.01: + positive_anchors.add(max_side) + direct_extents.add(max_side) + if min_side < -0.01: + negative_anchors.add(min_side) + direct_extents.add(min_side) + if local_dy > 0: + positive_anchors.add(float(local_dy)) + elif local_dy < 0: + negative_anchors.add(float(local_dy)) + + pad = 2.0 * radius + self.collision_engine.clearance + net_width + query_bounds = self._window_query_bounds(start, target, window_path, pad) + x_min = min(0.0, float(local_dx)) - 0.01 + x_max = max(0.0, float(local_dx)) + 0.01 + + for obj_id in self.collision_engine.static_index.intersection(query_bounds): + bounds = self.collision_engine.static_geometries[obj_id].bounds + local_corners = ( + self._to_local_xy(start, bounds[0], bounds[1]), + self._to_local_xy(start, bounds[0], bounds[3]), + self._to_local_xy(start, bounds[2], bounds[1]), + self._to_local_xy(start, bounds[2], bounds[3]), + ) + obs_min_x = min(pt[0] for pt in local_corners) + obs_max_x = max(pt[0] for pt in local_corners) + if obs_max_x < x_min or obs_min_x > x_max: + continue + obs_min_y = min(pt[1] for pt in local_corners) + obs_max_y = max(pt[1] for pt in local_corners) + positive_anchors.add(obs_max_y) + negative_anchors.add(obs_min_y) + + for obj_id in self.collision_engine.dynamic_index.intersection(query_bounds): + _, poly = self.collision_engine.dynamic_geometries[obj_id] + bounds = poly.bounds + local_corners = ( + self._to_local_xy(start, bounds[0], bounds[1]), + self._to_local_xy(start, bounds[0], bounds[3]), + self._to_local_xy(start, bounds[2], bounds[1]), + self._to_local_xy(start, bounds[2], bounds[3]), + ) + obs_min_x = min(pt[0] for pt in local_corners) + obs_max_x = max(pt[0] for pt in local_corners) + if obs_max_x < x_min or obs_min_x > x_max: + continue + obs_min_y = min(pt[1] for pt in local_corners) + obs_max_y = max(pt[1] for pt in local_corners) + positive_anchors.add(obs_max_y) + negative_anchors.add(obs_min_y) + + for anchor in tuple(positive_anchors): + if anchor > max(0.0, float(local_dy)) - 0.01: + direct_extents.add(anchor + pad) + for anchor in tuple(negative_anchors): + if anchor < min(0.0, float(local_dy)) + 0.01: + direct_extents.add(anchor - pad) + + return sorted(direct_extents, key=lambda value: (abs(value), value)) + + def _build_same_orientation_dogleg( + self, + start: Port, + target: Port, + net_width: float, + radius: float, + side_extent: float, + ) -> list[ComponentResult] | None: + local_dx, local_dy = self._to_local(start, target) + if local_dx < 4.0 * radius - 0.01 or abs(side_extent) < 0.01: + return None + + side_abs = abs(side_extent) + first_straight = side_abs - 2.0 * radius + second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) + if first_straight < -0.01 or second_straight < -0.01: + return None + min_straight = self.context.config.min_straight_length + if 0.01 < first_straight < min_straight - 0.01: + return None + if 0.01 < second_straight < min_straight - 0.01: + return None + + forward_length = local_dx - 4.0 * radius + if forward_length < -0.01: + return None + if 0.01 < forward_length < min_straight - 0.01: + return None + + first_dir = "CCW" if side_extent > 0 else "CW" + second_dir = "CW" if side_extent > 0 else "CCW" + dilation = self.collision_engine.clearance / 2.0 + + path: list[ComponentResult] = [] + curr = start + + for direction, straight_len in ( + (first_dir, first_straight), + (second_dir, forward_length), + (second_dir, second_straight), + (first_dir, None), + ): + bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) + path.append(bend) + curr = bend.end_port + if straight_len is None: + continue + if straight_len > 0.01: + straight = Straight.generate(curr, straight_len, net_width, dilation=dilation) + path.append(straight) + curr = straight.end_port + + if curr != target: + return None + return path + + def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]: + ports = self._path_ports(start, path) + windows: list[tuple[int, int]] = [] + min_radius = min(self.context.config.bend_radii, default=0.0) + + for window_size in range(len(path), 0, -1): + for start_idx in range(0, len(path) - window_size + 1): + end_idx = start_idx + window_size + window = path[start_idx:end_idx] + bend_count = sum(1 for comp in window if comp.move_type == "Bend90") + if bend_count < 4: + continue + window_start = ports[start_idx] + window_end = ports[end_idx] + if window_start.r != window_end.r: + continue + local_dx, _ = self._to_local(window_start, window_end) + if local_dx < 4.0 * min_radius - 0.01: + continue + windows.append((start_idx, end_idx)) + return windows + + def _try_refine_window( + self, + net_id: str, + start: Port, + net_width: float, + path: list[ComponentResult], + start_idx: int, + end_idx: int, + best_cost: float, + ) -> tuple[list[ComponentResult], float] | None: + ports = self._path_ports(start, path) + window_start = ports[start_idx] + window_end = ports[end_idx] + window_path = path[start_idx:end_idx] + + best_path: list[ComponentResult] | None = None + best_candidate_cost = best_cost + + for radius in self.context.config.bend_radii: + side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius) + for side_extent in side_extents: + replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) + if replacement is None: + continue + candidate_path = path[:start_idx] + replacement + path[end_idx:] + if has_self_collision(candidate_path): + continue + is_valid, collisions = self.collision_engine.verify_path(net_id, candidate_path) + if not is_valid or collisions != 0: + continue + candidate_cost = self.path_cost(candidate_path) + if candidate_cost + 1e-6 < best_candidate_cost: + best_candidate_cost = candidate_cost + best_path = candidate_path + + if best_path is None: + return None + return best_path, best_candidate_cost + + def refine_path( + self, + net_id: str, + start: Port, + target: Port, + net_width: float, + path: list[ComponentResult], + ) -> list[ComponentResult]: + _ = target + if not path: + return path + + bend_count = sum(1 for comp in path if comp.move_type == "Bend90") + if bend_count < 4: + return path + + best_path = path + best_cost = self.path_cost(path) + + for _ in range(3): + improved = False + for start_idx, end_idx in self._iter_refinement_windows(start, best_path): + refined = self._try_refine_window(net_id, start, net_width, best_path, start_idx, end_idx, best_cost) + if refined is None: + continue + best_path, best_cost = refined + improved = True + break + if not improved: + break + + return best_path diff --git a/inire/router/session.py b/inire/router/session.py new file mode 100644 index 0000000..dd1e8db --- /dev/null +++ b/inire/router/session.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Literal + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + from inire.router.pathfinder import PathFinder, RoutingResult + + +@dataclass +class RoutingSessionState: + netlist: dict[str, tuple[Port, Port]] + net_widths: dict[str, float] + results: dict[str, RoutingResult] + all_net_ids: list[str] + needs_self_collision_check: set[str] + start_time: float + session_timeout: float + initial_paths: dict[str, list[ComponentResult]] | None + store_expanded: bool + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None + shuffle_nets: bool + sort_nets: Literal["shortest", "longest", "user", None] + seed: int | None + + +def create_routing_session_state( + finder: PathFinder, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + *, + store_expanded: bool, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, + shuffle_nets: bool, + sort_nets: Literal["shortest", "longest", "user", None], + initial_paths: dict[str, list[ComponentResult]] | None, + seed: int | None, +) -> RoutingSessionState: + num_nets = len(netlist) + return RoutingSessionState( + netlist=netlist, + net_widths=net_widths, + results={}, + all_net_ids=list(netlist.keys()), + needs_self_collision_check=set(), + start_time=time.monotonic(), + session_timeout=max(60.0, 10.0 * num_nets * finder.max_iterations), + initial_paths=initial_paths, + store_expanded=store_expanded, + iteration_callback=iteration_callback, + shuffle_nets=shuffle_nets, + sort_nets=sort_nets, + seed=seed, + ) + + +def prepare_routing_session_state( + finder: PathFinder, + state: RoutingSessionState, +) -> None: + if state.initial_paths is None: + warm_start_order = state.sort_nets if state.sort_nets is not None else finder.warm_start + if warm_start_order is not None: + state.initial_paths = finder._build_greedy_warm_start_paths(state.netlist, state.net_widths, warm_start_order) + finder.context.clear_static_caches() + + if state.sort_nets and state.sort_nets != "user": + state.all_net_ids.sort( + key=lambda net_id: abs(state.netlist[net_id][1].x - state.netlist[net_id][0].x) + + abs(state.netlist[net_id][1].y - state.netlist[net_id][0].y), + reverse=(state.sort_nets == "longest"), + ) + + +def run_routing_iteration( + finder: PathFinder, + state: RoutingSessionState, + iteration: int, +) -> bool | None: + any_congestion = False + finder.accumulated_expanded_nodes = [] + finder.metrics.reset_per_route() + + if state.shuffle_nets and (iteration > 0 or state.initial_paths is None): + iteration_seed = (state.seed + iteration) if state.seed is not None else None + random.Random(iteration_seed).shuffle(state.all_net_ids) + + for net_id in state.all_net_ids: + start, target = state.netlist[net_id] + if time.monotonic() - state.start_time > state.session_timeout: + finder.path_state.finalize_dynamic_tree() + return None + + width = state.net_widths.get(net_id, 2.0) + result, net_congestion = finder._route_net_once( + net_id, + start, + target, + width, + iteration, + state.initial_paths, + state.store_expanded, + state.needs_self_collision_check, + ) + state.results[net_id] = result + any_congestion = any_congestion or net_congestion + + if state.iteration_callback: + state.iteration_callback(iteration, state.results) + return any_congestion + + +def refine_routing_session_results( + finder: PathFinder, + state: RoutingSessionState, +) -> None: + if not finder.refine_paths or not state.results: + return + + for net_id in state.all_net_ids: + res = state.results.get(net_id) + if not res or not res.path or not res.reached_target or not res.is_valid: + continue + start, target = state.netlist[net_id] + width = state.net_widths.get(net_id, 2.0) + finder.path_state.remove_path(net_id) + refined_path = finder._refine_path(net_id, start, target, width, res.path) + finder._install_path(net_id, refined_path) + state.results[net_id] = finder._build_routing_result( + net_id=net_id, + path=refined_path, + reached_target=res.reached_target, + collisions=res.collisions, + ) + + +def finalize_routing_session_results( + finder: PathFinder, + state: RoutingSessionState, +) -> dict[str, RoutingResult]: + finder.path_state.finalize_dynamic_tree() + return finder.verify_all_nets(state.results, state.netlist) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index d5fa61d..fc2a753 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -2,28 +2,28 @@ from __future__ import annotations import numpy from typing import TYPE_CHECKING + import rtree -from shapely.geometry import Point, LineString if TYPE_CHECKING: from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port - from inire.geometry.primitives import Port + class VisibilityManager: """ Manages corners of static obstacles for sparse A* / Visibility Graph jumps. """ - __slots__ = ('collision_engine', 'corners', 'corner_index', '_corner_graph', '_static_visibility_cache', '_built_static_version') + __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version") def __init__(self, collision_engine: CollisionEngine) -> None: self.collision_engine = collision_engine self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() self._corner_graph: dict[int, list[tuple[float, float, float]]] = {} - self._static_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} + self._point_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} self._built_static_version = -1 self._build() @@ -34,7 +34,7 @@ class VisibilityManager: self.corners = [] self.corner_index = rtree.index.Index() self._corner_graph = {} - self._static_visibility_cache = {} + self._point_visibility_cache = {} self._build() def _ensure_current(self) -> None: @@ -92,53 +92,51 @@ class VisibilityManager: if reach >= dist - 0.01: self._corner_graph[i].append((cx, cy, dist)) - def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: - """ - Find all corners visible from the origin. - Returns list of (x, y, distance). - """ - self._ensure_current() - if max_dist < 0: - return [] - + def _corner_idx_at(self, origin: Port) -> int | None: ox, oy = round(origin.x, 3), round(origin.y, 3) - - # 1. Exact corner check - # Use spatial index to find if origin is AT a corner nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) for idx in nearby: cx, cy = self.corners[idx] if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4: - # We are at a corner! Return pre-computed graph (filtered by max_dist) - if idx in self._corner_graph: - return [c for c in self._corner_graph[idx] if c[2] <= max_dist] + return idx + return None - # 2. Cache check for arbitrary points - # Grid-based caching for arbitrary points is tricky, - # but since static obstacles don't change, we can cache exact coordinates. + def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: + """ + Find visible corners from an arbitrary point. + This may perform direct ray-cast scans and is not intended for hot search paths. + """ + self._ensure_current() + if max_dist < 0: + return [] + + corner_idx = self._corner_idx_at(origin) + if corner_idx is not None and corner_idx in self._corner_graph: + return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] + + ox, oy = round(origin.x, 3), round(origin.y, 3) cache_key = (int(ox * 1000), int(oy * 1000)) - if cache_key in self._static_visibility_cache: - return self._static_visibility_cache[cache_key] + if cache_key in self._point_visibility_cache: + return self._point_visibility_cache[cache_key] - # 3. Full visibility check bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist) candidates = list(self.corner_index.intersection(bounds)) - + visible = [] for i in candidates: cx, cy = self.corners[i] dx, dy = cx - origin.x, cy - origin.y dist = numpy.sqrt(dx**2 + dy**2) - + if dist > max_dist or dist < 1e-3: continue - + angle = numpy.degrees(numpy.arctan2(dy, dx)) reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05) if reach >= dist - 0.01: visible.append((cx, cy, dist)) - - self._static_visibility_cache[cache_key] = visible + + self._point_visibility_cache[cache_key] = visible return visible def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: @@ -150,10 +148,14 @@ class VisibilityManager: if max_dist < 0: return [] - ox, oy = round(origin.x, 3), round(origin.y, 3) - nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) - for idx in nearby: - cx, cy = self.corners[idx] - if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4 and idx in self._corner_graph: - return [corner for corner in self._corner_graph[idx] if corner[2] <= max_dist] + corner_idx = self._corner_idx_at(origin) + if corner_idx is not None and corner_idx in self._corner_graph: + return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [] + + def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: + """ + Backward-compatible alias for arbitrary-point visibility queries. + Prefer `get_corner_visibility()` in routing code and `get_point_visibility()` elsewhere. + """ + return self.get_point_visibility(origin, max_dist=max_dist) diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index 40ccdc0..aeffc1c 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -61,6 +61,24 @@ def _summarize(results: dict[str, RoutingResult], duration_s: float) -> Scenario ) +def _build_evaluator( + bounds: tuple[float, float, float, float], + *, + clearance: float = 2.0, + obstacles: list[Polygon] | None = None, + bend_penalty: float = 50.0, + sbend_penalty: float = 150.0, +) -> CostEvaluator: + static_obstacles = obstacles or [] + engine = CollisionEngine(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=bounds) + danger_map.precompute(static_obstacles) + return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty) + + def run_example_01() -> ScenarioOutcome: _, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]}) netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))} @@ -158,33 +176,32 @@ def run_example_06() -> ScenarioOutcome: box(40, 60, 60, 80), box(40, 10, 60, 30), ] - engine = CollisionEngine(clearance=2.0) - for obstacle in obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - - contexts = [ - AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc"), - AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox"), - AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0), - ] - netlists = [ - {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}, - {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}, - {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, - ] - widths = [ - {"arc_model": 2.0}, - {"bbox_model": 2.0}, - {"clipped_model": 2.0}, + scenarios = [ + ( + AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="arc"), + {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}, + {"arc_model": 2.0}, + ), + ( + AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="bbox"), + {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}, + {"bbox_model": 2.0}, + ), + ( + AStarContext( + _build_evaluator(bounds, obstacles=obstacles), + bend_radii=[10.0], + bend_collision_type="clipped_bbox", + bend_clip_margin=1.0, + ), + {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, + {"clipped_model": 2.0}, + ), ] t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} - for context, netlist, net_widths in zip(contexts, netlists, widths, strict=True): + for context, netlist, net_widths in scenarios: pathfinder = PathFinder(context, use_tiered_strategy=False) combined_results.update(pathfinder.route_all(netlist, net_widths)) t1 = perf_counter() @@ -253,24 +270,19 @@ def run_example_07() -> ScenarioOutcome: def run_example_08() -> ScenarioOutcome: bounds = (0, 0, 150, 150) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - metrics = AStarMetrics() netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"custom_bend": 2.0} - context_std = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[]) + context_std = AStarContext(_build_evaluator(bounds), bend_radii=[10.0], sbend_radii=[]) context_custom = AStarContext( - evaluator, + _build_evaluator(bounds), bend_radii=[10.0], - bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), + bend_collision_type=Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]), sbend_radii=[], ) t0 = perf_counter() - results_std = PathFinder(context_std, metrics).route_all(netlist, widths) + results_std = PathFinder(context_std, AStarMetrics(), use_tiered_strategy=False).route_all(netlist, widths) results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all( {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}, diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index 85d9021..72467b4 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -6,6 +6,7 @@ from inire.geometry.components import SBend, Straight from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port from inire.router.astar import AStarContext, route_astar +from inire.router.config import CostConfig from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap from inire.router.pathfinder import RoutingResult @@ -86,6 +87,31 @@ def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" +def test_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None: + context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0) + + assert isinstance(basic_evaluator.config, CostConfig) + assert basic_evaluator.config is not context.config + assert basic_evaluator.config.bend_penalty == 120.0 + assert basic_evaluator.config.sbend_penalty == 240.0 + assert basic_evaluator.config.min_bend_radius == 5.0 + + +def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: + context = AStarContext(basic_evaluator, bend_radii=[10.0], bend_collision_type="arc") + + route_astar( + Port(0, 0, 0), + Port(30, 10, 0), + net_width=2.0, + context=context, + bend_collision_type="clipped_bbox", + return_partial=True, + ) + + assert context.config.bend_collision_type == "arc" + + def test_expand_moves_only_shortens_consecutive_straights( basic_evaluator: CostEvaluator, monkeypatch: pytest.MonkeyPatch, @@ -159,6 +185,53 @@ def test_expand_moves_does_not_chain_sbends( assert emitted +def test_add_node_rejects_self_collision_against_ancestor( + basic_evaluator: CostEvaluator, +) -> None: + context = AStarContext(basic_evaluator) + metrics = astar_module.AStarMetrics() + target = Port(100, 0, 0) + + root = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + ancestor = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) + ancestor_node = astar_module.AStarNode( + ancestor.end_port, + g_cost=ancestor.length, + h_cost=0.0, + parent=root, + component_result=ancestor, + ) + parent_result = Straight.generate(Port(30, 0, 0), 10.0, width=2.0, dilation=1.0) + parent_node = astar_module.AStarNode( + parent_result.end_port, + g_cost=ancestor.length + parent_result.length, + h_cost=0.0, + parent=ancestor_node, + component_result=parent_result, + ) + overlapping_move = Straight.generate(Port(5, 0, 0), 10.0, width=2.0, dilation=1.0) + + open_set: list[astar_module.AStarNode] = [] + astar_module.add_node( + parent_node, + overlapping_move, + target, + net_width=2.0, + net_id="test", + open_set=open_set, + closed_set={}, + context=context, + metrics=metrics, + congestion_cache={}, + move_type="S", + cache_key=("self_collision",), + self_collision_check=True, + ) + + assert not open_set + assert metrics.moves_added == 0 + + def test_expand_moves_adds_sbend_aligned_straight_stop_points( basic_evaluator: CostEvaluator, monkeypatch: pytest.MonkeyPatch, diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index dad6fbf..0c64751 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,4 +1,8 @@ import pytest +from shapely.affinity import rotate as shapely_rotate +from shapely.affinity import scale as shapely_scale +from shapely.affinity import translate as shapely_translate +from shapely.geometry import Polygon from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port, rotate_port, translate_port @@ -88,9 +92,48 @@ def test_bend_collision_models() -> None: # 2. Clipped BBox model res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) - # Area should be less than full bbox + # Conservative 8-point approximation should still be tighter than the full bbox. + assert len(res_clipped.geometry[0].exterior.coords) - 1 == 8 assert res_clipped.geometry[0].area < res_bbox.geometry[0].area + # It should also conservatively contain the true arc. + res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc") + assert res_clipped.geometry[0].covers(res_arc.geometry[0]) + + +def test_custom_bend_collision_polygon_uses_local_transform() -> None: + custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + + cases = [ + (Port(0, 0, 0), "CCW", (0.0, 10.0), 0.0, False), + (Port(0, 0, 0), "CW", (0.0, -10.0), 0.0, True), + (Port(0, 0, 90), "CCW", (-10.0, 0.0), 90.0, False), + ] + + for start, direction, center_xy, rotation_deg, mirror_y in cases: + result = Bend90.generate(start, 10.0, 2.0, direction=direction, collision_type=custom_poly) + expected = custom_poly + if mirror_y: + expected = shapely_scale(expected, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0)) + if rotation_deg: + expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False) + expected = shapely_translate(expected, center_xy[0], center_xy[1]) + + assert result.geometry[0].symmetric_difference(expected).area < 1e-6 + assert result.actual_geometry is not None + assert result.actual_geometry[0].symmetric_difference(expected).area < 1e-6 + + +def test_custom_bend_collision_polygon_becomes_actual_geometry() -> None: + custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0) + + assert result.actual_geometry is not None + assert result.dilated_actual_geometry is not None + assert result.geometry[0].symmetric_difference(result.actual_geometry[0]).area < 1e-6 + assert result.dilated_geometry is not None + assert result.dilated_geometry[0].symmetric_difference(result.dilated_actual_geometry[0]).area < 1e-6 + def test_sbend_collision_models() -> None: start = Port(0, 0, 0) diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 4372749..ea547fd 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -21,7 +21,7 @@ BASELINE_SECONDS = { "example_05_orientation_stress": 0.5630, "example_06_bend_collision_models": 5.2382, "example_07_large_scale_routing": 1.2081, - "example_08_custom_bend_geometry": 4.2111, + "example_08_custom_bend_geometry": 0.9848, "example_09_unroutable_best_effort": 0.0056, } @@ -33,7 +33,7 @@ EXPECTED_OUTCOMES = { "example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_07_large_scale_routing": {"total_results": 10, "valid_results": 10, "reached_targets": 10}, - "example_08_custom_bend_geometry": {"total_results": 2, "valid_results": 1, "reached_targets": 2}, + "example_08_custom_bend_geometry": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_09_unroutable_best_effort": {"total_results": 1, "valid_results": 0, "reached_targets": 0}, } diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index f6923c4..d6c33f8 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -6,7 +6,12 @@ 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.pathfinder import PathFinder +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 @@ -72,6 +77,136 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: 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, bool]: + _ = (start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check) + return RoutingResult(net_id, [], net_id == "net1", int(net_id == "net2"), reached_target=True), net_id == "net2" + + 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, + ) + + any_congestion = run_routing_iteration(pf, state, iteration=0) + + assert any_congestion is True + 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 + + +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_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: bounds = (0, -50, 100, 50) From dcc4d6436ce48930a1521da107ad2434c1d0212d Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 29 Mar 2026 20:35:58 -0700 Subject: [PATCH 3/6] refactor --- inire/geometry/collision.py | 686 ++++--------------- inire/geometry/collision_query_checker.py | 97 +++ inire/geometry/component_overlap.py | 58 ++ inire/geometry/components.py | 33 +- inire/geometry/dynamic_congestion_checker.py | 117 ++++ inire/geometry/dynamic_path_index.py | 114 +++ inire/geometry/path_verifier.py | 112 +++ inire/geometry/ray_caster.py | 112 +++ inire/geometry/static_move_checker.py | 146 ++++ inire/geometry/static_obstacle_index.py | 143 ++++ inire/router/outcomes.py | 27 + inire/router/path_state.py | 8 +- inire/router/pathfinder.py | 75 +- inire/router/refiner.py | 46 +- inire/router/session.py | 19 +- inire/router/visibility.py | 11 +- inire/tests/test_astar.py | 34 +- inire/tests/test_failed_net_congestion.py | 35 +- inire/tests/test_pathfinder.py | 81 ++- inire/tests/test_visibility.py | 20 + inire/tests/test_visualization.py | 26 + inire/utils/validation.py | 70 +- inire/utils/visualization.py | 18 +- 23 files changed, 1379 insertions(+), 709 deletions(-) create mode 100644 inire/geometry/collision_query_checker.py create mode 100644 inire/geometry/component_overlap.py create mode 100644 inire/geometry/dynamic_congestion_checker.py create mode 100644 inire/geometry/dynamic_path_index.py create mode 100644 inire/geometry/path_verifier.py create mode 100644 inire/geometry/ray_caster.py create mode 100644 inire/geometry/static_move_checker.py create mode 100644 inire/geometry/static_obstacle_index.py create mode 100644 inire/router/outcomes.py create mode 100644 inire/tests/test_visibility.py create mode 100644 inire/tests/test_visualization.py diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 5f9fbc6..663d919 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,18 +1,25 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal -import rtree + import numpy -import shapely -from shapely.prepared import prep -from shapely.strtree import STRtree -from shapely.geometry import box, LineString + +from inire.geometry.collision_query_checker import CollisionQueryChecker +from inire.geometry.dynamic_congestion_checker import DynamicCongestionChecker +from inire.geometry.dynamic_path_index import DynamicPathIndex +from inire.geometry.path_verifier import PathVerificationReport, PathVerifier +from inire.geometry.ray_caster import RayCaster +from inire.geometry.static_obstacle_index import StaticObstacleIndex +from inire.geometry.static_move_checker import StaticMoveChecker if TYPE_CHECKING: + from collections.abc import Iterable + from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry - from inire.geometry.primitives import Port + from shapely.strtree import STRtree + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port class CollisionEngine: @@ -21,15 +28,9 @@ class CollisionEngine: """ __slots__ = ( 'clearance', 'max_net_width', 'safety_zone_radius', - 'static_index', 'static_geometries', 'static_dilated', 'static_prepared', - 'static_is_rect', 'static_tree', 'static_obj_ids', 'static_safe_cache', - 'static_grid', 'grid_cell_size', '_static_id_counter', '_net_specific_trees', - '_net_specific_is_rect', '_net_specific_bounds', - 'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', 'dynamic_prepared', - 'dynamic_tree', 'dynamic_obj_ids', 'dynamic_grid', '_dynamic_id_counter', - 'metrics', '_dynamic_tree_dirty', '_dynamic_net_ids_array', '_inv_grid_cell_size', - '_static_bounds_array', '_static_is_rect_array', '_locked_nets', - '_static_raw_tree', '_static_raw_obj_ids', '_dynamic_bounds_array', '_static_version' + 'metrics', 'grid_cell_size', '_inv_grid_cell_size', '_dynamic_bounds_array', + '_path_verifier', '_dynamic_paths', '_static_obstacles', '_ray_caster', '_static_move_checker', + '_dynamic_congestion_checker', '_collision_query_checker', ) def __init__( @@ -42,44 +43,13 @@ class CollisionEngine: self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius - # Static obstacles - self.static_index = rtree.index.Index() - self.static_geometries: dict[int, Polygon] = {} - self.static_dilated: dict[int, Polygon] = {} - self.static_prepared: dict[int, PreparedGeometry] = {} - self.static_is_rect: dict[int, bool] = {} - self.static_tree: STRtree | None = None - self.static_obj_ids: list[int] = [] - self._static_bounds_array: numpy.ndarray | None = None - self._static_is_rect_array: numpy.ndarray | None = None - self._static_raw_tree: STRtree | None = None - self._static_raw_obj_ids: list[int] = [] - self._net_specific_trees: dict[tuple[float, float], STRtree] = {} - self._net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} - self._net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} - self._static_version = 0 - - self.static_safe_cache: set[tuple] = set() - self.static_grid: dict[tuple[int, int], list[int]] = {} self.grid_cell_size = 50.0 self._inv_grid_cell_size = 1.0 / self.grid_cell_size - self._static_id_counter = 0 + self._static_obstacles = StaticObstacleIndex(self) - # Dynamic paths - self.dynamic_index = rtree.index.Index() - self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {} - self.dynamic_dilated: dict[int, Polygon] = {} - self.dynamic_prepared: dict[int, PreparedGeometry] = {} - self.dynamic_tree: STRtree | None = None - self.dynamic_obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) - self.dynamic_grid: dict[tuple[int, int], list[int]] = {} - - self._dynamic_id_counter = 0 - self._dynamic_tree_dirty = True - self._dynamic_net_ids_array = numpy.array([], dtype=' int: + return self._static_obstacles.version + + def iter_static_dilated_geometries(self) -> Iterable[Polygon]: + return self._static_obstacles.dilated.values() + + def iter_static_obstacle_bounds( + self, + query_bounds: tuple[float, float, float, float], + ) -> Iterable[tuple[float, float, float, float]]: + for obj_id in self._static_obstacles.index.intersection(query_bounds): + yield self._static_obstacles.geometries[obj_id].bounds + + def iter_dynamic_path_bounds( + self, + query_bounds: tuple[float, float, float, float], + ) -> Iterable[tuple[float, float, float, float]]: + for obj_id in self._dynamic_paths.index.intersection(query_bounds): + yield self._dynamic_paths.geometries[obj_id][1].bounds + + def iter_dynamic_paths(self) -> Iterable[tuple[str, Polygon]]: + return self._dynamic_paths.geometries.values() def reset_metrics(self) -> None: for k in self.metrics: @@ -102,553 +100,135 @@ class CollisionEngine: f" Safety Zone: {m['safety_zone_checks']} full intersections performed") def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: - obj_id = self._static_id_counter - self._static_id_counter += 1 - - # Preserve existing dilation if provided, else use default C/2 - if dilated_geometry is not None: - dilated = dilated_geometry - else: - dilated = polygon.buffer(self.clearance / 2.0, join_style=2) - - self.static_geometries[obj_id] = polygon - self.static_dilated[obj_id] = dilated - self.static_prepared[obj_id] = prep(dilated) - self.static_index.insert(obj_id, dilated.bounds) - self._invalidate_static_caches() - b = dilated.bounds - area = (b[2] - b[0]) * (b[3] - b[1]) - self.static_is_rect[obj_id] = (abs(dilated.area - area) < 1e-4) - return obj_id + return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: """ Remove a static obstacle by ID. """ - if obj_id not in self.static_geometries: - return - - bounds = self.static_dilated[obj_id].bounds - self.static_index.delete(obj_id, bounds) - - del self.static_geometries[obj_id] - del self.static_dilated[obj_id] - del self.static_prepared[obj_id] - del self.static_is_rect[obj_id] - self._invalidate_static_caches() + self._static_obstacles.remove_obstacle(obj_id) def _invalidate_static_caches(self) -> None: - self.static_tree = None - self._static_bounds_array = None - self._static_is_rect_array = None - self.static_obj_ids = [] - self._static_raw_tree = None - self._static_raw_obj_ids = [] - self.static_grid = {} - self._net_specific_trees.clear() - self._net_specific_is_rect.clear() - self._net_specific_bounds.clear() - self.static_safe_cache.clear() - self._static_version += 1 + self._static_obstacles.invalidate_caches() def _ensure_static_tree(self) -> None: - if self.static_tree is None and self.static_dilated: - self.static_obj_ids = sorted(self.static_dilated.keys()) - geoms = [self.static_dilated[i] for i in self.static_obj_ids] - self.static_tree = STRtree(geoms) - self._static_bounds_array = numpy.array([g.bounds for g in geoms]) - self._static_is_rect_array = numpy.array([self.static_is_rect[i] for i in self.static_obj_ids]) + self._static_obstacles.ensure_tree() def _ensure_net_static_tree(self, net_width: float) -> STRtree: """ Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance). """ - key = (round(net_width, 4), round(self.clearance, 4)) - if key in self._net_specific_trees: - return self._net_specific_trees[key] - - # Physical separation must be >= clearance. - # Centerline to raw obstacle edge must be >= net_width/2 + clearance. - total_dilation = net_width / 2.0 + self.clearance - geoms = [] - is_rect_list = [] - bounds_list = [] - - for obj_id in sorted(self.static_geometries.keys()): - poly = self.static_geometries[obj_id] - dilated = poly.buffer(total_dilation, join_style=2) - geoms.append(dilated) - - b = dilated.bounds - bounds_list.append(b) - area = (b[2] - b[0]) * (b[3] - b[1]) - is_rect_list.append(abs(dilated.area - area) < 1e-4) - - tree = STRtree(geoms) - self._net_specific_trees[key] = tree - self._net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool) - self._net_specific_bounds[key] = numpy.array(bounds_list) - return tree + return self._static_obstacles.ensure_net_tree(net_width) def _ensure_static_raw_tree(self) -> None: - if self._static_raw_tree is None and self.static_geometries: - self._static_raw_obj_ids = sorted(self.static_geometries.keys()) - geoms = [self.static_geometries[i] for i in self._static_raw_obj_ids] - self._static_raw_tree = STRtree(geoms) + self._static_obstacles.ensure_raw_tree() def _ensure_dynamic_tree(self) -> None: - if self.dynamic_tree is None and self.dynamic_dilated: - ids = sorted(self.dynamic_dilated.keys()) - geoms = [self.dynamic_dilated[i] for i in ids] - self.dynamic_tree = STRtree(geoms) - self.dynamic_obj_ids = numpy.array(ids, dtype=numpy.int32) - self._dynamic_bounds_array = numpy.array([g.bounds for g in geoms]) - nids = [self.dynamic_geometries[obj_id][0] for obj_id in self.dynamic_obj_ids] - self._dynamic_net_ids_array = numpy.array(nids, dtype=' None: - if not self.dynamic_grid and self.dynamic_dilated: - cs = self.grid_cell_size - for obj_id, poly in self.dynamic_dilated.items(): - b = poly.bounds - for gx in range(int(b[0] / cs), int(b[2] / cs) + 1): - for gy in range(int(b[1] / cs), int(b[3] / cs) + 1): - cell = (gx, gy) - if cell not in self.dynamic_grid: self.dynamic_grid[cell] = [] - self.dynamic_grid[cell].append(obj_id) + self._dynamic_paths.ensure_grid() + + def rebuild_dynamic_tree(self) -> None: + self._dynamic_paths.tree = None + self._ensure_dynamic_tree() def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: - self.dynamic_tree = None - self.dynamic_grid = {} - self._dynamic_tree_dirty = True - dilation = self.clearance / 2.0 - for i, poly in enumerate(geometry): - obj_id = self._dynamic_id_counter - self._dynamic_id_counter += 1 - dilated = dilated_geometry[i] if dilated_geometry else poly.buffer(dilation) - self.dynamic_geometries[obj_id] = (net_id, poly) - self.dynamic_dilated[obj_id] = dilated - self.dynamic_index.insert(obj_id, dilated.bounds) + self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) def remove_path(self, net_id: str) -> None: - if net_id in self._locked_nets: return - to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] - if not to_remove: return - self.dynamic_tree = None - self.dynamic_grid = {} - self._dynamic_tree_dirty = True - for obj_id in to_remove: - self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds) - del self.dynamic_geometries[obj_id] - del self.dynamic_dilated[obj_id] + self._dynamic_paths.remove_path(net_id) def lock_net(self, net_id: str) -> None: """ Convert a routed net into static obstacles. """ - self._locked_nets.add(net_id) - - # Move all segments of this net to static obstacles - to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] - for obj_id in to_move: - poly = self.dynamic_geometries[obj_id][1] - dilated = self.dynamic_dilated[obj_id] - # Preserve dilation for perfect consistency - self.add_static_obstacle(poly, dilated_geometry=dilated) - - # Remove from dynamic index (without triggering the locked-net guard) - self.dynamic_tree = None - self.dynamic_grid = {} - self._dynamic_tree_dirty = True - for obj_id in to_move: - self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds) - del self.dynamic_geometries[obj_id] - del self.dynamic_dilated[obj_id] + self._dynamic_paths.lock_net(net_id) def unlock_net(self, net_id: str) -> None: - self._locked_nets.discard(net_id) + self._dynamic_paths.unlock_net(net_id) def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - self.metrics['static_straight_fast'] += 1 - reach = self.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) - return reach < length - 0.001 + return self._static_move_checker.check_move_straight_static(start_port, length, net_width) def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - """ Fast port-based check to see if a collision might be in a safety zone. """ - sz = self.safety_zone_radius - b = self._static_bounds_array[idx] - if start_port: - if (b[0]-sz <= start_port.x <= b[2]+sz and - b[1]-sz <= start_port.y <= b[3]+sz): return True - if end_port: - if (b[0]-sz <= end_port.x <= b[2]+sz and - b[1]-sz <= end_port.y <= b[3]+sz): return True - return False + return self._static_move_checker.is_in_safety_zone_fast(idx, start_port, end_port) - def check_move_static(self, result: ComponentResult, start_port: Port | None = None, end_port: Port | None = None, net_width: float | None = None) -> bool: - if not self.static_dilated: return False - self.metrics['static_tree_queries'] += 1 - self._ensure_static_tree() - - # 1. Fast total bounds check (Use dilated bounds to ensure clearance is caught) - tb = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds - hits = self.static_tree.query(box(*tb)) - if hits.size == 0: return False - - # 2. Per-hit check - s_bounds = self._static_bounds_array - move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds - for hit_idx in hits: - obs_b = s_bounds[hit_idx] - - # Check if any polygon in the move actually hits THIS obstacle's AABB - poly_hits_obs_aabb = False - for pb in move_poly_bounds: - if (pb[0] < obs_b[2] and pb[2] > obs_b[0] and - pb[1] < obs_b[3] and pb[3] > obs_b[1]): - poly_hits_obs_aabb = True - break - - if not poly_hits_obs_aabb: continue - - # Safety zone check (Fast port-based) - if self._is_in_safety_zone_fast(hit_idx, start_port, end_port): - # If near port, we must use the high-precision check - obj_id = self.static_obj_ids[hit_idx] - collision_found = False - for p_move in result.geometry: - if not self._is_in_safety_zone(p_move, obj_id, start_port, end_port): - collision_found = True; break - if not collision_found: continue - return True - - # Not in safety zone and AABBs overlap - check real intersection - obj_id = self.static_obj_ids[hit_idx] - # Use dilated geometry (Wi/2 + C/2) against static_dilated (C/2) to get Wi/2 + C. - # Touching means gap is exactly C. Intersection without touches means gap < C. - test_geoms = result.dilated_geometry if result.dilated_geometry else result.geometry - static_obs_dilated = self.static_dilated[obj_id] - - for i, p_test in enumerate(test_geoms): - if p_test.intersects(static_obs_dilated) and not p_test.touches(static_obs_dilated): - return True - return False + def check_move_static( + self, + result: ComponentResult, + start_port: Port | None = None, + end_port: Port | None = None, + net_width: float | None = None, + ) -> bool: + return self._static_move_checker.check_move_static( + result, + start_port=start_port, + end_port=end_port, + net_width=net_width, + ) def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - if not self.dynamic_geometries: return 0 - tb = result.total_dilated_bounds - if tb is None: return 0 - self._ensure_dynamic_grid() - dynamic_grid = self.dynamic_grid - if not dynamic_grid: return 0 - - cs_inv = self._inv_grid_cell_size - gx_min = int(tb[0] * cs_inv) - gy_min = int(tb[1] * cs_inv) - gx_max = int(tb[2] * cs_inv) - gy_max = int(tb[3] * cs_inv) - - dynamic_geometries = self.dynamic_geometries - - # Fast path for single cell - if gx_min == gx_max and gy_min == gy_max: - cell = (gx_min, gy_min) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - return self._check_real_congestion(result, net_id) - return 0 - - # General case - any_possible = False - for gx in range(gx_min, gx_max + 1): - for gy in range(gy_min, gy_max + 1): - cell = (gx, gy) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - any_possible = True - break - if any_possible: break - if any_possible: break - - if not any_possible: return 0 - return self._check_real_congestion(result, net_id) + return self._dynamic_congestion_checker.check_move_congestion(result, net_id) def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - self.metrics['congestion_tree_queries'] += 1 - self._ensure_dynamic_tree() - if self.dynamic_tree is None: return 0 - - # 1. Fast total bounds check (LAZY SAFE) - tb = result.total_dilated_bounds - d_bounds = self._dynamic_bounds_array - possible_total = (tb[0] < d_bounds[:, 2]) & (tb[2] > d_bounds[:, 0]) & \ - (tb[1] < d_bounds[:, 3]) & (tb[3] > d_bounds[:, 1]) - - valid_hits_mask = (self._dynamic_net_ids_array != net_id) - if not numpy.any(possible_total & valid_hits_mask): - return 0 + return self._dynamic_congestion_checker.check_real_congestion(result, net_id) - # 2. Per-polygon check using query - geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry - res_indices, tree_indices = self.dynamic_tree.query(geoms_to_test, predicate='intersects') - - if tree_indices.size == 0: - return 0 - - hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices) - - # Group by other net_id to minimize 'touches' calls - unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) - if unique_other_nets.size == 0: - return 0 - - tree_geoms = self.dynamic_tree.geometries - real_hits_count = 0 - - for other_nid in unique_other_nets: - other_mask = (hit_net_ids == other_nid) - sub_tree_indices = tree_indices[other_mask] - sub_res_indices = res_indices[other_mask] - - # Check if ANY hit for THIS other net is a real collision - found_real = False - for j in range(len(sub_tree_indices)): - p_test = geoms_to_test[sub_res_indices[j]] - p_tree = tree_geoms[sub_tree_indices[j]] - if not p_test.touches(p_tree): - # Add small area tolerance for numerical precision - if p_test.intersection(p_tree).area > 1e-7: - found_real = True - break - - if found_real: - real_hits_count += 1 - - return real_hits_count + def _is_in_safety_zone( + self, + geometry: Polygon, + obj_id: int, + start_port: Port | None, + end_port: Port | None, + ) -> bool: + return self._static_move_checker.is_in_safety_zone(geometry, obj_id, start_port, end_port) - def _is_in_safety_zone(self, geometry: Polygon, obj_id: int, start_port: Port | None, end_port: Port | None) -> bool: - """ - Only returns True if the collision is ACTUALLY inside a safety zone. - """ - raw_obstacle = self.static_geometries[obj_id] - sz = self.safety_zone_radius - - # Fast path: check if ports are even near the obstacle - obs_b = raw_obstacle.bounds - near_start = start_port and (obs_b[0]-sz <= start_port.x <= obs_b[2]+sz and - obs_b[1]-sz <= start_port.y <= obs_b[3]+sz) - near_end = end_port and (obs_b[0]-sz <= end_port.x <= obs_b[2]+sz and - obs_b[1]-sz <= end_port.y <= obs_b[3]+sz) - - if not near_start and not near_end: - return False + def check_collision( + self, + geometry: Polygon, + net_id: str, + buffer_mode: Literal["static", "congestion"] = "static", + start_port: Port | None = None, + end_port: Port | None = None, + dilated_geometry: Polygon | None = None, + bounds: tuple[float, float, float, float] | None = None, + net_width: float | None = None, + ) -> bool | int: + return self._collision_query_checker.check_collision( + geometry, + net_id, + buffer_mode=buffer_mode, + start_port=start_port, + end_port=end_port, + dilated_geometry=dilated_geometry, + bounds=bounds, + net_width=net_width, + ) - if not geometry.intersects(raw_obstacle): - return False - - self.metrics['safety_zone_checks'] += 1 - intersection = geometry.intersection(raw_obstacle) - if intersection.is_empty: return False - - ix_bounds = intersection.bounds - if start_port and near_start: - if (abs(ix_bounds[0] - start_port.x) < sz and abs(ix_bounds[1] - start_port.y) < sz and - abs(ix_bounds[2] - start_port.x) < sz and abs(ix_bounds[3] - start_port.y) < sz): return True - if end_port and near_end: - if (abs(ix_bounds[0] - end_port.x) < sz and abs(ix_bounds[1] - end_port.y) < sz and - abs(ix_bounds[2] - end_port.x) < sz and abs(ix_bounds[3] - end_port.y) < sz): return True - return False - - def check_collision(self, geometry: Polygon, net_id: str, buffer_mode: Literal['static', 'congestion'] = 'static', start_port: Port | None = None, end_port: Port | None = None, dilated_geometry: Polygon | None = None, bounds: tuple[float, float, float, float] | None = None, net_width: float | None = None) -> bool | int: - if buffer_mode == 'static': - self._ensure_static_tree() - if self.static_tree is None: return False - - # Separation needed: Centerline-to-WallEdge >= Wi/2 + C. - # static_tree has obstacles buffered by C/2. - # geometry is physical waveguide (Wi/2 from centerline). - # So we buffer geometry by C/2 to get Wi/2 + C/2. - # Intersection means separation < (Wi/2 + C/2) + C/2 = Wi/2 + C. - if dilated_geometry is not None: - test_geom = dilated_geometry - else: - dist = self.clearance / 2.0 - test_geom = geometry.buffer(dist + 1e-7, join_style=2) if dist > 0 else geometry - - hits = self.static_tree.query(test_geom, predicate='intersects') - tree_geoms = self.static_tree.geometries - for hit_idx in hits: - if test_geom.touches(tree_geoms[hit_idx]): continue - obj_id = self.static_obj_ids[hit_idx] - if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): continue - return True - return False - - self._ensure_dynamic_tree() - if self.dynamic_tree is None: return 0 - test_poly = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) - hits = self.dynamic_tree.query(test_poly, predicate='intersects') - tree_geoms = self.dynamic_tree.geometries - hit_net_ids = [] - for hit_idx in hits: - if test_poly.touches(tree_geoms[hit_idx]): continue - obj_id = self.dynamic_obj_ids[hit_idx] - other_id = self.dynamic_geometries[obj_id][0] - if other_id != net_id: - hit_net_ids.append(other_id) - return len(numpy.unique(hit_net_ids)) if hit_net_ids else 0 - - def is_collision(self, geometry: Polygon, net_id: str = 'default', net_width: float | None = None, start_port: Port | None = None, end_port: Port | None = None) -> bool: + def is_collision( + self, + geometry: Polygon, + net_id: str = "default", + net_width: float | None = None, + start_port: Port | None = None, + end_port: Port | None = None, + ) -> bool: """ Unified entry point for static collision checks. """ - result = self.check_collision(geometry, net_id, buffer_mode='static', start_port=start_port, end_port=end_port, net_width=net_width) + result = self.check_collision( + geometry, + net_id, + buffer_mode="static", + start_port=start_port, + end_port=end_port, + net_width=net_width, + ) return bool(result) + def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: + return self._path_verifier.verify_path_report(net_id, components) + def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]: - """ - Non-approximated, full-polygon intersection check of a path against all - static obstacles and other nets. - """ - collision_count = 0 - - # 1. Check against static obstacles - self._ensure_static_raw_tree() - if self._static_raw_tree is not None: - raw_geoms = self._static_raw_tree.geometries - for comp in components: - # Use ACTUAL geometry, not dilated/proxy - actual_geoms = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry - for p_actual in actual_geoms: - # Physical separation must be >= clearance. - p_verify = p_actual.buffer(self.clearance, join_style=2) - hits = self._static_raw_tree.query(p_verify, predicate='intersects') - for hit_idx in hits: - p_obs = raw_geoms[hit_idx] - # If they ONLY touch, gap is exactly clearance. Valid. - if p_verify.touches(p_obs): continue - - obj_id = self._static_raw_obj_ids[hit_idx] - if not self._is_in_safety_zone(p_actual, obj_id, None, None): - collision_count += 1 - - # 2. Check against other nets - self._ensure_dynamic_tree() - if self.dynamic_tree is not None: - tree_geoms = self.dynamic_tree.geometries - for comp in components: - # Robust fallback chain to ensure crossings are caught even with zero clearance - d_geoms = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry - if not d_geoms: continue - - # Ensure d_geoms is a list/array for STRtree.query - if not isinstance(d_geoms, (list, tuple, numpy.ndarray)): - d_geoms = [d_geoms] - - res_indices, tree_indices = self.dynamic_tree.query(d_geoms, predicate='intersects') - if tree_indices.size > 0: - hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices) - net_id_str = str(net_id) - - comp_hits = [] - for i in range(len(tree_indices)): - if hit_net_ids[i] == net_id_str: continue - - p_new = d_geoms[res_indices[i]] - p_tree = tree_geoms[tree_indices[i]] - if not p_new.touches(p_tree): - # Numerical tolerance for area overlap - if p_new.intersection(p_tree).area > 1e-7: - comp_hits.append(hit_net_ids[i]) - - if comp_hits: - collision_count += len(numpy.unique(comp_hits)) - - return (collision_count == 0), collision_count + report = self.verify_path_report(net_id, components) + return report.is_valid, report.collision_count def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: - rad = numpy.radians(angle_deg) - cos_v, sin_v = numpy.cos(rad), numpy.sin(rad) - dx, dy = max_dist * cos_v, max_dist * sin_v - min_x, max_x = sorted([origin.x, origin.x + dx]) - min_y, max_y = sorted([origin.y, origin.y + dy]) - - key = None - if net_width is not None: - tree = self._ensure_net_static_tree(net_width) - key = (round(net_width, 4), round(self.clearance, 4)) - is_rect_arr = self._net_specific_is_rect[key] - bounds_arr = self._net_specific_bounds[key] - else: - self._ensure_static_tree() - tree = self.static_tree - is_rect_arr = self._static_is_rect_array - bounds_arr = self._static_bounds_array - - if tree is None: return max_dist - candidates = tree.query(box(min_x, min_y, max_x, max_y)) - if candidates.size == 0: return max_dist - - min_dist = max_dist - inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 - inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 - - tree_geoms = tree.geometries - ray_line = None - - # Fast AABB-based pre-sort - candidates_bounds = bounds_arr[candidates] - # Distance to AABB min corner as heuristic - dist_sq = (candidates_bounds[:, 0] - origin.x)**2 + (candidates_bounds[:, 1] - origin.y)**2 - sorted_indices = numpy.argsort(dist_sq) - - for idx in sorted_indices: - c = candidates[idx] - b = bounds_arr[c] - - # Fast axis-aligned ray-AABB intersection - # (Standard Slab method) - if abs(dx) < 1e-12: # Vertical ray - if origin.x < b[0] or origin.x > b[2]: tx_min, tx_max = 1e30, -1e30 - else: tx_min, tx_max = -1e30, 1e30 - else: - t1, t2 = (b[0] - origin.x) * inv_dx, (b[2] - origin.x) * inv_dx - tx_min, tx_max = min(t1, t2), max(t1, t2) - - if abs(dy) < 1e-12: # Horizontal ray - if origin.y < b[1] or origin.y > b[3]: ty_min, ty_max = 1e30, -1e30 - else: ty_min, ty_max = -1e30, 1e30 - else: - t1, t2 = (b[1] - origin.y) * inv_dy, (b[3] - origin.y) * inv_dy - ty_min, ty_max = min(t1, t2), max(t1, t2) - - t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) - - # Intersection conditions - if t_max < 0 or t_min > t_max or t_min > 1.0: continue - - # If hit is further than current min_dist, skip - if t_min * max_dist >= min_dist: continue - - # HIGH PRECISION CHECK - if is_rect_arr[c]: - # Rectangles are perfectly described by their AABB - min_dist = max(0.0, t_min * max_dist) - continue - - # Fallback to full geometry check for non-rectangles (arcs, etc.) - if ray_line is None: - ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) - - obs_dilated = tree_geoms[c] - if obs_dilated.intersects(ray_line): - intersection = ray_line.intersection(obs_dilated) - if intersection.is_empty: continue - - def get_dist(geom): - if hasattr(geom, 'geoms'): return min(get_dist(g) for g in geom.geoms) - return numpy.sqrt((geom.coords[0][0] - origin.x)**2 + (geom.coords[0][1] - origin.y)**2) - - d = get_dist(intersection) - if d < min_dist: min_dist = d - - return min_dist + return self._ray_caster.ray_cast(origin, angle_deg, max_dist=max_dist, net_width=net_width) diff --git a/inire/geometry/collision_query_checker.py b/inire/geometry/collision_query_checker.py new file mode 100644 index 0000000..7ccdf65 --- /dev/null +++ b/inire/geometry/collision_query_checker.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import numpy + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.collision import CollisionEngine + from inire.geometry.primitives import Port + + +class CollisionQueryChecker: + __slots__ = ("engine",) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + + def check_collision( + self, + geometry: Polygon, + net_id: str, + buffer_mode: Literal["static", "congestion"] = "static", + start_port: Port | None = None, + end_port: Port | None = None, + dilated_geometry: Polygon | None = None, + bounds: tuple[float, float, float, float] | None = None, + net_width: float | None = None, + ) -> bool | int: + del bounds, net_width + + if buffer_mode == "static": + return self._check_static_collision( + geometry, + start_port=start_port, + end_port=end_port, + dilated_geometry=dilated_geometry, + ) + return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) + + def _check_static_collision( + self, + geometry: Polygon, + start_port: Port | None = None, + end_port: Port | None = None, + dilated_geometry: Polygon | None = None, + ) -> bool: + engine = self.engine + static_obstacles = engine._static_obstacles + engine._ensure_static_tree() + if static_obstacles.tree is None: + return False + + if dilated_geometry is not None: + test_geometry = dilated_geometry + else: + distance = engine.clearance / 2.0 + test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry + + hits = static_obstacles.tree.query(test_geometry, predicate="intersects") + tree_geometries = static_obstacles.tree.geometries + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = static_obstacles.obj_ids[hit_idx] + if engine._is_in_safety_zone(geometry, obj_id, start_port, end_port): + continue + return True + return False + + def _check_dynamic_collision( + self, + geometry: Polygon, + net_id: str, + dilated_geometry: Polygon | None = None, + ) -> int: + engine = self.engine + dynamic_paths = engine._dynamic_paths + engine._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(engine.clearance / 2.0) + hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") + tree_geometries = dynamic_paths.tree.geometries + hit_net_ids: list[str] = [] + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = dynamic_paths.obj_ids[hit_idx] + other_net_id = dynamic_paths.geometries[obj_id][0] + if other_net_id != net_id: + hit_net_ids.append(other_net_id) + if not hit_net_ids: + return 0 + return len(numpy.unique(hit_net_ids)) diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py new file mode 100644 index 0000000..44e1ec9 --- /dev/null +++ b/inire/geometry/component_overlap.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + + +def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]: + if prefer_actual and component.actual_geometry is not None: + return component.actual_geometry + return component.geometry + + +def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]: + if not prefer_actual: + return component.total_bounds + + polygons = component_polygons(component, prefer_actual=True) + min_x = min(polygon.bounds[0] for polygon in polygons) + min_y = min(polygon.bounds[1] for polygon in polygons) + max_x = max(polygon.bounds[2] for polygon in polygons) + max_y = max(polygon.bounds[3] for polygon in polygons) + return (min_x, min_y, max_x, max_y) + + +def components_overlap( + component_a: ComponentResult, + component_b: ComponentResult, + prefer_actual: bool = False, +) -> bool: + bounds_a = component_bounds(component_a, prefer_actual=prefer_actual) + bounds_b = component_bounds(component_b, prefer_actual=prefer_actual) + if not ( + bounds_a[0] < bounds_b[2] + and bounds_a[2] > bounds_b[0] + and bounds_a[1] < bounds_b[3] + and bounds_a[3] > bounds_b[1] + ): + return False + + polygons_a = component_polygons(component_a, prefer_actual=prefer_actual) + polygons_b = component_polygons(component_b, prefer_actual=prefer_actual) + for polygon_a in polygons_a: + for polygon_b in polygons_b: + if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): + return True + return False + + +def has_self_overlap(path: list[ComponentResult], prefer_actual: bool = False) -> bool: + for i, component in enumerate(path): + for j in range(i + 2, len(path)): + if components_overlap(component, path[j], prefer_actual=prefer_actual): + return True + return False diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 36b425b..abd9367 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -8,7 +8,7 @@ from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box -from inire.constants import TOLERANCE_ANGULAR, TOLERANCE_LINEAR +from inire.constants import TOLERANCE_ANGULAR from .primitives import Port, rotation_matrix2 @@ -18,6 +18,7 @@ def _normalize_length(value: float) -> float: class ComponentResult: __slots__ = ( + "start_port", "geometry", "dilated_geometry", "proxy_geometry", @@ -34,6 +35,7 @@ class ComponentResult: def __init__( self, + start_port: Port, geometry: list[Polygon], end_port: Port, length: float, @@ -43,6 +45,7 @@ class ComponentResult: actual_geometry: list[Polygon] | None = None, dilated_actual_geometry: list[Polygon] | None = None, ) -> None: + self.start_port = start_port self.geometry = geometry self.dilated_geometry = dilated_geometry self.proxy_geometry = proxy_geometry @@ -80,6 +83,7 @@ class ComponentResult: def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( + start_port=self.start_port + [dx, dy, 0], geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry], end_port=self.end_port + [dx, dy, 0], length=self.length, @@ -87,7 +91,11 @@ class ComponentResult: dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry], proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry], actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry], - dilated_actual_geometry=None if self.dilated_actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry], + dilated_actual_geometry=( + None + if self.dilated_actual_geometry is None + else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry] + ), ) @@ -226,11 +234,19 @@ class Straight: dilated_geometry = None if dilation > 0: half_w_d = half_w + dilation - pts_d = numpy.array(((-dilation, half_w_d), (length_f + dilation, half_w_d), (length_f + dilation, -half_w_d), (-dilation, -half_w_d))) + pts_d = numpy.array( + ( + (-dilation, half_w_d), + (length_f + dilation, half_w_d), + (length_f + dilation, -half_w_d), + (-dilation, -half_w_d), + ) + ) poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y)) dilated_geometry = [Polygon(poly_points_d)] return ComponentResult( + start_port=start_port, geometry=geometry, end_port=end_port, length=abs(length_f), @@ -299,10 +315,18 @@ class Bend90: dilated_actual_geometry = [poly.buffer(dilation) for poly in collision_polys] dilated_geometry = dilated_actual_geometry else: - dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation) + dilated_actual_geometry = _get_arc_polygons( + (float(center_xy[0]), float(center_xy[1])), + radius, + width, + ts, + sagitta, + dilation=dilation, + ) dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] return ComponentResult( + start_port=start_port, geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, @@ -398,6 +422,7 @@ class SBend: dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] return ComponentResult( + start_port=start_port, geometry=geometry, end_port=end_port, length=2.0 * radius * theta, diff --git a/inire/geometry/dynamic_congestion_checker.py b/inire/geometry/dynamic_congestion_checker.py new file mode 100644 index 0000000..4778550 --- /dev/null +++ b/inire/geometry/dynamic_congestion_checker.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy + +if TYPE_CHECKING: + from inire.geometry.collision import CollisionEngine + from inire.geometry.components import ComponentResult + + +class DynamicCongestionChecker: + __slots__ = ("engine",) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + + def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: + engine = self.engine + dynamic_paths = engine._dynamic_paths + if not dynamic_paths.geometries: + return 0 + + total_bounds = result.total_dilated_bounds + if total_bounds is None: + return 0 + + engine._ensure_dynamic_grid() + dynamic_grid = dynamic_paths.grid + if not dynamic_grid: + return 0 + + cell_size_inv = engine._inv_grid_cell_size + gx_min = int(total_bounds[0] * cell_size_inv) + gy_min = int(total_bounds[1] * cell_size_inv) + gx_max = int(total_bounds[2] * cell_size_inv) + gy_max = int(total_bounds[3] * cell_size_inv) + + dynamic_geometries = dynamic_paths.geometries + + if gx_min == gx_max and gy_min == gy_max: + cell = (gx_min, gy_min) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_geometries[obj_id][0] != net_id: + return self.check_real_congestion(result, net_id) + return 0 + + any_possible = False + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + cell = (gx, gy) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_geometries[obj_id][0] != net_id: + any_possible = True + break + if any_possible: + break + if any_possible: + break + + if not any_possible: + return 0 + return self.check_real_congestion(result, net_id) + + def check_real_congestion(self, result: ComponentResult, net_id: str) -> int: + engine = self.engine + dynamic_paths = engine._dynamic_paths + engine.metrics["congestion_tree_queries"] += 1 + engine._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + total_bounds = result.total_dilated_bounds + dynamic_bounds = engine._dynamic_bounds_array + possible_total = ( + (total_bounds[0] < dynamic_bounds[:, 2]) + & (total_bounds[2] > dynamic_bounds[:, 0]) + & (total_bounds[1] < dynamic_bounds[:, 3]) + & (total_bounds[3] > dynamic_bounds[:, 1]) + ) + + valid_hits_mask = dynamic_paths.net_ids_array != net_id + if not numpy.any(possible_total & valid_hits_mask): + return 0 + + geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry + res_indices, tree_indices = dynamic_paths.tree.query(geoms_to_test, predicate="intersects") + if tree_indices.size == 0: + return 0 + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) + if unique_other_nets.size == 0: + return 0 + + tree_geometries = dynamic_paths.tree.geometries + real_hits_count = 0 + + for other_net_id in unique_other_nets: + other_mask = hit_net_ids == other_net_id + sub_tree_indices = tree_indices[other_mask] + sub_res_indices = res_indices[other_mask] + + found_real = False + for index in range(len(sub_tree_indices)): + test_geometry = geoms_to_test[sub_res_indices[index]] + tree_geometry = tree_geometries[sub_tree_indices[index]] + if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7: + found_real = True + break + + if found_real: + real_hits_count += 1 + + return real_hits_count diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py new file mode 100644 index 0000000..e96bb1e --- /dev/null +++ b/inire/geometry/dynamic_path_index.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy +import rtree +from shapely.strtree import STRtree + +if TYPE_CHECKING: + from shapely.geometry import Polygon + from shapely.prepared import PreparedGeometry + + from inire.geometry.collision import CollisionEngine + + +class DynamicPathIndex: + __slots__ = ( + "engine", + "index", + "geometries", + "dilated", + "prepared", + "tree", + "obj_ids", + "grid", + "id_counter", + "tree_dirty", + "net_ids_array", + "bounds_array", + "locked_nets", + ) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + self.index = rtree.index.Index() + self.geometries: dict[int, tuple[str, Polygon]] = {} + self.dilated: dict[int, Polygon] = {} + self.prepared: dict[int, PreparedGeometry] = {} + self.tree: STRtree | None = None + self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) + self.grid: dict[tuple[int, int], list[int]] = {} + self.id_counter = 0 + self.tree_dirty = True + self.net_ids_array = numpy.array([], dtype=" None: + self.tree = None + self.grid = {} + self.tree_dirty = True + + def ensure_tree(self) -> None: + if self.tree is None and self.dilated: + ids = sorted(self.dilated.keys()) + geometries = [self.dilated[i] for i in ids] + self.tree = STRtree(geometries) + self.obj_ids = numpy.array(ids, dtype=numpy.int32) + self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) + net_ids = [self.geometries[obj_id][0] for obj_id in self.obj_ids] + self.net_ids_array = numpy.array(net_ids, dtype=" None: + if self.grid or not self.dilated: + return + + cell_size = self.engine.grid_cell_size + for obj_id, polygon in self.dilated.items(): + bounds = polygon.bounds + for gx in range(int(bounds[0] / cell_size), int(bounds[2] / cell_size) + 1): + for gy in range(int(bounds[1] / cell_size), int(bounds[3] / cell_size) + 1): + cell = (gx, gy) + self.grid.setdefault(cell, []).append(obj_id) + + def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + self.invalidate_queries() + dilation = self.engine.clearance / 2.0 + for index, polygon in enumerate(geometry): + obj_id = self.id_counter + self.id_counter += 1 + dilated = dilated_geometry[index] if dilated_geometry else polygon.buffer(dilation) + self.geometries[obj_id] = (net_id, polygon) + self.dilated[obj_id] = dilated + self.index.insert(obj_id, dilated.bounds) + + def remove_path(self, net_id: str) -> None: + if net_id in self.locked_nets: + return + + to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] + self.remove_obj_ids(to_remove) + + def remove_obj_ids(self, obj_ids: list[int]) -> None: + if not obj_ids: + return + + self.invalidate_queries() + for obj_id in obj_ids: + self.index.delete(obj_id, self.dilated[obj_id].bounds) + del self.geometries[obj_id] + del self.dilated[obj_id] + + def lock_net(self, net_id: str) -> None: + self.locked_nets.add(net_id) + to_move = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] + for obj_id in to_move: + polygon = self.geometries[obj_id][1] + dilated = self.dilated[obj_id] + self.engine.add_static_obstacle(polygon, dilated_geometry=dilated) + self.remove_obj_ids(to_move) + + def unlock_net(self, net_id: str) -> None: + self.locked_nets.discard(net_id) diff --git a/inire/geometry/path_verifier.py b/inire/geometry/path_verifier.py new file mode 100644 index 0000000..273cbd6 --- /dev/null +++ b/inire/geometry/path_verifier.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy + +from inire.geometry.component_overlap import components_overlap + +if TYPE_CHECKING: + from inire.geometry.collision import CollisionEngine + from inire.geometry.components import ComponentResult + + +@dataclass(frozen=True) +class PathVerificationReport: + static_collision_count: int + dynamic_collision_count: int + self_collision_count: int + total_length: float + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + +class PathVerifier: + __slots__ = ("engine",) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + + def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: + """ + Non-approximated, full-polygon intersection check of a path against all + static obstacles, other nets, and itself. + """ + static_collision_count = 0 + dynamic_collision_count = 0 + self_collision_count = 0 + total_length = sum(component.length for component in components) + + engine = self.engine + static_obstacles = engine._static_obstacles + dynamic_paths = engine._dynamic_paths + + # 1. Check against static obstacles. + engine._ensure_static_raw_tree() + if static_obstacles.raw_tree is not None: + raw_geoms = static_obstacles.raw_tree.geometries + for comp in components: + polygons = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry + for polygon in polygons: + # Physical separation must be >= clearance. + buffered = polygon.buffer(engine.clearance, join_style=2) + hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") + for hit_idx in hits: + obstacle = raw_geoms[hit_idx] + # If they only touch, gap is exactly clearance. Valid. + if buffered.touches(obstacle): + continue + + obj_id = static_obstacles.raw_obj_ids[hit_idx] + if not engine._is_in_safety_zone(polygon, obj_id, None, None): + static_collision_count += 1 + + # 2. Check against other nets. + engine._ensure_dynamic_tree() + if dynamic_paths.tree is not None: + tree_geoms = dynamic_paths.tree.geometries + for comp in components: + # Robust fallback chain to ensure crossings are caught even with zero clearance. + test_geometries = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry + if not test_geometries: + continue + + if not isinstance(test_geometries, list | tuple | numpy.ndarray): + test_geometries = [test_geometries] + + res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects") + if tree_indices.size == 0: + continue + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + comp_hits = [] + for i in range(len(tree_indices)): + if hit_net_ids[i] == str(net_id): + continue + + p_new = test_geometries[res_indices[i]] + p_tree = tree_geoms[tree_indices[i]] + if not p_new.touches(p_tree) and p_new.intersection(p_tree).area > 1e-7: + comp_hits.append(hit_net_ids[i]) + + if comp_hits: + dynamic_collision_count += len(numpy.unique(comp_hits)) + + # 3. Check for self collisions between non-adjacent components. + for i, comp_i in enumerate(components): + for j in range(i + 2, len(components)): + if components_overlap(comp_i, components[j], prefer_actual=True): + self_collision_count += 1 + + return PathVerificationReport( + static_collision_count=static_collision_count, + dynamic_collision_count=dynamic_collision_count, + self_collision_count=self_collision_count, + total_length=total_length, + ) diff --git a/inire/geometry/ray_caster.py b/inire/geometry/ray_caster.py new file mode 100644 index 0000000..a09ecf5 --- /dev/null +++ b/inire/geometry/ray_caster.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy +from shapely.geometry import LineString, box + +if TYPE_CHECKING: + from shapely.geometry.base import BaseGeometry + + from inire.geometry.collision import CollisionEngine + from inire.geometry.primitives import Port + + +class RayCaster: + __slots__ = ("engine",) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + + def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: + engine = self.engine + static_obstacles = engine._static_obstacles + + rad = numpy.radians(angle_deg) + cos_v, sin_v = numpy.cos(rad), numpy.sin(rad) + dx, dy = max_dist * cos_v, max_dist * sin_v + min_x, max_x = sorted([origin.x, origin.x + dx]) + min_y, max_y = sorted([origin.y, origin.y + dy]) + + key = None + if net_width is not None: + tree = engine._ensure_net_static_tree(net_width) + key = (round(net_width, 4), round(engine.clearance, 4)) + is_rect_arr = static_obstacles.net_specific_is_rect[key] + bounds_arr = static_obstacles.net_specific_bounds[key] + else: + engine._ensure_static_tree() + tree = static_obstacles.tree + is_rect_arr = static_obstacles.is_rect_array + bounds_arr = static_obstacles.bounds_array + + if tree is None: + return max_dist + + candidates = tree.query(box(min_x, min_y, max_x, max_y)) + if candidates.size == 0: + return max_dist + + min_dist = max_dist + inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 + inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 + tree_geoms = tree.geometries + ray_line = None + + # Distance to the AABB min corner is a cheap ordering heuristic. + candidates_bounds = bounds_arr[candidates] + dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 + sorted_indices = numpy.argsort(dist_sq) + + for idx in sorted_indices: + candidate_id = candidates[idx] + bounds = bounds_arr[candidate_id] + + if abs(dx) < 1e-12: + if origin.x < bounds[0] or origin.x > bounds[2]: + tx_min, tx_max = 1e30, -1e30 + else: + tx_min, tx_max = -1e30, 1e30 + else: + t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx + tx_min, tx_max = min(t1, t2), max(t1, t2) + + if abs(dy) < 1e-12: + if origin.y < bounds[1] or origin.y > bounds[3]: + ty_min, ty_max = 1e30, -1e30 + else: + ty_min, ty_max = -1e30, 1e30 + else: + t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy + ty_min, ty_max = min(t1, t2), max(t1, t2) + + t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) + if t_max < 0 or t_min > t_max or t_min > 1.0: + continue + if t_min * max_dist >= min_dist: + continue + + if is_rect_arr[candidate_id]: + min_dist = max(0.0, t_min * max_dist) + continue + + if ray_line is None: + ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) + + obstacle = tree_geoms[candidate_id] + if not obstacle.intersects(ray_line): + continue + + intersection = ray_line.intersection(obstacle) + if intersection.is_empty: + continue + + distance = self._intersection_distance(origin, intersection) + min_dist = min(min_dist, distance) + + return min_dist + + def _intersection_distance(self, origin: Port, geometry: BaseGeometry) -> float: + if hasattr(geometry, "geoms"): + return min(self._intersection_distance(origin, sub_geom) for sub_geom in geometry.geoms) + return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) diff --git a/inire/geometry/static_move_checker.py b/inire/geometry/static_move_checker.py new file mode 100644 index 0000000..f70cb88 --- /dev/null +++ b/inire/geometry/static_move_checker.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from shapely.geometry import box + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.collision import CollisionEngine + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + + +class StaticMoveChecker: + __slots__ = ("engine",) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + + def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: + engine = self.engine + engine.metrics["static_straight_fast"] += 1 + reach = engine.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) + return reach < length - 0.001 + + def is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: + engine = self.engine + sz = engine.safety_zone_radius + bounds = engine._static_obstacles.bounds_array[idx] + if start_port and bounds[0] - sz <= start_port.x <= bounds[2] + sz and bounds[1] - sz <= start_port.y <= bounds[3] + sz: + return True + return bool( + end_port + and bounds[0] - sz <= end_port.x <= bounds[2] + sz + and bounds[1] - sz <= end_port.y <= bounds[3] + sz + ) + + def check_move_static( + self, + result: ComponentResult, + start_port: Port | None = None, + end_port: Port | None = None, + net_width: float | None = None, + ) -> bool: + del net_width + + engine = self.engine + static_obstacles = engine._static_obstacles + if not static_obstacles.dilated: + return False + + engine.metrics["static_tree_queries"] += 1 + engine._ensure_static_tree() + + total_bounds = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds + hits = static_obstacles.tree.query(box(*total_bounds)) + if hits.size == 0: + return False + + static_bounds = static_obstacles.bounds_array + move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds + for hit_idx in hits: + obstacle_bounds = static_bounds[hit_idx] + poly_hits_obstacle_aabb = False + for poly_bounds in move_poly_bounds: + if ( + poly_bounds[0] < obstacle_bounds[2] + and poly_bounds[2] > obstacle_bounds[0] + and poly_bounds[1] < obstacle_bounds[3] + and poly_bounds[3] > obstacle_bounds[1] + ): + poly_hits_obstacle_aabb = True + break + + if not poly_hits_obstacle_aabb: + continue + + obj_id = static_obstacles.obj_ids[hit_idx] + if self.is_in_safety_zone_fast(hit_idx, start_port, end_port): + collision_found = False + for polygon in result.geometry: + if not self.is_in_safety_zone(polygon, obj_id, start_port, end_port): + collision_found = True + break + if collision_found: + return True + continue + + test_geometries = result.dilated_geometry if result.dilated_geometry else result.geometry + static_obstacle = static_obstacles.dilated[obj_id] + for polygon in test_geometries: + if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle): + return True + + return False + + def is_in_safety_zone( + self, + geometry: Polygon, + obj_id: int, + start_port: Port | None, + end_port: Port | None, + ) -> bool: + engine = self.engine + raw_obstacle = engine._static_obstacles.geometries[obj_id] + sz = engine.safety_zone_radius + + obstacle_bounds = raw_obstacle.bounds + near_start = start_port and ( + obstacle_bounds[0] - sz <= start_port.x <= obstacle_bounds[2] + sz + and obstacle_bounds[1] - sz <= start_port.y <= obstacle_bounds[3] + sz + ) + near_end = end_port and ( + obstacle_bounds[0] - sz <= end_port.x <= obstacle_bounds[2] + sz + and obstacle_bounds[1] - sz <= end_port.y <= obstacle_bounds[3] + sz + ) + + if not near_start and not near_end: + return False + if not geometry.intersects(raw_obstacle): + return False + + engine.metrics["safety_zone_checks"] += 1 + intersection = geometry.intersection(raw_obstacle) + if intersection.is_empty: + return False + + ix_bounds = intersection.bounds + if ( + start_port + and near_start + and abs(ix_bounds[0] - start_port.x) < sz + and abs(ix_bounds[1] - start_port.y) < sz + and abs(ix_bounds[2] - start_port.x) < sz + and abs(ix_bounds[3] - start_port.y) < sz + ): + return True + return bool( + end_port + and near_end + and abs(ix_bounds[0] - end_port.x) < sz + and abs(ix_bounds[1] - end_port.y) < sz + and abs(ix_bounds[2] - end_port.x) < sz + and abs(ix_bounds[3] - end_port.y) < sz + ) diff --git a/inire/geometry/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py new file mode 100644 index 0000000..aca1c60 --- /dev/null +++ b/inire/geometry/static_obstacle_index.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy +import rtree +from shapely.prepared import prep +from shapely.strtree import STRtree + +if TYPE_CHECKING: + from shapely.geometry import Polygon + from shapely.prepared import PreparedGeometry + + from inire.geometry.collision import CollisionEngine + + +class StaticObstacleIndex: + __slots__ = ( + "engine", + "index", + "geometries", + "dilated", + "prepared", + "is_rect", + "tree", + "obj_ids", + "bounds_array", + "is_rect_array", + "raw_tree", + "raw_obj_ids", + "net_specific_trees", + "net_specific_is_rect", + "net_specific_bounds", + "safe_cache", + "grid", + "id_counter", + "version", + ) + + def __init__(self, engine: CollisionEngine) -> None: + self.engine = engine + self.index = rtree.index.Index() + self.geometries: dict[int, Polygon] = {} + self.dilated: dict[int, Polygon] = {} + self.prepared: dict[int, PreparedGeometry] = {} + self.is_rect: dict[int, bool] = {} + self.tree: STRtree | None = None + self.obj_ids: list[int] = [] + self.bounds_array: numpy.ndarray | None = None + self.is_rect_array: numpy.ndarray | None = None + self.raw_tree: STRtree | None = None + self.raw_obj_ids: list[int] = [] + self.net_specific_trees: dict[tuple[float, float], STRtree] = {} + self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} + self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} + self.safe_cache: set[tuple] = set() + self.grid: dict[tuple[int, int], list[int]] = {} + self.id_counter = 0 + self.version = 0 + + def add_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: + obj_id = self.id_counter + self.id_counter += 1 + + if dilated_geometry is not None: + dilated = dilated_geometry + else: + dilated = polygon.buffer(self.engine.clearance / 2.0, join_style=2) + + self.geometries[obj_id] = polygon + self.dilated[obj_id] = dilated + self.prepared[obj_id] = prep(dilated) + self.index.insert(obj_id, dilated.bounds) + self.invalidate_caches() + bounds = dilated.bounds + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + self.is_rect[obj_id] = abs(dilated.area - area) < 1e-4 + return obj_id + + def remove_obstacle(self, obj_id: int) -> None: + if obj_id not in self.geometries: + return + + bounds = self.dilated[obj_id].bounds + self.index.delete(obj_id, bounds) + del self.geometries[obj_id] + del self.dilated[obj_id] + del self.prepared[obj_id] + del self.is_rect[obj_id] + self.invalidate_caches() + + def invalidate_caches(self) -> None: + self.tree = None + self.bounds_array = None + self.is_rect_array = None + self.obj_ids = [] + self.raw_tree = None + self.raw_obj_ids = [] + self.grid = {} + self.net_specific_trees.clear() + self.net_specific_is_rect.clear() + self.net_specific_bounds.clear() + self.safe_cache.clear() + self.version += 1 + + def ensure_tree(self) -> None: + if self.tree is None and self.dilated: + self.obj_ids = sorted(self.dilated.keys()) + geometries = [self.dilated[i] for i in self.obj_ids] + self.tree = STRtree(geometries) + self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) + self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids]) + + def ensure_net_tree(self, net_width: float) -> STRtree: + key = (round(net_width, 4), round(self.engine.clearance, 4)) + if key in self.net_specific_trees: + return self.net_specific_trees[key] + + total_dilation = net_width / 2.0 + self.engine.clearance + geometries = [] + is_rect_list = [] + bounds_list = [] + + for obj_id in sorted(self.geometries.keys()): + polygon = self.geometries[obj_id] + dilated = polygon.buffer(total_dilation, join_style=2) + geometries.append(dilated) + bounds = dilated.bounds + bounds_list.append(bounds) + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + is_rect_list.append(abs(dilated.area - area) < 1e-4) + + tree = STRtree(geometries) + self.net_specific_trees[key] = tree + self.net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool) + self.net_specific_bounds[key] = numpy.array(bounds_list) + return tree + + def ensure_raw_tree(self) -> None: + if self.raw_tree is None and self.geometries: + self.raw_obj_ids = sorted(self.geometries.keys()) + geometries = [self.geometries[i] for i in self.raw_obj_ids] + self.raw_tree = STRtree(geometries) diff --git a/inire/router/outcomes.py b/inire/router/outcomes.py new file mode 100644 index 0000000..9dab591 --- /dev/null +++ b/inire/router/outcomes.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Literal + + +RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] + +RETRYABLE_ROUTING_OUTCOMES = frozenset({"colliding", "partial", "unroutable"}) + + +def infer_routing_outcome( + *, + has_path: bool, + reached_target: bool, + collision_count: int, +) -> RoutingOutcome: + if not has_path: + return "unroutable" + if not reached_target: + return "partial" + if collision_count > 0: + return "colliding" + return "completed" + + +def routing_outcome_needs_retry(outcome: RoutingOutcome) -> bool: + return outcome in RETRYABLE_ROUTING_OUTCOMES diff --git a/inire/router/path_state.py b/inire/router/path_state.py index ea26287..bcb386e 100644 --- a/inire/router/path_state.py +++ b/inire/router/path_state.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import CollisionEngine, PathVerificationReport from inire.geometry.components import ComponentResult @@ -49,6 +49,8 @@ class PathStateManager: def verify_path(self, net_id: str, path: list[ComponentResult]) -> tuple[bool, int]: return self.collision_engine.verify_path(net_id, path) + def verify_path_report(self, net_id: str, path: list[ComponentResult]) -> PathVerificationReport: + return self.collision_engine.verify_path_report(net_id, path) + def finalize_dynamic_tree(self) -> None: - self.collision_engine.dynamic_tree = None - self.collision_engine._ensure_dynamic_tree() + self.collision_engine.rebuild_dynamic_tree() diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 0b8ed1a..13de9b9 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -2,10 +2,11 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Literal +from typing import TYPE_CHECKING, Literal from inire.router.astar import AStarMetrics, route_astar -from inire.router.refiner import PathRefiner, has_self_collision +from inire.router.outcomes import RoutingOutcome, infer_routing_outcome, routing_outcome_needs_retry +from inire.router.refiner import PathRefiner from inire.router.path_state import PathStateManager from inire.router.session import ( create_routing_session_state, @@ -16,6 +17,8 @@ from inire.router.session import ( ) if TYPE_CHECKING: + from collections.abc import Callable + from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port from inire.router.astar import AStarContext @@ -31,6 +34,7 @@ class RoutingResult: is_valid: bool collisions: int reached_target: bool = False + outcome: RoutingOutcome = "unroutable" class PathFinder: __slots__ = ( @@ -116,9 +120,6 @@ class PathFinder: self.path_state.remove_static_obstacles(temp_obj_ids) return greedy_paths - def _has_self_collision(self, path: list[ComponentResult]) -> bool: - return has_self_collision(path) - def _path_cost(self, path: list[ComponentResult]) -> float: return self.refiner.path_cost(path) @@ -132,13 +133,24 @@ class PathFinder: path: list[ComponentResult], reached_target: bool, collisions: int, + outcome: RoutingOutcome | None = None, ) -> RoutingResult: + resolved_outcome = ( + infer_routing_outcome( + has_path=bool(path), + reached_target=reached_target, + collision_count=collisions, + ) + if outcome is None + else outcome + ) return RoutingResult( net_id=net_id, path=path, - is_valid=reached_target and collisions == 0, + is_valid=(resolved_outcome == "completed"), collisions=collisions, reached_target=reached_target, + outcome=resolved_outcome, ) def _refine_path( @@ -161,7 +173,7 @@ class PathFinder: initial_paths: dict[str, list[ComponentResult]] | None, store_expanded: bool, needs_self_collision_check: set[str], - ) -> tuple[RoutingResult, bool]: + ) -> tuple[RoutingResult, RoutingOutcome]: self.path_state.remove_path(net_id) path: list[ComponentResult] | None = None @@ -195,31 +207,34 @@ class PathFinder: self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) if not path: - return RoutingResult(net_id, [], False, 0, reached_target=False), True + outcome = infer_routing_outcome(has_path=False, reached_target=False, collision_count=0) + return self._build_routing_result(net_id=net_id, path=[], reached_target=False, collisions=0, outcome=outcome), outcome last_p = path[-1].end_port reached = last_p == target - any_congestion = False - - if reached and net_id not in needs_self_collision_check and self._has_self_collision(path): - needs_self_collision_check.add(net_id) - any_congestion = True + collision_count = 0 self._install_path(net_id, path) - - collision_count = 0 if reached: - is_valid, collision_count = self.path_state.verify_path(net_id, path) - any_congestion = any_congestion or not is_valid + report = self.path_state.verify_path_report(net_id, path) + collision_count = report.collision_count + if report.self_collision_count > 0: + needs_self_collision_check.add(net_id) + outcome = infer_routing_outcome( + has_path=bool(path), + reached_target=reached, + collision_count=collision_count, + ) return ( self._build_routing_result( net_id=net_id, path=path, reached_target=reached, collisions=collision_count, + outcome=outcome, ), - any_congestion, + outcome, ) def route_all( @@ -251,10 +266,10 @@ class PathFinder: prepare_routing_session_state(self, state) for iteration in range(self.max_iterations): - any_congestion = run_routing_iteration(self, state, iteration) - if any_congestion is None: + iteration_outcomes = run_routing_iteration(self, state, iteration) + if iteration_outcomes is None: return self.verify_all_nets(state.results, state.netlist) - if not any_congestion: + if not any(routing_outcome_needs_retry(outcome) for outcome in iteration_outcomes.values()): break self.cost_evaluator.congestion_penalty *= self.congestion_multiplier @@ -270,16 +285,26 @@ class PathFinder: for net_id, (_, target_p) in netlist.items(): res = results.get(net_id) if not res or not res.path: - final_results[net_id] = RoutingResult(net_id, [], False, 0) + final_results[net_id] = self._build_routing_result( + net_id=net_id, + path=[], + reached_target=False, + collisions=0, + ) continue last_p = res.path[-1].end_port reached = last_p == target_p - is_valid, collisions = self.path_state.verify_path(net_id, res.path) + report = self.path_state.verify_path_report(net_id, res.path) final_results[net_id] = RoutingResult( net_id=net_id, path=res.path, - is_valid=(is_valid and reached), - collisions=collisions, + is_valid=(reached and report.is_valid), + collisions=report.collision_count, reached_target=reached, + outcome=infer_routing_outcome( + has_path=True, + reached_target=reached, + collision_count=report.collision_count, + ), ) return final_results diff --git a/inire/router/refiner.py b/inire/router/refiner.py index 8436484..cdd6ea1 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -3,48 +3,27 @@ from __future__ import annotations import math from typing import TYPE_CHECKING, Any +from inire.geometry.component_overlap import components_overlap, has_self_overlap from inire.geometry.components import Bend90, Straight if TYPE_CHECKING: + from inire.geometry.collision import CollisionEngine from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port from inire.router.astar import AStarContext - -def _components_overlap(component_a: ComponentResult, component_b: ComponentResult) -> bool: - bounds_a = component_a.total_bounds - bounds_b = component_b.total_bounds - if not ( - bounds_a[0] < bounds_b[2] - and bounds_a[2] > bounds_b[0] - and bounds_a[1] < bounds_b[3] - and bounds_a[3] > bounds_b[1] - ): - return False - - for polygon_a in component_a.geometry: - for polygon_b in component_b.geometry: - if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): - return True - return False - - def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: current = parent_node while current and current.parent: ancestor_component = current.component_result - if ancestor_component and _components_overlap(component, ancestor_component): + if ancestor_component and components_overlap(component, ancestor_component): return True current = current.parent return False def has_self_collision(path: list[ComponentResult]) -> bool: - for i, comp_i in enumerate(path): - for j in range(i + 2, len(path)): - if _components_overlap(comp_i, path[j]): - return True - return False + return has_self_overlap(path) class PathRefiner: @@ -54,7 +33,7 @@ class PathRefiner: self.context = context @property - def collision_engine(self): + def collision_engine(self) -> CollisionEngine: return self.context.cost_evaluator.collision_engine def path_cost(self, path: list[ComponentResult]) -> float: @@ -150,8 +129,7 @@ class PathRefiner: x_min = min(0.0, float(local_dx)) - 0.01 x_max = max(0.0, float(local_dx)) + 0.01 - for obj_id in self.collision_engine.static_index.intersection(query_bounds): - bounds = self.collision_engine.static_geometries[obj_id].bounds + for bounds in self.collision_engine.iter_static_obstacle_bounds(query_bounds): local_corners = ( self._to_local_xy(start, bounds[0], bounds[1]), self._to_local_xy(start, bounds[0], bounds[3]), @@ -167,9 +145,7 @@ class PathRefiner: positive_anchors.add(obs_max_y) negative_anchors.add(obs_min_y) - for obj_id in self.collision_engine.dynamic_index.intersection(query_bounds): - _, poly = self.collision_engine.dynamic_geometries[obj_id] - bounds = poly.bounds + for bounds in self.collision_engine.iter_dynamic_path_bounds(query_bounds): local_corners = ( self._to_local_xy(start, bounds[0], bounds[1]), self._to_local_xy(start, bounds[0], bounds[3]), @@ -256,7 +232,7 @@ class PathRefiner: min_radius = min(self.context.config.bend_radii, default=0.0) for window_size in range(len(path), 0, -1): - for start_idx in range(0, len(path) - window_size + 1): + for start_idx in range(len(path) - window_size + 1): end_idx = start_idx + window_size window = path[start_idx:end_idx] bend_count = sum(1 for comp in window if comp.move_type == "Bend90") @@ -297,10 +273,8 @@ class PathRefiner: if replacement is None: continue candidate_path = path[:start_idx] + replacement + path[end_idx:] - if has_self_collision(candidate_path): - continue - is_valid, collisions = self.collision_engine.verify_path(net_id, candidate_path) - if not is_valid or collisions != 0: + report = self.collision_engine.verify_path_report(net_id, candidate_path) + if not report.is_valid: continue candidate_cost = self.path_cost(candidate_path) if candidate_cost + 1e-6 < best_candidate_cost: diff --git a/inire/router/session.py b/inire/router/session.py index dd1e8db..e037eaf 100644 --- a/inire/router/session.py +++ b/inire/router/session.py @@ -3,9 +3,13 @@ from __future__ import annotations import random import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Literal +from typing import TYPE_CHECKING, Literal + +from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry if TYPE_CHECKING: + from collections.abc import Callable + from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port from inire.router.pathfinder import PathFinder, RoutingResult @@ -80,8 +84,8 @@ def run_routing_iteration( finder: PathFinder, state: RoutingSessionState, iteration: int, -) -> bool | None: - any_congestion = False +) -> dict[str, RoutingOutcome] | None: + outcomes: dict[str, RoutingOutcome] = {} finder.accumulated_expanded_nodes = [] finder.metrics.reset_per_route() @@ -107,11 +111,11 @@ def run_routing_iteration( state.needs_self_collision_check, ) state.results[net_id] = result - any_congestion = any_congestion or net_congestion + outcomes[net_id] = net_congestion if state.iteration_callback: state.iteration_callback(iteration, state.results) - return any_congestion + return outcomes def refine_routing_session_results( @@ -123,18 +127,19 @@ def refine_routing_session_results( for net_id in state.all_net_ids: res = state.results.get(net_id) - if not res or not res.path or not res.reached_target or not res.is_valid: + if not res or not res.path or routing_outcome_needs_retry(res.outcome): continue start, target = state.netlist[net_id] width = state.net_widths.get(net_id, 2.0) finder.path_state.remove_path(net_id) refined_path = finder._refine_path(net_id, start, target, width, res.path) finder._install_path(net_id, refined_path) + report = finder.path_state.verify_path_report(net_id, refined_path) state.results[net_id] = finder._build_routing_result( net_id=net_id, path=refined_path, reached_target=res.reached_target, - collisions=res.collisions, + collisions=report.collision_count, ) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index fc2a753..38fb6af 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -38,16 +38,16 @@ class VisibilityManager: self._build() def _ensure_current(self) -> None: - if self._built_static_version != self.collision_engine._static_version: + if self._built_static_version != self.collision_engine.get_static_version(): self.clear_cache() def _build(self) -> None: """ Extract corners and pre-compute corner-to-corner visibility. """ - self._built_static_version = self.collision_engine._static_version + self._built_static_version = self.collision_engine.get_static_version() raw_corners = [] - for obj_id, poly in self.collision_engine.static_dilated.items(): + for poly in self.collision_engine.iter_static_dilated_geometries(): coords = list(poly.exterior.coords) if coords[0] == coords[-1]: coords = coords[:-1] @@ -83,7 +83,8 @@ class VisibilityManager: self._corner_graph[i] = [] p1 = Port(self.corners[i][0], self.corners[i][1], 0) for j in range(num_corners): - if i == j: continue + if i == j: + continue cx, cy = self.corners[j] dx, dy = cx - p1.x, cy - p1.y dist = numpy.sqrt(dx**2 + dy**2) @@ -115,7 +116,7 @@ class VisibilityManager: return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] ox, oy = round(origin.x, 3), round(origin.y, 3) - cache_key = (int(ox * 1000), int(oy * 1000)) + cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000))) if cache_key in self._point_visibility_cache: return self._point_visibility_cache[cache_key] diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index 72467b4..e93e401 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -2,7 +2,7 @@ import pytest from shapely.geometry import Polygon import inire.router.astar as astar_module -from inire.geometry.components import SBend, Straight +from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port from inire.router.astar import AStarContext, route_astar @@ -87,6 +87,38 @@ def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" +def test_validate_routing_result_checks_expected_start() -> None: + path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + + validation = validate_routing_result( + result, + [], + clearance=2.0, + expected_start=Port(0, 0, 0), + expected_end=Port(110, 0, 0), + ) + + assert not validation["is_valid"] + assert "Initial port position mismatch" in validation["reason"] + + +def test_validate_routing_result_uses_exact_component_geometry() -> None: + bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0) + result = RoutingResult(net_id="test", path=[bend], is_valid=True, collisions=0) + obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) + + validation = validate_routing_result( + result, + [obstacle], + clearance=2.0, + expected_start=Port(0, 0, 0), + expected_end=bend.end_port, + ) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + + def test_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None: context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0) diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index fa89bb6..10bce06 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,6 +1,3 @@ - -import pytest -import numpy from inire.geometry.primitives import Port from inire.geometry.collision import CollisionEngine from inire.router.cost import CostEvaluator @@ -8,7 +5,7 @@ from inire.router.astar import AStarContext from inire.router.pathfinder import PathFinder from inire.router.danger_map import DangerMap -def test_failed_net_visibility(): +def test_failed_net_visibility() -> None: """ Verifies that nets that fail to reach their target (return partial paths) ARE added to the collision engine, making them visible to other nets @@ -16,55 +13,55 @@ def test_failed_net_visibility(): """ # 1. Setup engine = CollisionEngine(clearance=2.0) - + # Create a simple danger map (bounds 0-100) # We don't strictly need obstacles in it for this test. dm = DangerMap(bounds=(0, 0, 100, 100)) - + evaluator = CostEvaluator(engine, dm) - + # 2. Configure Router with low limit to FORCE failure # node_limit=10 is extremely low, likely allowing only a few moves. # Start (0,0) -> Target (100,0) is 100um away. - + # Let's add a static obstacle that blocks the direct path. from shapely.geometry import box + obstacle = box(40, -10, 60, 10) # Wall at x=50 engine.add_static_obstacle(obstacle) - + # With obstacle, direct jump fails. A* must search around. # Limit=10 should be enough to fail to find a path around. context = AStarContext(evaluator, node_limit=10) - + # 3. Configure PathFinder # max_iterations=1 because we only need to check the state after the first attempt. pf = PathFinder(context, max_iterations=1, warm_start=None) - + netlist = { "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} - + # 4. Route print("\nStarting Route...") results = pf.route_all(netlist, net_widths) - + res = results["net1"] print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") - + # 5. Verify Failure Condition # We expect reached_target to be False because of node_limit + obstacle assert not res.reached_target, "Test setup failed: Net reached target despite low limit!" assert len(res.path) > 0, "Test setup failed: No partial path returned!" - + # 6. Verify Visibility # Check if net1 is in the collision engine found_nets = set() - # CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)] - for obj_id, (nid, poly) in engine.dynamic_geometries.items(): + for nid, _poly in engine.iter_dynamic_paths(): found_nets.add(nid) - + print(f"Nets found in engine: {found_nets}") - + # The FIX Expectation: "net1" SHOULD be present assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!" diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index d6c33f8..326078b 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -6,6 +6,7 @@ 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, @@ -139,9 +140,17 @@ def test_run_routing_iteration_updates_results_and_invokes_callback( initial_paths: dict[str, list] | None, store_expanded: bool, needs_self_collision_check: set[str], - ) -> tuple[RoutingResult, bool]: + ) -> tuple[RoutingResult, RoutingOutcome]: _ = (start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check) - return RoutingResult(net_id, [], net_id == "net1", int(net_id == "net2"), reached_target=True), net_id == "net2" + 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, @@ -169,13 +178,14 @@ def test_run_routing_iteration_updates_results_and_invokes_callback( seed=None, ) - any_congestion = run_routing_iteration(pf, state, iteration=0) + outcomes = run_routing_iteration(pf, state, iteration=0) - assert any_congestion is True + 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( @@ -207,6 +217,69 @@ def test_run_routing_iteration_timeout_finalizes_tree( 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) diff --git a/inire/tests/test_visibility.py b/inire/tests/test_visibility.py new file mode 100644 index 0000000..e175d8c --- /dev/null +++ b/inire/tests/test_visibility.py @@ -0,0 +1,20 @@ +from shapely.geometry import box + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.visibility import VisibilityManager + + +def test_point_visibility_cache_respects_max_distance() -> None: + engine = CollisionEngine(clearance=0.0) + engine.add_static_obstacle(box(10, 20, 20, 30)) + engine.add_static_obstacle(box(100, 20, 110, 30)) + visibility = VisibilityManager(engine) + origin = Port(0, 0, 0) + + near_corners = visibility.get_point_visibility(origin, max_dist=40.0) + far_corners = visibility.get_point_visibility(origin, max_dist=200.0) + + assert len(near_corners) == 3 + assert len(far_corners) > len(near_corners) + assert any(corner[0] >= 100.0 for corner in far_corners) diff --git a/inire/tests/test_visualization.py b/inire/tests/test_visualization.py new file mode 100644 index 0000000..c1a4735 --- /dev/null +++ b/inire/tests/test_visualization.py @@ -0,0 +1,26 @@ +import matplotlib + +matplotlib.use("Agg") + +from inire.geometry.components import Bend90 +from inire.geometry.primitives import Port +from inire.router.pathfinder import RoutingResult +from inire.utils.visualization import plot_routing_results + + +def test_plot_routing_results_respects_show_actual() -> None: + bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox") + result = RoutingResult("net", [bend], True, 0, reached_target=True) + + fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True) + fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False) + + actual_line_points = max(len(line.get_xdata()) for line in ax_actual.lines) + proxy_line_points = max(len(line.get_xdata()) for line in ax_proxy.lines) + + assert actual_line_points > proxy_line_points + assert ax_actual.get_title().endswith("Actual Geometry)") + assert ax_proxy.get_title().endswith("(Proxy Geometry)") + + fig_actual.clf() + fig_proxy.clf() diff --git a/inire/utils/validation.py b/inire/utils/validation.py index a044854..0894566 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any import numpy -from inire.constants import TOLERANCE_LINEAR +from inire.geometry.collision import CollisionEngine if TYPE_CHECKING: from shapely.geometry import Polygon @@ -31,20 +31,19 @@ def validate_routing_result( Returns: A dictionary with validation results. """ - _ = expected_start if not result.path: return {"is_valid": False, "reason": "No path found"} - obstacle_collision_geoms = [] - self_intersection_geoms = [] connectivity_errors = [] - # 1. Connectivity Check - total_length = 0.0 - for comp in result.path: - total_length += comp.length + if expected_start: + first_port = result.path[0].start_port + dist_to_start = numpy.sqrt(((first_port[:2] - expected_start[:2])**2).sum()) + if dist_to_start > 0.005: + connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") + if abs(first_port[2] - expected_start[2]) > 0.1: + connectivity_errors.append(f"Initial port orientation mismatch: {first_port[2]} vs {expected_start[2]}") - # Boundary check if expected_end: last_port = result.path[-1].end_port dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum()) @@ -53,52 +52,29 @@ def validate_routing_result( if abs(last_port[2] - expected_end[2]) > 0.1: connectivity_errors.append(f"Final port orientation mismatch: {last_port[2]} vs {expected_end[2]}") - # 2. Geometry Buffering - dilation_half = clearance / 2.0 - dilation_full = clearance + engine = CollisionEngine(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + report = engine.verify_path_report("validation", result.path) - dilated_for_self = [] - - for comp in result.path: - for poly in comp.geometry: - # Check against obstacles - d_full = poly.buffer(dilation_full) - for obs in static_obstacles: - if d_full.intersects(obs): - intersection = d_full.intersection(obs) - if intersection.area > 1e-9: - obstacle_collision_geoms.append(intersection) - - # Save for self-intersection check - dilated_for_self.append(poly.buffer(dilation_half)) - - # 3. Self-intersection - for i, seg_i in enumerate(dilated_for_self): - for j, seg_j in enumerate(dilated_for_self): - if j > i + 1 and seg_i.intersects(seg_j): # Non-adjacent - overlap = seg_i.intersection(seg_j) - if overlap.area > TOLERANCE_LINEAR: - self_intersection_geoms.append((i, j, overlap)) - - is_valid = (len(obstacle_collision_geoms) == 0 and - len(self_intersection_geoms) == 0 and - len(connectivity_errors) == 0) + is_valid = report.is_valid and len(connectivity_errors) == 0 reasons = [] - if obstacle_collision_geoms: - reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.") - if self_intersection_geoms: - # report which indices - idx_str = ", ".join([f"{i}-{j}" for i, j, _ in self_intersection_geoms[:5]]) - reasons.append(f"Found {len(self_intersection_geoms)} self-intersections (e.g. {idx_str}).") + if report.static_collision_count: + reasons.append(f"Found {report.static_collision_count} obstacle collisions.") + if report.dynamic_collision_count: + reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") + if report.self_collision_count: + reasons.append(f"Found {report.self_collision_count} self-intersections.") if connectivity_errors: reasons.extend(connectivity_errors) return { "is_valid": is_valid, "reason": " ".join(reasons), - "obstacle_collisions": obstacle_collision_geoms, - "self_intersections": self_intersection_geoms, - "total_length": total_length, + "obstacle_collisions": report.static_collision_count, + "dynamic_collisions": report.dynamic_collision_count, + "self_intersections": report.self_collision_count, + "total_length": report.total_length, "connectivity_ok": len(connectivity_errors) == 0, } diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 8e2a0d8..522fd7f 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from matplotlib.figure import Figure from inire.geometry.primitives import Port + from inire.router.danger_map import DangerMap from inire.router.pathfinder import RoutingResult @@ -68,7 +69,11 @@ def plot_routing_results( # 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication) # Use comp.actual_geometry if it exists (should be the arc) - actual_geoms_to_plot = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry + actual_geoms_to_plot = ( + comp.actual_geometry + if show_actual and comp.actual_geometry is not None + else comp.geometry + ) for poly in actual_geoms_to_plot: if isinstance(poly, MultiPolygon): @@ -97,7 +102,7 @@ def plot_routing_results( # 4. Plot main arrows for netlist ports if netlist: - for net_id, (start_p, target_p) in netlist.items(): + for _net_id, (start_p, target_p) in netlist.items(): for p in [start_p, target_p]: rad = numpy.radians(p[2]) ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black", @@ -106,7 +111,10 @@ def plot_routing_results( ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3]) ax.set_aspect("equal") - ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") + if show_actual: + ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") + else: + ax.set_title("Inire Routing Results (Proxy Geometry)") # Legend handling for many nets if len(results) < 25: @@ -181,7 +189,7 @@ def plot_expanded_nodes( if not nodes: return fig, ax - x, y, _ = zip(*nodes) + x, y, _ = zip(*nodes, strict=False) ax.scatter(x, y, s=1, c=color, alpha=alpha, zorder=0) return fig, ax @@ -212,7 +220,7 @@ def plot_expansion_density( ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes) return fig, ax - x, y, _ = zip(*nodes) + x, y, _ = zip(*nodes, strict=False) # Create 2D histogram h, xedges, yedges = numpy.histogram2d( From 941d3e01df0675c071e5bc4d80e0d26104e6999c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 15:32:29 -0700 Subject: [PATCH 4/6] rework structure of everything --- DOCS.md | 210 ++++-- README.md | 54 +- examples/01_simple_route.py | 47 +- examples/02_congestion_resolution.py | 48 +- examples/03_locked_paths.py | 53 +- examples/04_sbends_and_radii.py | 66 +- examples/05_orientation_stress.py | 42 +- examples/06_bend_collision_models.py | 60 +- examples/07_large_scale_routing.py | 153 ++-- examples/08_custom_bend_geometry.py | 74 +- examples/09_unroutable_best_effort.py | 64 +- inire/__init__.py | 35 + inire/api.py | 97 +++ inire/constants.py | 2 +- inire/geometry/collision.py | 548 +++++++++++--- inire/geometry/collision_query_checker.py | 97 --- inire/geometry/component_overlap.py | 6 +- inire/geometry/components.py | 208 +++--- inire/geometry/dynamic_congestion_checker.py | 117 --- inire/geometry/dynamic_path_index.py | 60 +- inire/geometry/index_helpers.py | 48 ++ inire/geometry/path_verifier.py | 112 --- inire/geometry/primitives.py | 149 +--- inire/geometry/ray_caster.py | 112 --- inire/geometry/static_move_checker.py | 146 ---- inire/geometry/static_obstacle_index.py | 37 +- inire/model.py | 145 ++++ inire/router/_astar_admission.py | 210 ++++++ inire/router/_astar_moves.py | 302 ++++++++ inire/router/_astar_types.py | 152 ++++ inire/router/_router.py | 362 ++++++++++ inire/router/_search.py | 112 +++ inire/router/astar.py | 721 ------------------- inire/router/config.py | 40 - inire/router/cost.py | 205 ++++-- inire/router/danger_map.py | 29 +- inire/router/path_state.py | 56 -- inire/router/pathfinder.py | 310 -------- inire/router/refiner.py | 62 +- inire/router/results.py | 68 ++ inire/router/session.py | 151 ---- inire/router/visibility.py | 11 +- inire/tests/benchmark_scaling.py | 29 +- inire/tests/example_scenarios.py | 311 ++++---- inire/tests/support.py | 162 +++++ inire/tests/test_api.py | 108 +++ inire/tests/test_astar.py | 343 +++------ inire/tests/test_clearance_precision.py | 43 +- inire/tests/test_collision.py | 63 +- inire/tests/test_components.py | 75 +- inire/tests/test_congestion.py | 32 +- inire/tests/test_cost.py | 20 +- inire/tests/test_example_performance.py | 4 +- inire/tests/test_failed_net_congestion.py | 24 +- inire/tests/test_fuzz.py | 9 +- inire/tests/test_pathfinder.py | 502 +++++++------ inire/tests/test_primitives.py | 21 +- inire/tests/test_refinements.py | 40 +- inire/tests/test_variable_grid.py | 15 +- inire/tests/test_visibility.py | 4 +- inire/tests/test_visualization.py | 4 +- inire/utils/validation.py | 18 +- inire/utils/visualization.py | 22 +- pyproject.toml | 2 +- 64 files changed, 3846 insertions(+), 3586 deletions(-) create mode 100644 inire/api.py delete mode 100644 inire/geometry/collision_query_checker.py delete mode 100644 inire/geometry/dynamic_congestion_checker.py create mode 100644 inire/geometry/index_helpers.py delete mode 100644 inire/geometry/path_verifier.py delete mode 100644 inire/geometry/ray_caster.py delete mode 100644 inire/geometry/static_move_checker.py create mode 100644 inire/model.py create mode 100644 inire/router/_astar_admission.py create mode 100644 inire/router/_astar_moves.py create mode 100644 inire/router/_astar_types.py create mode 100644 inire/router/_router.py create mode 100644 inire/router/_search.py delete mode 100644 inire/router/astar.py delete mode 100644 inire/router/config.py delete mode 100644 inire/router/path_state.py delete mode 100644 inire/router/pathfinder.py create mode 100644 inire/router/results.py delete mode 100644 inire/router/session.py create mode 100644 inire/tests/support.py create mode 100644 inire/tests/test_api.py diff --git a/DOCS.md b/DOCS.md index 60ebdc6..fe4c73d 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,107 +1,159 @@ # Inire Configuration & API Documentation -This document describes the user-tunable parameters for the `inire` auto-router. +This document describes the current public API for `inire`. -## 1. AStarContext Parameters +## 1. Primary API -The `AStarContext` stores the configuration and persistent state for the A* search. It is initialized once and passed to `route_astar` or the `PathFinder`. +### `RoutingProblem` -| Parameter | Type | Default | Description | -| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | -| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. | -| `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. | -| `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). | -| `sbend_radii` | `list[float]` | `[5.0, 10.0, 50.0, 100.0]` | Available radii for S-bends (µm). | -| `sbend_offsets` | `list[float] \| None` | `None` (Auto) | Lateral offsets for parametric S-bends. | -| `bend_penalty` | `float` | 250.0 | Flat cost added for every 90-degree bend. | -| `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. | -| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"` (an 8-point conservative arc proxy). | -| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide for clipped models. | -| `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. | +`RoutingProblem` describes the physical routing problem: -## 2. AStarMetrics +- `bounds` +- `nets` +- `static_obstacles` +- `locked_routes` +- `clearance` +- `max_net_width` +- `safety_zone_radius` -The `AStarMetrics` object collects performance data during the search. +### `RoutingOptions` -| Property | Type | Description | -| :--------------------- | :---- | :---------------------------------------------------- | -| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. | -| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. | -| `max_depth_reached` | `int` | Deepest point in the search tree reached. | +`RoutingOptions` groups all expert controls for the routing engine: ---- +- `search` +- `objective` +- `congestion` +- `refinement` +- `diagnostics` -## 3. CostEvaluator Parameters +Route a problem with: -The `CostEvaluator` defines the "goodness" of a path. +```python +run = route(problem, options=options) +``` -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- | -| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | -| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. | -| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | +If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. ---- +### Incremental routing with `LockedRoute` -## 3. PathFinder Parameters +For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem: -The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. +```python +run_a = route(problem_a) +problem_b = RoutingProblem( + bounds=problem_a.bounds, + nets=(...), + locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()}, +) +run_b = route(problem_b) +``` -| Parameter | Type | Default | Description | -| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | -| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | -| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| -| `refine_paths` | `bool` | `True` | Run the post-route path simplifier that removes unnecessary bend ladders when it finds a valid lower-cost replacement. | +`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. ---- +## 2. Search Options -## 4. CollisionEngine Parameters +`RoutingOptions.search` is a `SearchOptions` object. -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ | -| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | -| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. | +| Field | Default | Description | +| :-- | :-- | :-- | +| `node_limit` | `1_000_000` | Maximum number of states to explore per net. | +| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. | +| `min_straight_length` | `5.0` | Minimum length of a single straight segment. | +| `greedy_h_weight` | `1.5` | Heuristic weight. `1.0` is optimal but slower. | +| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. | +| `sbend_radii` | `(10.0,)` | Available radii for S-bends. | +| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. | +| `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. | +| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | +| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. | ---- +## 3. Objective Weights -## 4. Physical Units & Precision -- **Coordinates**: Micrometers (µm). -- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. -- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. -- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost. +`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`. ---- +| Field | Default | Description | +| :-- | :-- | :-- | +| `unit_length_cost` | `1.0` | Cost per unit length. | +| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. | +| `sbend_penalty` | `500.0` | Flat S-bend penalty. | +| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. | +| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. | -## 5. Best Practices & Tuning Advice +## 4. Congestion Options -### Speed vs. Optimality -The `greedy_h_weight` is your primary lever for search performance. -- **`1.0`**: Dijkstra-like behavior. Guarantees the shortest path but is very slow. -- **`1.1` to `1.2`**: Recommended range. Balances wire length with fast convergence. -- **`> 1.5`**: Extremely fast "greedy" search. May produce zig-zags or suboptimal detours. +`RoutingOptions.congestion` is a `CongestionOptions` object. -### Avoiding "Zig-Zags" -If the router produces many small bends instead of a long straight line: -1. Increase `bend_penalty` (e.g., set to `100.0` or higher). -2. Increase available `bend_radii` if larger turns are physically acceptable. -3. Decrease `greedy_h_weight` closer to `1.0`. +| Field | Default | Description | +| :-- | :-- | :-- | +| `max_iterations` | `10` | Maximum rip-up and reroute iterations. | +| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. | +| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. | +| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. | +| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. | +| `shuffle_nets` | `False` | Shuffle routing order between iterations. | +| `sort_nets` | `None` | Optional deterministic routing order. | +| `seed` | `None` | RNG seed for shuffled routing order. | -### Visibility Guidance -The router can bias straight stop points using static obstacle corners. -- **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space. -- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner. -- **`"off"`**: Disables visibility-derived straight candidates entirely. -The arbitrary-point visibility scan remains available for diagnostics, but the router hot path intentionally uses the exact-corner / tangent-corner forms only. +## 5. Refinement Options -### Handling Congestion -In multi-net designs, if nets are overlapping: -1. Increase `congestion_penalty` in `CostEvaluator`. -2. Increase `max_iterations` in `PathFinder`. -3. If a solution is still not found, check if the `clearance` is physically possible given the design's narrowest bottlenecks. +`RoutingOptions.refinement` is a `RefinementOptions` object. -### S-Bend Usage -Parametric S-bends bridge lateral gaps without changing the waveguide's orientation. -- **Automatic Selection**: If `sbend_offsets` is set to `None` (the default), the router automatically chooses from a set of "natural" offsets (Fibonacci-aligned grid steps) and the offset needed to hit the target. -- **Specific Offsets**: To use specific offsets (e.g., 5.86µm for a 45° switchover), provide them in the `sbend_offsets` list. The router will prioritize these but will still try to align with the target if possible. -- **Constraints**: S-bends are only used for offsets $O < 2R$. For larger shifts, the router naturally combines two 90° bends and a straight segment. +| Field | Default | Description | +| :-- | :-- | :-- | +| `enabled` | `True` | Enable post-route refinement. | +| `objective` | `None` | Optional override objective for refinement. `None` reuses the search objective. | + +## 6. Diagnostics Options + +`RoutingOptions.diagnostics` is a `DiagnosticsOptions` object. + +| Field | Default | Description | +| :-- | :-- | :-- | +| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. | + +## 7. RouteMetrics + +`RoutingRunResult.metrics` is an immutable per-run snapshot. + +| Field | Type | Description | +| :-- | :-- | :-- | +| `nodes_expanded` | `int` | Total nodes expanded during the run. | +| `moves_generated` | `int` | Total candidate moves generated during the run. | +| `moves_added` | `int` | Total candidate moves admitted to the open set during the run. | +| `pruned_closed_set` | `int` | Total moves pruned because the state was already closed at lower cost. | +| `pruned_hard_collision` | `int` | Total moves pruned by hard collision checks. | +| `pruned_cost` | `int` | Total moves pruned by cost ceilings or invalid costs. | + +## 8. Internal Modules + +Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`. + +## 9. Tuning Notes + +### Speed vs. optimality + +- Lower `search.greedy_h_weight` toward `1.0` for better optimality. +- Raise `search.greedy_h_weight` for faster, greedier routing. + +### Congestion handling + +- Increase `congestion.base_penalty` to separate nets more aggressively in the first iteration. +- Increase `congestion.max_iterations` if congestion needs more reroute passes. +- Increase `congestion.multiplier` if later iterations need to escalate more quickly. + +### Bend-heavy routes + +- Increase `objective.bend_penalty` to discourage ladders of small bends. +- Increase available `search.bend_radii` when larger turns are physically acceptable. + +### Visibility guidance + +- `"tangent_corner"` is the default and best general-purpose setting in obstacle-dense layouts. +- `"exact_corner"` is more conservative. +- `"off"` disables visibility-derived straight candidates. + +### S-bends + +- Leave `search.sbend_offsets=None` to let the router derive natural offsets automatically. +- Provide explicit `search.sbend_offsets` for known process-preferred offsets. +- S-bends are only used for offsets smaller than `2R`. diff --git a/README.md b/README.md index b300c77..0da7268 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # inire: Auto-Routing for Photonic and RF Integrated Circuits -`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) to route multiple nets while maintaining strict geometric fidelity and clearance. +`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance. ## Key Features @@ -9,7 +9,7 @@ * **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths. * **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid. * **Safety & Proximity**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk. -* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions. +* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs. ## Installation @@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib ## Quick Start ```python -from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine -from inire.router.danger_map import DangerMap -from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route -# 1. Setup Environment -engine = CollisionEngine(clearance=2.0) -danger_map = DangerMap(bounds=(0, 0, 1000, 1000)) -danger_map.precompute([]) # Add polygons here for obstacles - -# 2. Configure Router -evaluator = CostEvaluator( - collision_engine=engine, - danger_map=danger_map, - greedy_h_weight=1.2 +problem = RoutingProblem( + bounds=(0, 0, 1000, 1000), + nets=( + NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0), + ), ) -context = AStarContext( - cost_evaluator=evaluator, - bend_penalty=10.0 +options = RoutingOptions( + search=SearchOptions( + bend_radii=(50.0, 100.0), + greedy_h_weight=1.2, + ), + objective=ObjectiveWeights( + bend_penalty=10.0, + ), ) -pf = PathFinder(context) -# 3. Define Netlist -netlist = { - "net1": (Port(0, 0, 0), Port(100, 50, 0)), -} +run = route(problem, options=options) -# 4. Route -results = pf.route_all(netlist, {"net1": 2.0}) - -if results["net1"].is_valid: +if run.results_by_net["net1"].is_valid: print("Successfully routed net1!") ``` +For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`. + ## Usage Examples For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**. @@ -82,11 +72,11 @@ Full documentation for all user-tunable parameters, cost functions, and collisio 2. **90° Bends**: Fixed-radius PDK cells. 3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$). -For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. +For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. ## Configuration -`inire` is highly tunable. Every major component (Router, CostEvaluator, PathFinder) accepts explicit named arguments in its constructor to control expansion rules, cost weights, and convergence limits. See `DOCS.md` for a full parameter reference. +`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference. ## License diff --git a/examples/01_simple_route.py b/examples/01_simple_route.py index 96fc4f9..2f43065 100644 --- a/examples/01_simple_route.py +++ b/examples/01_simple_route.py @@ -1,54 +1,29 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 01: Simple Route...") - # 1. Setup Environment - # We define a 100um x 100um routing area bounds = (0, 0, 100, 100) - - # Clearance of 2.0um between waveguides - engine = CollisionEngine(clearance=2.0) - - # Precompute DangerMap for heuristic speedup - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) # No obstacles yet - - # 2. Configure Router - evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # Start at (10, 50) pointing East (0 deg) - # Target at (90, 50) pointing East (0 deg) netlist = { "net1": (Port(10, 50, 0), Port(90, 50, 0)), } - net_widths = {"net1": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("net1", *netlist["net1"], width=2.0),), + ) + options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) - # 4. Route - results = pf.route_all(netlist, net_widths) - - # 5. Check Results - res = results["net1"] - if res.is_valid: + run = route(problem, options=options) + result = run.results_by_net["net1"] + if result.is_valid: print("Success! Route found.") - print(f"Path collisions: {res.collisions}") + print(f"Path collisions: {result.collisions}") else: print("Failed to find route.") - # 6. Visualize - # plot_routing_results takes a dict of RoutingResult objects - fig, ax = plot_routing_results(results, [], bounds) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/01_simple_route.png") print("Saved plot to examples/01_simple_route.png") diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index ffe8343..9d003bc 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -1,49 +1,41 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 02: Congestion Resolution (Triple Crossing)...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # Configure a router with high congestion penalties - 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]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics, base_congestion_penalty=1000.0) - - # 2. Define Netlist - # Three nets that must cross each other in a small area 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 = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + sbend_radii=(10.0,), + greedy_h_weight=1.5, + ), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + congestion=CongestionOptions(base_penalty=1000.0), + ) - # 3. Route - # PathFinder uses Negotiated Congestion to resolve overlaps iteratively - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - all_valid = all(res.is_valid for res in results.values()) + run = route(problem, options=options) + all_valid = all(result.is_valid for result in run.results_by_net.values()) if all_valid: print("Success! Congestion resolved for all nets.") else: print("Failed to resolve congestion for some nets.") - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/02_congestion_resolution.png") print("Saved plot to examples/02_congestion_resolution.png") diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 642c6f3..124a172 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -1,42 +1,37 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: - print("Running Example 03: Locked Paths...") + print("Running Example 03: Locked Routes...") - # 1. Setup Environment bounds = (0, -50, 100, 50) - 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]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Route Net A and 'Lock' it - # Net A is a straight path blocking the direct route for Net B + options = RoutingOptions( + search=SearchOptions(bend_radii=(10.0,)), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + ) print("Routing initial net...") - netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) - - # Locking prevents Net A from being removed or rerouted during NC iterations - engine.lock_net("netA") - print("Initial net locked as static obstacle.") + results_a = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), + ), + options=options, + ).results_by_net - # 3. Route Net B (forced to detour) print("Routing detour net around locked path...") - netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + locked_routes={"netA": results_a["netA"].as_locked_route()}, + ), + options=options, + ).results_by_net - # 4. Visualize results = {**results_a, **results_b} fig, ax = plot_routing_results(results, [], bounds) fig.savefig("examples/03_locked_paths.png") diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index a88159a..42eac9c 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,60 +1,38 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 04: S-Bends and Multiple Radii...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # 2. Configure Router - evaluator = CostEvaluator( - engine, - danger_map, - unit_length_cost=1.0, - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - context = AStarContext( - evaluator, - node_limit=50000, - bend_radii=[10.0, 30.0], - sbend_offsets=[5.0], # Use a simpler offset - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # start (10, 50), target (60, 55) -> 5um offset netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } - net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=50000, + bend_radii=(10.0, 30.0), + sbend_offsets=(5.0,), + ), + objective=ObjectiveWeights( + unit_length_cost=1.0, + bend_penalty=10.0, + sbend_penalty=20.0, + ), + ) - # 4. Route - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f"{net_id}: {status}, collisions={result.collisions}") - # 5. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f"{nid}: {status}, collisions={res.collisions}") - - # 6. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/04_sbends_and_radii.png") print("Saved plot to examples/04_sbends_and_radii.png") diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index 4e434c8..eab3c0e 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -1,46 +1,32 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 05: Orientation Stress Test...") - # 1. Setup Environment bounds = (0, 0, 200, 200) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0) - context = AStarContext(evaluator, bend_radii=[20.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Define Netlist - # Challenging orientation combinations netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions(bend_radii=(20.0,)), + objective=ObjectiveWeights(bend_penalty=50.0), + ) - # 3. Route print("Routing complex orientation nets...") - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f" {net_id}: {status}") - # 4. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f" {nid}: {status}") - - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/05_orientation_stress.png") print("Saved plot to examples/05_orientation_stress.png") diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index e135118..0df49ea 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -1,11 +1,7 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route 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.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results @@ -15,34 +11,30 @@ def _route_scenario( bend_collision_type: str, netlist: dict[str, tuple[Port, Port]], widths: dict[str, float], - *, - bend_clip_margin: float = 10.0, -) -> dict[str, object]: - engine = CollisionEngine(clearance=2.0) - for obstacle in obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - context = AStarContext( - evaluator, - bend_radii=[10.0], - bend_collision_type=bend_collision_type, - bend_clip_margin=bend_clip_margin, +) -> dict[str, RoutingResult]: + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=widths[net_id]) for net_id, (start, target) in netlist.items()), + static_obstacles=tuple(obstacles), ) - return PathFinder(context, use_tiered_strategy=False).route_all(netlist, widths) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type=bend_collision_type, + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(use_tiered_strategy=False), + ) + return route(problem, options=options).results_by_net def main() -> None: print("Running Example 06: Bend Collision Models...") - # 1. Setup Environment - # Give room for 10um bends near the edges bounds = (-20, -20, 170, 170) - - # Create three scenarios with identical obstacles - # We'll space them out vertically obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]) obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]) obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) @@ -52,29 +44,17 @@ def main() -> None: netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} - # 2. Route each scenario print("Routing Scenario 1 (Arc)...") res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0}) - print("Routing Scenario 2 (BBox)...") res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0}) - print("Routing Scenario 3 (Clipped BBox)...") - res_clipped = _route_scenario( - bounds, - obstacles, - "clipped_bbox", - netlist_clipped, - {"clipped_model": 2.0}, - bend_clip_margin=1.0, - ) + res_clipped = _route_scenario(bounds, obstacles, "clipped_bbox", netlist_clipped, {"clipped_model": 2.0}) - # 3. Combine results for visualization all_results = {**res_arc, **res_bbox, **res_clipped} all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} - # 4. Visualize - fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) fig.savefig("examples/06_bend_collision_models.png") print("Saved plot to examples/06_bend_collision_models.png") diff --git a/examples/07_large_scale_routing.py b/examples/07_large_scale_routing.py index ef92ea2..ea69812 100644 --- a/examples/07_large_scale_routing.py +++ b/examples/07_large_scale_routing.py @@ -1,108 +1,111 @@ -import numpy as np import time -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results, plot_danger_map, plot_expanded_nodes, plot_expansion_density + from shapely.geometry import box +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + Port, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, + route, +) +from inire.utils.visualization import plot_expanded_nodes, plot_routing_results + + def main() -> None: print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...") - # 1. Setup Environment bounds = (0, 0, 1000, 1000) - engine = CollisionEngine(clearance=6.0) - - # Bottleneck at x=500, 200um gap obstacles = [ box(450, 0, 550, 400), box(450, 600, 550, 1000), ] - for obs in obstacles: - engine.add_static_obstacle(obs) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, unit_length_cost=0.1, bend_penalty=100.0, sbend_penalty=400.0, congestion_penalty=100.0) - - context = AStarContext(evaluator, node_limit=2000000, bend_radii=[50.0], sbend_radii=[50.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics, max_iterations=15, base_congestion_penalty=100.0, congestion_multiplier=1.4) - - # 2. Define Netlist - netlist = {} num_nets = 10 start_x = 50 start_y_base = 500 - (num_nets * 10.0) / 2.0 - end_x = 950 end_y_base = 100 end_y_pitch = 800.0 / (num_nets - 1) - for i in range(num_nets): - sy = int(round(start_y_base + i * 10.0)) - ey = int(round(end_y_base + i * end_y_pitch)) - netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0)) + netlist: dict[str, tuple[Port, Port]] = {} + for index in range(num_nets): + start_y = int(round(start_y_base + index * 10.0)) + end_y = int(round(end_y_base + index * end_y_pitch)) + netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0)) - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + static_obstacles=tuple(obstacles), + clearance=6.0, + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=2_000_000, + bend_radii=(50.0,), + sbend_radii=(50.0,), + greedy_h_weight=1.5, + ), + objective=ObjectiveWeights( + unit_length_cost=0.1, + bend_penalty=100.0, + sbend_penalty=400.0, + ), + congestion=CongestionOptions( + max_iterations=15, + base_penalty=100.0, + multiplier=1.4, + shuffle_nets=True, + seed=42, + ), + diagnostics=DiagnosticsOptions(capture_expanded=True), + ) + + iteration_stats: list[dict[str, int]] = [] + + def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None: + successes = sum(1 for result in current_results.values() if result.is_valid) + total_collisions = sum(result.collisions for result in current_results.values()) + print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") + iteration_stats.append( + { + "Iteration": iteration, + "Success": successes, + "Congestion": total_collisions, + } + ) - # 3. Route print(f"Routing {len(netlist)} nets through 200um bottleneck...") + start_time = time.perf_counter() + run = route(problem, options=options, iteration_callback=iteration_callback) + end_time = time.perf_counter() - iteration_stats = [] - - def iteration_callback(idx, current_results): - successes = sum(1 for r in current_results.values() if r.is_valid) - total_collisions = sum(r.collisions for r in current_results.values()) - total_nodes = metrics.nodes_expanded - - print(f" Iteration {idx} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") - - # Adaptive Greediness: Decay from 1.5 to 1.1 over 10 iterations - new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) - evaluator.greedy_h_weight = new_greedy - print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}") - - iteration_stats.append({ - 'Iteration': idx, - 'Success': successes, - 'Congestion': total_collisions, - 'Nodes': total_nodes - }) - metrics.reset_per_route() - - t0 = time.perf_counter() - results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42) - t1 = time.perf_counter() - - print(f"Routing took {t1-t0:.4f}s") - - # 4. Check Results + print(f"Routing took {end_time - start_time:.4f}s") print("\n--- Iteration Summary ---") - print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}") - print("-" * 40) - for s in iteration_stats: - print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}") + print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8}") + print("-" * 30) + for stats in iteration_stats: + print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8}") - success_count = sum(1 for res in results.values() if res.is_valid) + success_count = sum(1 for result in run.results_by_net.values() if result.is_valid) print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.") - - for nid, res in results.items(): - if not res.is_valid: - print(f" FAILED: {nid}, collisions={res.collisions}") + for net_id, result in run.results_by_net.items(): + if not result.is_valid: + print(f" FAILED: {net_id}, collisions={result.collisions}") else: - print(f" {nid}: SUCCESS") - - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) - plot_danger_map(danger_map, ax=ax) + print(f" {net_id}: SUCCESS") + fig, ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist=netlist) + plot_expanded_nodes(list(run.expanded_nodes), ax=ax) fig.savefig("examples/07_large_scale_routing.png") print("Saved plot to examples/07_large_scale_routing.png") + if __name__ == "__main__": main() diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index faff701..5acd82e 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,59 +1,61 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results -def _route_with_context( - context: AStarContext, - metrics: AStarMetrics, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], -) -> dict[str, object]: - return PathFinder(context, metrics, use_tiered_strategy=False).route_all(netlist, net_widths) +def _run_request( + bounds: tuple[float, float, float, float], + bend_collision_type: object, + net_id: str, + start: Port, + target: Port, +) -> dict[str, RoutingResult]: + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec(net_id, start, target, width=2.0),), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type=bend_collision_type, + sbend_radii=(), + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(use_tiered_strategy=False), + ) + return route(problem, options=options).results_by_net def main() -> None: print("Running Example 08: Custom Bend Geometry...") - # 1. Setup Environment bounds = (0, 0, 150, 150) + start = Port(20, 20, 0) + target = Port(100, 100, 90) - # 2. Define Netlist - netlist = { - "custom_bend": (Port(20, 20, 0), Port(100, 100, 90)), - } - net_widths = {"custom_bend": 2.0} - - def build_context(bend_collision_type: object = "arc") -> tuple[AStarContext, AStarMetrics]: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - return AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=bend_collision_type, sbend_radii=[]), AStarMetrics() - - # 3. Route with standard arc first print("Routing with standard arc...") - context_std, metrics_std = build_context() - results_std = _route_with_context(context_std, metrics_std, netlist, net_widths) + results_std = _run_request(bounds, "arc", "custom_bend", start, target) - # 4. Define a custom Manhattan 90-degree bend proxy in bend-local coordinates. - # The polygon origin is the bend center. It is mirrored for CW bends and - # rotated with the bend orientation before being translated into place. custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) print("Routing with custom bend geometry...") - context_custom, metrics_custom = build_context(custom_poly) - results_custom = _route_with_context(context_custom, metrics_custom, {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}) + results_custom = _run_request(bounds, custom_poly, "custom_model", start, target) - # 5. Visualize all_results = {**results_std, **results_custom} - fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results( + all_results, + [], + bounds, + netlist={ + "custom_bend": (start, target), + "custom_model": (start, target), + }, + ) fig.savefig("examples/08_custom_bend_geometry.png") print("Saved plot to examples/08_custom_bend_geometry.png") diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 659a16c..3df1caf 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -1,58 +1,46 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results from shapely.geometry import box +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +from inire.utils.visualization import plot_routing_results + + def main() -> None: print("Running Example 09: Best-Effort Under Tight Search Budget...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - - # A small obstacle cluster keeps the partial route visually interesting. obstacles = [ box(35, 35, 45, 65), box(55, 35, 65, 65), ] - for obs in obstacles: - engine.add_static_obstacle(obs) + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),), + static_obstacles=tuple(obstacles), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=3, + bend_radii=(10.0,), + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(warm_start=None), + ) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - # Keep the search budget intentionally tiny so the router returns a partial path. - context = AStarContext(evaluator, node_limit=3, bend_radii=[10.0]) - metrics = AStarMetrics() - - pf = PathFinder(context, metrics, warm_start=None) - - # 2. Define Netlist: reaching the target requires additional turns the search budget cannot afford. - netlist = { - "budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180)), - } - net_widths = {"budget_limited_net": 2.0} - - # 3. Route print("Routing with a deliberately tiny node budget (should return a partial path)...") - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - res = results["budget_limited_net"] - if not res.reached_target: - print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.") + run = route(problem, options=options) + result = run.results_by_net["budget_limited_net"] + if not result.reached_target: + print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.") else: print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.") - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}) fig.savefig("examples/09_unroutable_best_effort.png") print("Saved plot to examples/09_unroutable_best_effort.png") + if __name__ == "__main__": main() diff --git a/inire/__init__.py b/inire/__init__.py index 5fb5ffb..7529ac7 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,8 +1,43 @@ """ inire Wave-router """ +from .api import ( + CongestionOptions as CongestionOptions, + DiagnosticsOptions as DiagnosticsOptions, + LockedRoute as LockedRoute, + NetSpec as NetSpec, + ObjectiveWeights as ObjectiveWeights, + RefinementOptions as RefinementOptions, + RoutingOptions as RoutingOptions, + RoutingProblem as RoutingProblem, + RoutingRunResult as RoutingRunResult, + SearchOptions as SearchOptions, + route as route, +) # noqa: PLC0414 from .geometry.primitives import Port as Port # noqa: PLC0414 from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 +from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414 __author__ = 'Jan Petykiewicz' __version__ = '0.1' + +__all__ = [ + "Bend90", + "CongestionOptions", + "DiagnosticsOptions", + "LockedRoute", + "NetSpec", + "ObjectiveWeights", + "Port", + "RefinementOptions", + "RoutingOptions", + "RoutingProblem", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", + "RouteMetrics", + "SBend", + "SearchOptions", + "Straight", + "route", +] diff --git a/inire/api.py b/inire/api.py new file mode 100644 index 0000000..9f83f63 --- /dev/null +++ b/inire/api.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.geometry.collision import RoutingWorld +from inire.model import ( + CongestionOptions, + DiagnosticsOptions, + LockedRoute, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingRunResult, + SearchOptions, +) +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.results import RouteMetrics, RoutingReport, RoutingResult + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from shapely.geometry import Polygon + + +__all__ = [ + "CongestionOptions", + "DiagnosticsOptions", + "LockedRoute", + "NetSpec", + "ObjectiveWeights", + "RefinementOptions", + "RouteMetrics", + "RoutingOptions", + "RoutingProblem", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", + "SearchOptions", + "route", +] + + +def _iter_locked_polygons( + locked_routes: dict[str, LockedRoute], +) -> Iterable[Polygon]: + for route in locked_routes.values(): + yield from route.geometry + + +def _build_context(problem: RoutingProblem, options: RoutingOptions) -> AStarContext: + world = RoutingWorld( + clearance=problem.clearance, + max_net_width=problem.max_net_width, + safety_zone_radius=problem.safety_zone_radius, + ) + for obstacle in problem.static_obstacles: + world.add_static_obstacle(obstacle) + for polygon in _iter_locked_polygons(problem.locked_routes): + world.add_static_obstacle(polygon) + + danger_obstacles = list(problem.static_obstacles) + danger_obstacles.extend(_iter_locked_polygons(problem.locked_routes)) + danger_map = DangerMap(bounds=problem.bounds) + danger_map.precompute(danger_obstacles) + + objective = options.objective + evaluator = CostEvaluator( + world, + danger_map, + unit_length_cost=objective.unit_length_cost, + greedy_h_weight=options.search.greedy_h_weight, + bend_penalty=objective.bend_penalty, + sbend_penalty=objective.sbend_penalty, + danger_weight=objective.danger_weight, + ) + return AStarContext(evaluator, problem, options) + + +def route( + problem: RoutingProblem, + *, + options: RoutingOptions | None = None, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, +) -> RoutingRunResult: + resolved_options = RoutingOptions() if options is None else options + finder = PathFinder(_build_context(problem, resolved_options)) + results = finder.route_all(iteration_callback=iteration_callback) + return RoutingRunResult( + results_by_net=results, + metrics=finder.metrics.snapshot(), + expanded_nodes=tuple(finder.accumulated_expanded_nodes), + ) diff --git a/inire/constants.py b/inire/constants.py index cdc2f62..1e0c0be 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -3,7 +3,7 @@ Centralized constants for the inire routing engine. """ # Search Grid Snap (5.0 µm default) -# TODO: Make this configurable in RouterConfig and define tolerances relative to the grid. +# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid. DEFAULT_SEARCH_GRID_SNAP_UM = 5.0 # Tolerances diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 663d919..b8a72fe 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -3,67 +3,69 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal import numpy +from shapely.geometry import LineString, box -from inire.geometry.collision_query_checker import CollisionQueryChecker -from inire.geometry.dynamic_congestion_checker import DynamicCongestionChecker +from inire.geometry.component_overlap import components_overlap from inire.geometry.dynamic_path_index import DynamicPathIndex -from inire.geometry.path_verifier import PathVerificationReport, PathVerifier -from inire.geometry.ray_caster import RayCaster +from inire.geometry.index_helpers import grid_cell_span from inire.geometry.static_obstacle_index import StaticObstacleIndex -from inire.geometry.static_move_checker import StaticMoveChecker +from inire.router.results import RoutingReport if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence from shapely.geometry import Polygon + from shapely.geometry.base import BaseGeometry from shapely.strtree import STRtree from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port -class CollisionEngine: +def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float: + if hasattr(geometry, "geoms"): + return min(_intersection_distance(origin, sub_geometry) for sub_geometry in geometry.geoms) + return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) + + +class RoutingWorld: """ - Manages spatial queries for collision detection with unified dilation logic. + Internal spatial state for collision detection, congestion, and verification. """ + __slots__ = ( - 'clearance', 'max_net_width', 'safety_zone_radius', - 'metrics', 'grid_cell_size', '_inv_grid_cell_size', '_dynamic_bounds_array', - '_path_verifier', '_dynamic_paths', '_static_obstacles', '_ray_caster', '_static_move_checker', - '_dynamic_congestion_checker', '_collision_query_checker', + "clearance", + "max_net_width", + "safety_zone_radius", + "metrics", + "grid_cell_size", + "_dynamic_paths", + "_static_obstacles", ) def __init__( - self, - clearance: float, - max_net_width: float = 2.0, - safety_zone_radius: float = 0.0021, - ) -> None: + self, + clearance: float, + max_net_width: float = 2.0, + safety_zone_radius: float = 0.0021, + ) -> None: self.clearance = clearance self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius self.grid_cell_size = 50.0 - self._inv_grid_cell_size = 1.0 / self.grid_cell_size self._static_obstacles = StaticObstacleIndex(self) - - self._dynamic_bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4) self._dynamic_paths = DynamicPathIndex(self) self.metrics = { - 'static_cache_hits': 0, - 'static_grid_skips': 0, - 'static_tree_queries': 0, - 'static_straight_fast': 0, - 'congestion_grid_skips': 0, - 'congestion_tree_queries': 0, - 'safety_zone_checks': 0 + "static_cache_hits": 0, + "static_grid_skips": 0, + "static_tree_queries": 0, + "static_straight_fast": 0, + "congestion_grid_skips": 0, + "congestion_tree_queries": 0, + "safety_zone_checks": 0, } - self._path_verifier = PathVerifier(self) - self._ray_caster = RayCaster(self) - self._static_move_checker = StaticMoveChecker(self) - self._dynamic_congestion_checker = DynamicCongestionChecker(self) - self._collision_query_checker = CollisionQueryChecker(self) def get_static_version(self) -> int: return self._static_obstacles.version @@ -89,23 +91,22 @@ class CollisionEngine: return self._dynamic_paths.geometries.values() def reset_metrics(self) -> None: - for k in self.metrics: - self.metrics[k] = 0 + for key in self.metrics: + self.metrics[key] = 0 def get_metrics_summary(self) -> str: - m = self.metrics - return (f"Collision Performance: \n" - f" Static: {m['static_tree_queries']} checks\n" - f" Congestion: {m['congestion_tree_queries']} checks\n" - f" Safety Zone: {m['safety_zone_checks']} full intersections performed") + metrics = self.metrics + return ( + "Collision Performance: \n" + f" Static: {metrics['static_tree_queries']} checks\n" + f" Congestion: {metrics['congestion_tree_queries']} checks\n" + f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed" + ) def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: - """ - Remove a static obstacle by ID. - """ self._static_obstacles.remove_obstacle(obj_id) def _invalidate_static_caches(self) -> None: @@ -115,9 +116,6 @@ class CollisionEngine: self._static_obstacles.ensure_tree() def _ensure_net_static_tree(self, net_width: float) -> STRtree: - """ - Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance). - """ return self._static_obstacles.ensure_net_tree(net_width) def _ensure_static_raw_tree(self) -> None: @@ -125,7 +123,6 @@ class CollisionEngine: def _ensure_dynamic_tree(self) -> None: self._dynamic_paths.ensure_tree() - self._dynamic_bounds_array = self._dynamic_paths.bounds_array def _ensure_dynamic_grid(self) -> None: self._dynamic_paths.ensure_grid() @@ -134,45 +131,28 @@ class CollisionEngine: self._dynamic_paths.tree = None self._ensure_dynamic_tree() - def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) def remove_path(self, net_id: str) -> None: self._dynamic_paths.remove_path(net_id) - def lock_net(self, net_id: str) -> None: - """ Convert a routed net into static obstacles. """ - self._dynamic_paths.lock_net(net_id) - - def unlock_net(self, net_id: str) -> None: - self._dynamic_paths.unlock_net(net_id) - def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - return self._static_move_checker.check_move_straight_static(start_port, length, net_width) + self.metrics["static_straight_fast"] += 1 + reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) + return reach < length - 0.001 def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - return self._static_move_checker.is_in_safety_zone_fast(idx, start_port, end_port) - - def check_move_static( - self, - result: ComponentResult, - start_port: Port | None = None, - end_port: Port | None = None, - net_width: float | None = None, - ) -> bool: - return self._static_move_checker.check_move_static( - result, - start_port=start_port, - end_port=end_port, - net_width=net_width, + bounds = self._static_obstacles.bounds_array[idx] + safety_zone = self.safety_zone_radius + if start_port and bounds[0] - safety_zone <= start_port.x <= bounds[2] + safety_zone and bounds[1] - safety_zone <= start_port.y <= bounds[3] + safety_zone: + return True + return bool( + end_port + and bounds[0] - safety_zone <= end_port.x <= bounds[2] + safety_zone + and bounds[1] - safety_zone <= end_port.y <= bounds[3] + safety_zone ) - def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - return self._dynamic_congestion_checker.check_move_congestion(result, net_id) - - def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - return self._dynamic_congestion_checker.check_real_congestion(result, net_id) - def _is_in_safety_zone( self, geometry: Polygon, @@ -180,7 +160,247 @@ class CollisionEngine: start_port: Port | None, end_port: Port | None, ) -> bool: - return self._static_move_checker.is_in_safety_zone(geometry, obj_id, start_port, end_port) + raw_obstacle = self._static_obstacles.geometries[obj_id] + safety_zone = self.safety_zone_radius + + obstacle_bounds = raw_obstacle.bounds + near_start = start_port and ( + obstacle_bounds[0] - safety_zone <= start_port.x <= obstacle_bounds[2] + safety_zone + and obstacle_bounds[1] - safety_zone <= start_port.y <= obstacle_bounds[3] + safety_zone + ) + near_end = end_port and ( + obstacle_bounds[0] - safety_zone <= end_port.x <= obstacle_bounds[2] + safety_zone + and obstacle_bounds[1] - safety_zone <= end_port.y <= obstacle_bounds[3] + safety_zone + ) + + if not near_start and not near_end: + return False + if not geometry.intersects(raw_obstacle): + return False + + self.metrics["safety_zone_checks"] += 1 + intersection = geometry.intersection(raw_obstacle) + if intersection.is_empty: + return False + + ix_bounds = intersection.bounds + if ( + start_port + and near_start + and abs(ix_bounds[0] - start_port.x) < safety_zone + and abs(ix_bounds[1] - start_port.y) < safety_zone + and abs(ix_bounds[2] - start_port.x) < safety_zone + and abs(ix_bounds[3] - start_port.y) < safety_zone + ): + return True + return bool( + end_port + and near_end + and abs(ix_bounds[0] - end_port.x) < safety_zone + and abs(ix_bounds[1] - end_port.y) < safety_zone + and abs(ix_bounds[2] - end_port.x) < safety_zone + and abs(ix_bounds[3] - end_port.y) < safety_zone + ) + + def check_move_static( + self, + result: ComponentResult, + start_port: Port | None = None, + end_port: Port | None = None, + net_width: float | None = None, + ) -> bool: + del net_width + + static_obstacles = self._static_obstacles + if not static_obstacles.dilated: + return False + + self.metrics["static_tree_queries"] += 1 + self._ensure_static_tree() + + hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) + if hits.size == 0: + return False + + static_bounds = static_obstacles.bounds_array + move_poly_bounds = result.dilated_bounds + for hit_idx in hits: + obstacle_bounds = static_bounds[hit_idx] + poly_hits_obstacle_aabb = False + for poly_bounds in move_poly_bounds: + if ( + poly_bounds[0] < obstacle_bounds[2] + and poly_bounds[2] > obstacle_bounds[0] + and poly_bounds[1] < obstacle_bounds[3] + and poly_bounds[3] > obstacle_bounds[1] + ): + poly_hits_obstacle_aabb = True + break + + if not poly_hits_obstacle_aabb: + continue + + obj_id = static_obstacles.obj_ids[hit_idx] + if self._is_in_safety_zone_fast(hit_idx, start_port, end_port): + collision_found = False + for polygon in result.collision_geometry: + if not self._is_in_safety_zone(polygon, obj_id, start_port, end_port): + collision_found = True + break + if collision_found: + return True + continue + + static_obstacle = static_obstacles.dilated[obj_id] + for polygon in result.dilated_collision_geometry: + if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle): + return True + + return False + + def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: + dynamic_paths = self._dynamic_paths + self.metrics["congestion_tree_queries"] += 1 + self._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + total_bounds = result.total_dilated_bounds + dynamic_bounds = dynamic_paths.bounds_array + possible_total = ( + (total_bounds[0] < dynamic_bounds[:, 2]) + & (total_bounds[2] > dynamic_bounds[:, 0]) + & (total_bounds[1] < dynamic_bounds[:, 3]) + & (total_bounds[3] > dynamic_bounds[:, 1]) + ) + + valid_hits_mask = dynamic_paths.net_ids_array != net_id + if not numpy.any(possible_total & valid_hits_mask): + return 0 + + geometries_to_test = result.dilated_collision_geometry + res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects") + if tree_indices.size == 0: + return 0 + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) + if unique_other_nets.size == 0: + return 0 + + tree_geometries = dynamic_paths.tree.geometries + real_hits_count = 0 + for other_net_id in unique_other_nets: + other_mask = hit_net_ids == other_net_id + sub_tree_indices = tree_indices[other_mask] + sub_res_indices = res_indices[other_mask] + + found_real = False + for index in range(len(sub_tree_indices)): + test_geometry = geometries_to_test[sub_res_indices[index]] + tree_geometry = tree_geometries[sub_tree_indices[index]] + if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7: + found_real = True + break + + if found_real: + real_hits_count += 1 + + return real_hits_count + + def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: + dynamic_paths = self._dynamic_paths + if not dynamic_paths.geometries: + return 0 + + total_bounds = result.total_dilated_bounds + self._ensure_dynamic_grid() + dynamic_grid = dynamic_paths.grid + if not dynamic_grid: + return 0 + + gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size) + + if gx_min == gx_max and gy_min == gy_max: + cell = (gx_min, gy_min) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_paths.geometries[obj_id][0] != net_id: + return self._check_real_congestion(result, net_id) + return 0 + + any_possible = False + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + cell = (gx, gy) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_paths.geometries[obj_id][0] != net_id: + any_possible = True + break + if any_possible: + break + if any_possible: + break + + if not any_possible: + return 0 + return self._check_real_congestion(result, net_id) + + def _check_static_collision( + self, + geometry: Polygon, + start_port: Port | None = None, + end_port: Port | None = None, + dilated_geometry: Polygon | None = None, + ) -> bool: + static_obstacles = self._static_obstacles + self._ensure_static_tree() + if static_obstacles.tree is None: + return False + + if dilated_geometry is not None: + test_geometry = dilated_geometry + else: + distance = self.clearance / 2.0 + test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry + + hits = static_obstacles.tree.query(test_geometry, predicate="intersects") + tree_geometries = static_obstacles.tree.geometries + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = static_obstacles.obj_ids[hit_idx] + if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): + continue + return True + return False + + def _check_dynamic_collision( + self, + geometry: Polygon, + net_id: str, + dilated_geometry: Polygon | None = None, + ) -> int: + dynamic_paths = self._dynamic_paths + self._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) + hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") + tree_geometries = dynamic_paths.tree.geometries + hit_net_ids: list[str] = [] + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = dynamic_paths.obj_ids[hit_idx] + other_net_id = dynamic_paths.geometries[obj_id][0] + if other_net_id != net_id: + hit_net_ids.append(other_net_id) + if not hit_net_ids: + return 0 + return len(numpy.unique(hit_net_ids)) def check_collision( self, @@ -193,16 +413,16 @@ class CollisionEngine: bounds: tuple[float, float, float, float] | None = None, net_width: float | None = None, ) -> bool | int: - return self._collision_query_checker.check_collision( - geometry, - net_id, - buffer_mode=buffer_mode, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - bounds=bounds, - net_width=net_width, - ) + del bounds, net_width + + if buffer_mode == "static": + return self._check_static_collision( + geometry, + start_port=start_port, + end_port=end_port, + dilated_geometry=dilated_geometry, + ) + return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) def is_collision( self, @@ -212,7 +432,6 @@ class CollisionEngine: start_port: Port | None = None, end_port: Port | None = None, ) -> bool: - """ Unified entry point for static collision checks. """ result = self.check_collision( geometry, net_id, @@ -223,12 +442,157 @@ class CollisionEngine: ) return bool(result) - def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: - return self._path_verifier.verify_path_report(net_id, components) + def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: + static_collision_count = 0 + dynamic_collision_count = 0 + self_collision_count = 0 + total_length = sum(component.length for component in components) - def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]: + static_obstacles = self._static_obstacles + dynamic_paths = self._dynamic_paths + + self._ensure_static_raw_tree() + if static_obstacles.raw_tree is not None: + raw_geometries = static_obstacles.raw_tree.geometries + for component in components: + for polygon in component.physical_geometry: + buffered = polygon.buffer(self.clearance, join_style=2) + hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") + for hit_idx in hits: + obstacle = raw_geometries[hit_idx] + if buffered.touches(obstacle): + continue + + obj_id = static_obstacles.raw_obj_ids[hit_idx] + if not self._is_in_safety_zone(polygon, obj_id, None, None): + static_collision_count += 1 + + self._ensure_dynamic_tree() + if dynamic_paths.tree is not None: + tree_geometries = dynamic_paths.tree.geometries + for component in components: + test_geometries = component.dilated_physical_geometry + res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects") + if tree_indices.size == 0: + continue + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + component_hits = [] + for index in range(len(tree_indices)): + if hit_net_ids[index] == str(net_id): + continue + + new_geometry = test_geometries[res_indices[index]] + tree_geometry = tree_geometries[tree_indices[index]] + if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7: + component_hits.append(hit_net_ids[index]) + + if component_hits: + dynamic_collision_count += len(numpy.unique(component_hits)) + + for index, component in enumerate(components): + for other_index in range(index + 2, len(components)): + if components_overlap(component, components[other_index], prefer_actual=True): + self_collision_count += 1 + + return RoutingReport( + static_collision_count=static_collision_count, + dynamic_collision_count=dynamic_collision_count, + self_collision_count=self_collision_count, + total_length=total_length, + ) + + def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]: report = self.verify_path_report(net_id, components) return report.is_valid, report.collision_count - def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: - return self._ray_caster.ray_cast(origin, angle_deg, max_dist=max_dist, net_width=net_width) + def ray_cast( + self, + origin: Port, + angle_deg: float, + max_dist: float = 2000.0, + net_width: float | None = None, + ) -> float: + static_obstacles = self._static_obstacles + + radians = numpy.radians(angle_deg) + cos_v, sin_v = numpy.cos(radians), numpy.sin(radians) + dx, dy = max_dist * cos_v, max_dist * sin_v + min_x, max_x = sorted([origin.x, origin.x + dx]) + min_y, max_y = sorted([origin.y, origin.y + dy]) + + if net_width is not None: + tree = self._ensure_net_static_tree(net_width) + key = (round(net_width, 4), round(self.clearance, 4)) + is_rect_array = static_obstacles.net_specific_is_rect[key] + bounds_array = static_obstacles.net_specific_bounds[key] + else: + self._ensure_static_tree() + tree = static_obstacles.tree + is_rect_array = static_obstacles.is_rect_array + bounds_array = static_obstacles.bounds_array + + if tree is None: + return max_dist + + candidates = tree.query(box(min_x, min_y, max_x, max_y)) + if candidates.size == 0: + return max_dist + + min_dist = max_dist + inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 + inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 + tree_geometries = tree.geometries + ray_line = None + + candidates_bounds = bounds_array[candidates] + dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 + sorted_indices = numpy.argsort(dist_sq) + + for idx in sorted_indices: + candidate_id = candidates[idx] + bounds = bounds_array[candidate_id] + + if abs(dx) < 1e-12: + if origin.x < bounds[0] or origin.x > bounds[2]: + tx_min, tx_max = 1e30, -1e30 + else: + tx_min, tx_max = -1e30, 1e30 + else: + t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx + tx_min, tx_max = min(t1, t2), max(t1, t2) + + if abs(dy) < 1e-12: + if origin.y < bounds[1] or origin.y > bounds[3]: + ty_min, ty_max = 1e30, -1e30 + else: + ty_min, ty_max = -1e30, 1e30 + else: + t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy + ty_min, ty_max = min(t1, t2), max(t1, t2) + + t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) + if t_max < 0 or t_min > t_max or t_min > 1.0: + continue + if t_min * max_dist >= min_dist: + continue + + if is_rect_array[candidate_id]: + min_dist = max(0.0, t_min * max_dist) + continue + + if ray_line is None: + ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) + + obstacle = tree_geometries[candidate_id] + if not obstacle.intersects(ray_line): + continue + + intersection = ray_line.intersection(obstacle) + if intersection.is_empty: + continue + + distance = _intersection_distance(origin, intersection) + min_dist = min(min_dist, distance) + + return min_dist diff --git a/inire/geometry/collision_query_checker.py b/inire/geometry/collision_query_checker.py deleted file mode 100644 index 7ccdf65..0000000 --- a/inire/geometry/collision_query_checker.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -import numpy - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.collision import CollisionEngine - from inire.geometry.primitives import Port - - -class CollisionQueryChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - bounds: tuple[float, float, float, float] | None = None, - net_width: float | None = None, - ) -> bool | int: - del bounds, net_width - - if buffer_mode == "static": - return self._check_static_collision( - geometry, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - ) - return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) - - def _check_static_collision( - self, - geometry: Polygon, - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - ) -> bool: - engine = self.engine - static_obstacles = engine._static_obstacles - engine._ensure_static_tree() - if static_obstacles.tree is None: - return False - - if dilated_geometry is not None: - test_geometry = dilated_geometry - else: - distance = engine.clearance / 2.0 - test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry - - hits = static_obstacles.tree.query(test_geometry, predicate="intersects") - tree_geometries = static_obstacles.tree.geometries - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = static_obstacles.obj_ids[hit_idx] - if engine._is_in_safety_zone(geometry, obj_id, start_port, end_port): - continue - return True - return False - - def _check_dynamic_collision( - self, - geometry: Polygon, - net_id: str, - dilated_geometry: Polygon | None = None, - ) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - engine._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(engine.clearance / 2.0) - hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") - tree_geometries = dynamic_paths.tree.geometries - hit_net_ids: list[str] = [] - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = dynamic_paths.obj_ids[hit_idx] - other_net_id = dynamic_paths.geometries[obj_id][0] - if other_net_id != net_id: - hit_net_ids.append(other_net_id) - if not hit_net_ids: - return 0 - return len(numpy.unique(hit_net_ids)) diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py index 44e1ec9..e2049ac 100644 --- a/inire/geometry/component_overlap.py +++ b/inire/geometry/component_overlap.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]: - if prefer_actual and component.actual_geometry is not None: - return component.actual_geometry - return component.geometry + if prefer_actual: + return list(component.physical_geometry) + return list(component.collision_geometry) def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]: diff --git a/inire/geometry/components.py b/inire/geometry/components.py index abd9367..d098041 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Literal import numpy @@ -12,61 +13,52 @@ from inire.constants import TOLERANCE_ANGULAR from .primitives import Port, rotation_matrix2 +MoveKind = Literal["straight", "bend90", "sbend"] +BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] +BendCollisionModel = BendCollisionModelName | Polygon + + def _normalize_length(value: float) -> float: return float(value) +@dataclass(frozen=True, slots=True) class ComponentResult: - __slots__ = ( - "start_port", - "geometry", - "dilated_geometry", - "proxy_geometry", - "actual_geometry", - "dilated_actual_geometry", - "end_port", - "length", - "move_type", - "_bounds", - "_total_bounds", - "_dilated_bounds", - "_total_dilated_bounds", - ) + start_port: Port + collision_geometry: tuple[Polygon, ...] + end_port: Port + length: float + move_type: MoveKind + physical_geometry: tuple[Polygon, ...] + dilated_collision_geometry: tuple[Polygon, ...] + dilated_physical_geometry: tuple[Polygon, ...] + _bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) + _total_bounds: tuple[float, float, float, float] = field(init=False, repr=False) + _dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) + _total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False) - def __init__( - self, - start_port: Port, - geometry: list[Polygon], - end_port: Port, - length: float, - move_type: str, - dilated_geometry: list[Polygon] | None = None, - proxy_geometry: list[Polygon] | None = None, - actual_geometry: list[Polygon] | None = None, - dilated_actual_geometry: list[Polygon] | None = None, - ) -> None: - self.start_port = start_port - self.geometry = geometry - self.dilated_geometry = dilated_geometry - self.proxy_geometry = proxy_geometry - self.actual_geometry = actual_geometry - self.dilated_actual_geometry = dilated_actual_geometry - self.end_port = end_port - self.length = float(length) - self.move_type = move_type + def __post_init__(self) -> None: + collision_geometry = tuple(self.collision_geometry) + physical_geometry = tuple(self.physical_geometry) + dilated_collision_geometry = tuple(self.dilated_collision_geometry) + dilated_physical_geometry = tuple(self.dilated_physical_geometry) - self._bounds = [poly.bounds for poly in self.geometry] - self._total_bounds = _combine_bounds(self._bounds) + object.__setattr__(self, "collision_geometry", collision_geometry) + object.__setattr__(self, "physical_geometry", physical_geometry) + object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry) + object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry) + object.__setattr__(self, "length", float(self.length)) - if self.dilated_geometry is None: - self._dilated_bounds = None - self._total_dilated_bounds = None - else: - self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry] - self._total_dilated_bounds = _combine_bounds(self._dilated_bounds) + bounds = tuple(poly.bounds for poly in collision_geometry) + object.__setattr__(self, "_bounds", bounds) + object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds))) + + dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry) + object.__setattr__(self, "_dilated_bounds", dilated_bounds) + object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds))) @property - def bounds(self) -> list[tuple[float, float, float, float]]: + def bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._bounds @property @@ -74,28 +66,23 @@ class ComponentResult: return self._total_bounds @property - def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None: + def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._dilated_bounds @property - def total_dilated_bounds(self) -> tuple[float, float, float, float] | None: + def total_dilated_bounds(self) -> tuple[float, float, float, float]: return self._total_dilated_bounds def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( - start_port=self.start_port + [dx, dy, 0], - geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry], - end_port=self.end_port + [dx, dy, 0], + start_port=self.start_port.translate(dx, dy), + collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry], + end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, - dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry], - proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry], - actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry], - dilated_actual_geometry=( - None - if self.dilated_actual_geometry is None - else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry] - ), + physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], + dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], + dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], ) @@ -144,16 +131,13 @@ def _get_arc_polygons( return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] -def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon: +def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: """Return a conservative 8-point polygonal proxy for the arc. The polygon uses 4 points along the outer edge and 4 along the inner edge. The outer edge is a circumscribed polyline and the inner edge is an inscribed polyline, so the result conservatively contains the true arc. - `clip_margin` is kept for API compatibility but is not used by this proxy. """ - del clip_margin - cx, cy = cxy sample_count = 4 angle_span = abs(float(ts[1]) - float(ts[0])) @@ -194,11 +178,10 @@ def _transform_custom_collision_polygon( def _apply_collision_model( arc_poly: Polygon, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, + collision_type: BendCollisionModel, radius: float, width: float, cxy: tuple[float, float], - clip_margin: float, ts: tuple[float, float], rotation_deg: float = 0.0, mirror_y: bool = False, @@ -208,7 +191,7 @@ def _apply_collision_model( if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": - clipped = _clip_bbox(cxy, radius, width, ts, clip_margin) + clipped = _clip_bbox(cxy, radius, width, ts) return [clipped if not clipped.is_empty else box(*arc_poly.bounds)] return [box(*arc_poly.bounds)] @@ -231,7 +214,6 @@ class Straight: poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y)) geometry = [Polygon(poly_points)] - dilated_geometry = None if dilation > 0: half_w_d = half_w + dilation pts_d = numpy.array( @@ -244,16 +226,18 @@ class Straight: ) poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y)) dilated_geometry = [Polygon(poly_points_d)] + else: + dilated_geometry = geometry return ComponentResult( start_port=start_port, - geometry=geometry, + collision_geometry=geometry, end_port=end_port, length=abs(length_f), - move_type="Straight", - dilated_geometry=dilated_geometry, - actual_geometry=geometry, - dilated_actual_geometry=dilated_geometry, + move_type="straight", + physical_geometry=geometry, + dilated_collision_geometry=dilated_geometry, + dilated_physical_geometry=dilated_geometry, ) @@ -265,8 +249,7 @@ class Bend90: width: float, direction: Literal["CW", "CCW"], sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) @@ -290,32 +273,18 @@ class Bend90: radius, width, (float(center_xy[0]), float(center_xy[1])), - clip_margin, ts, rotation_deg=float(start_port.r), mirror_y=(sign < 0), ) - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = _apply_collision_model( - arc_polys[0], - "clipped_bbox", - radius, - width, - (float(center_xy[0]), float(center_xy[1])), - clip_margin, - ts, - ) - - dilated_actual_geometry = None - dilated_geometry = None + physical_geometry = collision_polys if uses_custom_geometry else arc_polys if dilation > 0: if uses_custom_geometry: - dilated_actual_geometry = [poly.buffer(dilation) for poly in collision_polys] - dilated_geometry = dilated_actual_geometry + dilated_physical_geometry = [poly.buffer(dilation) for poly in collision_polys] + dilated_collision_geometry = dilated_physical_geometry else: - dilated_actual_geometry = _get_arc_polygons( + dilated_physical_geometry = _get_arc_polygons( (float(center_xy[0]), float(center_xy[1])), radius, width, @@ -323,18 +292,22 @@ class Bend90: sagitta, dilation=dilation, ) - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = collision_polys return ComponentResult( start_port=start_port, - geometry=collision_polys, + collision_geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, - move_type="Bend90", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=collision_polys if uses_custom_geometry else arc_polys, - dilated_actual_geometry=dilated_actual_geometry, + move_type="bend90", + physical_geometry=physical_geometry, + dilated_collision_geometry=dilated_collision_geometry, + dilated_physical_geometry=dilated_physical_geometry, ) @@ -346,8 +319,7 @@ class SBend: radius: float, width: float, sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: @@ -383,7 +355,6 @@ class SBend: radius, width, (float(c1_xy[0]), float(c1_xy[1])), - clip_margin, ts1, rotation_deg=float(start_port.r), mirror_y=(sign < 0), @@ -394,41 +365,36 @@ class SBend: radius, width, (float(c2_xy[0]), float(c2_xy[1])), - clip_margin, ts2, rotation_deg=float(start_port.r), mirror_y=(sign > 0), )[0], ] - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = [ - _apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0], - _apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], - ] - - dilated_actual_geometry = None - dilated_geometry = None + physical_geometry = geometry if uses_custom_geometry else actual_geometry if dilation > 0: if uses_custom_geometry: - dilated_actual_geometry = [poly.buffer(dilation) for poly in geometry] - dilated_geometry = dilated_actual_geometry + dilated_physical_geometry = [poly.buffer(dilation) for poly in geometry] + dilated_collision_geometry = dilated_physical_geometry else: - dilated_actual_geometry = [ + dilated_physical_geometry = [ _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], ] - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = geometry return ComponentResult( start_port=start_port, - geometry=geometry, + collision_geometry=geometry, end_port=end_port, length=2.0 * radius * theta, - move_type="SBend", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=geometry if uses_custom_geometry else actual_geometry, - dilated_actual_geometry=dilated_actual_geometry, + move_type="sbend", + physical_geometry=physical_geometry, + dilated_collision_geometry=dilated_collision_geometry, + dilated_physical_geometry=dilated_physical_geometry, ) diff --git a/inire/geometry/dynamic_congestion_checker.py b/inire/geometry/dynamic_congestion_checker.py deleted file mode 100644 index 4778550..0000000 --- a/inire/geometry/dynamic_congestion_checker.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - - -class DynamicCongestionChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - if not dynamic_paths.geometries: - return 0 - - total_bounds = result.total_dilated_bounds - if total_bounds is None: - return 0 - - engine._ensure_dynamic_grid() - dynamic_grid = dynamic_paths.grid - if not dynamic_grid: - return 0 - - cell_size_inv = engine._inv_grid_cell_size - gx_min = int(total_bounds[0] * cell_size_inv) - gy_min = int(total_bounds[1] * cell_size_inv) - gx_max = int(total_bounds[2] * cell_size_inv) - gy_max = int(total_bounds[3] * cell_size_inv) - - dynamic_geometries = dynamic_paths.geometries - - if gx_min == gx_max and gy_min == gy_max: - cell = (gx_min, gy_min) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - return self.check_real_congestion(result, net_id) - return 0 - - any_possible = False - for gx in range(gx_min, gx_max + 1): - for gy in range(gy_min, gy_max + 1): - cell = (gx, gy) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - any_possible = True - break - if any_possible: - break - if any_possible: - break - - if not any_possible: - return 0 - return self.check_real_congestion(result, net_id) - - def check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - engine.metrics["congestion_tree_queries"] += 1 - engine._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - total_bounds = result.total_dilated_bounds - dynamic_bounds = engine._dynamic_bounds_array - possible_total = ( - (total_bounds[0] < dynamic_bounds[:, 2]) - & (total_bounds[2] > dynamic_bounds[:, 0]) - & (total_bounds[1] < dynamic_bounds[:, 3]) - & (total_bounds[3] > dynamic_bounds[:, 1]) - ) - - valid_hits_mask = dynamic_paths.net_ids_array != net_id - if not numpy.any(possible_total & valid_hits_mask): - return 0 - - geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry - res_indices, tree_indices = dynamic_paths.tree.query(geoms_to_test, predicate="intersects") - if tree_indices.size == 0: - return 0 - - hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) - unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) - if unique_other_nets.size == 0: - return 0 - - tree_geometries = dynamic_paths.tree.geometries - real_hits_count = 0 - - for other_net_id in unique_other_nets: - other_mask = hit_net_ids == other_net_id - sub_tree_indices = tree_indices[other_mask] - sub_res_indices = res_indices[other_mask] - - found_real = False - for index in range(len(sub_tree_indices)): - test_geometry = geoms_to_test[sub_res_indices[index]] - tree_geometry = tree_geometries[sub_tree_indices[index]] - if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7: - found_real = True - break - - if found_real: - real_hits_count += 1 - - return real_hits_count diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py index e96bb1e..c19ff77 100644 --- a/inire/geometry/dynamic_path_index.py +++ b/inire/geometry/dynamic_path_index.py @@ -6,11 +6,14 @@ import numpy import rtree from shapely.strtree import STRtree -if TYPE_CHECKING: - from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry +from inire.geometry.index_helpers import build_index_payload, iter_grid_cells - from inire.geometry.collision import CollisionEngine +if TYPE_CHECKING: + from collections.abc import Sequence + + from shapely.geometry import Polygon + + from inire.geometry.collision import RoutingWorld class DynamicPathIndex: @@ -19,47 +22,38 @@ class DynamicPathIndex: "index", "geometries", "dilated", - "prepared", "tree", "obj_ids", "grid", "id_counter", - "tree_dirty", "net_ids_array", "bounds_array", - "locked_nets", ) - def __init__(self, engine: CollisionEngine) -> None: + def __init__(self, engine: RoutingWorld) -> None: self.engine = engine self.index = rtree.index.Index() self.geometries: dict[int, tuple[str, Polygon]] = {} self.dilated: dict[int, Polygon] = {} - self.prepared: dict[int, PreparedGeometry] = {} self.tree: STRtree | None = None self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) self.grid: dict[tuple[int, int], list[int]] = {} self.id_counter = 0 - self.tree_dirty = True - self.net_ids_array = numpy.array([], dtype=" None: self.tree = None self.grid = {} - self.tree_dirty = True def ensure_tree(self) -> None: if self.tree is None and self.dilated: - ids = sorted(self.dilated.keys()) - geometries = [self.dilated[i] for i in ids] + ids, geometries, bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) self.obj_ids = numpy.array(ids, dtype=numpy.int32) - self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) + self.bounds_array = bounds_array net_ids = [self.geometries[obj_id][0] for obj_id in self.obj_ids] - self.net_ids_array = numpy.array(net_ids, dtype=" None: if self.grid or not self.dilated: @@ -67,27 +61,20 @@ class DynamicPathIndex: cell_size = self.engine.grid_cell_size for obj_id, polygon in self.dilated.items(): - bounds = polygon.bounds - for gx in range(int(bounds[0] / cell_size), int(bounds[2] / cell_size) + 1): - for gy in range(int(bounds[1] / cell_size), int(bounds[3] / cell_size) + 1): - cell = (gx, gy) - self.grid.setdefault(cell, []).append(obj_id) + for cell in iter_grid_cells(polygon.bounds, cell_size): + self.grid.setdefault(cell, []).append(obj_id) - def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self.invalidate_queries() - dilation = self.engine.clearance / 2.0 for index, polygon in enumerate(geometry): obj_id = self.id_counter self.id_counter += 1 - dilated = dilated_geometry[index] if dilated_geometry else polygon.buffer(dilation) + dilated = dilated_geometry[index] self.geometries[obj_id] = (net_id, polygon) self.dilated[obj_id] = dilated self.index.insert(obj_id, dilated.bounds) def remove_path(self, net_id: str) -> None: - if net_id in self.locked_nets: - return - to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] self.remove_obj_ids(to_remove) @@ -101,14 +88,7 @@ class DynamicPathIndex: del self.geometries[obj_id] del self.dilated[obj_id] - def lock_net(self, net_id: str) -> None: - self.locked_nets.add(net_id) - to_move = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] - for obj_id in to_move: - polygon = self.geometries[obj_id][1] - dilated = self.dilated[obj_id] - self.engine.add_static_obstacle(polygon, dilated_geometry=dilated) - self.remove_obj_ids(to_move) - - def unlock_net(self, net_id: str) -> None: - self.locked_nets.discard(net_id) + def clear_paths(self) -> None: + if not self.geometries: + return + self.remove_obj_ids(list(self.geometries)) diff --git a/inire/geometry/index_helpers.py b/inire/geometry/index_helpers.py new file mode 100644 index 0000000..dad186b --- /dev/null +++ b/inire/geometry/index_helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import math +from collections.abc import Iterator, Mapping +from typing import TypeVar + +import numpy + +GeometryT = TypeVar("GeometryT") + + +def build_index_payload( + geometries: Mapping[int, GeometryT], +) -> tuple[list[int], list[GeometryT], numpy.ndarray]: + obj_ids = sorted(geometries) + ordered_geometries = [geometries[obj_id] for obj_id in obj_ids] + bounds_array = numpy.array([geometry.bounds for geometry in ordered_geometries], dtype=numpy.float64) + if not ordered_geometries: + bounds_array = bounds_array.reshape(0, 4) + return obj_ids, ordered_geometries, bounds_array + + +def grid_cell_span( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> tuple[int, int, int, int]: + return ( + math.floor(bounds[0] / cell_size), + math.floor(bounds[1] / cell_size), + math.floor(bounds[2] / cell_size), + math.floor(bounds[3] / cell_size), + ) + + +def iter_grid_cells( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> Iterator[tuple[int, int]]: + gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, cell_size) + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + yield (gx, gy) + + +def is_axis_aligned_rect(geometry, *, tolerance: float = 1e-4) -> bool: + bounds = geometry.bounds + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + return abs(geometry.area - area) < tolerance diff --git a/inire/geometry/path_verifier.py b/inire/geometry/path_verifier.py deleted file mode 100644 index 273cbd6..0000000 --- a/inire/geometry/path_verifier.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -import numpy - -from inire.geometry.component_overlap import components_overlap - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - - -@dataclass(frozen=True) -class PathVerificationReport: - static_collision_count: int - dynamic_collision_count: int - self_collision_count: int - total_length: float - - @property - def collision_count(self) -> int: - return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count - - @property - def is_valid(self) -> bool: - return self.collision_count == 0 - -class PathVerifier: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: - """ - Non-approximated, full-polygon intersection check of a path against all - static obstacles, other nets, and itself. - """ - static_collision_count = 0 - dynamic_collision_count = 0 - self_collision_count = 0 - total_length = sum(component.length for component in components) - - engine = self.engine - static_obstacles = engine._static_obstacles - dynamic_paths = engine._dynamic_paths - - # 1. Check against static obstacles. - engine._ensure_static_raw_tree() - if static_obstacles.raw_tree is not None: - raw_geoms = static_obstacles.raw_tree.geometries - for comp in components: - polygons = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry - for polygon in polygons: - # Physical separation must be >= clearance. - buffered = polygon.buffer(engine.clearance, join_style=2) - hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") - for hit_idx in hits: - obstacle = raw_geoms[hit_idx] - # If they only touch, gap is exactly clearance. Valid. - if buffered.touches(obstacle): - continue - - obj_id = static_obstacles.raw_obj_ids[hit_idx] - if not engine._is_in_safety_zone(polygon, obj_id, None, None): - static_collision_count += 1 - - # 2. Check against other nets. - engine._ensure_dynamic_tree() - if dynamic_paths.tree is not None: - tree_geoms = dynamic_paths.tree.geometries - for comp in components: - # Robust fallback chain to ensure crossings are caught even with zero clearance. - test_geometries = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry - if not test_geometries: - continue - - if not isinstance(test_geometries, list | tuple | numpy.ndarray): - test_geometries = [test_geometries] - - res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects") - if tree_indices.size == 0: - continue - - hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) - comp_hits = [] - for i in range(len(tree_indices)): - if hit_net_ids[i] == str(net_id): - continue - - p_new = test_geometries[res_indices[i]] - p_tree = tree_geoms[tree_indices[i]] - if not p_new.touches(p_tree) and p_new.intersection(p_tree).area > 1e-7: - comp_hits.append(hit_net_ids[i]) - - if comp_hits: - dynamic_collision_count += len(numpy.unique(comp_hits)) - - # 3. Check for self collisions between non-adjacent components. - for i, comp_i in enumerate(components): - for j in range(i + 2, len(components)): - if components_overlap(comp_i, components[j], prefer_actual=True): - self_collision_count += 1 - - return PathVerificationReport( - static_collision_count=static_collision_count, - dynamic_collision_count=dynamic_collision_count, - self_collision_count=self_collision_count, - total_length=total_length, - ) diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index b6d6b9c..30055ea 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,10 +1,10 @@ from __future__ import annotations -from collections.abc import Iterator +from dataclasses import dataclass from typing import Self import numpy -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray def _normalize_angle(angle_deg: int | float) -> int: @@ -13,119 +13,43 @@ def _normalize_angle(angle_deg: int | float) -> int: raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}") return angle - -def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]: - arr = numpy.asarray(value, dtype=numpy.int32) - if arr.shape != (3,): - raise ValueError(f"Port array must have shape (3,), got {arr.shape}") - arr = arr.copy() - arr[2] = _normalize_angle(int(arr[2])) - return arr - - +@dataclass(frozen=True, slots=True) class Port: """ - Port represented as an ndarray-backed (x, y, r) triple with int32 storage. + Port represented as a normalized integer (x, y, r) triple. """ - __slots__ = ("_xyr",) + x: int | float + y: int | float + r: int | float - def __init__(self, x: int | float, y: int | float, r: int | float) -> None: - self._xyr = numpy.array( - (int(round(x)), int(round(y)), _normalize_angle(r)), - dtype=numpy.int32, - ) - - @classmethod - def from_array(cls, xyr: ArrayLike) -> Self: - obj = cls.__new__(cls) - obj._xyr = _as_int32_triplet(xyr) - return obj - - @property - def x(self) -> int: - return int(self._xyr[0]) - - @x.setter - def x(self, val: int | float) -> None: - self._xyr[0] = int(round(val)) - - @property - def y(self) -> int: - return int(self._xyr[1]) - - @y.setter - def y(self, val: int | float) -> None: - self._xyr[1] = int(round(val)) - - @property - def r(self) -> int: - return int(self._xyr[2]) - - @r.setter - def r(self, val: int | float) -> None: - self._xyr[2] = _normalize_angle(val) - - @property - def orientation(self) -> int: - return self.r - - @orientation.setter - def orientation(self, val: int | float) -> None: - self.r = val - - @property - def xyr(self) -> NDArray[numpy.int32]: - return self._xyr - - @xyr.setter - def xyr(self, val: ArrayLike) -> None: - self._xyr = _as_int32_triplet(val) - - def __repr__(self) -> str: - return f"Port(x={self.x}, y={self.y}, r={self.r})" - - def __iter__(self) -> Iterator[int]: - return iter((self.x, self.y, self.r)) - - def __len__(self) -> int: - return 3 - - def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]: - return self._xyr[item] - - def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]: - return numpy.asarray(self._xyr, dtype=dtype) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Port): - return False - return bool(numpy.array_equal(self._xyr, other._xyr)) - - def __hash__(self) -> int: - return hash(self.as_tuple()) - - def copy(self) -> Self: - return type(self).from_array(self._xyr.copy()) + def __post_init__(self) -> None: + object.__setattr__(self, "x", int(round(self.x))) + object.__setattr__(self, "y", int(round(self.y))) + object.__setattr__(self, "r", _normalize_angle(self.r)) def as_tuple(self) -> tuple[int, int, int]: - return (self.x, self.y, self.r) + return (int(self.x), int(self.y), int(self.r)) - def translate(self, dxy: ArrayLike) -> Self: - dxy_arr = numpy.asarray(dxy, dtype=numpy.int32) - if dxy_arr.shape == (2,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r) - if dxy_arr.shape == (3,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2])) - raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}") + def translate( + self, + dx: int | float = 0, + dy: int | float = 0, + rotation: int | float = 0, + ) -> Self: + return type(self)(self.x + dx, self.y + dy, self.r + rotation) - def __add__(self, other: ArrayLike) -> Self: - return self.translate(other) - - def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]: - if isinstance(other, Port): - return self._xyr - other._xyr - return self._xyr - numpy.asarray(other, dtype=numpy.int32) + def rotated( + self, + angle: int | float, + origin: tuple[int | float, int | float] = (0, 0), + ) -> Self: + angle_i = _normalize_angle(angle) + rot = rotation_matrix2(angle_i) + origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32) + rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy + rotated = origin_xy + rot @ rel + return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i) ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32) @@ -145,16 +69,3 @@ def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: rot3[:2, :2] = rot2 rot3[2, 2] = 1 return rot3 - - -def translate_port(port: Port, dx: int | float, dy: int | float) -> Port: - return Port(port.x + dx, port.y + dy, port.r) - - -def rotate_port(port: Port, angle: int | float, origin: tuple[int | float, int | float] = (0, 0)) -> Port: - angle_i = _normalize_angle(angle) - rot = rotation_matrix2(angle_i) - origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32) - rel = numpy.array((port.x, port.y), dtype=numpy.int32) - origin_xy - rotated = origin_xy + rot @ rel - return Port(int(rotated[0]), int(rotated[1]), port.r + angle_i) diff --git a/inire/geometry/ray_caster.py b/inire/geometry/ray_caster.py deleted file mode 100644 index a09ecf5..0000000 --- a/inire/geometry/ray_caster.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy -from shapely.geometry import LineString, box - -if TYPE_CHECKING: - from shapely.geometry.base import BaseGeometry - - from inire.geometry.collision import CollisionEngine - from inire.geometry.primitives import Port - - -class RayCaster: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: - engine = self.engine - static_obstacles = engine._static_obstacles - - rad = numpy.radians(angle_deg) - cos_v, sin_v = numpy.cos(rad), numpy.sin(rad) - dx, dy = max_dist * cos_v, max_dist * sin_v - min_x, max_x = sorted([origin.x, origin.x + dx]) - min_y, max_y = sorted([origin.y, origin.y + dy]) - - key = None - if net_width is not None: - tree = engine._ensure_net_static_tree(net_width) - key = (round(net_width, 4), round(engine.clearance, 4)) - is_rect_arr = static_obstacles.net_specific_is_rect[key] - bounds_arr = static_obstacles.net_specific_bounds[key] - else: - engine._ensure_static_tree() - tree = static_obstacles.tree - is_rect_arr = static_obstacles.is_rect_array - bounds_arr = static_obstacles.bounds_array - - if tree is None: - return max_dist - - candidates = tree.query(box(min_x, min_y, max_x, max_y)) - if candidates.size == 0: - return max_dist - - min_dist = max_dist - inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 - inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 - tree_geoms = tree.geometries - ray_line = None - - # Distance to the AABB min corner is a cheap ordering heuristic. - candidates_bounds = bounds_arr[candidates] - dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 - sorted_indices = numpy.argsort(dist_sq) - - for idx in sorted_indices: - candidate_id = candidates[idx] - bounds = bounds_arr[candidate_id] - - if abs(dx) < 1e-12: - if origin.x < bounds[0] or origin.x > bounds[2]: - tx_min, tx_max = 1e30, -1e30 - else: - tx_min, tx_max = -1e30, 1e30 - else: - t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx - tx_min, tx_max = min(t1, t2), max(t1, t2) - - if abs(dy) < 1e-12: - if origin.y < bounds[1] or origin.y > bounds[3]: - ty_min, ty_max = 1e30, -1e30 - else: - ty_min, ty_max = -1e30, 1e30 - else: - t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy - ty_min, ty_max = min(t1, t2), max(t1, t2) - - t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) - if t_max < 0 or t_min > t_max or t_min > 1.0: - continue - if t_min * max_dist >= min_dist: - continue - - if is_rect_arr[candidate_id]: - min_dist = max(0.0, t_min * max_dist) - continue - - if ray_line is None: - ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) - - obstacle = tree_geoms[candidate_id] - if not obstacle.intersects(ray_line): - continue - - intersection = ray_line.intersection(obstacle) - if intersection.is_empty: - continue - - distance = self._intersection_distance(origin, intersection) - min_dist = min(min_dist, distance) - - return min_dist - - def _intersection_distance(self, origin: Port, geometry: BaseGeometry) -> float: - if hasattr(geometry, "geoms"): - return min(self._intersection_distance(origin, sub_geom) for sub_geom in geometry.geoms) - return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) diff --git a/inire/geometry/static_move_checker.py b/inire/geometry/static_move_checker.py deleted file mode 100644 index f70cb88..0000000 --- a/inire/geometry/static_move_checker.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from shapely.geometry import box - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - - -class StaticMoveChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - engine = self.engine - engine.metrics["static_straight_fast"] += 1 - reach = engine.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) - return reach < length - 0.001 - - def is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - engine = self.engine - sz = engine.safety_zone_radius - bounds = engine._static_obstacles.bounds_array[idx] - if start_port and bounds[0] - sz <= start_port.x <= bounds[2] + sz and bounds[1] - sz <= start_port.y <= bounds[3] + sz: - return True - return bool( - end_port - and bounds[0] - sz <= end_port.x <= bounds[2] + sz - and bounds[1] - sz <= end_port.y <= bounds[3] + sz - ) - - def check_move_static( - self, - result: ComponentResult, - start_port: Port | None = None, - end_port: Port | None = None, - net_width: float | None = None, - ) -> bool: - del net_width - - engine = self.engine - static_obstacles = engine._static_obstacles - if not static_obstacles.dilated: - return False - - engine.metrics["static_tree_queries"] += 1 - engine._ensure_static_tree() - - total_bounds = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds - hits = static_obstacles.tree.query(box(*total_bounds)) - if hits.size == 0: - return False - - static_bounds = static_obstacles.bounds_array - move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds - for hit_idx in hits: - obstacle_bounds = static_bounds[hit_idx] - poly_hits_obstacle_aabb = False - for poly_bounds in move_poly_bounds: - if ( - poly_bounds[0] < obstacle_bounds[2] - and poly_bounds[2] > obstacle_bounds[0] - and poly_bounds[1] < obstacle_bounds[3] - and poly_bounds[3] > obstacle_bounds[1] - ): - poly_hits_obstacle_aabb = True - break - - if not poly_hits_obstacle_aabb: - continue - - obj_id = static_obstacles.obj_ids[hit_idx] - if self.is_in_safety_zone_fast(hit_idx, start_port, end_port): - collision_found = False - for polygon in result.geometry: - if not self.is_in_safety_zone(polygon, obj_id, start_port, end_port): - collision_found = True - break - if collision_found: - return True - continue - - test_geometries = result.dilated_geometry if result.dilated_geometry else result.geometry - static_obstacle = static_obstacles.dilated[obj_id] - for polygon in test_geometries: - if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle): - return True - - return False - - def is_in_safety_zone( - self, - geometry: Polygon, - obj_id: int, - start_port: Port | None, - end_port: Port | None, - ) -> bool: - engine = self.engine - raw_obstacle = engine._static_obstacles.geometries[obj_id] - sz = engine.safety_zone_radius - - obstacle_bounds = raw_obstacle.bounds - near_start = start_port and ( - obstacle_bounds[0] - sz <= start_port.x <= obstacle_bounds[2] + sz - and obstacle_bounds[1] - sz <= start_port.y <= obstacle_bounds[3] + sz - ) - near_end = end_port and ( - obstacle_bounds[0] - sz <= end_port.x <= obstacle_bounds[2] + sz - and obstacle_bounds[1] - sz <= end_port.y <= obstacle_bounds[3] + sz - ) - - if not near_start and not near_end: - return False - if not geometry.intersects(raw_obstacle): - return False - - engine.metrics["safety_zone_checks"] += 1 - intersection = geometry.intersection(raw_obstacle) - if intersection.is_empty: - return False - - ix_bounds = intersection.bounds - if ( - start_port - and near_start - and abs(ix_bounds[0] - start_port.x) < sz - and abs(ix_bounds[1] - start_port.y) < sz - and abs(ix_bounds[2] - start_port.x) < sz - and abs(ix_bounds[3] - start_port.y) < sz - ): - return True - return bool( - end_port - and near_end - and abs(ix_bounds[0] - end_port.x) < sz - and abs(ix_bounds[1] - end_port.y) < sz - and abs(ix_bounds[2] - end_port.x) < sz - and abs(ix_bounds[3] - end_port.y) < sz - ) diff --git a/inire/geometry/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py index aca1c60..3f3ab38 100644 --- a/inire/geometry/static_obstacle_index.py +++ b/inire/geometry/static_obstacle_index.py @@ -4,14 +4,14 @@ from typing import TYPE_CHECKING import numpy import rtree -from shapely.prepared import prep from shapely.strtree import STRtree +from inire.geometry.index_helpers import build_index_payload, is_axis_aligned_rect + if TYPE_CHECKING: from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld class StaticObstacleIndex: @@ -20,7 +20,6 @@ class StaticObstacleIndex: "index", "geometries", "dilated", - "prepared", "is_rect", "tree", "obj_ids", @@ -31,18 +30,15 @@ class StaticObstacleIndex: "net_specific_trees", "net_specific_is_rect", "net_specific_bounds", - "safe_cache", - "grid", "id_counter", "version", ) - def __init__(self, engine: CollisionEngine) -> None: + def __init__(self, engine: RoutingWorld) -> None: self.engine = engine self.index = rtree.index.Index() self.geometries: dict[int, Polygon] = {} self.dilated: dict[int, Polygon] = {} - self.prepared: dict[int, PreparedGeometry] = {} self.is_rect: dict[int, bool] = {} self.tree: STRtree | None = None self.obj_ids: list[int] = [] @@ -53,8 +49,6 @@ class StaticObstacleIndex: self.net_specific_trees: dict[tuple[float, float], STRtree] = {} self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} - self.safe_cache: set[tuple] = set() - self.grid: dict[tuple[int, int], list[int]] = {} self.id_counter = 0 self.version = 0 @@ -69,12 +63,9 @@ class StaticObstacleIndex: self.geometries[obj_id] = polygon self.dilated[obj_id] = dilated - self.prepared[obj_id] = prep(dilated) + self.is_rect[obj_id] = is_axis_aligned_rect(dilated) self.index.insert(obj_id, dilated.bounds) self.invalidate_caches() - bounds = dilated.bounds - area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) - self.is_rect[obj_id] = abs(dilated.area - area) < 1e-4 return obj_id def remove_obstacle(self, obj_id: int) -> None: @@ -85,7 +76,6 @@ class StaticObstacleIndex: self.index.delete(obj_id, bounds) del self.geometries[obj_id] del self.dilated[obj_id] - del self.prepared[obj_id] del self.is_rect[obj_id] self.invalidate_caches() @@ -96,19 +86,15 @@ class StaticObstacleIndex: self.obj_ids = [] self.raw_tree = None self.raw_obj_ids = [] - self.grid = {} self.net_specific_trees.clear() self.net_specific_is_rect.clear() self.net_specific_bounds.clear() - self.safe_cache.clear() self.version += 1 def ensure_tree(self) -> None: if self.tree is None and self.dilated: - self.obj_ids = sorted(self.dilated.keys()) - geometries = [self.dilated[i] for i in self.obj_ids] + self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) - self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids]) def ensure_net_tree(self, net_width: float) -> STRtree: @@ -125,19 +111,16 @@ class StaticObstacleIndex: polygon = self.geometries[obj_id] dilated = polygon.buffer(total_dilation, join_style=2) geometries.append(dilated) - bounds = dilated.bounds - bounds_list.append(bounds) - area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) - is_rect_list.append(abs(dilated.area - area) < 1e-4) + bounds_list.append(dilated.bounds) + is_rect_list.append(is_axis_aligned_rect(dilated)) tree = STRtree(geometries) self.net_specific_trees[key] = tree self.net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool) - self.net_specific_bounds[key] = numpy.array(bounds_list) + self.net_specific_bounds[key] = numpy.array(bounds_list, dtype=numpy.float64) return tree def ensure_raw_tree(self) -> None: if self.raw_tree is None and self.geometries: - self.raw_obj_ids = sorted(self.geometries.keys()) - geometries = [self.geometries[i] for i in self.raw_obj_ids] + self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries) self.raw_tree = STRtree(geometries) diff --git a/inire/model.py b/inire/model.py new file mode 100644 index 0000000..1dcb359 --- /dev/null +++ b/inire/model.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from inire.geometry.components import BendCollisionModel +from inire.router.results import RouteMetrics, RoutingResult + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + + +@dataclass(frozen=True, slots=True) +class NetSpec: + net_id: str + start: Port + target: Port + width: float = 2.0 + + +@dataclass(frozen=True, slots=True) +class LockedRoute: + geometry: tuple[Polygon, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "geometry", tuple(self.geometry)) + + @classmethod + def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute: + polygons = [] + for component in path: + polygons.extend(component.physical_geometry) + return cls(geometry=tuple(polygons)) + + +def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute: + if isinstance(route, LockedRoute): + return route + route_items = tuple(route) + if route_items and hasattr(route_items[0], "physical_geometry"): + return LockedRoute.from_path(route_items) # type: ignore[arg-type] + return LockedRoute(geometry=route_items) + + +@dataclass(frozen=True, slots=True) +class ObjectiveWeights: + unit_length_cost: float = 1.0 + bend_penalty: float = 250.0 + sbend_penalty: float = 500.0 + danger_weight: float = 1.0 + congestion_penalty: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class SearchOptions: + node_limit: int = 1000000 + max_straight_length: float = 2000.0 + min_straight_length: float = 5.0 + greedy_h_weight: float = 1.5 + sbend_offsets: tuple[float, ...] | None = None + bend_radii: tuple[float, ...] = (50.0, 100.0) + sbend_radii: tuple[float, ...] = (10.0,) + bend_collision_type: BendCollisionModel = "arc" + visibility_guidance: str = "tangent_corner" + initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) + object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) + if self.sbend_offsets is not None: + object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) + if self.initial_paths is not None: + object.__setattr__( + self, + "initial_paths", + { + net_id: tuple(path) + for net_id, path in self.initial_paths.items() + }, + ) + + +@dataclass(frozen=True, slots=True) +class CongestionOptions: + max_iterations: int = 10 + base_penalty: float = 100.0 + multiplier: float = 1.5 + use_tiered_strategy: bool = True + warm_start: str | None = "shortest" + shuffle_nets: bool = False + sort_nets: str | None = None + seed: int | None = None + + +@dataclass(frozen=True, slots=True) +class RefinementOptions: + enabled: bool = True + objective: ObjectiveWeights | None = None + + +@dataclass(frozen=True, slots=True) +class DiagnosticsOptions: + capture_expanded: bool = False + + +@dataclass(frozen=True, slots=True) +class RoutingOptions: + search: SearchOptions = field(default_factory=SearchOptions) + objective: ObjectiveWeights = field(default_factory=ObjectiveWeights) + congestion: CongestionOptions = field(default_factory=CongestionOptions) + refinement: RefinementOptions = field(default_factory=RefinementOptions) + diagnostics: DiagnosticsOptions = field(default_factory=DiagnosticsOptions) + + +@dataclass(frozen=True, slots=True) +class RoutingProblem: + bounds: tuple[float, float, float, float] + nets: tuple[NetSpec, ...] = () + static_obstacles: tuple[Polygon, ...] = () + locked_routes: dict[str, LockedRoute] = field(default_factory=dict) + clearance: float = 2.0 + max_net_width: float = 2.0 + safety_zone_radius: float = 0.0021 + + def __post_init__(self) -> None: + object.__setattr__(self, "nets", tuple(self.nets)) + object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) + object.__setattr__( + self, + "locked_routes", + { + net_id: _coerce_locked_route(route) + for net_id, route in self.locked_routes.items() + }, + ) + + +@dataclass(frozen=True, slots=True) +class RoutingRunResult: + results_by_net: dict[str, RoutingResult] + metrics: RouteMetrics + expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py new file mode 100644 index 0000000..be244c8 --- /dev/null +++ b/inire/router/_astar_admission.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import heapq +from typing import TYPE_CHECKING, Literal + +from shapely.geometry import Polygon + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind +from inire.geometry.primitives import Port +from inire.router.refiner import component_hits_ancestor_chain + +from ._astar_types import AStarContext, AStarMetrics, AStarNode + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + + +def process_move( + parent: AStarNode, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + move_class: MoveKind, + params: tuple, + skip_congestion: bool, + bend_collision_type: BendCollisionModel, + max_cost: float | None = None, + self_collision_check: bool = False, +) -> None: + cp = parent.port + coll_type = bend_collision_type + coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type + self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 + + abs_key = ( + cp.as_tuple(), + move_class, + params, + net_width, + coll_key, + self_dilation, + ) + if abs_key in context.move_cache_abs: + res = context.move_cache_abs[abs_key] + else: + context.check_cache_eviction() + base_port = Port(0, 0, cp.r) + rel_key = ( + cp.r, + move_class, + params, + net_width, + coll_key, + self_dilation, + ) + if rel_key in context.move_cache_rel: + res_rel = context.move_cache_rel[rel_key] + else: + try: + if move_class == "straight": + res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation) + elif move_class == "bend90": + res_rel = Bend90.generate( + base_port, + params[0], + net_width, + params[1], + collision_type=coll_type, + dilation=self_dilation, + ) + else: + res_rel = SBend.generate( + base_port, + params[0], + params[1], + net_width, + collision_type=coll_type, + dilation=self_dilation, + ) + except ValueError: + return + context.move_cache_rel[rel_key] = res_rel + res = res_rel.translate(cp.x, cp.y) + context.move_cache_abs[abs_key] = res + + move_radius = params[0] if move_class == "bend90" else (params[1] if move_class == "sbend" else None) + add_node( + parent, + res, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + move_class, + abs_key, + move_radius=move_radius, + skip_congestion=skip_congestion, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + +def add_node( + parent: AStarNode, + result: ComponentResult, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + move_type: MoveKind, + cache_key: tuple, + move_radius: float | None = None, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, +) -> None: + metrics.moves_generated += 1 + metrics.total_moves_generated += 1 + state = result.end_port.as_tuple() + new_lower_bound_g = parent.g_cost + result.length + if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: + metrics.pruned_closed_set += 1 + metrics.total_pruned_closed_set += 1 + return + + parent_p = parent.port + end_p = result.end_port + + if cache_key in context.hard_collision_set: + metrics.pruned_hard_collision += 1 + metrics.total_pruned_hard_collision += 1 + return + + is_static_safe = cache_key in context.static_safe_cache + if not is_static_safe: + ce = context.cost_evaluator.collision_engine + if move_type == "straight": + collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) + else: + collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) + if collision_found: + context.hard_collision_set.add(cache_key) + metrics.pruned_hard_collision += 1 + metrics.total_pruned_hard_collision += 1 + return + context.static_safe_cache.add(cache_key) + + total_overlaps = 0 + if not skip_congestion: + if cache_key in congestion_cache: + total_overlaps = congestion_cache[cache_key] + else: + total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) + congestion_cache[cache_key] = total_overlaps + + if self_collision_check and component_hits_ancestor_chain(result, parent): + return + + penalty = context.cost_evaluator.component_penalty( + move_type, + move_radius=move_radius, + ) + + move_cost = context.cost_evaluator.evaluate_move( + result.collision_geometry, + result.end_port, + net_width, + net_id, + start_port=parent_p, + length=result.length, + dilated_geometry=result.dilated_collision_geometry, + penalty=penalty, + skip_static=True, + skip_congestion=True, + ) + move_cost += total_overlaps * context.cost_evaluator.congestion_penalty + + if max_cost is not None and parent.g_cost + move_cost > max_cost: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + return + if move_cost > 1e12: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + return + + g_cost = parent.g_cost + move_cost + if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: + metrics.pruned_closed_set += 1 + metrics.total_pruned_closed_set += 1 + return + + h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) + heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) + metrics.moves_added += 1 + metrics.total_moves_added += 1 diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py new file mode 100644 index 0000000..d326934 --- /dev/null +++ b/inire/router/_astar_moves.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import math + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import BendCollisionModel, MoveKind +from inire.geometry.primitives import Port + +from ._astar_admission import process_move +from ._astar_types import AStarContext, AStarMetrics, AStarNode + + +def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: + out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01} + return sorted((v for v in out if v > 0), reverse=True) + + +def _sbend_forward_span(offset: float, radius: float) -> float | None: + abs_offset = abs(offset) + if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: + return None + theta = math.acos(1.0 - abs_offset / (2.0 * radius)) + return 2.0 * radius * math.sin(theta) + + +def _visible_straight_candidates( + current: Port, + context: AStarContext, + max_reach: float, + cos_v: float, + sin_v: float, + net_width: float, +) -> list[float]: + search_options = context.options.search + mode = search_options.visibility_guidance + if mode == "off": + return [] + + if mode == "exact_corner": + max_bend_radius = max(search_options.bend_radii, default=0.0) + visibility_reach = max_reach + max_bend_radius + visible_corners = sorted( + context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach), + key=lambda corner: corner[2], + ) + if not visible_corners: + return [] + + candidates: set[int] = set() + for cx, cy, _ in visible_corners[:12]: + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + if local_x <= search_options.min_straight_length: + continue + candidates.add(int(round(local_x))) + return sorted(candidates, reverse=True) + + if mode != "tangent_corner": + return [] + + visibility_manager = context.visibility_manager + visibility_manager._ensure_current() + max_bend_radius = max(search_options.bend_radii, default=0.0) + if max_bend_radius <= 0 or not visibility_manager.corners: + return [] + + reach = max_reach + max_bend_radius + bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach) + candidate_ids = list(visibility_manager.corner_index.intersection(bounds)) + if not candidate_ids: + return [] + + scored: list[tuple[float, float, float, float, float]] = [] + for idx in candidate_ids: + cx, cy = visibility_manager.corners[idx] + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + local_y = -dx * sin_v + dy * cos_v + if local_x <= search_options.min_straight_length or local_x > reach + 0.01: + continue + + nearest_radius = min(search_options.bend_radii, key=lambda radius: abs(abs(local_y) - radius)) + tangent_error = abs(abs(local_y) - nearest_radius) + if tangent_error > 2.0: + continue + + length = local_x - nearest_radius + if length <= search_options.min_straight_length or length > max_reach + 0.01: + continue + + scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy)) + + if not scored: + return [] + + collision_engine = context.cost_evaluator.collision_engine + candidates: set[int] = set() + for _, dist, length, dx, dy in sorted(scored)[:4]: + angle = math.degrees(math.atan2(dy, dx)) + corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width) + if corner_reach < dist - 0.01: + continue + qlen = int(round(length)) + if qlen > 0: + candidates.add(qlen) + + return sorted(candidates, reverse=True) + + +def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]: + result = node.component_result + if result is None: + return None, None + move_type = result.move_type + if move_type == "straight": + return move_type, result.length + return move_type, None + + +def expand_moves( + current: AStarNode, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + bend_collision_type: BendCollisionModel | None = None, + max_cost: float | None = None, + skip_congestion: bool = False, + self_collision_check: bool = False, +) -> None: + search_options = context.options.search + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type + cp = current.port + prev_move_type, prev_straight_length = _previous_move_metadata(current) + dx_t = target.x - cp.x + dy_t = target.y - cp.y + dist_sq = dx_t * dx_t + dy_t * dy_t + + if cp.r == 0: + cos_v, sin_v = 1.0, 0.0 + elif cp.r == 90: + cos_v, sin_v = 0.0, 1.0 + elif cp.r == 180: + cos_v, sin_v = -1.0, 0.0 + else: + cos_v, sin_v = 0.0, -1.0 + + proj_t = dx_t * cos_v + dy_t * sin_v + perp_t = -dx_t * sin_v + dy_t * cos_v + dx_local = proj_t + dy_local = perp_t + + if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r: + max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width) + if max_reach >= proj_t - 0.01 and ( + prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR + ): + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "straight", + (int(round(proj_t)),), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width) + candidate_lengths = [ + search_options.min_straight_length, + max_reach, + max_reach / 2.0, + max_reach - 5.0, + ] + + axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t) + candidate_lengths.append(axis_target_dist) + for radius in search_options.bend_radii: + candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius)) + + candidate_lengths.extend( + _visible_straight_candidates( + cp, + context, + max_reach, + cos_v, + sin_v, + net_width, + ) + ) + + if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR: + for radius in search_options.sbend_radii: + sbend_span = _sbend_forward_span(dy_local, radius) + if sbend_span is None: + continue + candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span)) + + for length in _quantized_lengths(candidate_lengths, max_reach): + if length < search_options.min_straight_length: + continue + if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "straight", + (length,), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + angle_to_target = 0.0 + if dx_t != 0 or dy_t != 0: + angle_to_target = float((round((180.0 / math.pi) * math.atan2(dy_t, dx_t)) + 360.0) % 360.0) + allow_backwards = dist_sq < 150 * 150 + + for radius in search_options.bend_radii: + for direction in ("CW", "CCW"): + if not allow_backwards: + turn = 90 if direction == "CCW" else -90 + new_ori = (cp.r + turn) % 360 + new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0 + if abs(new_diff) > 135.0: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "bend90", + (radius, direction), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0 + if max_sbend_r <= 0 or prev_move_type == "sbend": + return + + explicit_offsets = search_options.sbend_offsets + offsets: set[int] = {int(round(v)) for v in explicit_offsets or []} + + if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r and 0 < abs(dy_local) < 2 * max_sbend_r: + offsets.add(int(round(dy_local))) + + if not offsets: + return + + for offset in sorted(offsets): + if offset == 0: + continue + for radius in search_options.sbend_radii: + if abs(offset) >= 2 * radius: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "sbend", + (offset, radius), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py new file mode 100644 index 0000000..785b3ae --- /dev/null +++ b/inire/router/_astar_types.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.model import RoutingOptions, RoutingProblem +from inire.router.visibility import VisibilityManager +from inire.router.results import RouteMetrics + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.router.cost import CostEvaluator + + +class AStarNode: + __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") + + def __init__( + self, + port, + g_cost: float, + h_cost: float, + parent: AStarNode | None = None, + component_result: ComponentResult | None = None, + ) -> None: + self.port = port + self.g_cost = g_cost + self.h_cost = h_cost + self.fh_cost = (g_cost + h_cost, h_cost) + self.parent = parent + self.component_result = component_result + + def __lt__(self, other: AStarNode) -> bool: + return self.fh_cost < other.fh_cost + + +class AStarMetrics: + __slots__ = ( + "total_nodes_expanded", + "total_moves_generated", + "total_moves_added", + "total_pruned_closed_set", + "total_pruned_hard_collision", + "total_pruned_cost", + "last_expanded_nodes", + "nodes_expanded", + "moves_generated", + "moves_added", + "pruned_closed_set", + "pruned_hard_collision", + "pruned_cost", + ) + + def __init__(self) -> None: + self.total_nodes_expanded = 0 + self.total_moves_generated = 0 + self.total_moves_added = 0 + self.total_pruned_closed_set = 0 + self.total_pruned_hard_collision = 0 + self.total_pruned_cost = 0 + self.last_expanded_nodes: list[tuple[int, int, int]] = [] + self.nodes_expanded = 0 + self.moves_generated = 0 + self.moves_added = 0 + self.pruned_closed_set = 0 + self.pruned_hard_collision = 0 + self.pruned_cost = 0 + + def reset_totals(self) -> None: + self.total_nodes_expanded = 0 + self.total_moves_generated = 0 + self.total_moves_added = 0 + self.total_pruned_closed_set = 0 + self.total_pruned_hard_collision = 0 + self.total_pruned_cost = 0 + + def reset_per_route(self) -> None: + self.nodes_expanded = 0 + self.moves_generated = 0 + self.moves_added = 0 + self.pruned_closed_set = 0 + self.pruned_hard_collision = 0 + self.pruned_cost = 0 + self.last_expanded_nodes = [] + + def snapshot(self) -> RouteMetrics: + return RouteMetrics( + nodes_expanded=self.total_nodes_expanded, + moves_generated=self.total_moves_generated, + moves_added=self.total_moves_added, + pruned_closed_set=self.total_pruned_closed_set, + pruned_hard_collision=self.total_pruned_hard_collision, + pruned_cost=self.total_pruned_cost, + ) + + +class AStarContext: + __slots__ = ( + "cost_evaluator", + "problem", + "options", + "max_cache_size", + "visibility_manager", + "move_cache_rel", + "move_cache_abs", + "hard_collision_set", + "static_safe_cache", + "static_cache_version", + ) + + def __init__( + self, + cost_evaluator: CostEvaluator, + problem: RoutingProblem, + options: RoutingOptions, + max_cache_size: int = 1000000, + ) -> None: + self.cost_evaluator = cost_evaluator + self.max_cache_size = max_cache_size + self.problem = problem + self.options = options + self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0)) + self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) + self.move_cache_rel: dict[tuple, ComponentResult] = {} + self.move_cache_abs: dict[tuple, ComponentResult] = {} + self.hard_collision_set: set[tuple] = set() + self.static_safe_cache: set[tuple] = set() + self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version() + + def clear_static_caches(self) -> None: + self.hard_collision_set.clear() + self.static_safe_cache.clear() + self.visibility_manager.clear_cache() + self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version() + + def ensure_static_caches_current(self) -> None: + current_version = self.cost_evaluator.collision_engine.get_static_version() + if self.static_cache_version != current_version: + self.clear_static_caches() + + def _evict_cache(self, cache: dict[tuple, ComponentResult]) -> None: + if len(cache) <= self.max_cache_size * 1.2: + return + + num_to_evict = max(1, int(len(cache) * 0.25)) + for idx, key in enumerate(tuple(cache.keys())): + if idx >= num_to_evict: + break + del cache[key] + + def check_cache_eviction(self) -> None: + self._evict_cache(self.move_cache_rel) + self._evict_cache(self.move_cache_abs) diff --git a/inire/router/_router.py b/inire/router/_router.py new file mode 100644 index 0000000..75aef6e --- /dev/null +++ b/inire/router/_router.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from inire.model import NetSpec, RoutingOptions, RoutingProblem +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._search import route_astar +from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry +from inire.router.refiner import PathRefiner +from inire.router.results import RoutingReport, RoutingResult + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + from inire.router.cost import CostEvaluator + + +@dataclass(slots=True) +class _RoutingState: + net_specs: dict[str, NetSpec] + ordered_net_ids: list[str] + results: dict[str, RoutingResult] + needs_self_collision_check: set[str] + start_time: float + timeout_s: float + initial_paths: dict[str, tuple[ComponentResult, ...]] | None + accumulated_expanded_nodes: list[tuple[int, int, int]] + + +__all__ = ["PathFinder"] + + +class PathFinder: + __slots__ = ( + "context", + "metrics", + "refiner", + "accumulated_expanded_nodes", + ) + + def __init__( + self, + context: AStarContext, + metrics: AStarMetrics | None = None, + ) -> None: + self.context = context + self.metrics = metrics if metrics is not None else AStarMetrics() + self.refiner = PathRefiner(self.context) + self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] + + @property + def problem(self) -> RoutingProblem: + return self.context.problem + + @property + def options(self) -> RoutingOptions: + return self.context.options + + @property + def cost_evaluator(self) -> CostEvaluator: + return self.context.cost_evaluator + + def _path_cost(self, path: Sequence[ComponentResult]) -> float: + return self.refiner.path_cost(path) + + def _refine_path( + self, + net_id: str, + start: Port, + target: Port, + net_width: float, + path: Sequence[ComponentResult], + ) -> list[ComponentResult]: + return self.refiner.refine_path(net_id, start, target, net_width, path) + + def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]: + all_geoms = [] + all_dilated = [] + for result in path: + all_geoms.extend(result.collision_geometry) + all_dilated.extend(result.dilated_collision_geometry) + return all_geoms, all_dilated + + def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: + all_geoms, all_dilated = self._extract_path_geometry(path) + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + + def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]: + obj_ids: list[int] = [] + for result in path: + for polygon in result.physical_geometry: + obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon)) + return obj_ids + + def _remove_static_obstacles(self, obj_ids: list[int]) -> None: + for obj_id in obj_ids: + self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) + + def _remove_path(self, net_id: str) -> None: + self.cost_evaluator.collision_engine.remove_path(net_id) + + def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport: + return self.cost_evaluator.collision_engine.verify_path_report(net_id, path) + + def _finalize_dynamic_tree(self) -> None: + self.cost_evaluator.collision_engine.rebuild_dynamic_tree() + + def _build_routing_result( + self, + *, + net_id: str, + path: Sequence[ComponentResult], + reached_target: bool | None = None, + report: RoutingReport | None = None, + ) -> RoutingResult: + resolved_reached_target = bool(path) if reached_target is None else reached_target + return RoutingResult( + net_id=net_id, + path=path, + reached_target=resolved_reached_target, + report=report if report is not None else RoutingReport(), + ) + + def _routing_order( + self, + net_specs: dict[str, NetSpec], + order: str, + ) -> list[str]: + ordered_net_ids = list(net_specs.keys()) + if order == "user": + return ordered_net_ids + ordered_net_ids.sort( + key=lambda net_id: abs(net_specs[net_id].target.x - net_specs[net_id].start.x) + + abs(net_specs[net_id].target.y - net_specs[net_id].start.y), + reverse=(order == "longest"), + ) + return ordered_net_ids + + def _build_greedy_warm_start_paths( + self, + net_specs: dict[str, NetSpec], + order: str, + ) -> dict[str, tuple[ComponentResult, ...]]: + greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} + temp_obj_ids: list[int] = [] + greedy_node_limit = min(self.options.search.node_limit, 2000) + for net_id in self._routing_order(net_specs, order): + net = net_specs[net_id] + h_start = self.cost_evaluator.h_manhattan(net.start, net.target) + max_cost_limit = max(h_start * 3.0, 2000.0) + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + skip_congestion=True, + max_cost=max_cost_limit, + self_collision_check=True, + node_limit=greedy_node_limit, + ) + if not path: + continue + greedy_paths[net_id] = tuple(path) + temp_obj_ids.extend(self._stage_path_as_static(path)) + self.context.clear_static_caches() + + self._remove_static_obstacles(temp_obj_ids) + return greedy_paths + + def _prepare_state(self) -> _RoutingState: + problem = self.problem + congestion = self.options.congestion + initial_paths = self.options.search.initial_paths + net_specs = {net.net_id: net for net in problem.nets} + num_nets = len(net_specs) + state = _RoutingState( + net_specs=net_specs, + ordered_net_ids=list(net_specs.keys()), + results={}, + needs_self_collision_check=set(), + start_time=time.monotonic(), + timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations), + initial_paths=initial_paths, + accumulated_expanded_nodes=[], + ) + if state.initial_paths is None: + warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start + if warm_start_order is not None: + state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order) + self.context.clear_static_caches() + + if congestion.sort_nets and congestion.sort_nets != "user": + state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets) + return state + + def _route_net_once( + self, + state: _RoutingState, + iteration: int, + net_id: str, + ) -> RoutingResult: + search = self.options.search + congestion = self.options.congestion + diagnostics = self.options.diagnostics + net = state.net_specs[net_id] + self._remove_path(net_id) + + if iteration == 0 and state.initial_paths and net_id in state.initial_paths: + path: Sequence[ComponentResult] | None = state.initial_paths[net_id] + else: + coll_model = search.bend_collision_type + skip_congestion = False + if congestion.use_tiered_strategy and iteration == 0: + skip_congestion = True + if coll_model == "arc": + coll_model = "clipped_bbox" + + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + bend_collision_type=coll_model, + return_partial=True, + store_expanded=diagnostics.capture_expanded, + skip_congestion=skip_congestion, + self_collision_check=(net_id in state.needs_self_collision_check), + node_limit=search.node_limit, + ) + + if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: + state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) + + if not path: + return self._build_routing_result(net_id=net_id, path=[], reached_target=False) + + reached_target = path[-1].end_port == net.target + report = None + self._install_path(net_id, path) + if reached_target: + report = self._verify_path_report(net_id, path) + if report.self_collision_count > 0: + state.needs_self_collision_check.add(net_id) + + return self._build_routing_result( + net_id=net_id, + path=path, + reached_target=reached_target, + report=report, + ) + + def _run_iteration( + self, + state: _RoutingState, + iteration: int, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, + ) -> dict[str, RoutingOutcome] | None: + outcomes: dict[str, RoutingOutcome] = {} + congestion = self.options.congestion + self.metrics.reset_per_route() + + if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): + iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None + random.Random(iteration_seed).shuffle(state.ordered_net_ids) + + for net_id in state.ordered_net_ids: + if time.monotonic() - state.start_time > state.timeout_s: + self._finalize_dynamic_tree() + return None + + result = self._route_net_once(state, iteration, net_id) + state.results[net_id] = result + outcomes[net_id] = result.outcome + + if iteration_callback: + iteration_callback(iteration, state.results) + return outcomes + + def _run_iterations( + self, + state: _RoutingState, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, + ) -> bool: + congestion = self.options.congestion + for iteration in range(congestion.max_iterations): + outcomes = self._run_iteration(state, iteration, iteration_callback) + if outcomes is None: + return True + if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()): + return False + self.cost_evaluator.congestion_penalty *= congestion.multiplier + return False + + def _refine_results(self, state: _RoutingState) -> None: + if not self.options.refinement.enabled or not state.results: + return + + for net_id in state.ordered_net_ids: + result = state.results.get(net_id) + if not result or not result.path or routing_outcome_needs_retry(result.outcome): + continue + net = state.net_specs[net_id] + self._remove_path(net_id) + refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path) + self._install_path(net_id, refined_path) + report = self._verify_path_report(net_id, refined_path) + state.results[net_id] = self._build_routing_result( + net_id=net_id, + path=refined_path, + reached_target=result.reached_target, + report=report, + ) + + def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: + final_results: dict[str, RoutingResult] = {} + for net in self.problem.nets: + result = state.results.get(net.net_id) + if not result or not result.path: + final_results[net.net_id] = self._build_routing_result( + net_id=net.net_id, + path=[], + reached_target=False, + ) + continue + report = self._verify_path_report(net.net_id, result.path) + final_results[net.net_id] = self._build_routing_result( + net_id=net.net_id, + path=result.path, + reached_target=result.reached_target, + report=report, + ) + return final_results + + def route_all( + self, + *, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, + ) -> dict[str, RoutingResult]: + self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty + self.accumulated_expanded_nodes = [] + self.metrics.reset_totals() + self.metrics.reset_per_route() + + state = self._prepare_state() + timed_out = self._run_iterations(state, iteration_callback) + self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes) + + if timed_out: + return self._verify_results(state) + + self._refine_results(state) + self._finalize_dynamic_tree() + return self._verify_results(state) diff --git a/inire/router/_search.py b/inire/router/_search.py new file mode 100644 index 0000000..7816ef3 --- /dev/null +++ b/inire/router/_search.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import heapq +from typing import TYPE_CHECKING + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import BendCollisionModel +from inire.geometry.primitives import Port + +from ._astar_moves import expand_moves as _expand_moves +from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode +from .results import RouteMetrics + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + + +def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]: + path = [] + curr: _AStarNode | None = end_node + while curr and curr.component_result: + path.append(curr.component_result) + curr = curr.parent + return path[::-1] + + +def route_astar( + start: Port, + target: Port, + net_width: float, + context: AStarContext, + metrics: AStarMetrics | None = None, + net_id: str = "default", + bend_collision_type: BendCollisionModel | None = None, + return_partial: bool = False, + store_expanded: bool = False, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, + node_limit: int | None = None, +) -> list[ComponentResult] | None: + if metrics is None: + metrics = AStarMetrics() + metrics.reset_per_route() + search_options = context.options.search + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type + + context.ensure_static_caches_current() + context.cost_evaluator.set_target(target) + open_set: list[_AStarNode] = [] + closed_set: dict[tuple[int, int, int], float] = {} + congestion_cache: dict[tuple, int] = {} + + start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) + heapq.heappush(open_set, start_node) + best_node = start_node + effective_node_limit = node_limit if node_limit is not None else search_options.node_limit + nodes_expanded = 0 + + while open_set: + if nodes_expanded >= effective_node_limit: + return _reconstruct_path(best_node) if return_partial else None + + current = heapq.heappop(open_set) + if max_cost is not None and current.fh_cost[0] > max_cost: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + continue + + if current.h_cost < best_node.h_cost: + best_node = current + + state = current.port.as_tuple() + if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR: + continue + closed_set[state] = current.g_cost + + if store_expanded: + metrics.last_expanded_nodes.append(state) + + nodes_expanded += 1 + metrics.total_nodes_expanded += 1 + metrics.nodes_expanded += 1 + + if current.port == target: + return _reconstruct_path(current) + + _expand_moves( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + skip_congestion=skip_congestion, + self_collision_check=self_collision_check, + ) + + return _reconstruct_path(best_node) if return_partial else None + + +__all__ = [ + "AStarContext", + "AStarMetrics", + "RouteMetrics", + "route_astar", +] diff --git a/inire/router/astar.py b/inire/router/astar.py deleted file mode 100644 index 796775f..0000000 --- a/inire/router/astar.py +++ /dev/null @@ -1,721 +0,0 @@ -from __future__ import annotations - -import heapq -import logging -import math -from typing import TYPE_CHECKING, Any, Literal - -import shapely - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port -from inire.router.config import RouterConfig, VisibilityGuidanceMode -from inire.router.refiner import component_hits_ancestor_chain -from inire.router.visibility import VisibilityManager - -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.router.cost import CostEvaluator - -logger = logging.getLogger(__name__) - - -class AStarNode: - __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") - - def __init__( - self, - port: Port, - g_cost: float, - h_cost: float, - parent: AStarNode | None = None, - component_result: ComponentResult | None = None, - ) -> None: - self.port = port - self.g_cost = g_cost - self.h_cost = h_cost - self.fh_cost = (g_cost + h_cost, h_cost) - self.parent = parent - self.component_result = component_result - - def __lt__(self, other: AStarNode) -> bool: - return self.fh_cost < other.fh_cost - - -class AStarMetrics: - __slots__ = ( - "total_nodes_expanded", - "last_expanded_nodes", - "nodes_expanded", - "moves_generated", - "moves_added", - "pruned_closed_set", - "pruned_hard_collision", - "pruned_cost", - ) - - def __init__(self) -> None: - self.total_nodes_expanded = 0 - self.last_expanded_nodes: list[tuple[int, int, int]] = [] - self.nodes_expanded = 0 - self.moves_generated = 0 - self.moves_added = 0 - self.pruned_closed_set = 0 - self.pruned_hard_collision = 0 - self.pruned_cost = 0 - - def reset_per_route(self) -> None: - self.nodes_expanded = 0 - self.moves_generated = 0 - self.moves_added = 0 - self.pruned_closed_set = 0 - self.pruned_hard_collision = 0 - self.pruned_cost = 0 - self.last_expanded_nodes = [] - - -class AStarContext: - __slots__ = ( - "cost_evaluator", - "config", - "visibility_manager", - "move_cache_rel", - "move_cache_abs", - "hard_collision_set", - "static_safe_cache", - "max_cache_size", - ) - - def __init__( - self, - cost_evaluator: CostEvaluator, - node_limit: int = 1000000, - max_straight_length: float = 2000.0, - min_straight_length: float = 5.0, - bend_radii: list[float] | None = None, - sbend_radii: list[float] | None = None, - sbend_offsets: list[float] | None = None, - bend_penalty: float = 250.0, - sbend_penalty: float | None = None, - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc", - bend_clip_margin: float = 10.0, - visibility_guidance: VisibilityGuidanceMode = "tangent_corner", - max_cache_size: int = 1000000, - ) -> None: - actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty - self.cost_evaluator = cost_evaluator - self.max_cache_size = max_cache_size - self.config = RouterConfig( - node_limit=node_limit, - max_straight_length=max_straight_length, - min_straight_length=min_straight_length, - bend_radii=bend_radii if bend_radii is not None else [50.0, 100.0], - sbend_radii=sbend_radii if sbend_radii is not None else [5.0, 10.0, 50.0, 100.0], - sbend_offsets=sbend_offsets, - bend_penalty=bend_penalty, - sbend_penalty=actual_sbend_penalty, - bend_collision_type=bend_collision_type, - bend_clip_margin=bend_clip_margin, - visibility_guidance=visibility_guidance, - ) - self.cost_evaluator.apply_routing_costs( - bend_penalty=self.config.bend_penalty, - sbend_penalty=self.config.sbend_penalty, - bend_radii=self.config.bend_radii, - ) - - self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) - self.move_cache_rel: dict[tuple, ComponentResult] = {} - self.move_cache_abs: dict[tuple, ComponentResult] = {} - self.hard_collision_set: set[tuple] = set() - self.static_safe_cache: set[tuple] = set() - - def clear_static_caches(self) -> None: - self.hard_collision_set.clear() - self.static_safe_cache.clear() - self.visibility_manager.clear_cache() - - def check_cache_eviction(self) -> None: - if len(self.move_cache_abs) <= self.max_cache_size * 1.2: - return - num_to_evict = int(len(self.move_cache_abs) * 0.25) - for idx, key in enumerate(list(self.move_cache_abs.keys())): - if idx >= num_to_evict: - break - del self.move_cache_abs[key] - - -def route_astar( - start: Port, - target: Port, - net_width: float, - context: AStarContext, - metrics: AStarMetrics | None = None, - net_id: str = "default", - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | None = None, - return_partial: bool = False, - store_expanded: bool = False, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, - node_limit: int | None = None, -) -> list[ComponentResult] | None: - if metrics is None: - metrics = AStarMetrics() - metrics.reset_per_route() - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type - - context.cost_evaluator.set_target(target) - open_set: list[AStarNode] = [] - closed_set: dict[tuple[int, int, int], float] = {} - congestion_cache: dict[tuple, int] = {} - - start_node = AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) - heapq.heappush(open_set, start_node) - best_node = start_node - effective_node_limit = node_limit if node_limit is not None else context.config.node_limit - nodes_expanded = 0 - - while open_set: - if nodes_expanded >= effective_node_limit: - return reconstruct_path(best_node) if return_partial else None - - current = heapq.heappop(open_set) - if max_cost is not None and current.fh_cost[0] > max_cost: - metrics.pruned_cost += 1 - continue - - if current.h_cost < best_node.h_cost: - best_node = current - - state = current.port.as_tuple() - if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR: - continue - closed_set[state] = current.g_cost - - if store_expanded: - metrics.last_expanded_nodes.append(state) - - nodes_expanded += 1 - metrics.total_nodes_expanded += 1 - metrics.nodes_expanded += 1 - - if current.port == target: - return reconstruct_path(current) - - expand_moves( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - effective_bend_collision_type, - max_cost=max_cost, - skip_congestion=skip_congestion, - self_collision_check=self_collision_check, - ) - - return reconstruct_path(best_node) if return_partial else None - - -def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: - out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01} - return sorted((v for v in out if v > 0), reverse=True) - - -def _sbend_forward_span(offset: float, radius: float) -> float | None: - abs_offset = abs(offset) - if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: - return None - theta = __import__("math").acos(1.0 - abs_offset / (2.0 * radius)) - return 2.0 * radius * __import__("math").sin(theta) - - -def _visible_straight_candidates( - current: Port, - context: AStarContext, - max_reach: float, - cos_v: float, - sin_v: float, - net_width: float, -) -> list[float]: - mode = context.config.visibility_guidance - if mode == "off": - return [] - - if mode == "exact_corner": - max_bend_radius = max(context.config.bend_radii, default=0.0) - visibility_reach = max_reach + max_bend_radius - visible_corners = sorted( - context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach), - key=lambda corner: corner[2], - ) - if not visible_corners: - return [] - - candidates: set[int] = set() - for cx, cy, _ in visible_corners[:12]: - dx = cx - current.x - dy = cy - current.y - local_x = dx * cos_v + dy * sin_v - if local_x <= context.config.min_straight_length: - continue - candidates.add(int(round(local_x))) - return sorted(candidates, reverse=True) - - if mode != "tangent_corner": - return [] - - visibility_manager = context.visibility_manager - visibility_manager._ensure_current() - max_bend_radius = max(context.config.bend_radii, default=0.0) - if max_bend_radius <= 0 or not visibility_manager.corners: - return [] - - reach = max_reach + max_bend_radius - bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach) - candidate_ids = list(visibility_manager.corner_index.intersection(bounds)) - if not candidate_ids: - return [] - - scored: list[tuple[float, float, float, float, float]] = [] - for idx in candidate_ids: - cx, cy = visibility_manager.corners[idx] - dx = cx - current.x - dy = cy - current.y - local_x = dx * cos_v + dy * sin_v - local_y = -dx * sin_v + dy * cos_v - if local_x <= context.config.min_straight_length or local_x > reach + 0.01: - continue - - nearest_radius = min(context.config.bend_radii, key=lambda radius: abs(abs(local_y) - radius)) - tangent_error = abs(abs(local_y) - nearest_radius) - if tangent_error > 2.0: - continue - - length = local_x - nearest_radius - if length <= context.config.min_straight_length or length > max_reach + 0.01: - continue - - scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy)) - - if not scored: - return [] - - collision_engine = context.cost_evaluator.collision_engine - candidates: set[int] = set() - for _, dist, length, dx, dy in sorted(scored)[:4]: - angle = math.degrees(math.atan2(dy, dx)) - corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width) - if corner_reach < dist - 0.01: - continue - qlen = int(round(length)) - if qlen > 0: - candidates.add(qlen) - - return sorted(candidates, reverse=True) - - -def _previous_move_metadata(node: AStarNode) -> tuple[str | None, float | None]: - result = node.component_result - if result is None: - return None, None - move_type = result.move_type - if move_type == "Straight": - return move_type, result.length - return move_type, None - - -def expand_moves( - current: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any | None = None, - max_cost: float | None = None, - skip_congestion: bool = False, - self_collision_check: bool = False, -) -> None: - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type - cp = current.port - prev_move_type, prev_straight_length = _previous_move_metadata(current) - dx_t = target.x - cp.x - dy_t = target.y - cp.y - dist_sq = dx_t * dx_t + dy_t * dy_t - - if cp.r == 0: - cos_v, sin_v = 1.0, 0.0 - elif cp.r == 90: - cos_v, sin_v = 0.0, 1.0 - elif cp.r == 180: - cos_v, sin_v = -1.0, 0.0 - else: - cos_v, sin_v = 0.0, -1.0 - - proj_t = dx_t * cos_v + dy_t * sin_v - perp_t = -dx_t * sin_v + dy_t * cos_v - dx_local = proj_t - dy_local = perp_t - - if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r: - max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width) - if max_reach >= proj_t - 0.01 and ( - prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR - ): - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "S", - (int(round(proj_t)),), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, context.config.max_straight_length, net_width=net_width) - candidate_lengths = [ - context.config.min_straight_length, - max_reach, - max_reach / 2.0, - max_reach - 5.0, - ] - - axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t) - candidate_lengths.append(axis_target_dist) - for radius in context.config.bend_radii: - candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius)) - - candidate_lengths.extend( - _visible_straight_candidates( - cp, - context, - max_reach, - cos_v, - sin_v, - net_width, - ) - ) - - if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR: - for radius in context.config.sbend_radii: - sbend_span = _sbend_forward_span(dy_local, radius) - if sbend_span is None: - continue - candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span)) - - for length in _quantized_lengths(candidate_lengths, max_reach): - if length < context.config.min_straight_length: - continue - if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "S", - (length,), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - angle_to_target = 0.0 - if dx_t != 0 or dy_t != 0: - angle_to_target = float((round((180.0 / 3.141592653589793) * __import__("math").atan2(dy_t, dx_t)) + 360.0) % 360.0) - allow_backwards = dist_sq < 150 * 150 - - for radius in context.config.bend_radii: - for direction in ("CW", "CCW"): - if not allow_backwards: - turn = 90 if direction == "CCW" else -90 - new_ori = (cp.r + turn) % 360 - new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0 - if abs(new_diff) > 135.0: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "B", - (radius, direction), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - max_sbend_r = max(context.config.sbend_radii) if context.config.sbend_radii else 0.0 - if max_sbend_r <= 0 or prev_move_type == "SBend": - return - - explicit_offsets = context.config.sbend_offsets - offsets: set[int] = set(int(round(v)) for v in explicit_offsets or []) - - # S-bends preserve orientation, so the implicit search only makes sense - # when the target is ahead in local coordinates and keeps the same - # orientation. Generating generic speculative offsets on the integer lattice - # explodes the search space without contributing useful moves. - if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r: - if 0 < abs(dy_local) < 2 * max_sbend_r: - offsets.add(int(round(dy_local))) - - if not offsets: - return - - for offset in sorted(offsets): - if offset == 0: - continue - for radius in context.config.sbend_radii: - if abs(offset) >= 2 * radius: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "SB", - (offset, radius), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - -def process_move( - parent: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - move_class: Literal["S", "B", "SB"], - params: tuple, - skip_congestion: bool, - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any, - max_cost: float | None = None, - self_collision_check: bool = False, -) -> None: - cp = parent.port - coll_type = bend_collision_type - coll_key = id(coll_type) if isinstance(coll_type, shapely.geometry.Polygon) else coll_type - self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 - - abs_key = ( - cp.as_tuple(), - move_class, - params, - net_width, - coll_key, - context.config.bend_clip_margin, - self_dilation, - ) - if abs_key in context.move_cache_abs: - res = context.move_cache_abs[abs_key] - else: - context.check_cache_eviction() - base_port = Port(0, 0, cp.r) - rel_key = ( - cp.r, - move_class, - params, - net_width, - coll_key, - context.config.bend_clip_margin, - self_dilation, - ) - if rel_key in context.move_cache_rel: - res_rel = context.move_cache_rel[rel_key] - else: - try: - if move_class == "S": - res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation) - elif move_class == "B": - res_rel = Bend90.generate( - base_port, - params[0], - net_width, - params[1], - collision_type=coll_type, - clip_margin=context.config.bend_clip_margin, - dilation=self_dilation, - ) - else: - res_rel = SBend.generate( - base_port, - params[0], - params[1], - net_width, - collision_type=coll_type, - clip_margin=context.config.bend_clip_margin, - dilation=self_dilation, - ) - except ValueError: - return - context.move_cache_rel[rel_key] = res_rel - res = res_rel.translate(cp.x, cp.y) - context.move_cache_abs[abs_key] = res - - move_radius = params[0] if move_class == "B" else (params[1] if move_class == "SB" else None) - add_node( - parent, - res, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - move_class, - abs_key, - move_radius=move_radius, - skip_congestion=skip_congestion, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - -def add_node( - parent: AStarNode, - result: ComponentResult, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - move_type: str, - cache_key: tuple, - move_radius: float | None = None, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, -) -> None: - metrics.moves_generated += 1 - state = result.end_port.as_tuple() - new_lower_bound_g = parent.g_cost + result.length - if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: - metrics.pruned_closed_set += 1 - return - - parent_p = parent.port - end_p = result.end_port - - if cache_key in context.hard_collision_set: - metrics.pruned_hard_collision += 1 - return - - is_static_safe = cache_key in context.static_safe_cache - if not is_static_safe: - ce = context.cost_evaluator.collision_engine - if move_type == "S": - collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) - else: - collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) - if collision_found: - context.hard_collision_set.add(cache_key) - metrics.pruned_hard_collision += 1 - return - context.static_safe_cache.add(cache_key) - - total_overlaps = 0 - if not skip_congestion: - if cache_key in congestion_cache: - total_overlaps = congestion_cache[cache_key] - else: - total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) - congestion_cache[cache_key] = total_overlaps - - if self_collision_check: - if component_hits_ancestor_chain(result, parent): - return - - penalty = 0.0 - if move_type == "SB": - penalty = context.config.sbend_penalty - elif move_type == "B": - penalty = context.config.bend_penalty - if move_radius is not None and move_radius > TOLERANCE_LINEAR: - penalty *= (10.0 / move_radius) ** 0.5 - - move_cost = context.cost_evaluator.evaluate_move( - result.geometry, - result.end_port, - net_width, - net_id, - start_port=parent_p, - length=result.length, - dilated_geometry=result.dilated_geometry, - penalty=penalty, - skip_static=True, - skip_congestion=True, - ) - move_cost += total_overlaps * context.cost_evaluator.congestion_penalty - - if max_cost is not None and parent.g_cost + move_cost > max_cost: - metrics.pruned_cost += 1 - return - if move_cost > 1e12: - metrics.pruned_cost += 1 - return - - g_cost = parent.g_cost + move_cost - if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: - metrics.pruned_closed_set += 1 - return - - h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) - heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) - metrics.moves_added += 1 - - -def reconstruct_path(end_node: AStarNode) -> list[ComponentResult]: - path = [] - curr: AStarNode | None = end_node - while curr and curr.component_result: - path.append(curr.component_result) - curr = curr.parent - return path[::-1] diff --git a/inire/router/config.py b/inire/router/config.py deleted file mode 100644 index aac6264..0000000 --- a/inire/router/config.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Literal, Any - - -VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"] - - - -@dataclass -class RouterConfig: - """Configuration parameters for the A* Router.""" - - node_limit: int = 1000000 - max_straight_length: float = 2000.0 - min_straight_length: float = 5.0 - - sbend_offsets: list[float] | None = None - - bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0]) - sbend_radii: list[float] = field(default_factory=lambda: [10.0]) - snap_to_target_dist: float = 1000.0 - bend_penalty: float = 250.0 - sbend_penalty: float = 500.0 - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc" - bend_clip_margin: float = 10.0 - visibility_guidance: VisibilityGuidanceMode = "tangent_corner" - - -@dataclass -class CostConfig: - """Configuration parameters for the Cost Evaluator.""" - - unit_length_cost: float = 1.0 - greedy_h_weight: float = 1.5 - congestion_penalty: float = 10000.0 - bend_penalty: float = 250.0 - sbend_penalty: float = 500.0 - min_bend_radius: float = 50.0 diff --git a/inire/router/cost.py b/inire/router/cost.py index 94aafe3..8468c3e 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -5,12 +5,15 @@ from typing import TYPE_CHECKING import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.router.config import CostConfig +from inire.model import ObjectiveWeights, RoutingOptions if TYPE_CHECKING: + from collections.abc import Sequence + from shapely.geometry import Polygon - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld + from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap @@ -19,10 +22,12 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "config", - "unit_length_cost", - "greedy_h_weight", - "congestion_penalty", + "_unit_length_cost", + "_greedy_h_weight", + "_bend_penalty", + "_sbend_penalty", + "_danger_weight", + "_congestion_penalty", "_target_x", "_target_y", "_target_r", @@ -33,53 +38,102 @@ class CostEvaluator: def __init__( self, - collision_engine: CollisionEngine, + collision_engine: RoutingWorld, danger_map: DangerMap | None = None, unit_length_cost: float = 1.0, greedy_h_weight: float = 1.5, - congestion_penalty: float = 10000.0, bend_penalty: float = 250.0, sbend_penalty: float | None = None, - min_bend_radius: float = 50.0, + danger_weight: float = 1.0, ) -> None: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty self.collision_engine = collision_engine self.danger_map = danger_map - self.config = CostConfig( - unit_length_cost=unit_length_cost, - greedy_h_weight=greedy_h_weight, - congestion_penalty=congestion_penalty, - bend_penalty=bend_penalty, - sbend_penalty=actual_sbend_penalty, - min_bend_radius=min_bend_radius, - ) - self.unit_length_cost = self.config.unit_length_cost - self.greedy_h_weight = self.config.greedy_h_weight - self.congestion_penalty = self.config.congestion_penalty - self._refresh_cached_config() + self._unit_length_cost = float(unit_length_cost) + self._greedy_h_weight = float(greedy_h_weight) + self._bend_penalty = float(bend_penalty) + self._sbend_penalty = float(actual_sbend_penalty) + self._danger_weight = float(danger_weight) + self._congestion_penalty = 0.0 self._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - def apply_routing_costs( - self, - *, - bend_penalty: float, - sbend_penalty: float, - bend_radii: list[float], - ) -> None: - self.config.bend_penalty = bend_penalty - self.config.sbend_penalty = sbend_penalty - self.config.min_bend_radius = min(bend_radii) if bend_radii else 50.0 - self._refresh_cached_config() + self._min_radius = 50.0 - def _refresh_cached_config(self) -> None: - self._min_radius = self.config.min_bend_radius - self.unit_length_cost = self.config.unit_length_cost - self.greedy_h_weight = self.config.greedy_h_weight - self.congestion_penalty = self.config.congestion_penalty + @property + def unit_length_cost(self) -> float: + return self._unit_length_cost + + @unit_length_cost.setter + def unit_length_cost(self, value: float) -> None: + self._unit_length_cost = float(value) + + @property + def greedy_h_weight(self) -> float: + return self._greedy_h_weight + + @greedy_h_weight.setter + def greedy_h_weight(self, value: float) -> None: + self._greedy_h_weight = float(value) + + @property + def congestion_penalty(self) -> float: + return self._congestion_penalty + + @congestion_penalty.setter + def congestion_penalty(self, value: float) -> None: + self._congestion_penalty = float(value) + + @property + def bend_penalty(self) -> float: + return self._bend_penalty + + @bend_penalty.setter + def bend_penalty(self, value: float) -> None: + self._bend_penalty = float(value) + + @property + def sbend_penalty(self) -> float: + return self._sbend_penalty + + @sbend_penalty.setter + def sbend_penalty(self, value: float) -> None: + self._sbend_penalty = float(value) + + @property + def danger_weight(self) -> float: + return self._danger_weight + + @danger_weight.setter + def danger_weight(self, value: float) -> None: + self._danger_weight = float(value) + + def set_min_bend_radius(self, radius: float) -> None: + self._min_radius = float(radius) if radius > 0 else 50.0 + + def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights: + return ObjectiveWeights( + unit_length_cost=self._unit_length_cost, + bend_penalty=self._bend_penalty, + sbend_penalty=self._sbend_penalty, + danger_weight=self._danger_weight, + congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty), + ) + + def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights: + refinement_objective = options.refinement.objective + if refinement_objective is None: + return ObjectiveWeights( + unit_length_cost=self._unit_length_cost, + bend_penalty=self._bend_penalty, + sbend_penalty=self._sbend_penalty, + danger_weight=self._danger_weight, + congestion_penalty=0.0, + ) + return refinement_objective def set_target(self, target: Port) -> None: self._target_x = target.x @@ -92,7 +146,7 @@ class CostEvaluator: def g_proximity(self, x: float, y: float) -> float: if self.danger_map is None: return 0.0 - return self.danger_map.get_cost(x, y) + return self._danger_weight * self.danger_map.get_cost(x, y) def h_manhattan(self, current: Port, target: Port) -> float: tx, ty = target.x, target.y @@ -102,7 +156,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self.config.bend_penalty + bp = self._bend_penalty penalty = 0.0 curr_r = current.r @@ -132,27 +186,29 @@ class CostEvaluator: if diff == 0 and perp_dist > 0: penalty += 2 * bp - return self.greedy_h_weight * (dist + penalty) + return self._greedy_h_weight * (dist + penalty) def evaluate_move( self, - geometry: list[Polygon] | None, + geometry: Sequence[Polygon] | None, end_port: Port, net_width: float, net_id: str, start_port: Port | None = None, length: float = 0.0, - dilated_geometry: list[Polygon] | None = None, + dilated_geometry: Sequence[Polygon] | None = None, skip_static: bool = False, skip_congestion: bool = False, penalty: float = 0.0, + weights: ObjectiveWeights | None = None, ) -> float: + active_weights = self.objective_weights() if weights is None else weights _ = net_width danger_map = self.danger_map if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - total_cost = length * self.unit_length_cost + penalty + total_cost = length * active_weights.unit_length_cost + penalty if not skip_static or not skip_congestion: if geometry is None: return 1e15 @@ -171,16 +227,71 @@ class CostEvaluator: if not skip_congestion: overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly) if isinstance(overlaps, int) and overlaps > 0: - total_cost += overlaps * self.congestion_penalty + total_cost += overlaps * active_weights.congestion_penalty - if danger_map is not None: + if danger_map is not None and active_weights.danger_weight: cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 cost_e = danger_map.get_cost(end_port.x, end_port.y) if start_port: mid_x = (start_port.x + end_port.x) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0 cost_m = danger_map.get_cost(mid_x, mid_y) - total_cost += length * (cost_s + cost_m + cost_e) / 3.0 + total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += length * cost_e + total_cost += length * active_weights.danger_weight * cost_e return total_cost + + def component_penalty( + self, + move_type: MoveKind, + *, + move_radius: float | None = None, + weights: ObjectiveWeights | None = None, + ) -> float: + active_weights = self.objective_weights() if weights is None else weights + penalty = 0.0 + if move_type == "sbend": + penalty = active_weights.sbend_penalty + elif move_type == "bend90": + penalty = active_weights.bend_penalty + if move_radius is not None and move_radius > TOLERANCE_LINEAR and penalty > 0: + penalty *= (10.0 / move_radius) ** 0.5 + return penalty + + def path_cost( + self, + net_id: str, + start_port: Port, + path: list[ComponentResult], + *, + weights: ObjectiveWeights | None = None, + ) -> float: + active_weights = self.objective_weights() if weights is None else weights + total = 0.0 + current_port = start_port + for component in path: + move_radius = None + if component.move_type == "bend90": + move_radius = component.length * 2.0 / np.pi if component.length > 0 else None + elif component.move_type == "sbend": + move_radius = None + penalty = self.component_penalty( + component.move_type, + move_radius=move_radius, + weights=active_weights, + ) + total += self.evaluate_move( + component.collision_geometry, + component.end_port, + net_width=0.0, + net_id=net_id, + start_port=current_port, + length=component.length, + dilated_geometry=component.dilated_collision_geometry, + skip_static=True, + skip_congestion=(active_weights.congestion_penalty <= 0.0), + penalty=penalty, + weights=active_weights, + ) + current_port = component.end_port + return total diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 4ade2f9..03b8a2a 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -1,21 +1,24 @@ from __future__ import annotations +from collections import OrderedDict from typing import TYPE_CHECKING + import numpy -import shapely from scipy.spatial import cKDTree -from functools import lru_cache if TYPE_CHECKING: from shapely.geometry import Polygon +_COST_CACHE_SIZE = 100000 + + class DangerMap: """ A proximity cost evaluator using a KD-Tree of obstacle boundary points. Scales with obstacle perimeter rather than design area. """ - __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree') + __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache') def __init__( self, @@ -38,6 +41,7 @@ class DangerMap: self.safety_threshold = safety_threshold self.k = k self.tree: cKDTree | None = None + self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict() def precompute(self, obstacles: list[Polygon]) -> None: """ @@ -64,9 +68,8 @@ class DangerMap: self.tree = cKDTree(numpy.array(all_points)) else: self.tree = None - - # Clear cache when tree changes - self._get_cost_quantized.cache_clear() + + self._cost_cache.clear() def is_within_bounds(self, x: float, y: float) -> bool: """ @@ -81,10 +84,18 @@ class DangerMap: """ qx_milli = int(round(x * 1000)) qy_milli = int(round(y * 1000)) - return self._get_cost_quantized(qx_milli, qy_milli) + key = (qx_milli, qy_milli) + if key in self._cost_cache: + self._cost_cache.move_to_end(key) + return self._cost_cache[key] - @lru_cache(maxsize=100000) - def _get_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: + cost = self._compute_cost_quantized(qx_milli, qy_milli) + self._cost_cache[key] = cost + if len(self._cost_cache) > _COST_CACHE_SIZE: + self._cost_cache.popitem(last=False) + return cost + + def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: qx = qx_milli / 1000.0 qy = qy_milli / 1000.0 if not self.is_within_bounds(qx, qy): diff --git a/inire/router/path_state.py b/inire/router/path_state.py deleted file mode 100644 index bcb386e..0000000 --- a/inire/router/path_state.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine, PathVerificationReport - from inire.geometry.components import ComponentResult - - -class PathStateManager: - __slots__ = ("collision_engine",) - - def __init__(self, collision_engine: CollisionEngine) -> None: - self.collision_engine = collision_engine - - def extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: - all_geoms = [] - all_dilated = [] - for res in path: - all_geoms.extend(res.geometry) - if res.dilated_geometry: - all_dilated.extend(res.dilated_geometry) - else: - dilation = self.collision_engine.clearance / 2.0 - all_dilated.extend([poly.buffer(dilation) for poly in res.geometry]) - return all_geoms, all_dilated - - def install_path(self, net_id: str, path: list[ComponentResult]) -> None: - all_geoms, all_dilated = self.extract_geometry(path) - self.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - def stage_path_as_static(self, path: list[ComponentResult]) -> list[int]: - obj_ids: list[int] = [] - for res in path: - geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry - dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry - for index, poly in enumerate(geoms): - dilated = dilated_geoms[index] if dilated_geoms else None - obj_ids.append(self.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated)) - return obj_ids - - def remove_static_obstacles(self, obj_ids: list[int]) -> None: - for obj_id in obj_ids: - self.collision_engine.remove_static_obstacle(obj_id) - - def remove_path(self, net_id: str) -> None: - self.collision_engine.remove_path(net_id) - - def verify_path(self, net_id: str, path: list[ComponentResult]) -> tuple[bool, int]: - return self.collision_engine.verify_path(net_id, path) - - def verify_path_report(self, net_id: str, path: list[ComponentResult]) -> PathVerificationReport: - return self.collision_engine.verify_path_report(net_id, path) - - def finalize_dynamic_tree(self) -> None: - self.collision_engine.rebuild_dynamic_tree() diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py deleted file mode 100644 index 13de9b9..0000000 --- a/inire/router/pathfinder.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal - -from inire.router.astar import AStarMetrics, route_astar -from inire.router.outcomes import RoutingOutcome, infer_routing_outcome, routing_outcome_needs_retry -from inire.router.refiner import PathRefiner -from inire.router.path_state import PathStateManager -from inire.router.session import ( - create_routing_session_state, - finalize_routing_session_results, - prepare_routing_session_state, - refine_routing_session_results, - run_routing_iteration, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.astar import AStarContext - from inire.router.cost import CostEvaluator - -logger = logging.getLogger(__name__) - - -@dataclass -class RoutingResult: - net_id: str - path: list[ComponentResult] - is_valid: bool - collisions: int - reached_target: bool = False - outcome: RoutingOutcome = "unroutable" - -class PathFinder: - __slots__ = ( - "context", - "metrics", - "max_iterations", - "base_congestion_penalty", - "use_tiered_strategy", - "congestion_multiplier", - "accumulated_expanded_nodes", - "warm_start", - "refine_paths", - "refiner", - "path_state", - ) - - def __init__( - self, - context: AStarContext, - metrics: AStarMetrics | None = None, - max_iterations: int = 10, - base_congestion_penalty: float = 100.0, - congestion_multiplier: float = 1.5, - use_tiered_strategy: bool = True, - warm_start: Literal["shortest", "longest", "user"] | None = "shortest", - refine_paths: bool = True, - ) -> None: - self.context = context - self.metrics = metrics if metrics is not None else AStarMetrics() - self.max_iterations = max_iterations - self.base_congestion_penalty = base_congestion_penalty - self.congestion_multiplier = congestion_multiplier - self.use_tiered_strategy = use_tiered_strategy - self.warm_start = warm_start - self.refine_paths = refine_paths - self.refiner = PathRefiner(context) - self.path_state = PathStateManager(context.cost_evaluator.collision_engine) - self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] - - @property - def cost_evaluator(self) -> CostEvaluator: - return self.context.cost_evaluator - - def _build_greedy_warm_start_paths( - self, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - order: Literal["shortest", "longest", "user"], - ) -> dict[str, list[ComponentResult]]: - all_net_ids = list(netlist.keys()) - if order != "user": - all_net_ids.sort( - key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y), - reverse=(order == "longest"), - ) - - greedy_paths: dict[str, list[ComponentResult]] = {} - temp_obj_ids: list[int] = [] - greedy_node_limit = min(self.context.config.node_limit, 2000) - for net_id in all_net_ids: - start, target = netlist[net_id] - width = net_widths.get(net_id, 2.0) - h_start = self.cost_evaluator.h_manhattan(start, target) - max_cost_limit = max(h_start * 3.0, 2000.0) - path = route_astar( - start, - target, - width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - skip_congestion=True, - max_cost=max_cost_limit, - self_collision_check=True, - node_limit=greedy_node_limit, - ) - if not path: - continue - greedy_paths[net_id] = path - temp_obj_ids.extend(self.path_state.stage_path_as_static(path)) - self.context.clear_static_caches() - - self.path_state.remove_static_obstacles(temp_obj_ids) - return greedy_paths - - def _path_cost(self, path: list[ComponentResult]) -> float: - return self.refiner.path_cost(path) - - def _install_path(self, net_id: str, path: list[ComponentResult]) -> None: - self.path_state.install_path(net_id, path) - - def _build_routing_result( - self, - *, - net_id: str, - path: list[ComponentResult], - reached_target: bool, - collisions: int, - outcome: RoutingOutcome | None = None, - ) -> RoutingResult: - resolved_outcome = ( - infer_routing_outcome( - has_path=bool(path), - reached_target=reached_target, - collision_count=collisions, - ) - if outcome is None - else outcome - ) - return RoutingResult( - net_id=net_id, - path=path, - is_valid=(resolved_outcome == "completed"), - collisions=collisions, - reached_target=reached_target, - outcome=resolved_outcome, - ) - - def _refine_path( - self, - net_id: str, - start: Port, - target: Port, - net_width: float, - path: list[ComponentResult], - ) -> list[ComponentResult]: - return self.refiner.refine_path(net_id, start, target, net_width, path) - - def _route_net_once( - self, - net_id: str, - start: Port, - target: Port, - width: float, - iteration: int, - initial_paths: dict[str, list[ComponentResult]] | None, - store_expanded: bool, - needs_self_collision_check: set[str], - ) -> tuple[RoutingResult, RoutingOutcome]: - self.path_state.remove_path(net_id) - path: list[ComponentResult] | None = None - - if iteration == 0 and initial_paths and net_id in initial_paths: - path = initial_paths[net_id] - else: - target_coll_model = self.context.config.bend_collision_type - coll_model = target_coll_model - skip_cong = False - if self.use_tiered_strategy and iteration == 0: - skip_cong = True - if target_coll_model == "arc": - coll_model = "clipped_bbox" - - path = route_astar( - start, - target, - width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - bend_collision_type=coll_model, - return_partial=True, - store_expanded=store_expanded, - skip_congestion=skip_cong, - self_collision_check=(net_id in needs_self_collision_check), - node_limit=self.context.config.node_limit, - ) - - if store_expanded and self.metrics.last_expanded_nodes: - self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) - - if not path: - outcome = infer_routing_outcome(has_path=False, reached_target=False, collision_count=0) - return self._build_routing_result(net_id=net_id, path=[], reached_target=False, collisions=0, outcome=outcome), outcome - - last_p = path[-1].end_port - reached = last_p == target - collision_count = 0 - - self._install_path(net_id, path) - if reached: - report = self.path_state.verify_path_report(net_id, path) - collision_count = report.collision_count - if report.self_collision_count > 0: - needs_self_collision_check.add(net_id) - - outcome = infer_routing_outcome( - has_path=bool(path), - reached_target=reached, - collision_count=collision_count, - ) - return ( - self._build_routing_result( - net_id=net_id, - path=path, - reached_target=reached, - collisions=collision_count, - outcome=outcome, - ), - outcome, - ) - - def route_all( - self, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - store_expanded: bool = False, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, - shuffle_nets: bool = False, - sort_nets: Literal["shortest", "longest", "user", None] = None, - initial_paths: dict[str, list[ComponentResult]] | None = None, - seed: int | None = None, - ) -> dict[str, RoutingResult]: - self.cost_evaluator.congestion_penalty = self.base_congestion_penalty - self.accumulated_expanded_nodes = [] - self.metrics.reset_per_route() - - state = create_routing_session_state( - self, - netlist, - net_widths, - store_expanded=store_expanded, - iteration_callback=iteration_callback, - shuffle_nets=shuffle_nets, - sort_nets=sort_nets, - initial_paths=initial_paths, - seed=seed, - ) - prepare_routing_session_state(self, state) - - for iteration in range(self.max_iterations): - iteration_outcomes = run_routing_iteration(self, state, iteration) - if iteration_outcomes is None: - return self.verify_all_nets(state.results, state.netlist) - if not any(routing_outcome_needs_retry(outcome) for outcome in iteration_outcomes.values()): - break - self.cost_evaluator.congestion_penalty *= self.congestion_multiplier - - refine_routing_session_results(self, state) - return finalize_routing_session_results(self, state) - - def verify_all_nets( - self, - results: dict[str, RoutingResult], - netlist: dict[str, tuple[Port, Port]], - ) -> dict[str, RoutingResult]: - final_results: dict[str, RoutingResult] = {} - for net_id, (_, target_p) in netlist.items(): - res = results.get(net_id) - if not res or not res.path: - final_results[net_id] = self._build_routing_result( - net_id=net_id, - path=[], - reached_target=False, - collisions=0, - ) - continue - last_p = res.path[-1].end_port - reached = last_p == target_p - report = self.path_state.verify_path_report(net_id, res.path) - final_results[net_id] = RoutingResult( - net_id=net_id, - path=res.path, - is_valid=(reached and report.is_valid), - collisions=report.collision_count, - reached_target=reached, - outcome=infer_routing_outcome( - has_path=True, - reached_target=reached, - collision_count=report.collision_count, - ), - ) - return final_results diff --git a/inire/router/refiner.py b/inire/router/refiner.py index cdd6ea1..1f9112e 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -7,10 +7,12 @@ from inire.geometry.component_overlap import components_overlap, has_self_overla from inire.geometry.components import Bend90, Straight if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine + from collections.abc import Sequence + + from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port - from inire.router.astar import AStarContext + from inire.router._search import AStarContext def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: current = parent_node @@ -22,7 +24,7 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) return False -def has_self_collision(path: list[ComponentResult]) -> bool: +def has_self_collision(path: Sequence[ComponentResult]) -> bool: return has_self_overlap(path) @@ -33,26 +35,26 @@ class PathRefiner: self.context = context @property - def collision_engine(self) -> CollisionEngine: + def collision_engine(self) -> RoutingWorld: return self.context.cost_evaluator.collision_engine - def path_cost(self, path: list[ComponentResult]) -> float: - total = 0.0 - bend_penalty = self.context.config.bend_penalty - sbend_penalty = self.context.config.sbend_penalty - for comp in path: - total += comp.length - if comp.move_type == "Bend90": - radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0 - if radius > 0: - total += bend_penalty * (10.0 / radius) ** 0.5 - else: - total += bend_penalty - elif comp.move_type == "SBend": - total += sbend_penalty - return total + def path_cost( + self, + path: Sequence[ComponentResult], + *, + net_id: str = "default", + start: Port | None = None, + ) -> float: + if not path: + return 0.0 + actual_start = path[0].start_port if start is None else start + return self.score_path(net_id, actual_start, path) - def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]: + def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float: + weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options) + return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights) + + def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: ports = [start] ports.extend(comp.end_port for comp in path) return ports @@ -79,7 +81,7 @@ class PathRefiner: return -dx, -dy return -dy, dx - def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]: + def _window_query_bounds(self, start: Port, target: Port, path: Sequence[ComponentResult], pad: float) -> tuple[float, float, float, float]: min_x = float(min(start.x, target.x)) min_y = float(min(start.y, target.y)) max_x = float(max(start.x, target.x)) @@ -96,7 +98,7 @@ class PathRefiner: self, start: Port, target: Port, - window_path: list[ComponentResult], + window_path: Sequence[ComponentResult], net_width: float, radius: float, ) -> list[float]: @@ -187,7 +189,7 @@ class PathRefiner: second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) if first_straight < -0.01 or second_straight < -0.01: return None - min_straight = self.context.config.min_straight_length + min_straight = self.context.options.search.min_straight_length if 0.01 < first_straight < min_straight - 0.01: return None if 0.01 < second_straight < min_straight - 0.01: @@ -226,16 +228,16 @@ class PathRefiner: return None return path - def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]: + def _iter_refinement_windows(self, start: Port, path: Sequence[ComponentResult]) -> list[tuple[int, int]]: ports = self._path_ports(start, path) windows: list[tuple[int, int]] = [] - min_radius = min(self.context.config.bend_radii, default=0.0) + min_radius = min(self.context.options.search.bend_radii, default=0.0) for window_size in range(len(path), 0, -1): for start_idx in range(len(path) - window_size + 1): end_idx = start_idx + window_size window = path[start_idx:end_idx] - bend_count = sum(1 for comp in window if comp.move_type == "Bend90") + bend_count = sum(1 for comp in window if comp.move_type == "bend90") if bend_count < 4: continue window_start = ports[start_idx] @@ -266,7 +268,7 @@ class PathRefiner: best_path: list[ComponentResult] | None = None best_candidate_cost = best_cost - for radius in self.context.config.bend_radii: + for radius in self.context.options.search.bend_radii: side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius) for side_extent in side_extents: replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) @@ -297,12 +299,14 @@ class PathRefiner: if not path: return path - bend_count = sum(1 for comp in path if comp.move_type == "Bend90") + path = list(path) + + bend_count = sum(1 for comp in path if comp.move_type == "bend90") if bend_count < 4: return path best_path = path - best_cost = self.path_cost(path) + best_cost = self.score_path(net_id, start, path) for _ in range(3): improved = False diff --git a/inire/router/results.py b/inire/router/results.py new file mode 100644 index 0000000..3548f64 --- /dev/null +++ b/inire/router/results.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from inire.router.outcomes import RoutingOutcome, infer_routing_outcome + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.model import LockedRoute + + +@dataclass(frozen=True, slots=True) +class RoutingReport: + static_collision_count: int = 0 + dynamic_collision_count: int = 0 + self_collision_count: int = 0 + total_length: float = 0.0 + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + nodes_expanded: int + moves_generated: int + moves_added: int + pruned_closed_set: int + pruned_hard_collision: int + pruned_cost: int + + +@dataclass(frozen=True, slots=True) +class RoutingResult: + net_id: str + path: tuple[ComponentResult, ...] + reached_target: bool = False + report: RoutingReport = field(default_factory=RoutingReport) + + def __post_init__(self) -> None: + object.__setattr__(self, "path", tuple(self.path)) + + @property + def collisions(self) -> int: + return self.report.collision_count + + @property + def outcome(self) -> RoutingOutcome: + return infer_routing_outcome( + has_path=bool(self.path), + reached_target=self.reached_target, + collision_count=self.report.collision_count, + ) + + @property + def is_valid(self) -> bool: + return self.outcome == "completed" + + def as_locked_route(self) -> LockedRoute: + from inire.model import LockedRoute + + return LockedRoute.from_path(self.path) diff --git a/inire/router/session.py b/inire/router/session.py deleted file mode 100644 index e037eaf..0000000 --- a/inire/router/session.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import random -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal - -from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry - -if TYPE_CHECKING: - from collections.abc import Callable - - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.pathfinder import PathFinder, RoutingResult - - -@dataclass -class RoutingSessionState: - netlist: dict[str, tuple[Port, Port]] - net_widths: dict[str, float] - results: dict[str, RoutingResult] - all_net_ids: list[str] - needs_self_collision_check: set[str] - start_time: float - session_timeout: float - initial_paths: dict[str, list[ComponentResult]] | None - store_expanded: bool - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None - shuffle_nets: bool - sort_nets: Literal["shortest", "longest", "user", None] - seed: int | None - - -def create_routing_session_state( - finder: PathFinder, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - *, - store_expanded: bool, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, - shuffle_nets: bool, - sort_nets: Literal["shortest", "longest", "user", None], - initial_paths: dict[str, list[ComponentResult]] | None, - seed: int | None, -) -> RoutingSessionState: - num_nets = len(netlist) - return RoutingSessionState( - netlist=netlist, - net_widths=net_widths, - results={}, - all_net_ids=list(netlist.keys()), - needs_self_collision_check=set(), - start_time=time.monotonic(), - session_timeout=max(60.0, 10.0 * num_nets * finder.max_iterations), - initial_paths=initial_paths, - store_expanded=store_expanded, - iteration_callback=iteration_callback, - shuffle_nets=shuffle_nets, - sort_nets=sort_nets, - seed=seed, - ) - - -def prepare_routing_session_state( - finder: PathFinder, - state: RoutingSessionState, -) -> None: - if state.initial_paths is None: - warm_start_order = state.sort_nets if state.sort_nets is not None else finder.warm_start - if warm_start_order is not None: - state.initial_paths = finder._build_greedy_warm_start_paths(state.netlist, state.net_widths, warm_start_order) - finder.context.clear_static_caches() - - if state.sort_nets and state.sort_nets != "user": - state.all_net_ids.sort( - key=lambda net_id: abs(state.netlist[net_id][1].x - state.netlist[net_id][0].x) - + abs(state.netlist[net_id][1].y - state.netlist[net_id][0].y), - reverse=(state.sort_nets == "longest"), - ) - - -def run_routing_iteration( - finder: PathFinder, - state: RoutingSessionState, - iteration: int, -) -> dict[str, RoutingOutcome] | None: - outcomes: dict[str, RoutingOutcome] = {} - finder.accumulated_expanded_nodes = [] - finder.metrics.reset_per_route() - - if state.shuffle_nets and (iteration > 0 or state.initial_paths is None): - iteration_seed = (state.seed + iteration) if state.seed is not None else None - random.Random(iteration_seed).shuffle(state.all_net_ids) - - for net_id in state.all_net_ids: - start, target = state.netlist[net_id] - if time.monotonic() - state.start_time > state.session_timeout: - finder.path_state.finalize_dynamic_tree() - return None - - width = state.net_widths.get(net_id, 2.0) - result, net_congestion = finder._route_net_once( - net_id, - start, - target, - width, - iteration, - state.initial_paths, - state.store_expanded, - state.needs_self_collision_check, - ) - state.results[net_id] = result - outcomes[net_id] = net_congestion - - if state.iteration_callback: - state.iteration_callback(iteration, state.results) - return outcomes - - -def refine_routing_session_results( - finder: PathFinder, - state: RoutingSessionState, -) -> None: - if not finder.refine_paths or not state.results: - return - - for net_id in state.all_net_ids: - res = state.results.get(net_id) - if not res or not res.path or routing_outcome_needs_retry(res.outcome): - continue - start, target = state.netlist[net_id] - width = state.net_widths.get(net_id, 2.0) - finder.path_state.remove_path(net_id) - refined_path = finder._refine_path(net_id, start, target, width, res.path) - finder._install_path(net_id, refined_path) - report = finder.path_state.verify_path_report(net_id, refined_path) - state.results[net_id] = finder._build_routing_result( - net_id=net_id, - path=refined_path, - reached_target=res.reached_target, - collisions=report.collision_count, - ) - - -def finalize_routing_session_results( - finder: PathFinder, - state: RoutingSessionState, -) -> dict[str, RoutingResult]: - finder.path_state.finalize_dynamic_tree() - return finder.verify_all_nets(state.results, state.netlist) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index 38fb6af..ed83d00 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING import rtree if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.primitives import Port @@ -18,7 +18,7 @@ class VisibilityManager: """ __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version") - def __init__(self, collision_engine: CollisionEngine) -> None: + def __init__(self, collision_engine: RoutingWorld) -> None: self.collision_engine = collision_engine self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() @@ -153,10 +153,3 @@ class VisibilityManager: if corner_idx is not None and corner_idx in self._corner_graph: return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [] - - def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: - """ - Backward-compatible alias for arbitrary-point visibility queries. - Prefer `get_corner_visibility()` in routing code and `get_point_visibility()` elsewhere. - """ - return self.get_point_visibility(origin, max_dist=max_dist) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py index d13becd..3513c62 100644 --- a/inire/tests/benchmark_scaling.py +++ b/inire/tests/benchmark_scaling.py @@ -1,10 +1,13 @@ import time + +from inire import NetSpec from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.router.danger_map import DangerMap from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.pathfinder import PathFinder +from inire.router._astar_types import AStarMetrics +from inire.router._router import PathFinder +from inire.tests.support import build_context def benchmark_scaling() -> None: print("Starting Scalability Benchmark...") @@ -20,25 +23,33 @@ def benchmark_scaling() -> None: assert mem_gb < 2.0 # 2. Node Expansion Rate (50 nets) - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Use a smaller area for routing benchmark to keep it fast routing_bounds = (0, 0, 1000, 1000) danger_map = DangerMap(bounds=routing_bounds) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - num_nets = 50 netlist = {} for i in range(num_nets): # Parallel nets spaced by 10um netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0)) + metrics = AStarMetrics() + pf = PathFinder( + build_context( + evaluator, + bounds=routing_bounds, + nets=( + NetSpec(net_id=net_id, start=start, target=target, width=2.0) + for net_id, (start, target) in netlist.items() + ), + ), + metrics=metrics, + ) print(f"Routing {num_nets} nets...") start_time = time.monotonic() - results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0)) + results = pf.route_all() end_time = time.monotonic() total_time = end_time - start_time diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index aeffc1c..da4f98c 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -6,12 +6,13 @@ from typing import Callable from shapely.geometry import Polygon, box -from inire.geometry.collision import CollisionEngine +from inire import NetSpec, RoutingResult +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics +from inire.router._astar_types import AStarMetrics from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder, RoutingResult +from inire.tests.support import build_context, build_pathfinder @dataclass(frozen=True) @@ -28,30 +29,6 @@ class ScenarioDefinition: run: Callable[[], ScenarioOutcome] -def _build_router( - *, - bounds: tuple[float, float, float, float], - clearance: float = 2.0, - obstacles: list[Polygon] | None = None, - evaluator_kwargs: dict[str, float] | None = None, - context_kwargs: dict[str, object] | None = None, - pathfinder_kwargs: dict[str, object] | None = None, -) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]: - static_obstacles = obstacles or [] - engine = CollisionEngine(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(static_obstacles) - - evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) - context = AStarContext(evaluator, **(context_kwargs or {})) - metrics = AStarMetrics() - pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {})) - return engine, evaluator, context, metrics, pathfinder - - def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: return ScenarioOutcome( duration_s=duration_s, @@ -70,7 +47,7 @@ def _build_evaluator( sbend_penalty: float = 150.0, ) -> CostEvaluator: static_obstacles = obstacles or [] - engine = CollisionEngine(clearance=clearance) + engine = RoutingWorld(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) @@ -79,92 +56,155 @@ def _build_evaluator( return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty) +def _net_specs( + netlist: dict[str, tuple[Port, Port]], + widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_routing_stack( + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + widths: dict[str, float], + clearance: float = 2.0, + obstacles: list[Polygon] | None = None, + evaluator_kwargs: dict[str, float] | None = None, + request_kwargs: dict[str, object] | None = None, +) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]: + static_obstacles = obstacles or [] + engine = RoutingWorld(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=bounds) + danger_map.precompute(static_obstacles) + + evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) + metrics = AStarMetrics() + pathfinder = build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + metrics=metrics, + **(request_kwargs or {}), + ) + return engine, evaluator, metrics, pathfinder + + def run_example_01() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]}) netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))} + widths = {"net1": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, {"net1": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_02() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "bend_penalty": 50.0, - "sbend_penalty": 150.0, - }, - context_kwargs={ - "bend_radii": [10.0], - "sbend_radii": [10.0], - }, - pathfinder_kwargs={"base_congestion_penalty": 1000.0}, - ) 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)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "greedy_h_weight": 1.5, + "bend_penalty": 50.0, + "sbend_penalty": 150.0, + }, + request_kwargs={ + "bend_radii": [10.0], + "sbend_radii": [10.0], + "base_penalty": 1000.0, + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_03() -> ScenarioOutcome: - engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]}) + netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} + widths_a = {"netA": 2.0} + engine, evaluator, _, pathfinder = _build_routing_stack( + bounds=(0, -50, 100, 50), + netlist=netlist_a, + widths=widths_a, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - engine.lock_net("netA") - results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}) + results_a = pathfinder.route_all() + for polygon in results_a["netA"].as_locked_route().geometry: + engine.add_static_obstacle(polygon) + results_b = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}), + bend_radii=[10.0], + ).route_all() t1 = perf_counter() return _summarize({**results_a, **results_b}, t1 - t0) def run_example_04() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "unit_length_cost": 1.0, - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - context_kwargs={ - "node_limit": 50000, - "bend_radii": [10.0, 30.0], - "sbend_offsets": [5.0], - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - ) netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } widths = {"sbend_only": 2.0, "multi_radii": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "unit_length_cost": 1.0, + "bend_penalty": 10.0, + "sbend_penalty": 20.0, + }, + request_kwargs={ + "node_limit": 50000, + "bend_radii": [10.0, 30.0], + "sbend_offsets": [5.0], + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_05() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 200, 200), - evaluator_kwargs={"bend_penalty": 50.0}, - context_kwargs={"bend_radii": [20.0]}, - ) netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 200, 200), + netlist=netlist, + widths=widths, + evaluator_kwargs={"bend_penalty": 50.0}, + request_kwargs={"bend_radii": [20.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) @@ -178,32 +218,35 @@ def run_example_06() -> ScenarioOutcome: ] scenarios = [ ( - AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="arc"), + _build_evaluator(bounds, obstacles=obstacles), {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}, {"arc_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False}, ), ( - AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="bbox"), + _build_evaluator(bounds, obstacles=obstacles), {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}, {"bbox_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False}, ), ( - AStarContext( - _build_evaluator(bounds, obstacles=obstacles), - bend_radii=[10.0], - bend_collision_type="clipped_bbox", - bend_clip_margin=1.0, - ), + _build_evaluator(bounds, obstacles=obstacles), {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, {"clipped_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "clipped_bbox", "use_tiered_strategy": False}, ), ] t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} - for context, netlist, net_widths in scenarios: - pathfinder = PathFinder(context, use_tiered_strategy=False) - combined_results.update(pathfinder.route_all(netlist, net_widths)) + for evaluator, netlist, net_widths, request_kwargs in scenarios: + pathfinder = build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, net_widths), + **request_kwargs, + ) + combined_results.update(pathfinder.route_all()) t1 = perf_counter() return _summarize(combined_results, t1 - t0) @@ -214,29 +257,6 @@ def run_example_07() -> ScenarioOutcome: box(450, 0, 550, 400), box(450, 600, 550, 1000), ] - _, evaluator, _, metrics, pathfinder = _build_router( - bounds=bounds, - clearance=6.0, - obstacles=obstacles, - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "unit_length_cost": 0.1, - "bend_penalty": 100.0, - "sbend_penalty": 400.0, - "congestion_penalty": 100.0, - }, - context_kwargs={ - "node_limit": 2000000, - "bend_radii": [50.0], - "sbend_radii": [50.0], - }, - pathfinder_kwargs={ - "max_iterations": 15, - "base_congestion_penalty": 100.0, - "congestion_multiplier": 1.4, - }, - ) - num_nets = 10 start_x = 50 start_y_base = 500 - (num_nets * 10.0) / 2.0 @@ -249,6 +269,31 @@ def run_example_07() -> ScenarioOutcome: sy = int(round(start_y_base + index * 10.0)) ey = int(round(end_y_base + index * end_y_pitch)) netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0)) + widths = dict.fromkeys(netlist, 2.0) + _, evaluator, metrics, pathfinder = _build_routing_stack( + bounds=bounds, + netlist=netlist, + widths=widths, + clearance=6.0, + obstacles=obstacles, + evaluator_kwargs={ + "greedy_h_weight": 1.5, + "unit_length_cost": 0.1, + "bend_penalty": 100.0, + "sbend_penalty": 400.0, + }, + request_kwargs={ + "node_limit": 2000000, + "bend_radii": [50.0], + "sbend_radii": [50.0], + "max_iterations": 15, + "base_penalty": 100.0, + "multiplier": 1.4, + "capture_expanded": True, + "shuffle_nets": True, + "seed": 42, + }, + ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) @@ -256,14 +301,7 @@ def run_example_07() -> ScenarioOutcome: metrics.reset_per_route() t0 = perf_counter() - results = pathfinder.route_all( - netlist, - dict.fromkeys(netlist, 2.0), - store_expanded=True, - iteration_callback=iteration_callback, - shuffle_nets=True, - seed=42, - ) + results = pathfinder.route_all(iteration_callback=iteration_callback) t1 = perf_counter() return _summarize(results, t1 - t0) @@ -272,21 +310,30 @@ def run_example_08() -> ScenarioOutcome: bounds = (0, 0, 150, 150) netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"custom_bend": 2.0} - - context_std = AStarContext(_build_evaluator(bounds), bend_radii=[10.0], sbend_radii=[]) - context_custom = AStarContext( - _build_evaluator(bounds), - bend_radii=[10.0], - bend_collision_type=Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]), - sbend_radii=[], - ) + custom_model = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + standard_evaluator = _build_evaluator(bounds) + custom_evaluator = _build_evaluator(bounds) t0 = perf_counter() - results_std = PathFinder(context_std, AStarMetrics(), use_tiered_strategy=False).route_all(netlist, widths) - results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all( - {"custom_model": netlist["custom_bend"]}, - {"custom_model": 2.0}, - ) + results_std = build_pathfinder( + standard_evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + bend_radii=[10.0], + sbend_radii=[], + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() + results_custom = build_pathfinder( + custom_evaluator, + bounds=bounds, + nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + bend_radii=[10.0], + bend_collision_type=custom_model, + sbend_radii=[], + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() t1 = perf_counter() return _summarize({**results_std, **results_custom}, t1 - t0) @@ -296,16 +343,18 @@ def run_example_09() -> ScenarioOutcome: box(35, 35, 45, 65), box(55, 35, 65, 65), ] - _, _, _, _, pathfinder = _build_router( + netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} + widths = {"budget_limited_net": 2.0} + _, _, _, pathfinder = _build_routing_stack( bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - context_kwargs={"node_limit": 3, "bend_radii": [10.0]}, - pathfinder_kwargs={"warm_start": None}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None}, ) - netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} t0 = perf_counter() - results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) @@ -313,7 +362,7 @@ def run_example_09() -> ScenarioOutcome: SCENARIOS: tuple[ScenarioDefinition, ...] = ( ScenarioDefinition("example_01_simple_route", run_example_01), ScenarioDefinition("example_02_congestion_resolution", run_example_02), - ScenarioDefinition("example_03_locked_paths", run_example_03), + ScenarioDefinition("example_03_locked_routes", run_example_03), ScenarioDefinition("example_04_sbends_and_radii", run_example_04), ScenarioDefinition("example_05_orientation_stress", run_example_05), ScenarioDefinition("example_06_bend_collision_models", run_example_06), diff --git a/inire/tests/support.py b/inire/tests/support.py new file mode 100644 index 0000000..3461790 --- /dev/null +++ b/inire/tests/support.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from inire.model import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, +) +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder + + +def build_problem( + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + **overrides: object, +) -> RoutingProblem: + return RoutingProblem( + bounds=bounds, + nets=tuple(nets), + **overrides, + ) + + +def build_request( + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + **overrides: object, +) -> RoutingProblem: + return build_problem(bounds=bounds, nets=nets, **overrides) + + +def build_options( + *, + objective: ObjectiveWeights | None = None, + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, + refinement: RefinementOptions | None = None, + diagnostics: DiagnosticsOptions | None = None, + **overrides: object, +) -> RoutingOptions: + if objective is None: + objective = ObjectiveWeights() + if search is None: + search = SearchOptions() + if congestion is None: + congestion = CongestionOptions() + if refinement is None: + refinement = RefinementOptions() + if diagnostics is None: + diagnostics = DiagnosticsOptions() + + search_fields = set(SearchOptions.__dataclass_fields__) + congestion_fields = set(CongestionOptions.__dataclass_fields__) + refinement_fields = set(RefinementOptions.__dataclass_fields__) + diagnostics_fields = set(DiagnosticsOptions.__dataclass_fields__) + objective_fields = set(ObjectiveWeights.__dataclass_fields__) + + search_overrides = {key: value for key, value in overrides.items() if key in search_fields} + congestion_overrides = {key: value for key, value in overrides.items() if key in congestion_fields} + refinement_overrides = {key: value for key, value in overrides.items() if key in refinement_fields} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in diagnostics_fields} + objective_overrides = {key: value for key, value in overrides.items() if key in objective_fields} + + unknown = set(overrides) - search_fields - congestion_fields - refinement_fields - diagnostics_fields - objective_fields + if unknown: + unknown_names = ", ".join(sorted(unknown)) + raise TypeError(f"Unsupported RoutingOptions overrides: {unknown_names}") + + resolved_objective = objective if not objective_overrides else ObjectiveWeights( + **{ + field: getattr(objective, field) + for field in objective_fields + } + | objective_overrides + ) + resolved_search = search if not search_overrides else SearchOptions( + **{ + field: getattr(search, field) + for field in search_fields + } + | search_overrides + ) + resolved_congestion = congestion if not congestion_overrides else CongestionOptions( + **{ + field: getattr(congestion, field) + for field in congestion_fields + } + | congestion_overrides + ) + resolved_refinement = refinement if not refinement_overrides else RefinementOptions( + **{ + field: getattr(refinement, field) + for field in refinement_fields + } + | refinement_overrides + ) + resolved_diagnostics = diagnostics if not diagnostics_overrides else DiagnosticsOptions( + **{ + field: getattr(diagnostics, field) + for field in diagnostics_fields + } + | diagnostics_overrides + ) + return RoutingOptions( + search=resolved_search, + objective=resolved_objective, + congestion=resolved_congestion, + refinement=resolved_refinement, + diagnostics=resolved_diagnostics, + ) + + +def build_context( + evaluator, + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + problem: RoutingProblem | None = None, + options: RoutingOptions | None = None, + **overrides: object, +) -> AStarContext: + resolved_problem = problem if problem is not None else build_problem(bounds=bounds, nets=nets) + resolved_options = options if options is not None else build_options(**overrides) + return AStarContext( + evaluator, + resolved_problem, + resolved_options, + ) + + +def build_pathfinder( + evaluator, + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + netlist: dict[str, tuple[object, object]] | None = None, + net_widths: dict[str, float] | None = None, + problem: RoutingProblem | None = None, + options: RoutingOptions | None = None, + **overrides: object, +) -> PathFinder: + resolved_problem = problem + if resolved_problem is None: + resolved_nets = tuple(nets) + if netlist is not None: + widths = {} if net_widths is None else net_widths + resolved_nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + resolved_problem = build_problem(bounds=bounds, nets=resolved_nets) + resolved_options = options if options is not None else build_options(**overrides) + return PathFinder(build_context(evaluator, bounds=bounds, problem=resolved_problem, options=resolved_options)) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py new file mode 100644 index 0000000..8474606 --- /dev/null +++ b/inire/tests/test_api.py @@ -0,0 +1,108 @@ +from shapely.geometry import box + +from inire import ( + CongestionOptions, + DiagnosticsOptions, + LockedRoute, + NetSpec, + ObjectiveWeights, + Port, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, + route, +) +from inire.geometry.components import Straight + + +def test_route_problem_smoke() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + ) + + run = route(problem) + + assert set(run.results_by_net) == {"net1"} + assert run.results_by_net["net1"].is_valid + + +def test_route_problem_supports_configs_and_debug_data() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 10, 0), Port(90, 90, 0), width=2.0),), + static_obstacles=(box(40, 0, 60, 70),), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + node_limit=50000, + greedy_h_weight=1.2, + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(warm_start=None), + refinement=RefinementOptions(enabled=True), + diagnostics=DiagnosticsOptions(capture_expanded=True), + ) + + run = route(problem, options=options) + + assert run.results_by_net["net1"].reached_target + assert run.expanded_nodes + assert run.metrics.nodes_expanded > 0 + + +def test_route_problem_locked_routes_become_static_obstacles() -> None: + locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),) + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),), + locked_routes={"locked": LockedRoute.from_path(locked)}, + ) + options = RoutingOptions( + congestion=CongestionOptions(max_iterations=1, warm_start=None), + refinement=RefinementOptions(enabled=False), + ) + + run = route(problem, options=options) + result = run.results_by_net["crossing"] + + assert not result.is_valid + + +def test_locked_routes_enable_incremental_requests_without_sessions() -> None: + problem_a = RoutingProblem( + bounds=(0, -50, 100, 50), + nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), + ) + options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) + results_a = route(problem_a, options=options) + assert results_a.results_by_net["netA"].is_valid + + problem_b = RoutingProblem( + bounds=(0, -50, 100, 50), + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()}, + ) + results_b = route(problem_b, options=options) + + assert results_b.results_by_net["netB"].is_valid + + +def test_route_results_metrics_are_snapshots() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + ) + options = RoutingOptions() + run1 = route(problem, options=options) + first_metrics = run1.metrics + run2 = route(problem, options=options) + + assert first_metrics == run1.metrics + assert run1.metrics is not run2.metrics + assert first_metrics.nodes_expanded > 0 diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index e93e401..58597fd 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,34 +1,36 @@ import pytest from shapely.geometry import Polygon -import inire.router.astar as astar_module -from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.collision import CollisionEngine +from inire import RoutingResult +from inire.geometry.components import Bend90, Straight +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar -from inire.router.config import CostConfig +from inire.router._astar_types import AStarContext +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import RoutingResult +from inire.tests.support import build_context, build_options, build_problem from inire.utils.validation import validate_routing_result +BOUNDS = (0, -50, 150, 150) + @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, -50, 150, 150)) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) def test_astar_straight(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) + context = build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(50, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -38,14 +40,14 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[10.0]) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0]) start = Port(0, 0, 0) # 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -59,13 +61,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -74,13 +76,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) + context = build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(10.1, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) assert target.x == 10 validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) @@ -89,7 +91,7 @@ def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: def test_validate_routing_result_checks_expected_start() -> None: path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result( result, @@ -105,7 +107,7 @@ def test_validate_routing_result_checks_expected_start() -> None: def test_validate_routing_result_uses_exact_component_geometry() -> None: bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0) - result = RoutingResult(net_id="test", path=[bend], is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=[bend], reached_target=True) obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) validation = validate_routing_result( @@ -119,18 +121,19 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" -def test_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0) +def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: + basic_evaluator.bend_penalty = 120.0 + basic_evaluator.sbend_penalty = 240.0 + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0]) - assert isinstance(basic_evaluator.config, CostConfig) - assert basic_evaluator.config is not context.config - assert basic_evaluator.config.bend_penalty == 120.0 - assert basic_evaluator.config.sbend_penalty == 240.0 - assert basic_evaluator.config.min_bend_radius == 5.0 + assert basic_evaluator.bend_penalty == 120.0 + assert basic_evaluator.sbend_penalty == 240.0 + assert context.options.search.bend_radii == (5.0,) + assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[10.0], bend_collision_type="arc") + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc") route_astar( Port(0, 0, 0), @@ -141,254 +144,92 @@ def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: C return_partial=True, ) - assert context.config.bend_collision_type == "arc" + assert context.options.search.bend_collision_type == "arc" -def test_expand_moves_only_shortens_consecutive_straights( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0) - prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) - current = astar_module.AStarNode( - prev_result.end_port, - g_cost=prev_result.length, - h_cost=0.0, - component_result=prev_result, - ) +def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None: + obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) + basic_evaluator.collision_engine.add_static_obstacle(obstacle) + basic_evaluator.danger_map.precompute([obstacle]) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2) + start = Port(0, 0, 0) + target = Port(60, 0, 0) - emitted: list[tuple[str, tuple]] = [] + partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True) + no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False) - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(80, 0, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"] - assert straight_lengths - assert all(length < prev_result.length for length in straight_lengths) + assert partial_path is not None + assert partial_path + assert partial_path[-1].end_port != target + assert no_partial_path is None -def test_expand_moves_does_not_chain_sbends( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0) - prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0) - current = astar_module.AStarNode( - prev_result.end_port, - g_cost=prev_result.length, - h_cost=0.0, - component_result=prev_result, - ) - - emitted: list[str] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append(args[9]) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(60, 10, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - assert "SB" not in emitted - assert emitted - - -def test_add_node_rejects_self_collision_against_ancestor( - basic_evaluator: CostEvaluator, -) -> None: - context = AStarContext(basic_evaluator) - metrics = astar_module.AStarMetrics() - target = Port(100, 0, 0) - - root = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) - ancestor = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) - ancestor_node = astar_module.AStarNode( - ancestor.end_port, - g_cost=ancestor.length, - h_cost=0.0, - parent=root, - component_result=ancestor, - ) - parent_result = Straight.generate(Port(30, 0, 0), 10.0, width=2.0, dilation=1.0) - parent_node = astar_module.AStarNode( - parent_result.end_port, - g_cost=ancestor.length + parent_result.length, - h_cost=0.0, - parent=ancestor_node, - component_result=parent_result, - ) - overlapping_move = Straight.generate(Port(5, 0, 0), 10.0, width=2.0, dilation=1.0) - - open_set: list[astar_module.AStarNode] = [] - astar_module.add_node( - parent_node, - overlapping_move, - target, - net_width=2.0, - net_id="test", - open_set=open_set, - closed_set={}, - context=context, - metrics=metrics, - congestion_cache={}, - move_type="S", - cache_key=("self_collision",), - self_collision_check=True, - ) - - assert not open_set - assert metrics.moves_added == 0 - - -def test_expand_moves_adds_sbend_aligned_straight_stop_points( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext( +def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: + context = build_context( basic_evaluator, + bounds=BOUNDS, bend_radii=[10.0], sbend_radii=[10.0], + sbend_offsets=[10.0], max_straight_length=150.0, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + target = Port(100, 10, 0) - emitted: list[tuple[str, tuple]] = [] + path = route_astar(start, target, net_width=2.0, context=context) - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(100, 10, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, + assert path is not None + assert path[-1].end_port == target + assert sum(1 for component in path if component.move_type == "sbend") == 1 + assert not any( + first.move_type == second.move_type == "sbend" + for first, second in zip(path, path[1:], strict=False) ) - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - sbend_span = astar_module._sbend_forward_span(10.0, 10.0) - assert sbend_span is not None - assert int(round(100.0 - sbend_span)) in straight_lengths - assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths - -def test_expand_moves_adds_exact_corner_visibility_stop_points( +@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"]) +def test_route_astar_supports_all_visibility_guidance_modes( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, + visibility_guidance: str, ) -> None: - context = AStarContext( - basic_evaluator, - bend_radii=[10.0], - max_straight_length=150.0, - visibility_guidance="exact_corner", - ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) - - monkeypatch.setattr( - astar_module.VisibilityManager, - "get_corner_visibility", - lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)], - ) - - emitted: list[tuple[str, tuple]] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(120, 20, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - assert 40 in straight_lengths - assert 75 in straight_lengths - - -def test_expand_moves_adds_tangent_corner_visibility_stop_points( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - class DummyCornerIndex: - def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]: - return [0, 1] - - context = AStarContext( + obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)]) + basic_evaluator.collision_engine.add_static_obstacle(obstacle) + basic_evaluator.danger_map.precompute([obstacle]) + context = build_context( basic_evaluator, + bounds=BOUNDS, bend_radii=[10.0], sbend_radii=[], max_straight_length=150.0, - visibility_guidance="tangent_corner", + visibility_guidance=visibility_guidance, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + target = Port(80, 50, 0) - monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None) - context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)] - context.visibility_manager.corner_index = DummyCornerIndex() - monkeypatch.setattr( - type(context.cost_evaluator.collision_engine), - "ray_cast", - lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist, + path = route_astar(start, target, net_width=2.0, context=context) + + assert path is not None + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + assert validation["connectivity_ok"] + + +def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: + context = AStarContext( + basic_evaluator, + build_problem(bounds=BOUNDS), + build_options( + min_straight_length=1.0, + max_straight_length=100.0, + ), + max_cache_size=2, ) + start = Port(0, 0, 0) + targets = [Port(length, 0, 0) for length in range(10, 70, 10)] - emitted: list[tuple[str, tuple]] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(120, 20, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - assert 40 in straight_lengths - assert 70 in straight_lengths + for target in targets: + path = route_astar(start, target, net_width=2.0, context=context) + assert path is not None + assert path[-1].end_port == target diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 3f17b1c..5866019 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,13 +1,13 @@ import pytest import numpy from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder, RoutingResult +from inire import RoutingResult +from inire.tests.support import build_pathfinder def test_clearance_thresholds(): """ @@ -16,12 +16,12 @@ def test_clearance_thresholds(): """ # Clearance = 2.0, Width = 2.0 # Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0 - ce = CollisionEngine(clearance=2.0) + ce = RoutingWorld(clearance=2.0) # Net 1: Centerline at y=0 p1 = Port(0, 0, 0) res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0) - ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry) + ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry) # Net 2: Parallel to Net 1 # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. @@ -47,12 +47,10 @@ def test_verify_all_nets_cases(): """ Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases. """ - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, 0, 100, 100)) danger_map.precompute([]) evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map) - context = AStarContext(cost_evaluator=evaluator) - pf = PathFinder(context, warm_start=None, max_iterations=1) # Case 1: Parallel paths exactly at clearance (Should be VALID) netlist_parallel_ok = { @@ -60,8 +58,15 @@ def test_verify_all_nets_cases(): "net2": (Port(0, 54, 0), Port(100, 54, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} - - results = pf.route_all(netlist_parallel_ok, net_widths) + + results = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_ok, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -74,7 +79,14 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = pf.route_all(netlist_parallel_fail, net_widths) + results_p = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_fail, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() # verify_all_nets should flag both as invalid because they cross-collide assert not results_p["net3"].is_valid assert not results_p["net4"].is_valid @@ -87,6 +99,13 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = pf.route_all(netlist_cross, net_widths) + results_c = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_cross, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() assert not results_c["net5"].is_valid assert not results_c["net6"].is_valid diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index f83bb16..8a8c2b9 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,13 +1,13 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight def test_collision_detection() -> None: # Clearance = 2um - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # 10x10 um obstacle at (10,10) obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) @@ -33,7 +33,7 @@ def test_collision_detection() -> None: def test_safety_zone() -> None: # Use zero clearance for this test to verify the 2nm port safety zone # against the physical obstacle boundary. - engine = CollisionEngine(clearance=0.0) + engine = RoutingWorld(clearance=0.0) obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) engine.add_static_obstacle(obstacle) @@ -50,7 +50,7 @@ def test_safety_zone() -> None: def test_configurable_max_net_width() -> None: # Large max_net_width (10.0) -> large pre-dilation (6.0) - engine = CollisionEngine(clearance=2.0, max_net_width=10.0) + engine = RoutingWorld(clearance=2.0, max_net_width=10.0) obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) engine.add_static_obstacle(obstacle) @@ -65,7 +65,7 @@ def test_configurable_max_net_width() -> None: def test_ray_cast_width_clearance() -> None: # Clearance = 2.0um, Width = 2.0um. # Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um. - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Obstacle at x=10 to 20 obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) @@ -83,7 +83,7 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) engine.add_static_obstacle(obstacle) @@ -103,3 +103,54 @@ def test_check_move_static_clearance() -> None: start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) + + +def test_verify_path_report_preserves_long_net_id() -> None: + engine = RoutingWorld(clearance=2.0) + net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789" + path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + geoms = [poly for component in path for poly in component.collision_geometry] + dilated = [poly for component in path for poly in component.dilated_collision_geometry] + + engine.add_path(net_id, geoms, dilated_geometry=dilated) + report = engine.verify_path_report(net_id, path) + + assert report.dynamic_collision_count == 0 + + +def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None: + engine = RoutingWorld(clearance=2.0) + shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_" + net_a = f"{shared_prefix}A" + net_b = f"{shared_prefix}B" + path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + + engine.add_path( + net_a, + [poly for component in path_a for poly in component.collision_geometry], + dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry], + ) + engine.add_path( + net_b, + [poly for component in path_b for poly in component.collision_geometry], + dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry], + ) + + report = engine.verify_path_report(net_a, path_a) + + assert report.dynamic_collision_count == 1 + + +def test_remove_path_clears_dynamic_path() -> None: + engine = RoutingWorld(clearance=2.0) + path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + geoms = [poly for component in path for poly in component.collision_geometry] + dilated = [poly for component in path for poly in component.dilated_collision_geometry] + + engine.add_path("netA", geoms, dilated_geometry=dilated) + assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"} + + engine.remove_path("netA") + assert list(engine.iter_dynamic_paths()) == [] + assert len(engine._static_obstacles.geometries) == 0 diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 0c64751..9ac3c98 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,11 +1,12 @@ import pytest +from dataclasses import FrozenInstanceError from shapely.affinity import rotate as shapely_rotate from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port def test_straight_generation() -> None: @@ -16,15 +17,16 @@ def test_straight_generation() -> None: assert result.end_port.x == 10.0 assert result.end_port.y == 0.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 1 + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 1 # Bounds of the polygon - minx, miny, maxx, maxy = result.geometry[0].bounds + minx, miny, maxx, maxy = result.collision_geometry[0].bounds assert minx == 0.0 assert maxx == 10.0 assert miny == -1.0 assert maxy == 1.0 + assert isinstance(result.collision_geometry, tuple) def test_bend90_generation() -> None: @@ -36,13 +38,13 @@ def test_bend90_generation() -> None: result_cw = Bend90.generate(start, radius, width, direction="CW") assert result_cw.end_port.x == 10.0 assert result_cw.end_port.y == -10.0 - assert result_cw.end_port.orientation == 270.0 + assert result_cw.end_port.r == 270.0 # CCW bend result_ccw = Bend90.generate(start, radius, width, direction="CCW") assert result_ccw.end_port.x == 10.0 assert result_ccw.end_port.y == 10.0 - assert result_ccw.end_port.orientation == 90.0 + assert result_ccw.end_port.r == 90.0 def test_sbend_generation() -> None: @@ -53,8 +55,8 @@ def test_sbend_generation() -> None: result = SBend.generate(start, offset, radius, width) assert result.end_port.y == 5.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 2 # Optimization: returns individual arcs + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs # Verify failure for large offset with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"): @@ -70,7 +72,7 @@ def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() -> result = SBend.generate(start, offset, radius, width) assert result.end_port.y == -5.0 - second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds + second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds assert second_arc_maxy <= width / 2.0 + 1e-6 assert second_arc_miny < -width / 2.0 @@ -84,21 +86,21 @@ def test_bend_collision_models() -> None: res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") # Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) - minx, miny, maxx, maxy = res_bbox.geometry[0].bounds + minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds assert minx <= 0.0 + 1e-6 assert maxx >= 10.0 - 1e-6 assert miny <= 0.0 + 1e-6 assert maxy >= 10.0 - 1e-6 # 2. Clipped BBox model - res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) + res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox") # Conservative 8-point approximation should still be tighter than the full bbox. - assert len(res_clipped.geometry[0].exterior.coords) - 1 == 8 - assert res_clipped.geometry[0].area < res_bbox.geometry[0].area + assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8 + assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area # It should also conservatively contain the true arc. res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc") - assert res_clipped.geometry[0].covers(res_arc.geometry[0]) + assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0]) def test_custom_bend_collision_polygon_uses_local_transform() -> None: @@ -119,20 +121,18 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None: expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False) expected = shapely_translate(expected, center_xy[0], center_xy[1]) - assert result.geometry[0].symmetric_difference(expected).area < 1e-6 - assert result.actual_geometry is not None - assert result.actual_geometry[0].symmetric_difference(expected).area < 1e-6 + assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6 + assert result.physical_geometry[0].symmetric_difference(expected).area < 1e-6 -def test_custom_bend_collision_polygon_becomes_actual_geometry() -> None: +def test_custom_bend_collision_polygon_keeps_collision_and_physical_geometry_aligned() -> None: custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0) - assert result.actual_geometry is not None - assert result.dilated_actual_geometry is not None - assert result.geometry[0].symmetric_difference(result.actual_geometry[0]).area < 1e-6 - assert result.dilated_geometry is not None - assert result.dilated_geometry[0].symmetric_difference(result.dilated_actual_geometry[0]).area < 1e-6 + assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area < 1e-6 + assert result.dilated_collision_geometry is not None + assert result.dilated_physical_geometry is not None + assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area < 1e-6 def test_sbend_collision_models() -> None: @@ -143,11 +143,11 @@ def test_sbend_collision_models() -> None: res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") # Geometry should be a list of individual bbox polygons for each arc - assert len(res_bbox.geometry) == 2 + assert len(res_bbox.collision_geometry) == 2 res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") - area_bbox = sum(p.area for p in res_bbox.geometry) - area_arc = sum(p.area for p in res_arc.geometry) + area_bbox = sum(p.area for p in res_bbox.collision_geometry) + area_arc = sum(p.area for p in res_arc.collision_geometry) assert area_bbox > area_arc @@ -161,14 +161,14 @@ def test_sbend_continuity() -> None: res = SBend.generate(start, offset, radius, width) # Target orientation should be same as start - assert abs(res.end_port.orientation - 90.0) < 1e-6 + assert abs(res.end_port.r - 90.0) < 1e-6 # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 # Geometry should be a list of valid polygons - assert len(res.geometry) == 2 - for p in res.geometry: + assert len(res.collision_geometry) == 2 + for p in res.collision_geometry: assert p.is_valid @@ -185,8 +185,8 @@ def test_arc_sagitta_precision() -> None: # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse * 2 @@ -205,12 +205,19 @@ def test_component_transform_invariance() -> None: angle = 90.0 # 1. Transform the generated geometry - p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) + p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle) # 2. Generate at transformed start - start_transformed = rotate_port(translate_port(start0, dx, dy), angle) + start_transformed = start0.translate(dx, dy).rotated(angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 - assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 + assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6 + + +def test_component_result_is_immutable_value_type() -> None: + result = Straight.generate(Port(0, 0, 0), 10.0, 2.0) + + with pytest.raises(FrozenInstanceError): + result.length = 42.0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 53f5400..c5b8491 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,25 +1,28 @@ import pytest from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.router._router import PathFinder +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire.tests.support import build_context, build_pathfinder + +BOUNDS = (0, -40, 100, 40) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Wider bounds to allow going around (y from -40 to 40) - danger_map = DangerMap(bounds=(0, -40, 100, 40)) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0]) + context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0]) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) @@ -32,22 +35,27 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: for res in path: # Check if the end port orientation is same as start # and it's not a single straight (which would have y=0) - if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1: + if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1: found_sbend = True break assert found_sbend def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[5.0, 10.0]) - # Increase base penalty to force detour immediately - pf = PathFinder(context, max_iterations=10, base_congestion_penalty=1000.0) - 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} + pf = build_pathfinder( + basic_evaluator, + bounds=BOUNDS, + netlist=netlist, + net_widths=net_widths, + bend_radii=[5.0, 10.0], + max_iterations=10, + base_penalty=1000.0, + ) # Force them into a narrow corridor that only fits ONE. obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall @@ -57,7 +65,7 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) basic_evaluator.danger_map.precompute([obs_top, obs_bottom]) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() assert len(results) == 2 assert results["net1"].reached_target diff --git a/inire/tests/test_cost.py b/inire/tests/test_cost.py index e3c5c26..e6f73c4 100644 --- a/inire/tests/test_cost.py +++ b/inire/tests/test_cost.py @@ -1,12 +1,12 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap def test_cost_calculation() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # 50x50 um area, 1um resolution danger_map = DangerMap(bounds=(0, 0, 50, 50)) danger_map.precompute([]) @@ -61,7 +61,23 @@ def test_danger_map_kd_tree_and_cache() -> None: # We can check if calling it again is fast or just verify it returns same result cost_near_2 = dm.get_cost(100.5, 100.5) assert cost_near_2 == cost_near + assert len(dm._cost_cache) == 2 # 4. Out of bounds assert dm.get_cost(-1, -1) >= 1e12 + +def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None: + obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) + dm_a = DangerMap((0, 0, 100, 100)) + dm_b = DangerMap((0, 0, 100, 100)) + + dm_a.precompute([obstacle]) + dm_b.precompute([]) + + dm_a.get_cost(15.0, 15.0) + assert len(dm_a._cost_cache) == 1 + assert len(dm_b._cost_cache) == 0 + + dm_a.precompute([]) + assert len(dm_a._cost_cache) == 0 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index ea547fd..7583d42 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -16,7 +16,7 @@ REGRESSION_FACTOR = 1.5 BASELINE_SECONDS = { "example_01_simple_route": 0.0035, "example_02_congestion_resolution": 0.2666, - "example_03_locked_paths": 0.2304, + "example_03_locked_routes": 0.2304, "example_04_sbends_and_radii": 1.8734, "example_05_orientation_stress": 0.5630, "example_06_bend_collision_models": 5.2382, @@ -28,7 +28,7 @@ BASELINE_SECONDS = { EXPECTED_OUTCOMES = { "example_01_simple_route": {"total_results": 1, "valid_results": 1, "reached_targets": 1}, "example_02_congestion_resolution": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, - "example_03_locked_paths": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, + "example_03_locked_routes": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_04_sbends_and_radii": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index 10bce06..4e7e7e3 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,9 +1,8 @@ from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder from inire.router.danger_map import DangerMap +from inire.tests.support import build_pathfinder def test_failed_net_visibility() -> None: """ @@ -12,7 +11,7 @@ def test_failed_net_visibility() -> None: for negotiated congestion. """ # 1. Setup - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Create a simple danger map (bounds 0-100) # We don't strictly need obstacles in it for this test. @@ -32,20 +31,23 @@ def test_failed_net_visibility() -> None: # With obstacle, direct jump fails. A* must search around. # Limit=10 should be enough to fail to find a path around. - context = AStarContext(evaluator, node_limit=10) - - # 3. Configure PathFinder - # max_iterations=1 because we only need to check the state after the first attempt. - pf = PathFinder(context, max_iterations=1, warm_start=None) - netlist = { "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} + pf = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist, + net_widths=net_widths, + node_limit=10, + max_iterations=1, + warm_start=None, + ) # 4. Route print("\nStarting Route...") - results = pf.route_all(netlist, net_widths) + results = pf.route_all() res = results["net1"] print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index ee5490f..058b277 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -4,11 +4,12 @@ import pytest from hypothesis import given, settings, strategies as st from shapely.geometry import Point, Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap +from inire.tests.support import build_context @st.composite @@ -39,7 +40,7 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: net_width = 2.0 clearance = 2.0 - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) for obs in obstacles: engine.add_static_obstacle(obs) @@ -47,7 +48,7 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, node_limit=5000) # Lower limit for fuzzing stability + context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability # Check if start/target are inside obstacles (safety zone check) # The router should handle this gracefully (either route or return None) diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 326078b..05328ab 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,28 +1,57 @@ import pytest +from shapely.geometry import box -from inire.geometry.collision import CollisionEngine +from inire import NetSpec +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90, Straight from inire.geometry.primitives import Port -from inire.router.astar import AStarContext +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder 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, -) +from inire.tests.support import build_context + +DEFAULT_BOUNDS = (0, 0, 100, 100) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, 0, 100, 100)) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=DEFAULT_BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map) +def _request_nets( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + metrics=None, + **request_overrides: object, +) -> PathFinder: + return PathFinder( + build_context( + evaluator, + bounds=bounds, + nets=_request_nets(netlist, net_widths), + **request_overrides, + ), + metrics=metrics, + ) + + def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list: path = [] curr = start @@ -37,17 +66,22 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[ return path -def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) +def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]: + return [ + (component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple()) + for component in path + ] + +def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: 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} + pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() assert len(results) == 2 assert results["net1"].is_valid @@ -57,10 +91,6 @@ def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: 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 = { @@ -68,8 +98,16 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: "net2": (Port(50, 0, 90), Port(50, 50, 90)), } net_widths = {"net1": 2.0, "net2": 2.0} + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths=net_widths, + max_iterations=1, + base_penalty=1.0, + warm_start=None, + ) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() # Both should be invalid because they cross assert not results["net1"].is_valid @@ -78,231 +116,211 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: assert results["net2"].collisions > 0 -def test_prepare_routing_session_state_builds_warm_start_and_sorts_nets( +def test_route_all_respects_requested_net_order_in_callback( 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)) + callback_orders: list[list[str]] = [] 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, + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths={net_id: 2.0 for net_id in netlist}, + max_iterations=1, + warm_start=None, sort_nets="longest", - initial_paths=None, - seed=None, + enabled=False, + ) + pf.route_all( + iteration_callback=lambda iteration, results: callback_orders.append(list(results)), ) - 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"] + assert callback_orders == [["long", "mid", "short"]] -def test_run_routing_iteration_updates_results_and_invokes_callback( +def test_route_all_invokes_iteration_callback_with_results( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, ) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) - callback_results: list[dict[str, RoutingResult]] = [] + callback_results: list[dict[str, object]] = [] + netlist = { + "net1": (Port(0, 0, 0), Port(10, 0, 0)), + "net2": (Port(0, 10, 0), Port(10, 10, 0)), + } + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths={"net1": 2.0, "net2": 2.0}, + ) - 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", + results = pf.route_all( + iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)), + ) + + assert len(callback_results) == 1 + assert set(callback_results[0]) == {"net1", "net2"} + assert callback_results[0]["net1"].is_valid + assert callback_results[0]["net2"].is_valid + assert results["net1"].reached_target + assert results["net2"].reached_target + + +def test_route_all_uses_complete_initial_paths_without_rerouting( + basic_evaluator: CostEvaluator, +) -> None: + start = Port(0, 0, 0) + target = Port(20, 20, 0) + initial_path = _build_manual_path( + start, + 2.0, + basic_evaluator.collision_engine.clearance, + [("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")], + ) + pf = _build_pathfinder( + basic_evaluator, + netlist={"net": (start, target)}, + net_widths={"net": 2.0}, + bend_radii=[5.0], + max_iterations=1, + warm_start=None, + initial_paths={"net": tuple(initial_path)}, + enabled=False, + ) + + result = pf.route_all()["net"] + + assert result.is_valid + assert result.reached_target + assert _path_signature(result.path) == _path_signature(initial_path) + + +def test_route_all_retries_partial_initial_paths_across_iterations( + basic_evaluator: CostEvaluator, +) -> None: + start = Port(0, 0, 0) + target = Port(10, 0, 0) + partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)] + pf = _build_pathfinder( + basic_evaluator, + netlist={"net": (start, target)}, + net_widths={"net": 2.0}, + max_iterations=2, + warm_start=None, + capture_expanded=True, + initial_paths={"net": tuple(partial_path)}, + enabled=False, + ) + iterations: list[int] = [] + + result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"] + + assert iterations == [0, 1] + assert result.is_valid + assert result.reached_target + assert result.outcome == "completed" + assert _path_signature(result.path) != _path_signature(partial_path) + assert pf.accumulated_expanded_nodes + + +def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None: + netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))} + widths = {"net": 2.0} + + def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]: + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=(-20, -20, 60, 60)) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map) + context = build_context( + evaluator, + bounds=(-20, -20, 60, 60), + nets=_request_nets(netlist, widths), + bend_radii=[10.0], + max_straight_length=50.0, + node_limit=50, + warm_start=None, + max_iterations=1, + enabled=False, ) - return result, result.outcome + return engine, context, PathFinder(context) - 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, - ) + engine_auto, _context_auto, pf_auto = build_router() + assert pf_auto.route_all()["net"].is_valid + engine_auto.add_static_obstacle(box(4, 4, 8, 12)) + auto_result = pf_auto.route_all()["net"] - outcomes = run_routing_iteration(pf, state, iteration=0) + engine_manual, context_manual, pf_manual = build_router() + assert pf_manual.route_all()["net"].is_valid + engine_manual.add_static_obstacle(box(4, 4, 8, 12)) + context_manual.clear_static_caches() + manual_result = pf_manual.route_all()["net"] - 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" + assert auto_result.reached_target == manual_result.reached_target + assert auto_result.collisions == manual_result.collisions + assert auto_result.outcome == manual_result.outcome + assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [ + (comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path + ] 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) + def build_pathfinder( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + *, + refinement_enabled: bool, + ) -> tuple[RoutingWorld, PathFinder]: + engine = RoutingWorld(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) + return engine, _build_pathfinder( + evaluator, + netlist=netlist, + net_widths=net_widths, + bounds=bounds, + bend_radii=[10.0], + enabled=refinement_enabled, + ) - 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"] + net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} + width_a = {"netA": 2.0} + net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} + width_b = {"netB": 2.0} - 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_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False) + base_results = base_pf.route_all() + for polygon in base_results["netA"].as_locked_route().geometry: + base_engine.add_static_obstacle(polygon) + base_result = _build_pathfinder( + base_pf.cost_evaluator, + netlist=net_b, + net_widths=width_b, + bounds=bounds, + bend_radii=[10.0], + enabled=False, + ).route_all()["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") + refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True) + refined_results = refined_pf.route_all() + for polygon in refined_results["netA"].as_locked_route().geometry: + refined_engine.add_static_obstacle(polygon) + refined_result = _build_pathfinder( + refined_pf.cost_evaluator, + netlist=net_b, + net_widths=width_b, + bounds=bounds, + bend_radii=[10.0], + enabled=True, + ).route_all()["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 @@ -319,22 +337,30 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: } net_widths = {net_id: 2.0 for net_id in netlist} - def build_pathfinder(*, refine_paths: bool) -> PathFinder: - engine = CollisionEngine(clearance=2.0) + def build_pathfinder(*, refinement_enabled: bool) -> PathFinder: + engine = RoutingWorld(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) + return _build_pathfinder( + evaluator, + netlist=netlist, + net_widths=net_widths, + bounds=bounds, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=refinement_enabled, + ) - base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths) - refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths) + base_results = build_pathfinder(refinement_enabled=False).route_all() + refined_results = build_pathfinder(refinement_enabled=True).route_all() 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") + 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 @@ -342,12 +368,18 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: def test_refine_path_handles_same_orientation_lateral_offset() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(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) + pf = _build_pathfinder( + evaluator, + netlist={"net": (Port(0, 0, 0), Port(60, 15, 0))}, + net_widths={"net": 2.0}, + bounds=(-20, -20, 120, 120), + bend_radii=[5.0, 10.0], + enabled=True, + ) start = Port(0, 0, 0) width = 2.0 @@ -374,19 +406,25 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None: 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 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) + engine = RoutingWorld(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) + pf = _build_pathfinder( + evaluator, + netlist={"net": (Port(0, 0, 0), Port(65, 30, 90))}, + net_widths={"net": 2.0}, + bounds=(-20, -20, 120, 120), + bend_radii=[5.0, 10.0], + enabled=True, + ) start = Port(0, 0, 0) width = 2.0 @@ -415,7 +453,7 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> 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 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) diff --git a/inire/tests/test_primitives.py b/inire/tests/test_primitives.py index 6e62d45..7e2bd5b 100644 --- a/inire/tests/test_primitives.py +++ b/inire/tests/test_primitives.py @@ -1,8 +1,10 @@ +from dataclasses import FrozenInstanceError from typing import Any from hypothesis import given, strategies as st +import pytest -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port @st.composite @@ -24,11 +26,11 @@ def test_port_transform_invariants(p: Port) -> None: # Rotating 90 degrees 4 times should return to same orientation p_rot = p for _ in range(4): - p_rot = rotate_port(p_rot, 90) + p_rot = p_rot.rotated(90) assert abs(p_rot.x - p.x) < 1e-6 assert abs(p_rot.y - p.y) < 1e-6 - assert (p_rot.orientation % 360) == (p.orientation % 360) + assert (p_rot.r % 360) == (p.r % 360) @given( @@ -37,14 +39,21 @@ def test_port_transform_invariants(p: Port) -> None: dy=st.floats(min_value=-1000, max_value=1000), ) def test_translate_snapping(p: Port, dx: float, dy: float) -> None: - p_trans = translate_port(p, dx, dy) + p_trans = p.translate(dx, dy) assert isinstance(p_trans.x, int) assert isinstance(p_trans.y, int) def test_orientation_normalization() -> None: p = Port(0, 0, 360) - assert p.orientation == 0 + assert p.r == 0 p2 = Port(0, 0, -90) - assert p2.orientation == 270 + assert p2.r == 270 + + +def test_port_is_immutable_value_type() -> None: + p = Port(1, 2, 90) + + with pytest.raises(FrozenInstanceError): + p.x = 3 diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 25c3740..3a735b1 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,10 +1,9 @@ -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90 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.pathfinder import PathFinder +from inire.tests.support import build_pathfinder def test_arc_resolution_sagitta() -> None: @@ -18,34 +17,45 @@ def test_arc_resolution_sagitta() -> None: # Check number of points in the polygon exterior # (num_segments + 1) * 2 points usually - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse -def test_locked_paths() -> None: - engine = CollisionEngine(clearance=2.0) +def test_locked_routes() -> None: + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, -50, 100, 50)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[5.0, 10.0]) - pf = PathFinder(context) # 1. Route Net A netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) + results_a = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_a, + net_widths={"netA": 2.0}, + bend_radii=[5.0, 10.0], + ).route_all() assert results_a["netA"].is_valid - # 2. Lock Net A - engine.lock_net("netA") + # 2. Treat Net A as locked geometry in the next run. + for polygon in results_a["netA"].as_locked_route().geometry: + engine.add_static_obstacle(polygon) # 3. Route Net B through the same space. It should detour or fail. # We'll place Net B's start/target such that it MUST cross Net A's physical path. netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} # Route Net B - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_b, + net_widths={"netB": 2.0}, + bend_radii=[5.0, 10.0], + ).route_all() # Net B should be is_valid (it detoured) or at least not have collisions # with Net A in the dynamic set (because netA is now static). @@ -55,8 +65,8 @@ def test_locked_paths() -> None: assert results_b["netB"].is_valid # Verify geometry doesn't intersect locked netA (physical check) - poly_a = [p.geometry[0] for p in results_a["netA"].path] - poly_b = [p.geometry[0] for p in results_b["netB"].path] + poly_a = [p.physical_geometry[0] for p in results_a["netA"].path] + poly_b = [p.physical_geometry[0] for p in results_b["netB"].path] for pa in poly_a: for pb in poly_b: diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index ea6dee8..fbf6ba5 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,17 +1,20 @@ import unittest + from inire.geometry.primitives import Port -from inire.router.astar import route_astar, AStarContext +from inire.router._search import route_astar from inire.router.cost import CostEvaluator -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld +from inire.tests.support import build_context class TestIntegerPorts(unittest.TestCase): def setUp(self): - self.ce = CollisionEngine(clearance=2.0) + self.ce = RoutingWorld(clearance=2.0) self.cost = CostEvaluator(self.ce) + self.bounds = (0, 0, 100, 100) def test_route_reaches_integer_target(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0, 0, 0) target = Port(12, 0, 0) @@ -24,7 +27,7 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) @@ -36,7 +39,7 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) diff --git a/inire/tests/test_visibility.py b/inire/tests/test_visibility.py index e175d8c..0e2100f 100644 --- a/inire/tests/test_visibility.py +++ b/inire/tests/test_visibility.py @@ -1,12 +1,12 @@ from shapely.geometry import box -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.router.visibility import VisibilityManager def test_point_visibility_cache_respects_max_distance() -> None: - engine = CollisionEngine(clearance=0.0) + engine = RoutingWorld(clearance=0.0) engine.add_static_obstacle(box(10, 20, 20, 30)) engine.add_static_obstacle(box(100, 20, 110, 30)) visibility = VisibilityManager(engine) diff --git a/inire/tests/test_visualization.py b/inire/tests/test_visualization.py index c1a4735..eb139ca 100644 --- a/inire/tests/test_visualization.py +++ b/inire/tests/test_visualization.py @@ -4,13 +4,13 @@ matplotlib.use("Agg") from inire.geometry.components import Bend90 from inire.geometry.primitives import Port -from inire.router.pathfinder import RoutingResult +from inire import RoutingResult from inire.utils.visualization import plot_routing_results def test_plot_routing_results_respects_show_actual() -> None: bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox") - result = RoutingResult("net", [bend], True, 0, reached_target=True) + result = RoutingResult("net", [bend], reached_target=True) fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True) fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False) diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 0894566..9c22dc4 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,12 +3,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any import numpy -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld if TYPE_CHECKING: from shapely.geometry import Polygon from inire.geometry.primitives import Port - from inire.router.pathfinder import RoutingResult + from inire.router.results import RoutingResult def validate_routing_result( @@ -38,21 +38,21 @@ def validate_routing_result( if expected_start: first_port = result.path[0].start_port - dist_to_start = numpy.sqrt(((first_port[:2] - expected_start[:2])**2).sum()) + dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2) if dist_to_start > 0.005: connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") - if abs(first_port[2] - expected_start[2]) > 0.1: - connectivity_errors.append(f"Initial port orientation mismatch: {first_port[2]} vs {expected_start[2]}") + if abs(first_port.r - expected_start.r) > 0.1: + connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") if expected_end: last_port = result.path[-1].end_port - dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum()) + dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2) if dist_to_end > 0.005: connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") - if abs(last_port[2] - expected_end[2]) > 0.1: - connectivity_errors.append(f"Final port orientation mismatch: {last_port[2]} vs {expected_end[2]}") + if abs(last_port.r - expected_end.r) > 0.1: + connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") - engine = CollisionEngine(clearance=clearance) + engine = RoutingWorld(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) report = engine.verify_path_report("validation", result.path) diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 522fd7f..044208a 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap - from inire.router.pathfinder import RoutingResult + from inire.router.results import RoutingResult def plot_routing_results( @@ -51,8 +51,7 @@ def plot_routing_results( label_added = False for comp in res.path: # 1. Plot Collision Geometry (Translucent fill) - # This is the geometry used during search (e.g. proxy or arc) - for poly in comp.geometry: + for poly in comp.collision_geometry: if isinstance(poly, MultiPolygon): geoms = list(poly.geoms) else: @@ -67,13 +66,7 @@ def plot_routing_results( x, y = g.xy ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2) - # 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication) - # Use comp.actual_geometry if it exists (should be the arc) - actual_geoms_to_plot = ( - comp.actual_geometry - if show_actual and comp.actual_geometry is not None - else comp.geometry - ) + actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry for poly in actual_geoms_to_plot: if isinstance(poly, MultiPolygon): @@ -91,21 +84,20 @@ def plot_routing_results( # 3. Plot subtle port orientation arrow p = comp.end_port - rad = numpy.radians(p.orientation) + rad = numpy.radians(p.r) ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4) if not res.path and not res.is_valid: - # Best-effort display: If the path is empty but failed, it might be unroutable. - # We don't have a partial path in RoutingResult currently. + # Empty failed paths are typically unroutable. pass # 4. Plot main arrows for netlist ports if netlist: for _net_id, (start_p, target_p) in netlist.items(): for p in [start_p, target_p]: - rad = numpy.radians(p[2]) - ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black", + rad = numpy.radians(p.r) + ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=25, width=0.004, pivot="tail", zorder=6) ax.set_xlim(bounds[0], bounds[2]) diff --git a/pyproject.toml b/pyproject.toml index afbec0f..efbd939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ lint.ignore = [ "C408", # dict(x=y) instead of {'x': y} "PLR09", # Too many xxx "PLR2004", # magic number - #"PLC0414", # import x as x + "PLC0414", # import x as x "TRY003", # Long exception message ] From bc218a416b3cb618680d39860faeaea1a9c32f74 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 19:51:37 -0700 Subject: [PATCH 5/6] lots more refactoring --- DOCS.md | 37 ++- README.md | 10 +- examples/03_locked_paths.py | 2 +- examples/09_unroutable_best_effort.py | 2 +- inire/__init__.py | 42 ++- inire/api.py | 97 ------- inire/constants.py | 6 - inire/geometry/collision.py | 149 +--------- inire/geometry/component_overlap.py | 53 ++-- inire/geometry/components.py | 6 + inire/geometry/dynamic_path_index.py | 5 - inire/geometry/primitives.py | 8 - inire/model.py | 71 ++--- inire/results.py | 86 ++++++ inire/router/_astar_admission.py | 56 ++-- inire/router/_astar_moves.py | 30 +- inire/router/_astar_types.py | 44 ++- inire/router/_router.py | 222 ++++++-------- inire/router/_search.py | 45 +-- inire/router/_seed_materialization.py | 53 ++++ inire/router/_stack.py | 52 ++++ inire/router/cost.py | 192 +++---------- inire/router/outcomes.py | 27 -- inire/router/refiner.py | 22 +- inire/router/results.py | 78 +---- inire/seeds.py | 48 ++++ inire/tests/benchmark_scaling.py | 68 ----- inire/tests/example_scenarios.py | 117 +++++--- inire/tests/support.py | 162 ----------- inire/tests/test_api.py | 41 ++- inire/tests/test_astar.py | 169 ++++++++--- inire/tests/test_clearance_precision.py | 59 ++-- inire/tests/test_collision.py | 87 ++---- inire/tests/test_congestion.py | 100 ++++--- inire/tests/test_example_performance.py | 27 +- inire/tests/test_failed_net_congestion.py | 32 ++- inire/tests/test_fuzz.py | 30 +- inire/tests/test_pathfinder.py | 336 ++++------------------ inire/tests/test_refinements.py | 36 ++- inire/tests/test_route_behavior.py | 301 +++++++++++++++++++ inire/tests/test_variable_grid.py | 31 +- inire/utils/validation.py | 80 ------ inire/utils/visualization.py | 2 +- 43 files changed, 1430 insertions(+), 1691 deletions(-) delete mode 100644 inire/api.py create mode 100644 inire/results.py create mode 100644 inire/router/_seed_materialization.py create mode 100644 inire/router/_stack.py delete mode 100644 inire/router/outcomes.py create mode 100644 inire/seeds.py delete mode 100644 inire/tests/benchmark_scaling.py delete mode 100644 inire/tests/support.py create mode 100644 inire/tests/test_route_behavior.py delete mode 100644 inire/utils/validation.py diff --git a/DOCS.md b/DOCS.md index fe4c73d..338ded2 100644 --- a/DOCS.md +++ b/DOCS.md @@ -11,9 +11,8 @@ This document describes the current public API for `inire`. - `bounds` - `nets` - `static_obstacles` -- `locked_routes` +- `initial_paths` - `clearance` -- `max_net_width` - `safety_zone_radius` ### `RoutingOptions` @@ -34,21 +33,39 @@ run = route(problem, options=options) If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. -### Incremental routing with `LockedRoute` +The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. -For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem: +Stable example: + +```python +from inire import route, RoutingOptions, RoutingProblem +``` + +Unstable example: + +```python +from inire.router._router import PathFinder +``` + +### Incremental routing with locked geometry + +For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem: ```python run_a = route(problem_a) problem_b = RoutingProblem( bounds=problem_a.bounds, nets=(...), - locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()}, + static_obstacles=run_a.results_by_net["netA"].locked_geometry, ) run_b = route(problem_b) ``` -`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. +`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. + +### Initial paths with `PathSeed` + +Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are materialized with the current width, clearance, and bend collision settings for the run, and partial seeds are retried by normal routing in later iterations. ## 2. Search Options @@ -65,7 +82,6 @@ run_b = route(problem_b) | `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. | | `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. | | `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | -| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. | ## 3. Objective Weights @@ -77,7 +93,6 @@ run_b = route(problem_b) | `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. | | `sbend_penalty` | `500.0` | Flat S-bend penalty. | | `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. | -| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. | ## 4. Congestion Options @@ -89,9 +104,9 @@ run_b = route(problem_b) | `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. | | `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. | | `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. | -| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. | +| `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. | +| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. | | `shuffle_nets` | `False` | Shuffle routing order between iterations. | -| `sort_nets` | `None` | Optional deterministic routing order. | | `seed` | `None` | RNG seed for shuffled routing order. | ## 5. Refinement Options @@ -126,7 +141,7 @@ run_b = route(problem_b) ## 8. Internal Modules -Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`. +Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`. ## 9. Tuning Notes diff --git a/README.md b/README.md index 0da7268..b93ce69 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ if run.results_by_net["net1"].is_valid: print("Successfully routed net1!") ``` -For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`. +For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`. ## Usage Examples @@ -65,6 +65,12 @@ python3 examples/01_simple_route.py Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. +## API Stability + +The stable API lives at the package root and is centered on `route(problem, options=...)`. + +Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. + ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: @@ -76,7 +82,7 @@ For multi-net problems, the negotiated-congestion loop handles rip-up and rerout ## Configuration -`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference. +`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference. ## License diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 124a172..0f60fbb 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -27,7 +27,7 @@ def main() -> None: RoutingProblem( bounds=bounds, nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - locked_routes={"netA": results_a["netA"].as_locked_route()}, + static_obstacles=results_a["netA"].locked_geometry, ), options=options, ).results_by_net diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 3df1caf..0ff297f 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -26,7 +26,7 @@ def main() -> None: bend_penalty=50.0, sbend_penalty=150.0, ), - congestion=CongestionOptions(warm_start=None), + congestion=CongestionOptions(warm_start_enabled=False), ) print("Routing with a deliberately tiny node budget (should return a partial path)...") diff --git a/inire/__init__.py b/inire/__init__.py index 7529ac7..f6ee2d5 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,43 +1,59 @@ """ inire Wave-router """ -from .api import ( +from collections.abc import Callable + +from .geometry.primitives import Port as Port # noqa: PLC0414 +from .model import ( CongestionOptions as CongestionOptions, DiagnosticsOptions as DiagnosticsOptions, - LockedRoute as LockedRoute, NetSpec as NetSpec, ObjectiveWeights as ObjectiveWeights, RefinementOptions as RefinementOptions, RoutingOptions as RoutingOptions, RoutingProblem as RoutingProblem, - RoutingRunResult as RoutingRunResult, SearchOptions as SearchOptions, - route as route, ) # noqa: PLC0414 -from .geometry.primitives import Port as Port # noqa: PLC0414 -from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 -from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414 +from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414 +from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414 __author__ = 'Jan Petykiewicz' __version__ = '0.1' + +def route( + problem: RoutingProblem, + *, + options: RoutingOptions | None = None, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, +) -> RoutingRunResult: + from .router._stack import build_routing_stack + + resolved_options = RoutingOptions() if options is None else options + stack = build_routing_stack(problem, resolved_options) + finder = stack.finder + results = finder.route_all(iteration_callback=iteration_callback) + return RoutingRunResult( + results_by_net=results, + metrics=finder.metrics.snapshot(), + expanded_nodes=tuple(finder.accumulated_expanded_nodes), + ) + __all__ = [ - "Bend90", + "Bend90Seed", "CongestionOptions", "DiagnosticsOptions", - "LockedRoute", "NetSpec", "ObjectiveWeights", + "PathSeed", "Port", "RefinementOptions", "RoutingOptions", "RoutingProblem", - "RoutingReport", "RoutingResult", "RoutingRunResult", - "RouteMetrics", - "SBend", + "SBendSeed", "SearchOptions", - "Straight", + "StraightSeed", "route", ] diff --git a/inire/api.py b/inire/api.py deleted file mode 100644 index 9f83f63..0000000 --- a/inire/api.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from inire.geometry.collision import RoutingWorld -from inire.model import ( - CongestionOptions, - DiagnosticsOptions, - LockedRoute, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - RoutingRunResult, - SearchOptions, -) -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.results import RouteMetrics, RoutingReport, RoutingResult - -if TYPE_CHECKING: - from collections.abc import Callable, Iterable - - from shapely.geometry import Polygon - - -__all__ = [ - "CongestionOptions", - "DiagnosticsOptions", - "LockedRoute", - "NetSpec", - "ObjectiveWeights", - "RefinementOptions", - "RouteMetrics", - "RoutingOptions", - "RoutingProblem", - "RoutingReport", - "RoutingResult", - "RoutingRunResult", - "SearchOptions", - "route", -] - - -def _iter_locked_polygons( - locked_routes: dict[str, LockedRoute], -) -> Iterable[Polygon]: - for route in locked_routes.values(): - yield from route.geometry - - -def _build_context(problem: RoutingProblem, options: RoutingOptions) -> AStarContext: - world = RoutingWorld( - clearance=problem.clearance, - max_net_width=problem.max_net_width, - safety_zone_radius=problem.safety_zone_radius, - ) - for obstacle in problem.static_obstacles: - world.add_static_obstacle(obstacle) - for polygon in _iter_locked_polygons(problem.locked_routes): - world.add_static_obstacle(polygon) - - danger_obstacles = list(problem.static_obstacles) - danger_obstacles.extend(_iter_locked_polygons(problem.locked_routes)) - danger_map = DangerMap(bounds=problem.bounds) - danger_map.precompute(danger_obstacles) - - objective = options.objective - evaluator = CostEvaluator( - world, - danger_map, - unit_length_cost=objective.unit_length_cost, - greedy_h_weight=options.search.greedy_h_weight, - bend_penalty=objective.bend_penalty, - sbend_penalty=objective.sbend_penalty, - danger_weight=objective.danger_weight, - ) - return AStarContext(evaluator, problem, options) - - -def route( - problem: RoutingProblem, - *, - options: RoutingOptions | None = None, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, -) -> RoutingRunResult: - resolved_options = RoutingOptions() if options is None else options - finder = PathFinder(_build_context(problem, resolved_options)) - results = finder.route_all(iteration_callback=iteration_callback) - return RoutingRunResult( - results_by_net=results, - metrics=finder.metrics.snapshot(), - expanded_nodes=tuple(finder.accumulated_expanded_nodes), - ) diff --git a/inire/constants.py b/inire/constants.py index 1e0c0be..bfbebe6 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -2,11 +2,5 @@ Centralized constants for the inire routing engine. """ -# Search Grid Snap (5.0 µm default) -# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid. -DEFAULT_SEARCH_GRID_SNAP_UM = 5.0 - -# Tolerances TOLERANCE_LINEAR = 1e-6 TOLERANCE_ANGULAR = 1e-3 -TOLERANCE_GRID = 1e-6 diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index b8a72fe..5d5b13b 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import numpy from shapely.geometry import LineString, box @@ -8,8 +8,8 @@ from shapely.geometry import LineString, box from inire.geometry.component_overlap import components_overlap from inire.geometry.dynamic_path_index import DynamicPathIndex from inire.geometry.index_helpers import grid_cell_span +from inire.results import RoutingReport from inire.geometry.static_obstacle_index import StaticObstacleIndex -from inire.router.results import RoutingReport if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -35,9 +35,7 @@ class RoutingWorld: __slots__ = ( "clearance", - "max_net_width", "safety_zone_radius", - "metrics", "grid_cell_size", "_dynamic_paths", "_static_obstacles", @@ -46,27 +44,15 @@ class RoutingWorld: def __init__( self, clearance: float, - max_net_width: float = 2.0, safety_zone_radius: float = 0.0021, ) -> None: self.clearance = clearance - self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius self.grid_cell_size = 50.0 self._static_obstacles = StaticObstacleIndex(self) self._dynamic_paths = DynamicPathIndex(self) - self.metrics = { - "static_cache_hits": 0, - "static_grid_skips": 0, - "static_tree_queries": 0, - "static_straight_fast": 0, - "congestion_grid_skips": 0, - "congestion_tree_queries": 0, - "safety_zone_checks": 0, - } - def get_static_version(self) -> int: return self._static_obstacles.version @@ -87,31 +73,12 @@ class RoutingWorld: for obj_id in self._dynamic_paths.index.intersection(query_bounds): yield self._dynamic_paths.geometries[obj_id][1].bounds - def iter_dynamic_paths(self) -> Iterable[tuple[str, Polygon]]: - return self._dynamic_paths.geometries.values() - - def reset_metrics(self) -> None: - for key in self.metrics: - self.metrics[key] = 0 - - def get_metrics_summary(self) -> str: - metrics = self.metrics - return ( - "Collision Performance: \n" - f" Static: {metrics['static_tree_queries']} checks\n" - f" Congestion: {metrics['congestion_tree_queries']} checks\n" - f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed" - ) - def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: self._static_obstacles.remove_obstacle(obj_id) - def _invalidate_static_caches(self) -> None: - self._static_obstacles.invalidate_caches() - def _ensure_static_tree(self) -> None: self._static_obstacles.ensure_tree() @@ -127,10 +94,6 @@ class RoutingWorld: def _ensure_dynamic_grid(self) -> None: self._dynamic_paths.ensure_grid() - def rebuild_dynamic_tree(self) -> None: - self._dynamic_paths.tree = None - self._ensure_dynamic_tree() - def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) @@ -138,7 +101,6 @@ class RoutingWorld: self._dynamic_paths.remove_path(net_id) def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - self.metrics["static_straight_fast"] += 1 reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) return reach < length - 0.001 @@ -178,7 +140,6 @@ class RoutingWorld: if not geometry.intersects(raw_obstacle): return False - self.metrics["safety_zone_checks"] += 1 intersection = geometry.intersection(raw_obstacle) if intersection.is_empty: return False @@ -207,15 +168,13 @@ class RoutingWorld: result: ComponentResult, start_port: Port | None = None, end_port: Port | None = None, - net_width: float | None = None, ) -> bool: - del net_width - + # TODO: If static buffering becomes net-width-specific, add dedicated + # width-aware geometry/index handling instead of reviving dead args here. static_obstacles = self._static_obstacles if not static_obstacles.dilated: return False - self.metrics["static_tree_queries"] += 1 self._ensure_static_tree() hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) @@ -260,7 +219,6 @@ class RoutingWorld: def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: dynamic_paths = self._dynamic_paths - self.metrics["congestion_tree_queries"] += 1 self._ensure_dynamic_tree() if dynamic_paths.tree is None: return 0 @@ -347,101 +305,6 @@ class RoutingWorld: return 0 return self._check_real_congestion(result, net_id) - def _check_static_collision( - self, - geometry: Polygon, - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - ) -> bool: - static_obstacles = self._static_obstacles - self._ensure_static_tree() - if static_obstacles.tree is None: - return False - - if dilated_geometry is not None: - test_geometry = dilated_geometry - else: - distance = self.clearance / 2.0 - test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry - - hits = static_obstacles.tree.query(test_geometry, predicate="intersects") - tree_geometries = static_obstacles.tree.geometries - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = static_obstacles.obj_ids[hit_idx] - if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): - continue - return True - return False - - def _check_dynamic_collision( - self, - geometry: Polygon, - net_id: str, - dilated_geometry: Polygon | None = None, - ) -> int: - dynamic_paths = self._dynamic_paths - self._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) - hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") - tree_geometries = dynamic_paths.tree.geometries - hit_net_ids: list[str] = [] - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = dynamic_paths.obj_ids[hit_idx] - other_net_id = dynamic_paths.geometries[obj_id][0] - if other_net_id != net_id: - hit_net_ids.append(other_net_id) - if not hit_net_ids: - return 0 - return len(numpy.unique(hit_net_ids)) - - def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - bounds: tuple[float, float, float, float] | None = None, - net_width: float | None = None, - ) -> bool | int: - del bounds, net_width - - if buffer_mode == "static": - return self._check_static_collision( - geometry, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - ) - return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) - - def is_collision( - self, - geometry: Polygon, - net_id: str = "default", - net_width: float | None = None, - start_port: Port | None = None, - end_port: Port | None = None, - ) -> bool: - result = self.check_collision( - geometry, - net_id, - buffer_mode="static", - start_port=start_port, - end_port=end_port, - net_width=net_width, - ) - return bool(result) - def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: static_collision_count = 0 dynamic_collision_count = 0 @@ -502,10 +365,6 @@ class RoutingWorld: total_length=total_length, ) - def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]: - report = self.verify_path_report(net_id, components) - return report.is_valid, report.collision_count - def ray_cast( self, origin: Port, diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py index e2049ac..816508d 100644 --- a/inire/geometry/component_overlap.py +++ b/inire/geometry/component_overlap.py @@ -8,31 +8,34 @@ if TYPE_CHECKING: from inire.geometry.components import ComponentResult -def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]: - if prefer_actual: - return list(component.physical_geometry) - return list(component.collision_geometry) - - -def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]: - if not prefer_actual: - return component.total_bounds - - polygons = component_polygons(component, prefer_actual=True) - min_x = min(polygon.bounds[0] for polygon in polygons) - min_y = min(polygon.bounds[1] for polygon in polygons) - max_x = max(polygon.bounds[2] for polygon in polygons) - max_y = max(polygon.bounds[3] for polygon in polygons) - return (min_x, min_y, max_x, max_y) - - def components_overlap( component_a: ComponentResult, component_b: ComponentResult, prefer_actual: bool = False, ) -> bool: - bounds_a = component_bounds(component_a, prefer_actual=prefer_actual) - bounds_b = component_bounds(component_b, prefer_actual=prefer_actual) + polygons_a: tuple[Polygon, ...] + polygons_b: tuple[Polygon, ...] + if prefer_actual: + polygons_a = component_a.physical_geometry + polygons_b = component_b.physical_geometry + bounds_a = ( + min(polygon.bounds[0] for polygon in polygons_a), + min(polygon.bounds[1] for polygon in polygons_a), + max(polygon.bounds[2] for polygon in polygons_a), + max(polygon.bounds[3] for polygon in polygons_a), + ) + bounds_b = ( + min(polygon.bounds[0] for polygon in polygons_b), + min(polygon.bounds[1] for polygon in polygons_b), + max(polygon.bounds[2] for polygon in polygons_b), + max(polygon.bounds[3] for polygon in polygons_b), + ) + else: + polygons_a = component_a.collision_geometry + polygons_b = component_b.collision_geometry + bounds_a = component_a.total_bounds + bounds_b = component_b.total_bounds + if not ( bounds_a[0] < bounds_b[2] and bounds_a[2] > bounds_b[0] @@ -41,18 +44,8 @@ def components_overlap( ): return False - polygons_a = component_polygons(component_a, prefer_actual=prefer_actual) - polygons_b = component_polygons(component_b, prefer_actual=prefer_actual) for polygon_a in polygons_a: for polygon_b in polygons_b: if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): return True return False - - -def has_self_overlap(path: list[ComponentResult], prefer_actual: bool = False) -> bool: - for i, component in enumerate(path): - for j in range(i + 2, len(path)): - if components_overlap(component, path[j], prefer_actual=prefer_actual): - return True - return False diff --git a/inire/geometry/components.py b/inire/geometry/components.py index d098041..693f16e 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -10,6 +10,7 @@ from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box from inire.constants import TOLERANCE_ANGULAR +from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed from .primitives import Port, rotation_matrix2 @@ -29,6 +30,7 @@ class ComponentResult: end_port: Port length: float move_type: MoveKind + move_spec: PathSegmentSeed physical_geometry: tuple[Polygon, ...] dilated_collision_geometry: tuple[Polygon, ...] dilated_physical_geometry: tuple[Polygon, ...] @@ -80,6 +82,7 @@ class ComponentResult: end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, + move_spec=self.move_spec, physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], @@ -235,6 +238,7 @@ class Straight: end_port=end_port, length=abs(length_f), move_type="straight", + move_spec=StraightSeed(length=length_f), physical_geometry=geometry, dilated_collision_geometry=dilated_geometry, dilated_physical_geometry=dilated_geometry, @@ -305,6 +309,7 @@ class Bend90: end_port=end_port, length=abs(radius) * numpy.pi / 2.0, move_type="bend90", + move_spec=Bend90Seed(radius=radius, direction=direction), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, @@ -394,6 +399,7 @@ class SBend: end_port=end_port, length=2.0 * radius * theta, move_type="sbend", + move_spec=SBendSeed(offset=offset, radius=radius), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py index c19ff77..d8363f6 100644 --- a/inire/geometry/dynamic_path_index.py +++ b/inire/geometry/dynamic_path_index.py @@ -87,8 +87,3 @@ class DynamicPathIndex: self.index.delete(obj_id, self.dilated[obj_id].bounds) del self.geometries[obj_id] del self.dilated[obj_id] - - def clear_paths(self) -> None: - if not self.geometries: - return - self.remove_obj_ids(list(self.geometries)) diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index 30055ea..b42b267 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -61,11 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32) def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: quadrant = (_normalize_angle(rotation_deg) // 90) % 4 return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] - - -def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: - rot2 = rotation_matrix2(rotation_deg) - rot3 = numpy.zeros((3, 3), dtype=numpy.int32) - rot3[:2, :2] = rot2 - rot3[2, 2] = 1 - return rot3 diff --git a/inire/model.py b/inire/model.py index 1dcb359..20d71fe 100644 --- a/inire/model.py +++ b/inire/model.py @@ -1,18 +1,22 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from inire.geometry.components import BendCollisionModel -from inire.router.results import RouteMetrics, RoutingResult +from inire.seeds import PathSeed if TYPE_CHECKING: from shapely.geometry import Polygon - from inire.geometry.components import ComponentResult + from inire.geometry.components import BendCollisionModel from inire.geometry.primitives import Port +NetOrder = Literal["user", "shortest", "longest"] +VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"] + + @dataclass(frozen=True, slots=True) class NetSpec: net_id: str @@ -21,37 +25,12 @@ class NetSpec: width: float = 2.0 -@dataclass(frozen=True, slots=True) -class LockedRoute: - geometry: tuple[Polygon, ...] - - def __post_init__(self) -> None: - object.__setattr__(self, "geometry", tuple(self.geometry)) - - @classmethod - def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute: - polygons = [] - for component in path: - polygons.extend(component.physical_geometry) - return cls(geometry=tuple(polygons)) - - -def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute: - if isinstance(route, LockedRoute): - return route - route_items = tuple(route) - if route_items and hasattr(route_items[0], "physical_geometry"): - return LockedRoute.from_path(route_items) # type: ignore[arg-type] - return LockedRoute(geometry=route_items) - - @dataclass(frozen=True, slots=True) class ObjectiveWeights: unit_length_cost: float = 1.0 bend_penalty: float = 250.0 sbend_penalty: float = 500.0 danger_weight: float = 1.0 - congestion_penalty: float = 0.0 @dataclass(frozen=True, slots=True) @@ -64,23 +43,13 @@ class SearchOptions: bend_radii: tuple[float, ...] = (50.0, 100.0) sbend_radii: tuple[float, ...] = (10.0,) bend_collision_type: BendCollisionModel = "arc" - visibility_guidance: str = "tangent_corner" - initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None + visibility_guidance: VisibilityGuidance = "tangent_corner" def __post_init__(self) -> None: object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) if self.sbend_offsets is not None: object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) - if self.initial_paths is not None: - object.__setattr__( - self, - "initial_paths", - { - net_id: tuple(path) - for net_id, path in self.initial_paths.items() - }, - ) @dataclass(frozen=True, slots=True) @@ -89,9 +58,9 @@ class CongestionOptions: base_penalty: float = 100.0 multiplier: float = 1.5 use_tiered_strategy: bool = True - warm_start: str | None = "shortest" + net_order: NetOrder = "user" + warm_start_enabled: bool = True shuffle_nets: bool = False - sort_nets: str | None = None seed: int | None = None @@ -120,26 +89,18 @@ class RoutingProblem: bounds: tuple[float, float, float, float] nets: tuple[NetSpec, ...] = () static_obstacles: tuple[Polygon, ...] = () - locked_routes: dict[str, LockedRoute] = field(default_factory=dict) + initial_paths: dict[str, PathSeed] = field(default_factory=dict) clearance: float = 2.0 - max_net_width: float = 2.0 safety_zone_radius: float = 0.0021 def __post_init__(self) -> None: object.__setattr__(self, "nets", tuple(self.nets)) object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) + initial_paths = dict(self.initial_paths) + if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()): + raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances") object.__setattr__( self, - "locked_routes", - { - net_id: _coerce_locked_route(route) - for net_id, route in self.locked_routes.items() - }, + "initial_paths", + initial_paths, ) - - -@dataclass(frozen=True, slots=True) -class RoutingRunResult: - results_by_net: dict[str, RoutingResult] - metrics: RouteMetrics - expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/results.py b/inire/results.py new file mode 100644 index 0000000..d16e92c --- /dev/null +++ b/inire/results.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from inire.seeds import PathSeed + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + + +RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] + + +@dataclass(frozen=True, slots=True) +class RoutingReport: + static_collision_count: int = 0 + dynamic_collision_count: int = 0 + self_collision_count: int = 0 + total_length: float = 0.0 + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + nodes_expanded: int + moves_generated: int + moves_added: int + pruned_closed_set: int + pruned_hard_collision: int + pruned_cost: int + + +@dataclass(frozen=True, slots=True) +class RoutingResult: + net_id: str + path: tuple[ComponentResult, ...] + reached_target: bool = False + report: RoutingReport = field(default_factory=RoutingReport) + + def __post_init__(self) -> None: + object.__setattr__(self, "path", tuple(self.path)) + + @property + def collisions(self) -> int: + return self.report.collision_count + + @property + def outcome(self) -> RoutingOutcome: + if not self.path: + return "unroutable" + if not self.reached_target: + return "partial" + if self.report.collision_count > 0: + return "colliding" + return "completed" + + @property + def is_valid(self) -> bool: + return self.outcome == "completed" + + @property + def locked_geometry(self) -> tuple[Polygon, ...]: + polygons = [] + for component in self.path: + polygons.extend(component.physical_geometry) + return tuple(polygons) + + def as_seed(self) -> PathSeed: + return PathSeed(tuple(component.move_spec for component in self.path)) + + +@dataclass(frozen=True, slots=True) +class RoutingRunResult: + results_by_net: dict[str, RoutingResult] + metrics: RouteMetrics + expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index be244c8..594a970 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -1,16 +1,16 @@ from __future__ import annotations import heapq -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from shapely.geometry import Polygon from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind +from inire.geometry.components import Bend90, SBend, Straight, MoveKind from inire.geometry.primitives import Port from inire.router.refiner import component_hits_ancestor_chain -from ._astar_types import AStarContext, AStarMetrics, AStarNode +from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -26,15 +26,12 @@ def process_move( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], + config: SearchRunConfig, move_class: MoveKind, params: tuple, - skip_congestion: bool, - bend_collision_type: BendCollisionModel, - max_cost: float | None = None, - self_collision_check: bool = False, ) -> None: cp = parent.port - coll_type = bend_collision_type + coll_type = config.bend_collision_type coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 @@ -101,12 +98,9 @@ def process_move( context, metrics, congestion_cache, + config, move_class, abs_key, - move_radius=move_radius, - skip_congestion=skip_congestion, - max_cost=max_cost, - self_collision_check=self_collision_check, ) @@ -121,12 +115,9 @@ def add_node( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], + config: SearchRunConfig, move_type: MoveKind, cache_key: tuple, - move_radius: float | None = None, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, ) -> None: metrics.moves_generated += 1 metrics.total_moves_generated += 1 @@ -151,7 +142,7 @@ def add_node( if move_type == "straight": collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) else: - collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) + collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p) if collision_found: context.hard_collision_set.add(cache_key) metrics.pruned_hard_collision += 1 @@ -160,36 +151,23 @@ def add_node( context.static_safe_cache.add(cache_key) total_overlaps = 0 - if not skip_congestion: + if not config.skip_congestion: if cache_key in congestion_cache: total_overlaps = congestion_cache[cache_key] else: total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) congestion_cache[cache_key] = total_overlaps - if self_collision_check and component_hits_ancestor_chain(result, parent): + if config.self_collision_check and component_hits_ancestor_chain(result, parent): return - penalty = context.cost_evaluator.component_penalty( - move_type, - move_radius=move_radius, - ) - - move_cost = context.cost_evaluator.evaluate_move( - result.collision_geometry, - result.end_port, - net_width, - net_id, + move_cost = context.cost_evaluator.score_component( + result, start_port=parent_p, - length=result.length, - dilated_geometry=result.dilated_collision_geometry, - penalty=penalty, - skip_static=True, - skip_congestion=True, ) - move_cost += total_overlaps * context.cost_evaluator.congestion_penalty + move_cost += total_overlaps * context.congestion_penalty - if max_cost is not None and parent.g_cost + move_cost > max_cost: + if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost: metrics.pruned_cost += 1 metrics.total_pruned_cost += 1 return @@ -204,7 +182,11 @@ def add_node( metrics.total_pruned_closed_set += 1 return - h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) + h_cost = context.cost_evaluator.h_manhattan( + result.end_port, + target, + min_bend_radius=context.min_bend_radius, + ) heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) metrics.moves_added += 1 metrics.total_moves_added += 1 diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py index d326934..71ca920 100644 --- a/inire/router/_astar_moves.py +++ b/inire/router/_astar_moves.py @@ -3,11 +3,11 @@ from __future__ import annotations import math from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import BendCollisionModel, MoveKind +from inire.geometry.components import MoveKind from inire.geometry.primitives import Port from ._astar_admission import process_move -from ._astar_types import AStarContext, AStarMetrics, AStarNode +from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: @@ -129,13 +129,9 @@ def expand_moves( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], - bend_collision_type: BendCollisionModel | None = None, - max_cost: float | None = None, - skip_congestion: bool = False, - self_collision_check: bool = False, + config: SearchRunConfig, ) -> None: search_options = context.options.search - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type cp = current.port prev_move_type, prev_straight_length = _previous_move_metadata(current) dx_t = target.x - cp.x @@ -171,12 +167,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "straight", (int(round(proj_t)),), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width) @@ -225,12 +218,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "straight", (length,), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) angle_to_target = 0.0 @@ -256,12 +246,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "bend90", (radius, direction), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0 @@ -293,10 +280,7 @@ def expand_moves( context, metrics, congestion_cache, + config, "sbend", (offset, radius), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index 785b3ae..f63fe43 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -1,16 +1,53 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING +from inire.geometry.components import BendCollisionModel from inire.model import RoutingOptions, RoutingProblem +from inire.results import RouteMetrics from inire.router.visibility import VisibilityManager -from inire.router.results import RouteMetrics if TYPE_CHECKING: from inire.geometry.components import ComponentResult from inire.router.cost import CostEvaluator +@dataclass(frozen=True, slots=True) +class SearchRunConfig: + bend_collision_type: BendCollisionModel + node_limit: int + return_partial: bool = False + store_expanded: bool = False + skip_congestion: bool = False + max_cost: float | None = None + self_collision_check: bool = False + + @classmethod + def from_options( + cls, + options: RoutingOptions, + *, + bend_collision_type: BendCollisionModel | None = None, + node_limit: int | None = None, + return_partial: bool = False, + store_expanded: bool = False, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, + ) -> SearchRunConfig: + search = options.search + return cls( + bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type, + node_limit=search.node_limit if node_limit is None else node_limit, + return_partial=return_partial, + store_expanded=store_expanded, + skip_congestion=skip_congestion, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + class AStarNode: __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") @@ -96,6 +133,8 @@ class AStarMetrics: class AStarContext: __slots__ = ( "cost_evaluator", + "congestion_penalty", + "min_bend_radius", "problem", "options", "max_cache_size", @@ -115,10 +154,11 @@ class AStarContext: max_cache_size: int = 1000000, ) -> None: self.cost_evaluator = cost_evaluator + self.congestion_penalty = 0.0 self.max_cache_size = max_cache_size self.problem = problem self.options = options - self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0)) + self.min_bend_radius = min(self.options.search.bend_radii, default=50.0) self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) self.move_cache_rel: dict[tuple, ComponentResult] = {} self.move_cache_abs: dict[tuple, ComponentResult] = {} diff --git a/inire/router/_router.py b/inire/router/_router.py index 75aef6e..5aaf00c 100644 --- a/inire/router/_router.py +++ b/inire/router/_router.py @@ -5,19 +5,17 @@ import time from dataclasses import dataclass from typing import TYPE_CHECKING -from inire.model import NetSpec, RoutingOptions, RoutingProblem -from inire.router._astar_types import AStarContext, AStarMetrics +from inire.model import NetOrder, NetSpec +from inire.results import RoutingOutcome, RoutingReport, RoutingResult +from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig from inire.router._search import route_astar -from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry +from inire.router._seed_materialization import materialize_path_seed from inire.router.refiner import PathRefiner -from inire.router.results import RoutingReport, RoutingResult if TYPE_CHECKING: from collections.abc import Callable, Sequence from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.cost import CostEvaluator @dataclass(slots=True) @@ -31,10 +29,6 @@ class _RoutingState: initial_paths: dict[str, tuple[ComponentResult, ...]] | None accumulated_expanded_nodes: list[tuple[int, int, int]] - -__all__ = ["PathFinder"] - - class PathFinder: __slots__ = ( "context", @@ -53,83 +47,18 @@ class PathFinder: self.refiner = PathRefiner(self.context) self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] - @property - def problem(self) -> RoutingProblem: - return self.context.problem - - @property - def options(self) -> RoutingOptions: - return self.context.options - - @property - def cost_evaluator(self) -> CostEvaluator: - return self.context.cost_evaluator - - def _path_cost(self, path: Sequence[ComponentResult]) -> float: - return self.refiner.path_cost(path) - - def _refine_path( - self, - net_id: str, - start: Port, - target: Port, - net_width: float, - path: Sequence[ComponentResult], - ) -> list[ComponentResult]: - return self.refiner.refine_path(net_id, start, target, net_width, path) - - def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]: + def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: all_geoms = [] all_dilated = [] for result in path: all_geoms.extend(result.collision_geometry) all_dilated.extend(result.dilated_collision_geometry) - return all_geoms, all_dilated - - def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: - all_geoms, all_dilated = self._extract_path_geometry(path) - self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]: - obj_ids: list[int] = [] - for result in path: - for polygon in result.physical_geometry: - obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon)) - return obj_ids - - def _remove_static_obstacles(self, obj_ids: list[int]) -> None: - for obj_id in obj_ids: - self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) - - def _remove_path(self, net_id: str) -> None: - self.cost_evaluator.collision_engine.remove_path(net_id) - - def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport: - return self.cost_evaluator.collision_engine.verify_path_report(net_id, path) - - def _finalize_dynamic_tree(self) -> None: - self.cost_evaluator.collision_engine.rebuild_dynamic_tree() - - def _build_routing_result( - self, - *, - net_id: str, - path: Sequence[ComponentResult], - reached_target: bool | None = None, - report: RoutingReport | None = None, - ) -> RoutingResult: - resolved_reached_target = bool(path) if reached_target is None else reached_target - return RoutingResult( - net_id=net_id, - path=path, - reached_target=resolved_reached_target, - report=report if report is not None else RoutingReport(), - ) + self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) def _routing_order( self, net_specs: dict[str, NetSpec], - order: str, + order: NetOrder, ) -> list[str]: ordered_net_ids = list(net_specs.keys()) if order == "user": @@ -144,15 +73,26 @@ class PathFinder: def _build_greedy_warm_start_paths( self, net_specs: dict[str, NetSpec], - order: str, + order: NetOrder, ) -> dict[str, tuple[ComponentResult, ...]]: greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} temp_obj_ids: list[int] = [] - greedy_node_limit = min(self.options.search.node_limit, 2000) + greedy_node_limit = min(self.context.options.search.node_limit, 2000) for net_id in self._routing_order(net_specs, order): net = net_specs[net_id] - h_start = self.cost_evaluator.h_manhattan(net.start, net.target) + h_start = self.context.cost_evaluator.h_manhattan( + net.start, + net.target, + min_bend_radius=self.context.min_bend_radius, + ) max_cost_limit = max(h_start * 3.0, 2000.0) + run_config = SearchRunConfig.from_options( + self.context.options, + skip_congestion=True, + max_cost=max_cost_limit, + self_collision_check=True, + node_limit=greedy_node_limit, + ) path = route_astar( net.start, net.target, @@ -160,24 +100,24 @@ class PathFinder: context=self.context, metrics=self.metrics, net_id=net_id, - skip_congestion=True, - max_cost=max_cost_limit, - self_collision_check=True, - node_limit=greedy_node_limit, + config=run_config, ) if not path: continue greedy_paths[net_id] = tuple(path) - temp_obj_ids.extend(self._stage_path_as_static(path)) + for result in path: + for polygon in result.physical_geometry: + temp_obj_ids.append(self.context.cost_evaluator.collision_engine.add_static_obstacle(polygon)) self.context.clear_static_caches() - self._remove_static_obstacles(temp_obj_ids) + for obj_id in temp_obj_ids: + self.context.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) return greedy_paths def _prepare_state(self) -> _RoutingState: - problem = self.problem - congestion = self.options.congestion - initial_paths = self.options.search.initial_paths + problem = self.context.problem + congestion = self.context.options.congestion + initial_paths = self._materialize_problem_initial_paths() net_specs = {net.net_id: net for net in problem.nets} num_nets = len(net_specs) state = _RoutingState( @@ -190,27 +130,45 @@ class PathFinder: initial_paths=initial_paths, accumulated_expanded_nodes=[], ) - if state.initial_paths is None: - warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start - if warm_start_order is not None: - state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order) - self.context.clear_static_caches() + if state.initial_paths is None and congestion.warm_start_enabled: + state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order) + self.context.clear_static_caches() - if congestion.sort_nets and congestion.sort_nets != "user": - state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets) + if congestion.net_order != "user": + state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order) return state + def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None: + if not self.context.problem.initial_paths: + return None + + search = self.context.options.search + net_specs = {net.net_id: net for net in self.context.problem.nets} + initial_paths: dict[str, tuple[ComponentResult, ...]] = {} + for net_id, seed in self.context.problem.initial_paths.items(): + if net_id not in net_specs: + raise ValueError(f"Initial path provided for unknown net: {net_id}") + net = net_specs[net_id] + initial_paths[net_id] = materialize_path_seed( + seed, + start=net.start, + net_width=net.width, + search=search, + clearance=self.context.cost_evaluator.collision_engine.clearance, + ) + return initial_paths + def _route_net_once( self, state: _RoutingState, iteration: int, net_id: str, ) -> RoutingResult: - search = self.options.search - congestion = self.options.congestion - diagnostics = self.options.diagnostics + search = self.context.options.search + congestion = self.context.options.congestion + diagnostics = self.context.options.diagnostics net = state.net_specs[net_id] - self._remove_path(net_id) + self.context.cost_evaluator.collision_engine.remove_path(net_id) if iteration == 0 and state.initial_paths and net_id in state.initial_paths: path: Sequence[ComponentResult] | None = state.initial_paths[net_id] @@ -222,13 +180,8 @@ class PathFinder: if coll_model == "arc": coll_model = "clipped_bbox" - path = route_astar( - net.start, - net.target, - net.width, - context=self.context, - metrics=self.metrics, - net_id=net_id, + run_config = SearchRunConfig.from_options( + self.context.options, bend_collision_type=coll_model, return_partial=True, store_expanded=diagnostics.capture_expanded, @@ -236,26 +189,35 @@ class PathFinder: self_collision_check=(net_id in state.needs_self_collision_check), node_limit=search.node_limit, ) + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + config=run_config, + ) if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) if not path: - return self._build_routing_result(net_id=net_id, path=[], reached_target=False) + return RoutingResult(net_id=net_id, path=(), reached_target=False) reached_target = path[-1].end_port == net.target report = None self._install_path(net_id, path) if reached_target: - report = self._verify_path_report(net_id, path) + report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, path) if report.self_collision_count > 0: state.needs_self_collision_check.add(net_id) - return self._build_routing_result( + return RoutingResult( net_id=net_id, path=path, reached_target=reached_target, - report=report, + report=RoutingReport() if report is None else report, ) def _run_iteration( @@ -265,7 +227,7 @@ class PathFinder: iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, ) -> dict[str, RoutingOutcome] | None: outcomes: dict[str, RoutingOutcome] = {} - congestion = self.options.congestion + congestion = self.context.options.congestion self.metrics.reset_per_route() if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): @@ -274,7 +236,6 @@ class PathFinder: for net_id in state.ordered_net_ids: if time.monotonic() - state.start_time > state.timeout_s: - self._finalize_dynamic_tree() return None result = self._route_net_once(state, iteration, net_id) @@ -290,30 +251,30 @@ class PathFinder: state: _RoutingState, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, ) -> bool: - congestion = self.options.congestion + congestion = self.context.options.congestion for iteration in range(congestion.max_iterations): outcomes = self._run_iteration(state, iteration, iteration_callback) if outcomes is None: return True - if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()): + if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()): return False - self.cost_evaluator.congestion_penalty *= congestion.multiplier + self.context.congestion_penalty *= congestion.multiplier return False def _refine_results(self, state: _RoutingState) -> None: - if not self.options.refinement.enabled or not state.results: + if not self.context.options.refinement.enabled or not state.results: return for net_id in state.ordered_net_ids: result = state.results.get(net_id) - if not result or not result.path or routing_outcome_needs_retry(result.outcome): + if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}: continue net = state.net_specs[net_id] - self._remove_path(net_id) - refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path) + self.context.cost_evaluator.collision_engine.remove_path(net_id) + refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path) self._install_path(net_id, refined_path) - report = self._verify_path_report(net_id, refined_path) - state.results[net_id] = self._build_routing_result( + report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path) + state.results[net_id] = RoutingResult( net_id=net_id, path=refined_path, reached_target=result.reached_target, @@ -322,17 +283,13 @@ class PathFinder: def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: final_results: dict[str, RoutingResult] = {} - for net in self.problem.nets: + for net in self.context.problem.nets: result = state.results.get(net.net_id) if not result or not result.path: - final_results[net.net_id] = self._build_routing_result( - net_id=net.net_id, - path=[], - reached_target=False, - ) + final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False) continue - report = self._verify_path_report(net.net_id, result.path) - final_results[net.net_id] = self._build_routing_result( + report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path) + final_results[net.net_id] = RoutingResult( net_id=net.net_id, path=result.path, reached_target=result.reached_target, @@ -345,7 +302,7 @@ class PathFinder: *, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, ) -> dict[str, RoutingResult]: - self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty + self.context.congestion_penalty = self.context.options.congestion.base_penalty self.accumulated_expanded_nodes = [] self.metrics.reset_totals() self.metrics.reset_per_route() @@ -358,5 +315,4 @@ class PathFinder: return self._verify_results(state) self._refine_results(state) - self._finalize_dynamic_tree() return self._verify_results(state) diff --git a/inire/router/_search.py b/inire/router/_search.py index 7816ef3..2cf7daa 100644 --- a/inire/router/_search.py +++ b/inire/router/_search.py @@ -4,12 +4,10 @@ import heapq from typing import TYPE_CHECKING from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import BendCollisionModel from inire.geometry.primitives import Port from ._astar_moves import expand_moves as _expand_moves -from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode -from .results import RouteMetrics +from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -29,21 +27,14 @@ def route_astar( target: Port, net_width: float, context: AStarContext, + *, metrics: AStarMetrics | None = None, net_id: str = "default", - bend_collision_type: BendCollisionModel | None = None, - return_partial: bool = False, - store_expanded: bool = False, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, - node_limit: int | None = None, + config: SearchRunConfig, ) -> list[ComponentResult] | None: if metrics is None: metrics = AStarMetrics() metrics.reset_per_route() - search_options = context.options.search - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type context.ensure_static_caches_current() context.cost_evaluator.set_target(target) @@ -51,18 +42,21 @@ def route_astar( closed_set: dict[tuple[int, int, int], float] = {} congestion_cache: dict[tuple, int] = {} - start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) + start_node = _AStarNode( + start, + 0.0, + context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius), + ) heapq.heappush(open_set, start_node) best_node = start_node - effective_node_limit = node_limit if node_limit is not None else search_options.node_limit nodes_expanded = 0 while open_set: - if nodes_expanded >= effective_node_limit: - return _reconstruct_path(best_node) if return_partial else None + if nodes_expanded >= config.node_limit: + return _reconstruct_path(best_node) if config.return_partial else None current = heapq.heappop(open_set) - if max_cost is not None and current.fh_cost[0] > max_cost: + if config.max_cost is not None and current.fh_cost[0] > config.max_cost: metrics.pruned_cost += 1 metrics.total_pruned_cost += 1 continue @@ -75,7 +69,7 @@ def route_astar( continue closed_set[state] = current.g_cost - if store_expanded: + if config.store_expanded: metrics.last_expanded_nodes.append(state) nodes_expanded += 1 @@ -95,18 +89,7 @@ def route_astar( context, metrics, congestion_cache, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - skip_congestion=skip_congestion, - self_collision_check=self_collision_check, + config=config, ) - return _reconstruct_path(best_node) if return_partial else None - - -__all__ = [ - "AStarContext", - "AStarMetrics", - "RouteMetrics", - "route_astar", -] + return _reconstruct_path(best_node) if config.return_partial else None diff --git a/inire/router/_seed_materialization.py b/inire/router/_seed_materialization.py new file mode 100644 index 0000000..b63cda9 --- /dev/null +++ b/inire/router/_seed_materialization.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.model import SearchOptions +from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + + +def materialize_path_seed( + seed: PathSeed, + *, + start: Port, + net_width: float, + search: SearchOptions, + clearance: float, +) -> tuple[ComponentResult, ...]: + from inire.geometry.components import Bend90, SBend, Straight + + path: list[ComponentResult] = [] + current = start + dilation = clearance / 2.0 + bend_collision_type = search.bend_collision_type + + for segment in seed.segments: + if isinstance(segment, StraightSeed): + component = Straight.generate(current, segment.length, net_width, dilation=dilation) + elif isinstance(segment, Bend90Seed): + component = Bend90.generate( + current, + segment.radius, + net_width, + segment.direction, + collision_type=bend_collision_type, + dilation=dilation, + ) + elif isinstance(segment, SBendSeed): + component = SBend.generate( + current, + segment.offset, + segment.radius, + net_width, + collision_type=bend_collision_type, + dilation=dilation, + ) + else: + raise TypeError(f"Unsupported seed segment: {type(segment)!r}") + path.append(component) + current = component.end_port + return tuple(path) diff --git a/inire/router/_stack.py b/inire/router/_stack.py new file mode 100644 index 0000000..71aa119 --- /dev/null +++ b/inire/router/_stack.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from inire.model import RoutingOptions, RoutingProblem + + +@dataclass(frozen=True, slots=True) +class RoutingStack: + world: object + danger_map: object + evaluator: object + context: object + finder: object + + +def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack: + from inire.geometry.collision import RoutingWorld + from inire.router._astar_types import AStarContext + from inire.router._router import PathFinder + from inire.router.cost import CostEvaluator + from inire.router.danger_map import DangerMap + + world = RoutingWorld( + clearance=problem.clearance, + safety_zone_radius=problem.safety_zone_radius, + ) + for obstacle in problem.static_obstacles: + world.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=problem.bounds) + danger_map.precompute(list(problem.static_obstacles)) + + objective = options.objective + evaluator = CostEvaluator( + world, + danger_map, + unit_length_cost=objective.unit_length_cost, + greedy_h_weight=options.search.greedy_h_weight, + bend_penalty=objective.bend_penalty, + sbend_penalty=objective.sbend_penalty, + danger_weight=objective.danger_weight, + ) + context = AStarContext(evaluator, problem, options) + finder = PathFinder(context) + return RoutingStack( + world=world, + danger_map=danger_map, + evaluator=evaluator, + context=context, + finder=finder, + ) diff --git a/inire/router/cost.py b/inire/router/cost.py index 8468c3e..73fa121 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -5,13 +5,9 @@ from typing import TYPE_CHECKING import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.model import ObjectiveWeights, RoutingOptions +from inire.model import ObjectiveWeights if TYPE_CHECKING: - from collections.abc import Sequence - - from shapely.geometry import Polygon - from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port @@ -22,18 +18,13 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "_unit_length_cost", + "_search_weights", "_greedy_h_weight", - "_bend_penalty", - "_sbend_penalty", - "_danger_weight", - "_congestion_penalty", "_target_x", "_target_y", "_target_r", "_target_cos", "_target_sin", - "_min_radius", ) def __init__( @@ -49,91 +40,25 @@ class CostEvaluator: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty self.collision_engine = collision_engine self.danger_map = danger_map - self._unit_length_cost = float(unit_length_cost) + self._search_weights = ObjectiveWeights( + unit_length_cost=unit_length_cost, + bend_penalty=bend_penalty, + sbend_penalty=actual_sbend_penalty, + danger_weight=danger_weight, + ) self._greedy_h_weight = float(greedy_h_weight) - self._bend_penalty = float(bend_penalty) - self._sbend_penalty = float(actual_sbend_penalty) - self._danger_weight = float(danger_weight) - self._congestion_penalty = 0.0 self._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - self._min_radius = 50.0 - @property - def unit_length_cost(self) -> float: - return self._unit_length_cost + def default_weights(self) -> ObjectiveWeights: + return self._search_weights - @unit_length_cost.setter - def unit_length_cost(self, value: float) -> None: - self._unit_length_cost = float(value) - - @property - def greedy_h_weight(self) -> float: - return self._greedy_h_weight - - @greedy_h_weight.setter - def greedy_h_weight(self, value: float) -> None: - self._greedy_h_weight = float(value) - - @property - def congestion_penalty(self) -> float: - return self._congestion_penalty - - @congestion_penalty.setter - def congestion_penalty(self, value: float) -> None: - self._congestion_penalty = float(value) - - @property - def bend_penalty(self) -> float: - return self._bend_penalty - - @bend_penalty.setter - def bend_penalty(self, value: float) -> None: - self._bend_penalty = float(value) - - @property - def sbend_penalty(self) -> float: - return self._sbend_penalty - - @sbend_penalty.setter - def sbend_penalty(self, value: float) -> None: - self._sbend_penalty = float(value) - - @property - def danger_weight(self) -> float: - return self._danger_weight - - @danger_weight.setter - def danger_weight(self, value: float) -> None: - self._danger_weight = float(value) - - def set_min_bend_radius(self, radius: float) -> None: - self._min_radius = float(radius) if radius > 0 else 50.0 - - def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights: - return ObjectiveWeights( - unit_length_cost=self._unit_length_cost, - bend_penalty=self._bend_penalty, - sbend_penalty=self._sbend_penalty, - danger_weight=self._danger_weight, - congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty), - ) - - def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights: - refinement_objective = options.refinement.objective - if refinement_objective is None: - return ObjectiveWeights( - unit_length_cost=self._unit_length_cost, - bend_penalty=self._bend_penalty, - sbend_penalty=self._sbend_penalty, - danger_weight=self._danger_weight, - congestion_penalty=0.0, - ) - return refinement_objective + def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights: + return self._search_weights if weights is None else weights def set_target(self, target: Port) -> None: self._target_x = target.x @@ -143,12 +68,13 @@ class CostEvaluator: self._target_cos = np.cos(rad) self._target_sin = np.sin(rad) - def g_proximity(self, x: float, y: float) -> float: - if self.danger_map is None: - return 0.0 - return self._danger_weight * self.danger_map.get_cost(x, y) - - def h_manhattan(self, current: Port, target: Port) -> float: + def h_manhattan( + self, + current: Port, + target: Port, + *, + min_bend_radius: float = 50.0, + ) -> float: tx, ty = target.x, target.y if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r: self.set_target(target) @@ -156,7 +82,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self._bend_penalty + bp = self._search_weights.bend_penalty penalty = 0.0 curr_r = current.r @@ -168,7 +94,7 @@ class CostEvaluator: v_dy = ty - current.y side_proj = v_dx * self._target_cos + v_dy * self._target_sin perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos) - if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0): + if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0): penalty += 2 * bp if curr_r == 0: @@ -188,46 +114,27 @@ class CostEvaluator: return self._greedy_h_weight * (dist + penalty) - def evaluate_move( + def score_component( self, - geometry: Sequence[Polygon] | None, - end_port: Port, - net_width: float, - net_id: str, + component: ComponentResult, + *, start_port: Port | None = None, - length: float = 0.0, - dilated_geometry: Sequence[Polygon] | None = None, - skip_static: bool = False, - skip_congestion: bool = False, - penalty: float = 0.0, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights - _ = net_width + active_weights = self._resolve_weights(weights) danger_map = self.danger_map + end_port = component.end_port if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - total_cost = length * active_weights.unit_length_cost + penalty - if not skip_static or not skip_congestion: - if geometry is None: - return 1e15 - collision_engine = self.collision_engine - for i, poly in enumerate(geometry): - dil_poly = dilated_geometry[i] if dilated_geometry else None - if not skip_static and collision_engine.check_collision( - poly, - net_id, - buffer_mode="static", - start_port=start_port, - end_port=end_port, - dilated_geometry=dil_poly, - ): - return 1e15 - if not skip_congestion: - overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly) - if isinstance(overlaps, int) and overlaps > 0: - total_cost += overlaps * active_weights.congestion_penalty + move_radius = None + if component.move_type == "bend90": + move_radius = component.length * 2.0 / np.pi if component.length > 0 else None + total_cost = component.length * active_weights.unit_length_cost + self.component_penalty( + component.move_type, + move_radius=move_radius, + weights=active_weights, + ) if danger_map is not None and active_weights.danger_weight: cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 @@ -236,9 +143,9 @@ class CostEvaluator: mid_x = (start_port.x + end_port.x) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0 cost_m = danger_map.get_cost(mid_x, mid_y) - total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 + total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += length * active_weights.danger_weight * cost_e + total_cost += component.length * active_weights.danger_weight * cost_e return total_cost def component_penalty( @@ -248,7 +155,7 @@ class CostEvaluator: move_radius: float | None = None, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights + active_weights = self._resolve_weights(weights) penalty = 0.0 if move_type == "sbend": penalty = active_weights.sbend_penalty @@ -260,37 +167,18 @@ class CostEvaluator: def path_cost( self, - net_id: str, start_port: Port, path: list[ComponentResult], *, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights + active_weights = self._resolve_weights(weights) total = 0.0 current_port = start_port for component in path: - move_radius = None - if component.move_type == "bend90": - move_radius = component.length * 2.0 / np.pi if component.length > 0 else None - elif component.move_type == "sbend": - move_radius = None - penalty = self.component_penalty( - component.move_type, - move_radius=move_radius, - weights=active_weights, - ) - total += self.evaluate_move( - component.collision_geometry, - component.end_port, - net_width=0.0, - net_id=net_id, + total += self.score_component( + component, start_port=current_port, - length=component.length, - dilated_geometry=component.dilated_collision_geometry, - skip_static=True, - skip_congestion=(active_weights.congestion_penalty <= 0.0), - penalty=penalty, weights=active_weights, ) current_port = component.end_port diff --git a/inire/router/outcomes.py b/inire/router/outcomes.py deleted file mode 100644 index 9dab591..0000000 --- a/inire/router/outcomes.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from typing import Literal - - -RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] - -RETRYABLE_ROUTING_OUTCOMES = frozenset({"colliding", "partial", "unroutable"}) - - -def infer_routing_outcome( - *, - has_path: bool, - reached_target: bool, - collision_count: int, -) -> RoutingOutcome: - if not has_path: - return "unroutable" - if not reached_target: - return "partial" - if collision_count > 0: - return "colliding" - return "completed" - - -def routing_outcome_needs_retry(outcome: RoutingOutcome) -> bool: - return outcome in RETRYABLE_ROUTING_OUTCOMES diff --git a/inire/router/refiner.py b/inire/router/refiner.py index 1f9112e..6aa5d1f 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -3,7 +3,7 @@ from __future__ import annotations import math from typing import TYPE_CHECKING, Any -from inire.geometry.component_overlap import components_overlap, has_self_overlap +from inire.geometry.component_overlap import components_overlap from inire.geometry.components import Bend90, Straight if TYPE_CHECKING: @@ -12,7 +12,8 @@ if TYPE_CHECKING: from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port - from inire.router._search import AStarContext + from inire.router._astar_types import AStarContext + def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: current = parent_node @@ -24,10 +25,6 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) return False -def has_self_collision(path: Sequence[ComponentResult]) -> bool: - return has_self_overlap(path) - - class PathRefiner: __slots__ = ("context",) @@ -42,17 +39,16 @@ class PathRefiner: self, path: Sequence[ComponentResult], *, - net_id: str = "default", start: Port | None = None, ) -> float: if not path: return 0.0 actual_start = path[0].start_port if start is None else start - return self.score_path(net_id, actual_start, path) + return self.score_path(actual_start, path) - def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float: - weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options) - return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights) + def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float: + weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights + return self.context.cost_evaluator.path_cost(start, path, weights=weights) def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: ports = [start] @@ -291,11 +287,9 @@ class PathRefiner: self, net_id: str, start: Port, - target: Port, net_width: float, path: list[ComponentResult], ) -> list[ComponentResult]: - _ = target if not path: return path @@ -306,7 +300,7 @@ class PathRefiner: return path best_path = path - best_cost = self.score_path(net_id, start, path) + best_cost = self.score_path(start, path) for _ in range(3): improved = False diff --git a/inire/router/results.py b/inire/router/results.py index 3548f64..a9d3c1f 100644 --- a/inire/router/results.py +++ b/inire/router/results.py @@ -1,68 +1,16 @@ -from __future__ import annotations +"""Semi-private compatibility exports for router result types. -from dataclasses import dataclass, field -from typing import TYPE_CHECKING +These deep-module imports remain accessible for advanced use, but they are +unstable and may change without notice. Prefer importing public result types +from ``inire`` or ``inire.results``. +""" -from inire.router.outcomes import RoutingOutcome, infer_routing_outcome +from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.model import LockedRoute - - -@dataclass(frozen=True, slots=True) -class RoutingReport: - static_collision_count: int = 0 - dynamic_collision_count: int = 0 - self_collision_count: int = 0 - total_length: float = 0.0 - - @property - def collision_count(self) -> int: - return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count - - @property - def is_valid(self) -> bool: - return self.collision_count == 0 - - -@dataclass(frozen=True, slots=True) -class RouteMetrics: - nodes_expanded: int - moves_generated: int - moves_added: int - pruned_closed_set: int - pruned_hard_collision: int - pruned_cost: int - - -@dataclass(frozen=True, slots=True) -class RoutingResult: - net_id: str - path: tuple[ComponentResult, ...] - reached_target: bool = False - report: RoutingReport = field(default_factory=RoutingReport) - - def __post_init__(self) -> None: - object.__setattr__(self, "path", tuple(self.path)) - - @property - def collisions(self) -> int: - return self.report.collision_count - - @property - def outcome(self) -> RoutingOutcome: - return infer_routing_outcome( - has_path=bool(self.path), - reached_target=self.reached_target, - collision_count=self.report.collision_count, - ) - - @property - def is_valid(self) -> bool: - return self.outcome == "completed" - - def as_locked_route(self) -> LockedRoute: - from inire.model import LockedRoute - - return LockedRoute.from_path(self.path) +__all__ = [ + "RouteMetrics", + "RoutingOutcome", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", +] diff --git a/inire/seeds.py b/inire/seeds.py new file mode 100644 index 0000000..635e489 --- /dev/null +++ b/inire/seeds.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +BendDirection = Literal["CW", "CCW"] + + +@dataclass(frozen=True, slots=True) +class StraightSeed: + length: float + + def __post_init__(self) -> None: + object.__setattr__(self, "length", float(self.length)) + + +@dataclass(frozen=True, slots=True) +class Bend90Seed: + radius: float + direction: BendDirection + + def __post_init__(self) -> None: + object.__setattr__(self, "radius", float(self.radius)) + + +@dataclass(frozen=True, slots=True) +class SBendSeed: + offset: float + radius: float + + def __post_init__(self) -> None: + object.__setattr__(self, "offset", float(self.offset)) + object.__setattr__(self, "radius", float(self.radius)) + + +PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed + + +@dataclass(frozen=True, slots=True) +class PathSeed: + segments: tuple[PathSegmentSeed, ...] + + def __post_init__(self) -> None: + segments = tuple(self.segments) + if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments): + raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances") + object.__setattr__(self, "segments", segments) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py deleted file mode 100644 index 3513c62..0000000 --- a/inire/tests/benchmark_scaling.py +++ /dev/null @@ -1,68 +0,0 @@ -import time - -from inire import NetSpec -from inire.geometry.primitives import Port -from inire.geometry.collision import RoutingWorld -from inire.router.danger_map import DangerMap -from inire.router.cost import CostEvaluator -from inire.router._astar_types import AStarMetrics -from inire.router._router import PathFinder -from inire.tests.support import build_context - -def benchmark_scaling() -> None: - print("Starting Scalability Benchmark...") - - # 1. Memory Verification (20x20mm) - # Resolution 1um -> 20000 x 20000 grid - bounds = (0, 0, 20000, 20000) - print(f"Initializing DangerMap for {bounds} area...") - dm = DangerMap(bounds=bounds, resolution=1.0) - # nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB - mem_gb = dm.grid.nbytes / (1024**3) - print(f"DangerMap memory usage: {mem_gb:.2f} GB") - assert mem_gb < 2.0 - - # 2. Node Expansion Rate (50 nets) - engine = RoutingWorld(clearance=2.0) - # Use a smaller area for routing benchmark to keep it fast - routing_bounds = (0, 0, 1000, 1000) - danger_map = DangerMap(bounds=routing_bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map) - num_nets = 50 - netlist = {} - for i in range(num_nets): - # Parallel nets spaced by 10um - netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0)) - metrics = AStarMetrics() - pf = PathFinder( - build_context( - evaluator, - bounds=routing_bounds, - nets=( - NetSpec(net_id=net_id, start=start, target=target, width=2.0) - for net_id, (start, target) in netlist.items() - ), - ), - metrics=metrics, - ) - - print(f"Routing {num_nets} nets...") - start_time = time.monotonic() - results = pf.route_all() - end_time = time.monotonic() - - total_time = end_time - start_time - print(f"Total routing time: {total_time:.2f} s") - print(f"Time per net: {total_time/num_nets:.4f} s") - - if total_time > 0: - nodes_per_sec = metrics.total_nodes_expanded / total_time - print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s") - - # Success rate - successes = sum(1 for r in results.values() if r.is_valid) - print(f"Success rate: {successes/num_nets * 100:.1f}%") - -if __name__ == "__main__": - benchmark_scaling() diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index da4f98c..2e74ec1 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,40 +1,44 @@ from __future__ import annotations -from dataclasses import dataclass from time import perf_counter from typing import Callable from shapely.geometry import Polygon, box -from inire import NetSpec, RoutingResult +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, +) from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router._astar_types import AStarMetrics +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_pathfinder +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) -@dataclass(frozen=True) -class ScenarioOutcome: - duration_s: float - total_results: int - valid_results: int - reached_targets: int - - -@dataclass(frozen=True) -class ScenarioDefinition: - name: str - run: Callable[[], ScenarioOutcome] +ScenarioOutcome = tuple[float, int, int, int] +ScenarioRun = Callable[[], ScenarioOutcome] def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: - return ScenarioOutcome( - duration_s=duration_s, - total_results=len(results), - valid_results=sum(1 for result in results.values() if result.is_valid), - reached_targets=sum(1 for result in results.values() if result.reached_target), + return ( + duration_s, + len(results), + sum(1 for result in results.values() if result.is_valid), + sum(1 for result in results.values() if result.reached_target), ) @@ -66,6 +70,39 @@ def _net_specs( ) +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...], + metrics: AStarMetrics | None = None, + **request_kwargs: object, +) -> PathFinder: + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + _build_options(**request_kwargs), + ), + metrics=metrics, + ) + + def _build_routing_stack( *, bounds: tuple[float, float, float, float], @@ -86,7 +123,7 @@ def _build_routing_stack( evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) metrics = AStarMetrics() - pathfinder = build_pathfinder( + pathfinder = _build_pathfinder( evaluator, bounds=bounds, nets=_net_specs(netlist, widths), @@ -150,9 +187,9 @@ def run_example_03() -> ScenarioOutcome: ) t0 = perf_counter() results_a = pathfinder.route_all() - for polygon in results_a["netA"].as_locked_route().geometry: + for polygon in results_a["netA"].locked_geometry: engine.add_static_obstacle(polygon) - results_b = build_pathfinder( + results_b = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}), @@ -240,7 +277,7 @@ def run_example_06() -> ScenarioOutcome: t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} for evaluator, netlist, net_widths, request_kwargs in scenarios: - pathfinder = build_pathfinder( + pathfinder = _build_pathfinder( evaluator, bounds=bounds, nets=_net_specs(netlist, net_widths), @@ -296,9 +333,7 @@ def run_example_07() -> ScenarioOutcome: ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: - new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) - evaluator.greedy_h_weight = new_greedy - metrics.reset_per_route() + _ = idx, current_results t0 = perf_counter() results = pathfinder.route_all(iteration_callback=iteration_callback) @@ -315,7 +350,7 @@ def run_example_08() -> ScenarioOutcome: custom_evaluator = _build_evaluator(bounds) t0 = perf_counter() - results_std = build_pathfinder( + results_std = _build_pathfinder( standard_evaluator, bounds=bounds, nets=_net_specs(netlist, widths), @@ -324,7 +359,7 @@ def run_example_08() -> ScenarioOutcome: use_tiered_strategy=False, metrics=AStarMetrics(), ).route_all() - results_custom = build_pathfinder( + results_custom = _build_pathfinder( custom_evaluator, bounds=bounds, nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), @@ -351,7 +386,7 @@ def run_example_09() -> ScenarioOutcome: widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False}, ) t0 = perf_counter() results = pathfinder.route_all() @@ -359,14 +394,14 @@ def run_example_09() -> ScenarioOutcome: return _summarize(results, t1 - t0) -SCENARIOS: tuple[ScenarioDefinition, ...] = ( - ScenarioDefinition("example_01_simple_route", run_example_01), - ScenarioDefinition("example_02_congestion_resolution", run_example_02), - ScenarioDefinition("example_03_locked_routes", run_example_03), - ScenarioDefinition("example_04_sbends_and_radii", run_example_04), - ScenarioDefinition("example_05_orientation_stress", run_example_05), - ScenarioDefinition("example_06_bend_collision_models", run_example_06), - ScenarioDefinition("example_07_large_scale_routing", run_example_07), - ScenarioDefinition("example_08_custom_bend_geometry", run_example_08), - ScenarioDefinition("example_09_unroutable_best_effort", run_example_09), +SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( + ("example_01_simple_route", run_example_01), + ("example_02_congestion_resolution", run_example_02), + ("example_03_locked_routes", run_example_03), + ("example_04_sbends_and_radii", run_example_04), + ("example_05_orientation_stress", run_example_05), + ("example_06_bend_collision_models", run_example_06), + ("example_07_large_scale_routing", run_example_07), + ("example_08_custom_bend_geometry", run_example_08), + ("example_09_unroutable_best_effort", run_example_09), ) diff --git a/inire/tests/support.py b/inire/tests/support.py deleted file mode 100644 index 3461790..0000000 --- a/inire/tests/support.py +++ /dev/null @@ -1,162 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable - -from inire.model import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - SearchOptions, -) -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder - - -def build_problem( - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - **overrides: object, -) -> RoutingProblem: - return RoutingProblem( - bounds=bounds, - nets=tuple(nets), - **overrides, - ) - - -def build_request( - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - **overrides: object, -) -> RoutingProblem: - return build_problem(bounds=bounds, nets=nets, **overrides) - - -def build_options( - *, - objective: ObjectiveWeights | None = None, - search: SearchOptions | None = None, - congestion: CongestionOptions | None = None, - refinement: RefinementOptions | None = None, - diagnostics: DiagnosticsOptions | None = None, - **overrides: object, -) -> RoutingOptions: - if objective is None: - objective = ObjectiveWeights() - if search is None: - search = SearchOptions() - if congestion is None: - congestion = CongestionOptions() - if refinement is None: - refinement = RefinementOptions() - if diagnostics is None: - diagnostics = DiagnosticsOptions() - - search_fields = set(SearchOptions.__dataclass_fields__) - congestion_fields = set(CongestionOptions.__dataclass_fields__) - refinement_fields = set(RefinementOptions.__dataclass_fields__) - diagnostics_fields = set(DiagnosticsOptions.__dataclass_fields__) - objective_fields = set(ObjectiveWeights.__dataclass_fields__) - - search_overrides = {key: value for key, value in overrides.items() if key in search_fields} - congestion_overrides = {key: value for key, value in overrides.items() if key in congestion_fields} - refinement_overrides = {key: value for key, value in overrides.items() if key in refinement_fields} - diagnostics_overrides = {key: value for key, value in overrides.items() if key in diagnostics_fields} - objective_overrides = {key: value for key, value in overrides.items() if key in objective_fields} - - unknown = set(overrides) - search_fields - congestion_fields - refinement_fields - diagnostics_fields - objective_fields - if unknown: - unknown_names = ", ".join(sorted(unknown)) - raise TypeError(f"Unsupported RoutingOptions overrides: {unknown_names}") - - resolved_objective = objective if not objective_overrides else ObjectiveWeights( - **{ - field: getattr(objective, field) - for field in objective_fields - } - | objective_overrides - ) - resolved_search = search if not search_overrides else SearchOptions( - **{ - field: getattr(search, field) - for field in search_fields - } - | search_overrides - ) - resolved_congestion = congestion if not congestion_overrides else CongestionOptions( - **{ - field: getattr(congestion, field) - for field in congestion_fields - } - | congestion_overrides - ) - resolved_refinement = refinement if not refinement_overrides else RefinementOptions( - **{ - field: getattr(refinement, field) - for field in refinement_fields - } - | refinement_overrides - ) - resolved_diagnostics = diagnostics if not diagnostics_overrides else DiagnosticsOptions( - **{ - field: getattr(diagnostics, field) - for field in diagnostics_fields - } - | diagnostics_overrides - ) - return RoutingOptions( - search=resolved_search, - objective=resolved_objective, - congestion=resolved_congestion, - refinement=resolved_refinement, - diagnostics=resolved_diagnostics, - ) - - -def build_context( - evaluator, - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - problem: RoutingProblem | None = None, - options: RoutingOptions | None = None, - **overrides: object, -) -> AStarContext: - resolved_problem = problem if problem is not None else build_problem(bounds=bounds, nets=nets) - resolved_options = options if options is not None else build_options(**overrides) - return AStarContext( - evaluator, - resolved_problem, - resolved_options, - ) - - -def build_pathfinder( - evaluator, - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - netlist: dict[str, tuple[object, object]] | None = None, - net_widths: dict[str, float] | None = None, - problem: RoutingProblem | None = None, - options: RoutingOptions | None = None, - **overrides: object, -) -> PathFinder: - resolved_problem = problem - if resolved_problem is None: - resolved_nets = tuple(nets) - if netlist is not None: - widths = {} if net_widths is None else net_widths - resolved_nets = tuple( - NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - resolved_problem = build_problem(bounds=bounds, nets=resolved_nets) - resolved_options = options if options is not None else build_options(**overrides) - return PathFinder(build_context(evaluator, bounds=bounds, problem=resolved_problem, options=resolved_options)) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py index 8474606..858cac9 100644 --- a/inire/tests/test_api.py +++ b/inire/tests/test_api.py @@ -1,9 +1,11 @@ +import importlib + +import pytest from shapely.geometry import box from inire import ( CongestionOptions, DiagnosticsOptions, - LockedRoute, NetSpec, ObjectiveWeights, Port, @@ -16,6 +18,26 @@ from inire import ( from inire.geometry.components import Straight +def test_root_module_exports_only_stable_surface() -> None: + import inire + + assert not hasattr(inire, "RoutingWorld") + assert not hasattr(inire, "AStarContext") + assert not hasattr(inire, "PathFinder") + assert not hasattr(inire, "CostEvaluator") + assert not hasattr(inire, "DangerMap") + + +def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None: + router_module = importlib.import_module("inire.router._router") + search_module = importlib.import_module("inire.router._search") + collision_module = importlib.import_module("inire.geometry.collision") + + assert hasattr(router_module, "PathFinder") + assert hasattr(search_module, "route_astar") + assert hasattr(collision_module, "RoutingWorld") + + def test_route_problem_smoke() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), @@ -44,7 +66,7 @@ def test_route_problem_supports_configs_and_debug_data() -> None: bend_penalty=50.0, sbend_penalty=150.0, ), - congestion=CongestionOptions(warm_start=None), + congestion=CongestionOptions(warm_start_enabled=False), refinement=RefinementOptions(enabled=True), diagnostics=DiagnosticsOptions(capture_expanded=True), ) @@ -61,10 +83,10 @@ def test_route_problem_locked_routes_become_static_obstacles() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),), - locked_routes={"locked": LockedRoute.from_path(locked)}, + static_obstacles=tuple(polygon for component in locked for polygon in component.physical_geometry), ) options = RoutingOptions( - congestion=CongestionOptions(max_iterations=1, warm_start=None), + congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), refinement=RefinementOptions(enabled=False), ) @@ -86,13 +108,22 @@ def test_locked_routes_enable_incremental_requests_without_sessions() -> None: problem_b = RoutingProblem( bounds=(0, -50, 100, 50), nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()}, + static_obstacles=results_a.results_by_net["netA"].locked_geometry, ) results_b = route(problem_b, options=options) assert results_b.results_by_net["netB"].is_valid +def test_route_problem_rejects_untyped_initial_paths() -> None: + with pytest.raises(TypeError): + RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + initial_paths={"net1": (object(),)}, # type: ignore[dict-item] + ) + + def test_route_results_metrics_are_snapshots() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index 58597fd..3d637b9 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,16 +1,16 @@ +import math + import pytest from shapely.geometry import Polygon -from inire import RoutingResult +from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions from inire.geometry.components import Bend90, Straight from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_options, build_problem -from inire.utils.validation import validate_routing_result BOUNDS = (0, -50, 150, 150) @@ -23,15 +23,95 @@ def basic_evaluator() -> CostEvaluator: return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _build_options(**search_overrides: object) -> RoutingOptions: + return RoutingOptions(search=SearchOptions(**search_overrides)) + + +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + **search_overrides: object, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds), + _build_options(**search_overrides), + ) + + +def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object): + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options, **config_overrides), + ) + + +def _validate_routing_result( + result: RoutingResult, + static_obstacles: list[Polygon], + clearance: float, + expected_start: Port | None = None, + expected_end: Port | None = None, +) -> dict[str, object]: + if not result.path: + return {"is_valid": False, "reason": "No path found"} + + connectivity_errors: list[str] = [] + if expected_start: + first_port = result.path[0].start_port + dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y) + if dist_to_start > 0.005: + connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") + if abs(first_port.r - expected_start.r) > 0.1: + connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") + + if expected_end: + last_port = result.path[-1].end_port + dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y) + if dist_to_end > 0.005: + connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") + if abs(last_port.r - expected_end.r) > 0.1: + connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") + + engine = RoutingWorld(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + report = engine.verify_path_report("validation", result.path) + is_valid = report.is_valid and not connectivity_errors + + reasons = [] + if report.static_collision_count: + reasons.append(f"Found {report.static_collision_count} obstacle collisions.") + if report.dynamic_collision_count: + reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") + if report.self_collision_count: + reasons.append(f"Found {report.self_collision_count} self-intersections.") + reasons.extend(connectivity_errors) + + return { + "is_valid": is_valid, + "reason": " ".join(reasons), + "obstacle_collisions": report.static_collision_count, + "dynamic_collisions": report.dynamic_collision_count, + "self_intersections": report.self_collision_count, + "total_length": report.total_length, + "connectivity_ok": not connectivity_errors, + } + + def test_astar_straight(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(50, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -40,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0]) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,)) start = Port(0, 0, 0) # 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -61,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" # Path should have detoured, so length > 50 @@ -76,15 +156,15 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(10.1, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) assert target.x == 10 - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -93,7 +173,7 @@ def test_validate_routing_result_checks_expected_start() -> None: path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result( + validation = _validate_routing_result( result, [], clearance=2.0, @@ -110,7 +190,7 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: result = RoutingResult(net_id="test", path=[bend], reached_target=True) obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) - validation = validate_routing_result( + validation = _validate_routing_result( result, [obstacle], clearance=2.0, @@ -122,26 +202,31 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: - basic_evaluator.bend_penalty = 120.0 - basic_evaluator.sbend_penalty = 240.0 - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0]) + basic_evaluator = CostEvaluator( + basic_evaluator.collision_engine, + basic_evaluator.danger_map, + bend_penalty=120.0, + sbend_penalty=240.0, + ) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,)) - assert basic_evaluator.bend_penalty == 120.0 - assert basic_evaluator.sbend_penalty == 240.0 assert context.options.search.bend_radii == (5.0,) assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc") + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc") route_astar( Port(0, 0, 0), Port(30, 10, 0), net_width=2.0, context=context, - bend_collision_type="clipped_bbox", - return_partial=True, + config=SearchRunConfig.from_options( + context.options, + bend_collision_type="clipped_bbox", + return_partial=True, + ), ) assert context.options.search.bend_collision_type == "arc" @@ -151,12 +236,12 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2) start = Port(0, 0, 0) target = Port(60, 0, 0) - partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True) - no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False) + partial_path = _route(context, start, target, return_partial=True) + no_partial_path = _route(context, start, target, return_partial=False) assert partial_path is not None assert partial_path @@ -165,18 +250,18 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: - context = build_context( + context = _build_context( basic_evaluator, bounds=BOUNDS, - bend_radii=[10.0], - sbend_radii=[10.0], - sbend_offsets=[10.0], + bend_radii=(10.0,), + sbend_radii=(10.0,), + sbend_offsets=(10.0,), max_straight_length=150.0, ) start = Port(0, 0, 0) target = Port(100, 10, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None assert path[-1].end_port == target @@ -195,22 +280,22 @@ def test_route_astar_supports_all_visibility_guidance_modes( obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)]) basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context( + context = _build_context( basic_evaluator, bounds=BOUNDS, - bend_radii=[10.0], - sbend_radii=[], + bend_radii=(10.0,), + sbend_radii=(), max_straight_length=150.0, visibility_guidance=visibility_guidance, ) start = Port(0, 0, 0) target = Port(80, 50, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -219,8 +304,8 @@ def test_route_astar_supports_all_visibility_guidance_modes( def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: context = AStarContext( basic_evaluator, - build_problem(bounds=BOUNDS), - build_options( + RoutingProblem(bounds=BOUNDS), + _build_options( min_straight_length=1.0, max_straight_length=100.0, ), @@ -230,6 +315,6 @@ def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_eval targets = [Port(length, 0, 0) for length in range(10, 70, 10)] for target in targets: - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None assert path[-1].end_port == target diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 5866019..67264cc 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,13 +1,41 @@ import pytest import numpy from shapely.geometry import Polygon +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap from inire import RoutingResult -from inire.tests.support import build_pathfinder + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions( + search=SearchOptions() if search is None else search, + congestion=CongestionOptions() if congestion is None else congestion, + ), + ), + ) def test_clearance_thresholds(): """ @@ -27,21 +55,21 @@ def test_clearance_thresholds(): # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. p2_ok = Port(0, 5, 0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_ok]) - assert is_v, f"Gap 3 should be valid, but got {count} collisions" + report_ok = ce.verify_path_report("net2", [res2_ok]) + assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions" # 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK. p2_exact = Port(0, 4, 0) res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_exact]) - assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions" + report_exact = ce.verify_path_report("net2", [res2_exact]) + assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions" # 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL. p2_fail = Port(0, 3, 0) res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_fail]) - assert not is_v, "Gap 1.999 should be invalid" - assert count > 0 + report_fail = ce.verify_path_report("net2", [res2_fail]) + assert not report_fail.is_valid, "Gap 1.999 should be invalid" + assert report_fail.collision_count > 0 def test_verify_all_nets_cases(): """ @@ -59,13 +87,12 @@ def test_verify_all_nets_cases(): } net_widths = {"net1": 2.0, "net2": 2.0} - results = build_pathfinder( + results = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_parallel_ok, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -79,13 +106,12 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = build_pathfinder( + results_p = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_parallel_fail, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() # verify_all_nets should flag both as invalid because they cross-collide assert not results_p["net3"].is_valid @@ -99,13 +125,12 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = build_pathfinder( + results_c = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_cross, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() assert not results_c["net5"].is_valid assert not results_c["net6"].is_valid diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index 8a8c2b9..7eb0e4f 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,65 +1,42 @@ -from shapely.geometry import Polygon - from inire.geometry.collision import RoutingWorld -from inire.geometry.primitives import Port from inire.geometry.components import Straight +from inire.geometry.primitives import Port + + +def _install_static_straight( + engine: RoutingWorld, + start: Port, + length: float, + *, + width: float, + dilation: float = 0.0, +) -> None: + obstacle = Straight.generate(start, length, width=width, dilation=dilation) + for polygon in obstacle.physical_geometry: + engine.add_static_obstacle(polygon) def test_collision_detection() -> None: - # Clearance = 2um engine = RoutingWorld(clearance=2.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0) - # 10x10 um obstacle at (10,10) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) + direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0) + assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port) - # 1. Direct hit - test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)]) - assert engine.is_collision(test_poly, net_width=2.0) + far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0) + assert not engine.check_move_static(far_away, start_port=far_away.start_port) - # 2. Far away - test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]) - assert not engine.is_collision(test_poly_far, net_width=2.0) - - # 3. Near hit (within clearance) - # Obstacle edge at x=10. - # test_poly edge at x=9. - # Distance = 1.0 um. - # Required distance (Wi+C)/2 = 2.0. Collision! - test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)]) - assert engine.is_collision(test_poly_near, net_width=2.0) + near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0) + assert engine.check_move_static(near_hit, start_port=near_hit.start_port) def test_safety_zone() -> None: - # Use zero clearance for this test to verify the 2nm port safety zone - # against the physical obstacle boundary. engine = RoutingWorld(clearance=0.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) - - # Port exactly on the boundary start_port = Port(10, 12, 0) - - # Move starting from this port that overlaps the obstacle by 1nm - # (Inside the 2nm safety zone) - test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)]) - - assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port) - - -def test_configurable_max_net_width() -> None: - # Large max_net_width (10.0) -> large pre-dilation (6.0) - engine = RoutingWorld(clearance=2.0, max_net_width=10.0) - - obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) - engine.add_static_obstacle(obstacle) - - test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)]) - # physical check: dilated test_poly by C/2 = 1.0. - # Dilated test_poly bounds: (14, 19, 17, 26). - # obstacle: (20, 20, 25, 25). No physical collision. - assert not engine.is_collision(test_poly, net_width=2.0) + test_move = Straight.generate(start_port, 0.002, width=0.001) + assert not engine.check_move_static(test_move, start_port=start_port) def test_ray_cast_width_clearance() -> None: @@ -68,8 +45,7 @@ def test_ray_cast_width_clearance() -> None: engine = RoutingWorld(clearance=2.0) # Obstacle at x=10 to 20 - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0) # 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK. start_ok = Port(6, 50, 90) @@ -84,25 +60,24 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: engine = RoutingWorld(clearance=2.0) - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0) # Straight move of length 10 at x=8 (Width 2.0) # Gap = 10 - 8 = 2.0 < 3.0. COLLISION. start = Port(8, 0, 90) res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2 - assert engine.check_move_static(res, start_port=start, net_width=2.0) + assert engine.check_move_static(res, start_port=start) # Move at x=7. Gap = 3.0 == minimum. OK. start_ok = Port(7, 0, 90) res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0) + assert not engine.check_move_static(res_ok, start_port=start_ok) # 3. Same exact-boundary case. start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) + assert not engine.check_move_static(res_exact, start_port=start_exact) def test_verify_path_report_preserves_long_net_id() -> None: @@ -149,8 +124,8 @@ def test_remove_path_clears_dynamic_path() -> None: dilated = [poly for component in path for poly in component.dilated_collision_geometry] engine.add_path("netA", geoms, dilated_geometry=dilated) - assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"} + assert {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} == {"netA"} engine.remove_path("netA") - assert list(engine.iter_dynamic_paths()) == [] + assert list(engine._dynamic_paths.geometries.values()) == [] assert len(engine._static_obstacles.geometries) == 0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index c5b8491..7d3f2eb 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,13 +1,14 @@ import pytest -from shapely.geometry import Polygon +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.model import NetSpec +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._router import PathFinder from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_pathfinder BOUNDS = (0, -40, 100, 40) @@ -21,13 +22,69 @@ def basic_evaluator() -> CostEvaluator: return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...] = (), + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions( + search=SearchOptions() if search is None else search, + congestion=CongestionOptions() if congestion is None else congestion, + ), + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + _build_context( + evaluator, + bounds=bounds, + nets=nets, + search=search, + congestion=congestion, + ), + ) + + +def _route(context: AStarContext, start: Port, target: Port) -> object: + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + + def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0]) + context = _build_context( + basic_evaluator, + bounds=BOUNDS, + search=SearchOptions(sbend_offsets=(2.0, 5.0)), + ) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) target = Port(50, 2, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None # Check if any component in the path is an SBend @@ -39,38 +96,3 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: found_sbend = True break assert found_sbend - - -def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None: - 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} - pf = build_pathfinder( - basic_evaluator, - bounds=BOUNDS, - netlist=netlist, - net_widths=net_widths, - bend_radii=[5.0, 10.0], - max_iterations=10, - base_penalty=1000.0, - ) - - # Force them into a narrow corridor that only fits ONE. - obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall - obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) - - basic_evaluator.collision_engine.add_static_obstacle(obs_top) - basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) - basic_evaluator.danger_map.precompute([obs_top, obs_bottom]) - - results = pf.route_all() - - assert len(results) == 2 - assert results["net1"].reached_target - assert results["net2"].reached_target - assert results["net1"].is_valid - assert results["net2"].is_valid - assert results["net1"].collisions == 0 - assert results["net2"].collisions == 0 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 7583d42..de9e9f2 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -2,10 +2,11 @@ from __future__ import annotations import os import statistics +from collections.abc import Callable import pytest -from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome +from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" @@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = { def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None: + _, total_results, valid_results, reached_targets = outcome expected = EXPECTED_OUTCOMES[name] - assert outcome.total_results == expected["total_results"] - assert outcome.valid_results == expected["valid_results"] - assert outcome.reached_targets == expected["reached_targets"] + assert total_results == expected["total_results"] + assert valid_results == expected["valid_results"] + assert reached_targets == expected["reached_targets"] @pytest.mark.performance @pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks") -@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS]) -def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None: +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS]) +def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None: + name, run = scenario timings = [] for _ in range(PERFORMANCE_REPEATS): - outcome = scenario.run() - _assert_expected_outcome(scenario.name, outcome) - timings.append(outcome.duration_s) + outcome = run() + _assert_expected_outcome(name, outcome) + timings.append(outcome[0]) median_runtime = statistics.median(timings) - assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, ( - f"{scenario.name} median runtime {median_runtime:.4f}s exceeded " - f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s " + assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, ( + f"{name} median runtime {median_runtime:.4f}s exceeded " + f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s " f"from timings {timings!r}" ) diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index 4e7e7e3..db300ce 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,8 +1,11 @@ +from inire import CongestionOptions, RoutingOptions, RoutingProblem from inire.geometry.primitives import Port from inire.geometry.collision import RoutingWorld +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_pathfinder def test_failed_net_visibility() -> None: """ @@ -35,14 +38,21 @@ def test_failed_net_visibility() -> None: "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} - pf = build_pathfinder( - evaluator, - bounds=(0, 0, 100, 100), - netlist=netlist, - net_widths=net_widths, - node_limit=10, - max_iterations=1, - warm_start=None, + pf = PathFinder( + AStarContext( + evaluator, + RoutingProblem( + bounds=(0, 0, 100, 100), + nets=tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ), + ), + RoutingOptions( + search=RoutingOptions().search.__class__(node_limit=10), + congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), + ), + ), ) # 4. Route @@ -59,9 +69,7 @@ def test_failed_net_visibility() -> None: # 6. Verify Visibility # Check if net1 is in the collision engine - found_nets = set() - for nid, _poly in engine.iter_dynamic_paths(): - found_nets.add(nid) + found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} print(f"Nets found in engine: {found_nets}") diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index 058b277..7c43251 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -6,10 +6,11 @@ from shapely.geometry import Point, Polygon from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.model import RoutingOptions, RoutingProblem, SearchOptions +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context @st.composite @@ -35,6 +36,29 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance return all(point.distance(obstacle) >= required_gap for obstacle in obstacles) +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + **search_overrides: object, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds), + RoutingOptions(search=SearchOptions(**search_overrides)), + ) + + +def _route(context: AStarContext, start: Port, target: Port): + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + + @settings(max_examples=3, deadline=None) @given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port()) def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: @@ -48,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability + context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Check if start/target are inside obstacles (safety zone check) # The router should handle this gracefully (either route or return None) try: - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) # This is a crash-smoke test rather than a full correctness proof. # If a full path is returned, it should at least terminate at the requested target. diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 05328ab..773bd56 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,7 +1,15 @@ -import pytest from shapely.geometry import box -from inire import NetSpec +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, +) from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90, Straight from inire.geometry.primitives import Port @@ -9,18 +17,15 @@ from inire.router._astar_types import AStarContext from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context DEFAULT_BOUNDS = (0, 0, 100, 100) - -@pytest.fixture -def basic_evaluator() -> CostEvaluator: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=DEFAULT_BOUNDS) - danger_map.precompute([]) - return CostEvaluator(engine, danger_map) - +_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"} +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) def _request_nets( netlist: dict[str, tuple[Port, Port]], @@ -32,6 +37,37 @@ def _request_nets( ) +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...] = (), + **request_overrides: object, +) -> AStarContext: + problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS} + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets, **problem_overrides), + _build_options(**option_overrides), + ) + + def _build_pathfinder( evaluator: CostEvaluator, *, @@ -42,7 +78,7 @@ def _build_pathfinder( **request_overrides: object, ) -> PathFinder: return PathFinder( - build_context( + _build_context( evaluator, bounds=bounds, nets=_request_nets(netlist, net_widths), @@ -64,167 +100,6 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[ path.append(comp) curr = comp.end_port return path - - -def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]: - return [ - (component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple()) - for component in path - ] - - -def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: - 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} - pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths) - - results = pf.route_all() - - 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: - # 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} - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths=net_widths, - max_iterations=1, - base_penalty=1.0, - warm_start=None, - ) - - results = pf.route_all() - - # 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_route_all_respects_requested_net_order_in_callback( - basic_evaluator: CostEvaluator, -) -> None: - callback_orders: list[list[str]] = [] - - 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)), - } - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths={net_id: 2.0 for net_id in netlist}, - max_iterations=1, - warm_start=None, - sort_nets="longest", - enabled=False, - ) - pf.route_all( - iteration_callback=lambda iteration, results: callback_orders.append(list(results)), - ) - - assert callback_orders == [["long", "mid", "short"]] - - -def test_route_all_invokes_iteration_callback_with_results( - basic_evaluator: CostEvaluator, -) -> None: - callback_results: list[dict[str, object]] = [] - netlist = { - "net1": (Port(0, 0, 0), Port(10, 0, 0)), - "net2": (Port(0, 10, 0), Port(10, 10, 0)), - } - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths={"net1": 2.0, "net2": 2.0}, - ) - - results = pf.route_all( - iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)), - ) - - assert len(callback_results) == 1 - assert set(callback_results[0]) == {"net1", "net2"} - assert callback_results[0]["net1"].is_valid - assert callback_results[0]["net2"].is_valid - assert results["net1"].reached_target - assert results["net2"].reached_target - - -def test_route_all_uses_complete_initial_paths_without_rerouting( - basic_evaluator: CostEvaluator, -) -> None: - start = Port(0, 0, 0) - target = Port(20, 20, 0) - initial_path = _build_manual_path( - start, - 2.0, - basic_evaluator.collision_engine.clearance, - [("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")], - ) - pf = _build_pathfinder( - basic_evaluator, - netlist={"net": (start, target)}, - net_widths={"net": 2.0}, - bend_radii=[5.0], - max_iterations=1, - warm_start=None, - initial_paths={"net": tuple(initial_path)}, - enabled=False, - ) - - result = pf.route_all()["net"] - - assert result.is_valid - assert result.reached_target - assert _path_signature(result.path) == _path_signature(initial_path) - - -def test_route_all_retries_partial_initial_paths_across_iterations( - basic_evaluator: CostEvaluator, -) -> None: - start = Port(0, 0, 0) - target = Port(10, 0, 0) - partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)] - pf = _build_pathfinder( - basic_evaluator, - netlist={"net": (start, target)}, - net_widths={"net": 2.0}, - max_iterations=2, - warm_start=None, - capture_expanded=True, - initial_paths={"net": tuple(partial_path)}, - enabled=False, - ) - iterations: list[int] = [] - - result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"] - - assert iterations == [0, 1] - assert result.is_valid - assert result.reached_target - assert result.outcome == "completed" - assert _path_signature(result.path) != _path_signature(partial_path) - assert pf.accumulated_expanded_nodes - - def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None: netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))} widths = {"net": 2.0} @@ -234,14 +109,14 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No danger_map = DangerMap(bounds=(-20, -20, 60, 60)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = build_context( + context = _build_context( evaluator, bounds=(-20, -20, 60, 60), nets=_request_nets(netlist, widths), bend_radii=[10.0], max_straight_length=50.0, node_limit=50, - warm_start=None, + warm_start_enabled=False, max_iterations=1, enabled=False, ) @@ -264,109 +139,6 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [ (comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path ] - - -def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: - bounds = (0, -50, 100, 50) - - def build_pathfinder( - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - *, - refinement_enabled: bool, - ) -> tuple[RoutingWorld, PathFinder]: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - return engine, _build_pathfinder( - evaluator, - netlist=netlist, - net_widths=net_widths, - bounds=bounds, - bend_radii=[10.0], - enabled=refinement_enabled, - ) - - net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} - width_a = {"netA": 2.0} - net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} - width_b = {"netB": 2.0} - - base_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False) - base_results = base_pf.route_all() - for polygon in base_results["netA"].as_locked_route().geometry: - base_engine.add_static_obstacle(polygon) - base_result = _build_pathfinder( - base_pf.cost_evaluator, - netlist=net_b, - net_widths=width_b, - bounds=bounds, - bend_radii=[10.0], - enabled=False, - ).route_all()["netB"] - - refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True) - refined_results = refined_pf.route_all() - for polygon in refined_results["netA"].as_locked_route().geometry: - refined_engine.add_static_obstacle(polygon) - refined_result = _build_pathfinder( - refined_pf.cost_evaluator, - netlist=net_b, - net_widths=width_b, - bounds=bounds, - bend_radii=[10.0], - enabled=True, - ).route_all()["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(*, refinement_enabled: bool) -> PathFinder: - engine = RoutingWorld(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) - return _build_pathfinder( - evaluator, - netlist=netlist, - net_widths=net_widths, - bounds=bounds, - bend_radii=[10.0], - sbend_radii=[10.0], - base_penalty=1000.0, - enabled=refinement_enabled, - ) - - base_results = build_pathfinder(refinement_enabled=False).route_all() - refined_results = build_pathfinder(refinement_enabled=True).route_all() - - 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 = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(-20, -20, 120, 120)) @@ -403,13 +175,13 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None: ) target = path[-1].end_port - refined = pf._refine_path("net", start, target, width, path) + refined = pf.refiner.refine_path("net", start, 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) + assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None: @@ -450,10 +222,10 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> ) target = path[-1].end_port - refined = pf._refine_path("net", start, target, width, path) + refined = pf.refiner.refine_path("net", start, 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) + assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 3a735b1..56ce7a4 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,9 +1,33 @@ +from inire import RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90 from inire.geometry.primitives import Port +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_pathfinder + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions(search=SearchOptions() if search is None else search), + ), + ) def test_arc_resolution_sagitta() -> None: @@ -31,17 +55,17 @@ def test_locked_routes() -> None: # 1. Route Net A netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} - results_a = build_pathfinder( + results_a = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), netlist=netlist_a, net_widths={"netA": 2.0}, - bend_radii=[5.0, 10.0], + search=SearchOptions(bend_radii=(5.0, 10.0)), ).route_all() assert results_a["netA"].is_valid # 2. Treat Net A as locked geometry in the next run. - for polygon in results_a["netA"].as_locked_route().geometry: + for polygon in results_a["netA"].locked_geometry: engine.add_static_obstacle(polygon) # 3. Route Net B through the same space. It should detour or fail. @@ -49,12 +73,12 @@ def test_locked_routes() -> None: netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} # Route Net B - results_b = build_pathfinder( + results_b = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), netlist=netlist_b, net_widths={"netB": 2.0}, - bend_radii=[5.0, 10.0], + search=SearchOptions(bend_radii=(5.0, 10.0)), ).route_all() # Net B should be is_valid (it detoured) or at least not have collisions diff --git a/inire/tests/test_route_behavior.py b/inire/tests/test_route_behavior.py new file mode 100644 index 0000000..8664a12 --- /dev/null +++ b/inire/tests/test_route_behavior.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from shapely.geometry import Polygon + +from inire import ( + Bend90Seed, + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + PathSeed, + Port, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, + StraightSeed, + route, +) + +DEFAULT_BOUNDS = (0, 0, 100, 100) + +_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"} +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) + + +def _request_nets( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _route_problem( + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + static_obstacles: tuple[Polygon, ...] = (), + iteration_callback=None, + **overrides: object, +): + problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS} + problem = RoutingProblem( + bounds=bounds, + nets=_request_nets(netlist, net_widths), + static_obstacles=static_obstacles, + **problem_overrides, + ) + return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback) + + +def _bend_count(result: RoutingResult) -> int: + return sum(1 for component in result.path if component.move_type == "bend90") + + +def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed: + segments = [] + for kind, value in steps: + if kind == "B": + segments.append(Bend90Seed(radius=5.0, direction=value)) + else: + segments.append(StraightSeed(length=value)) + return PathSeed(tuple(segments)) + + +def test_route_parallel_nets_are_valid() -> None: + run = _route_problem( + 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}, + ) + + assert len(run.results_by_net) == 2 + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions == 0 + assert run.results_by_net["net2"].collisions == 0 + + +def test_route_reports_crossing_nets_without_congestion_resolution() -> None: + run = _route_problem( + 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}, + max_iterations=1, + base_penalty=1.0, + warm_start_enabled=False, + ) + + assert not run.results_by_net["net1"].is_valid + assert not run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions > 0 + assert run.results_by_net["net2"].collisions > 0 + + +def test_route_callback_respects_requested_net_order() -> None: + callback_orders: list[list[str]] = [] + + _route_problem( + 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)), + }, + net_widths={"short": 2.0, "long": 2.0, "mid": 2.0}, + max_iterations=1, + warm_start_enabled=False, + net_order="longest", + enabled=False, + iteration_callback=lambda iteration, results: callback_orders.append(list(results)), + ) + + assert callback_orders == [["long", "mid", "short"]] + + +def test_route_callback_receives_iteration_results() -> None: + callback_results: list[dict[str, RoutingResult]] = [] + + run = _route_problem( + netlist={ + "net1": (Port(0, 0, 0), Port(10, 0, 0)), + "net2": (Port(0, 10, 0), Port(10, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + iteration_callback=lambda iteration, results: callback_results.append(dict(results)), + ) + + assert len(callback_results) == 1 + assert set(callback_results[0]) == {"net1", "net2"} + assert callback_results[0]["net1"].is_valid + assert callback_results[0]["net2"].is_valid + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + + +def test_route_uses_complete_initial_paths_without_rerouting() -> None: + initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")]) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))}, + net_widths={"net": 2.0}, + bend_radii=[5.0], + max_iterations=1, + warm_start_enabled=False, + initial_paths={"net": initial_seed}, + enabled=False, + ) + + result = run.results_by_net["net"] + assert result.is_valid + assert result.reached_target + assert result.as_seed() == initial_seed + + +def test_route_retries_partial_initial_paths_across_iterations() -> None: + iterations: list[int] = [] + partial_seed = PathSeed((StraightSeed(length=5.0),)) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))}, + net_widths={"net": 2.0}, + max_iterations=2, + warm_start_enabled=False, + capture_expanded=True, + initial_paths={"net": partial_seed}, + enabled=False, + iteration_callback=lambda iteration, results: iterations.append(iteration), + ) + + result = run.results_by_net["net"] + assert iterations == [0, 1] + assert result.is_valid + assert result.reached_target + assert result.outcome == "completed" + assert result.as_seed() != partial_seed + assert run.expanded_nodes + + +def test_route_negotiated_congestion_resolution() -> None: + obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) + obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) + run = _route_problem( + bounds=(0, -40, 100, 40), + 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}, + static_obstacles=(obs_top, obs_bottom), + bend_radii=(5.0, 10.0), + max_iterations=10, + base_penalty=1000.0, + ) + + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + + +def test_route_refinement_reduces_locked_detour_bends() -> None: + route_a = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))}, + net_widths={"netA": 2.0}, + bend_radii=[10.0], + enabled=False, + ) + locked_geometry = route_a.results_by_net["netA"].locked_geometry + + base_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=False, + ) + refined_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=True, + ) + + base_result = base_run.results_by_net["netB"] + refined_result = refined_run.results_by_net["netB"] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) + + +def test_route_refinement_simplifies_triple_crossing_detours() -> None: + base_run = _route_problem( + 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={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=False, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + refined_run = _route_problem( + 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={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=True, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + + for net_id in ("vertical_up", "vertical_down"): + base_result = base_run.results_by_net[net_id] + refined_result = refined_run.results_by_net[net_id] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index fbf6ba5..e0acd71 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,10 +1,11 @@ import unittest from inire.geometry.primitives import Port +from inire.model import RoutingOptions, RoutingProblem +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.geometry.collision import RoutingWorld -from inire.tests.support import build_context class TestIntegerPorts(unittest.TestCase): @@ -13,12 +14,28 @@ class TestIntegerPorts(unittest.TestCase): self.cost = CostEvaluator(self.ce) self.bounds = (0, 0, 100, 100) + def _build_context(self) -> AStarContext: + return AStarContext( + self.cost, + RoutingProblem(bounds=self.bounds), + RoutingOptions(), + ) + + def _route(self, context: AStarContext, start: Port, target: Port): + return route_astar( + start, + target, + net_width=1.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + def test_route_reaches_integer_target(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0, 0, 0) target = Port(12, 0, 0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) last_port = path[-1].end_port @@ -27,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 12) @@ -39,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 8) diff --git a/inire/utils/validation.py b/inire/utils/validation.py deleted file mode 100644 index 9c22dc4..0000000 --- a/inire/utils/validation.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -import numpy - -from inire.geometry.collision import RoutingWorld - -if TYPE_CHECKING: - from shapely.geometry import Polygon - from inire.geometry.primitives import Port - from inire.router.results import RoutingResult - - -def validate_routing_result( - result: RoutingResult, - static_obstacles: list[Polygon], - clearance: float, - expected_start: Port | None = None, - expected_end: Port | None = None, - ) -> dict[str, Any]: - """ - Perform a high-precision validation of a routed path. - - Args: - result: The routing result to validate. - static_obstacles: List of static obstacle geometries. - clearance: Required minimum distance. - expected_start: Optional expected start port. - expected_end: Optional expected end port. - - Returns: - A dictionary with validation results. - """ - if not result.path: - return {"is_valid": False, "reason": "No path found"} - - connectivity_errors = [] - - if expected_start: - first_port = result.path[0].start_port - dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2) - if dist_to_start > 0.005: - connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") - if abs(first_port.r - expected_start.r) > 0.1: - connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") - - if expected_end: - last_port = result.path[-1].end_port - dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2) - if dist_to_end > 0.005: - connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") - if abs(last_port.r - expected_end.r) > 0.1: - connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") - - engine = RoutingWorld(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - report = engine.verify_path_report("validation", result.path) - - is_valid = report.is_valid and len(connectivity_errors) == 0 - - reasons = [] - if report.static_collision_count: - reasons.append(f"Found {report.static_collision_count} obstacle collisions.") - if report.dynamic_collision_count: - reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") - if report.self_collision_count: - reasons.append(f"Found {report.self_collision_count} self-intersections.") - if connectivity_errors: - reasons.extend(connectivity_errors) - - return { - "is_valid": is_valid, - "reason": " ".join(reasons), - "obstacle_collisions": report.static_collision_count, - "dynamic_collisions": report.dynamic_collision_count, - "self_intersections": report.self_collision_count, - "total_length": report.total_length, - "connectivity_ok": len(connectivity_errors) == 0, - } diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 044208a..8268c47 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap - from inire.router.results import RoutingResult + from inire.results import RoutingResult def plot_routing_results( From e11132b51d7f4dc871d448531eea0965abf197b0 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 21:22:20 -0700 Subject: [PATCH 6/6] fix examples --- DOCS.md | 1 + README.md | 14 ++ examples/03_locked_paths.py | 2 +- examples/06_bend_collision_models.png | Bin 85921 -> 87436 bytes examples/06_bend_collision_models.py | 12 +- examples/07_large_scale_routing.py | 37 +++-- examples/08_custom_bend_geometry.py | 77 ++++++---- examples/09_unroutable_best_effort.py | 2 +- examples/README.md | 4 +- inire/geometry/components.py | 86 ++++++----- inire/model.py | 1 + inire/router/_astar_admission.py | 2 + inire/router/_astar_types.py | 2 + inire/router/_seed_materialization.py | 3 + inire/router/cost.py | 8 ++ inire/tests/example_scenarios.py | 30 ++-- inire/tests/test_components.py | 20 ++- inire/tests/test_cost.py | 12 ++ inire/tests/test_example_performance.py | 10 +- inire/tests/test_example_regressions.py | 184 ++++++++++++++++++++++++ 20 files changed, 406 insertions(+), 101 deletions(-) create mode 100644 inire/tests/test_example_regressions.py diff --git a/DOCS.md b/DOCS.md index 338ded2..d458bda 100644 --- a/DOCS.md +++ b/DOCS.md @@ -81,6 +81,7 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are | `sbend_radii` | `(10.0,)` | Available radii for S-bends. | | `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. | | `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. | +| `bend_clip_margin` | `None` | Optional legacy shrink margin for `"clipped_bbox"`. Leave `None` for the default 8-point proxy. | | `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | ## 3. Objective Weights diff --git a/README.md b/README.md index b93ce69..a66f699 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ Check the `examples/` directory for ready-to-run scripts. To run an example: python3 examples/01_simple_route.py ``` +## Testing + +Run the default correctness suite with: + +```bash +python3 -m pytest +``` + +Runtime regression checks for the example scenarios are opt-in and require: + +```bash +INIRE_RUN_PERFORMANCE=1 python3 -m pytest -q inire/tests/test_example_performance.py +``` + ## Documentation Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 0f60fbb..ed309a8 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -3,7 +3,7 @@ from inire.utils.visualization import plot_routing_results def main() -> None: - print("Running Example 03: Locked Routes...") + print("Running Example 03: Locked Paths...") bounds = (0, -50, 100, 50) options = RoutingOptions( diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index 1a4401a5ee2bd9ed68101d62ea423773d0feb332..fa2c49f5e39b8140b68fe766c0e5a144a19c8fc1 100644 GIT binary patch delta 52098 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