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 1a4401a..fa2c49f 100644 Binary files a/examples/06_bend_collision_models.png and b/examples/06_bend_collision_models.png differ 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