From bc218a416b3cb618680d39860faeaea1a9c32f74 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 30 Mar 2026 19:51:37 -0700 Subject: [PATCH] lots more refactoring --- DOCS.md | 37 ++- README.md | 10 +- examples/03_locked_paths.py | 2 +- examples/09_unroutable_best_effort.py | 2 +- inire/__init__.py | 42 ++- inire/api.py | 97 ------- inire/constants.py | 6 - inire/geometry/collision.py | 149 +--------- inire/geometry/component_overlap.py | 53 ++-- inire/geometry/components.py | 6 + inire/geometry/dynamic_path_index.py | 5 - inire/geometry/primitives.py | 8 - inire/model.py | 71 ++--- inire/results.py | 86 ++++++ inire/router/_astar_admission.py | 56 ++-- inire/router/_astar_moves.py | 30 +- inire/router/_astar_types.py | 44 ++- inire/router/_router.py | 222 ++++++-------- inire/router/_search.py | 45 +-- inire/router/_seed_materialization.py | 53 ++++ inire/router/_stack.py | 52 ++++ inire/router/cost.py | 192 +++---------- inire/router/outcomes.py | 27 -- inire/router/refiner.py | 22 +- inire/router/results.py | 78 +---- inire/seeds.py | 48 ++++ inire/tests/benchmark_scaling.py | 68 ----- inire/tests/example_scenarios.py | 117 +++++--- inire/tests/support.py | 162 ----------- inire/tests/test_api.py | 41 ++- inire/tests/test_astar.py | 169 ++++++++--- inire/tests/test_clearance_precision.py | 59 ++-- inire/tests/test_collision.py | 87 ++---- inire/tests/test_congestion.py | 100 ++++--- inire/tests/test_example_performance.py | 27 +- inire/tests/test_failed_net_congestion.py | 32 ++- inire/tests/test_fuzz.py | 30 +- inire/tests/test_pathfinder.py | 336 ++++------------------ inire/tests/test_refinements.py | 36 ++- inire/tests/test_route_behavior.py | 301 +++++++++++++++++++ inire/tests/test_variable_grid.py | 31 +- inire/utils/validation.py | 80 ------ inire/utils/visualization.py | 2 +- 43 files changed, 1430 insertions(+), 1691 deletions(-) delete mode 100644 inire/api.py create mode 100644 inire/results.py create mode 100644 inire/router/_seed_materialization.py create mode 100644 inire/router/_stack.py delete mode 100644 inire/router/outcomes.py create mode 100644 inire/seeds.py delete mode 100644 inire/tests/benchmark_scaling.py delete mode 100644 inire/tests/support.py create mode 100644 inire/tests/test_route_behavior.py delete mode 100644 inire/utils/validation.py diff --git a/DOCS.md b/DOCS.md index fe4c73d..338ded2 100644 --- a/DOCS.md +++ b/DOCS.md @@ -11,9 +11,8 @@ This document describes the current public API for `inire`. - `bounds` - `nets` - `static_obstacles` -- `locked_routes` +- `initial_paths` - `clearance` -- `max_net_width` - `safety_zone_radius` ### `RoutingOptions` @@ -34,21 +33,39 @@ run = route(problem, options=options) If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. -### Incremental routing with `LockedRoute` +The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. -For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem: +Stable example: + +```python +from inire import route, RoutingOptions, RoutingProblem +``` + +Unstable example: + +```python +from inire.router._router import PathFinder +``` + +### Incremental routing with locked geometry + +For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem: ```python run_a = route(problem_a) problem_b = RoutingProblem( bounds=problem_a.bounds, nets=(...), - locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()}, + static_obstacles=run_a.results_by_net["netA"].locked_geometry, ) run_b = route(problem_b) ``` -`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. +`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. + +### Initial paths with `PathSeed` + +Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are materialized with the current width, clearance, and bend collision settings for the run, and partial seeds are retried by normal routing in later iterations. ## 2. Search Options @@ -65,7 +82,6 @@ run_b = route(problem_b) | `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. | | `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. | | `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | -| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. | ## 3. Objective Weights @@ -77,7 +93,6 @@ run_b = route(problem_b) | `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. | | `sbend_penalty` | `500.0` | Flat S-bend penalty. | | `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. | -| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. | ## 4. Congestion Options @@ -89,9 +104,9 @@ run_b = route(problem_b) | `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. | | `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. | | `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. | -| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. | +| `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. | +| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. | | `shuffle_nets` | `False` | Shuffle routing order between iterations. | -| `sort_nets` | `None` | Optional deterministic routing order. | | `seed` | `None` | RNG seed for shuffled routing order. | ## 5. Refinement Options @@ -126,7 +141,7 @@ run_b = route(problem_b) ## 8. Internal Modules -Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`. +Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`. ## 9. Tuning Notes diff --git a/README.md b/README.md index 0da7268..b93ce69 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ if run.results_by_net["net1"].is_valid: print("Successfully routed net1!") ``` -For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`. +For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`. ## Usage Examples @@ -65,6 +65,12 @@ python3 examples/01_simple_route.py Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. +## API Stability + +The stable API lives at the package root and is centered on `route(problem, options=...)`. + +Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. + ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: @@ -76,7 +82,7 @@ For multi-net problems, the negotiated-congestion loop handles rip-up and rerout ## Configuration -`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference. +`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference. ## License diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 124a172..0f60fbb 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -27,7 +27,7 @@ def main() -> None: RoutingProblem( bounds=bounds, nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - locked_routes={"netA": results_a["netA"].as_locked_route()}, + static_obstacles=results_a["netA"].locked_geometry, ), options=options, ).results_by_net diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 3df1caf..0ff297f 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -26,7 +26,7 @@ def main() -> None: bend_penalty=50.0, sbend_penalty=150.0, ), - congestion=CongestionOptions(warm_start=None), + congestion=CongestionOptions(warm_start_enabled=False), ) print("Routing with a deliberately tiny node budget (should return a partial path)...") diff --git a/inire/__init__.py b/inire/__init__.py index 7529ac7..f6ee2d5 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,43 +1,59 @@ """ inire Wave-router """ -from .api import ( +from collections.abc import Callable + +from .geometry.primitives import Port as Port # noqa: PLC0414 +from .model import ( CongestionOptions as CongestionOptions, DiagnosticsOptions as DiagnosticsOptions, - LockedRoute as LockedRoute, NetSpec as NetSpec, ObjectiveWeights as ObjectiveWeights, RefinementOptions as RefinementOptions, RoutingOptions as RoutingOptions, RoutingProblem as RoutingProblem, - RoutingRunResult as RoutingRunResult, SearchOptions as SearchOptions, - route as route, ) # noqa: PLC0414 -from .geometry.primitives import Port as Port # noqa: PLC0414 -from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 -from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414 +from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414 +from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414 __author__ = 'Jan Petykiewicz' __version__ = '0.1' + +def route( + problem: RoutingProblem, + *, + options: RoutingOptions | None = None, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, +) -> RoutingRunResult: + from .router._stack import build_routing_stack + + resolved_options = RoutingOptions() if options is None else options + stack = build_routing_stack(problem, resolved_options) + finder = stack.finder + results = finder.route_all(iteration_callback=iteration_callback) + return RoutingRunResult( + results_by_net=results, + metrics=finder.metrics.snapshot(), + expanded_nodes=tuple(finder.accumulated_expanded_nodes), + ) + __all__ = [ - "Bend90", + "Bend90Seed", "CongestionOptions", "DiagnosticsOptions", - "LockedRoute", "NetSpec", "ObjectiveWeights", + "PathSeed", "Port", "RefinementOptions", "RoutingOptions", "RoutingProblem", - "RoutingReport", "RoutingResult", "RoutingRunResult", - "RouteMetrics", - "SBend", + "SBendSeed", "SearchOptions", - "Straight", + "StraightSeed", "route", ] diff --git a/inire/api.py b/inire/api.py deleted file mode 100644 index 9f83f63..0000000 --- a/inire/api.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from inire.geometry.collision import RoutingWorld -from inire.model import ( - CongestionOptions, - DiagnosticsOptions, - LockedRoute, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - RoutingRunResult, - SearchOptions, -) -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.results import RouteMetrics, RoutingReport, RoutingResult - -if TYPE_CHECKING: - from collections.abc import Callable, Iterable - - from shapely.geometry import Polygon - - -__all__ = [ - "CongestionOptions", - "DiagnosticsOptions", - "LockedRoute", - "NetSpec", - "ObjectiveWeights", - "RefinementOptions", - "RouteMetrics", - "RoutingOptions", - "RoutingProblem", - "RoutingReport", - "RoutingResult", - "RoutingRunResult", - "SearchOptions", - "route", -] - - -def _iter_locked_polygons( - locked_routes: dict[str, LockedRoute], -) -> Iterable[Polygon]: - for route in locked_routes.values(): - yield from route.geometry - - -def _build_context(problem: RoutingProblem, options: RoutingOptions) -> AStarContext: - world = RoutingWorld( - clearance=problem.clearance, - max_net_width=problem.max_net_width, - safety_zone_radius=problem.safety_zone_radius, - ) - for obstacle in problem.static_obstacles: - world.add_static_obstacle(obstacle) - for polygon in _iter_locked_polygons(problem.locked_routes): - world.add_static_obstacle(polygon) - - danger_obstacles = list(problem.static_obstacles) - danger_obstacles.extend(_iter_locked_polygons(problem.locked_routes)) - danger_map = DangerMap(bounds=problem.bounds) - danger_map.precompute(danger_obstacles) - - objective = options.objective - evaluator = CostEvaluator( - world, - danger_map, - unit_length_cost=objective.unit_length_cost, - greedy_h_weight=options.search.greedy_h_weight, - bend_penalty=objective.bend_penalty, - sbend_penalty=objective.sbend_penalty, - danger_weight=objective.danger_weight, - ) - return AStarContext(evaluator, problem, options) - - -def route( - problem: RoutingProblem, - *, - options: RoutingOptions | None = None, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, -) -> RoutingRunResult: - resolved_options = RoutingOptions() if options is None else options - finder = PathFinder(_build_context(problem, resolved_options)) - results = finder.route_all(iteration_callback=iteration_callback) - return RoutingRunResult( - results_by_net=results, - metrics=finder.metrics.snapshot(), - expanded_nodes=tuple(finder.accumulated_expanded_nodes), - ) diff --git a/inire/constants.py b/inire/constants.py index 1e0c0be..bfbebe6 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -2,11 +2,5 @@ Centralized constants for the inire routing engine. """ -# Search Grid Snap (5.0 µm default) -# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid. -DEFAULT_SEARCH_GRID_SNAP_UM = 5.0 - -# Tolerances TOLERANCE_LINEAR = 1e-6 TOLERANCE_ANGULAR = 1e-3 -TOLERANCE_GRID = 1e-6 diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index b8a72fe..5d5b13b 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import numpy from shapely.geometry import LineString, box @@ -8,8 +8,8 @@ from shapely.geometry import LineString, box from inire.geometry.component_overlap import components_overlap from inire.geometry.dynamic_path_index import DynamicPathIndex from inire.geometry.index_helpers import grid_cell_span +from inire.results import RoutingReport from inire.geometry.static_obstacle_index import StaticObstacleIndex -from inire.router.results import RoutingReport if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -35,9 +35,7 @@ class RoutingWorld: __slots__ = ( "clearance", - "max_net_width", "safety_zone_radius", - "metrics", "grid_cell_size", "_dynamic_paths", "_static_obstacles", @@ -46,27 +44,15 @@ class RoutingWorld: def __init__( self, clearance: float, - max_net_width: float = 2.0, safety_zone_radius: float = 0.0021, ) -> None: self.clearance = clearance - self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius self.grid_cell_size = 50.0 self._static_obstacles = StaticObstacleIndex(self) self._dynamic_paths = DynamicPathIndex(self) - self.metrics = { - "static_cache_hits": 0, - "static_grid_skips": 0, - "static_tree_queries": 0, - "static_straight_fast": 0, - "congestion_grid_skips": 0, - "congestion_tree_queries": 0, - "safety_zone_checks": 0, - } - def get_static_version(self) -> int: return self._static_obstacles.version @@ -87,31 +73,12 @@ class RoutingWorld: for obj_id in self._dynamic_paths.index.intersection(query_bounds): yield self._dynamic_paths.geometries[obj_id][1].bounds - def iter_dynamic_paths(self) -> Iterable[tuple[str, Polygon]]: - return self._dynamic_paths.geometries.values() - - def reset_metrics(self) -> None: - for key in self.metrics: - self.metrics[key] = 0 - - def get_metrics_summary(self) -> str: - metrics = self.metrics - return ( - "Collision Performance: \n" - f" Static: {metrics['static_tree_queries']} checks\n" - f" Congestion: {metrics['congestion_tree_queries']} checks\n" - f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed" - ) - def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: self._static_obstacles.remove_obstacle(obj_id) - def _invalidate_static_caches(self) -> None: - self._static_obstacles.invalidate_caches() - def _ensure_static_tree(self) -> None: self._static_obstacles.ensure_tree() @@ -127,10 +94,6 @@ class RoutingWorld: def _ensure_dynamic_grid(self) -> None: self._dynamic_paths.ensure_grid() - def rebuild_dynamic_tree(self) -> None: - self._dynamic_paths.tree = None - self._ensure_dynamic_tree() - def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) @@ -138,7 +101,6 @@ class RoutingWorld: self._dynamic_paths.remove_path(net_id) def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - self.metrics["static_straight_fast"] += 1 reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) return reach < length - 0.001 @@ -178,7 +140,6 @@ class RoutingWorld: if not geometry.intersects(raw_obstacle): return False - self.metrics["safety_zone_checks"] += 1 intersection = geometry.intersection(raw_obstacle) if intersection.is_empty: return False @@ -207,15 +168,13 @@ class RoutingWorld: result: ComponentResult, start_port: Port | None = None, end_port: Port | None = None, - net_width: float | None = None, ) -> bool: - del net_width - + # TODO: If static buffering becomes net-width-specific, add dedicated + # width-aware geometry/index handling instead of reviving dead args here. static_obstacles = self._static_obstacles if not static_obstacles.dilated: return False - self.metrics["static_tree_queries"] += 1 self._ensure_static_tree() hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) @@ -260,7 +219,6 @@ class RoutingWorld: def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: dynamic_paths = self._dynamic_paths - self.metrics["congestion_tree_queries"] += 1 self._ensure_dynamic_tree() if dynamic_paths.tree is None: return 0 @@ -347,101 +305,6 @@ class RoutingWorld: return 0 return self._check_real_congestion(result, net_id) - def _check_static_collision( - self, - geometry: Polygon, - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - ) -> bool: - static_obstacles = self._static_obstacles - self._ensure_static_tree() - if static_obstacles.tree is None: - return False - - if dilated_geometry is not None: - test_geometry = dilated_geometry - else: - distance = self.clearance / 2.0 - test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry - - hits = static_obstacles.tree.query(test_geometry, predicate="intersects") - tree_geometries = static_obstacles.tree.geometries - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = static_obstacles.obj_ids[hit_idx] - if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): - continue - return True - return False - - def _check_dynamic_collision( - self, - geometry: Polygon, - net_id: str, - dilated_geometry: Polygon | None = None, - ) -> int: - dynamic_paths = self._dynamic_paths - self._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) - hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") - tree_geometries = dynamic_paths.tree.geometries - hit_net_ids: list[str] = [] - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = dynamic_paths.obj_ids[hit_idx] - other_net_id = dynamic_paths.geometries[obj_id][0] - if other_net_id != net_id: - hit_net_ids.append(other_net_id) - if not hit_net_ids: - return 0 - return len(numpy.unique(hit_net_ids)) - - def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - bounds: tuple[float, float, float, float] | None = None, - net_width: float | None = None, - ) -> bool | int: - del bounds, net_width - - if buffer_mode == "static": - return self._check_static_collision( - geometry, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - ) - return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) - - def is_collision( - self, - geometry: Polygon, - net_id: str = "default", - net_width: float | None = None, - start_port: Port | None = None, - end_port: Port | None = None, - ) -> bool: - result = self.check_collision( - geometry, - net_id, - buffer_mode="static", - start_port=start_port, - end_port=end_port, - net_width=net_width, - ) - return bool(result) - def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: static_collision_count = 0 dynamic_collision_count = 0 @@ -502,10 +365,6 @@ class RoutingWorld: total_length=total_length, ) - def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]: - report = self.verify_path_report(net_id, components) - return report.is_valid, report.collision_count - def ray_cast( self, origin: Port, diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py index e2049ac..816508d 100644 --- a/inire/geometry/component_overlap.py +++ b/inire/geometry/component_overlap.py @@ -8,31 +8,34 @@ if TYPE_CHECKING: from inire.geometry.components import ComponentResult -def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]: - if prefer_actual: - return list(component.physical_geometry) - return list(component.collision_geometry) - - -def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]: - if not prefer_actual: - return component.total_bounds - - polygons = component_polygons(component, prefer_actual=True) - min_x = min(polygon.bounds[0] for polygon in polygons) - min_y = min(polygon.bounds[1] for polygon in polygons) - max_x = max(polygon.bounds[2] for polygon in polygons) - max_y = max(polygon.bounds[3] for polygon in polygons) - return (min_x, min_y, max_x, max_y) - - def components_overlap( component_a: ComponentResult, component_b: ComponentResult, prefer_actual: bool = False, ) -> bool: - bounds_a = component_bounds(component_a, prefer_actual=prefer_actual) - bounds_b = component_bounds(component_b, prefer_actual=prefer_actual) + polygons_a: tuple[Polygon, ...] + polygons_b: tuple[Polygon, ...] + if prefer_actual: + polygons_a = component_a.physical_geometry + polygons_b = component_b.physical_geometry + bounds_a = ( + min(polygon.bounds[0] for polygon in polygons_a), + min(polygon.bounds[1] for polygon in polygons_a), + max(polygon.bounds[2] for polygon in polygons_a), + max(polygon.bounds[3] for polygon in polygons_a), + ) + bounds_b = ( + min(polygon.bounds[0] for polygon in polygons_b), + min(polygon.bounds[1] for polygon in polygons_b), + max(polygon.bounds[2] for polygon in polygons_b), + max(polygon.bounds[3] for polygon in polygons_b), + ) + else: + polygons_a = component_a.collision_geometry + polygons_b = component_b.collision_geometry + bounds_a = component_a.total_bounds + bounds_b = component_b.total_bounds + if not ( bounds_a[0] < bounds_b[2] and bounds_a[2] > bounds_b[0] @@ -41,18 +44,8 @@ def components_overlap( ): return False - polygons_a = component_polygons(component_a, prefer_actual=prefer_actual) - polygons_b = component_polygons(component_b, prefer_actual=prefer_actual) for polygon_a in polygons_a: for polygon_b in polygons_b: if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): return True return False - - -def has_self_overlap(path: list[ComponentResult], prefer_actual: bool = False) -> bool: - for i, component in enumerate(path): - for j in range(i + 2, len(path)): - if components_overlap(component, path[j], prefer_actual=prefer_actual): - return True - return False diff --git a/inire/geometry/components.py b/inire/geometry/components.py index d098041..693f16e 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -10,6 +10,7 @@ from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box from inire.constants import TOLERANCE_ANGULAR +from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed from .primitives import Port, rotation_matrix2 @@ -29,6 +30,7 @@ class ComponentResult: end_port: Port length: float move_type: MoveKind + move_spec: PathSegmentSeed physical_geometry: tuple[Polygon, ...] dilated_collision_geometry: tuple[Polygon, ...] dilated_physical_geometry: tuple[Polygon, ...] @@ -80,6 +82,7 @@ class ComponentResult: end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, + move_spec=self.move_spec, physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], @@ -235,6 +238,7 @@ class Straight: end_port=end_port, length=abs(length_f), move_type="straight", + move_spec=StraightSeed(length=length_f), physical_geometry=geometry, dilated_collision_geometry=dilated_geometry, dilated_physical_geometry=dilated_geometry, @@ -305,6 +309,7 @@ class Bend90: end_port=end_port, length=abs(radius) * numpy.pi / 2.0, move_type="bend90", + move_spec=Bend90Seed(radius=radius, direction=direction), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, @@ -394,6 +399,7 @@ class SBend: end_port=end_port, length=2.0 * radius * theta, move_type="sbend", + move_spec=SBendSeed(offset=offset, radius=radius), physical_geometry=physical_geometry, dilated_collision_geometry=dilated_collision_geometry, dilated_physical_geometry=dilated_physical_geometry, diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py index c19ff77..d8363f6 100644 --- a/inire/geometry/dynamic_path_index.py +++ b/inire/geometry/dynamic_path_index.py @@ -87,8 +87,3 @@ class DynamicPathIndex: self.index.delete(obj_id, self.dilated[obj_id].bounds) del self.geometries[obj_id] del self.dilated[obj_id] - - def clear_paths(self) -> None: - if not self.geometries: - return - self.remove_obj_ids(list(self.geometries)) diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index 30055ea..b42b267 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -61,11 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32) def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: quadrant = (_normalize_angle(rotation_deg) // 90) % 4 return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] - - -def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: - rot2 = rotation_matrix2(rotation_deg) - rot3 = numpy.zeros((3, 3), dtype=numpy.int32) - rot3[:2, :2] = rot2 - rot3[2, 2] = 1 - return rot3 diff --git a/inire/model.py b/inire/model.py index 1dcb359..20d71fe 100644 --- a/inire/model.py +++ b/inire/model.py @@ -1,18 +1,22 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from inire.geometry.components import BendCollisionModel -from inire.router.results import RouteMetrics, RoutingResult +from inire.seeds import PathSeed if TYPE_CHECKING: from shapely.geometry import Polygon - from inire.geometry.components import ComponentResult + from inire.geometry.components import BendCollisionModel from inire.geometry.primitives import Port +NetOrder = Literal["user", "shortest", "longest"] +VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"] + + @dataclass(frozen=True, slots=True) class NetSpec: net_id: str @@ -21,37 +25,12 @@ class NetSpec: width: float = 2.0 -@dataclass(frozen=True, slots=True) -class LockedRoute: - geometry: tuple[Polygon, ...] - - def __post_init__(self) -> None: - object.__setattr__(self, "geometry", tuple(self.geometry)) - - @classmethod - def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute: - polygons = [] - for component in path: - polygons.extend(component.physical_geometry) - return cls(geometry=tuple(polygons)) - - -def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute: - if isinstance(route, LockedRoute): - return route - route_items = tuple(route) - if route_items and hasattr(route_items[0], "physical_geometry"): - return LockedRoute.from_path(route_items) # type: ignore[arg-type] - return LockedRoute(geometry=route_items) - - @dataclass(frozen=True, slots=True) class ObjectiveWeights: unit_length_cost: float = 1.0 bend_penalty: float = 250.0 sbend_penalty: float = 500.0 danger_weight: float = 1.0 - congestion_penalty: float = 0.0 @dataclass(frozen=True, slots=True) @@ -64,23 +43,13 @@ class SearchOptions: bend_radii: tuple[float, ...] = (50.0, 100.0) sbend_radii: tuple[float, ...] = (10.0,) bend_collision_type: BendCollisionModel = "arc" - visibility_guidance: str = "tangent_corner" - initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None + visibility_guidance: VisibilityGuidance = "tangent_corner" def __post_init__(self) -> None: object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) if self.sbend_offsets is not None: object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) - if self.initial_paths is not None: - object.__setattr__( - self, - "initial_paths", - { - net_id: tuple(path) - for net_id, path in self.initial_paths.items() - }, - ) @dataclass(frozen=True, slots=True) @@ -89,9 +58,9 @@ class CongestionOptions: base_penalty: float = 100.0 multiplier: float = 1.5 use_tiered_strategy: bool = True - warm_start: str | None = "shortest" + net_order: NetOrder = "user" + warm_start_enabled: bool = True shuffle_nets: bool = False - sort_nets: str | None = None seed: int | None = None @@ -120,26 +89,18 @@ class RoutingProblem: bounds: tuple[float, float, float, float] nets: tuple[NetSpec, ...] = () static_obstacles: tuple[Polygon, ...] = () - locked_routes: dict[str, LockedRoute] = field(default_factory=dict) + initial_paths: dict[str, PathSeed] = field(default_factory=dict) clearance: float = 2.0 - max_net_width: float = 2.0 safety_zone_radius: float = 0.0021 def __post_init__(self) -> None: object.__setattr__(self, "nets", tuple(self.nets)) object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) + initial_paths = dict(self.initial_paths) + if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()): + raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances") object.__setattr__( self, - "locked_routes", - { - net_id: _coerce_locked_route(route) - for net_id, route in self.locked_routes.items() - }, + "initial_paths", + initial_paths, ) - - -@dataclass(frozen=True, slots=True) -class RoutingRunResult: - results_by_net: dict[str, RoutingResult] - metrics: RouteMetrics - expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/results.py b/inire/results.py new file mode 100644 index 0000000..d16e92c --- /dev/null +++ b/inire/results.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from inire.seeds import PathSeed + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + + +RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] + + +@dataclass(frozen=True, slots=True) +class RoutingReport: + static_collision_count: int = 0 + dynamic_collision_count: int = 0 + self_collision_count: int = 0 + total_length: float = 0.0 + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + nodes_expanded: int + moves_generated: int + moves_added: int + pruned_closed_set: int + pruned_hard_collision: int + pruned_cost: int + + +@dataclass(frozen=True, slots=True) +class RoutingResult: + net_id: str + path: tuple[ComponentResult, ...] + reached_target: bool = False + report: RoutingReport = field(default_factory=RoutingReport) + + def __post_init__(self) -> None: + object.__setattr__(self, "path", tuple(self.path)) + + @property + def collisions(self) -> int: + return self.report.collision_count + + @property + def outcome(self) -> RoutingOutcome: + if not self.path: + return "unroutable" + if not self.reached_target: + return "partial" + if self.report.collision_count > 0: + return "colliding" + return "completed" + + @property + def is_valid(self) -> bool: + return self.outcome == "completed" + + @property + def locked_geometry(self) -> tuple[Polygon, ...]: + polygons = [] + for component in self.path: + polygons.extend(component.physical_geometry) + return tuple(polygons) + + def as_seed(self) -> PathSeed: + return PathSeed(tuple(component.move_spec for component in self.path)) + + +@dataclass(frozen=True, slots=True) +class RoutingRunResult: + results_by_net: dict[str, RoutingResult] + metrics: RouteMetrics + expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py index be244c8..594a970 100644 --- a/inire/router/_astar_admission.py +++ b/inire/router/_astar_admission.py @@ -1,16 +1,16 @@ from __future__ import annotations import heapq -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from shapely.geometry import Polygon from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind +from inire.geometry.components import Bend90, SBend, Straight, MoveKind from inire.geometry.primitives import Port from inire.router.refiner import component_hits_ancestor_chain -from ._astar_types import AStarContext, AStarMetrics, AStarNode +from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -26,15 +26,12 @@ def process_move( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], + config: SearchRunConfig, move_class: MoveKind, params: tuple, - skip_congestion: bool, - bend_collision_type: BendCollisionModel, - max_cost: float | None = None, - self_collision_check: bool = False, ) -> None: cp = parent.port - coll_type = bend_collision_type + coll_type = config.bend_collision_type coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 @@ -101,12 +98,9 @@ def process_move( context, metrics, congestion_cache, + config, move_class, abs_key, - move_radius=move_radius, - skip_congestion=skip_congestion, - max_cost=max_cost, - self_collision_check=self_collision_check, ) @@ -121,12 +115,9 @@ def add_node( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], + config: SearchRunConfig, move_type: MoveKind, cache_key: tuple, - move_radius: float | None = None, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, ) -> None: metrics.moves_generated += 1 metrics.total_moves_generated += 1 @@ -151,7 +142,7 @@ def add_node( if move_type == "straight": collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) else: - collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) + collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p) if collision_found: context.hard_collision_set.add(cache_key) metrics.pruned_hard_collision += 1 @@ -160,36 +151,23 @@ def add_node( context.static_safe_cache.add(cache_key) total_overlaps = 0 - if not skip_congestion: + if not config.skip_congestion: if cache_key in congestion_cache: total_overlaps = congestion_cache[cache_key] else: total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) congestion_cache[cache_key] = total_overlaps - if self_collision_check and component_hits_ancestor_chain(result, parent): + if config.self_collision_check and component_hits_ancestor_chain(result, parent): return - penalty = context.cost_evaluator.component_penalty( - move_type, - move_radius=move_radius, - ) - - move_cost = context.cost_evaluator.evaluate_move( - result.collision_geometry, - result.end_port, - net_width, - net_id, + move_cost = context.cost_evaluator.score_component( + result, start_port=parent_p, - length=result.length, - dilated_geometry=result.dilated_collision_geometry, - penalty=penalty, - skip_static=True, - skip_congestion=True, ) - move_cost += total_overlaps * context.cost_evaluator.congestion_penalty + move_cost += total_overlaps * context.congestion_penalty - if max_cost is not None and parent.g_cost + move_cost > max_cost: + if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost: metrics.pruned_cost += 1 metrics.total_pruned_cost += 1 return @@ -204,7 +182,11 @@ def add_node( metrics.total_pruned_closed_set += 1 return - h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) + h_cost = context.cost_evaluator.h_manhattan( + result.end_port, + target, + min_bend_radius=context.min_bend_radius, + ) heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) metrics.moves_added += 1 metrics.total_moves_added += 1 diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py index d326934..71ca920 100644 --- a/inire/router/_astar_moves.py +++ b/inire/router/_astar_moves.py @@ -3,11 +3,11 @@ from __future__ import annotations import math from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import BendCollisionModel, MoveKind +from inire.geometry.components import MoveKind from inire.geometry.primitives import Port from ._astar_admission import process_move -from ._astar_types import AStarContext, AStarMetrics, AStarNode +from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: @@ -129,13 +129,9 @@ def expand_moves( context: AStarContext, metrics: AStarMetrics, congestion_cache: dict[tuple, int], - bend_collision_type: BendCollisionModel | None = None, - max_cost: float | None = None, - skip_congestion: bool = False, - self_collision_check: bool = False, + config: SearchRunConfig, ) -> None: search_options = context.options.search - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type cp = current.port prev_move_type, prev_straight_length = _previous_move_metadata(current) dx_t = target.x - cp.x @@ -171,12 +167,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "straight", (int(round(proj_t)),), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width) @@ -225,12 +218,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "straight", (length,), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) angle_to_target = 0.0 @@ -256,12 +246,9 @@ def expand_moves( context, metrics, congestion_cache, + config, "bend90", (radius, direction), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0 @@ -293,10 +280,7 @@ def expand_moves( context, metrics, congestion_cache, + config, "sbend", (offset, radius), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py index 785b3ae..f63fe43 100644 --- a/inire/router/_astar_types.py +++ b/inire/router/_astar_types.py @@ -1,16 +1,53 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING +from inire.geometry.components import BendCollisionModel from inire.model import RoutingOptions, RoutingProblem +from inire.results import RouteMetrics from inire.router.visibility import VisibilityManager -from inire.router.results import RouteMetrics if TYPE_CHECKING: from inire.geometry.components import ComponentResult from inire.router.cost import CostEvaluator +@dataclass(frozen=True, slots=True) +class SearchRunConfig: + bend_collision_type: BendCollisionModel + node_limit: int + return_partial: bool = False + store_expanded: bool = False + skip_congestion: bool = False + max_cost: float | None = None + self_collision_check: bool = False + + @classmethod + def from_options( + cls, + options: RoutingOptions, + *, + bend_collision_type: BendCollisionModel | None = None, + node_limit: int | None = None, + return_partial: bool = False, + store_expanded: bool = False, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, + ) -> SearchRunConfig: + search = options.search + return cls( + bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type, + node_limit=search.node_limit if node_limit is None else node_limit, + return_partial=return_partial, + store_expanded=store_expanded, + skip_congestion=skip_congestion, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + class AStarNode: __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") @@ -96,6 +133,8 @@ class AStarMetrics: class AStarContext: __slots__ = ( "cost_evaluator", + "congestion_penalty", + "min_bend_radius", "problem", "options", "max_cache_size", @@ -115,10 +154,11 @@ class AStarContext: max_cache_size: int = 1000000, ) -> None: self.cost_evaluator = cost_evaluator + self.congestion_penalty = 0.0 self.max_cache_size = max_cache_size self.problem = problem self.options = options - self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0)) + self.min_bend_radius = min(self.options.search.bend_radii, default=50.0) self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) self.move_cache_rel: dict[tuple, ComponentResult] = {} self.move_cache_abs: dict[tuple, ComponentResult] = {} diff --git a/inire/router/_router.py b/inire/router/_router.py index 75aef6e..5aaf00c 100644 --- a/inire/router/_router.py +++ b/inire/router/_router.py @@ -5,19 +5,17 @@ import time from dataclasses import dataclass from typing import TYPE_CHECKING -from inire.model import NetSpec, RoutingOptions, RoutingProblem -from inire.router._astar_types import AStarContext, AStarMetrics +from inire.model import NetOrder, NetSpec +from inire.results import RoutingOutcome, RoutingReport, RoutingResult +from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig from inire.router._search import route_astar -from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry +from inire.router._seed_materialization import materialize_path_seed from inire.router.refiner import PathRefiner -from inire.router.results import RoutingReport, RoutingResult if TYPE_CHECKING: from collections.abc import Callable, Sequence from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.cost import CostEvaluator @dataclass(slots=True) @@ -31,10 +29,6 @@ class _RoutingState: initial_paths: dict[str, tuple[ComponentResult, ...]] | None accumulated_expanded_nodes: list[tuple[int, int, int]] - -__all__ = ["PathFinder"] - - class PathFinder: __slots__ = ( "context", @@ -53,83 +47,18 @@ class PathFinder: self.refiner = PathRefiner(self.context) self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] - @property - def problem(self) -> RoutingProblem: - return self.context.problem - - @property - def options(self) -> RoutingOptions: - return self.context.options - - @property - def cost_evaluator(self) -> CostEvaluator: - return self.context.cost_evaluator - - def _path_cost(self, path: Sequence[ComponentResult]) -> float: - return self.refiner.path_cost(path) - - def _refine_path( - self, - net_id: str, - start: Port, - target: Port, - net_width: float, - path: Sequence[ComponentResult], - ) -> list[ComponentResult]: - return self.refiner.refine_path(net_id, start, target, net_width, path) - - def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]: + def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: all_geoms = [] all_dilated = [] for result in path: all_geoms.extend(result.collision_geometry) all_dilated.extend(result.dilated_collision_geometry) - return all_geoms, all_dilated - - def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: - all_geoms, all_dilated = self._extract_path_geometry(path) - self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]: - obj_ids: list[int] = [] - for result in path: - for polygon in result.physical_geometry: - obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon)) - return obj_ids - - def _remove_static_obstacles(self, obj_ids: list[int]) -> None: - for obj_id in obj_ids: - self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) - - def _remove_path(self, net_id: str) -> None: - self.cost_evaluator.collision_engine.remove_path(net_id) - - def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport: - return self.cost_evaluator.collision_engine.verify_path_report(net_id, path) - - def _finalize_dynamic_tree(self) -> None: - self.cost_evaluator.collision_engine.rebuild_dynamic_tree() - - def _build_routing_result( - self, - *, - net_id: str, - path: Sequence[ComponentResult], - reached_target: bool | None = None, - report: RoutingReport | None = None, - ) -> RoutingResult: - resolved_reached_target = bool(path) if reached_target is None else reached_target - return RoutingResult( - net_id=net_id, - path=path, - reached_target=resolved_reached_target, - report=report if report is not None else RoutingReport(), - ) + self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) def _routing_order( self, net_specs: dict[str, NetSpec], - order: str, + order: NetOrder, ) -> list[str]: ordered_net_ids = list(net_specs.keys()) if order == "user": @@ -144,15 +73,26 @@ class PathFinder: def _build_greedy_warm_start_paths( self, net_specs: dict[str, NetSpec], - order: str, + order: NetOrder, ) -> dict[str, tuple[ComponentResult, ...]]: greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} temp_obj_ids: list[int] = [] - greedy_node_limit = min(self.options.search.node_limit, 2000) + greedy_node_limit = min(self.context.options.search.node_limit, 2000) for net_id in self._routing_order(net_specs, order): net = net_specs[net_id] - h_start = self.cost_evaluator.h_manhattan(net.start, net.target) + h_start = self.context.cost_evaluator.h_manhattan( + net.start, + net.target, + min_bend_radius=self.context.min_bend_radius, + ) max_cost_limit = max(h_start * 3.0, 2000.0) + run_config = SearchRunConfig.from_options( + self.context.options, + skip_congestion=True, + max_cost=max_cost_limit, + self_collision_check=True, + node_limit=greedy_node_limit, + ) path = route_astar( net.start, net.target, @@ -160,24 +100,24 @@ class PathFinder: context=self.context, metrics=self.metrics, net_id=net_id, - skip_congestion=True, - max_cost=max_cost_limit, - self_collision_check=True, - node_limit=greedy_node_limit, + config=run_config, ) if not path: continue greedy_paths[net_id] = tuple(path) - temp_obj_ids.extend(self._stage_path_as_static(path)) + for result in path: + for polygon in result.physical_geometry: + temp_obj_ids.append(self.context.cost_evaluator.collision_engine.add_static_obstacle(polygon)) self.context.clear_static_caches() - self._remove_static_obstacles(temp_obj_ids) + for obj_id in temp_obj_ids: + self.context.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) return greedy_paths def _prepare_state(self) -> _RoutingState: - problem = self.problem - congestion = self.options.congestion - initial_paths = self.options.search.initial_paths + problem = self.context.problem + congestion = self.context.options.congestion + initial_paths = self._materialize_problem_initial_paths() net_specs = {net.net_id: net for net in problem.nets} num_nets = len(net_specs) state = _RoutingState( @@ -190,27 +130,45 @@ class PathFinder: initial_paths=initial_paths, accumulated_expanded_nodes=[], ) - if state.initial_paths is None: - warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start - if warm_start_order is not None: - state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order) - self.context.clear_static_caches() + if state.initial_paths is None and congestion.warm_start_enabled: + state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order) + self.context.clear_static_caches() - if congestion.sort_nets and congestion.sort_nets != "user": - state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets) + if congestion.net_order != "user": + state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order) return state + def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None: + if not self.context.problem.initial_paths: + return None + + search = self.context.options.search + net_specs = {net.net_id: net for net in self.context.problem.nets} + initial_paths: dict[str, tuple[ComponentResult, ...]] = {} + for net_id, seed in self.context.problem.initial_paths.items(): + if net_id not in net_specs: + raise ValueError(f"Initial path provided for unknown net: {net_id}") + net = net_specs[net_id] + initial_paths[net_id] = materialize_path_seed( + seed, + start=net.start, + net_width=net.width, + search=search, + clearance=self.context.cost_evaluator.collision_engine.clearance, + ) + return initial_paths + def _route_net_once( self, state: _RoutingState, iteration: int, net_id: str, ) -> RoutingResult: - search = self.options.search - congestion = self.options.congestion - diagnostics = self.options.diagnostics + search = self.context.options.search + congestion = self.context.options.congestion + diagnostics = self.context.options.diagnostics net = state.net_specs[net_id] - self._remove_path(net_id) + self.context.cost_evaluator.collision_engine.remove_path(net_id) if iteration == 0 and state.initial_paths and net_id in state.initial_paths: path: Sequence[ComponentResult] | None = state.initial_paths[net_id] @@ -222,13 +180,8 @@ class PathFinder: if coll_model == "arc": coll_model = "clipped_bbox" - path = route_astar( - net.start, - net.target, - net.width, - context=self.context, - metrics=self.metrics, - net_id=net_id, + run_config = SearchRunConfig.from_options( + self.context.options, bend_collision_type=coll_model, return_partial=True, store_expanded=diagnostics.capture_expanded, @@ -236,26 +189,35 @@ class PathFinder: self_collision_check=(net_id in state.needs_self_collision_check), node_limit=search.node_limit, ) + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + config=run_config, + ) if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) if not path: - return self._build_routing_result(net_id=net_id, path=[], reached_target=False) + return RoutingResult(net_id=net_id, path=(), reached_target=False) reached_target = path[-1].end_port == net.target report = None self._install_path(net_id, path) if reached_target: - report = self._verify_path_report(net_id, path) + report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, path) if report.self_collision_count > 0: state.needs_self_collision_check.add(net_id) - return self._build_routing_result( + return RoutingResult( net_id=net_id, path=path, reached_target=reached_target, - report=report, + report=RoutingReport() if report is None else report, ) def _run_iteration( @@ -265,7 +227,7 @@ class PathFinder: iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, ) -> dict[str, RoutingOutcome] | None: outcomes: dict[str, RoutingOutcome] = {} - congestion = self.options.congestion + congestion = self.context.options.congestion self.metrics.reset_per_route() if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): @@ -274,7 +236,6 @@ class PathFinder: for net_id in state.ordered_net_ids: if time.monotonic() - state.start_time > state.timeout_s: - self._finalize_dynamic_tree() return None result = self._route_net_once(state, iteration, net_id) @@ -290,30 +251,30 @@ class PathFinder: state: _RoutingState, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, ) -> bool: - congestion = self.options.congestion + congestion = self.context.options.congestion for iteration in range(congestion.max_iterations): outcomes = self._run_iteration(state, iteration, iteration_callback) if outcomes is None: return True - if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()): + if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()): return False - self.cost_evaluator.congestion_penalty *= congestion.multiplier + self.context.congestion_penalty *= congestion.multiplier return False def _refine_results(self, state: _RoutingState) -> None: - if not self.options.refinement.enabled or not state.results: + if not self.context.options.refinement.enabled or not state.results: return for net_id in state.ordered_net_ids: result = state.results.get(net_id) - if not result or not result.path or routing_outcome_needs_retry(result.outcome): + if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}: continue net = state.net_specs[net_id] - self._remove_path(net_id) - refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path) + self.context.cost_evaluator.collision_engine.remove_path(net_id) + refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path) self._install_path(net_id, refined_path) - report = self._verify_path_report(net_id, refined_path) - state.results[net_id] = self._build_routing_result( + report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path) + state.results[net_id] = RoutingResult( net_id=net_id, path=refined_path, reached_target=result.reached_target, @@ -322,17 +283,13 @@ class PathFinder: def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: final_results: dict[str, RoutingResult] = {} - for net in self.problem.nets: + for net in self.context.problem.nets: result = state.results.get(net.net_id) if not result or not result.path: - final_results[net.net_id] = self._build_routing_result( - net_id=net.net_id, - path=[], - reached_target=False, - ) + final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False) continue - report = self._verify_path_report(net.net_id, result.path) - final_results[net.net_id] = self._build_routing_result( + report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path) + final_results[net.net_id] = RoutingResult( net_id=net.net_id, path=result.path, reached_target=result.reached_target, @@ -345,7 +302,7 @@ class PathFinder: *, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, ) -> dict[str, RoutingResult]: - self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty + self.context.congestion_penalty = self.context.options.congestion.base_penalty self.accumulated_expanded_nodes = [] self.metrics.reset_totals() self.metrics.reset_per_route() @@ -358,5 +315,4 @@ class PathFinder: return self._verify_results(state) self._refine_results(state) - self._finalize_dynamic_tree() return self._verify_results(state) diff --git a/inire/router/_search.py b/inire/router/_search.py index 7816ef3..2cf7daa 100644 --- a/inire/router/_search.py +++ b/inire/router/_search.py @@ -4,12 +4,10 @@ import heapq from typing import TYPE_CHECKING from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import BendCollisionModel from inire.geometry.primitives import Port from ._astar_moves import expand_moves as _expand_moves -from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode -from .results import RouteMetrics +from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -29,21 +27,14 @@ def route_astar( target: Port, net_width: float, context: AStarContext, + *, metrics: AStarMetrics | None = None, net_id: str = "default", - bend_collision_type: BendCollisionModel | None = None, - return_partial: bool = False, - store_expanded: bool = False, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, - node_limit: int | None = None, + config: SearchRunConfig, ) -> list[ComponentResult] | None: if metrics is None: metrics = AStarMetrics() metrics.reset_per_route() - search_options = context.options.search - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type context.ensure_static_caches_current() context.cost_evaluator.set_target(target) @@ -51,18 +42,21 @@ def route_astar( closed_set: dict[tuple[int, int, int], float] = {} congestion_cache: dict[tuple, int] = {} - start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) + start_node = _AStarNode( + start, + 0.0, + context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius), + ) heapq.heappush(open_set, start_node) best_node = start_node - effective_node_limit = node_limit if node_limit is not None else search_options.node_limit nodes_expanded = 0 while open_set: - if nodes_expanded >= effective_node_limit: - return _reconstruct_path(best_node) if return_partial else None + if nodes_expanded >= config.node_limit: + return _reconstruct_path(best_node) if config.return_partial else None current = heapq.heappop(open_set) - if max_cost is not None and current.fh_cost[0] > max_cost: + if config.max_cost is not None and current.fh_cost[0] > config.max_cost: metrics.pruned_cost += 1 metrics.total_pruned_cost += 1 continue @@ -75,7 +69,7 @@ def route_astar( continue closed_set[state] = current.g_cost - if store_expanded: + if config.store_expanded: metrics.last_expanded_nodes.append(state) nodes_expanded += 1 @@ -95,18 +89,7 @@ def route_astar( context, metrics, congestion_cache, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - skip_congestion=skip_congestion, - self_collision_check=self_collision_check, + config=config, ) - return _reconstruct_path(best_node) if return_partial else None - - -__all__ = [ - "AStarContext", - "AStarMetrics", - "RouteMetrics", - "route_astar", -] + return _reconstruct_path(best_node) if config.return_partial else None diff --git a/inire/router/_seed_materialization.py b/inire/router/_seed_materialization.py new file mode 100644 index 0000000..b63cda9 --- /dev/null +++ b/inire/router/_seed_materialization.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.model import SearchOptions +from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + + +def materialize_path_seed( + seed: PathSeed, + *, + start: Port, + net_width: float, + search: SearchOptions, + clearance: float, +) -> tuple[ComponentResult, ...]: + from inire.geometry.components import Bend90, SBend, Straight + + path: list[ComponentResult] = [] + current = start + dilation = clearance / 2.0 + bend_collision_type = search.bend_collision_type + + for segment in seed.segments: + if isinstance(segment, StraightSeed): + component = Straight.generate(current, segment.length, net_width, dilation=dilation) + elif isinstance(segment, Bend90Seed): + component = Bend90.generate( + current, + segment.radius, + net_width, + segment.direction, + collision_type=bend_collision_type, + dilation=dilation, + ) + elif isinstance(segment, SBendSeed): + component = SBend.generate( + current, + segment.offset, + segment.radius, + net_width, + collision_type=bend_collision_type, + dilation=dilation, + ) + else: + raise TypeError(f"Unsupported seed segment: {type(segment)!r}") + path.append(component) + current = component.end_port + return tuple(path) diff --git a/inire/router/_stack.py b/inire/router/_stack.py new file mode 100644 index 0000000..71aa119 --- /dev/null +++ b/inire/router/_stack.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from inire.model import RoutingOptions, RoutingProblem + + +@dataclass(frozen=True, slots=True) +class RoutingStack: + world: object + danger_map: object + evaluator: object + context: object + finder: object + + +def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack: + from inire.geometry.collision import RoutingWorld + from inire.router._astar_types import AStarContext + from inire.router._router import PathFinder + from inire.router.cost import CostEvaluator + from inire.router.danger_map import DangerMap + + world = RoutingWorld( + clearance=problem.clearance, + safety_zone_radius=problem.safety_zone_radius, + ) + for obstacle in problem.static_obstacles: + world.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=problem.bounds) + danger_map.precompute(list(problem.static_obstacles)) + + objective = options.objective + evaluator = CostEvaluator( + world, + danger_map, + unit_length_cost=objective.unit_length_cost, + greedy_h_weight=options.search.greedy_h_weight, + bend_penalty=objective.bend_penalty, + sbend_penalty=objective.sbend_penalty, + danger_weight=objective.danger_weight, + ) + context = AStarContext(evaluator, problem, options) + finder = PathFinder(context) + return RoutingStack( + world=world, + danger_map=danger_map, + evaluator=evaluator, + context=context, + finder=finder, + ) diff --git a/inire/router/cost.py b/inire/router/cost.py index 8468c3e..73fa121 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -5,13 +5,9 @@ from typing import TYPE_CHECKING import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.model import ObjectiveWeights, RoutingOptions +from inire.model import ObjectiveWeights if TYPE_CHECKING: - from collections.abc import Sequence - - from shapely.geometry import Polygon - from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port @@ -22,18 +18,13 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "_unit_length_cost", + "_search_weights", "_greedy_h_weight", - "_bend_penalty", - "_sbend_penalty", - "_danger_weight", - "_congestion_penalty", "_target_x", "_target_y", "_target_r", "_target_cos", "_target_sin", - "_min_radius", ) def __init__( @@ -49,91 +40,25 @@ class CostEvaluator: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty self.collision_engine = collision_engine self.danger_map = danger_map - self._unit_length_cost = float(unit_length_cost) + self._search_weights = ObjectiveWeights( + unit_length_cost=unit_length_cost, + bend_penalty=bend_penalty, + sbend_penalty=actual_sbend_penalty, + danger_weight=danger_weight, + ) self._greedy_h_weight = float(greedy_h_weight) - self._bend_penalty = float(bend_penalty) - self._sbend_penalty = float(actual_sbend_penalty) - self._danger_weight = float(danger_weight) - self._congestion_penalty = 0.0 self._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - self._min_radius = 50.0 - @property - def unit_length_cost(self) -> float: - return self._unit_length_cost + def default_weights(self) -> ObjectiveWeights: + return self._search_weights - @unit_length_cost.setter - def unit_length_cost(self, value: float) -> None: - self._unit_length_cost = float(value) - - @property - def greedy_h_weight(self) -> float: - return self._greedy_h_weight - - @greedy_h_weight.setter - def greedy_h_weight(self, value: float) -> None: - self._greedy_h_weight = float(value) - - @property - def congestion_penalty(self) -> float: - return self._congestion_penalty - - @congestion_penalty.setter - def congestion_penalty(self, value: float) -> None: - self._congestion_penalty = float(value) - - @property - def bend_penalty(self) -> float: - return self._bend_penalty - - @bend_penalty.setter - def bend_penalty(self, value: float) -> None: - self._bend_penalty = float(value) - - @property - def sbend_penalty(self) -> float: - return self._sbend_penalty - - @sbend_penalty.setter - def sbend_penalty(self, value: float) -> None: - self._sbend_penalty = float(value) - - @property - def danger_weight(self) -> float: - return self._danger_weight - - @danger_weight.setter - def danger_weight(self, value: float) -> None: - self._danger_weight = float(value) - - def set_min_bend_radius(self, radius: float) -> None: - self._min_radius = float(radius) if radius > 0 else 50.0 - - def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights: - return ObjectiveWeights( - unit_length_cost=self._unit_length_cost, - bend_penalty=self._bend_penalty, - sbend_penalty=self._sbend_penalty, - danger_weight=self._danger_weight, - congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty), - ) - - def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights: - refinement_objective = options.refinement.objective - if refinement_objective is None: - return ObjectiveWeights( - unit_length_cost=self._unit_length_cost, - bend_penalty=self._bend_penalty, - sbend_penalty=self._sbend_penalty, - danger_weight=self._danger_weight, - congestion_penalty=0.0, - ) - return refinement_objective + def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights: + return self._search_weights if weights is None else weights def set_target(self, target: Port) -> None: self._target_x = target.x @@ -143,12 +68,13 @@ class CostEvaluator: self._target_cos = np.cos(rad) self._target_sin = np.sin(rad) - def g_proximity(self, x: float, y: float) -> float: - if self.danger_map is None: - return 0.0 - return self._danger_weight * self.danger_map.get_cost(x, y) - - def h_manhattan(self, current: Port, target: Port) -> float: + def h_manhattan( + self, + current: Port, + target: Port, + *, + min_bend_radius: float = 50.0, + ) -> float: tx, ty = target.x, target.y if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r: self.set_target(target) @@ -156,7 +82,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self._bend_penalty + bp = self._search_weights.bend_penalty penalty = 0.0 curr_r = current.r @@ -168,7 +94,7 @@ class CostEvaluator: v_dy = ty - current.y side_proj = v_dx * self._target_cos + v_dy * self._target_sin perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos) - if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0): + if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0): penalty += 2 * bp if curr_r == 0: @@ -188,46 +114,27 @@ class CostEvaluator: return self._greedy_h_weight * (dist + penalty) - def evaluate_move( + def score_component( self, - geometry: Sequence[Polygon] | None, - end_port: Port, - net_width: float, - net_id: str, + component: ComponentResult, + *, start_port: Port | None = None, - length: float = 0.0, - dilated_geometry: Sequence[Polygon] | None = None, - skip_static: bool = False, - skip_congestion: bool = False, - penalty: float = 0.0, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights - _ = net_width + active_weights = self._resolve_weights(weights) danger_map = self.danger_map + end_port = component.end_port if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - total_cost = length * active_weights.unit_length_cost + penalty - if not skip_static or not skip_congestion: - if geometry is None: - return 1e15 - collision_engine = self.collision_engine - for i, poly in enumerate(geometry): - dil_poly = dilated_geometry[i] if dilated_geometry else None - if not skip_static and collision_engine.check_collision( - poly, - net_id, - buffer_mode="static", - start_port=start_port, - end_port=end_port, - dilated_geometry=dil_poly, - ): - return 1e15 - if not skip_congestion: - overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly) - if isinstance(overlaps, int) and overlaps > 0: - total_cost += overlaps * active_weights.congestion_penalty + move_radius = None + if component.move_type == "bend90": + move_radius = component.length * 2.0 / np.pi if component.length > 0 else None + total_cost = component.length * active_weights.unit_length_cost + self.component_penalty( + component.move_type, + move_radius=move_radius, + weights=active_weights, + ) if danger_map is not None and active_weights.danger_weight: cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 @@ -236,9 +143,9 @@ class CostEvaluator: mid_x = (start_port.x + end_port.x) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0 cost_m = danger_map.get_cost(mid_x, mid_y) - total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 + total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += length * active_weights.danger_weight * cost_e + total_cost += component.length * active_weights.danger_weight * cost_e return total_cost def component_penalty( @@ -248,7 +155,7 @@ class CostEvaluator: move_radius: float | None = None, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights + active_weights = self._resolve_weights(weights) penalty = 0.0 if move_type == "sbend": penalty = active_weights.sbend_penalty @@ -260,37 +167,18 @@ class CostEvaluator: def path_cost( self, - net_id: str, start_port: Port, path: list[ComponentResult], *, weights: ObjectiveWeights | None = None, ) -> float: - active_weights = self.objective_weights() if weights is None else weights + active_weights = self._resolve_weights(weights) total = 0.0 current_port = start_port for component in path: - move_radius = None - if component.move_type == "bend90": - move_radius = component.length * 2.0 / np.pi if component.length > 0 else None - elif component.move_type == "sbend": - move_radius = None - penalty = self.component_penalty( - component.move_type, - move_radius=move_radius, - weights=active_weights, - ) - total += self.evaluate_move( - component.collision_geometry, - component.end_port, - net_width=0.0, - net_id=net_id, + total += self.score_component( + component, start_port=current_port, - length=component.length, - dilated_geometry=component.dilated_collision_geometry, - skip_static=True, - skip_congestion=(active_weights.congestion_penalty <= 0.0), - penalty=penalty, weights=active_weights, ) current_port = component.end_port diff --git a/inire/router/outcomes.py b/inire/router/outcomes.py deleted file mode 100644 index 9dab591..0000000 --- a/inire/router/outcomes.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from typing import Literal - - -RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] - -RETRYABLE_ROUTING_OUTCOMES = frozenset({"colliding", "partial", "unroutable"}) - - -def infer_routing_outcome( - *, - has_path: bool, - reached_target: bool, - collision_count: int, -) -> RoutingOutcome: - if not has_path: - return "unroutable" - if not reached_target: - return "partial" - if collision_count > 0: - return "colliding" - return "completed" - - -def routing_outcome_needs_retry(outcome: RoutingOutcome) -> bool: - return outcome in RETRYABLE_ROUTING_OUTCOMES diff --git a/inire/router/refiner.py b/inire/router/refiner.py index 1f9112e..6aa5d1f 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -3,7 +3,7 @@ from __future__ import annotations import math from typing import TYPE_CHECKING, Any -from inire.geometry.component_overlap import components_overlap, has_self_overlap +from inire.geometry.component_overlap import components_overlap from inire.geometry.components import Bend90, Straight if TYPE_CHECKING: @@ -12,7 +12,8 @@ if TYPE_CHECKING: from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port - from inire.router._search import AStarContext + from inire.router._astar_types import AStarContext + def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: current = parent_node @@ -24,10 +25,6 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) return False -def has_self_collision(path: Sequence[ComponentResult]) -> bool: - return has_self_overlap(path) - - class PathRefiner: __slots__ = ("context",) @@ -42,17 +39,16 @@ class PathRefiner: self, path: Sequence[ComponentResult], *, - net_id: str = "default", start: Port | None = None, ) -> float: if not path: return 0.0 actual_start = path[0].start_port if start is None else start - return self.score_path(net_id, actual_start, path) + return self.score_path(actual_start, path) - def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float: - weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options) - return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights) + def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float: + weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights + return self.context.cost_evaluator.path_cost(start, path, weights=weights) def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: ports = [start] @@ -291,11 +287,9 @@ class PathRefiner: self, net_id: str, start: Port, - target: Port, net_width: float, path: list[ComponentResult], ) -> list[ComponentResult]: - _ = target if not path: return path @@ -306,7 +300,7 @@ class PathRefiner: return path best_path = path - best_cost = self.score_path(net_id, start, path) + best_cost = self.score_path(start, path) for _ in range(3): improved = False diff --git a/inire/router/results.py b/inire/router/results.py index 3548f64..a9d3c1f 100644 --- a/inire/router/results.py +++ b/inire/router/results.py @@ -1,68 +1,16 @@ -from __future__ import annotations +"""Semi-private compatibility exports for router result types. -from dataclasses import dataclass, field -from typing import TYPE_CHECKING +These deep-module imports remain accessible for advanced use, but they are +unstable and may change without notice. Prefer importing public result types +from ``inire`` or ``inire.results``. +""" -from inire.router.outcomes import RoutingOutcome, infer_routing_outcome +from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.model import LockedRoute - - -@dataclass(frozen=True, slots=True) -class RoutingReport: - static_collision_count: int = 0 - dynamic_collision_count: int = 0 - self_collision_count: int = 0 - total_length: float = 0.0 - - @property - def collision_count(self) -> int: - return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count - - @property - def is_valid(self) -> bool: - return self.collision_count == 0 - - -@dataclass(frozen=True, slots=True) -class RouteMetrics: - nodes_expanded: int - moves_generated: int - moves_added: int - pruned_closed_set: int - pruned_hard_collision: int - pruned_cost: int - - -@dataclass(frozen=True, slots=True) -class RoutingResult: - net_id: str - path: tuple[ComponentResult, ...] - reached_target: bool = False - report: RoutingReport = field(default_factory=RoutingReport) - - def __post_init__(self) -> None: - object.__setattr__(self, "path", tuple(self.path)) - - @property - def collisions(self) -> int: - return self.report.collision_count - - @property - def outcome(self) -> RoutingOutcome: - return infer_routing_outcome( - has_path=bool(self.path), - reached_target=self.reached_target, - collision_count=self.report.collision_count, - ) - - @property - def is_valid(self) -> bool: - return self.outcome == "completed" - - def as_locked_route(self) -> LockedRoute: - from inire.model import LockedRoute - - return LockedRoute.from_path(self.path) +__all__ = [ + "RouteMetrics", + "RoutingOutcome", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", +] diff --git a/inire/seeds.py b/inire/seeds.py new file mode 100644 index 0000000..635e489 --- /dev/null +++ b/inire/seeds.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +BendDirection = Literal["CW", "CCW"] + + +@dataclass(frozen=True, slots=True) +class StraightSeed: + length: float + + def __post_init__(self) -> None: + object.__setattr__(self, "length", float(self.length)) + + +@dataclass(frozen=True, slots=True) +class Bend90Seed: + radius: float + direction: BendDirection + + def __post_init__(self) -> None: + object.__setattr__(self, "radius", float(self.radius)) + + +@dataclass(frozen=True, slots=True) +class SBendSeed: + offset: float + radius: float + + def __post_init__(self) -> None: + object.__setattr__(self, "offset", float(self.offset)) + object.__setattr__(self, "radius", float(self.radius)) + + +PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed + + +@dataclass(frozen=True, slots=True) +class PathSeed: + segments: tuple[PathSegmentSeed, ...] + + def __post_init__(self) -> None: + segments = tuple(self.segments) + if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments): + raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances") + object.__setattr__(self, "segments", segments) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py deleted file mode 100644 index 3513c62..0000000 --- a/inire/tests/benchmark_scaling.py +++ /dev/null @@ -1,68 +0,0 @@ -import time - -from inire import NetSpec -from inire.geometry.primitives import Port -from inire.geometry.collision import RoutingWorld -from inire.router.danger_map import DangerMap -from inire.router.cost import CostEvaluator -from inire.router._astar_types import AStarMetrics -from inire.router._router import PathFinder -from inire.tests.support import build_context - -def benchmark_scaling() -> None: - print("Starting Scalability Benchmark...") - - # 1. Memory Verification (20x20mm) - # Resolution 1um -> 20000 x 20000 grid - bounds = (0, 0, 20000, 20000) - print(f"Initializing DangerMap for {bounds} area...") - dm = DangerMap(bounds=bounds, resolution=1.0) - # nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB - mem_gb = dm.grid.nbytes / (1024**3) - print(f"DangerMap memory usage: {mem_gb:.2f} GB") - assert mem_gb < 2.0 - - # 2. Node Expansion Rate (50 nets) - engine = RoutingWorld(clearance=2.0) - # Use a smaller area for routing benchmark to keep it fast - routing_bounds = (0, 0, 1000, 1000) - danger_map = DangerMap(bounds=routing_bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map) - num_nets = 50 - netlist = {} - for i in range(num_nets): - # Parallel nets spaced by 10um - netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0)) - metrics = AStarMetrics() - pf = PathFinder( - build_context( - evaluator, - bounds=routing_bounds, - nets=( - NetSpec(net_id=net_id, start=start, target=target, width=2.0) - for net_id, (start, target) in netlist.items() - ), - ), - metrics=metrics, - ) - - print(f"Routing {num_nets} nets...") - start_time = time.monotonic() - results = pf.route_all() - end_time = time.monotonic() - - total_time = end_time - start_time - print(f"Total routing time: {total_time:.2f} s") - print(f"Time per net: {total_time/num_nets:.4f} s") - - if total_time > 0: - nodes_per_sec = metrics.total_nodes_expanded / total_time - print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s") - - # Success rate - successes = sum(1 for r in results.values() if r.is_valid) - print(f"Success rate: {successes/num_nets * 100:.1f}%") - -if __name__ == "__main__": - benchmark_scaling() diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index da4f98c..2e74ec1 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,40 +1,44 @@ from __future__ import annotations -from dataclasses import dataclass from time import perf_counter from typing import Callable from shapely.geometry import Polygon, box -from inire import NetSpec, RoutingResult +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, +) from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router._astar_types import AStarMetrics +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_pathfinder +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) -@dataclass(frozen=True) -class ScenarioOutcome: - duration_s: float - total_results: int - valid_results: int - reached_targets: int - - -@dataclass(frozen=True) -class ScenarioDefinition: - name: str - run: Callable[[], ScenarioOutcome] +ScenarioOutcome = tuple[float, int, int, int] +ScenarioRun = Callable[[], ScenarioOutcome] def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: - return ScenarioOutcome( - duration_s=duration_s, - total_results=len(results), - valid_results=sum(1 for result in results.values() if result.is_valid), - reached_targets=sum(1 for result in results.values() if result.reached_target), + return ( + duration_s, + len(results), + sum(1 for result in results.values() if result.is_valid), + sum(1 for result in results.values() if result.reached_target), ) @@ -66,6 +70,39 @@ def _net_specs( ) +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...], + metrics: AStarMetrics | None = None, + **request_kwargs: object, +) -> PathFinder: + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + _build_options(**request_kwargs), + ), + metrics=metrics, + ) + + def _build_routing_stack( *, bounds: tuple[float, float, float, float], @@ -86,7 +123,7 @@ def _build_routing_stack( evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) metrics = AStarMetrics() - pathfinder = build_pathfinder( + pathfinder = _build_pathfinder( evaluator, bounds=bounds, nets=_net_specs(netlist, widths), @@ -150,9 +187,9 @@ def run_example_03() -> ScenarioOutcome: ) t0 = perf_counter() results_a = pathfinder.route_all() - for polygon in results_a["netA"].as_locked_route().geometry: + for polygon in results_a["netA"].locked_geometry: engine.add_static_obstacle(polygon) - results_b = build_pathfinder( + results_b = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}), @@ -240,7 +277,7 @@ def run_example_06() -> ScenarioOutcome: t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} for evaluator, netlist, net_widths, request_kwargs in scenarios: - pathfinder = build_pathfinder( + pathfinder = _build_pathfinder( evaluator, bounds=bounds, nets=_net_specs(netlist, net_widths), @@ -296,9 +333,7 @@ def run_example_07() -> ScenarioOutcome: ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: - new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) - evaluator.greedy_h_weight = new_greedy - metrics.reset_per_route() + _ = idx, current_results t0 = perf_counter() results = pathfinder.route_all(iteration_callback=iteration_callback) @@ -315,7 +350,7 @@ def run_example_08() -> ScenarioOutcome: custom_evaluator = _build_evaluator(bounds) t0 = perf_counter() - results_std = build_pathfinder( + results_std = _build_pathfinder( standard_evaluator, bounds=bounds, nets=_net_specs(netlist, widths), @@ -324,7 +359,7 @@ def run_example_08() -> ScenarioOutcome: use_tiered_strategy=False, metrics=AStarMetrics(), ).route_all() - results_custom = build_pathfinder( + results_custom = _build_pathfinder( custom_evaluator, bounds=bounds, nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), @@ -351,7 +386,7 @@ def run_example_09() -> ScenarioOutcome: widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False}, ) t0 = perf_counter() results = pathfinder.route_all() @@ -359,14 +394,14 @@ def run_example_09() -> ScenarioOutcome: return _summarize(results, t1 - t0) -SCENARIOS: tuple[ScenarioDefinition, ...] = ( - ScenarioDefinition("example_01_simple_route", run_example_01), - ScenarioDefinition("example_02_congestion_resolution", run_example_02), - ScenarioDefinition("example_03_locked_routes", run_example_03), - ScenarioDefinition("example_04_sbends_and_radii", run_example_04), - ScenarioDefinition("example_05_orientation_stress", run_example_05), - ScenarioDefinition("example_06_bend_collision_models", run_example_06), - ScenarioDefinition("example_07_large_scale_routing", run_example_07), - ScenarioDefinition("example_08_custom_bend_geometry", run_example_08), - ScenarioDefinition("example_09_unroutable_best_effort", run_example_09), +SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( + ("example_01_simple_route", run_example_01), + ("example_02_congestion_resolution", run_example_02), + ("example_03_locked_routes", run_example_03), + ("example_04_sbends_and_radii", run_example_04), + ("example_05_orientation_stress", run_example_05), + ("example_06_bend_collision_models", run_example_06), + ("example_07_large_scale_routing", run_example_07), + ("example_08_custom_bend_geometry", run_example_08), + ("example_09_unroutable_best_effort", run_example_09), ) diff --git a/inire/tests/support.py b/inire/tests/support.py deleted file mode 100644 index 3461790..0000000 --- a/inire/tests/support.py +++ /dev/null @@ -1,162 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable - -from inire.model import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - SearchOptions, -) -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder - - -def build_problem( - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - **overrides: object, -) -> RoutingProblem: - return RoutingProblem( - bounds=bounds, - nets=tuple(nets), - **overrides, - ) - - -def build_request( - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - **overrides: object, -) -> RoutingProblem: - return build_problem(bounds=bounds, nets=nets, **overrides) - - -def build_options( - *, - objective: ObjectiveWeights | None = None, - search: SearchOptions | None = None, - congestion: CongestionOptions | None = None, - refinement: RefinementOptions | None = None, - diagnostics: DiagnosticsOptions | None = None, - **overrides: object, -) -> RoutingOptions: - if objective is None: - objective = ObjectiveWeights() - if search is None: - search = SearchOptions() - if congestion is None: - congestion = CongestionOptions() - if refinement is None: - refinement = RefinementOptions() - if diagnostics is None: - diagnostics = DiagnosticsOptions() - - search_fields = set(SearchOptions.__dataclass_fields__) - congestion_fields = set(CongestionOptions.__dataclass_fields__) - refinement_fields = set(RefinementOptions.__dataclass_fields__) - diagnostics_fields = set(DiagnosticsOptions.__dataclass_fields__) - objective_fields = set(ObjectiveWeights.__dataclass_fields__) - - search_overrides = {key: value for key, value in overrides.items() if key in search_fields} - congestion_overrides = {key: value for key, value in overrides.items() if key in congestion_fields} - refinement_overrides = {key: value for key, value in overrides.items() if key in refinement_fields} - diagnostics_overrides = {key: value for key, value in overrides.items() if key in diagnostics_fields} - objective_overrides = {key: value for key, value in overrides.items() if key in objective_fields} - - unknown = set(overrides) - search_fields - congestion_fields - refinement_fields - diagnostics_fields - objective_fields - if unknown: - unknown_names = ", ".join(sorted(unknown)) - raise TypeError(f"Unsupported RoutingOptions overrides: {unknown_names}") - - resolved_objective = objective if not objective_overrides else ObjectiveWeights( - **{ - field: getattr(objective, field) - for field in objective_fields - } - | objective_overrides - ) - resolved_search = search if not search_overrides else SearchOptions( - **{ - field: getattr(search, field) - for field in search_fields - } - | search_overrides - ) - resolved_congestion = congestion if not congestion_overrides else CongestionOptions( - **{ - field: getattr(congestion, field) - for field in congestion_fields - } - | congestion_overrides - ) - resolved_refinement = refinement if not refinement_overrides else RefinementOptions( - **{ - field: getattr(refinement, field) - for field in refinement_fields - } - | refinement_overrides - ) - resolved_diagnostics = diagnostics if not diagnostics_overrides else DiagnosticsOptions( - **{ - field: getattr(diagnostics, field) - for field in diagnostics_fields - } - | diagnostics_overrides - ) - return RoutingOptions( - search=resolved_search, - objective=resolved_objective, - congestion=resolved_congestion, - refinement=resolved_refinement, - diagnostics=resolved_diagnostics, - ) - - -def build_context( - evaluator, - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - problem: RoutingProblem | None = None, - options: RoutingOptions | None = None, - **overrides: object, -) -> AStarContext: - resolved_problem = problem if problem is not None else build_problem(bounds=bounds, nets=nets) - resolved_options = options if options is not None else build_options(**overrides) - return AStarContext( - evaluator, - resolved_problem, - resolved_options, - ) - - -def build_pathfinder( - evaluator, - *, - bounds: tuple[float, float, float, float], - nets: Iterable[NetSpec] = (), - netlist: dict[str, tuple[object, object]] | None = None, - net_widths: dict[str, float] | None = None, - problem: RoutingProblem | None = None, - options: RoutingOptions | None = None, - **overrides: object, -) -> PathFinder: - resolved_problem = problem - if resolved_problem is None: - resolved_nets = tuple(nets) - if netlist is not None: - widths = {} if net_widths is None else net_widths - resolved_nets = tuple( - NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - resolved_problem = build_problem(bounds=bounds, nets=resolved_nets) - resolved_options = options if options is not None else build_options(**overrides) - return PathFinder(build_context(evaluator, bounds=bounds, problem=resolved_problem, options=resolved_options)) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py index 8474606..858cac9 100644 --- a/inire/tests/test_api.py +++ b/inire/tests/test_api.py @@ -1,9 +1,11 @@ +import importlib + +import pytest from shapely.geometry import box from inire import ( CongestionOptions, DiagnosticsOptions, - LockedRoute, NetSpec, ObjectiveWeights, Port, @@ -16,6 +18,26 @@ from inire import ( from inire.geometry.components import Straight +def test_root_module_exports_only_stable_surface() -> None: + import inire + + assert not hasattr(inire, "RoutingWorld") + assert not hasattr(inire, "AStarContext") + assert not hasattr(inire, "PathFinder") + assert not hasattr(inire, "CostEvaluator") + assert not hasattr(inire, "DangerMap") + + +def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None: + router_module = importlib.import_module("inire.router._router") + search_module = importlib.import_module("inire.router._search") + collision_module = importlib.import_module("inire.geometry.collision") + + assert hasattr(router_module, "PathFinder") + assert hasattr(search_module, "route_astar") + assert hasattr(collision_module, "RoutingWorld") + + def test_route_problem_smoke() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), @@ -44,7 +66,7 @@ def test_route_problem_supports_configs_and_debug_data() -> None: bend_penalty=50.0, sbend_penalty=150.0, ), - congestion=CongestionOptions(warm_start=None), + congestion=CongestionOptions(warm_start_enabled=False), refinement=RefinementOptions(enabled=True), diagnostics=DiagnosticsOptions(capture_expanded=True), ) @@ -61,10 +83,10 @@ def test_route_problem_locked_routes_become_static_obstacles() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),), - locked_routes={"locked": LockedRoute.from_path(locked)}, + static_obstacles=tuple(polygon for component in locked for polygon in component.physical_geometry), ) options = RoutingOptions( - congestion=CongestionOptions(max_iterations=1, warm_start=None), + congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), refinement=RefinementOptions(enabled=False), ) @@ -86,13 +108,22 @@ def test_locked_routes_enable_incremental_requests_without_sessions() -> None: problem_b = RoutingProblem( bounds=(0, -50, 100, 50), nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()}, + static_obstacles=results_a.results_by_net["netA"].locked_geometry, ) results_b = route(problem_b, options=options) assert results_b.results_by_net["netB"].is_valid +def test_route_problem_rejects_untyped_initial_paths() -> None: + with pytest.raises(TypeError): + RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + initial_paths={"net1": (object(),)}, # type: ignore[dict-item] + ) + + def test_route_results_metrics_are_snapshots() -> None: problem = RoutingProblem( bounds=(0, 0, 100, 100), diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index 58597fd..3d637b9 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,16 +1,16 @@ +import math + import pytest from shapely.geometry import Polygon -from inire import RoutingResult +from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions from inire.geometry.components import Bend90, Straight from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_options, build_problem -from inire.utils.validation import validate_routing_result BOUNDS = (0, -50, 150, 150) @@ -23,15 +23,95 @@ def basic_evaluator() -> CostEvaluator: return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _build_options(**search_overrides: object) -> RoutingOptions: + return RoutingOptions(search=SearchOptions(**search_overrides)) + + +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + **search_overrides: object, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds), + _build_options(**search_overrides), + ) + + +def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object): + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options, **config_overrides), + ) + + +def _validate_routing_result( + result: RoutingResult, + static_obstacles: list[Polygon], + clearance: float, + expected_start: Port | None = None, + expected_end: Port | None = None, +) -> dict[str, object]: + if not result.path: + return {"is_valid": False, "reason": "No path found"} + + connectivity_errors: list[str] = [] + if expected_start: + first_port = result.path[0].start_port + dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y) + if dist_to_start > 0.005: + connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") + if abs(first_port.r - expected_start.r) > 0.1: + connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") + + if expected_end: + last_port = result.path[-1].end_port + dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y) + if dist_to_end > 0.005: + connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") + if abs(last_port.r - expected_end.r) > 0.1: + connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") + + engine = RoutingWorld(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + report = engine.verify_path_report("validation", result.path) + is_valid = report.is_valid and not connectivity_errors + + reasons = [] + if report.static_collision_count: + reasons.append(f"Found {report.static_collision_count} obstacle collisions.") + if report.dynamic_collision_count: + reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") + if report.self_collision_count: + reasons.append(f"Found {report.self_collision_count} self-intersections.") + reasons.extend(connectivity_errors) + + return { + "is_valid": is_valid, + "reason": " ".join(reasons), + "obstacle_collisions": report.static_collision_count, + "dynamic_collisions": report.dynamic_collision_count, + "self_intersections": report.self_collision_count, + "total_length": report.total_length, + "connectivity_ok": not connectivity_errors, + } + + def test_astar_straight(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(50, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -40,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0]) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,)) start = Port(0, 0, 0) # 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -61,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" # Path should have detoured, so length > 50 @@ -76,15 +156,15 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(10.1, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) assert target.x == 10 - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -93,7 +173,7 @@ def test_validate_routing_result_checks_expected_start() -> None: path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result( + validation = _validate_routing_result( result, [], clearance=2.0, @@ -110,7 +190,7 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: result = RoutingResult(net_id="test", path=[bend], reached_target=True) obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) - validation = validate_routing_result( + validation = _validate_routing_result( result, [obstacle], clearance=2.0, @@ -122,26 +202,31 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: - basic_evaluator.bend_penalty = 120.0 - basic_evaluator.sbend_penalty = 240.0 - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0]) + basic_evaluator = CostEvaluator( + basic_evaluator.collision_engine, + basic_evaluator.danger_map, + bend_penalty=120.0, + sbend_penalty=240.0, + ) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,)) - assert basic_evaluator.bend_penalty == 120.0 - assert basic_evaluator.sbend_penalty == 240.0 assert context.options.search.bend_radii == (5.0,) assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc") + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc") route_astar( Port(0, 0, 0), Port(30, 10, 0), net_width=2.0, context=context, - bend_collision_type="clipped_bbox", - return_partial=True, + config=SearchRunConfig.from_options( + context.options, + bend_collision_type="clipped_bbox", + return_partial=True, + ), ) assert context.options.search.bend_collision_type == "arc" @@ -151,12 +236,12 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2) start = Port(0, 0, 0) target = Port(60, 0, 0) - partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True) - no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False) + partial_path = _route(context, start, target, return_partial=True) + no_partial_path = _route(context, start, target, return_partial=False) assert partial_path is not None assert partial_path @@ -165,18 +250,18 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: - context = build_context( + context = _build_context( basic_evaluator, bounds=BOUNDS, - bend_radii=[10.0], - sbend_radii=[10.0], - sbend_offsets=[10.0], + bend_radii=(10.0,), + sbend_radii=(10.0,), + sbend_offsets=(10.0,), max_straight_length=150.0, ) start = Port(0, 0, 0) target = Port(100, 10, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None assert path[-1].end_port == target @@ -195,22 +280,22 @@ def test_route_astar_supports_all_visibility_guidance_modes( obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)]) basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = build_context( + context = _build_context( basic_evaluator, bounds=BOUNDS, - bend_radii=[10.0], - sbend_radii=[], + bend_radii=(10.0,), + sbend_radii=(), max_straight_length=150.0, visibility_guidance=visibility_guidance, ) start = Port(0, 0, 0) target = Port(80, 50, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None result = RoutingResult(net_id="test", path=path, reached_target=True) - validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -219,8 +304,8 @@ def test_route_astar_supports_all_visibility_guidance_modes( def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: context = AStarContext( basic_evaluator, - build_problem(bounds=BOUNDS), - build_options( + RoutingProblem(bounds=BOUNDS), + _build_options( min_straight_length=1.0, max_straight_length=100.0, ), @@ -230,6 +315,6 @@ def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_eval targets = [Port(length, 0, 0) for length in range(10, 70, 10)] for target in targets: - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None assert path[-1].end_port == target diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 5866019..67264cc 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,13 +1,41 @@ import pytest import numpy from shapely.geometry import Polygon +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap from inire import RoutingResult -from inire.tests.support import build_pathfinder + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions( + search=SearchOptions() if search is None else search, + congestion=CongestionOptions() if congestion is None else congestion, + ), + ), + ) def test_clearance_thresholds(): """ @@ -27,21 +55,21 @@ def test_clearance_thresholds(): # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. p2_ok = Port(0, 5, 0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_ok]) - assert is_v, f"Gap 3 should be valid, but got {count} collisions" + report_ok = ce.verify_path_report("net2", [res2_ok]) + assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions" # 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK. p2_exact = Port(0, 4, 0) res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_exact]) - assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions" + report_exact = ce.verify_path_report("net2", [res2_exact]) + assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions" # 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL. p2_fail = Port(0, 3, 0) res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_fail]) - assert not is_v, "Gap 1.999 should be invalid" - assert count > 0 + report_fail = ce.verify_path_report("net2", [res2_fail]) + assert not report_fail.is_valid, "Gap 1.999 should be invalid" + assert report_fail.collision_count > 0 def test_verify_all_nets_cases(): """ @@ -59,13 +87,12 @@ def test_verify_all_nets_cases(): } net_widths = {"net1": 2.0, "net2": 2.0} - results = build_pathfinder( + results = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_parallel_ok, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -79,13 +106,12 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = build_pathfinder( + results_p = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_parallel_fail, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() # verify_all_nets should flag both as invalid because they cross-collide assert not results_p["net3"].is_valid @@ -99,13 +125,12 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = build_pathfinder( + results_c = _build_pathfinder( evaluator, bounds=(0, 0, 100, 100), netlist=netlist_cross, net_widths=net_widths, - warm_start=None, - max_iterations=1, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), ).route_all() assert not results_c["net5"].is_valid assert not results_c["net6"].is_valid diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index 8a8c2b9..7eb0e4f 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,65 +1,42 @@ -from shapely.geometry import Polygon - from inire.geometry.collision import RoutingWorld -from inire.geometry.primitives import Port from inire.geometry.components import Straight +from inire.geometry.primitives import Port + + +def _install_static_straight( + engine: RoutingWorld, + start: Port, + length: float, + *, + width: float, + dilation: float = 0.0, +) -> None: + obstacle = Straight.generate(start, length, width=width, dilation=dilation) + for polygon in obstacle.physical_geometry: + engine.add_static_obstacle(polygon) def test_collision_detection() -> None: - # Clearance = 2um engine = RoutingWorld(clearance=2.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0) - # 10x10 um obstacle at (10,10) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) + direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0) + assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port) - # 1. Direct hit - test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)]) - assert engine.is_collision(test_poly, net_width=2.0) + far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0) + assert not engine.check_move_static(far_away, start_port=far_away.start_port) - # 2. Far away - test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]) - assert not engine.is_collision(test_poly_far, net_width=2.0) - - # 3. Near hit (within clearance) - # Obstacle edge at x=10. - # test_poly edge at x=9. - # Distance = 1.0 um. - # Required distance (Wi+C)/2 = 2.0. Collision! - test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)]) - assert engine.is_collision(test_poly_near, net_width=2.0) + near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0) + assert engine.check_move_static(near_hit, start_port=near_hit.start_port) def test_safety_zone() -> None: - # Use zero clearance for this test to verify the 2nm port safety zone - # against the physical obstacle boundary. engine = RoutingWorld(clearance=0.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) - - # Port exactly on the boundary start_port = Port(10, 12, 0) - - # Move starting from this port that overlaps the obstacle by 1nm - # (Inside the 2nm safety zone) - test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)]) - - assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port) - - -def test_configurable_max_net_width() -> None: - # Large max_net_width (10.0) -> large pre-dilation (6.0) - engine = RoutingWorld(clearance=2.0, max_net_width=10.0) - - obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) - engine.add_static_obstacle(obstacle) - - test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)]) - # physical check: dilated test_poly by C/2 = 1.0. - # Dilated test_poly bounds: (14, 19, 17, 26). - # obstacle: (20, 20, 25, 25). No physical collision. - assert not engine.is_collision(test_poly, net_width=2.0) + test_move = Straight.generate(start_port, 0.002, width=0.001) + assert not engine.check_move_static(test_move, start_port=start_port) def test_ray_cast_width_clearance() -> None: @@ -68,8 +45,7 @@ def test_ray_cast_width_clearance() -> None: engine = RoutingWorld(clearance=2.0) # Obstacle at x=10 to 20 - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0) # 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK. start_ok = Port(6, 50, 90) @@ -84,25 +60,24 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: engine = RoutingWorld(clearance=2.0) - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0) # Straight move of length 10 at x=8 (Width 2.0) # Gap = 10 - 8 = 2.0 < 3.0. COLLISION. start = Port(8, 0, 90) res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2 - assert engine.check_move_static(res, start_port=start, net_width=2.0) + assert engine.check_move_static(res, start_port=start) # Move at x=7. Gap = 3.0 == minimum. OK. start_ok = Port(7, 0, 90) res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0) + assert not engine.check_move_static(res_ok, start_port=start_ok) # 3. Same exact-boundary case. start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) + assert not engine.check_move_static(res_exact, start_port=start_exact) def test_verify_path_report_preserves_long_net_id() -> None: @@ -149,8 +124,8 @@ def test_remove_path_clears_dynamic_path() -> None: dilated = [poly for component in path for poly in component.dilated_collision_geometry] engine.add_path("netA", geoms, dilated_geometry=dilated) - assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"} + assert {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} == {"netA"} engine.remove_path("netA") - assert list(engine.iter_dynamic_paths()) == [] + assert list(engine._dynamic_paths.geometries.values()) == [] assert len(engine._static_obstacles.geometries) == 0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index c5b8491..7d3f2eb 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,13 +1,14 @@ import pytest -from shapely.geometry import Polygon +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.model import NetSpec +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._router import PathFinder from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context, build_pathfinder BOUNDS = (0, -40, 100, 40) @@ -21,13 +22,69 @@ def basic_evaluator() -> CostEvaluator: return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...] = (), + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions( + search=SearchOptions() if search is None else search, + congestion=CongestionOptions() if congestion is None else congestion, + ), + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + _build_context( + evaluator, + bounds=bounds, + nets=nets, + search=search, + congestion=congestion, + ), + ) + + +def _route(context: AStarContext, start: Port, target: Port) -> object: + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + + def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: - context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0]) + context = _build_context( + basic_evaluator, + bounds=BOUNDS, + search=SearchOptions(sbend_offsets=(2.0, 5.0)), + ) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) target = Port(50, 2, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None # Check if any component in the path is an SBend @@ -39,38 +96,3 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: found_sbend = True break assert found_sbend - - -def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None: - netlist = { - "net1": (Port(0, 0, 0), Port(50, 0, 0)), - "net2": (Port(0, 10, 0), Port(50, 10, 0)), - } - net_widths = {"net1": 2.0, "net2": 2.0} - pf = build_pathfinder( - basic_evaluator, - bounds=BOUNDS, - netlist=netlist, - net_widths=net_widths, - bend_radii=[5.0, 10.0], - max_iterations=10, - base_penalty=1000.0, - ) - - # Force them into a narrow corridor that only fits ONE. - obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall - obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) - - basic_evaluator.collision_engine.add_static_obstacle(obs_top) - basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) - basic_evaluator.danger_map.precompute([obs_top, obs_bottom]) - - results = pf.route_all() - - assert len(results) == 2 - assert results["net1"].reached_target - assert results["net2"].reached_target - assert results["net1"].is_valid - assert results["net2"].is_valid - assert results["net1"].collisions == 0 - assert results["net2"].collisions == 0 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 7583d42..de9e9f2 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -2,10 +2,11 @@ from __future__ import annotations import os import statistics +from collections.abc import Callable import pytest -from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome +from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" @@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = { def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None: + _, total_results, valid_results, reached_targets = outcome expected = EXPECTED_OUTCOMES[name] - assert outcome.total_results == expected["total_results"] - assert outcome.valid_results == expected["valid_results"] - assert outcome.reached_targets == expected["reached_targets"] + assert total_results == expected["total_results"] + assert valid_results == expected["valid_results"] + assert reached_targets == expected["reached_targets"] @pytest.mark.performance @pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks") -@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS]) -def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None: +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS]) +def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None: + name, run = scenario timings = [] for _ in range(PERFORMANCE_REPEATS): - outcome = scenario.run() - _assert_expected_outcome(scenario.name, outcome) - timings.append(outcome.duration_s) + outcome = run() + _assert_expected_outcome(name, outcome) + timings.append(outcome[0]) median_runtime = statistics.median(timings) - assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, ( - f"{scenario.name} median runtime {median_runtime:.4f}s exceeded " - f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s " + assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, ( + f"{name} median runtime {median_runtime:.4f}s exceeded " + f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s " f"from timings {timings!r}" ) diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index 4e7e7e3..db300ce 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,8 +1,11 @@ +from inire import CongestionOptions, RoutingOptions, RoutingProblem from inire.geometry.primitives import Port from inire.geometry.collision import RoutingWorld +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_pathfinder def test_failed_net_visibility() -> None: """ @@ -35,14 +38,21 @@ def test_failed_net_visibility() -> None: "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} - pf = build_pathfinder( - evaluator, - bounds=(0, 0, 100, 100), - netlist=netlist, - net_widths=net_widths, - node_limit=10, - max_iterations=1, - warm_start=None, + pf = PathFinder( + AStarContext( + evaluator, + RoutingProblem( + bounds=(0, 0, 100, 100), + nets=tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ), + ), + RoutingOptions( + search=RoutingOptions().search.__class__(node_limit=10), + congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), + ), + ), ) # 4. Route @@ -59,9 +69,7 @@ def test_failed_net_visibility() -> None: # 6. Verify Visibility # Check if net1 is in the collision engine - found_nets = set() - for nid, _poly in engine.iter_dynamic_paths(): - found_nets.add(nid) + found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} print(f"Nets found in engine: {found_nets}") diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index 058b277..7c43251 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -6,10 +6,11 @@ from shapely.geometry import Point, Polygon from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port +from inire.model import RoutingOptions, RoutingProblem, SearchOptions +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context @st.composite @@ -35,6 +36,29 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance return all(point.distance(obstacle) >= required_gap for obstacle in obstacles) +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + **search_overrides: object, +) -> AStarContext: + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds), + RoutingOptions(search=SearchOptions(**search_overrides)), + ) + + +def _route(context: AStarContext, start: Port, target: Port): + return route_astar( + start, + target, + net_width=2.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + + @settings(max_examples=3, deadline=None) @given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port()) def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: @@ -48,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability + context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Check if start/target are inside obstacles (safety zone check) # The router should handle this gracefully (either route or return None) try: - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) # This is a crash-smoke test rather than a full correctness proof. # If a full path is returned, it should at least terminate at the requested target. diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 05328ab..773bd56 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,7 +1,15 @@ -import pytest from shapely.geometry import box -from inire import NetSpec +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, +) from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90, Straight from inire.geometry.primitives import Port @@ -9,18 +17,15 @@ from inire.router._astar_types import AStarContext from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_context DEFAULT_BOUNDS = (0, 0, 100, 100) - -@pytest.fixture -def basic_evaluator() -> CostEvaluator: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=DEFAULT_BOUNDS) - danger_map.precompute([]) - return CostEvaluator(engine, danger_map) - +_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"} +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) def _request_nets( netlist: dict[str, tuple[Port, Port]], @@ -32,6 +37,37 @@ def _request_nets( ) +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...] = (), + **request_overrides: object, +) -> AStarContext: + problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS} + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets, **problem_overrides), + _build_options(**option_overrides), + ) + + def _build_pathfinder( evaluator: CostEvaluator, *, @@ -42,7 +78,7 @@ def _build_pathfinder( **request_overrides: object, ) -> PathFinder: return PathFinder( - build_context( + _build_context( evaluator, bounds=bounds, nets=_request_nets(netlist, net_widths), @@ -64,167 +100,6 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[ path.append(comp) curr = comp.end_port return path - - -def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]: - return [ - (component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple()) - for component in path - ] - - -def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: - netlist = { - "net1": (Port(0, 0, 0), Port(50, 0, 0)), - "net2": (Port(0, 10, 0), Port(50, 10, 0)), - } - net_widths = {"net1": 2.0, "net2": 2.0} - pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths) - - results = pf.route_all() - - assert len(results) == 2 - assert results["net1"].is_valid - assert results["net2"].is_valid - assert results["net1"].collisions == 0 - assert results["net2"].collisions == 0 - - -def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: - # Net 1: (0, 25) -> (100, 25) Horizontal - # Net 2: (50, 0) -> (50, 50) Vertical - netlist = { - "net1": (Port(0, 25, 0), Port(100, 25, 0)), - "net2": (Port(50, 0, 90), Port(50, 50, 90)), - } - net_widths = {"net1": 2.0, "net2": 2.0} - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths=net_widths, - max_iterations=1, - base_penalty=1.0, - warm_start=None, - ) - - results = pf.route_all() - - # Both should be invalid because they cross - assert not results["net1"].is_valid - assert not results["net2"].is_valid - assert results["net1"].collisions > 0 - assert results["net2"].collisions > 0 - - -def test_route_all_respects_requested_net_order_in_callback( - basic_evaluator: CostEvaluator, -) -> None: - callback_orders: list[list[str]] = [] - - netlist = { - "short": (Port(0, 0, 0), Port(10, 0, 0)), - "long": (Port(0, 0, 0), Port(40, 10, 0)), - "mid": (Port(0, 0, 0), Port(20, 0, 0)), - } - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths={net_id: 2.0 for net_id in netlist}, - max_iterations=1, - warm_start=None, - sort_nets="longest", - enabled=False, - ) - pf.route_all( - iteration_callback=lambda iteration, results: callback_orders.append(list(results)), - ) - - assert callback_orders == [["long", "mid", "short"]] - - -def test_route_all_invokes_iteration_callback_with_results( - basic_evaluator: CostEvaluator, -) -> None: - callback_results: list[dict[str, object]] = [] - netlist = { - "net1": (Port(0, 0, 0), Port(10, 0, 0)), - "net2": (Port(0, 10, 0), Port(10, 10, 0)), - } - pf = _build_pathfinder( - basic_evaluator, - netlist=netlist, - net_widths={"net1": 2.0, "net2": 2.0}, - ) - - results = pf.route_all( - iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)), - ) - - assert len(callback_results) == 1 - assert set(callback_results[0]) == {"net1", "net2"} - assert callback_results[0]["net1"].is_valid - assert callback_results[0]["net2"].is_valid - assert results["net1"].reached_target - assert results["net2"].reached_target - - -def test_route_all_uses_complete_initial_paths_without_rerouting( - basic_evaluator: CostEvaluator, -) -> None: - start = Port(0, 0, 0) - target = Port(20, 20, 0) - initial_path = _build_manual_path( - start, - 2.0, - basic_evaluator.collision_engine.clearance, - [("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")], - ) - pf = _build_pathfinder( - basic_evaluator, - netlist={"net": (start, target)}, - net_widths={"net": 2.0}, - bend_radii=[5.0], - max_iterations=1, - warm_start=None, - initial_paths={"net": tuple(initial_path)}, - enabled=False, - ) - - result = pf.route_all()["net"] - - assert result.is_valid - assert result.reached_target - assert _path_signature(result.path) == _path_signature(initial_path) - - -def test_route_all_retries_partial_initial_paths_across_iterations( - basic_evaluator: CostEvaluator, -) -> None: - start = Port(0, 0, 0) - target = Port(10, 0, 0) - partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)] - pf = _build_pathfinder( - basic_evaluator, - netlist={"net": (start, target)}, - net_widths={"net": 2.0}, - max_iterations=2, - warm_start=None, - capture_expanded=True, - initial_paths={"net": tuple(partial_path)}, - enabled=False, - ) - iterations: list[int] = [] - - result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"] - - assert iterations == [0, 1] - assert result.is_valid - assert result.reached_target - assert result.outcome == "completed" - assert _path_signature(result.path) != _path_signature(partial_path) - assert pf.accumulated_expanded_nodes - - def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None: netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))} widths = {"net": 2.0} @@ -234,14 +109,14 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No danger_map = DangerMap(bounds=(-20, -20, 60, 60)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = build_context( + context = _build_context( evaluator, bounds=(-20, -20, 60, 60), nets=_request_nets(netlist, widths), bend_radii=[10.0], max_straight_length=50.0, node_limit=50, - warm_start=None, + warm_start_enabled=False, max_iterations=1, enabled=False, ) @@ -264,109 +139,6 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [ (comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path ] - - -def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: - bounds = (0, -50, 100, 50) - - def build_pathfinder( - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - *, - refinement_enabled: bool, - ) -> tuple[RoutingWorld, PathFinder]: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - return engine, _build_pathfinder( - evaluator, - netlist=netlist, - net_widths=net_widths, - bounds=bounds, - bend_radii=[10.0], - enabled=refinement_enabled, - ) - - net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} - width_a = {"netA": 2.0} - net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} - width_b = {"netB": 2.0} - - base_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False) - base_results = base_pf.route_all() - for polygon in base_results["netA"].as_locked_route().geometry: - base_engine.add_static_obstacle(polygon) - base_result = _build_pathfinder( - base_pf.cost_evaluator, - netlist=net_b, - net_widths=width_b, - bounds=bounds, - bend_radii=[10.0], - enabled=False, - ).route_all()["netB"] - - refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True) - refined_results = refined_pf.route_all() - for polygon in refined_results["netA"].as_locked_route().geometry: - refined_engine.add_static_obstacle(polygon) - refined_result = _build_pathfinder( - refined_pf.cost_evaluator, - netlist=net_b, - net_widths=width_b, - bounds=bounds, - bend_radii=[10.0], - enabled=True, - ).route_all()["netB"] - - base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90") - refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90") - - assert base_result.is_valid - assert refined_result.is_valid - assert refined_bends < base_bends - assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path) - - -def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: - bounds = (0, 0, 100, 100) - netlist = { - "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), - "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), - "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), - } - net_widths = {net_id: 2.0 for net_id in netlist} - - def build_pathfinder(*, refinement_enabled: bool) -> PathFinder: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) - return _build_pathfinder( - evaluator, - netlist=netlist, - net_widths=net_widths, - bounds=bounds, - bend_radii=[10.0], - sbend_radii=[10.0], - base_penalty=1000.0, - enabled=refinement_enabled, - ) - - base_results = build_pathfinder(refinement_enabled=False).route_all() - refined_results = build_pathfinder(refinement_enabled=True).route_all() - - for net_id in ("vertical_up", "vertical_down"): - base_result = base_results[net_id] - refined_result = refined_results[net_id] - base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90") - refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90") - - assert base_result.is_valid - assert refined_result.is_valid - assert refined_bends < base_bends - - def test_refine_path_handles_same_orientation_lateral_offset() -> None: engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(-20, -20, 120, 120)) @@ -403,13 +175,13 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None: ) target = path[-1].end_port - refined = pf._refine_path("net", start, target, width, path) + refined = pf.refiner.refine_path("net", start, width, path) assert target == Port(60, 15, 0) assert sum(1 for comp in path if comp.move_type == "bend90") == 6 assert sum(1 for comp in refined if comp.move_type == "bend90") == 4 assert refined[-1].end_port == target - assert pf._path_cost(refined) < pf._path_cost(path) + assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None: @@ -450,10 +222,10 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> ) target = path[-1].end_port - refined = pf._refine_path("net", start, target, width, path) + refined = pf.refiner.refine_path("net", start, width, path) assert target == Port(65, 30, 90) assert sum(1 for comp in path if comp.move_type == "bend90") == 7 assert sum(1 for comp in refined if comp.move_type == "bend90") == 5 assert refined[-1].end_port == target - assert pf._path_cost(refined) < pf._path_cost(path) + assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 3a735b1..56ce7a4 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,9 +1,33 @@ +from inire import RoutingOptions, RoutingProblem, SearchOptions from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90 from inire.geometry.primitives import Port +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.tests.support import build_pathfinder + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions(search=SearchOptions() if search is None else search), + ), + ) def test_arc_resolution_sagitta() -> None: @@ -31,17 +55,17 @@ def test_locked_routes() -> None: # 1. Route Net A netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} - results_a = build_pathfinder( + results_a = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), netlist=netlist_a, net_widths={"netA": 2.0}, - bend_radii=[5.0, 10.0], + search=SearchOptions(bend_radii=(5.0, 10.0)), ).route_all() assert results_a["netA"].is_valid # 2. Treat Net A as locked geometry in the next run. - for polygon in results_a["netA"].as_locked_route().geometry: + for polygon in results_a["netA"].locked_geometry: engine.add_static_obstacle(polygon) # 3. Route Net B through the same space. It should detour or fail. @@ -49,12 +73,12 @@ def test_locked_routes() -> None: netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} # Route Net B - results_b = build_pathfinder( + results_b = _build_pathfinder( evaluator, bounds=(0, -50, 100, 50), netlist=netlist_b, net_widths={"netB": 2.0}, - bend_radii=[5.0, 10.0], + search=SearchOptions(bend_radii=(5.0, 10.0)), ).route_all() # Net B should be is_valid (it detoured) or at least not have collisions diff --git a/inire/tests/test_route_behavior.py b/inire/tests/test_route_behavior.py new file mode 100644 index 0000000..8664a12 --- /dev/null +++ b/inire/tests/test_route_behavior.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from shapely.geometry import Polygon + +from inire import ( + Bend90Seed, + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + PathSeed, + Port, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, + StraightSeed, + route, +) + +DEFAULT_BOUNDS = (0, 0, 100, 100) + +_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"} +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) + + +def _request_nets( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _route_problem( + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + static_obstacles: tuple[Polygon, ...] = (), + iteration_callback=None, + **overrides: object, +): + problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS} + problem = RoutingProblem( + bounds=bounds, + nets=_request_nets(netlist, net_widths), + static_obstacles=static_obstacles, + **problem_overrides, + ) + return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback) + + +def _bend_count(result: RoutingResult) -> int: + return sum(1 for component in result.path if component.move_type == "bend90") + + +def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed: + segments = [] + for kind, value in steps: + if kind == "B": + segments.append(Bend90Seed(radius=5.0, direction=value)) + else: + segments.append(StraightSeed(length=value)) + return PathSeed(tuple(segments)) + + +def test_route_parallel_nets_are_valid() -> None: + run = _route_problem( + netlist={ + "net1": (Port(0, 0, 0), Port(50, 0, 0)), + "net2": (Port(0, 10, 0), Port(50, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + ) + + assert len(run.results_by_net) == 2 + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions == 0 + assert run.results_by_net["net2"].collisions == 0 + + +def test_route_reports_crossing_nets_without_congestion_resolution() -> None: + run = _route_problem( + netlist={ + "net1": (Port(0, 25, 0), Port(100, 25, 0)), + "net2": (Port(50, 0, 90), Port(50, 50, 90)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + max_iterations=1, + base_penalty=1.0, + warm_start_enabled=False, + ) + + assert not run.results_by_net["net1"].is_valid + assert not run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions > 0 + assert run.results_by_net["net2"].collisions > 0 + + +def test_route_callback_respects_requested_net_order() -> None: + callback_orders: list[list[str]] = [] + + _route_problem( + netlist={ + "short": (Port(0, 0, 0), Port(10, 0, 0)), + "long": (Port(0, 0, 0), Port(40, 10, 0)), + "mid": (Port(0, 0, 0), Port(20, 0, 0)), + }, + net_widths={"short": 2.0, "long": 2.0, "mid": 2.0}, + max_iterations=1, + warm_start_enabled=False, + net_order="longest", + enabled=False, + iteration_callback=lambda iteration, results: callback_orders.append(list(results)), + ) + + assert callback_orders == [["long", "mid", "short"]] + + +def test_route_callback_receives_iteration_results() -> None: + callback_results: list[dict[str, RoutingResult]] = [] + + run = _route_problem( + netlist={ + "net1": (Port(0, 0, 0), Port(10, 0, 0)), + "net2": (Port(0, 10, 0), Port(10, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + iteration_callback=lambda iteration, results: callback_results.append(dict(results)), + ) + + assert len(callback_results) == 1 + assert set(callback_results[0]) == {"net1", "net2"} + assert callback_results[0]["net1"].is_valid + assert callback_results[0]["net2"].is_valid + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + + +def test_route_uses_complete_initial_paths_without_rerouting() -> None: + initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")]) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))}, + net_widths={"net": 2.0}, + bend_radii=[5.0], + max_iterations=1, + warm_start_enabled=False, + initial_paths={"net": initial_seed}, + enabled=False, + ) + + result = run.results_by_net["net"] + assert result.is_valid + assert result.reached_target + assert result.as_seed() == initial_seed + + +def test_route_retries_partial_initial_paths_across_iterations() -> None: + iterations: list[int] = [] + partial_seed = PathSeed((StraightSeed(length=5.0),)) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))}, + net_widths={"net": 2.0}, + max_iterations=2, + warm_start_enabled=False, + capture_expanded=True, + initial_paths={"net": partial_seed}, + enabled=False, + iteration_callback=lambda iteration, results: iterations.append(iteration), + ) + + result = run.results_by_net["net"] + assert iterations == [0, 1] + assert result.is_valid + assert result.reached_target + assert result.outcome == "completed" + assert result.as_seed() != partial_seed + assert run.expanded_nodes + + +def test_route_negotiated_congestion_resolution() -> None: + obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) + obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) + run = _route_problem( + bounds=(0, -40, 100, 40), + netlist={ + "net1": (Port(0, 0, 0), Port(50, 0, 0)), + "net2": (Port(0, 10, 0), Port(50, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + static_obstacles=(obs_top, obs_bottom), + bend_radii=(5.0, 10.0), + max_iterations=10, + base_penalty=1000.0, + ) + + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + + +def test_route_refinement_reduces_locked_detour_bends() -> None: + route_a = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))}, + net_widths={"netA": 2.0}, + bend_radii=[10.0], + enabled=False, + ) + locked_geometry = route_a.results_by_net["netA"].locked_geometry + + base_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=False, + ) + refined_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=True, + ) + + base_result = base_run.results_by_net["netB"] + refined_result = refined_run.results_by_net["netB"] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) + + +def test_route_refinement_simplifies_triple_crossing_detours() -> None: + base_run = _route_problem( + bounds=(0, 0, 100, 100), + netlist={ + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + }, + net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=False, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + refined_run = _route_problem( + bounds=(0, 0, 100, 100), + netlist={ + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + }, + net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=True, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + + for net_id in ("vertical_up", "vertical_down"): + base_result = base_run.results_by_net[net_id] + refined_result = refined_run.results_by_net[net_id] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index fbf6ba5..e0acd71 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,10 +1,11 @@ import unittest from inire.geometry.primitives import Port +from inire.model import RoutingOptions, RoutingProblem +from inire.router._astar_types import AStarContext, SearchRunConfig from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.geometry.collision import RoutingWorld -from inire.tests.support import build_context class TestIntegerPorts(unittest.TestCase): @@ -13,12 +14,28 @@ class TestIntegerPorts(unittest.TestCase): self.cost = CostEvaluator(self.ce) self.bounds = (0, 0, 100, 100) + def _build_context(self) -> AStarContext: + return AStarContext( + self.cost, + RoutingProblem(bounds=self.bounds), + RoutingOptions(), + ) + + def _route(self, context: AStarContext, start: Port, target: Port): + return route_astar( + start, + target, + net_width=1.0, + context=context, + config=SearchRunConfig.from_options(context.options), + ) + def test_route_reaches_integer_target(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0, 0, 0) target = Port(12, 0, 0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) last_port = path[-1].end_port @@ -27,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 12) @@ -39,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = build_context(self.cost, bounds=self.bounds) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 8) diff --git a/inire/utils/validation.py b/inire/utils/validation.py deleted file mode 100644 index 9c22dc4..0000000 --- a/inire/utils/validation.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -import numpy - -from inire.geometry.collision import RoutingWorld - -if TYPE_CHECKING: - from shapely.geometry import Polygon - from inire.geometry.primitives import Port - from inire.router.results import RoutingResult - - -def validate_routing_result( - result: RoutingResult, - static_obstacles: list[Polygon], - clearance: float, - expected_start: Port | None = None, - expected_end: Port | None = None, - ) -> dict[str, Any]: - """ - Perform a high-precision validation of a routed path. - - Args: - result: The routing result to validate. - static_obstacles: List of static obstacle geometries. - clearance: Required minimum distance. - expected_start: Optional expected start port. - expected_end: Optional expected end port. - - Returns: - A dictionary with validation results. - """ - if not result.path: - return {"is_valid": False, "reason": "No path found"} - - connectivity_errors = [] - - if expected_start: - first_port = result.path[0].start_port - dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2) - if dist_to_start > 0.005: - connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") - if abs(first_port.r - expected_start.r) > 0.1: - connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") - - if expected_end: - last_port = result.path[-1].end_port - dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2) - if dist_to_end > 0.005: - connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") - if abs(last_port.r - expected_end.r) > 0.1: - connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") - - engine = RoutingWorld(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - report = engine.verify_path_report("validation", result.path) - - is_valid = report.is_valid and len(connectivity_errors) == 0 - - reasons = [] - if report.static_collision_count: - reasons.append(f"Found {report.static_collision_count} obstacle collisions.") - if report.dynamic_collision_count: - reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") - if report.self_collision_count: - reasons.append(f"Found {report.self_collision_count} self-intersections.") - if connectivity_errors: - reasons.extend(connectivity_errors) - - return { - "is_valid": is_valid, - "reason": " ".join(reasons), - "obstacle_collisions": report.static_collision_count, - "dynamic_collisions": report.dynamic_collision_count, - "self_intersections": report.self_collision_count, - "total_length": report.total_length, - "connectivity_ok": len(connectivity_errors) == 0, - } diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 044208a..8268c47 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap - from inire.router.results import RoutingResult + from inire.results import RoutingResult def plot_routing_results(