From e2c91076f778bbdff44b94c5b8e19becec1f0223 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 23:40:29 -0700 Subject: [PATCH 1/3] example fixes and improvements --- DOCS.md | 5 +- examples/02_congestion_resolution.py | 4 +- examples/03_locked_paths.py | 41 +++++----- examples/06_bend_collision_models.png | Bin 87436 -> 80222 bytes examples/06_bend_collision_models.py | 34 ++++---- examples/08_custom_bend_geometry.png | Bin 62621 -> 61436 bytes examples/08_custom_bend_geometry.py | 97 +++++++++++------------ examples/README.md | 7 +- inire/geometry/components.py | 71 ++++++++++++++--- inire/model.py | 40 +++++++++- inire/router/_astar_admission.py | 6 ++ inire/router/_astar_types.py | 12 ++- inire/router/_router.py | 4 +- inire/router/_seed_materialization.py | 6 +- inire/tests/example_scenarios.py | 27 ++++--- inire/tests/test_components.py | 22 +++++- inire/tests/test_example_performance.py | 4 +- inire/tests/test_example_regressions.py | 100 ++++++++++++++++++++---- 18 files changed, 336 insertions(+), 144 deletions(-) diff --git a/DOCS.md b/DOCS.md index d458bda..2cfab9d 100644 --- a/DOCS.md +++ b/DOCS.md @@ -80,7 +80,9 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are | `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. | +| `bend_collision_type` | `"arc"` | Bend collision/proxy model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or, for backward compatibility, a custom polygon. A legacy custom polygon here is treated as both the physical bend and its proxy unless overridden by the split fields below. | +| `bend_proxy_geometry` | `None` | Optional explicit bend proxy geometry. Use this when you want a custom search/collision envelope that differs from the routed bend shape. Supplying only a custom polygon proxy warns and keeps the physical bend as the standard arc. | +| `bend_physical_geometry` | `None` | Optional explicit bend physical geometry. Use `"arc"` or a custom polygon. If you set a custom physical polygon and do not set a proxy, the proxy defaults to the same 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. | @@ -161,6 +163,7 @@ Lower-level search and collision modules are semi-private implementation details - Increase `objective.bend_penalty` to discourage ladders of small bends. - Increase available `search.bend_radii` when larger turns are physically acceptable. +- Use `search.bend_physical_geometry` and `search.bend_proxy_geometry` together when you need a real custom bend shape plus a different conservative proxy. ### Visibility guidance diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index 9d003bc..f6b2751 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -22,8 +22,8 @@ def main() -> None: greedy_h_weight=1.5, ), objective=ObjectiveWeights( - bend_penalty=250.0, - sbend_penalty=500.0, + bend_penalty=50.0, + sbend_penalty=150.0, ), congestion=CongestionOptions(base_penalty=1000.0), ) diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index ed309a8..b00741f 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -1,4 +1,7 @@ -from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder +from inire.router._stack import build_routing_stack from inire.utils.visualization import plot_routing_results @@ -6,31 +9,31 @@ def main() -> None: print("Running Example 03: Locked Paths...") bounds = (0, -50, 100, 50) - options = RoutingOptions( - search=SearchOptions(bend_radii=(10.0,)), - objective=ObjectiveWeights( - bend_penalty=250.0, - sbend_penalty=500.0, - ), - ) print("Routing initial net...") - results_a = route( - RoutingProblem( + stack = build_routing_stack( + problem=RoutingProblem( bounds=bounds, nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), ), - options=options, - ).results_by_net + options=RoutingOptions(search=SearchOptions(bend_radii=(10.0,))), + ) + engine = stack.world + evaluator = stack.evaluator + results_a = stack.finder.route_all() print("Routing detour net around locked path...") - results_b = route( - RoutingProblem( - bounds=bounds, - nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - static_obstacles=results_a["netA"].locked_geometry, + for polygon in results_a["netA"].locked_geometry: + engine.add_static_obstacle(polygon) + results_b = PathFinder( + AStarContext( + evaluator, + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + ), + RoutingOptions(search=SearchOptions(bend_radii=(10.0,))), ), - options=options, - ).results_by_net + ).route_all() results = {**results_a, **results_b} fig, ax = plot_routing_results(results, [], bounds) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index fa2c49f5e39b8140b68fe766c0e5a144a19c8fc1..178d952810386ecfcc3e1304ef1c4f8927e1de74 100644 GIT binary patch literal 80222 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%0AS-C;Ml-9a?VrD{ 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 dict[str, RoutingResult]: problem = RoutingProblem( @@ -23,6 +26,8 @@ def _route_scenario( search=SearchOptions( bend_radii=(10.0,), bend_collision_type=bend_collision_type, + bend_proxy_geometry=bend_proxy_geometry, + bend_physical_geometry=bend_physical_geometry, bend_clip_margin=bend_clip_margin, ), objective=ObjectiveWeights( @@ -40,29 +45,30 @@ def main() -> None: bounds = (-20, -20, 170, 170) 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)]) + obs_custom = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) + custom_bend = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) - obstacles = [obs_arc, obs_bbox, obs_clipped] + obstacles = [obs_arc, obs_bbox, obs_custom] netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} - netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} + netlist_custom = {"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))} print("Routing Scenario 1 (Arc)...") - res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0}) + res_arc = _route_scenario(bounds, obstacles, netlist_arc, {"arc_model": 2.0}, bend_collision_type="arc") 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( + res_bbox = _route_scenario(bounds, obstacles, netlist_bbox, {"bbox_model": 2.0}, bend_collision_type="bbox") + print("Routing Scenario 3 (Custom Manhattan Geometry With Matching Proxy)...") + res_custom = _route_scenario( bounds, obstacles, - "clipped_bbox", - netlist_clipped, - {"clipped_model": 2.0}, - bend_clip_margin=1.0, + netlist_custom, + {"custom_geometry": 2.0}, + bend_physical_geometry=custom_bend, + bend_proxy_geometry=custom_bend, ) - all_results = {**res_arc, **res_bbox, **res_clipped} - all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} + all_results = {**res_arc, **res_bbox, **res_custom} + all_netlists = {**netlist_arc, **netlist_bbox, **netlist_custom} fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) fig.savefig("examples/06_bend_collision_models.png") diff --git a/examples/08_custom_bend_geometry.png b/examples/08_custom_bend_geometry.png index 48c2e5c51088fda4e5ca73ba1720ef33744362d1..708834366569c40a4a1b85e128f1c940725e75e9 100644 GIT binary patch literal 61436 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 diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index 25e715b..d24e087 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,65 +1,58 @@ -from shapely.geometry import Polygon +from shapely.geometry import Polygon, box -from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions -from inire.geometry.collision import RoutingWorld +from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions, route +from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry 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_session( + bounds: tuple[float, float, float, float], + net_id: str, + start: Port, + target: Port, + *, + bend_collision_type: BendCollisionModel = "arc", + bend_proxy_geometry: BendCollisionModel | None = None, + bend_physical_geometry: BendPhysicalGeometry | None = None, +) -> dict[str, object]: + 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, + bend_proxy_geometry=bend_proxy_geometry, + bend_physical_geometry=bend_physical_geometry, + sbend_radii=(), + ), + congestion=CongestionOptions(max_iterations=1, 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) + custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + custom_proxy = box(0, -11, 11, 0) - print("Routing with standard arc...") - 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([(-10, -10), (10, -10), (10, 10), (-10, 10)]) - - 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() + print("Routing standard arc in its own session...") + results_std = _run_session(bounds, "standard_arc", start, target) + print("Routing custom geometry with a separate custom proxy in its own session...") + results_custom = _run_session( + bounds, + "custom_geometry_and_proxy", + start, + target, + bend_physical_geometry=custom_physical, + bend_proxy_geometry=custom_proxy, + ) all_results = {**results_std, **results_custom} fig, _ax = plot_routing_results( @@ -67,8 +60,8 @@ def main() -> None: [], bounds, netlist={ - "custom_bend": (start, target), - "custom_model": (start, target), + "standard_arc": (start, target), + "custom_geometry_and_proxy": (start, target), }, ) fig.savefig("examples/08_custom_bend_geometry.png") diff --git a/examples/README.md b/examples/README.md index cfea579..a079c07 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,13 +14,14 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting ![Fan-Out Routing](07_large_scale_routing.png) -## 2. Custom Bend Geometry Models +## 2. Bend Geometry Models `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). +* **Custom Manhattan Geometry**: A custom 90-degree bend polygon with the same width as the normal waveguide. -Example 08 also demonstrates a custom polygonal bend geometry. It uses a centered `20x20` box as a custom bend collision model. +Example 06 uses the Manhattan polygon as both the true routed bend geometry and the collision proxy. +Example 08 compares the standard arc against a run that uses a custom physical bend plus a separate custom proxy polygon, with each net routed in its own session. ![Custom Bend Geometry](08_custom_bend_geometry.png) diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 714ef55..48af961 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -17,6 +17,7 @@ from .primitives import Port, rotation_matrix2 MoveKind = Literal["straight", "bend90", "sbend"] BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] BendCollisionModel = BendCollisionModelName | Polygon +BendPhysicalGeometry = Literal["arc"] | Polygon def _normalize_length(value: float) -> float: @@ -281,6 +282,7 @@ class Bend90: direction: Literal["CW", "CCW"], sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", + physical_geometry_type: BendPhysicalGeometry = "arc", clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: @@ -310,16 +312,33 @@ class Bend90: mirror_y=(sign < 0), ) - physical_geometry = arc_polys - if dilation > 0: - dilated_physical_geometry = _get_arc_polygons( - (float(center_xy[0]), float(center_xy[1])), + if isinstance(physical_geometry_type, Polygon): + physical_geometry = _apply_collision_model( + arc_polys[0], + physical_geometry_type, radius, width, + (float(center_xy[0]), float(center_xy[1])), ts, - sagitta, - dilation=dilation, + rotation_deg=float(start_port.r), + mirror_y=(sign < 0), ) + uses_physical_custom_geometry = True + else: + physical_geometry = arc_polys + uses_physical_custom_geometry = False + if dilation > 0: + if uses_physical_custom_geometry: + dilated_physical_geometry = [poly.buffer(dilation) for poly in 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] ) @@ -349,6 +368,7 @@ class SBend: width: float, sagitta: float = 0.01, collision_type: BendCollisionModel = "arc", + physical_geometry_type: BendPhysicalGeometry = "arc", clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: @@ -402,12 +422,41 @@ class SBend: )[0], ] - physical_geometry = actual_geometry - if dilation > 0: - 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], + if isinstance(physical_geometry_type, Polygon): + physical_geometry = [ + _apply_collision_model( + arc1, + physical_geometry_type, + radius, + width, + (float(c1_xy[0]), float(c1_xy[1])), + ts1, + rotation_deg=float(start_port.r), + mirror_y=(sign < 0), + )[0], + _apply_collision_model( + arc2, + physical_geometry_type, + radius, + width, + (float(c2_xy[0]), float(c2_xy[1])), + ts2, + rotation_deg=float(start_port.r), + mirror_y=(sign > 0), + )[0], ] + uses_physical_custom_geometry = True + else: + physical_geometry = actual_geometry + uses_physical_custom_geometry = False + if dilation > 0: + if uses_physical_custom_geometry: + dilated_physical_geometry = [poly.buffer(dilation) for poly in 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] ) diff --git a/inire/model.py b/inire/model.py index 5100899..f0607ae 100644 --- a/inire/model.py +++ b/inire/model.py @@ -1,15 +1,15 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal -from inire.geometry.components import BendCollisionModel +from shapely.geometry import Polygon + +from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry from inire.seeds import PathSeed if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.components import BendCollisionModel from inire.geometry.primitives import Port @@ -43,6 +43,8 @@ class SearchOptions: bend_radii: tuple[float, ...] = (50.0, 100.0) sbend_radii: tuple[float, ...] = (10.0,) bend_collision_type: BendCollisionModel = "arc" + bend_proxy_geometry: BendCollisionModel | None = None + bend_physical_geometry: BendPhysicalGeometry | None = None bend_clip_margin: float | None = None visibility_guidance: VisibilityGuidance = "tangent_corner" @@ -51,6 +53,36 @@ class SearchOptions: 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.bend_physical_geometry is None and isinstance(self.bend_proxy_geometry, Polygon): + warnings.warn( + "Custom bend proxy provided without bend_physical_geometry; routed bends will keep standard arc geometry.", + stacklevel=2, + ) + + +def resolve_bend_geometry( + search: SearchOptions, + *, + bend_collision_override: BendCollisionModel | None = None, +) -> tuple[BendCollisionModel, BendPhysicalGeometry]: + bend_physical_geometry = search.bend_physical_geometry + if bend_physical_geometry is None and isinstance(search.bend_collision_type, Polygon) and search.bend_proxy_geometry is None: + bend_physical_geometry = search.bend_collision_type + if bend_physical_geometry is None: + bend_physical_geometry = "arc" + + if bend_collision_override is not None: + bend_proxy_geometry = bend_collision_override + elif search.bend_proxy_geometry is not None: + bend_proxy_geometry = search.bend_proxy_geometry + elif isinstance(search.bend_collision_type, Polygon): + bend_proxy_geometry = search.bend_collision_type + elif bend_physical_geometry != "arc" and search.bend_collision_type == "arc": + bend_proxy_geometry = bend_physical_geometry + else: + bend_proxy_geometry = search.bend_collision_type + + return bend_proxy_geometry, bend_physical_geometry @dataclass(frozen=True, slots=True) diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index ff075cd..8152afb 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -33,6 +33,8 @@ def process_move( cp = parent.port coll_type = config.bend_collision_type coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type + physical_type = config.bend_physical_geometry + physical_key = id(physical_type) if isinstance(physical_type, Polygon) else physical_type self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 abs_key = ( @@ -41,6 +43,7 @@ def process_move( params, net_width, coll_key, + physical_key, self_dilation, ) if abs_key in context.move_cache_abs: @@ -54,6 +57,7 @@ def process_move( params, net_width, coll_key, + physical_key, self_dilation, ) if rel_key in context.move_cache_rel: @@ -69,6 +73,7 @@ def process_move( net_width, params[1], collision_type=coll_type, + physical_geometry_type=config.bend_physical_geometry, clip_margin=config.bend_clip_margin, dilation=self_dilation, ) @@ -79,6 +84,7 @@ def process_move( params[1], net_width, collision_type=coll_type, + physical_geometry_type=config.bend_physical_geometry, clip_margin=config.bend_clip_margin, dilation=self_dilation, ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index 6bf2b37..2796c73 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -3,8 +3,8 @@ 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.geometry.components import BendCollisionModel, BendPhysicalGeometry +from inire.model import RoutingOptions, RoutingProblem, resolve_bend_geometry from inire.results import RouteMetrics from inire.router.visibility import VisibilityManager @@ -16,6 +16,7 @@ if TYPE_CHECKING: @dataclass(frozen=True, slots=True) class SearchRunConfig: bend_collision_type: BendCollisionModel + bend_physical_geometry: BendPhysicalGeometry bend_clip_margin: float | None node_limit: int return_partial: bool = False @@ -38,8 +39,13 @@ class SearchRunConfig: self_collision_check: bool = False, ) -> SearchRunConfig: search = options.search + bend_collision_type, bend_physical_geometry = resolve_bend_geometry( + search, + bend_collision_override=bend_collision_type, + ) return cls( - bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type, + bend_collision_type=bend_collision_type, + bend_physical_geometry=bend_physical_geometry, bend_clip_margin=search.bend_clip_margin, node_limit=search.node_limit if node_limit is None else node_limit, return_partial=return_partial, diff --git a/inire/router/_router.py b/inire/router/_router.py index 5aaf00c..81bc6ef 100644 --- a/inire/router/_router.py +++ b/inire/router/_router.py @@ -5,7 +5,7 @@ import time from dataclasses import dataclass from typing import TYPE_CHECKING -from inire.model import NetOrder, NetSpec +from inire.model import NetOrder, NetSpec, resolve_bend_geometry from inire.results import RoutingOutcome, RoutingReport, RoutingResult from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig from inire.router._search import route_astar @@ -173,7 +173,7 @@ class PathFinder: 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 + coll_model, _ = resolve_bend_geometry(search) skip_congestion = False if congestion.use_tiered_strategy and iteration == 0: skip_congestion = True diff --git a/inire/router/_seed_materialization.py b/inire/router/_seed_materialization.py index f370db6..25a76be 100644 --- a/inire/router/_seed_materialization.py +++ b/inire/router/_seed_materialization.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from inire.model import SearchOptions +from inire.model import SearchOptions, resolve_bend_geometry from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed if TYPE_CHECKING: @@ -23,7 +23,7 @@ def materialize_path_seed( path: list[ComponentResult] = [] current = start dilation = clearance / 2.0 - bend_collision_type = search.bend_collision_type + bend_collision_type, bend_physical_geometry = resolve_bend_geometry(search) bend_clip_margin = search.bend_clip_margin for segment in seed.segments: @@ -36,6 +36,7 @@ def materialize_path_seed( net_width, segment.direction, collision_type=bend_collision_type, + physical_geometry_type=bend_physical_geometry, clip_margin=bend_clip_margin, dilation=dilation, ) @@ -46,6 +47,7 @@ def materialize_path_seed( segment.radius, net_width, collision_type=bend_collision_type, + physical_geometry_type=bend_physical_geometry, clip_margin=bend_clip_margin, dilation=dilation, ) diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index 06619c4..2dd78ed 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -253,6 +253,7 @@ def run_example_06() -> ScenarioOutcome: box(40, 60, 60, 80), box(40, 10, 60, 30), ] + custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) scenarios = [ ( _build_evaluator(bounds, obstacles=obstacles), @@ -268,12 +269,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}, + {"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))}, + {"custom_geometry": 2.0}, { "bend_radii": [10.0], - "bend_collision_type": "clipped_bbox", - "bend_clip_margin": 1.0, + "bend_physical_geometry": custom_physical, + "bend_proxy_geometry": custom_physical, "use_tiered_strategy": False, }, ), @@ -353,27 +354,29 @@ def run_example_07() -> ScenarioOutcome: 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([(-10, -10), (10, -10), (10, 10), (-10, 10)]) - evaluator = _build_evaluator(bounds) + netlist = {"standard_arc": (Port(20, 20, 0), Port(100, 100, 90))} + widths = {"standard_arc": 2.0} + custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + custom_proxy = box(0, -11, 11, 0) t0 = perf_counter() results_std = _build_pathfinder( - evaluator, + _build_evaluator(bounds), bounds=bounds, nets=_net_specs(netlist, widths), bend_radii=[10.0], sbend_radii=[], max_iterations=1, + use_tiered_strategy=False, metrics=AStarMetrics(), ).route_all() results_custom = _build_pathfinder( - evaluator, + _build_evaluator(bounds), bounds=bounds, - nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}), bend_radii=[10.0], - bend_collision_type=custom_model, + bend_physical_geometry=custom_physical, + bend_proxy_geometry=custom_proxy, sbend_radii=[], max_iterations=1, use_tiered_strategy=False, diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 2708a56..3c14326 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -137,13 +137,29 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None: assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6 -def test_custom_bend_collision_polygon_only_overrides_search_geometry() -> None: +def test_custom_bend_collision_polygon_is_true_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) + result = Bend90.generate( + Port(0, 0, 0), + 10.0, + 2.0, + direction="CCW", + collision_type=custom_poly, + physical_geometry_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 + + +def test_custom_bend_collision_polygon_can_differ_from_physical_geometry() -> None: + custom_proxy = Polygon([(0, -11), (11, -11), (11, 0), (0, 0)]) + result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_proxy, dilation=1.0) + + assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area > 1e-6 assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area > 1e-6 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 7f8517b..80d56aa 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -22,7 +22,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, } @@ -34,7 +34,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_example_regressions.py b/inire/tests/test_example_regressions.py index 1a56cd2..e78fcab 100644 --- a/inire/tests/test_example_regressions.py +++ b/inire/tests/test_example_regressions.py @@ -25,7 +25,7 @@ EXPECTED_OUTCOMES = { "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_08_custom_bend_geometry": (2, 2, 2), "example_09_unroutable_best_effort": (1, 0, 0), } @@ -150,35 +150,107 @@ def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None: assert all(result.reached_target for result in results.values()) -def test_example_08_custom_box_restores_legacy_collision_outcome() -> None: +def test_example_06_custom_geometry_can_be_true_physical_geometry() -> 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)]), + ) + custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + result = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("custom_geometry", Port(10, 20, 0), Port(90, 40, 90), width=2.0),), + static_obstacles=obstacles, + ), + options=RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_physical_geometry=custom_poly, + bend_proxy_geometry=custom_poly, + ), + objective=ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0), + congestion=CongestionOptions(use_tiered_strategy=False), + ), + ).results_by_net["custom_geometry"] + + assert result.is_valid + bends = [component for component in result.path if component.move_type == "bend90"] + assert bends + assert all( + component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area < 1e-6 + for component in bends + ) + + +def test_custom_proxy_without_physical_geometry_warns_and_keeps_arc_geometry() -> None: + custom_proxy = Polygon([(0, -11), (11, -11), (11, 0), (0, 0)]) + + with pytest.warns(UserWarning, match="Custom bend proxy provided without bend_physical_geometry"): + search = SearchOptions( + bend_radii=(10.0,), + sbend_radii=(), + bend_proxy_geometry=custom_proxy, + ) + + problem = RoutingProblem( + bounds=(0, 0, 150, 150), + nets=(NetSpec("proxy_only", Port(20, 20, 0), Port(100, 100, 90), width=2.0),), + ) + result = route( + problem, + options=RoutingOptions( + search=search, + congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False), + ), + ).results_by_net["proxy_only"] + + bends = [component for component in result.path if component.move_type == "bend90"] + assert bends + assert all( + component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area > 1e-6 + for component in bends + ) + + +def test_example_08_custom_geometry_runs_in_separate_sessions() -> 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) + netlist = {"standard_arc": (Port(20, 20, 0), Port(100, 100, 90))} + widths = {"standard_arc": 2.0} + custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + custom_proxy = box(0, -11, 11, 0) standard = _build_pathfinder( - evaluator, + _build_evaluator(bounds), bounds=bounds, nets=_net_specs(netlist, widths), bend_radii=[10.0], sbend_radii=[], max_iterations=1, + use_tiered_strategy=False, metrics=AStarMetrics(), ).route_all() custom = _build_pathfinder( - evaluator, + _build_evaluator(bounds), bounds=bounds, - nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}), bend_radii=[10.0], - bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), + bend_physical_geometry=custom_physical, + bend_proxy_geometry=custom_proxy, 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 + assert standard["standard_arc"].is_valid + assert standard["standard_arc"].reached_target + assert custom["custom_geometry_and_proxy"].is_valid + assert custom["custom_geometry_and_proxy"].reached_target + custom_bends = [component for component in custom["custom_geometry_and_proxy"].path if component.move_type == "bend90"] + assert custom_bends + assert all( + component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area > 1e-6 + for component in custom_bends + ) From 1849075b119a29e7b72ee05a0feffd6217aedbbc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 23:54:30 -0700 Subject: [PATCH 2/3] linter fixes --- examples/09_unroutable_best_effort.py | 7 +++++- inire/geometry/collision.py | 27 ++++++++++++++++------ inire/geometry/component_overlap.py | 5 +++-- inire/geometry/components.py | 15 ++++++++----- inire/geometry/index_helpers.py | 13 ++++++----- inire/geometry/primitives.py | 8 ++++--- inire/geometry/static_obstacle_index.py | 4 ++-- inire/model.py | 3 +-- inire/results.py | 2 +- inire/router/_astar_admission.py | 1 - inire/router/_astar_moves.py | 15 ++++++++----- inire/router/_astar_types.py | 9 ++++---- inire/router/_router.py | 10 +++++---- inire/router/_search.py | 2 +- inire/router/_stack.py | 19 +++++++++++----- inire/router/cost.py | 5 +++-- inire/router/danger_map.py | 4 ++-- inire/router/refiner.py | 12 +++++----- inire/router/visibility.py | 2 +- inire/tests/example_scenarios.py | 6 ++--- inire/tests/test_clearance_precision.py | 14 +++++------- inire/tests/test_collision.py | 14 ++++++------ inire/tests/test_cost.py | 12 +++++----- inire/tests/test_example_performance.py | 5 ++++- inire/utils/visualization.py | 30 ++++++++++++------------- pyproject.toml | 12 ++++++++++ 26 files changed, 152 insertions(+), 104 deletions(-) diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 1aeb152..227f5bf 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -37,7 +37,12 @@ def main() -> None: else: print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.") - 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, _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") diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 5d5b13b..02aaede 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -105,9 +105,16 @@ class RoutingWorld: return reach < length - 0.001 def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - bounds = self._static_obstacles.bounds_array[idx] + bounds_array = self._static_obstacles.bounds_array + if bounds_array is None: + return False + bounds = 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: + 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 @@ -176,15 +183,18 @@ class RoutingWorld: return False self._ensure_static_tree() + tree = static_obstacles.tree + bounds_array = static_obstacles.bounds_array + if tree is None or bounds_array is None: + return False - hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) + hits = 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] + obstacle_bounds = bounds_array[hit_idx] poly_hits_obstacle_aabb = False for poly_bounds in move_poly_bounds: if ( @@ -319,7 +329,7 @@ class RoutingWorld: 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) + buffered = polygon.buffer(self.clearance, join_style="mitre") hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") for hit_idx in hits: obstacle = raw_geometries[hit_idx] @@ -373,6 +383,9 @@ class RoutingWorld: net_width: float | None = None, ) -> float: static_obstacles = self._static_obstacles + tree: STRtree | None + is_rect_array: numpy.ndarray | None + bounds_array: numpy.ndarray | None radians = numpy.radians(angle_deg) cos_v, sin_v = numpy.cos(radians), numpy.sin(radians) @@ -391,7 +404,7 @@ class RoutingWorld: is_rect_array = static_obstacles.is_rect_array bounds_array = static_obstacles.bounds_array - if tree is None: + if tree is None or is_rect_array is None or bounds_array is None: return max_dist candidates = tree.query(box(min_x, min_y, max_x, max_y)) diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py index 816508d..f72603f 100644 --- a/inire/geometry/component_overlap.py +++ b/inire/geometry/component_overlap.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Sequence from shapely.geometry import Polygon from inire.geometry.components import ComponentResult @@ -13,8 +14,8 @@ def components_overlap( component_b: ComponentResult, prefer_actual: bool = False, ) -> bool: - polygons_a: tuple[Polygon, ...] - polygons_b: tuple[Polygon, ...] + polygons_a: Sequence[Polygon] + polygons_b: Sequence[Polygon] if prefer_actual: polygons_a = component_a.physical_geometry polygons_b = component_b.physical_geometry diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 48af961..86b7282 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal +from typing import TYPE_CHECKING, Literal import numpy from shapely.affinity import rotate as shapely_rotate @@ -13,6 +13,9 @@ from inire.constants import TOLERANCE_ANGULAR from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed from .primitives import Port, rotation_matrix2 +if TYPE_CHECKING: + from collections.abc import Sequence + MoveKind = Literal["straight", "bend90", "sbend"] BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] @@ -27,14 +30,14 @@ def _normalize_length(value: float) -> float: @dataclass(frozen=True, slots=True) class ComponentResult: start_port: Port - collision_geometry: tuple[Polygon, ...] + collision_geometry: Sequence[Polygon] 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, ...] + physical_geometry: Sequence[Polygon] + dilated_collision_geometry: Sequence[Polygon] + dilated_physical_geometry: Sequence[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) @@ -146,7 +149,7 @@ def _clip_bbox_legacy( 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 bbox_poly.buffer(-shrink, join_style="mitre") if shrink > 0 else bbox_poly def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: diff --git a/inire/geometry/index_helpers.py b/inire/geometry/index_helpers.py index dad186b..201dafe 100644 --- a/inire/geometry/index_helpers.py +++ b/inire/geometry/index_helpers.py @@ -1,17 +1,18 @@ from __future__ import annotations import math -from collections.abc import Iterator, Mapping -from typing import TypeVar +from typing import TYPE_CHECKING import numpy -GeometryT = TypeVar("GeometryT") +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping + from shapely.geometry.base import BaseGeometry def build_index_payload( - geometries: Mapping[int, GeometryT], -) -> tuple[list[int], list[GeometryT], numpy.ndarray]: + geometries: Mapping[int, BaseGeometry], +) -> tuple[list[int], list[BaseGeometry], 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) @@ -42,7 +43,7 @@ def iter_grid_cells( yield (gx, gy) -def is_axis_aligned_rect(geometry, *, tolerance: float = 1e-4) -> bool: +def is_axis_aligned_rect(geometry: BaseGeometry, *, 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/primitives.py b/inire/geometry/primitives.py index b42b267..db61198 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,10 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Self +from typing import TYPE_CHECKING, Self import numpy -from numpy.typing import NDArray + +if TYPE_CHECKING: + from numpy.typing import NDArray def _normalize_angle(angle_deg: int | float) -> int: @@ -58,6 +60,6 @@ ROT2_180 = numpy.array(((-1, 0), (0, -1)), dtype=numpy.int32) ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32) -def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: +def rotation_matrix2(rotation_deg: int | float) -> NDArray[numpy.int32]: quadrant = (_normalize_angle(rotation_deg) // 90) % 4 return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] diff --git a/inire/geometry/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py index 3f3ab38..32cffba 100644 --- a/inire/geometry/static_obstacle_index.py +++ b/inire/geometry/static_obstacle_index.py @@ -59,7 +59,7 @@ class StaticObstacleIndex: if dilated_geometry is not None: dilated = dilated_geometry else: - dilated = polygon.buffer(self.engine.clearance / 2.0, join_style=2) + dilated = polygon.buffer(self.engine.clearance / 2.0, join_style="mitre") self.geometries[obj_id] = polygon self.dilated[obj_id] = dilated @@ -109,7 +109,7 @@ class StaticObstacleIndex: for obj_id in sorted(self.geometries.keys()): polygon = self.geometries[obj_id] - dilated = polygon.buffer(total_dilation, join_style=2) + dilated = polygon.buffer(total_dilation, join_style="mitre") geometries.append(dilated) bounds_list.append(dilated.bounds) is_rect_list.append(is_axis_aligned_rect(dilated)) diff --git a/inire/model.py b/inire/model.py index f0607ae..05e923a 100644 --- a/inire/model.py +++ b/inire/model.py @@ -5,11 +5,10 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal from shapely.geometry import Polygon - -from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry from inire.seeds import PathSeed if TYPE_CHECKING: + from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry from inire.geometry.primitives import Port diff --git a/inire/results.py b/inire/results.py index d16e92c..e9d178b 100644 --- a/inire/results.py +++ b/inire/results.py @@ -70,7 +70,7 @@ class RoutingResult: @property def locked_geometry(self) -> tuple[Polygon, ...]: - polygons = [] + polygons: list[Polygon] = [] for component in self.path: polygons.extend(component.physical_geometry) return tuple(polygons) diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index 8152afb..d10a755 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -94,7 +94,6 @@ def process_move( 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, diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py index 71ca920..56aae96 100644 --- a/inire/router/_astar_moves.py +++ b/inire/router/_astar_moves.py @@ -1,13 +1,16 @@ from __future__ import annotations import math +from typing import TYPE_CHECKING from inire.constants import TOLERANCE_LINEAR -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, SearchRunConfig + +if TYPE_CHECKING: + from inire.geometry.components import MoveKind + from inire.geometry.primitives import Port + from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: @@ -96,7 +99,7 @@ def _visible_straight_candidates( return [] collision_engine = context.cost_evaluator.collision_engine - candidates: set[int] = set() + tangent_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) @@ -104,9 +107,9 @@ def _visible_straight_candidates( continue qlen = int(round(length)) if qlen > 0: - candidates.add(qlen) + tangent_candidates.add(qlen) - return sorted(candidates, reverse=True) + return sorted(tangent_candidates, reverse=True) def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]: diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index 2796c73..96f9aa9 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING -from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry -from inire.model import RoutingOptions, RoutingProblem, resolve_bend_geometry +from inire.model import resolve_bend_geometry from inire.results import RouteMetrics from inire.router.visibility import VisibilityManager if TYPE_CHECKING: - from inire.geometry.components import ComponentResult + from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry, ComponentResult + from inire.geometry.primitives import Port + from inire.model import RoutingOptions, RoutingProblem from inire.router.cost import CostEvaluator @@ -61,7 +62,7 @@ class AStarNode: def __init__( self, - port, + port: Port, g_cost: float, h_cost: float, parent: AStarNode | None = None, diff --git a/inire/router/_router.py b/inire/router/_router.py index 81bc6ef..a52d8d6 100644 --- a/inire/router/_router.py +++ b/inire/router/_router.py @@ -15,6 +15,8 @@ from inire.router.refiner import PathRefiner if TYPE_CHECKING: from collections.abc import Callable, Sequence + from shapely.geometry import Polygon + from inire.geometry.components import ComponentResult @@ -48,8 +50,8 @@ class PathFinder: self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: - all_geoms = [] - all_dilated = [] + all_geoms: list[Polygon] = [] + all_dilated: list[Polygon] = [] for result in path: all_geoms.extend(result.collision_geometry) all_dilated.extend(result.dilated_collision_geometry) @@ -215,7 +217,7 @@ class PathFinder: return RoutingResult( net_id=net_id, - path=path, + path=tuple(path), reached_target=reached_target, report=RoutingReport() if report is None else report, ) @@ -276,7 +278,7 @@ class PathFinder: 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, + path=tuple(refined_path), reached_target=result.reached_target, report=report, ) diff --git a/inire/router/_search.py b/inire/router/_search.py index 2cf7daa..8e7a0ab 100644 --- a/inire/router/_search.py +++ b/inire/router/_search.py @@ -4,13 +4,13 @@ import heapq from typing import TYPE_CHECKING from inire.constants import TOLERANCE_LINEAR -from inire.geometry.primitives import Port from ._astar_moves import expand_moves as _expand_moves from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]: diff --git a/inire/router/_stack.py b/inire/router/_stack.py index 71aa119..9bf67a2 100644 --- a/inire/router/_stack.py +++ b/inire/router/_stack.py @@ -1,17 +1,24 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING -from inire.model import RoutingOptions, RoutingProblem +if TYPE_CHECKING: + from inire.geometry.collision import RoutingWorld + from inire.model import RoutingOptions, RoutingProblem + 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 @dataclass(frozen=True, slots=True) class RoutingStack: - world: object - danger_map: object - evaluator: object - context: object - finder: object + world: RoutingWorld + danger_map: DangerMap + evaluator: CostEvaluator + context: AStarContext + finder: PathFinder def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack: diff --git a/inire/router/cost.py b/inire/router/cost.py index c4b62c3..eaf9d66 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -8,6 +8,7 @@ from inire.constants import TOLERANCE_LINEAR from inire.model import ObjectiveWeights if TYPE_CHECKING: + from collections.abc import Sequence from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port @@ -71,7 +72,7 @@ class CostEvaluator: def set_target(self, target: Port) -> None: self._target_x = target.x self._target_y = target.y - self._target_r = target.r + self._target_r = int(target.r) rad = np.radians(target.r) self._target_cos = np.cos(rad) self._target_sin = np.sin(rad) @@ -176,7 +177,7 @@ class CostEvaluator: def path_cost( self, start_port: Port, - path: list[ComponentResult], + path: Sequence[ComponentResult], *, weights: ObjectiveWeights | None = None, ) -> float: diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 03b8a2a..12b3b14 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -51,14 +51,14 @@ class DangerMap: for poly in obstacles: # Sample exterior exterior = poly.exterior - dist = 0 + dist = 0.0 while dist < exterior.length: pt = exterior.interpolate(dist) all_points.append((pt.x, pt.y)) dist += self.resolution # Sample interiors (holes) for interior in poly.interiors: - dist = 0 + dist = 0.0 while dist < interior.length: pt = interior.interpolate(dist) all_points.append((pt.x, pt.y)) diff --git a/inire/router/refiner.py b/inire/router/refiner.py index 6aa5d1f..ee9c9e9 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -1,7 +1,7 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from inire.geometry.component_overlap import components_overlap from inire.geometry.components import Bend90, Straight @@ -55,7 +55,7 @@ class PathRefiner: ports.extend(comp.end_port for comp in path) return ports - def _to_local(self, start: Port, point: Port) -> tuple[int, int]: + def _to_local(self, start: Port, point: Port) -> tuple[float, float]: dx = point.x - start.x dy = point.y - start.y if start.r == 0: @@ -197,8 +197,8 @@ class PathRefiner: 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" + first_dir: Literal["CW", "CCW"] = "CCW" if side_extent > 0 else "CW" + second_dir: Literal["CW", "CCW"] = "CW" if side_extent > 0 else "CCW" dilation = self.collision_engine.clearance / 2.0 path: list[ComponentResult] = [] @@ -288,10 +288,10 @@ class PathRefiner: net_id: str, start: Port, net_width: float, - path: list[ComponentResult], + path: Sequence[ComponentResult], ) -> list[ComponentResult]: if not path: - return path + return list(path) path = list(path) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index ed83d00..d149f42 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -23,7 +23,7 @@ class VisibilityManager: self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() self._corner_graph: dict[int, list[tuple[float, float, float]]] = {} - self._point_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} + self._point_visibility_cache: dict[tuple[int, int, int], list[tuple[float, float, float]]] = {} self._built_static_version = -1 self._build() diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index 2dd78ed..bad876c 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,7 +1,7 @@ from __future__ import annotations from time import perf_counter -from typing import Callable +from collections.abc import Callable from shapely.geometry import Polygon, box @@ -154,7 +154,7 @@ def run_example_02() -> ScenarioOutcome: "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} + widths = dict.fromkeys(netlist, 2.0) _, _, _, pathfinder = _build_routing_stack( bounds=(0, 0, 100, 100), netlist=netlist, @@ -232,7 +232,7 @@ def run_example_05() -> ScenarioOutcome: "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} + widths = dict.fromkeys(netlist, 2.0) _, _, _, pathfinder = _build_routing_stack( bounds=(0, 0, 200, 200), netlist=netlist, diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 67264cc..2829d8c 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,6 +1,3 @@ -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 @@ -10,7 +7,6 @@ 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 def _build_pathfinder( @@ -45,19 +41,19 @@ def test_clearance_thresholds(): # Clearance = 2.0, Width = 2.0 # Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.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.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. p2_ok = Port(0, 5, 0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) 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) @@ -105,7 +101,7 @@ def test_verify_all_nets_cases(): # Reset engine engine.remove_path("net1") engine.remove_path("net2") - + results_p = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), @@ -124,7 +120,7 @@ def test_verify_all_nets_cases(): } engine.remove_path("net3") engine.remove_path("net4") - + results_c = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index 7eb0e4f..284055d 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -43,15 +43,15 @@ 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 = RoutingWorld(clearance=2.0) - + # Obstacle at x=10 to 20 _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) reach_ok = engine.ray_cast(start_ok, 90, max_dist=10.0, net_width=2.0) assert reach_ok >= 10.0 - + # 2. Parallel move at x=8. Gap = 10 - 8 = 2.0. COLLISION. start_fail = Port(8, 50, 90) reach_fail = engine.ray_cast(start_fail, 90, max_dist=10.0, net_width=2.0) @@ -61,19 +61,19 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: engine = RoutingWorld(clearance=2.0) _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) - + # 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) - + # 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) diff --git a/inire/tests/test_cost.py b/inire/tests/test_cost.py index 6eef3bc..05976a9 100644 --- a/inire/tests/test_cost.py +++ b/inire/tests/test_cost.py @@ -17,7 +17,7 @@ def test_cost_calculation() -> None: p2 = Port(10, 10, 0) h = evaluator.h_manhattan(p1, p2) - # Manhattan distance = 20. + # Manhattan distance = 20. # Jog alignment penalty = 2*bp = 20. # Side check penalty = 2*bp = 20. # Total = 1.1 * (20 + 40) = 66.0 @@ -56,25 +56,25 @@ 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) dm = DangerMap(bounds, resolution=1.0, safety_threshold=10.0) - + # Square obstacle at (100, 100) to (110, 110) obstacle = Polygon([(100, 100), (110, 100), (110, 110), (100, 110)]) dm.precompute([obstacle]) - + # 1. High cost near boundary cost_near = dm.get_cost(100.5, 100.5) assert cost_near > 1.0 - + # 2. Zero cost far away cost_far = dm.get_cost(500, 500) assert cost_far == 0.0 - + # 3. Check cache usage (internal detail check) # 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 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 80d56aa..2d44d11 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -2,12 +2,15 @@ from __future__ import annotations import os import statistics -from collections.abc import Callable +from typing import TYPE_CHECKING import pytest from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome +if TYPE_CHECKING: + from collections.abc import Callable + RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" PERFORMANCE_REPEATS = 3 diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 8268c47..d828d1e 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import matplotlib.pyplot as plt import numpy from shapely.geometry import MultiPolygon, Polygon @@ -129,7 +129,7 @@ def plot_danger_map( if ax is None: fig, ax = plt.subplots(figsize=(10, 10)) else: - fig = ax.get_figure() + fig = cast("Figure", ax.get_figure()) # Generate a temporary grid for visualization res = resolution if resolution is not None else max(1.0, (danger_map.maxx - danger_map.minx) / 200.0) @@ -155,12 +155,12 @@ def plot_danger_map( # Need to transpose because grid is [x, y] and imshow expects [row, col] (y, x) im = ax.imshow( grid.T, - origin='lower', - extent=[danger_map.minx, danger_map.maxx, danger_map.miny, danger_map.maxy], - cmap='YlOrRd', - alpha=0.6 + origin="lower", + extent=(danger_map.minx, danger_map.maxx, danger_map.miny, danger_map.maxy), + cmap="YlOrRd", + alpha=0.6, ) - plt.colorbar(im, ax=ax, label='Danger Cost') + plt.colorbar(im, ax=ax, label="Danger Cost") ax.set_title("Danger Map (Proximity Costs)") return fig, ax @@ -176,7 +176,7 @@ def plot_expanded_nodes( if ax is None: fig, ax = plt.subplots(figsize=(10, 10)) else: - fig = ax.get_figure() + fig = cast("Figure", ax.get_figure()) if not nodes: return fig, ax @@ -206,7 +206,7 @@ def plot_expansion_density( if ax is None: fig, ax = plt.subplots(figsize=(12, 12)) else: - fig = ax.get_figure() + fig = cast("Figure", ax.get_figure()) if not nodes: ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes) @@ -224,14 +224,14 @@ def plot_expansion_density( # Plot as image im = ax.imshow( h.T, - origin='lower', - extent=[bounds[0], bounds[2], bounds[1], bounds[3]], - cmap='plasma', - interpolation='nearest', - alpha=0.7 + origin="lower", + extent=(bounds[0], bounds[2], bounds[1], bounds[3]), + cmap="plasma", + interpolation="nearest", + alpha=0.7, ) - plt.colorbar(im, ax=ax, label='Expansion Count') + plt.colorbar(im, ax=ax, label="Expansion Count") ax.set_title("Search Expansion Density") ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3]) diff --git a/pyproject.toml b/pyproject.toml index efbd939..e4031c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,18 @@ lint.ignore = [ "TRY003", # Long exception message ] +[tool.ruff.lint.per-file-ignores] +"inire/tests/*.py" = ["ANN", "ARG005", "PT009"] + +[tool.mypy] +python_version = "3.11" +warn_unused_configs = true +exclude = ["^examples/", "^inire/tests/"] + +[[tool.mypy.overrides]] +module = ["scipy.*"] +ignore_missing_imports = true + [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["inire"] From 725980e694980c6cccabccc50f3c9e6a6f3bd978 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Tue, 31 Mar 2026 17:26:00 -0700 Subject: [PATCH 3/3] update docs and perf metrics --- DOCS.md | 57 ++- README.md | 10 +- docs/architecture.md | 47 ++ docs/performance.md | 25 ++ docs/performance_baseline.json | 474 ++++++++++++++++++++ docs/plans/cost_and_collision_engine.md | 42 -- docs/plans/geometric_representation.md | 37 -- docs/plans/high_level_notes.md | 13 - docs/plans/implementation_plan.md | 64 --- docs/plans/package_structure.md | 57 --- docs/plans/routing_architecture_decision.md | 67 --- docs/plans/routing_search_spec.md | 66 --- docs/plans/testing_plan.md | 138 ------ examples/README.md | 6 +- inire/geometry/collision.py | 18 + inire/geometry/dynamic_path_index.py | 8 + inire/geometry/static_obstacle_index.py | 6 + inire/results.py | 37 ++ inire/router/_astar_admission.py | 9 + inire/router/_astar_types.py | 151 +++++++ inire/router/_router.py | 12 +- inire/router/visibility.py | 14 + inire/tests/example_scenarios.py | 169 +++++-- inire/tests/test_api.py | 7 + inire/tests/test_performance_reporting.py | 45 ++ scripts/record_performance_baseline.py | 129 ++++++ 26 files changed, 1183 insertions(+), 525 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/performance.md create mode 100644 docs/performance_baseline.json delete mode 100644 docs/plans/cost_and_collision_engine.md delete mode 100644 docs/plans/geometric_representation.md delete mode 100644 docs/plans/high_level_notes.md delete mode 100644 docs/plans/implementation_plan.md delete mode 100644 docs/plans/package_structure.md delete mode 100644 docs/plans/routing_architecture_decision.md delete mode 100644 docs/plans/routing_search_spec.md delete mode 100644 docs/plans/testing_plan.md create mode 100644 inire/tests/test_performance_reporting.py create mode 100644 scripts/record_performance_baseline.py diff --git a/DOCS.md b/DOCS.md index 2cfab9d..2534a17 100644 --- a/DOCS.md +++ b/DOCS.md @@ -133,19 +133,60 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are `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. | +### Search Counters + +- `nodes_expanded`: Total nodes expanded during the run. +- `moves_generated`: Total candidate moves generated during the run. +- `moves_added`: Total candidate moves admitted to the open set. +- `pruned_closed_set`: Total moves pruned because the state was already closed at lower cost. +- `pruned_hard_collision`: Total moves pruned by hard collision checks. +- `pruned_cost`: Total moves pruned by cost ceilings or invalid costs. +- `route_iterations`: Number of negotiated-congestion iterations entered. +- `nets_routed`: Number of net-routing attempts executed across all iterations. +- `nets_reached_target`: Number of those attempts that reached the requested target port. +- `warm_start_paths_built`: Number of warm-start seed paths built by the greedy bootstrap pass. +- `warm_start_paths_used`: Number of routing attempts satisfied directly from an initial or warm-start path. +- `refine_path_calls`: Number of completed paths passed through the post-route refiner. +- `timeout_events`: Number of timeout exits encountered during the run. + +### Cache Counters + +- `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity. +- `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity. +- `static_safe_cache_hits`: Reuse count for the static-safe admission cache. +- `hard_collision_cache_hits`: Reuse count for the hard-collision cache. +- `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity. + +### Index And Collision Counters + +- `dynamic_path_objects_added` / `dynamic_path_objects_removed`: Dynamic-path geometry objects inserted into or removed from the live routing index. +- `dynamic_tree_rebuilds`: Number of dynamic STRtree rebuilds. +- `dynamic_grid_rebuilds`: Number of dynamic congestion-grid rebuilds. +- `static_tree_rebuilds`: Number of static dilated-obstacle STRtree rebuilds. +- `static_raw_tree_rebuilds`: Number of raw static-obstacle STRtree rebuilds used for verification. +- `static_net_tree_rebuilds`: Number of net-width-specific static STRtree rebuilds. +- `visibility_builds`: Number of static visibility-graph rebuilds. +- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building that graph. +- `visibility_corner_queries` / `visibility_corner_hits`: Precomputed-corner visibility query activity. +- `visibility_point_queries`, `visibility_point_cache_hits`, `visibility_point_cache_misses`: Arbitrary-point visibility query and cache activity. +- `ray_cast_calls`: Number of ray-cast queries issued against static obstacles. +- `ray_cast_candidate_bounds`: Total broad-phase candidate bounds considered by ray casts. +- `ray_cast_exact_geometry_checks`: Total exact non-rectangular geometry checks performed by ray casts. +- `congestion_check_calls`: Number of congestion broad-phase checks requested by search. +- `congestion_exact_pair_checks`: Number of exact geometry-pair checks performed while confirming congestion hits. + +### Verification Counters + +- `verify_path_report_calls`: Number of full path-verification passes. +- `verify_static_buffer_ops`: Number of static-verification `buffer()` operations. +- `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification. ## 8. Internal Modules 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=...)`. +The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**. + ## 9. Tuning Notes ### Speed vs. optimality diff --git a/README.md b/README.md index a66f699..96cfbc7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ * **Negotiated Congestion**: Iteratively resolves multi-net bottlenecks by inflating costs in high-traffic regions. * **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. +* **Safety & Proximity**: Uses a sampled obstacle-boundary proximity model to bias routes away from nearby geometry. * **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs. ## Installation @@ -77,7 +77,11 @@ INIRE_RUN_PERFORMANCE=1 python3 -m pytest -q inire/tests/test_example_performanc ## Documentation -Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. +Current documentation lives in: + +* **[DOCS.md](DOCS.md)** for the public API and option reference. +* **[docs/architecture.md](docs/architecture.md)** for the current implementation structure. +* **[docs/performance.md](docs/performance.md)** for the committed performance-counter baseline. ## API Stability @@ -92,7 +96,7 @@ Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router. 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 negotiated-congestion 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 and seeks a collision-free configuration without crossings. ## Configuration diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3855199 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,47 @@ +# Architecture Overview + +`inire` is a single-package Python router with a small stable API at the package root and a larger semi-private implementation under `inire.geometry` and `inire.router`. + +## Stable Surface + +- The supported entrypoint is `route(problem, options=...)`. +- Stable public types live at the package root and include `RoutingProblem`, `RoutingOptions`, `NetSpec`, `Port`, `RoutingResult`, and `RoutingRunResult`. +- Deep imports such as `inire.router._router.PathFinder` and `inire.geometry.collision.RoutingWorld` are intentionally accessible for advanced workflows, but they are unstable. + +## Current Module Layout + +- `inire/model.py`: Immutable request and option dataclasses. +- `inire/results.py`: Immutable routing results plus the per-run `RouteMetrics` snapshot. +- `inire/seeds.py`: Serializable path-seed primitives. +- `inire/geometry/primitives.py`: Integer Manhattan ports and small transform helpers. +- `inire/geometry/components.py`: `Straight`, `Bend90`, and `SBend` geometry generation. +- `inire/geometry/collision.py`: Routing-world collision, congestion, ray-cast, and path-verification logic. +- `inire/geometry/static_obstacle_index.py` and `inire/geometry/dynamic_path_index.py`: Spatial-index management for static obstacles and routed paths. +- `inire/router/_search.py`, `_astar_moves.py`, `_astar_admission.py`, `_astar_types.py`: The state-lattice A* search loop and move admission pipeline. +- `inire/router/_router.py`: The negotiated-congestion driver and refinement orchestration. +- `inire/router/refiner.py`: Post-route path simplification for completed paths. +- `inire/router/cost.py` and `inire/router/danger_map.py`: Search scoring and obstacle-proximity biasing. +- `inire/utils/visualization.py`: Plotting and diagnostics helpers. + +## Routing Stack + +`route(problem, options=...)` builds a routing stack composed of: + +1. `RoutingWorld` for collision state. +2. `DangerMap` for static-obstacle proximity costs. +3. `CostEvaluator` for move scoring and heuristic support. +4. `AStarContext` for caches and search configuration. +5. `PathFinder` for negotiated congestion, rip-up/reroute, and refinement. + +The search state is a snapped Manhattan `(x, y, r)` port. From each state the router expands straight segments, 90-degree bends, and compact S-bends, then validates candidates against static geometry, dynamic congestion, and optional self-collision checks. + +## Notes On Current Behavior + +- Static obstacles and routed paths are treated as single-layer geometry; automatic crossings are not supported. +- The danger-map implementation uses sampled obstacle-boundary points and a KD-tree, not a dense distance-transform grid. +- `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration. +- Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning. + +## Performance Visibility + +`RoutingRunResult.metrics` includes both A* counters and index/cache/verification counters. The committed example-corpus baseline for those counters is tracked in `docs/performance.md` and `docs/performance_baseline.json`. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..3144243 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,25 @@ +# Performance Baseline + +Generated on 2026-03-31 by `scripts/record_performance_baseline.py`. + +The full machine-readable snapshot lives in `docs/performance_baseline.json`. + +| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls | +| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | +| example_01_simple_route | 0.0042 | 1 | 1 | 1 | 1 | 1 | 2 | 22 | 11 | 7 | 2 | 2 | 0 | 3 | +| example_02_congestion_resolution | 0.3335 | 3 | 3 | 3 | 1 | 3 | 366 | 1176 | 1413 | 668 | 8 | 4 | 0 | 35 | +| example_03_locked_paths | 0.1810 | 2 | 2 | 2 | 2 | 2 | 191 | 681 | 904 | 307 | 5 | 4 | 0 | 14 | +| example_04_sbends_and_radii | 2.0151 | 2 | 2 | 2 | 1 | 2 | 15 | 18218 | 123 | 65 | 4 | 3 | 0 | 6 | +| example_05_orientation_stress | 0.2438 | 3 | 3 | 3 | 2 | 6 | 286 | 1243 | 1624 | 681 | 12 | 3 | 412 | 12 | +| example_06_bend_collision_models | 4.1636 | 3 | 3 | 3 | 3 | 3 | 240 | 40530 | 1026 | 629 | 6 | 6 | 0 | 9 | +| example_07_large_scale_routing | 1.3759 | 10 | 10 | 10 | 1 | 10 | 78 | 11151 | 372 | 227 | 20 | 11 | 0 | 30 | +| example_08_custom_bend_geometry | 0.2437 | 2 | 2 | 2 | 2 | 2 | 18 | 2308 | 78 | 56 | 4 | 4 | 0 | 6 | +| example_09_unroutable_best_effort | 0.0052 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 1 | 0 | 0 | 1 | + +## Full Counter Set + +Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters. + +Tracked metric keys: + +nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_cache_misses, dynamic_path_objects_added, dynamic_path_objects_removed, dynamic_tree_rebuilds, dynamic_grid_rebuilds, static_tree_rebuilds, static_raw_tree_rebuilds, static_net_tree_rebuilds, visibility_builds, visibility_corner_pairs_checked, visibility_corner_queries, visibility_corner_hits, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, ray_cast_calls, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_exact_pair_checks diff --git a/docs/performance_baseline.json b/docs/performance_baseline.json new file mode 100644 index 0000000..0c3c030 --- /dev/null +++ b/docs/performance_baseline.json @@ -0,0 +1,474 @@ +{ + "generated_on": "2026-03-31", + "generator": "scripts/record_performance_baseline.py", + "scenarios": [ + { + "duration_s": 0.0041740520391613245, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 2, + "dynamic_path_objects_removed": 1, + "dynamic_tree_rebuilds": 2, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 1, + "move_cache_abs_misses": 10, + "move_cache_rel_hits": 0, + "move_cache_rel_misses": 10, + "moves_added": 7, + "moves_generated": 11, + "nets_reached_target": 1, + "nets_routed": 1, + "nodes_expanded": 2, + "pruned_closed_set": 0, + "pruned_cost": 4, + "pruned_hard_collision": 0, + "ray_cast_calls": 22, + "ray_cast_candidate_bounds": 12, + "ray_cast_exact_geometry_checks": 0, + "refine_path_calls": 1, + "route_iterations": 1, + "static_net_tree_rebuilds": 1, + "static_raw_tree_rebuilds": 0, + "static_safe_cache_hits": 1, + "static_tree_rebuilds": 1, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 3, + "verify_static_buffer_ops": 0, + "visibility_builds": 2, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 12, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 1, + "warm_start_paths_used": 1 + }, + "name": "example_01_simple_route", + "reached_targets": 1, + "total_results": 1, + "valid_results": 1 + }, + { + "duration_s": 0.3335385399404913, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 32, + "dynamic_path_objects_removed": 17, + "dynamic_tree_rebuilds": 8, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 12, + "move_cache_abs_misses": 1401, + "move_cache_rel_hits": 1293, + "move_cache_rel_misses": 108, + "moves_added": 668, + "moves_generated": 1413, + "nets_reached_target": 3, + "nets_routed": 3, + "nodes_expanded": 366, + "pruned_closed_set": 157, + "pruned_cost": 208, + "pruned_hard_collision": 380, + "ray_cast_calls": 1176, + "ray_cast_candidate_bounds": 925, + "ray_cast_exact_geometry_checks": 136, + "refine_path_calls": 3, + "route_iterations": 1, + "static_net_tree_rebuilds": 3, + "static_raw_tree_rebuilds": 0, + "static_safe_cache_hits": 1, + "static_tree_rebuilds": 2, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 90, + "verify_path_report_calls": 35, + "verify_static_buffer_ops": 0, + "visibility_builds": 4, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 12, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 3, + "warm_start_paths_used": 3 + }, + "name": "example_02_congestion_resolution", + "reached_targets": 3, + "total_results": 3, + "valid_results": 3 + }, + { + "duration_s": 0.1809853739105165, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 17, + "dynamic_path_objects_removed": 10, + "dynamic_tree_rebuilds": 5, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 1, + "move_cache_abs_misses": 903, + "move_cache_rel_hits": 821, + "move_cache_rel_misses": 82, + "moves_added": 307, + "moves_generated": 904, + "nets_reached_target": 2, + "nets_routed": 2, + "nodes_expanded": 191, + "pruned_closed_set": 97, + "pruned_cost": 140, + "pruned_hard_collision": 181, + "ray_cast_calls": 681, + "ray_cast_candidate_bounds": 179, + "ray_cast_exact_geometry_checks": 0, + "refine_path_calls": 2, + "route_iterations": 2, + "static_net_tree_rebuilds": 2, + "static_raw_tree_rebuilds": 1, + "static_safe_cache_hits": 1, + "static_tree_rebuilds": 2, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 14, + "verify_static_buffer_ops": 69, + "visibility_builds": 4, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 24, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 2, + "warm_start_paths_used": 2 + }, + "name": "example_03_locked_paths", + "reached_targets": 2, + "total_results": 2, + "valid_results": 2 + }, + { + "duration_s": 2.0151148419827223, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 14, + "dynamic_path_objects_removed": 7, + "dynamic_tree_rebuilds": 4, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 1, + "move_cache_abs_misses": 122, + "move_cache_rel_hits": 80, + "move_cache_rel_misses": 42, + "moves_added": 65, + "moves_generated": 123, + "nets_reached_target": 2, + "nets_routed": 2, + "nodes_expanded": 15, + "pruned_closed_set": 2, + "pruned_cost": 25, + "pruned_hard_collision": 16, + "ray_cast_calls": 18218, + "ray_cast_candidate_bounds": 50717, + "ray_cast_exact_geometry_checks": 21265, + "refine_path_calls": 2, + "route_iterations": 1, + "static_net_tree_rebuilds": 2, + "static_raw_tree_rebuilds": 0, + "static_safe_cache_hits": 1, + "static_tree_rebuilds": 2, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 6, + "verify_static_buffer_ops": 0, + "visibility_builds": 3, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 18148, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 2, + "warm_start_paths_used": 2 + }, + "name": "example_04_sbends_and_radii", + "reached_targets": 2, + "total_results": 2, + "valid_results": 2 + }, + { + "duration_s": 0.2437819039914757, + "metrics": { + "congestion_cache_hits": 2, + "congestion_cache_misses": 412, + "congestion_check_calls": 412, + "congestion_exact_pair_checks": 66, + "dynamic_grid_rebuilds": 3, + "dynamic_path_objects_added": 37, + "dynamic_path_objects_removed": 25, + "dynamic_tree_rebuilds": 12, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 253, + "move_cache_abs_misses": 1371, + "move_cache_rel_hits": 1269, + "move_cache_rel_misses": 102, + "moves_added": 681, + "moves_generated": 1624, + "nets_reached_target": 6, + "nets_routed": 6, + "nodes_expanded": 286, + "pruned_closed_set": 139, + "pruned_cost": 505, + "pruned_hard_collision": 14, + "ray_cast_calls": 1243, + "ray_cast_candidate_bounds": 45, + "ray_cast_exact_geometry_checks": 43, + "refine_path_calls": 3, + "route_iterations": 2, + "static_net_tree_rebuilds": 3, + "static_raw_tree_rebuilds": 0, + "static_safe_cache_hits": 3, + "static_tree_rebuilds": 1, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 2, + "verify_path_report_calls": 12, + "verify_static_buffer_ops": 0, + "visibility_builds": 3, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 0, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 2, + "warm_start_paths_used": 2 + }, + "name": "example_05_orientation_stress", + "reached_targets": 3, + "total_results": 3, + "valid_results": 3 + }, + { + "duration_s": 4.163613382959738, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 36, + "dynamic_path_objects_removed": 18, + "dynamic_tree_rebuilds": 6, + "hard_collision_cache_hits": 18, + "move_cache_abs_hits": 186, + "move_cache_abs_misses": 840, + "move_cache_rel_hits": 702, + "move_cache_rel_misses": 138, + "moves_added": 629, + "moves_generated": 1026, + "nets_reached_target": 3, + "nets_routed": 3, + "nodes_expanded": 240, + "pruned_closed_set": 108, + "pruned_cost": 204, + "pruned_hard_collision": 85, + "ray_cast_calls": 40530, + "ray_cast_candidate_bounds": 121732, + "ray_cast_exact_geometry_checks": 36858, + "refine_path_calls": 3, + "route_iterations": 3, + "static_net_tree_rebuilds": 3, + "static_raw_tree_rebuilds": 3, + "static_safe_cache_hits": 141, + "static_tree_rebuilds": 6, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 9, + "verify_static_buffer_ops": 54, + "visibility_builds": 6, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 39848, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 3, + "warm_start_paths_used": 3 + }, + "name": "example_06_bend_collision_models", + "reached_targets": 3, + "total_results": 3, + "valid_results": 3 + }, + { + "duration_s": 1.375933071016334, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 88, + "dynamic_path_objects_removed": 44, + "dynamic_tree_rebuilds": 20, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 6, + "move_cache_abs_misses": 366, + "move_cache_rel_hits": 275, + "move_cache_rel_misses": 91, + "moves_added": 227, + "moves_generated": 372, + "nets_reached_target": 10, + "nets_routed": 10, + "nodes_expanded": 78, + "pruned_closed_set": 20, + "pruned_cost": 64, + "pruned_hard_collision": 61, + "ray_cast_calls": 11151, + "ray_cast_candidate_bounds": 21198, + "ray_cast_exact_geometry_checks": 11651, + "refine_path_calls": 10, + "route_iterations": 1, + "static_net_tree_rebuilds": 10, + "static_raw_tree_rebuilds": 1, + "static_safe_cache_hits": 6, + "static_tree_rebuilds": 10, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 30, + "verify_static_buffer_ops": 132, + "visibility_builds": 11, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 10768, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 10, + "warm_start_paths_used": 10 + }, + "name": "example_07_large_scale_routing", + "reached_targets": 10, + "total_results": 10, + "valid_results": 10 + }, + { + "duration_s": 0.2436628290452063, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 12, + "dynamic_path_objects_removed": 6, + "dynamic_tree_rebuilds": 4, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 2, + "move_cache_abs_misses": 76, + "move_cache_rel_hits": 32, + "move_cache_rel_misses": 44, + "moves_added": 56, + "moves_generated": 78, + "nets_reached_target": 2, + "nets_routed": 2, + "nodes_expanded": 18, + "pruned_closed_set": 6, + "pruned_cost": 16, + "pruned_hard_collision": 0, + "ray_cast_calls": 2308, + "ray_cast_candidate_bounds": 3802, + "ray_cast_exact_geometry_checks": 1904, + "refine_path_calls": 2, + "route_iterations": 2, + "static_net_tree_rebuilds": 2, + "static_raw_tree_rebuilds": 0, + "static_safe_cache_hits": 2, + "static_tree_rebuilds": 2, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 6, + "verify_static_buffer_ops": 0, + "visibility_builds": 4, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 2252, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 2, + "warm_start_paths_used": 2 + }, + "name": "example_08_custom_bend_geometry", + "reached_targets": 2, + "total_results": 2, + "valid_results": 2 + }, + { + "duration_s": 0.0052433289820328355, + "metrics": { + "congestion_cache_hits": 0, + "congestion_cache_misses": 0, + "congestion_check_calls": 0, + "congestion_exact_pair_checks": 0, + "dynamic_grid_rebuilds": 0, + "dynamic_path_objects_added": 1, + "dynamic_path_objects_removed": 0, + "dynamic_tree_rebuilds": 1, + "hard_collision_cache_hits": 0, + "move_cache_abs_hits": 0, + "move_cache_abs_misses": 16, + "move_cache_rel_hits": 2, + "move_cache_rel_misses": 14, + "moves_added": 10, + "moves_generated": 16, + "nets_reached_target": 0, + "nets_routed": 1, + "nodes_expanded": 3, + "pruned_closed_set": 0, + "pruned_cost": 4, + "pruned_hard_collision": 2, + "ray_cast_calls": 13, + "ray_cast_candidate_bounds": 5, + "ray_cast_exact_geometry_checks": 0, + "refine_path_calls": 0, + "route_iterations": 1, + "static_net_tree_rebuilds": 1, + "static_raw_tree_rebuilds": 1, + "static_safe_cache_hits": 0, + "static_tree_rebuilds": 0, + "timeout_events": 0, + "verify_dynamic_exact_pair_checks": 0, + "verify_path_report_calls": 1, + "verify_static_buffer_ops": 1, + "visibility_builds": 0, + "visibility_corner_hits": 0, + "visibility_corner_pairs_checked": 0, + "visibility_corner_queries": 0, + "visibility_point_cache_hits": 0, + "visibility_point_cache_misses": 0, + "visibility_point_queries": 0, + "warm_start_paths_built": 0, + "warm_start_paths_used": 0 + }, + "name": "example_09_unroutable_best_effort", + "reached_targets": 0, + "total_results": 1, + "valid_results": 0 + } + ] +} diff --git a/docs/plans/cost_and_collision_engine.md b/docs/plans/cost_and_collision_engine.md deleted file mode 100644 index ced6a82..0000000 --- a/docs/plans/cost_and_collision_engine.md +++ /dev/null @@ -1,42 +0,0 @@ -# Cost and Collision Engine Spec - -This document describes the methods for ensuring "analytic correctness" while maintaining a computationally efficient cost function. - -## 1. Analytic Correctness -The router balances speed and verification using a two-tier approach. - -### 1.1. R-Tree Geometry Engine -The router uses an **R-Tree of Polygons** for all geometric queries. -* **Move Validation:** Every "Move" proposed by the search is checked for intersection against the R-Tree. -* **Pre-dilation for Clearance:** All obstacles and paths use a global clearance $C$. At the start of the session, all user-provided obstacles are **pre-dilated by $(W_{max} + C)/2$** for initial broad pruning. However, individual path dilation for intersection tests uses $(W_i + C)/2$ for a specific net's width $W_i$. -* **Safety Zone:** To prevent immediate collision flags for ports placed on or near obstacle boundaries, the router ignores collisions within a radius of **2nm** of start and end ports. -* **Single Layer:** All routing and collision detection occur on a single layer. - -### 1.2. Cost Calculation (Soft Constraint) -The "Danger Cost" $g_{proximity}(n)$ is a function of the distance $d$ to the nearest obstacle: -$$g_{proximity}(n) = \begin{cases} \infty & \text{if } d < (W_i + C)/2 \\ \frac{k}{d^2} & \text{if } (W_i + C)/2 \le d < \text{Safety Threshold} \\ 0 & \text{if } d \ge \text{Safety Threshold} \end{cases}$$ - -To optimize A* search, a **Static Danger Map (Precomputed Grid)** is used for the heuristic. -* **Grid Resolution:** Default **1000nm (1µm)**. -* **Static Nature:** The grid only accounts for fixed obstacles. It is computed once at the start of the session and is **not re-computed** during the Negotiated Congestion loop. -* **Efficiency:** For a 20x20mm layout, this results in a 20k x 20k matrix. - * **Memory:** Using a `uint8` or `float16` representation, this consumes ~400-800MB (Default < 2GB). For extremely high resolution or larger areas, the system supports up to **20GB** allocation. -* **Precision:** Strict intersection checks still use the R-Tree for "analytic correctness." - -## 2. Collision Detection Implementation -The system relies on `shapely` for geometry and `rtree` for spatial indexing. -1. **Arc Resolution:** Arcs are represented as polygons approximated by segments with a maximum deviation (sagitta). -2. **Intersection Test:** A "Move" is valid only if its geometry does not intersect any obstacle in the R-Tree. -3. **Self-Intersection:** Paths from the same net must not intersect themselves. -4. **No Crossings:** Strictly 2D; no crossings (vias or bridges) are supported. - -## 3. Negotiated Congestion (Path R-Tree) -To handle multiple nets, the router maintains a separate R-Tree containing the dilated geometries ($C/2$) of all currently routed paths. -* **Congestion Cost:** $P \times (\text{Overlaps in Path R-Tree})$. -* **Failure Policy:** If no collision-free path is found after the max iterations, the router returns the **"least-bad" (lowest cost)** path. These paths MUST be explicitly flagged as invalid (e.g., via an `is_valid=False` attribute or a non-zero `collision_count`) so the user can identify and manually fix the failure. - -## 4. Handling Global Clearances -Clearances are global. Both obstacles and paths are pre-dilated once. This ensures that any two objects maintain at least $C$ distance if their dilated versions do not intersect. - -## 5. Locked Paths -The router supports **Locked Paths**—existing geometries inserted into the static Obstacle R-Tree, ensuring they are never modified or rerouted. diff --git a/docs/plans/geometric_representation.md b/docs/plans/geometric_representation.md deleted file mode 100644 index f051abf..0000000 --- a/docs/plans/geometric_representation.md +++ /dev/null @@ -1,37 +0,0 @@ -# Geometric Representation and Data Structures - -This document defines the core data structures for representing the routing problem. - -## 1. Port Definitions (Connectivity) -Routing requests are defined as a mapping of source ports to destination ports. - -* **Port Structure:** `(x, y, orientation)` - * `x, y`: Coordinates snapped to **1nm** grid. - * `orientation`: Strictly $\{0, 90, 180, 270\}$ degrees. -* **Netlist:** A dictionary or list of tuples: `{(start_port): (end_port)}`. Each entry represents a single-layer path that must be routed. -* **Heterogeneous Widths:** The router supports different widths $W_i$ per net. Dilation for a specific net $i$ is calculated as $(W_i + C)/2$ to maintain the global clearance $C$. - -## 2. Component Library (Move Generator) -The router uses a discrete set of components to expand states in the A* search. - -### 2.1. Straight Waveguides -* **A* Expansion:** Generates "Straight" moves of varying lengths (e.g., $1\mu m, 5\mu m, 25\mu m$). -* **Snap-to-Target:** If the destination port $T$ is directly ahead and the current state's orientation matches $T$'s entry orientation, a special move is generated to close the gap exactly. - -### 2.2. Fixed 90° Bends (PDK Cells) -* **Parameters:** `radius`, `width`. -* **A* Expansion:** A discrete move that changes orientation by $\pm 90^\circ$ and shifts the coordinate by the radius. -* **Grid Alignment:** If the bend radius $R$ is not a multiple of the search grid (default $1\mu m$), the resulting state is **snapped to the nearest grid point**, and a warning is issued to the user. - -### 2.3. Parametric S-Bends (Compact) -* **Parameters:** `radius`, `width`. -* **A* Expansion:** Used ONLY for lateral offsets $O < 2R$. -* **Large Offsets ($O \ge 2R$):** The router does not use a single S-bend move for large offsets. Instead, the A* search naturally finds the optimal path by combining two 90° bends and a straight segment. This ensures maximum flexibility in obstacle avoidance for large shifts. - -## 3. Obstacle Representation -Obstacles are provided as raw polygons on a single layer. - -* **Pre-processing:** All input polygons are inserted into an **R-Tree**. -* **Buffer/Dilation:** Obstacles are pre-dilated by $(W_{max} + Clearance)/2$ for initial pruning, but final collision tests use the net-specific width $W_i$. -* **No Multi-layer:** The router assumes all obstacles and paths share the same plane. -* **Safety Zone:** Ignore collisions within **2nm** of start and end ports for robustness. diff --git a/docs/plans/high_level_notes.md b/docs/plans/high_level_notes.md deleted file mode 100644 index 9e6d3fb..0000000 --- a/docs/plans/high_level_notes.md +++ /dev/null @@ -1,13 +0,0 @@ -# High-Level Notes: Auto-Routing for Integrated Circuits - -We're implementing auto-routing for photonic and RF integrated circuits. The problem space has the following features: - - - **Single Layer:** All paths are on a single layer; multi-layer routing is not supported. - - **No Crossings:** Crossings are to be avoided and are not supported by the router's automatic placement. The user must manually handle any required crossings. - - **Large Bend Radii:** Bends use large radii ($R$), and are usually pre-generated (e.g. 90-degree cells). - - **Proximity Sensitivity:** Paths are sensitive to proximity to other paths and obstacles (crosstalk/coupling). - - **Manhattan Preference:** Manhattan pathing is sufficient for most cases; any-angle is rare. - - **S-Bends:** S-bends are necessary for small lateral offsets ($O < 2R$). - - **Hybrid Search:** A* state-lattice search is used for discrete component placement. - - **Dilation:** A $Clearance/2$ dilation is applied to all obstacles and paths for efficient collision avoidance. - - **Negotiated Congestion:** A multi-net "PathFinder" loop iteratively reroutes nets to resolve congestion. diff --git a/docs/plans/implementation_plan.md b/docs/plans/implementation_plan.md deleted file mode 100644 index b0af45c..0000000 --- a/docs/plans/implementation_plan.md +++ /dev/null @@ -1,64 +0,0 @@ -# Implementation Plan - -This plan outlines the step-by-step implementation of the `inire` auto-router. For detailed test cases, refer to [Testing Plan](./testing_plan.md). - -## Phase 1: Core Geometry & Move Generation -**Goal:** Implement Ports, Polygons, and Component Library with high geometric fidelity. -1. **Project Setup:** Initialize `inire/` structure and `pytest` configuration. Include `hypothesis` for property-based testing. -2. **`geometry.primitives`:** - * `Port` with **1nm** snapping. - * Basic 2D transformations (rotate, translate). - * **Property-Based Tests:** Verify transform invariants (e.g., $90^\circ$ rotation cycles). -3. **`geometry.components`:** - * `Straight`, `Bend90`, `SBend`. - * **Search Grid Snapping:** Implement 1µm snapping for expanded ports. - * **Small S-Bends ($O < 2R$):** Logic for parametric generation. - * **Edge Cases:** Handle $O=2R$ and $L < 1\mu m$. -4. **Tests:** - * Verify geometric correctness (refer to Testing Plan Section 1). - * Unit tests for `Port` snapping and component transformations. - -## Phase 2: Collision Engine & Cost -**Goal:** Build the R-Tree wrapper and the analytic cost function. -1. **`geometry.collision`:** Implement `CollisionEngine`. - * **Pre-dilation:** Obstacles/Paths dilated by $Clearance/2$. - * **Safety Zone:** Ignore collisions within **2nm** of start/end ports. -2. **`router.danger_map`:** - * Implement **1µm** pre-computed proximity grid. - * Optimize for design sizes up to **20x20mm** (< 2GB memory). -3. **`router.cost`:** Implement `CostEvaluator`. - * Bend cost: $10 \times (\text{Manhattan distance between ports})$. - * Integrate R-Tree for strict checks and Danger Map for heuristic. -4. **Tests:** - * Verify collision detection with simple overlapping shapes (Testing Plan Section 2.1). - * Verify Danger Map accuracy and memory footprint (Testing Plan Section 2.2). - * **Post-Route Validator:** Implement the independent `validate_path` utility. - -## Phase 3: Single-Net A* Search -**Goal:** Route a single net from A to B with 1nm precision. -1. **`router.astar`:** Implement the priority queue loop. - * State representation: `(x_µm, y_µm, theta)`. - * Move expansion loop with 1µm grid. - * **Natural S-Bends:** Ensure search can find $O \ge 2R$ shifts by combining moves. - * **Look-ahead Snapping:** Actively bridge to the 1nm target when in the capture radius (10µm). -2. **Heuristic:** Manhattan distance $h(n)$ + orientation penalty + Danger Map lookup. -3. **Tests:** - * Solve simple maze problems and verify path optimality (Testing Plan Section 3). - * Verify snap-to-target precision at 1nm resolution. - * **Determinism:** Verify same seed = same path. - -## Phase 4: Multi-Net PathFinder -**Goal:** Implement the "Negotiated Congestion" loop for multiple nets. -1. **`router.pathfinder`:** - * Sequential routing -> Identify congestion -> Inflate cost -> Reroute. - * **R-Tree Congestion:** Store dilated path geometries. -2. **Explicit Results:** Return `RoutingResult` objects with `is_valid` and `collisions` metadata. -3. **Tests:** - * Full multi-net benchmarks (Testing Plan Section 4). - * Verify rerouting behavior in crowded environments. - -## Phase 5: Visualization, Benchmarking & Fuzzing -1. **`utils.visualization`:** Plot paths using `matplotlib`. Highlight collisions in red. -2. **Benchmarks:** Stress test with 50+ nets. Verify performance and node limits (Testing Plan Section 5). -3. **Fuzzing:** Run A* on randomized layouts to ensure stability. -4. **Final Validation:** Ensure all `is_valid=True` results pass the independent `validate_path` check. diff --git a/docs/plans/package_structure.md b/docs/plans/package_structure.md deleted file mode 100644 index 2877a65..0000000 --- a/docs/plans/package_structure.md +++ /dev/null @@ -1,57 +0,0 @@ -# Python Package Structure - -This document outlines the directory structure and module organization for the `inire` auto-router package. - -## 1. Directory Layout - -``` -inire/ -├── __init__.py # Exposes the main `Router` class and key types -├── geometry/ # Core geometric primitives and operations -│ ├── __init__.py -│ ├── primitives.py # Point, Port, Polygon, Arc classes -│ ├── collision.py # R-Tree wrapper and intersection logic -│ └── components.py # Move generators (Straight, Bend90, SBend) -├── router/ # Search algorithms and pathfinding -│ ├── __init__.py -│ ├── astar.py # Hybrid State-Lattice A* implementation -│ ├── graph.py # Node, Edge, and Graph data structures -│ ├── cost.py # Cost functions (length, bend, proximity) -│ ├── danger_map.py # Pre-computed grid for heuristic proximity costs -│ └── pathfinder.py # Multi-net "Negotiated Congestion" manager -├── utils/ # Utility functions -│ ├── __init__.py -│ └── visualization.py # Plotting tools for debug/heatmaps (matplotlib/klayout) -└── tests/ # Unit and integration tests - ├── __init__.py - ├── conftest.py # Pytest fixtures (common shapes, PDK cells) - ├── test_primitives.py # Tests for Port and coordinate transforms - ├── test_components.py # Tests for Straight, Bend90, SBend generation - ├── test_collision.py # Tests for R-Tree and dilation logic - ├── test_cost.py # Tests for Danger Map and cost evaluation - ├── test_astar.py # Tests for single-net routing (mazes, snapping) - └── test_pathfinder.py # Multi-net "Negotiated Congestion" benchmarks -``` - -## 2. Module Responsibilities - -### `inire.geometry` -* **`primitives.py`**: Defines the `Port` named tuple `(x, y, theta)` and helper functions for coordinate transforms. -* **`collision.py`**: Wraps the `rtree` or `shapely` library. Handles the "Analytic Correctness" checks (exact polygon distance). -* **`components.py`**: Logic to generate "Moves" from a start port. E.g., `SBend.generate(start_port, offset, radius)` returns a list of polygons and the end port. Handles $O > 2R$ logic. - -### `inire.router` -* **`astar.py`**: The heavy lifter. Maintains the `OpenSet` (priority queue) and `ClosedSet`. Implements the "Snap-to-Target" logic. -* **`cost.py`**: compute $f(n) = g(n) + h(n)$. encapsulates the "Danger Map" and Path R-Tree lookups. -* **`danger_map.py`**: Manages the pre-computed proximity grid used for $O(1)$ heuristic calculations. -* **`pathfinder.py`**: Orchestrates the multi-net loop. Tracks the Path R-Tree for negotiated congestion and triggers reroutes. - -### `inire.tests` -* **Structure:** Tests are co-located within the package for ease of access. -* **Fixtures:** `conftest.py` will provide standard PDK cells (e.g., a $10\mu m$ radius bend) to avoid repetition in test cases. - -## 3. Dependencies -* `numpy`: Vector math. -* `shapely`: Polygon geometry and intersection. -* `rtree`: Spatial indexing. -* `networkx` (Optional): Not used for core search to ensure performance. diff --git a/docs/plans/routing_architecture_decision.md b/docs/plans/routing_architecture_decision.md deleted file mode 100644 index 398fc2d..0000000 --- a/docs/plans/routing_architecture_decision.md +++ /dev/null @@ -1,67 +0,0 @@ -# Architecture Decision Record: Auto-Routing for Photonic & RF ICs - -## 1. Problem Context -Photonic and RF routing differ significantly from digital VLSI due to physical constraints: -* **Geometric Rigidity:** 90° bends are pre-rendered PDK cells with fixed bounding boxes. -* **Parametric Flexibility:** S-bends must be generated on-the-fly to handle arbitrary offsets, provided they maintain a constant radius $R$. -* **Signal Integrity:** High sensitivity to proximity (coupling/crosstalk) and a strong preference for single-layer, non-crossing paths. -* **Manual Intervention:** The router is strictly 2D and avoids all other geometries on the same layer. No crossings (e.g. vias or bridges) are supported by the automatic routing engine. The user must manually handle any required crossings by placing components (e.g. crossing cells) and splitting the net list accordingly. This simplifies the router's task to 2D obstacle avoidance and spacing optimization. - ---- - -## 2. Candidate Algorithms & Tradeoffs - -### 2.1. Rubber-Band (Topological) Routing -This approach treats paths as elastic bands stretched around obstacles, later "inflating" them to have width and curvature. - -| Feature | Analysis | -| :--- | :--- | -| **Strengths** | Excellent at "River Routing" and maintaining minimum clearances. Inherently avoids crossings. | -| **Downsides** | **The Inflation Gap:** A valid thin-line topology may be physically un-routable once the large radius $R$ is applied. It struggles to integrate rigid, pre-rendered 90° blocks into a continuous elastic model. | -| **Future Potential** | High, if a "Post-Processing" engine can reliably snap elastic curves to discrete PDK cells without breaking connectivity. | - -### 2.2. Voronoi-Based (Medial Axis) Routing -Uses a Voronoi diagram to find paths that are maximally distant from all obstacles. - -| Feature | Analysis | -| :--- | :--- | -| **Strengths** | Theoretically optimal for "Safety" and crosstalk reduction. Guaranteed maximum clearance. | -| **Downsides** | **Manhattan Incompatibility:** Voronoi edges are any-angle and often jagged. Mapping these to a Manhattan-heavy PDK (90° bends) requires a lossy "snapping" phase that often violates the very safety the algorithm intended to provide. | -| **Future Potential** | Useful as a "Channel Finder" to guide a more rigid router, but unsuitable as a standalone geometric solver. | - -### 2.3. Integer Linear Programming (ILP) -Formulates routing as a massive optimization problem where a solver picks the best path from a pool of candidates. - -| Feature | Analysis | -| :--- | :--- | -| **Strengths** | Can find the mathematically "best" layout (e.g., minimum total length or total bends) across all nets simultaneously. | -| **Downsides** | **Candidate Explosion:** Because S-bends are generated on-the-fly, the number of possible candidate shapes is infinite. To use ILP, one must "discretize" the search space, which may miss the one specific geometry needed for a tight fit. | -| **Future Potential** | Effective for small, high-congestion "Switchbox" areas where all possible geometries can be pre-tabulated. | - ---- - -## 3. Selected Approach: Hybrid State-Lattice A* - -### 3.1. Rationale -The **State-Lattice** variant of the A* algorithm is selected as the primary routing engine. Unlike standard A* which moves between grid cells, State-Lattice moves between **states** defined as $(x, y, \theta)$. - -1. **Native PDK Integration:** The router treats the pre-rendered 90° bend cell as a discrete "Move" in the search tree. The algorithm only considers placing a bend if the cell's bounding box is clear of obstacles. -2. **Parametric S-Bends:** When the search needs to bridge a lateral gap, it triggers a "Procedural Move." It calculates a fixed-radius S-bend on-the-fly. If the resulting arc is valid and collision-free, it is added as an edge in the search graph. -3. **Predictable Costing:** It allows for a sophisticated cost function $f(n) = g(n) + h(n)$ where: - * $g(n)$ penalizes path length and proximity to obstacles (using a distance-transform field). - * $h(n)$ (the heuristic) guides the search toward the destination while favoring Manhattan alignments. - -### 3.2. Implementation Strategy -* **Step 1: Distance Transform.** Pre-calculate a "Danger Map" of the layout. Cells close to obstacles have a high cost; cells far away have low cost. This handles the **Proximity Sensitivity** constraint. -* **Step 2: State Expansion.** From the current point, explore: - * `Straight(length)` - * `PDK_Bend_90(direction)` - * `S_Bend(target_offset, R)` -* **Step 3: Rip-up and Reroute.** To handle the sequential nature of A*, implement a "Negotiated Congestion" scheme (PathFinder algorithm) where nets "pay" more to occupy areas that other nets also want. - ---- - -## 4. Summary of Tradeoffs for Future Review -* **Why not pure Manhattan?** Photonic/RF requirements for large $R$ and S-bends make standard grid-based maze routers obsolete. -* **Why not any-angle?** While any-angle is possible, the PDK's reliance on pre-rendered 90° blocks makes a lattice-based approach more manufacturing-stable. -* **Risk:** The primary risk is **Search Time**. As the library of moves grows (more S-bend options), the branching factor increases. This must be managed with aggressive pruning and spatial indexing (e.g., R-trees). diff --git a/docs/plans/routing_search_spec.md b/docs/plans/routing_search_spec.md deleted file mode 100644 index 53d00ef..0000000 --- a/docs/plans/routing_search_spec.md +++ /dev/null @@ -1,66 +0,0 @@ -# Routing Search Specification - -This document details the Hybrid State-Lattice A* implementation and the "PathFinder" (Negotiated Congestion) algorithm for multi-net routing. - -## 1. Hybrid State-Lattice A* Search -The core router operates on states $S = (x, y, \theta)$, where $\theta \in \{0, 90, 180, 270\}$. - -### 1.1. State Representation & Snapping -To ensure search stability and hash-map performance: -* **Intermediate Ports:** Every state expanded by A* is snapped to a search grid. -* **Search Grid:** Default snap is **1000nm (1µm)**. -* **Final Alignment:** The "Snap-to-Target" logic bridges the gap from the coarse search grid to the final **1nm** port coordinates. -* **Max Design Size:** Guidelines for memory/performance assume up to **20mm x 20mm** routing area. - -### 1.2. Move Expansion Logic -From state $S_n$, the following moves are evaluated: -1. **Tiered Straight Steps:** Expand by a set of distances $L \in \{1\mu m, 5\mu m, 25\mu m\}$. -2. **Snap-to-Target:** A "last-inch" look-ahead move. If the target $T$ is within a **Capture Radius (Default: 10µm)** and a straight segment or single bend can reach it, a special move is generated to close the gap exactly at 1nm resolution. -3. **90° Bend:** Try clockwise/counter-clockwise turns using fixed PDK cells. -4. **Small-Offset S-Bend:** - * **Only for $O < 2R$:** Parametric S-bend (two tangent arcs). - * **$O \ge 2R$:** Search naturally finds these by combining 90° bends and straight segments. - -### 1.3. Cost Function $f(n) = g(n) + h(n)$ -The search uses a flexible, component-based cost model. - -* **$g(n)$ (Actual Cost):** $\sum \text{ComponentCost}_i + \text{ProximityCost} + \text{CongestionCost}$ - * **Straight Segment:** $L \times C_{unit\_length}$. - * **90° Bend:** $10 \times (\text{Manhattan distance between ports})$. - * **S-Bend:** $f(O, R)$. - * **Proximity Cost:** $k/d^2$ penalty (strict checks use R-Tree). - * **Congestion Cost:** $P \times (\text{Overlaps in Path R-Tree})$. -* **$h(n)$ (Heuristic):** - * Manhattan distance $L_1$ to the target. - * Orientation Penalty: High cost if the state's orientation doesn't match the target's entry orientation. - * **Greedy Weighting:** The A* search uses a weighted heuristic (e.g., $1.1 \times h(n)$) to prioritize search speed over strict path optimality. - * **Danger Map Heuristic:** Fast lookups from the **1µm** pre-computed proximity grid. - -## 2. Multi-Net "PathFinder" Strategy (Negotiated Congestion) -1. **Iteration:** Identify "Congestion Areas" using Path R-Tree intersections. -2. **Inflation:** Increase penalty multiplier $P$ for congested areas. -3. **Repeat:** Continue until no overlaps exist or the max iteration count is reached (Default: **20 iterations**). - -### 2.1. Convergence & Result Policy -* **Least Bad Attempt:** If no 100% collision-free solution is found, return the result with the lowest total cost (including overlaps). -* **Explicit Reporting:** Results MUST include a `RoutingResult` object containing: - * `path_geometry`: The actual polygon sequence. - * `is_valid`: Boolean (True only if no collisions). - * `collisions`: A count or list of detected overlap points/polygons. -* **Visualization:** Overlapping regions are highlighted (e.g., in red) in the heatmaps. - -## 3. Search Limits & Scaling -* **Node Limit:** A* search is capped at **50,000 nodes** per net per iteration. -* **Dynamic Timeout:** Session-level timeout based on problem size: - * `Timeout = max(2s, 0.05s * num_nets * num_iterations)`. - * *Example:* A 100-net problem over 20 iterations times out at **100 seconds**. - -## 4. Determinism -All search and rip-up operations are strictly deterministic. -* **Seed:** A user-provided `seed` (int) MUST be used to initialize any random number generators (e.g., if used for tie-breaking or initial net ordering). -* **Tie-Breaking:** If two nodes have the same $f(n)$, a consistent tie-breaking rule (e.g., based on node insertion order or state hash) must be used. - -## 5. Optimizations -* **A* Pruning:** Head toward the target and prune suboptimal orientations. -* **Safety Zones:** Ignore collisions within **2nm** of start/end ports. -* **Spatial Indexing:** R-Tree queries are limited to the bounding box of the current move. diff --git a/docs/plans/testing_plan.md b/docs/plans/testing_plan.md deleted file mode 100644 index 0a45c9b..0000000 --- a/docs/plans/testing_plan.md +++ /dev/null @@ -1,138 +0,0 @@ -# Testing Plan (Refined) - -This document defines the comprehensive testing strategy for the `inire` auto-router, ensuring analytic correctness and performance. - -## 1. Unit Tests: Geometry & Components (`inire/geometry`) - -### 1.1. Primitives (`primitives.py`) -* **Port Snapping:** Verify that `Port(x, y, orientation)` snaps `x` and `y` to the nearest **1nm** grid point. -* **Coordinate Transforms:** - * Translate a port by `(dx, dy)`. - * Rotate a port by `90`, `180`, `270` degrees around an origin. - * Verify orientation wrapping (e.g., $270^\circ + 90^\circ \to 0^\circ$). -* **Polygon Creation:** - * Generate a polygon from a list of points. - * Verify bounding box calculation. -* **Property-Based Testing (Hypothesis):** - * Verify that any `Port` after transformation remains snapped to the **1nm** grid. - * Verify that $Rotate(Rotate(Port, 90), -90)$ returns the original `Port` (up to snapping). - -### 1.2. Component Generation (`components.py`) -* **Straight Moves:** - * Generate a `Straight(length=10.0, width=2.0)` segment. - * Verify the end port is exactly `length` away in the correct orientation. - * Verify the resulting polygon's dimensions. - * **Edge Case:** $L < 1\mu m$ (below search grid). Verify it still generates a valid 1nm segment. -* **Bend90:** - * Generate a `Bend90(radius=10.0, width=2.0, direction='CW')`. - * Verify the end port's orientation is changed by $-90^\circ$. - * Verify the end port's position is exactly `(R, -R)` relative to the start (for $0^\circ \to 270^\circ$). - * **Grid Snapping:** Verify that if the bend radius results in a non-1µm aligned port, it is snapped to the nearest **1µm** search grid point (with a warning). -* **SBend (Small Offset $O < 2R$):** - * Generate an `SBend(offset=5.0, radius=10.0, width=2.0)`. - * Verify the total length matches the analytical $L = 2\sqrt{O(2R - O/4)}$ (or equivalent arc-based formula). - * Verify the tangent continuity at the junction of the two arcs. - * **Edge Case:** $O = 2R$. Verify it either generates two 90-degree bends or fails gracefully with a clear error. - * Verify it fails/warns if $O > 2R$. - -### 1.3. Geometric Fidelity -* **Arc Resolution (Sagitta):** - * Verify that `Bend90` and `SBend` polygons are approximated by segments such that the maximum deviation (sagitta) is within a user-defined tolerance (e.g., 10nm). - * Test with varying radii to ensure segment count scales appropriately. - -## 2. Unit Tests: Collision & Cost (`inire/geometry/collision` & `router/cost`) - -### 2.1. Collision Engine -* **Pre-dilation Logic:** - * Verify that an obstacle (polygon) is correctly dilated by $(W_{max} + C)/2$. - * **Heterogeneous Widths:** Verify that a path for Net A (width $W_1$) is dilated by $(W_1 + C)/2$, while Net B (width $W_2$) uses $(W_2 + C)/2$. -* **Locked Paths:** - * Insert an existing path geometry into the "Static Obstacle" R-Tree. - * Verify that the router treats it as an unmovable obstacle and avoids it. -* **R-Tree Queries:** - * Test intersection detection between two overlapping polygons. - * Test non-intersection between adjacent but non-overlapping polygons (exactly $C$ distance apart). -* **Safety Zone (2nm):** - * Create a port exactly on the edge of an obstacle. - * Verify that a "Move" starting from this port is NOT flagged for collision if the intersection occurs within **2nm** of the port. -* **Self-Intersection:** - * Verify that a path consisting of multiple segments is flagged if it loops back on itself. - -### 2.2. Danger Map & Cost Evaluator -* **Danger Map Generation:** - * Initialize a map for a $100\mu m \times 100\mu m$ area with a single obstacle. - * Verify the cost $g_{proximity}$ matches $k/d^2$ for cells near the obstacle. - * Verify cost is $0$ for cells beyond the **Safety Threshold**. -* **Memory Check:** - * Mock a $20mm \times 20mm$ grid and verify memory allocation stays within limits (e.g., `< 2GB` for standard `uint8` resolution). -* **Cost Calculation:** - * Verify total cost $f(n)$ correctly sums length, bend penalties ($10 \times$ Manhattan), and proximity costs. - -### 2.3. Robustness & Limits -* **Design Bounds:** - * Test routing at the extreme edges of the $20mm \times 20mm$ coordinate space. - * Verify that moves extending outside the design bounds are correctly pruned or flagged. -* **Empty/Invalid Inputs:** - * Test with an empty netlist. - * Test with start and end ports at the exact same location. - -## 3. Integration Tests: Single-Net A* Search (`inire/router/astar`) - -### 3.1. Open Space Scenarios -* **Straight Line:** Route from `(0,0,0)` to `(100,0,0)`. Verify it uses only `Straight` moves. -* **Simple Turn:** Route from `(0,0,0)` to `(20,20,90)`. Verify it uses a `Bend90` and `Straight` segments. -* **Small S-Bend:** Route with an offset of $5\mu m$ and radius $10\mu m$. Verify it uses the `SBend` component. -* **Large S-Bend ($O \ge 2R$):** Route with an offset of $50\mu m$ and radius $10\mu m$. Verify it combines two `Bend90`s and a `Straight` segment. - -### 3.2. Obstacle Avoidance (The "Maze" Tests) -* **L-Obstacle:** Place an obstacle blocking the direct path. Verify the router goes around it. -* **Narrow Channel:** Create two obstacles with a gap slightly wider than $W_i + C$. Verify the router passes through. -* **Dead End:** Create a U-shaped obstacle. Verify the search explores alternatives and fails gracefully if no path exists. - -### 3.3. Snapping & Precision -* **Snap-to-Target Lookahead:** - * Route to a target at `(100.005, 0, 0)` (not on 1µm grid). - * Verify the search reaches the vicinity via the 1µm grid and the final segment bridges the **5nm** gap exactly. -* **Grid Alignment:** - * Start from a port at `(0.5, 0.5, 0)`. Verify it snaps to the 1µm search grid correctly for the first move expansion. - -### 3.4. Failure Modes -* **Unreachable Target:** Create a target completely enclosed by obstacles. Verify the search terminates after exploring all options (or hitting the 50,000 node limit) and returns an invalid result. -* **Start/End Collision:** Place a port deep inside an obstacle (beyond the 2nm safety zone). Verify the router identifies the immediate collision and fails gracefully. - -## 4. Integration Tests: Multi-Net PathFinder (`inire/router/pathfinder`) - -### 4.1. Congestion Scenarios -* **Parallel Paths:** Route two nets that can both take straight paths. Verify no reroutes occur. -* **The "Cross" Test:** Two nets must cross paths in 2D. - * Since crossings are illegal, verify the second net finds a detour. - * Verify the `Negotiated Congestion` loop increases the cost of the shared region. -* **Bottleneck:** Force 3 nets through a channel that only fits 2. - * Verify the router returns 2 valid paths and 1 "least bad" path (with collisions flagged). - * Verify the `is_valid=False` attribute is set for the failing net. - -### 4.2. Determinism & Performance -* **Seed Consistency:** Run the same multi-net problem twice with the same seed; verify identical results (pixel-perfect). -* **Node Limit Enforcement:** Trigger a complex search that exceeds **50,000 nodes**. Verify it terminates and returns the best-so-far or failure. -* **Timeout:** Verify the session-level timeout stops the process for extremely large problems. - -## 5. Benchmarking & Regression -* **Standard Benchmark Suite:** A set of 5-10 layouts with varying net counts (1 to 50). -* **Metrics to Track:** - * Total wire length. - * Total number of bends. - * Execution time per net. - * Success rate (percentage of nets routed without collisions). - * **Node Expansion Rate:** Nodes per second. - * **Memory Usage:** Peak RSS during 20x20mm routing. -* **Fuzz Testing:** - * Generate random obstacles and ports within a 1mm x 1mm area. - * Verify that the router never crashes. - * Verify that every result marked `is_valid=True` is confirmed collision-free by a high-precision (slow) check. - -## 6. Analytic Correctness Guarantees -* **Post-Route Validation:** - * Implement an independent `validate_path(path, obstacles, clearance)` function using `shapely`'s most precise intersection tests. - * Run this on every test result to ensure the `CollisionEngine` (which uses R-Tree for speed) hasn't missed any edge cases. -* **Orientation Check:** - * Verify that the final port of every path matches the target orientation exactly $\{0, 90, 180, 270\}$. diff --git a/examples/README.md b/examples/README.md index a079c07..4040cd6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,7 +35,5 @@ Demonstrates the router's ability to handle complex orientation requirements, in ![Orientation Stress Test](05_orientation_stress.png) -## 5. Tiered Fidelity & Lazy Dilation -Our architecture leverages two key optimizations for high-performance routing: -1. **Tiered Fidelity**: Initial routing passes use fast `clipped_bbox` proxies. If collisions are found, the system automatically escalates to high-fidelity `arc` geometry for the affected regions. -2. **Lazy Dilation**: Geometric buffering (dilation) is deferred until a collision check is strictly necessary, avoiding thousands of redundant `buffer()` and `translate()` calls. +## 5. Tiered Fidelity +The current implementation can use a cheaper bend proxy on the first negotiated-congestion pass before later passes fall back to the configured bend model. This is controlled by `RoutingOptions.congestion.use_tiered_strategy` together with the bend collision settings described in `DOCS.md`. diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 02aaede..acc7398 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -37,6 +37,7 @@ class RoutingWorld: "clearance", "safety_zone_radius", "grid_cell_size", + "metrics", "_dynamic_paths", "_static_obstacles", ) @@ -50,6 +51,7 @@ class RoutingWorld: self.safety_zone_radius = safety_zone_radius self.grid_cell_size = 50.0 + self.metrics = None self._static_obstacles = StaticObstacleIndex(self) self._dynamic_paths = DynamicPathIndex(self) @@ -265,6 +267,8 @@ class RoutingWorld: found_real = False for index in range(len(sub_tree_indices)): + if self.metrics is not None: + self.metrics.total_congestion_exact_pair_checks += 1 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: @@ -277,6 +281,8 @@ class RoutingWorld: return real_hits_count def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: + if self.metrics is not None: + self.metrics.total_congestion_check_calls += 1 dynamic_paths = self._dynamic_paths if not dynamic_paths.geometries: return 0 @@ -316,6 +322,8 @@ class RoutingWorld: return self._check_real_congestion(result, net_id) def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: + if self.metrics is not None: + self.metrics.total_verify_path_report_calls += 1 static_collision_count = 0 dynamic_collision_count = 0 self_collision_count = 0 @@ -329,6 +337,8 @@ class RoutingWorld: raw_geometries = static_obstacles.raw_tree.geometries for component in components: for polygon in component.physical_geometry: + if self.metrics is not None: + self.metrics.total_verify_static_buffer_ops += 1 buffered = polygon.buffer(self.clearance, join_style="mitre") hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") for hit_idx in hits: @@ -355,6 +365,8 @@ class RoutingWorld: if hit_net_ids[index] == str(net_id): continue + if self.metrics is not None: + self.metrics.total_verify_dynamic_exact_pair_checks += 1 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: @@ -382,6 +394,8 @@ class RoutingWorld: max_dist: float = 2000.0, net_width: float | None = None, ) -> float: + if self.metrics is not None: + self.metrics.total_ray_cast_calls += 1 static_obstacles = self._static_obstacles tree: STRtree | None is_rect_array: numpy.ndarray | None @@ -410,6 +424,8 @@ class RoutingWorld: candidates = tree.query(box(min_x, min_y, max_x, max_y)) if candidates.size == 0: return max_dist + if self.metrics is not None: + self.metrics.total_ray_cast_candidate_bounds += int(candidates.size) min_dist = max_dist inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 @@ -457,6 +473,8 @@ class RoutingWorld: ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) obstacle = tree_geometries[candidate_id] + if self.metrics is not None: + self.metrics.total_ray_cast_exact_geometry_checks += 1 if not obstacle.intersects(ray_line): continue diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py index d8363f6..0985f53 100644 --- a/inire/geometry/dynamic_path_index.py +++ b/inire/geometry/dynamic_path_index.py @@ -48,6 +48,8 @@ class DynamicPathIndex: def ensure_tree(self) -> None: if self.tree is None and self.dilated: + if self.engine.metrics is not None: + self.engine.metrics.total_dynamic_tree_rebuilds += 1 ids, geometries, bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) self.obj_ids = numpy.array(ids, dtype=numpy.int32) @@ -59,6 +61,8 @@ class DynamicPathIndex: if self.grid or not self.dilated: return + if self.engine.metrics is not None: + self.engine.metrics.total_dynamic_grid_rebuilds += 1 cell_size = self.engine.grid_cell_size for obj_id, polygon in self.dilated.items(): for cell in iter_grid_cells(polygon.bounds, cell_size): @@ -66,6 +70,8 @@ class DynamicPathIndex: def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self.invalidate_queries() + if self.engine.metrics is not None: + self.engine.metrics.total_dynamic_path_objects_added += len(geometry) for index, polygon in enumerate(geometry): obj_id = self.id_counter self.id_counter += 1 @@ -83,6 +89,8 @@ class DynamicPathIndex: return self.invalidate_queries() + if self.engine.metrics is not None: + self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids) for obj_id in obj_ids: self.index.delete(obj_id, self.dilated[obj_id].bounds) del self.geometries[obj_id] diff --git a/inire/geometry/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py index 32cffba..d0b4ad6 100644 --- a/inire/geometry/static_obstacle_index.py +++ b/inire/geometry/static_obstacle_index.py @@ -93,6 +93,8 @@ class StaticObstacleIndex: def ensure_tree(self) -> None: if self.tree is None and self.dilated: + if self.engine.metrics is not None: + self.engine.metrics.total_static_tree_rebuilds += 1 self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids]) @@ -102,6 +104,8 @@ class StaticObstacleIndex: if key in self.net_specific_trees: return self.net_specific_trees[key] + if self.engine.metrics is not None: + self.engine.metrics.total_static_net_tree_rebuilds += 1 total_dilation = net_width / 2.0 + self.engine.clearance geometries = [] is_rect_list = [] @@ -122,5 +126,7 @@ class StaticObstacleIndex: def ensure_raw_tree(self) -> None: if self.raw_tree is None and self.geometries: + if self.engine.metrics is not None: + self.engine.metrics.total_static_raw_tree_rebuilds += 1 self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries) self.raw_tree = STRtree(geometries) diff --git a/inire/results.py b/inire/results.py index e9d178b..0ac5a29 100644 --- a/inire/results.py +++ b/inire/results.py @@ -38,6 +38,43 @@ class RouteMetrics: pruned_closed_set: int pruned_hard_collision: int pruned_cost: int + route_iterations: int + nets_routed: int + nets_reached_target: int + warm_start_paths_built: int + warm_start_paths_used: int + refine_path_calls: int + timeout_events: int + move_cache_abs_hits: int + move_cache_abs_misses: int + move_cache_rel_hits: int + move_cache_rel_misses: int + static_safe_cache_hits: int + hard_collision_cache_hits: int + congestion_cache_hits: int + congestion_cache_misses: int + dynamic_path_objects_added: int + dynamic_path_objects_removed: int + dynamic_tree_rebuilds: int + dynamic_grid_rebuilds: int + static_tree_rebuilds: int + static_raw_tree_rebuilds: int + static_net_tree_rebuilds: int + visibility_builds: int + visibility_corner_pairs_checked: int + visibility_corner_queries: int + visibility_corner_hits: int + visibility_point_queries: int + visibility_point_cache_hits: int + visibility_point_cache_misses: int + ray_cast_calls: int + ray_cast_candidate_bounds: int + ray_cast_exact_geometry_checks: int + congestion_check_calls: int + congestion_exact_pair_checks: int + verify_path_report_calls: int + verify_static_buffer_ops: int + verify_dynamic_exact_pair_checks: int @dataclass(frozen=True, slots=True) diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index d10a755..088b526 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -47,8 +47,10 @@ def process_move( self_dilation, ) if abs_key in context.move_cache_abs: + context.metrics.total_move_cache_abs_hits += 1 res = context.move_cache_abs[abs_key] else: + context.metrics.total_move_cache_abs_misses += 1 context.check_cache_eviction() base_port = Port(0, 0, cp.r) rel_key = ( @@ -61,8 +63,10 @@ def process_move( self_dilation, ) if rel_key in context.move_cache_rel: + context.metrics.total_move_cache_rel_hits += 1 res_rel = context.move_cache_rel[rel_key] else: + context.metrics.total_move_cache_rel_misses += 1 try: if move_class == "straight": res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation) @@ -139,11 +143,14 @@ def add_node( end_p = result.end_port if cache_key in context.hard_collision_set: + context.metrics.total_hard_collision_cache_hits += 1 metrics.pruned_hard_collision += 1 metrics.total_pruned_hard_collision += 1 return is_static_safe = cache_key in context.static_safe_cache + if is_static_safe: + context.metrics.total_static_safe_cache_hits += 1 if not is_static_safe: ce = context.cost_evaluator.collision_engine if move_type == "straight": @@ -160,8 +167,10 @@ def add_node( total_overlaps = 0 if not config.skip_congestion: if cache_key in congestion_cache: + context.metrics.total_congestion_cache_hits += 1 total_overlaps = congestion_cache[cache_key] else: + context.metrics.total_congestion_cache_misses += 1 total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) congestion_cache[cache_key] = total_overlaps diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index 96f9aa9..0092cbd 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -87,6 +87,43 @@ class AStarMetrics: "total_pruned_closed_set", "total_pruned_hard_collision", "total_pruned_cost", + "total_route_iterations", + "total_nets_routed", + "total_nets_reached_target", + "total_warm_start_paths_built", + "total_warm_start_paths_used", + "total_refine_path_calls", + "total_timeout_events", + "total_move_cache_abs_hits", + "total_move_cache_abs_misses", + "total_move_cache_rel_hits", + "total_move_cache_rel_misses", + "total_static_safe_cache_hits", + "total_hard_collision_cache_hits", + "total_congestion_cache_hits", + "total_congestion_cache_misses", + "total_dynamic_path_objects_added", + "total_dynamic_path_objects_removed", + "total_dynamic_tree_rebuilds", + "total_dynamic_grid_rebuilds", + "total_static_tree_rebuilds", + "total_static_raw_tree_rebuilds", + "total_static_net_tree_rebuilds", + "total_visibility_builds", + "total_visibility_corner_pairs_checked", + "total_visibility_corner_queries", + "total_visibility_corner_hits", + "total_visibility_point_queries", + "total_visibility_point_cache_hits", + "total_visibility_point_cache_misses", + "total_ray_cast_calls", + "total_ray_cast_candidate_bounds", + "total_ray_cast_exact_geometry_checks", + "total_congestion_check_calls", + "total_congestion_exact_pair_checks", + "total_verify_path_report_calls", + "total_verify_static_buffer_ops", + "total_verify_dynamic_exact_pair_checks", "last_expanded_nodes", "nodes_expanded", "moves_generated", @@ -103,6 +140,43 @@ class AStarMetrics: self.total_pruned_closed_set = 0 self.total_pruned_hard_collision = 0 self.total_pruned_cost = 0 + self.total_route_iterations = 0 + self.total_nets_routed = 0 + self.total_nets_reached_target = 0 + self.total_warm_start_paths_built = 0 + self.total_warm_start_paths_used = 0 + self.total_refine_path_calls = 0 + self.total_timeout_events = 0 + self.total_move_cache_abs_hits = 0 + self.total_move_cache_abs_misses = 0 + self.total_move_cache_rel_hits = 0 + self.total_move_cache_rel_misses = 0 + self.total_static_safe_cache_hits = 0 + self.total_hard_collision_cache_hits = 0 + self.total_congestion_cache_hits = 0 + self.total_congestion_cache_misses = 0 + self.total_dynamic_path_objects_added = 0 + self.total_dynamic_path_objects_removed = 0 + self.total_dynamic_tree_rebuilds = 0 + self.total_dynamic_grid_rebuilds = 0 + self.total_static_tree_rebuilds = 0 + self.total_static_raw_tree_rebuilds = 0 + self.total_static_net_tree_rebuilds = 0 + self.total_visibility_builds = 0 + self.total_visibility_corner_pairs_checked = 0 + self.total_visibility_corner_queries = 0 + self.total_visibility_corner_hits = 0 + self.total_visibility_point_queries = 0 + self.total_visibility_point_cache_hits = 0 + self.total_visibility_point_cache_misses = 0 + self.total_ray_cast_calls = 0 + self.total_ray_cast_candidate_bounds = 0 + self.total_ray_cast_exact_geometry_checks = 0 + self.total_congestion_check_calls = 0 + self.total_congestion_exact_pair_checks = 0 + self.total_verify_path_report_calls = 0 + self.total_verify_static_buffer_ops = 0 + self.total_verify_dynamic_exact_pair_checks = 0 self.last_expanded_nodes: list[tuple[int, int, int]] = [] self.nodes_expanded = 0 self.moves_generated = 0 @@ -118,6 +192,43 @@ class AStarMetrics: self.total_pruned_closed_set = 0 self.total_pruned_hard_collision = 0 self.total_pruned_cost = 0 + self.total_route_iterations = 0 + self.total_nets_routed = 0 + self.total_nets_reached_target = 0 + self.total_warm_start_paths_built = 0 + self.total_warm_start_paths_used = 0 + self.total_refine_path_calls = 0 + self.total_timeout_events = 0 + self.total_move_cache_abs_hits = 0 + self.total_move_cache_abs_misses = 0 + self.total_move_cache_rel_hits = 0 + self.total_move_cache_rel_misses = 0 + self.total_static_safe_cache_hits = 0 + self.total_hard_collision_cache_hits = 0 + self.total_congestion_cache_hits = 0 + self.total_congestion_cache_misses = 0 + self.total_dynamic_path_objects_added = 0 + self.total_dynamic_path_objects_removed = 0 + self.total_dynamic_tree_rebuilds = 0 + self.total_dynamic_grid_rebuilds = 0 + self.total_static_tree_rebuilds = 0 + self.total_static_raw_tree_rebuilds = 0 + self.total_static_net_tree_rebuilds = 0 + self.total_visibility_builds = 0 + self.total_visibility_corner_pairs_checked = 0 + self.total_visibility_corner_queries = 0 + self.total_visibility_corner_hits = 0 + self.total_visibility_point_queries = 0 + self.total_visibility_point_cache_hits = 0 + self.total_visibility_point_cache_misses = 0 + self.total_ray_cast_calls = 0 + self.total_ray_cast_candidate_bounds = 0 + self.total_ray_cast_exact_geometry_checks = 0 + self.total_congestion_check_calls = 0 + self.total_congestion_exact_pair_checks = 0 + self.total_verify_path_report_calls = 0 + self.total_verify_static_buffer_ops = 0 + self.total_verify_dynamic_exact_pair_checks = 0 def reset_per_route(self) -> None: self.nodes_expanded = 0 @@ -136,12 +247,50 @@ class AStarMetrics: pruned_closed_set=self.total_pruned_closed_set, pruned_hard_collision=self.total_pruned_hard_collision, pruned_cost=self.total_pruned_cost, + route_iterations=self.total_route_iterations, + nets_routed=self.total_nets_routed, + nets_reached_target=self.total_nets_reached_target, + warm_start_paths_built=self.total_warm_start_paths_built, + warm_start_paths_used=self.total_warm_start_paths_used, + refine_path_calls=self.total_refine_path_calls, + timeout_events=self.total_timeout_events, + move_cache_abs_hits=self.total_move_cache_abs_hits, + move_cache_abs_misses=self.total_move_cache_abs_misses, + move_cache_rel_hits=self.total_move_cache_rel_hits, + move_cache_rel_misses=self.total_move_cache_rel_misses, + static_safe_cache_hits=self.total_static_safe_cache_hits, + hard_collision_cache_hits=self.total_hard_collision_cache_hits, + congestion_cache_hits=self.total_congestion_cache_hits, + congestion_cache_misses=self.total_congestion_cache_misses, + dynamic_path_objects_added=self.total_dynamic_path_objects_added, + dynamic_path_objects_removed=self.total_dynamic_path_objects_removed, + dynamic_tree_rebuilds=self.total_dynamic_tree_rebuilds, + dynamic_grid_rebuilds=self.total_dynamic_grid_rebuilds, + static_tree_rebuilds=self.total_static_tree_rebuilds, + static_raw_tree_rebuilds=self.total_static_raw_tree_rebuilds, + static_net_tree_rebuilds=self.total_static_net_tree_rebuilds, + visibility_builds=self.total_visibility_builds, + visibility_corner_pairs_checked=self.total_visibility_corner_pairs_checked, + visibility_corner_queries=self.total_visibility_corner_queries, + visibility_corner_hits=self.total_visibility_corner_hits, + visibility_point_queries=self.total_visibility_point_queries, + visibility_point_cache_hits=self.total_visibility_point_cache_hits, + visibility_point_cache_misses=self.total_visibility_point_cache_misses, + ray_cast_calls=self.total_ray_cast_calls, + ray_cast_candidate_bounds=self.total_ray_cast_candidate_bounds, + ray_cast_exact_geometry_checks=self.total_ray_cast_exact_geometry_checks, + congestion_check_calls=self.total_congestion_check_calls, + congestion_exact_pair_checks=self.total_congestion_exact_pair_checks, + verify_path_report_calls=self.total_verify_path_report_calls, + verify_static_buffer_ops=self.total_verify_static_buffer_ops, + verify_dynamic_exact_pair_checks=self.total_verify_dynamic_exact_pair_checks, ) class AStarContext: __slots__ = ( "cost_evaluator", + "metrics", "congestion_penalty", "min_bend_radius", "problem", @@ -160,9 +309,11 @@ class AStarContext: cost_evaluator: CostEvaluator, problem: RoutingProblem, options: RoutingOptions, + metrics: AStarMetrics | None = None, max_cache_size: int = 1000000, ) -> None: self.cost_evaluator = cost_evaluator + self.metrics = metrics if metrics is not None else AStarMetrics() self.congestion_penalty = 0.0 self.max_cache_size = max_cache_size self.problem = problem diff --git a/inire/router/_router.py b/inire/router/_router.py index a52d8d6..801e1b8 100644 --- a/inire/router/_router.py +++ b/inire/router/_router.py @@ -45,7 +45,9 @@ class PathFinder: metrics: AStarMetrics | None = None, ) -> None: self.context = context - self.metrics = metrics if metrics is not None else AStarMetrics() + self.metrics = self.context.metrics if metrics is None else metrics + self.context.metrics = self.metrics + self.context.cost_evaluator.collision_engine.metrics = self.metrics self.refiner = PathRefiner(self.context) self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] @@ -106,6 +108,7 @@ class PathFinder: ) if not path: continue + self.metrics.total_warm_start_paths_built += 1 greedy_paths[net_id] = tuple(path) for result in path: for polygon in result.physical_geometry: @@ -170,9 +173,11 @@ class PathFinder: congestion = self.context.options.congestion diagnostics = self.context.options.diagnostics net = state.net_specs[net_id] + self.metrics.total_nets_routed += 1 self.context.cost_evaluator.collision_engine.remove_path(net_id) if iteration == 0 and state.initial_paths and net_id in state.initial_paths: + self.metrics.total_warm_start_paths_used += 1 path: Sequence[ComponentResult] | None = state.initial_paths[net_id] else: coll_model, _ = resolve_bend_geometry(search) @@ -208,6 +213,8 @@ class PathFinder: return RoutingResult(net_id=net_id, path=(), reached_target=False) reached_target = path[-1].end_port == net.target + if reached_target: + self.metrics.total_nets_reached_target += 1 report = None self._install_path(net_id, path) if reached_target: @@ -230,6 +237,7 @@ class PathFinder: ) -> dict[str, RoutingOutcome] | None: outcomes: dict[str, RoutingOutcome] = {} congestion = self.context.options.congestion + self.metrics.total_route_iterations += 1 self.metrics.reset_per_route() if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): @@ -238,6 +246,7 @@ class PathFinder: for net_id in state.ordered_net_ids: if time.monotonic() - state.start_time > state.timeout_s: + self.metrics.total_timeout_events += 1 return None result = self._route_net_once(state, iteration, net_id) @@ -272,6 +281,7 @@ class PathFinder: if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}: continue net = state.net_specs[net_id] + self.metrics.total_refine_path_calls += 1 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) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index d149f42..4fc51ed 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -45,6 +45,8 @@ class VisibilityManager: """ Extract corners and pre-compute corner-to-corner visibility. """ + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_builds += 1 self._built_static_version = self.collision_engine.get_static_version() raw_corners = [] for poly in self.collision_engine.iter_static_dilated_geometries(): @@ -85,6 +87,8 @@ class VisibilityManager: for j in range(num_corners): if i == j: continue + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_corner_pairs_checked += 1 cx, cy = self.corners[j] dx, dy = cx - p1.x, cy - p1.y dist = numpy.sqrt(dx**2 + dy**2) @@ -107,6 +111,8 @@ class VisibilityManager: Find visible corners from an arbitrary point. This may perform direct ray-cast scans and is not intended for hot search paths. """ + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_point_queries += 1 self._ensure_current() if max_dist < 0: return [] @@ -118,7 +124,11 @@ class VisibilityManager: ox, oy = round(origin.x, 3), round(origin.y, 3) cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000))) if cache_key in self._point_visibility_cache: + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_point_cache_hits += 1 return self._point_visibility_cache[cache_key] + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_point_cache_misses += 1 bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist) candidates = list(self.corner_index.intersection(bounds)) @@ -145,11 +155,15 @@ class VisibilityManager: Return precomputed visibility only when the origin is already at a known corner. This avoids the expensive arbitrary-point visibility scan in hot search paths. """ + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_corner_queries += 1 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: + if self.collision_engine.metrics is not None: + self.collision_engine.metrics.total_visibility_corner_hits += 1 return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [] diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index bad876c..02cab01 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from time import perf_counter from collections.abc import Callable @@ -18,6 +19,7 @@ from inire import ( ) from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.results import RouteMetrics from inire.router._astar_types import AStarContext, AStarMetrics from inire.router._router import PathFinder from inire.router.cost import CostEvaluator @@ -31,6 +33,25 @@ _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) ScenarioOutcome = tuple[float, int, int, int] ScenarioRun = Callable[[], ScenarioOutcome] +ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"] + + +@dataclass(frozen=True, slots=True) +class ScenarioSnapshot: + name: str + duration_s: float + total_results: int + valid_results: int + reached_targets: int + metrics: RouteMetrics + + def as_outcome(self) -> ScenarioOutcome: + return ( + self.duration_s, + self.total_results, + self.valid_results, + self.reached_targets, + ) def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: @@ -42,6 +63,32 @@ def _summarize(results: dict[str, RoutingResult], duration_s: float) -> Scenario ) +def _make_snapshot( + name: str, + results: dict[str, RoutingResult], + duration_s: float, + metrics: RouteMetrics, +) -> ScenarioSnapshot: + return ScenarioSnapshot( + name=name, + 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), + metrics=metrics, + ) + + +def _sum_metrics(metrics_list: tuple[RouteMetrics, ...]) -> RouteMetrics: + metric_names = RouteMetrics.__dataclass_fields__ + return RouteMetrics( + **{ + name: sum(getattr(metrics, name) for metrics in metrics_list) + for name in metric_names + } + ) + + def _build_evaluator( bounds: tuple[float, float, float, float], *, @@ -93,13 +140,15 @@ def _build_pathfinder( metrics: AStarMetrics | None = None, **request_kwargs: object, ) -> PathFinder: + resolved_metrics = AStarMetrics() if metrics is None else metrics return PathFinder( AStarContext( evaluator, RoutingProblem(bounds=bounds, nets=nets), _build_options(**request_kwargs), + metrics=resolved_metrics, ), - metrics=metrics, + metrics=resolved_metrics, ) @@ -133,7 +182,7 @@ def _build_routing_stack( return engine, evaluator, metrics, pathfinder -def run_example_01() -> ScenarioOutcome: +def snapshot_example_01() -> ScenarioSnapshot: netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))} widths = {"net1": 2.0} _, _, _, pathfinder = _build_routing_stack( @@ -145,10 +194,14 @@ def run_example_01() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all() t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_01_simple_route", results, t1 - t0, pathfinder.metrics.snapshot()) -def run_example_02() -> ScenarioOutcome: +def run_example_01() -> ScenarioOutcome: + return snapshot_example_01().as_outcome() + + +def snapshot_example_02() -> ScenarioSnapshot: netlist = { "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), @@ -173,10 +226,14 @@ def run_example_02() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all() t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_02_congestion_resolution", results, t1 - t0, pathfinder.metrics.snapshot()) -def run_example_03() -> ScenarioOutcome: +def run_example_02() -> ScenarioOutcome: + return snapshot_example_02().as_outcome() + + +def snapshot_example_03() -> ScenarioSnapshot: netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} widths_a = {"netA": 2.0} engine, evaluator, _, pathfinder = _build_routing_stack( @@ -187,19 +244,26 @@ def run_example_03() -> ScenarioOutcome: ) t0 = perf_counter() results_a = pathfinder.route_all() + metrics_a = pathfinder.metrics.snapshot() for polygon in results_a["netA"].locked_geometry: engine.add_static_obstacle(polygon) - results_b = _build_pathfinder( + pathfinder_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() + ) + results_b = pathfinder_b.route_all() t1 = perf_counter() - return _summarize({**results_a, **results_b}, t1 - t0) + combined_metrics = _sum_metrics((metrics_a, pathfinder_b.metrics.snapshot())) + return _make_snapshot("example_03_locked_paths", {**results_a, **results_b}, t1 - t0, combined_metrics) -def run_example_04() -> ScenarioOutcome: +def run_example_03() -> ScenarioOutcome: + return snapshot_example_03().as_outcome() + + +def snapshot_example_04() -> ScenarioSnapshot: netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), @@ -223,10 +287,14 @@ def run_example_04() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all() t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_04_sbends_and_radii", results, t1 - t0, pathfinder.metrics.snapshot()) -def run_example_05() -> ScenarioOutcome: +def run_example_04() -> ScenarioOutcome: + return snapshot_example_04().as_outcome() + + +def snapshot_example_05() -> ScenarioSnapshot: netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), @@ -243,10 +311,14 @@ def run_example_05() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all() t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_05_orientation_stress", results, t1 - t0, pathfinder.metrics.snapshot()) -def run_example_06() -> ScenarioOutcome: +def run_example_05() -> ScenarioOutcome: + return snapshot_example_05().as_outcome() + + +def snapshot_example_06() -> ScenarioSnapshot: bounds = (-20, -20, 170, 170) obstacles = [ box(40, 110, 60, 130), @@ -282,6 +354,7 @@ def run_example_06() -> ScenarioOutcome: t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} + route_metrics: list[RouteMetrics] = [] for evaluator, netlist, net_widths, request_kwargs in scenarios: pathfinder = _build_pathfinder( evaluator, @@ -290,11 +363,21 @@ def run_example_06() -> ScenarioOutcome: **request_kwargs, ) combined_results.update(pathfinder.route_all()) + route_metrics.append(pathfinder.metrics.snapshot()) t1 = perf_counter() - return _summarize(combined_results, t1 - t0) + return _make_snapshot( + "example_06_bend_collision_models", + combined_results, + t1 - t0, + _sum_metrics(tuple(route_metrics)), + ) -def run_example_07() -> ScenarioOutcome: +def run_example_06() -> ScenarioOutcome: + return snapshot_example_06().as_outcome() + + +def snapshot_example_07() -> ScenarioSnapshot: bounds = (0, 0, 1000, 1000) obstacles = [ box(450, 0, 550, 400), @@ -349,10 +432,14 @@ def run_example_07() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all(iteration_callback=iteration_callback) t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_07_large_scale_routing", results, t1 - t0, pathfinder.metrics.snapshot()) -def run_example_08() -> ScenarioOutcome: +def run_example_07() -> ScenarioOutcome: + return snapshot_example_07().as_outcome() + + +def snapshot_example_08() -> ScenarioSnapshot: bounds = (0, 0, 150, 150) netlist = {"standard_arc": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"standard_arc": 2.0} @@ -360,7 +447,7 @@ def run_example_08() -> ScenarioOutcome: custom_proxy = box(0, -11, 11, 0) t0 = perf_counter() - results_std = _build_pathfinder( + pathfinder_std = _build_pathfinder( _build_evaluator(bounds), bounds=bounds, nets=_net_specs(netlist, widths), @@ -369,8 +456,9 @@ def run_example_08() -> ScenarioOutcome: max_iterations=1, use_tiered_strategy=False, metrics=AStarMetrics(), - ).route_all() - results_custom = _build_pathfinder( + ) + results_std = pathfinder_std.route_all() + pathfinder_custom = _build_pathfinder( _build_evaluator(bounds), bounds=bounds, nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}), @@ -381,12 +469,23 @@ def run_example_08() -> ScenarioOutcome: max_iterations=1, use_tiered_strategy=False, metrics=AStarMetrics(), - ).route_all() + ) + results_custom = pathfinder_custom.route_all() t1 = perf_counter() - return _summarize({**results_std, **results_custom}, t1 - t0) + combined_metrics = _sum_metrics((pathfinder_std.metrics.snapshot(), pathfinder_custom.metrics.snapshot())) + return _make_snapshot( + "example_08_custom_bend_geometry", + {**results_std, **results_custom}, + t1 - t0, + combined_metrics, + ) -def run_example_09() -> ScenarioOutcome: +def run_example_08() -> ScenarioOutcome: + return snapshot_example_08().as_outcome() + + +def snapshot_example_09() -> ScenarioSnapshot: obstacles = [ box(35, 35, 45, 65), box(55, 35, 65, 65), @@ -404,7 +503,11 @@ def run_example_09() -> ScenarioOutcome: t0 = perf_counter() results = pathfinder.route_all() t1 = perf_counter() - return _summarize(results, t1 - t0) + return _make_snapshot("example_09_unroutable_best_effort", results, t1 - t0, pathfinder.metrics.snapshot()) + + +def run_example_09() -> ScenarioOutcome: + return snapshot_example_09().as_outcome() SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( @@ -418,3 +521,19 @@ SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( ("example_08_custom_bend_geometry", run_example_08), ("example_09_unroutable_best_effort", run_example_09), ) + +SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = ( + ("example_01_simple_route", snapshot_example_01), + ("example_02_congestion_resolution", snapshot_example_02), + ("example_03_locked_paths", snapshot_example_03), + ("example_04_sbends_and_radii", snapshot_example_04), + ("example_05_orientation_stress", snapshot_example_05), + ("example_06_bend_collision_models", snapshot_example_06), + ("example_07_large_scale_routing", snapshot_example_07), + ("example_08_custom_bend_geometry", snapshot_example_08), + ("example_09_unroutable_best_effort", snapshot_example_09), +) + + +def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]: + return tuple(run() for _, run in SCENARIO_SNAPSHOTS) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py index 858cac9..d25df09 100644 --- a/inire/tests/test_api.py +++ b/inire/tests/test_api.py @@ -76,6 +76,13 @@ def test_route_problem_supports_configs_and_debug_data() -> None: assert run.results_by_net["net1"].reached_target assert run.expanded_nodes assert run.metrics.nodes_expanded > 0 + assert run.metrics.route_iterations >= 1 + assert run.metrics.nets_routed >= 1 + assert run.metrics.move_cache_abs_misses >= 0 + assert run.metrics.ray_cast_calls >= 0 + assert run.metrics.dynamic_tree_rebuilds >= 0 + assert run.metrics.visibility_builds >= 0 + assert run.metrics.verify_path_report_calls >= 0 def test_route_problem_locked_routes_become_static_obstacles() -> None: diff --git a/inire/tests/test_performance_reporting.py b/inire/tests/test_performance_reporting.py new file mode 100644 index 0000000..ebf7f60 --- /dev/null +++ b/inire/tests/test_performance_reporting.py @@ -0,0 +1,45 @@ +import json +import subprocess +import sys +from pathlib import Path + +from inire.tests.example_scenarios import snapshot_example_01 + + +def test_snapshot_example_01_exposes_metrics() -> None: + snapshot = snapshot_example_01() + + assert snapshot.name == "example_01_simple_route" + assert snapshot.total_results == 1 + assert snapshot.valid_results == 1 + assert snapshot.reached_targets == 1 + assert snapshot.metrics.route_iterations >= 1 + assert snapshot.metrics.nets_routed >= 1 + assert snapshot.metrics.nodes_expanded > 0 + assert snapshot.metrics.move_cache_abs_misses >= 0 + assert snapshot.metrics.ray_cast_calls >= 0 + assert snapshot.metrics.dynamic_tree_rebuilds >= 0 + assert snapshot.metrics.visibility_builds >= 0 + + +def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[2] + script_path = repo_root / "scripts" / "record_performance_baseline.py" + + subprocess.run( + [ + sys.executable, + str(script_path), + "--output-dir", + str(tmp_path), + "--scenario", + "example_01_simple_route", + ], + check=True, + ) + + payload = json.loads((tmp_path / "performance_baseline.json").read_text()) + assert payload["generated_on"] + assert payload["generator"] == "scripts/record_performance_baseline.py" + assert [entry["name"] for entry in payload["scenarios"]] == ["example_01_simple_route"] + assert (tmp_path / "performance.md").exists() diff --git a/scripts/record_performance_baseline.py b/scripts/record_performance_baseline.py new file mode 100644 index 0000000..8d731be --- /dev/null +++ b/scripts/record_performance_baseline.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict +from datetime import date +from pathlib import Path + +from inire.tests.example_scenarios import SCENARIO_SNAPSHOTS + + +SUMMARY_METRICS = ( + "route_iterations", + "nets_routed", + "nodes_expanded", + "ray_cast_calls", + "moves_generated", + "moves_added", + "dynamic_tree_rebuilds", + "visibility_builds", + "congestion_check_calls", + "verify_path_report_calls", +) + + +def _build_payload(selected_scenarios: tuple[str, ...] | None = None) -> dict[str, object]: + allowed = None if selected_scenarios is None else set(selected_scenarios) + snapshots = [] + for name, run in SCENARIO_SNAPSHOTS: + if allowed is not None and name not in allowed: + continue + snapshots.append(run()) + return { + "generated_on": date.today().isoformat(), + "generator": "scripts/record_performance_baseline.py", + "scenarios": [asdict(snapshot) for snapshot in snapshots], + } + + +def _render_markdown(payload: dict[str, object]) -> str: + rows = payload["scenarios"] + lines = [ + "# Performance Baseline", + "", + f"Generated on {payload['generated_on']} by `{payload['generator']}`.", + "", + "The full machine-readable snapshot lives in `docs/performance_baseline.json`.", + "", + "| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |", + "| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |", + ] + for row in rows: + metrics = row["metrics"] + lines.append( + "| " + f"{row['name']} | " + f"{row['duration_s']:.4f} | " + f"{row['total_results']} | " + f"{row['valid_results']} | " + f"{row['reached_targets']} | " + f"{metrics['route_iterations']} | " + f"{metrics['nets_routed']} | " + f"{metrics['nodes_expanded']} | " + f"{metrics['ray_cast_calls']} | " + f"{metrics['moves_generated']} | " + f"{metrics['moves_added']} | " + f"{metrics['dynamic_tree_rebuilds']} | " + f"{metrics['visibility_builds']} | " + f"{metrics['congestion_check_calls']} | " + f"{metrics['verify_path_report_calls']} |" + ) + + lines.extend( + [ + "", + "## Full Counter Set", + "", + "Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.", + "", + "Tracked metric keys:", + "", + ", ".join(rows[0]["metrics"].keys()) if rows else "", + ] + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Record the example-scenario performance baseline.") + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Directory to write performance_baseline.json and performance.md into. Defaults to /docs.", + ) + parser.add_argument( + "--scenario", + action="append", + dest="scenarios", + default=[], + help="Optional scenario name to include. May be passed more than once.", + ) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[1] + docs_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve() + docs_dir.mkdir(exist_ok=True) + + selected = tuple(args.scenarios) if args.scenarios else None + payload = _build_payload(selected) + json_path = docs_dir / "performance_baseline.json" + markdown_path = docs_dir / "performance.md" + + json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + markdown_path.write_text(_render_markdown(payload)) + + if json_path.is_relative_to(repo_root): + print(f"Wrote {json_path.relative_to(repo_root)}") + else: + print(f"Wrote {json_path}") + if markdown_path.is_relative_to(repo_root): + print(f"Wrote {markdown_path.relative_to(repo_root)}") + else: + print(f"Wrote {markdown_path}") + + +if __name__ == "__main__": + main()