diff --git a/DOCS.md b/DOCS.md index 60ebdc6..fe4c73d 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,107 +1,159 @@ # Inire Configuration & API Documentation -This document describes the user-tunable parameters for the `inire` auto-router. +This document describes the current public API for `inire`. -## 1. AStarContext Parameters +## 1. Primary API -The `AStarContext` stores the configuration and persistent state for the A* search. It is initialized once and passed to `route_astar` or the `PathFinder`. +### `RoutingProblem` -| Parameter | Type | Default | Description | -| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | -| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. | -| `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. | -| `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). | -| `sbend_radii` | `list[float]` | `[5.0, 10.0, 50.0, 100.0]` | Available radii for S-bends (µm). | -| `sbend_offsets` | `list[float] \| None` | `None` (Auto) | Lateral offsets for parametric S-bends. | -| `bend_penalty` | `float` | 250.0 | Flat cost added for every 90-degree bend. | -| `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. | -| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"` (an 8-point conservative arc proxy). | -| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide for clipped models. | -| `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. | +`RoutingProblem` describes the physical routing problem: -## 2. AStarMetrics +- `bounds` +- `nets` +- `static_obstacles` +- `locked_routes` +- `clearance` +- `max_net_width` +- `safety_zone_radius` -The `AStarMetrics` object collects performance data during the search. +### `RoutingOptions` -| Property | Type | Description | -| :--------------------- | :---- | :---------------------------------------------------- | -| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. | -| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. | -| `max_depth_reached` | `int` | Deepest point in the search tree reached. | +`RoutingOptions` groups all expert controls for the routing engine: ---- +- `search` +- `objective` +- `congestion` +- `refinement` +- `diagnostics` -## 3. CostEvaluator Parameters +Route a problem with: -The `CostEvaluator` defines the "goodness" of a path. +```python +run = route(problem, options=options) +``` -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- | -| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | -| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. | -| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | +If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. ---- +### Incremental routing with `LockedRoute` -## 3. PathFinder Parameters +For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem: -The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. +```python +run_a = route(problem_a) +problem_b = RoutingProblem( + bounds=problem_a.bounds, + nets=(...), + locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()}, +) +run_b = route(problem_b) +``` -| Parameter | Type | Default | Description | -| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | -| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | -| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| -| `refine_paths` | `bool` | `True` | Run the post-route path simplifier that removes unnecessary bend ladders when it finds a valid lower-cost replacement. | +`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. ---- +## 2. Search Options -## 4. CollisionEngine Parameters +`RoutingOptions.search` is a `SearchOptions` object. -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ | -| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | -| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. | +| Field | Default | Description | +| :-- | :-- | :-- | +| `node_limit` | `1_000_000` | Maximum number of states to explore per net. | +| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. | +| `min_straight_length` | `5.0` | Minimum length of a single straight segment. | +| `greedy_h_weight` | `1.5` | Heuristic weight. `1.0` is optimal but slower. | +| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. | +| `sbend_radii` | `(10.0,)` | Available radii for S-bends. | +| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. | +| `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. | +| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | +| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. | ---- +## 3. Objective Weights -## 4. Physical Units & Precision -- **Coordinates**: Micrometers (µm). -- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. -- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. -- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost. +`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`. ---- +| Field | Default | Description | +| :-- | :-- | :-- | +| `unit_length_cost` | `1.0` | Cost per unit length. | +| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. | +| `sbend_penalty` | `500.0` | Flat S-bend penalty. | +| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. | +| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. | -## 5. Best Practices & Tuning Advice +## 4. Congestion Options -### Speed vs. Optimality -The `greedy_h_weight` is your primary lever for search performance. -- **`1.0`**: Dijkstra-like behavior. Guarantees the shortest path but is very slow. -- **`1.1` to `1.2`**: Recommended range. Balances wire length with fast convergence. -- **`> 1.5`**: Extremely fast "greedy" search. May produce zig-zags or suboptimal detours. +`RoutingOptions.congestion` is a `CongestionOptions` object. -### Avoiding "Zig-Zags" -If the router produces many small bends instead of a long straight line: -1. Increase `bend_penalty` (e.g., set to `100.0` or higher). -2. Increase available `bend_radii` if larger turns are physically acceptable. -3. Decrease `greedy_h_weight` closer to `1.0`. +| Field | Default | Description | +| :-- | :-- | :-- | +| `max_iterations` | `10` | Maximum rip-up and reroute iterations. | +| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. | +| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. | +| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. | +| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. | +| `shuffle_nets` | `False` | Shuffle routing order between iterations. | +| `sort_nets` | `None` | Optional deterministic routing order. | +| `seed` | `None` | RNG seed for shuffled routing order. | -### Visibility Guidance -The router can bias straight stop points using static obstacle corners. -- **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space. -- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner. -- **`"off"`**: Disables visibility-derived straight candidates entirely. -The arbitrary-point visibility scan remains available for diagnostics, but the router hot path intentionally uses the exact-corner / tangent-corner forms only. +## 5. Refinement Options -### Handling Congestion -In multi-net designs, if nets are overlapping: -1. Increase `congestion_penalty` in `CostEvaluator`. -2. Increase `max_iterations` in `PathFinder`. -3. If a solution is still not found, check if the `clearance` is physically possible given the design's narrowest bottlenecks. +`RoutingOptions.refinement` is a `RefinementOptions` object. -### S-Bend Usage -Parametric S-bends bridge lateral gaps without changing the waveguide's orientation. -- **Automatic Selection**: If `sbend_offsets` is set to `None` (the default), the router automatically chooses from a set of "natural" offsets (Fibonacci-aligned grid steps) and the offset needed to hit the target. -- **Specific Offsets**: To use specific offsets (e.g., 5.86µm for a 45° switchover), provide them in the `sbend_offsets` list. The router will prioritize these but will still try to align with the target if possible. -- **Constraints**: S-bends are only used for offsets $O < 2R$. For larger shifts, the router naturally combines two 90° bends and a straight segment. +| Field | Default | Description | +| :-- | :-- | :-- | +| `enabled` | `True` | Enable post-route refinement. | +| `objective` | `None` | Optional override objective for refinement. `None` reuses the search objective. | + +## 6. Diagnostics Options + +`RoutingOptions.diagnostics` is a `DiagnosticsOptions` object. + +| Field | Default | Description | +| :-- | :-- | :-- | +| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. | + +## 7. RouteMetrics + +`RoutingRunResult.metrics` is an immutable per-run snapshot. + +| Field | Type | Description | +| :-- | :-- | :-- | +| `nodes_expanded` | `int` | Total nodes expanded during the run. | +| `moves_generated` | `int` | Total candidate moves generated during the run. | +| `moves_added` | `int` | Total candidate moves admitted to the open set during the run. | +| `pruned_closed_set` | `int` | Total moves pruned because the state was already closed at lower cost. | +| `pruned_hard_collision` | `int` | Total moves pruned by hard collision checks. | +| `pruned_cost` | `int` | Total moves pruned by cost ceilings or invalid costs. | + +## 8. Internal Modules + +Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`. + +## 9. Tuning Notes + +### Speed vs. optimality + +- Lower `search.greedy_h_weight` toward `1.0` for better optimality. +- Raise `search.greedy_h_weight` for faster, greedier routing. + +### Congestion handling + +- Increase `congestion.base_penalty` to separate nets more aggressively in the first iteration. +- Increase `congestion.max_iterations` if congestion needs more reroute passes. +- Increase `congestion.multiplier` if later iterations need to escalate more quickly. + +### Bend-heavy routes + +- Increase `objective.bend_penalty` to discourage ladders of small bends. +- Increase available `search.bend_radii` when larger turns are physically acceptable. + +### Visibility guidance + +- `"tangent_corner"` is the default and best general-purpose setting in obstacle-dense layouts. +- `"exact_corner"` is more conservative. +- `"off"` disables visibility-derived straight candidates. + +### S-bends + +- Leave `search.sbend_offsets=None` to let the router derive natural offsets automatically. +- Provide explicit `search.sbend_offsets` for known process-preferred offsets. +- S-bends are only used for offsets smaller than `2R`. diff --git a/README.md b/README.md index b300c77..0da7268 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # inire: Auto-Routing for Photonic and RF Integrated Circuits -`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) to route multiple nets while maintaining strict geometric fidelity and clearance. +`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance. ## Key Features @@ -9,7 +9,7 @@ * **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths. * **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid. * **Safety & Proximity**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk. -* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions. +* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs. ## Installation @@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib ## Quick Start ```python -from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine -from inire.router.danger_map import DangerMap -from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route -# 1. Setup Environment -engine = CollisionEngine(clearance=2.0) -danger_map = DangerMap(bounds=(0, 0, 1000, 1000)) -danger_map.precompute([]) # Add polygons here for obstacles - -# 2. Configure Router -evaluator = CostEvaluator( - collision_engine=engine, - danger_map=danger_map, - greedy_h_weight=1.2 +problem = RoutingProblem( + bounds=(0, 0, 1000, 1000), + nets=( + NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0), + ), ) -context = AStarContext( - cost_evaluator=evaluator, - bend_penalty=10.0 +options = RoutingOptions( + search=SearchOptions( + bend_radii=(50.0, 100.0), + greedy_h_weight=1.2, + ), + objective=ObjectiveWeights( + bend_penalty=10.0, + ), ) -pf = PathFinder(context) -# 3. Define Netlist -netlist = { - "net1": (Port(0, 0, 0), Port(100, 50, 0)), -} +run = route(problem, options=options) -# 4. Route -results = pf.route_all(netlist, {"net1": 2.0}) - -if results["net1"].is_valid: +if run.results_by_net["net1"].is_valid: print("Successfully routed net1!") ``` +For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`. + ## Usage Examples For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**. @@ -82,11 +72,11 @@ Full documentation for all user-tunable parameters, cost functions, and collisio 2. **90° Bends**: Fixed-radius PDK cells. 3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$). -For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. +For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. ## Configuration -`inire` is highly tunable. Every major component (Router, CostEvaluator, PathFinder) accepts explicit named arguments in its constructor to control expansion rules, cost weights, and convergence limits. See `DOCS.md` for a full parameter reference. +`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference. ## License diff --git a/examples/01_simple_route.py b/examples/01_simple_route.py index 96fc4f9..2f43065 100644 --- a/examples/01_simple_route.py +++ b/examples/01_simple_route.py @@ -1,54 +1,29 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 01: Simple Route...") - # 1. Setup Environment - # We define a 100um x 100um routing area bounds = (0, 0, 100, 100) - - # Clearance of 2.0um between waveguides - engine = CollisionEngine(clearance=2.0) - - # Precompute DangerMap for heuristic speedup - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) # No obstacles yet - - # 2. Configure Router - evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # Start at (10, 50) pointing East (0 deg) - # Target at (90, 50) pointing East (0 deg) netlist = { "net1": (Port(10, 50, 0), Port(90, 50, 0)), } - net_widths = {"net1": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("net1", *netlist["net1"], width=2.0),), + ) + options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) - # 4. Route - results = pf.route_all(netlist, net_widths) - - # 5. Check Results - res = results["net1"] - if res.is_valid: + run = route(problem, options=options) + result = run.results_by_net["net1"] + if result.is_valid: print("Success! Route found.") - print(f"Path collisions: {res.collisions}") + print(f"Path collisions: {result.collisions}") else: print("Failed to find route.") - # 6. Visualize - # plot_routing_results takes a dict of RoutingResult objects - fig, ax = plot_routing_results(results, [], bounds) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/01_simple_route.png") print("Saved plot to examples/01_simple_route.png") diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index ffe8343..9d003bc 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -1,49 +1,41 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 02: Congestion Resolution (Triple Crossing)...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # Configure a router with high congestion penalties - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics, base_congestion_penalty=1000.0) - - # 2. Define Netlist - # Three nets that must cross each other in a small area netlist = { "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + sbend_radii=(10.0,), + greedy_h_weight=1.5, + ), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + congestion=CongestionOptions(base_penalty=1000.0), + ) - # 3. Route - # PathFinder uses Negotiated Congestion to resolve overlaps iteratively - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - all_valid = all(res.is_valid for res in results.values()) + run = route(problem, options=options) + all_valid = all(result.is_valid for result in run.results_by_net.values()) if all_valid: print("Success! Congestion resolved for all nets.") else: print("Failed to resolve congestion for some nets.") - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/02_congestion_resolution.png") print("Saved plot to examples/02_congestion_resolution.png") diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 642c6f3..124a172 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -1,42 +1,37 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: - print("Running Example 03: Locked Paths...") + print("Running Example 03: Locked Routes...") - # 1. Setup Environment bounds = (0, -50, 100, 50) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Route Net A and 'Lock' it - # Net A is a straight path blocking the direct route for Net B + options = RoutingOptions( + search=SearchOptions(bend_radii=(10.0,)), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + ) print("Routing initial net...") - netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) - - # Locking prevents Net A from being removed or rerouted during NC iterations - engine.lock_net("netA") - print("Initial net locked as static obstacle.") + results_a = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), + ), + options=options, + ).results_by_net - # 3. Route Net B (forced to detour) print("Routing detour net around locked path...") - netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + locked_routes={"netA": results_a["netA"].as_locked_route()}, + ), + options=options, + ).results_by_net - # 4. Visualize results = {**results_a, **results_b} fig, ax = plot_routing_results(results, [], bounds) fig.savefig("examples/03_locked_paths.png") diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index a88159a..42eac9c 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,60 +1,38 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 04: S-Bends and Multiple Radii...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # 2. Configure Router - evaluator = CostEvaluator( - engine, - danger_map, - unit_length_cost=1.0, - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - context = AStarContext( - evaluator, - node_limit=50000, - bend_radii=[10.0, 30.0], - sbend_offsets=[5.0], # Use a simpler offset - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # start (10, 50), target (60, 55) -> 5um offset netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } - net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=50000, + bend_radii=(10.0, 30.0), + sbend_offsets=(5.0,), + ), + objective=ObjectiveWeights( + unit_length_cost=1.0, + bend_penalty=10.0, + sbend_penalty=20.0, + ), + ) - # 4. Route - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f"{net_id}: {status}, collisions={result.collisions}") - # 5. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f"{nid}: {status}, collisions={res.collisions}") - - # 6. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/04_sbends_and_radii.png") print("Saved plot to examples/04_sbends_and_radii.png") diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index 4e434c8..eab3c0e 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -1,46 +1,32 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 05: Orientation Stress Test...") - # 1. Setup Environment bounds = (0, 0, 200, 200) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0) - context = AStarContext(evaluator, bend_radii=[20.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Define Netlist - # Challenging orientation combinations netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions(bend_radii=(20.0,)), + objective=ObjectiveWeights(bend_penalty=50.0), + ) - # 3. Route print("Routing complex orientation nets...") - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f" {net_id}: {status}") - # 4. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f" {nid}: {status}") - - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/05_orientation_stress.png") print("Saved plot to examples/05_orientation_stress.png") diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index e135118..0df49ea 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -1,11 +1,7 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route from inire.geometry.primitives import Port -from inire.router.astar import AStarContext -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results @@ -15,34 +11,30 @@ def _route_scenario( bend_collision_type: str, netlist: dict[str, tuple[Port, Port]], widths: dict[str, float], - *, - bend_clip_margin: float = 10.0, -) -> dict[str, object]: - engine = CollisionEngine(clearance=2.0) - for obstacle in obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - context = AStarContext( - evaluator, - bend_radii=[10.0], - bend_collision_type=bend_collision_type, - bend_clip_margin=bend_clip_margin, +) -> dict[str, RoutingResult]: + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=widths[net_id]) for net_id, (start, target) in netlist.items()), + static_obstacles=tuple(obstacles), ) - return PathFinder(context, use_tiered_strategy=False).route_all(netlist, widths) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type=bend_collision_type, + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(use_tiered_strategy=False), + ) + return route(problem, options=options).results_by_net def main() -> None: print("Running Example 06: Bend Collision Models...") - # 1. Setup Environment - # Give room for 10um bends near the edges bounds = (-20, -20, 170, 170) - - # Create three scenarios with identical obstacles - # We'll space them out vertically obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]) obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]) obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) @@ -52,29 +44,17 @@ def main() -> None: netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} - # 2. Route each scenario print("Routing Scenario 1 (Arc)...") res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0}) - print("Routing Scenario 2 (BBox)...") res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0}) - print("Routing Scenario 3 (Clipped BBox)...") - res_clipped = _route_scenario( - bounds, - obstacles, - "clipped_bbox", - netlist_clipped, - {"clipped_model": 2.0}, - bend_clip_margin=1.0, - ) + res_clipped = _route_scenario(bounds, obstacles, "clipped_bbox", netlist_clipped, {"clipped_model": 2.0}) - # 3. Combine results for visualization all_results = {**res_arc, **res_bbox, **res_clipped} all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} - # 4. Visualize - fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) fig.savefig("examples/06_bend_collision_models.png") print("Saved plot to examples/06_bend_collision_models.png") diff --git a/examples/07_large_scale_routing.py b/examples/07_large_scale_routing.py index ef92ea2..ea69812 100644 --- a/examples/07_large_scale_routing.py +++ b/examples/07_large_scale_routing.py @@ -1,108 +1,111 @@ -import numpy as np import time -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results, plot_danger_map, plot_expanded_nodes, plot_expansion_density + from shapely.geometry import box +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + Port, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, + route, +) +from inire.utils.visualization import plot_expanded_nodes, plot_routing_results + + def main() -> None: print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...") - # 1. Setup Environment bounds = (0, 0, 1000, 1000) - engine = CollisionEngine(clearance=6.0) - - # Bottleneck at x=500, 200um gap obstacles = [ box(450, 0, 550, 400), box(450, 600, 550, 1000), ] - for obs in obstacles: - engine.add_static_obstacle(obs) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, unit_length_cost=0.1, bend_penalty=100.0, sbend_penalty=400.0, congestion_penalty=100.0) - - context = AStarContext(evaluator, node_limit=2000000, bend_radii=[50.0], sbend_radii=[50.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics, max_iterations=15, base_congestion_penalty=100.0, congestion_multiplier=1.4) - - # 2. Define Netlist - netlist = {} num_nets = 10 start_x = 50 start_y_base = 500 - (num_nets * 10.0) / 2.0 - end_x = 950 end_y_base = 100 end_y_pitch = 800.0 / (num_nets - 1) - for i in range(num_nets): - sy = int(round(start_y_base + i * 10.0)) - ey = int(round(end_y_base + i * end_y_pitch)) - netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0)) + netlist: dict[str, tuple[Port, Port]] = {} + for index in range(num_nets): + start_y = int(round(start_y_base + index * 10.0)) + end_y = int(round(end_y_base + index * end_y_pitch)) + netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0)) - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + static_obstacles=tuple(obstacles), + clearance=6.0, + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=2_000_000, + bend_radii=(50.0,), + sbend_radii=(50.0,), + greedy_h_weight=1.5, + ), + objective=ObjectiveWeights( + unit_length_cost=0.1, + bend_penalty=100.0, + sbend_penalty=400.0, + ), + congestion=CongestionOptions( + max_iterations=15, + base_penalty=100.0, + multiplier=1.4, + shuffle_nets=True, + seed=42, + ), + diagnostics=DiagnosticsOptions(capture_expanded=True), + ) + + iteration_stats: list[dict[str, int]] = [] + + def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None: + successes = sum(1 for result in current_results.values() if result.is_valid) + total_collisions = sum(result.collisions for result in current_results.values()) + print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") + iteration_stats.append( + { + "Iteration": iteration, + "Success": successes, + "Congestion": total_collisions, + } + ) - # 3. Route print(f"Routing {len(netlist)} nets through 200um bottleneck...") + start_time = time.perf_counter() + run = route(problem, options=options, iteration_callback=iteration_callback) + end_time = time.perf_counter() - iteration_stats = [] - - def iteration_callback(idx, current_results): - successes = sum(1 for r in current_results.values() if r.is_valid) - total_collisions = sum(r.collisions for r in current_results.values()) - total_nodes = metrics.nodes_expanded - - print(f" Iteration {idx} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") - - # Adaptive Greediness: Decay from 1.5 to 1.1 over 10 iterations - new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) - evaluator.greedy_h_weight = new_greedy - print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}") - - iteration_stats.append({ - 'Iteration': idx, - 'Success': successes, - 'Congestion': total_collisions, - 'Nodes': total_nodes - }) - metrics.reset_per_route() - - t0 = time.perf_counter() - results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42) - t1 = time.perf_counter() - - print(f"Routing took {t1-t0:.4f}s") - - # 4. Check Results + print(f"Routing took {end_time - start_time:.4f}s") print("\n--- Iteration Summary ---") - print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}") - print("-" * 40) - for s in iteration_stats: - print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}") + print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8}") + print("-" * 30) + for stats in iteration_stats: + print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8}") - success_count = sum(1 for res in results.values() if res.is_valid) + success_count = sum(1 for result in run.results_by_net.values() if result.is_valid) print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.") - - for nid, res in results.items(): - if not res.is_valid: - print(f" FAILED: {nid}, collisions={res.collisions}") + for net_id, result in run.results_by_net.items(): + if not result.is_valid: + print(f" FAILED: {net_id}, collisions={result.collisions}") else: - print(f" {nid}: SUCCESS") - - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) - plot_danger_map(danger_map, ax=ax) + print(f" {net_id}: SUCCESS") + fig, ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist=netlist) + plot_expanded_nodes(list(run.expanded_nodes), ax=ax) fig.savefig("examples/07_large_scale_routing.png") print("Saved plot to examples/07_large_scale_routing.png") + if __name__ == "__main__": main() diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index faff701..5acd82e 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,59 +1,61 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results -def _route_with_context( - context: AStarContext, - metrics: AStarMetrics, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], -) -> dict[str, object]: - return PathFinder(context, metrics, use_tiered_strategy=False).route_all(netlist, net_widths) +def _run_request( + bounds: tuple[float, float, float, float], + bend_collision_type: object, + net_id: str, + start: Port, + target: Port, +) -> dict[str, RoutingResult]: + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec(net_id, start, target, width=2.0),), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type=bend_collision_type, + sbend_radii=(), + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(use_tiered_strategy=False), + ) + return route(problem, options=options).results_by_net def main() -> None: print("Running Example 08: Custom Bend Geometry...") - # 1. Setup Environment bounds = (0, 0, 150, 150) + start = Port(20, 20, 0) + target = Port(100, 100, 90) - # 2. Define Netlist - netlist = { - "custom_bend": (Port(20, 20, 0), Port(100, 100, 90)), - } - net_widths = {"custom_bend": 2.0} - - def build_context(bend_collision_type: object = "arc") -> tuple[AStarContext, AStarMetrics]: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - return AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=bend_collision_type, sbend_radii=[]), AStarMetrics() - - # 3. Route with standard arc first print("Routing with standard arc...") - context_std, metrics_std = build_context() - results_std = _route_with_context(context_std, metrics_std, netlist, net_widths) + results_std = _run_request(bounds, "arc", "custom_bend", start, target) - # 4. Define a custom Manhattan 90-degree bend proxy in bend-local coordinates. - # The polygon origin is the bend center. It is mirrored for CW bends and - # rotated with the bend orientation before being translated into place. custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) print("Routing with custom bend geometry...") - context_custom, metrics_custom = build_context(custom_poly) - results_custom = _route_with_context(context_custom, metrics_custom, {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}) + results_custom = _run_request(bounds, custom_poly, "custom_model", start, target) - # 5. Visualize all_results = {**results_std, **results_custom} - fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results( + all_results, + [], + bounds, + netlist={ + "custom_bend": (start, target), + "custom_model": (start, target), + }, + ) fig.savefig("examples/08_custom_bend_geometry.png") print("Saved plot to examples/08_custom_bend_geometry.png") diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 659a16c..3df1caf 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -1,58 +1,46 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results from shapely.geometry import box +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +from inire.utils.visualization import plot_routing_results + + def main() -> None: print("Running Example 09: Best-Effort Under Tight Search Budget...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - - # A small obstacle cluster keeps the partial route visually interesting. obstacles = [ box(35, 35, 45, 65), box(55, 35, 65, 65), ] - for obs in obstacles: - engine.add_static_obstacle(obs) + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),), + static_obstacles=tuple(obstacles), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=3, + bend_radii=(10.0,), + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(warm_start=None), + ) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - # Keep the search budget intentionally tiny so the router returns a partial path. - context = AStarContext(evaluator, node_limit=3, bend_radii=[10.0]) - metrics = AStarMetrics() - - pf = PathFinder(context, metrics, warm_start=None) - - # 2. Define Netlist: reaching the target requires additional turns the search budget cannot afford. - netlist = { - "budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180)), - } - net_widths = {"budget_limited_net": 2.0} - - # 3. Route print("Routing with a deliberately tiny node budget (should return a partial path)...") - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - res = results["budget_limited_net"] - if not res.reached_target: - print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.") + run = route(problem, options=options) + result = run.results_by_net["budget_limited_net"] + if not result.reached_target: + print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.") else: print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.") - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}) fig.savefig("examples/09_unroutable_best_effort.png") print("Saved plot to examples/09_unroutable_best_effort.png") + if __name__ == "__main__": main() diff --git a/inire/__init__.py b/inire/__init__.py index 5fb5ffb..7529ac7 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,8 +1,43 @@ """ inire Wave-router """ +from .api import ( + CongestionOptions as CongestionOptions, + DiagnosticsOptions as DiagnosticsOptions, + LockedRoute as LockedRoute, + NetSpec as NetSpec, + ObjectiveWeights as ObjectiveWeights, + RefinementOptions as RefinementOptions, + RoutingOptions as RoutingOptions, + RoutingProblem as RoutingProblem, + RoutingRunResult as RoutingRunResult, + SearchOptions as SearchOptions, + route as route, +) # noqa: PLC0414 from .geometry.primitives import Port as Port # noqa: PLC0414 from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 +from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414 __author__ = 'Jan Petykiewicz' __version__ = '0.1' + +__all__ = [ + "Bend90", + "CongestionOptions", + "DiagnosticsOptions", + "LockedRoute", + "NetSpec", + "ObjectiveWeights", + "Port", + "RefinementOptions", + "RoutingOptions", + "RoutingProblem", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", + "RouteMetrics", + "SBend", + "SearchOptions", + "Straight", + "route", +] diff --git a/inire/api.py b/inire/api.py new file mode 100644 index 0000000..9f83f63 --- /dev/null +++ b/inire/api.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.geometry.collision import RoutingWorld +from inire.model import ( + CongestionOptions, + DiagnosticsOptions, + LockedRoute, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingRunResult, + SearchOptions, +) +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.results import RouteMetrics, RoutingReport, RoutingResult + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from shapely.geometry import Polygon + + +__all__ = [ + "CongestionOptions", + "DiagnosticsOptions", + "LockedRoute", + "NetSpec", + "ObjectiveWeights", + "RefinementOptions", + "RouteMetrics", + "RoutingOptions", + "RoutingProblem", + "RoutingReport", + "RoutingResult", + "RoutingRunResult", + "SearchOptions", + "route", +] + + +def _iter_locked_polygons( + locked_routes: dict[str, LockedRoute], +) -> Iterable[Polygon]: + for route in locked_routes.values(): + yield from route.geometry + + +def _build_context(problem: RoutingProblem, options: RoutingOptions) -> AStarContext: + world = RoutingWorld( + clearance=problem.clearance, + max_net_width=problem.max_net_width, + safety_zone_radius=problem.safety_zone_radius, + ) + for obstacle in problem.static_obstacles: + world.add_static_obstacle(obstacle) + for polygon in _iter_locked_polygons(problem.locked_routes): + world.add_static_obstacle(polygon) + + danger_obstacles = list(problem.static_obstacles) + danger_obstacles.extend(_iter_locked_polygons(problem.locked_routes)) + danger_map = DangerMap(bounds=problem.bounds) + danger_map.precompute(danger_obstacles) + + objective = options.objective + evaluator = CostEvaluator( + world, + danger_map, + unit_length_cost=objective.unit_length_cost, + greedy_h_weight=options.search.greedy_h_weight, + bend_penalty=objective.bend_penalty, + sbend_penalty=objective.sbend_penalty, + danger_weight=objective.danger_weight, + ) + return AStarContext(evaluator, problem, options) + + +def route( + problem: RoutingProblem, + *, + options: RoutingOptions | None = None, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, +) -> RoutingRunResult: + resolved_options = RoutingOptions() if options is None else options + finder = PathFinder(_build_context(problem, resolved_options)) + results = finder.route_all(iteration_callback=iteration_callback) + return RoutingRunResult( + results_by_net=results, + metrics=finder.metrics.snapshot(), + expanded_nodes=tuple(finder.accumulated_expanded_nodes), + ) diff --git a/inire/constants.py b/inire/constants.py index cdc2f62..1e0c0be 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -3,7 +3,7 @@ Centralized constants for the inire routing engine. """ # Search Grid Snap (5.0 µm default) -# TODO: Make this configurable in RouterConfig and define tolerances relative to the grid. +# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid. DEFAULT_SEARCH_GRID_SNAP_UM = 5.0 # Tolerances diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 663d919..b8a72fe 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -3,67 +3,69 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal import numpy +from shapely.geometry import LineString, box -from inire.geometry.collision_query_checker import CollisionQueryChecker -from inire.geometry.dynamic_congestion_checker import DynamicCongestionChecker +from inire.geometry.component_overlap import components_overlap from inire.geometry.dynamic_path_index import DynamicPathIndex -from inire.geometry.path_verifier import PathVerificationReport, PathVerifier -from inire.geometry.ray_caster import RayCaster +from inire.geometry.index_helpers import grid_cell_span from inire.geometry.static_obstacle_index import StaticObstacleIndex -from inire.geometry.static_move_checker import StaticMoveChecker +from inire.router.results import RoutingReport if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence from shapely.geometry import Polygon + from shapely.geometry.base import BaseGeometry from shapely.strtree import STRtree from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port -class CollisionEngine: +def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float: + if hasattr(geometry, "geoms"): + return min(_intersection_distance(origin, sub_geometry) for sub_geometry in geometry.geoms) + return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) + + +class RoutingWorld: """ - Manages spatial queries for collision detection with unified dilation logic. + Internal spatial state for collision detection, congestion, and verification. """ + __slots__ = ( - 'clearance', 'max_net_width', 'safety_zone_radius', - 'metrics', 'grid_cell_size', '_inv_grid_cell_size', '_dynamic_bounds_array', - '_path_verifier', '_dynamic_paths', '_static_obstacles', '_ray_caster', '_static_move_checker', - '_dynamic_congestion_checker', '_collision_query_checker', + "clearance", + "max_net_width", + "safety_zone_radius", + "metrics", + "grid_cell_size", + "_dynamic_paths", + "_static_obstacles", ) def __init__( - self, - clearance: float, - max_net_width: float = 2.0, - safety_zone_radius: float = 0.0021, - ) -> None: + self, + clearance: float, + max_net_width: float = 2.0, + safety_zone_radius: float = 0.0021, + ) -> None: self.clearance = clearance self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius self.grid_cell_size = 50.0 - self._inv_grid_cell_size = 1.0 / self.grid_cell_size self._static_obstacles = StaticObstacleIndex(self) - - self._dynamic_bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4) self._dynamic_paths = DynamicPathIndex(self) self.metrics = { - 'static_cache_hits': 0, - 'static_grid_skips': 0, - 'static_tree_queries': 0, - 'static_straight_fast': 0, - 'congestion_grid_skips': 0, - 'congestion_tree_queries': 0, - 'safety_zone_checks': 0 + "static_cache_hits": 0, + "static_grid_skips": 0, + "static_tree_queries": 0, + "static_straight_fast": 0, + "congestion_grid_skips": 0, + "congestion_tree_queries": 0, + "safety_zone_checks": 0, } - self._path_verifier = PathVerifier(self) - self._ray_caster = RayCaster(self) - self._static_move_checker = StaticMoveChecker(self) - self._dynamic_congestion_checker = DynamicCongestionChecker(self) - self._collision_query_checker = CollisionQueryChecker(self) def get_static_version(self) -> int: return self._static_obstacles.version @@ -89,23 +91,22 @@ class CollisionEngine: return self._dynamic_paths.geometries.values() def reset_metrics(self) -> None: - for k in self.metrics: - self.metrics[k] = 0 + for key in self.metrics: + self.metrics[key] = 0 def get_metrics_summary(self) -> str: - m = self.metrics - return (f"Collision Performance: \n" - f" Static: {m['static_tree_queries']} checks\n" - f" Congestion: {m['congestion_tree_queries']} checks\n" - f" Safety Zone: {m['safety_zone_checks']} full intersections performed") + metrics = self.metrics + return ( + "Collision Performance: \n" + f" Static: {metrics['static_tree_queries']} checks\n" + f" Congestion: {metrics['congestion_tree_queries']} checks\n" + f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed" + ) def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: - """ - Remove a static obstacle by ID. - """ self._static_obstacles.remove_obstacle(obj_id) def _invalidate_static_caches(self) -> None: @@ -115,9 +116,6 @@ class CollisionEngine: self._static_obstacles.ensure_tree() def _ensure_net_static_tree(self, net_width: float) -> STRtree: - """ - Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance). - """ return self._static_obstacles.ensure_net_tree(net_width) def _ensure_static_raw_tree(self) -> None: @@ -125,7 +123,6 @@ class CollisionEngine: def _ensure_dynamic_tree(self) -> None: self._dynamic_paths.ensure_tree() - self._dynamic_bounds_array = self._dynamic_paths.bounds_array def _ensure_dynamic_grid(self) -> None: self._dynamic_paths.ensure_grid() @@ -134,45 +131,28 @@ class CollisionEngine: self._dynamic_paths.tree = None self._ensure_dynamic_tree() - def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) def remove_path(self, net_id: str) -> None: self._dynamic_paths.remove_path(net_id) - def lock_net(self, net_id: str) -> None: - """ Convert a routed net into static obstacles. """ - self._dynamic_paths.lock_net(net_id) - - def unlock_net(self, net_id: str) -> None: - self._dynamic_paths.unlock_net(net_id) - def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - return self._static_move_checker.check_move_straight_static(start_port, length, net_width) + self.metrics["static_straight_fast"] += 1 + reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) + return reach < length - 0.001 def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - return self._static_move_checker.is_in_safety_zone_fast(idx, start_port, end_port) - - def check_move_static( - self, - result: ComponentResult, - start_port: Port | None = None, - end_port: Port | None = None, - net_width: float | None = None, - ) -> bool: - return self._static_move_checker.check_move_static( - result, - start_port=start_port, - end_port=end_port, - net_width=net_width, + bounds = self._static_obstacles.bounds_array[idx] + safety_zone = self.safety_zone_radius + if start_port and bounds[0] - safety_zone <= start_port.x <= bounds[2] + safety_zone and bounds[1] - safety_zone <= start_port.y <= bounds[3] + safety_zone: + return True + return bool( + end_port + and bounds[0] - safety_zone <= end_port.x <= bounds[2] + safety_zone + and bounds[1] - safety_zone <= end_port.y <= bounds[3] + safety_zone ) - def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - return self._dynamic_congestion_checker.check_move_congestion(result, net_id) - - def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - return self._dynamic_congestion_checker.check_real_congestion(result, net_id) - def _is_in_safety_zone( self, geometry: Polygon, @@ -180,7 +160,247 @@ class CollisionEngine: start_port: Port | None, end_port: Port | None, ) -> bool: - return self._static_move_checker.is_in_safety_zone(geometry, obj_id, start_port, end_port) + raw_obstacle = self._static_obstacles.geometries[obj_id] + safety_zone = self.safety_zone_radius + + obstacle_bounds = raw_obstacle.bounds + near_start = start_port and ( + obstacle_bounds[0] - safety_zone <= start_port.x <= obstacle_bounds[2] + safety_zone + and obstacle_bounds[1] - safety_zone <= start_port.y <= obstacle_bounds[3] + safety_zone + ) + near_end = end_port and ( + obstacle_bounds[0] - safety_zone <= end_port.x <= obstacle_bounds[2] + safety_zone + and obstacle_bounds[1] - safety_zone <= end_port.y <= obstacle_bounds[3] + safety_zone + ) + + if not near_start and not near_end: + return False + if not geometry.intersects(raw_obstacle): + return False + + self.metrics["safety_zone_checks"] += 1 + intersection = geometry.intersection(raw_obstacle) + if intersection.is_empty: + return False + + ix_bounds = intersection.bounds + if ( + start_port + and near_start + and abs(ix_bounds[0] - start_port.x) < safety_zone + and abs(ix_bounds[1] - start_port.y) < safety_zone + and abs(ix_bounds[2] - start_port.x) < safety_zone + and abs(ix_bounds[3] - start_port.y) < safety_zone + ): + return True + return bool( + end_port + and near_end + and abs(ix_bounds[0] - end_port.x) < safety_zone + and abs(ix_bounds[1] - end_port.y) < safety_zone + and abs(ix_bounds[2] - end_port.x) < safety_zone + and abs(ix_bounds[3] - end_port.y) < safety_zone + ) + + def check_move_static( + self, + result: ComponentResult, + start_port: Port | None = None, + end_port: Port | None = None, + net_width: float | None = None, + ) -> bool: + del net_width + + static_obstacles = self._static_obstacles + if not static_obstacles.dilated: + return False + + self.metrics["static_tree_queries"] += 1 + self._ensure_static_tree() + + hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) + if hits.size == 0: + return False + + static_bounds = static_obstacles.bounds_array + move_poly_bounds = result.dilated_bounds + for hit_idx in hits: + obstacle_bounds = static_bounds[hit_idx] + poly_hits_obstacle_aabb = False + for poly_bounds in move_poly_bounds: + if ( + poly_bounds[0] < obstacle_bounds[2] + and poly_bounds[2] > obstacle_bounds[0] + and poly_bounds[1] < obstacle_bounds[3] + and poly_bounds[3] > obstacle_bounds[1] + ): + poly_hits_obstacle_aabb = True + break + + if not poly_hits_obstacle_aabb: + continue + + obj_id = static_obstacles.obj_ids[hit_idx] + if self._is_in_safety_zone_fast(hit_idx, start_port, end_port): + collision_found = False + for polygon in result.collision_geometry: + if not self._is_in_safety_zone(polygon, obj_id, start_port, end_port): + collision_found = True + break + if collision_found: + return True + continue + + static_obstacle = static_obstacles.dilated[obj_id] + for polygon in result.dilated_collision_geometry: + if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle): + return True + + return False + + def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: + dynamic_paths = self._dynamic_paths + self.metrics["congestion_tree_queries"] += 1 + self._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + total_bounds = result.total_dilated_bounds + dynamic_bounds = dynamic_paths.bounds_array + possible_total = ( + (total_bounds[0] < dynamic_bounds[:, 2]) + & (total_bounds[2] > dynamic_bounds[:, 0]) + & (total_bounds[1] < dynamic_bounds[:, 3]) + & (total_bounds[3] > dynamic_bounds[:, 1]) + ) + + valid_hits_mask = dynamic_paths.net_ids_array != net_id + if not numpy.any(possible_total & valid_hits_mask): + return 0 + + geometries_to_test = result.dilated_collision_geometry + res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects") + if tree_indices.size == 0: + return 0 + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) + if unique_other_nets.size == 0: + return 0 + + tree_geometries = dynamic_paths.tree.geometries + real_hits_count = 0 + for other_net_id in unique_other_nets: + other_mask = hit_net_ids == other_net_id + sub_tree_indices = tree_indices[other_mask] + sub_res_indices = res_indices[other_mask] + + found_real = False + for index in range(len(sub_tree_indices)): + test_geometry = geometries_to_test[sub_res_indices[index]] + tree_geometry = tree_geometries[sub_tree_indices[index]] + if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7: + found_real = True + break + + if found_real: + real_hits_count += 1 + + return real_hits_count + + def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: + dynamic_paths = self._dynamic_paths + if not dynamic_paths.geometries: + return 0 + + total_bounds = result.total_dilated_bounds + self._ensure_dynamic_grid() + dynamic_grid = dynamic_paths.grid + if not dynamic_grid: + return 0 + + gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size) + + if gx_min == gx_max and gy_min == gy_max: + cell = (gx_min, gy_min) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_paths.geometries[obj_id][0] != net_id: + return self._check_real_congestion(result, net_id) + return 0 + + any_possible = False + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + cell = (gx, gy) + if cell in dynamic_grid: + for obj_id in dynamic_grid[cell]: + if dynamic_paths.geometries[obj_id][0] != net_id: + any_possible = True + break + if any_possible: + break + if any_possible: + break + + if not any_possible: + return 0 + return self._check_real_congestion(result, net_id) + + def _check_static_collision( + self, + geometry: Polygon, + start_port: Port | None = None, + end_port: Port | None = None, + dilated_geometry: Polygon | None = None, + ) -> bool: + static_obstacles = self._static_obstacles + self._ensure_static_tree() + if static_obstacles.tree is None: + return False + + if dilated_geometry is not None: + test_geometry = dilated_geometry + else: + distance = self.clearance / 2.0 + test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry + + hits = static_obstacles.tree.query(test_geometry, predicate="intersects") + tree_geometries = static_obstacles.tree.geometries + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = static_obstacles.obj_ids[hit_idx] + if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): + continue + return True + return False + + def _check_dynamic_collision( + self, + geometry: Polygon, + net_id: str, + dilated_geometry: Polygon | None = None, + ) -> int: + dynamic_paths = self._dynamic_paths + self._ensure_dynamic_tree() + if dynamic_paths.tree is None: + return 0 + + test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) + hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") + tree_geometries = dynamic_paths.tree.geometries + hit_net_ids: list[str] = [] + for hit_idx in hits: + if test_geometry.touches(tree_geometries[hit_idx]): + continue + obj_id = dynamic_paths.obj_ids[hit_idx] + other_net_id = dynamic_paths.geometries[obj_id][0] + if other_net_id != net_id: + hit_net_ids.append(other_net_id) + if not hit_net_ids: + return 0 + return len(numpy.unique(hit_net_ids)) def check_collision( self, @@ -193,16 +413,16 @@ class CollisionEngine: bounds: tuple[float, float, float, float] | None = None, net_width: float | None = None, ) -> bool | int: - return self._collision_query_checker.check_collision( - geometry, - net_id, - buffer_mode=buffer_mode, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - bounds=bounds, - net_width=net_width, - ) + del bounds, net_width + + if buffer_mode == "static": + return self._check_static_collision( + geometry, + start_port=start_port, + end_port=end_port, + dilated_geometry=dilated_geometry, + ) + return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) def is_collision( self, @@ -212,7 +432,6 @@ class CollisionEngine: start_port: Port | None = None, end_port: Port | None = None, ) -> bool: - """ Unified entry point for static collision checks. """ result = self.check_collision( geometry, net_id, @@ -223,12 +442,157 @@ class CollisionEngine: ) return bool(result) - def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: - return self._path_verifier.verify_path_report(net_id, components) + def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: + static_collision_count = 0 + dynamic_collision_count = 0 + self_collision_count = 0 + total_length = sum(component.length for component in components) - def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]: + static_obstacles = self._static_obstacles + dynamic_paths = self._dynamic_paths + + self._ensure_static_raw_tree() + if static_obstacles.raw_tree is not None: + raw_geometries = static_obstacles.raw_tree.geometries + for component in components: + for polygon in component.physical_geometry: + buffered = polygon.buffer(self.clearance, join_style=2) + hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") + for hit_idx in hits: + obstacle = raw_geometries[hit_idx] + if buffered.touches(obstacle): + continue + + obj_id = static_obstacles.raw_obj_ids[hit_idx] + if not self._is_in_safety_zone(polygon, obj_id, None, None): + static_collision_count += 1 + + self._ensure_dynamic_tree() + if dynamic_paths.tree is not None: + tree_geometries = dynamic_paths.tree.geometries + for component in components: + test_geometries = component.dilated_physical_geometry + res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects") + if tree_indices.size == 0: + continue + + hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) + component_hits = [] + for index in range(len(tree_indices)): + if hit_net_ids[index] == str(net_id): + continue + + new_geometry = test_geometries[res_indices[index]] + tree_geometry = tree_geometries[tree_indices[index]] + if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7: + component_hits.append(hit_net_ids[index]) + + if component_hits: + dynamic_collision_count += len(numpy.unique(component_hits)) + + for index, component in enumerate(components): + for other_index in range(index + 2, len(components)): + if components_overlap(component, components[other_index], prefer_actual=True): + self_collision_count += 1 + + return RoutingReport( + static_collision_count=static_collision_count, + dynamic_collision_count=dynamic_collision_count, + self_collision_count=self_collision_count, + total_length=total_length, + ) + + def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]: report = self.verify_path_report(net_id, components) return report.is_valid, report.collision_count - def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: - return self._ray_caster.ray_cast(origin, angle_deg, max_dist=max_dist, net_width=net_width) + def ray_cast( + self, + origin: Port, + angle_deg: float, + max_dist: float = 2000.0, + net_width: float | None = None, + ) -> float: + static_obstacles = self._static_obstacles + + radians = numpy.radians(angle_deg) + cos_v, sin_v = numpy.cos(radians), numpy.sin(radians) + dx, dy = max_dist * cos_v, max_dist * sin_v + min_x, max_x = sorted([origin.x, origin.x + dx]) + min_y, max_y = sorted([origin.y, origin.y + dy]) + + if net_width is not None: + tree = self._ensure_net_static_tree(net_width) + key = (round(net_width, 4), round(self.clearance, 4)) + is_rect_array = static_obstacles.net_specific_is_rect[key] + bounds_array = static_obstacles.net_specific_bounds[key] + else: + self._ensure_static_tree() + tree = static_obstacles.tree + is_rect_array = static_obstacles.is_rect_array + bounds_array = static_obstacles.bounds_array + + if tree is None: + return max_dist + + candidates = tree.query(box(min_x, min_y, max_x, max_y)) + if candidates.size == 0: + return max_dist + + min_dist = max_dist + inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 + inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 + tree_geometries = tree.geometries + ray_line = None + + candidates_bounds = bounds_array[candidates] + dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 + sorted_indices = numpy.argsort(dist_sq) + + for idx in sorted_indices: + candidate_id = candidates[idx] + bounds = bounds_array[candidate_id] + + if abs(dx) < 1e-12: + if origin.x < bounds[0] or origin.x > bounds[2]: + tx_min, tx_max = 1e30, -1e30 + else: + tx_min, tx_max = -1e30, 1e30 + else: + t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx + tx_min, tx_max = min(t1, t2), max(t1, t2) + + if abs(dy) < 1e-12: + if origin.y < bounds[1] or origin.y > bounds[3]: + ty_min, ty_max = 1e30, -1e30 + else: + ty_min, ty_max = -1e30, 1e30 + else: + t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy + ty_min, ty_max = min(t1, t2), max(t1, t2) + + t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) + if t_max < 0 or t_min > t_max or t_min > 1.0: + continue + if t_min * max_dist >= min_dist: + continue + + if is_rect_array[candidate_id]: + min_dist = max(0.0, t_min * max_dist) + continue + + if ray_line is None: + ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) + + obstacle = tree_geometries[candidate_id] + if not obstacle.intersects(ray_line): + continue + + intersection = ray_line.intersection(obstacle) + if intersection.is_empty: + continue + + distance = _intersection_distance(origin, intersection) + min_dist = min(min_dist, distance) + + return min_dist diff --git a/inire/geometry/collision_query_checker.py b/inire/geometry/collision_query_checker.py deleted file mode 100644 index 7ccdf65..0000000 --- a/inire/geometry/collision_query_checker.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -import numpy - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.collision import CollisionEngine - from inire.geometry.primitives import Port - - -class CollisionQueryChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - bounds: tuple[float, float, float, float] | None = None, - net_width: float | None = None, - ) -> bool | int: - del bounds, net_width - - if buffer_mode == "static": - return self._check_static_collision( - geometry, - start_port=start_port, - end_port=end_port, - dilated_geometry=dilated_geometry, - ) - return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry) - - def _check_static_collision( - self, - geometry: Polygon, - start_port: Port | None = None, - end_port: Port | None = None, - dilated_geometry: Polygon | None = None, - ) -> bool: - engine = self.engine - static_obstacles = engine._static_obstacles - engine._ensure_static_tree() - if static_obstacles.tree is None: - return False - - if dilated_geometry is not None: - test_geometry = dilated_geometry - else: - distance = engine.clearance / 2.0 - test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry - - hits = static_obstacles.tree.query(test_geometry, predicate="intersects") - tree_geometries = static_obstacles.tree.geometries - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = static_obstacles.obj_ids[hit_idx] - if engine._is_in_safety_zone(geometry, obj_id, start_port, end_port): - continue - return True - return False - - def _check_dynamic_collision( - self, - geometry: Polygon, - net_id: str, - dilated_geometry: Polygon | None = None, - ) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - engine._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(engine.clearance / 2.0) - hits = dynamic_paths.tree.query(test_geometry, predicate="intersects") - tree_geometries = dynamic_paths.tree.geometries - hit_net_ids: list[str] = [] - for hit_idx in hits: - if test_geometry.touches(tree_geometries[hit_idx]): - continue - obj_id = dynamic_paths.obj_ids[hit_idx] - other_net_id = dynamic_paths.geometries[obj_id][0] - if other_net_id != net_id: - hit_net_ids.append(other_net_id) - if not hit_net_ids: - return 0 - return len(numpy.unique(hit_net_ids)) diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py index 44e1ec9..e2049ac 100644 --- a/inire/geometry/component_overlap.py +++ b/inire/geometry/component_overlap.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]: - if prefer_actual and component.actual_geometry is not None: - return component.actual_geometry - return component.geometry + if prefer_actual: + return list(component.physical_geometry) + return list(component.collision_geometry) def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]: diff --git a/inire/geometry/components.py b/inire/geometry/components.py index abd9367..d098041 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Literal import numpy @@ -12,61 +13,52 @@ from inire.constants import TOLERANCE_ANGULAR from .primitives import Port, rotation_matrix2 +MoveKind = Literal["straight", "bend90", "sbend"] +BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] +BendCollisionModel = BendCollisionModelName | Polygon + + def _normalize_length(value: float) -> float: return float(value) +@dataclass(frozen=True, slots=True) class ComponentResult: - __slots__ = ( - "start_port", - "geometry", - "dilated_geometry", - "proxy_geometry", - "actual_geometry", - "dilated_actual_geometry", - "end_port", - "length", - "move_type", - "_bounds", - "_total_bounds", - "_dilated_bounds", - "_total_dilated_bounds", - ) + start_port: Port + collision_geometry: tuple[Polygon, ...] + end_port: Port + length: float + move_type: MoveKind + physical_geometry: tuple[Polygon, ...] + dilated_collision_geometry: tuple[Polygon, ...] + dilated_physical_geometry: tuple[Polygon, ...] + _bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) + _total_bounds: tuple[float, float, float, float] = field(init=False, repr=False) + _dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False) + _total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False) - def __init__( - self, - start_port: Port, - geometry: list[Polygon], - end_port: Port, - length: float, - move_type: str, - dilated_geometry: list[Polygon] | None = None, - proxy_geometry: list[Polygon] | None = None, - actual_geometry: list[Polygon] | None = None, - dilated_actual_geometry: list[Polygon] | None = None, - ) -> None: - self.start_port = start_port - self.geometry = geometry - self.dilated_geometry = dilated_geometry - self.proxy_geometry = proxy_geometry - self.actual_geometry = actual_geometry - self.dilated_actual_geometry = dilated_actual_geometry - self.end_port = end_port - self.length = float(length) - self.move_type = move_type + def __post_init__(self) -> None: + collision_geometry = tuple(self.collision_geometry) + physical_geometry = tuple(self.physical_geometry) + dilated_collision_geometry = tuple(self.dilated_collision_geometry) + dilated_physical_geometry = tuple(self.dilated_physical_geometry) - self._bounds = [poly.bounds for poly in self.geometry] - self._total_bounds = _combine_bounds(self._bounds) + object.__setattr__(self, "collision_geometry", collision_geometry) + object.__setattr__(self, "physical_geometry", physical_geometry) + object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry) + object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry) + object.__setattr__(self, "length", float(self.length)) - if self.dilated_geometry is None: - self._dilated_bounds = None - self._total_dilated_bounds = None - else: - self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry] - self._total_dilated_bounds = _combine_bounds(self._dilated_bounds) + bounds = tuple(poly.bounds for poly in collision_geometry) + object.__setattr__(self, "_bounds", bounds) + object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds))) + + dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry) + object.__setattr__(self, "_dilated_bounds", dilated_bounds) + object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds))) @property - def bounds(self) -> list[tuple[float, float, float, float]]: + def bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._bounds @property @@ -74,28 +66,23 @@ class ComponentResult: return self._total_bounds @property - def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None: + def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._dilated_bounds @property - def total_dilated_bounds(self) -> tuple[float, float, float, float] | None: + def total_dilated_bounds(self) -> tuple[float, float, float, float]: return self._total_dilated_bounds def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( - start_port=self.start_port + [dx, dy, 0], - geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry], - end_port=self.end_port + [dx, dy, 0], + start_port=self.start_port.translate(dx, dy), + collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry], + end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, - dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry], - proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry], - actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry], - dilated_actual_geometry=( - None - if self.dilated_actual_geometry is None - else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry] - ), + physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], + dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], + dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], ) @@ -144,16 +131,13 @@ def _get_arc_polygons( return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] -def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon: +def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: """Return a conservative 8-point polygonal proxy for the arc. The polygon uses 4 points along the outer edge and 4 along the inner edge. The outer edge is a circumscribed polyline and the inner edge is an inscribed polyline, so the result conservatively contains the true arc. - `clip_margin` is kept for API compatibility but is not used by this proxy. """ - del clip_margin - cx, cy = cxy sample_count = 4 angle_span = abs(float(ts[1]) - float(ts[0])) @@ -194,11 +178,10 @@ def _transform_custom_collision_polygon( def _apply_collision_model( arc_poly: Polygon, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, + collision_type: BendCollisionModel, radius: float, width: float, cxy: tuple[float, float], - clip_margin: float, ts: tuple[float, float], rotation_deg: float = 0.0, mirror_y: bool = False, @@ -208,7 +191,7 @@ def _apply_collision_model( if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": - clipped = _clip_bbox(cxy, radius, width, ts, clip_margin) + clipped = _clip_bbox(cxy, radius, width, ts) return [clipped if not clipped.is_empty else box(*arc_poly.bounds)] return [box(*arc_poly.bounds)] @@ -231,7 +214,6 @@ class Straight: poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y)) geometry = [Polygon(poly_points)] - dilated_geometry = None if dilation > 0: half_w_d = half_w + dilation pts_d = numpy.array( @@ -244,16 +226,18 @@ class Straight: ) poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y)) dilated_geometry = [Polygon(poly_points_d)] + else: + dilated_geometry = geometry return ComponentResult( start_port=start_port, - geometry=geometry, + collision_geometry=geometry, end_port=end_port, length=abs(length_f), - move_type="Straight", - dilated_geometry=dilated_geometry, - actual_geometry=geometry, - dilated_actual_geometry=dilated_geometry, + move_type="straight", + physical_geometry=geometry, + dilated_collision_geometry=dilated_geometry, + dilated_physical_geometry=dilated_geometry, ) @@ -265,8 +249,7 @@ class Bend90: width: float, direction: Literal["CW", "CCW"], sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) @@ -290,32 +273,18 @@ class Bend90: radius, width, (float(center_xy[0]), float(center_xy[1])), - clip_margin, ts, rotation_deg=float(start_port.r), mirror_y=(sign < 0), ) - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = _apply_collision_model( - arc_polys[0], - "clipped_bbox", - radius, - width, - (float(center_xy[0]), float(center_xy[1])), - clip_margin, - ts, - ) - - dilated_actual_geometry = None - dilated_geometry = None + physical_geometry = collision_polys if uses_custom_geometry else arc_polys if dilation > 0: if uses_custom_geometry: - dilated_actual_geometry = [poly.buffer(dilation) for poly in collision_polys] - dilated_geometry = dilated_actual_geometry + dilated_physical_geometry = [poly.buffer(dilation) for poly in collision_polys] + dilated_collision_geometry = dilated_physical_geometry else: - dilated_actual_geometry = _get_arc_polygons( + dilated_physical_geometry = _get_arc_polygons( (float(center_xy[0]), float(center_xy[1])), radius, width, @@ -323,18 +292,22 @@ class Bend90: sagitta, dilation=dilation, ) - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = collision_polys return ComponentResult( start_port=start_port, - geometry=collision_polys, + collision_geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, - move_type="Bend90", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=collision_polys if uses_custom_geometry else arc_polys, - dilated_actual_geometry=dilated_actual_geometry, + move_type="bend90", + physical_geometry=physical_geometry, + dilated_collision_geometry=dilated_collision_geometry, + dilated_physical_geometry=dilated_physical_geometry, ) @@ -346,8 +319,7 @@ class SBend: radius: float, width: float, sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: @@ -383,7 +355,6 @@ class SBend: radius, width, (float(c1_xy[0]), float(c1_xy[1])), - clip_margin, ts1, rotation_deg=float(start_port.r), mirror_y=(sign < 0), @@ -394,41 +365,36 @@ class SBend: radius, width, (float(c2_xy[0]), float(c2_xy[1])), - clip_margin, ts2, rotation_deg=float(start_port.r), mirror_y=(sign > 0), )[0], ] - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = [ - _apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0], - _apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], - ] - - dilated_actual_geometry = None - dilated_geometry = None + physical_geometry = geometry if uses_custom_geometry else actual_geometry if dilation > 0: if uses_custom_geometry: - dilated_actual_geometry = [poly.buffer(dilation) for poly in geometry] - dilated_geometry = dilated_actual_geometry + dilated_physical_geometry = [poly.buffer(dilation) for poly in geometry] + dilated_collision_geometry = dilated_physical_geometry else: - dilated_actual_geometry = [ + dilated_physical_geometry = [ _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], ] - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = geometry return ComponentResult( start_port=start_port, - geometry=geometry, + collision_geometry=geometry, end_port=end_port, length=2.0 * radius * theta, - move_type="SBend", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=geometry if uses_custom_geometry else actual_geometry, - dilated_actual_geometry=dilated_actual_geometry, + move_type="sbend", + physical_geometry=physical_geometry, + dilated_collision_geometry=dilated_collision_geometry, + dilated_physical_geometry=dilated_physical_geometry, ) diff --git a/inire/geometry/dynamic_congestion_checker.py b/inire/geometry/dynamic_congestion_checker.py deleted file mode 100644 index 4778550..0000000 --- a/inire/geometry/dynamic_congestion_checker.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - - -class DynamicCongestionChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - if not dynamic_paths.geometries: - return 0 - - total_bounds = result.total_dilated_bounds - if total_bounds is None: - return 0 - - engine._ensure_dynamic_grid() - dynamic_grid = dynamic_paths.grid - if not dynamic_grid: - return 0 - - cell_size_inv = engine._inv_grid_cell_size - gx_min = int(total_bounds[0] * cell_size_inv) - gy_min = int(total_bounds[1] * cell_size_inv) - gx_max = int(total_bounds[2] * cell_size_inv) - gy_max = int(total_bounds[3] * cell_size_inv) - - dynamic_geometries = dynamic_paths.geometries - - if gx_min == gx_max and gy_min == gy_max: - cell = (gx_min, gy_min) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - return self.check_real_congestion(result, net_id) - return 0 - - any_possible = False - for gx in range(gx_min, gx_max + 1): - for gy in range(gy_min, gy_max + 1): - cell = (gx, gy) - if cell in dynamic_grid: - for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: - any_possible = True - break - if any_possible: - break - if any_possible: - break - - if not any_possible: - return 0 - return self.check_real_congestion(result, net_id) - - def check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - engine = self.engine - dynamic_paths = engine._dynamic_paths - engine.metrics["congestion_tree_queries"] += 1 - engine._ensure_dynamic_tree() - if dynamic_paths.tree is None: - return 0 - - total_bounds = result.total_dilated_bounds - dynamic_bounds = engine._dynamic_bounds_array - possible_total = ( - (total_bounds[0] < dynamic_bounds[:, 2]) - & (total_bounds[2] > dynamic_bounds[:, 0]) - & (total_bounds[1] < dynamic_bounds[:, 3]) - & (total_bounds[3] > dynamic_bounds[:, 1]) - ) - - valid_hits_mask = dynamic_paths.net_ids_array != net_id - if not numpy.any(possible_total & valid_hits_mask): - return 0 - - geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry - res_indices, tree_indices = dynamic_paths.tree.query(geoms_to_test, predicate="intersects") - if tree_indices.size == 0: - return 0 - - hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) - unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) - if unique_other_nets.size == 0: - return 0 - - tree_geometries = dynamic_paths.tree.geometries - real_hits_count = 0 - - for other_net_id in unique_other_nets: - other_mask = hit_net_ids == other_net_id - sub_tree_indices = tree_indices[other_mask] - sub_res_indices = res_indices[other_mask] - - found_real = False - for index in range(len(sub_tree_indices)): - test_geometry = geoms_to_test[sub_res_indices[index]] - tree_geometry = tree_geometries[sub_tree_indices[index]] - if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7: - found_real = True - break - - if found_real: - real_hits_count += 1 - - return real_hits_count diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py index e96bb1e..c19ff77 100644 --- a/inire/geometry/dynamic_path_index.py +++ b/inire/geometry/dynamic_path_index.py @@ -6,11 +6,14 @@ import numpy import rtree from shapely.strtree import STRtree -if TYPE_CHECKING: - from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry +from inire.geometry.index_helpers import build_index_payload, iter_grid_cells - from inire.geometry.collision import CollisionEngine +if TYPE_CHECKING: + from collections.abc import Sequence + + from shapely.geometry import Polygon + + from inire.geometry.collision import RoutingWorld class DynamicPathIndex: @@ -19,47 +22,38 @@ class DynamicPathIndex: "index", "geometries", "dilated", - "prepared", "tree", "obj_ids", "grid", "id_counter", - "tree_dirty", "net_ids_array", "bounds_array", - "locked_nets", ) - def __init__(self, engine: CollisionEngine) -> None: + def __init__(self, engine: RoutingWorld) -> None: self.engine = engine self.index = rtree.index.Index() self.geometries: dict[int, tuple[str, Polygon]] = {} self.dilated: dict[int, Polygon] = {} - self.prepared: dict[int, PreparedGeometry] = {} self.tree: STRtree | None = None self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) self.grid: dict[tuple[int, int], list[int]] = {} self.id_counter = 0 - self.tree_dirty = True - self.net_ids_array = numpy.array([], dtype=" None: self.tree = None self.grid = {} - self.tree_dirty = True def ensure_tree(self) -> None: if self.tree is None and self.dilated: - ids = sorted(self.dilated.keys()) - geometries = [self.dilated[i] for i in ids] + ids, geometries, bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) self.obj_ids = numpy.array(ids, dtype=numpy.int32) - self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) + self.bounds_array = bounds_array net_ids = [self.geometries[obj_id][0] for obj_id in self.obj_ids] - self.net_ids_array = numpy.array(net_ids, dtype=" None: if self.grid or not self.dilated: @@ -67,27 +61,20 @@ class DynamicPathIndex: cell_size = self.engine.grid_cell_size for obj_id, polygon in self.dilated.items(): - bounds = polygon.bounds - for gx in range(int(bounds[0] / cell_size), int(bounds[2] / cell_size) + 1): - for gy in range(int(bounds[1] / cell_size), int(bounds[3] / cell_size) + 1): - cell = (gx, gy) - self.grid.setdefault(cell, []).append(obj_id) + for cell in iter_grid_cells(polygon.bounds, cell_size): + self.grid.setdefault(cell, []).append(obj_id) - def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: self.invalidate_queries() - dilation = self.engine.clearance / 2.0 for index, polygon in enumerate(geometry): obj_id = self.id_counter self.id_counter += 1 - dilated = dilated_geometry[index] if dilated_geometry else polygon.buffer(dilation) + dilated = dilated_geometry[index] self.geometries[obj_id] = (net_id, polygon) self.dilated[obj_id] = dilated self.index.insert(obj_id, dilated.bounds) def remove_path(self, net_id: str) -> None: - if net_id in self.locked_nets: - return - to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] self.remove_obj_ids(to_remove) @@ -101,14 +88,7 @@ class DynamicPathIndex: del self.geometries[obj_id] del self.dilated[obj_id] - def lock_net(self, net_id: str) -> None: - self.locked_nets.add(net_id) - to_move = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] - for obj_id in to_move: - polygon = self.geometries[obj_id][1] - dilated = self.dilated[obj_id] - self.engine.add_static_obstacle(polygon, dilated_geometry=dilated) - self.remove_obj_ids(to_move) - - def unlock_net(self, net_id: str) -> None: - self.locked_nets.discard(net_id) + def clear_paths(self) -> None: + if not self.geometries: + return + self.remove_obj_ids(list(self.geometries)) diff --git a/inire/geometry/index_helpers.py b/inire/geometry/index_helpers.py new file mode 100644 index 0000000..dad186b --- /dev/null +++ b/inire/geometry/index_helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import math +from collections.abc import Iterator, Mapping +from typing import TypeVar + +import numpy + +GeometryT = TypeVar("GeometryT") + + +def build_index_payload( + geometries: Mapping[int, GeometryT], +) -> tuple[list[int], list[GeometryT], numpy.ndarray]: + obj_ids = sorted(geometries) + ordered_geometries = [geometries[obj_id] for obj_id in obj_ids] + bounds_array = numpy.array([geometry.bounds for geometry in ordered_geometries], dtype=numpy.float64) + if not ordered_geometries: + bounds_array = bounds_array.reshape(0, 4) + return obj_ids, ordered_geometries, bounds_array + + +def grid_cell_span( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> tuple[int, int, int, int]: + return ( + math.floor(bounds[0] / cell_size), + math.floor(bounds[1] / cell_size), + math.floor(bounds[2] / cell_size), + math.floor(bounds[3] / cell_size), + ) + + +def iter_grid_cells( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> Iterator[tuple[int, int]]: + gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, cell_size) + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + yield (gx, gy) + + +def is_axis_aligned_rect(geometry, *, tolerance: float = 1e-4) -> bool: + bounds = geometry.bounds + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + return abs(geometry.area - area) < tolerance diff --git a/inire/geometry/path_verifier.py b/inire/geometry/path_verifier.py deleted file mode 100644 index 273cbd6..0000000 --- a/inire/geometry/path_verifier.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -import numpy - -from inire.geometry.component_overlap import components_overlap - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - - -@dataclass(frozen=True) -class PathVerificationReport: - static_collision_count: int - dynamic_collision_count: int - self_collision_count: int - total_length: float - - @property - def collision_count(self) -> int: - return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count - - @property - def is_valid(self) -> bool: - return self.collision_count == 0 - -class PathVerifier: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport: - """ - Non-approximated, full-polygon intersection check of a path against all - static obstacles, other nets, and itself. - """ - static_collision_count = 0 - dynamic_collision_count = 0 - self_collision_count = 0 - total_length = sum(component.length for component in components) - - engine = self.engine - static_obstacles = engine._static_obstacles - dynamic_paths = engine._dynamic_paths - - # 1. Check against static obstacles. - engine._ensure_static_raw_tree() - if static_obstacles.raw_tree is not None: - raw_geoms = static_obstacles.raw_tree.geometries - for comp in components: - polygons = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry - for polygon in polygons: - # Physical separation must be >= clearance. - buffered = polygon.buffer(engine.clearance, join_style=2) - hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") - for hit_idx in hits: - obstacle = raw_geoms[hit_idx] - # If they only touch, gap is exactly clearance. Valid. - if buffered.touches(obstacle): - continue - - obj_id = static_obstacles.raw_obj_ids[hit_idx] - if not engine._is_in_safety_zone(polygon, obj_id, None, None): - static_collision_count += 1 - - # 2. Check against other nets. - engine._ensure_dynamic_tree() - if dynamic_paths.tree is not None: - tree_geoms = dynamic_paths.tree.geometries - for comp in components: - # Robust fallback chain to ensure crossings are caught even with zero clearance. - test_geometries = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry - if not test_geometries: - continue - - if not isinstance(test_geometries, list | tuple | numpy.ndarray): - test_geometries = [test_geometries] - - res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects") - if tree_indices.size == 0: - continue - - hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices) - comp_hits = [] - for i in range(len(tree_indices)): - if hit_net_ids[i] == str(net_id): - continue - - p_new = test_geometries[res_indices[i]] - p_tree = tree_geoms[tree_indices[i]] - if not p_new.touches(p_tree) and p_new.intersection(p_tree).area > 1e-7: - comp_hits.append(hit_net_ids[i]) - - if comp_hits: - dynamic_collision_count += len(numpy.unique(comp_hits)) - - # 3. Check for self collisions between non-adjacent components. - for i, comp_i in enumerate(components): - for j in range(i + 2, len(components)): - if components_overlap(comp_i, components[j], prefer_actual=True): - self_collision_count += 1 - - return PathVerificationReport( - static_collision_count=static_collision_count, - dynamic_collision_count=dynamic_collision_count, - self_collision_count=self_collision_count, - total_length=total_length, - ) diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index b6d6b9c..30055ea 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,10 +1,10 @@ from __future__ import annotations -from collections.abc import Iterator +from dataclasses import dataclass from typing import Self import numpy -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray def _normalize_angle(angle_deg: int | float) -> int: @@ -13,119 +13,43 @@ def _normalize_angle(angle_deg: int | float) -> int: raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}") return angle - -def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]: - arr = numpy.asarray(value, dtype=numpy.int32) - if arr.shape != (3,): - raise ValueError(f"Port array must have shape (3,), got {arr.shape}") - arr = arr.copy() - arr[2] = _normalize_angle(int(arr[2])) - return arr - - +@dataclass(frozen=True, slots=True) class Port: """ - Port represented as an ndarray-backed (x, y, r) triple with int32 storage. + Port represented as a normalized integer (x, y, r) triple. """ - __slots__ = ("_xyr",) + x: int | float + y: int | float + r: int | float - def __init__(self, x: int | float, y: int | float, r: int | float) -> None: - self._xyr = numpy.array( - (int(round(x)), int(round(y)), _normalize_angle(r)), - dtype=numpy.int32, - ) - - @classmethod - def from_array(cls, xyr: ArrayLike) -> Self: - obj = cls.__new__(cls) - obj._xyr = _as_int32_triplet(xyr) - return obj - - @property - def x(self) -> int: - return int(self._xyr[0]) - - @x.setter - def x(self, val: int | float) -> None: - self._xyr[0] = int(round(val)) - - @property - def y(self) -> int: - return int(self._xyr[1]) - - @y.setter - def y(self, val: int | float) -> None: - self._xyr[1] = int(round(val)) - - @property - def r(self) -> int: - return int(self._xyr[2]) - - @r.setter - def r(self, val: int | float) -> None: - self._xyr[2] = _normalize_angle(val) - - @property - def orientation(self) -> int: - return self.r - - @orientation.setter - def orientation(self, val: int | float) -> None: - self.r = val - - @property - def xyr(self) -> NDArray[numpy.int32]: - return self._xyr - - @xyr.setter - def xyr(self, val: ArrayLike) -> None: - self._xyr = _as_int32_triplet(val) - - def __repr__(self) -> str: - return f"Port(x={self.x}, y={self.y}, r={self.r})" - - def __iter__(self) -> Iterator[int]: - return iter((self.x, self.y, self.r)) - - def __len__(self) -> int: - return 3 - - def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]: - return self._xyr[item] - - def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]: - return numpy.asarray(self._xyr, dtype=dtype) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Port): - return False - return bool(numpy.array_equal(self._xyr, other._xyr)) - - def __hash__(self) -> int: - return hash(self.as_tuple()) - - def copy(self) -> Self: - return type(self).from_array(self._xyr.copy()) + def __post_init__(self) -> None: + object.__setattr__(self, "x", int(round(self.x))) + object.__setattr__(self, "y", int(round(self.y))) + object.__setattr__(self, "r", _normalize_angle(self.r)) def as_tuple(self) -> tuple[int, int, int]: - return (self.x, self.y, self.r) + return (int(self.x), int(self.y), int(self.r)) - def translate(self, dxy: ArrayLike) -> Self: - dxy_arr = numpy.asarray(dxy, dtype=numpy.int32) - if dxy_arr.shape == (2,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r) - if dxy_arr.shape == (3,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2])) - raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}") + def translate( + self, + dx: int | float = 0, + dy: int | float = 0, + rotation: int | float = 0, + ) -> Self: + return type(self)(self.x + dx, self.y + dy, self.r + rotation) - def __add__(self, other: ArrayLike) -> Self: - return self.translate(other) - - def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]: - if isinstance(other, Port): - return self._xyr - other._xyr - return self._xyr - numpy.asarray(other, dtype=numpy.int32) + def rotated( + self, + angle: int | float, + origin: tuple[int | float, int | float] = (0, 0), + ) -> Self: + angle_i = _normalize_angle(angle) + rot = rotation_matrix2(angle_i) + origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32) + rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy + rotated = origin_xy + rot @ rel + return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i) ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32) @@ -145,16 +69,3 @@ def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: rot3[:2, :2] = rot2 rot3[2, 2] = 1 return rot3 - - -def translate_port(port: Port, dx: int | float, dy: int | float) -> Port: - return Port(port.x + dx, port.y + dy, port.r) - - -def rotate_port(port: Port, angle: int | float, origin: tuple[int | float, int | float] = (0, 0)) -> Port: - angle_i = _normalize_angle(angle) - rot = rotation_matrix2(angle_i) - origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32) - rel = numpy.array((port.x, port.y), dtype=numpy.int32) - origin_xy - rotated = origin_xy + rot @ rel - return Port(int(rotated[0]), int(rotated[1]), port.r + angle_i) diff --git a/inire/geometry/ray_caster.py b/inire/geometry/ray_caster.py deleted file mode 100644 index a09ecf5..0000000 --- a/inire/geometry/ray_caster.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy -from shapely.geometry import LineString, box - -if TYPE_CHECKING: - from shapely.geometry.base import BaseGeometry - - from inire.geometry.collision import CollisionEngine - from inire.geometry.primitives import Port - - -class RayCaster: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: - engine = self.engine - static_obstacles = engine._static_obstacles - - rad = numpy.radians(angle_deg) - cos_v, sin_v = numpy.cos(rad), numpy.sin(rad) - dx, dy = max_dist * cos_v, max_dist * sin_v - min_x, max_x = sorted([origin.x, origin.x + dx]) - min_y, max_y = sorted([origin.y, origin.y + dy]) - - key = None - if net_width is not None: - tree = engine._ensure_net_static_tree(net_width) - key = (round(net_width, 4), round(engine.clearance, 4)) - is_rect_arr = static_obstacles.net_specific_is_rect[key] - bounds_arr = static_obstacles.net_specific_bounds[key] - else: - engine._ensure_static_tree() - tree = static_obstacles.tree - is_rect_arr = static_obstacles.is_rect_array - bounds_arr = static_obstacles.bounds_array - - if tree is None: - return max_dist - - candidates = tree.query(box(min_x, min_y, max_x, max_y)) - if candidates.size == 0: - return max_dist - - min_dist = max_dist - inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 - inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 - tree_geoms = tree.geometries - ray_line = None - - # Distance to the AABB min corner is a cheap ordering heuristic. - candidates_bounds = bounds_arr[candidates] - dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 - sorted_indices = numpy.argsort(dist_sq) - - for idx in sorted_indices: - candidate_id = candidates[idx] - bounds = bounds_arr[candidate_id] - - if abs(dx) < 1e-12: - if origin.x < bounds[0] or origin.x > bounds[2]: - tx_min, tx_max = 1e30, -1e30 - else: - tx_min, tx_max = -1e30, 1e30 - else: - t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx - tx_min, tx_max = min(t1, t2), max(t1, t2) - - if abs(dy) < 1e-12: - if origin.y < bounds[1] or origin.y > bounds[3]: - ty_min, ty_max = 1e30, -1e30 - else: - ty_min, ty_max = -1e30, 1e30 - else: - t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy - ty_min, ty_max = min(t1, t2), max(t1, t2) - - t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) - if t_max < 0 or t_min > t_max or t_min > 1.0: - continue - if t_min * max_dist >= min_dist: - continue - - if is_rect_arr[candidate_id]: - min_dist = max(0.0, t_min * max_dist) - continue - - if ray_line is None: - ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) - - obstacle = tree_geoms[candidate_id] - if not obstacle.intersects(ray_line): - continue - - intersection = ray_line.intersection(obstacle) - if intersection.is_empty: - continue - - distance = self._intersection_distance(origin, intersection) - min_dist = min(min_dist, distance) - - return min_dist - - def _intersection_distance(self, origin: Port, geometry: BaseGeometry) -> float: - if hasattr(geometry, "geoms"): - return min(self._intersection_distance(origin, sub_geom) for sub_geom in geometry.geoms) - return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) diff --git a/inire/geometry/static_move_checker.py b/inire/geometry/static_move_checker.py deleted file mode 100644 index f70cb88..0000000 --- a/inire/geometry/static_move_checker.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from shapely.geometry import box - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.collision import CollisionEngine - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - - -class StaticMoveChecker: - __slots__ = ("engine",) - - def __init__(self, engine: CollisionEngine) -> None: - self.engine = engine - - def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - engine = self.engine - engine.metrics["static_straight_fast"] += 1 - reach = engine.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) - return reach < length - 0.001 - - def is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - engine = self.engine - sz = engine.safety_zone_radius - bounds = engine._static_obstacles.bounds_array[idx] - if start_port and bounds[0] - sz <= start_port.x <= bounds[2] + sz and bounds[1] - sz <= start_port.y <= bounds[3] + sz: - return True - return bool( - end_port - and bounds[0] - sz <= end_port.x <= bounds[2] + sz - and bounds[1] - sz <= end_port.y <= bounds[3] + sz - ) - - def check_move_static( - self, - result: ComponentResult, - start_port: Port | None = None, - end_port: Port | None = None, - net_width: float | None = None, - ) -> bool: - del net_width - - engine = self.engine - static_obstacles = engine._static_obstacles - if not static_obstacles.dilated: - return False - - engine.metrics["static_tree_queries"] += 1 - engine._ensure_static_tree() - - total_bounds = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds - hits = static_obstacles.tree.query(box(*total_bounds)) - if hits.size == 0: - return False - - static_bounds = static_obstacles.bounds_array - move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds - for hit_idx in hits: - obstacle_bounds = static_bounds[hit_idx] - poly_hits_obstacle_aabb = False - for poly_bounds in move_poly_bounds: - if ( - poly_bounds[0] < obstacle_bounds[2] - and poly_bounds[2] > obstacle_bounds[0] - and poly_bounds[1] < obstacle_bounds[3] - and poly_bounds[3] > obstacle_bounds[1] - ): - poly_hits_obstacle_aabb = True - break - - if not poly_hits_obstacle_aabb: - continue - - obj_id = static_obstacles.obj_ids[hit_idx] - if self.is_in_safety_zone_fast(hit_idx, start_port, end_port): - collision_found = False - for polygon in result.geometry: - if not self.is_in_safety_zone(polygon, obj_id, start_port, end_port): - collision_found = True - break - if collision_found: - return True - continue - - test_geometries = result.dilated_geometry if result.dilated_geometry else result.geometry - static_obstacle = static_obstacles.dilated[obj_id] - for polygon in test_geometries: - if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle): - return True - - return False - - def is_in_safety_zone( - self, - geometry: Polygon, - obj_id: int, - start_port: Port | None, - end_port: Port | None, - ) -> bool: - engine = self.engine - raw_obstacle = engine._static_obstacles.geometries[obj_id] - sz = engine.safety_zone_radius - - obstacle_bounds = raw_obstacle.bounds - near_start = start_port and ( - obstacle_bounds[0] - sz <= start_port.x <= obstacle_bounds[2] + sz - and obstacle_bounds[1] - sz <= start_port.y <= obstacle_bounds[3] + sz - ) - near_end = end_port and ( - obstacle_bounds[0] - sz <= end_port.x <= obstacle_bounds[2] + sz - and obstacle_bounds[1] - sz <= end_port.y <= obstacle_bounds[3] + sz - ) - - if not near_start and not near_end: - return False - if not geometry.intersects(raw_obstacle): - return False - - engine.metrics["safety_zone_checks"] += 1 - intersection = geometry.intersection(raw_obstacle) - if intersection.is_empty: - return False - - ix_bounds = intersection.bounds - if ( - start_port - and near_start - and abs(ix_bounds[0] - start_port.x) < sz - and abs(ix_bounds[1] - start_port.y) < sz - and abs(ix_bounds[2] - start_port.x) < sz - and abs(ix_bounds[3] - start_port.y) < sz - ): - return True - return bool( - end_port - and near_end - and abs(ix_bounds[0] - end_port.x) < sz - and abs(ix_bounds[1] - end_port.y) < sz - and abs(ix_bounds[2] - end_port.x) < sz - and abs(ix_bounds[3] - end_port.y) < sz - ) diff --git a/inire/geometry/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py index aca1c60..3f3ab38 100644 --- a/inire/geometry/static_obstacle_index.py +++ b/inire/geometry/static_obstacle_index.py @@ -4,14 +4,14 @@ from typing import TYPE_CHECKING import numpy import rtree -from shapely.prepared import prep from shapely.strtree import STRtree +from inire.geometry.index_helpers import build_index_payload, is_axis_aligned_rect + if TYPE_CHECKING: from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld class StaticObstacleIndex: @@ -20,7 +20,6 @@ class StaticObstacleIndex: "index", "geometries", "dilated", - "prepared", "is_rect", "tree", "obj_ids", @@ -31,18 +30,15 @@ class StaticObstacleIndex: "net_specific_trees", "net_specific_is_rect", "net_specific_bounds", - "safe_cache", - "grid", "id_counter", "version", ) - def __init__(self, engine: CollisionEngine) -> None: + def __init__(self, engine: RoutingWorld) -> None: self.engine = engine self.index = rtree.index.Index() self.geometries: dict[int, Polygon] = {} self.dilated: dict[int, Polygon] = {} - self.prepared: dict[int, PreparedGeometry] = {} self.is_rect: dict[int, bool] = {} self.tree: STRtree | None = None self.obj_ids: list[int] = [] @@ -53,8 +49,6 @@ class StaticObstacleIndex: self.net_specific_trees: dict[tuple[float, float], STRtree] = {} self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} - self.safe_cache: set[tuple] = set() - self.grid: dict[tuple[int, int], list[int]] = {} self.id_counter = 0 self.version = 0 @@ -69,12 +63,9 @@ class StaticObstacleIndex: self.geometries[obj_id] = polygon self.dilated[obj_id] = dilated - self.prepared[obj_id] = prep(dilated) + self.is_rect[obj_id] = is_axis_aligned_rect(dilated) self.index.insert(obj_id, dilated.bounds) self.invalidate_caches() - bounds = dilated.bounds - area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) - self.is_rect[obj_id] = abs(dilated.area - area) < 1e-4 return obj_id def remove_obstacle(self, obj_id: int) -> None: @@ -85,7 +76,6 @@ class StaticObstacleIndex: self.index.delete(obj_id, bounds) del self.geometries[obj_id] del self.dilated[obj_id] - del self.prepared[obj_id] del self.is_rect[obj_id] self.invalidate_caches() @@ -96,19 +86,15 @@ class StaticObstacleIndex: self.obj_ids = [] self.raw_tree = None self.raw_obj_ids = [] - self.grid = {} self.net_specific_trees.clear() self.net_specific_is_rect.clear() self.net_specific_bounds.clear() - self.safe_cache.clear() self.version += 1 def ensure_tree(self) -> None: if self.tree is None and self.dilated: - self.obj_ids = sorted(self.dilated.keys()) - geometries = [self.dilated[i] for i in self.obj_ids] + self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated) self.tree = STRtree(geometries) - self.bounds_array = numpy.array([geometry.bounds for geometry in geometries]) self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids]) def ensure_net_tree(self, net_width: float) -> STRtree: @@ -125,19 +111,16 @@ class StaticObstacleIndex: polygon = self.geometries[obj_id] dilated = polygon.buffer(total_dilation, join_style=2) geometries.append(dilated) - bounds = dilated.bounds - bounds_list.append(bounds) - area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) - is_rect_list.append(abs(dilated.area - area) < 1e-4) + bounds_list.append(dilated.bounds) + is_rect_list.append(is_axis_aligned_rect(dilated)) tree = STRtree(geometries) self.net_specific_trees[key] = tree self.net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool) - self.net_specific_bounds[key] = numpy.array(bounds_list) + self.net_specific_bounds[key] = numpy.array(bounds_list, dtype=numpy.float64) return tree def ensure_raw_tree(self) -> None: if self.raw_tree is None and self.geometries: - self.raw_obj_ids = sorted(self.geometries.keys()) - geometries = [self.geometries[i] for i in self.raw_obj_ids] + self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries) self.raw_tree = STRtree(geometries) diff --git a/inire/model.py b/inire/model.py new file mode 100644 index 0000000..1dcb359 --- /dev/null +++ b/inire/model.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from inire.geometry.components import BendCollisionModel +from inire.router.results import RouteMetrics, RoutingResult + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + + +@dataclass(frozen=True, slots=True) +class NetSpec: + net_id: str + start: Port + target: Port + width: float = 2.0 + + +@dataclass(frozen=True, slots=True) +class LockedRoute: + geometry: tuple[Polygon, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "geometry", tuple(self.geometry)) + + @classmethod + def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute: + polygons = [] + for component in path: + polygons.extend(component.physical_geometry) + return cls(geometry=tuple(polygons)) + + +def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute: + if isinstance(route, LockedRoute): + return route + route_items = tuple(route) + if route_items and hasattr(route_items[0], "physical_geometry"): + return LockedRoute.from_path(route_items) # type: ignore[arg-type] + return LockedRoute(geometry=route_items) + + +@dataclass(frozen=True, slots=True) +class ObjectiveWeights: + unit_length_cost: float = 1.0 + bend_penalty: float = 250.0 + sbend_penalty: float = 500.0 + danger_weight: float = 1.0 + congestion_penalty: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class SearchOptions: + node_limit: int = 1000000 + max_straight_length: float = 2000.0 + min_straight_length: float = 5.0 + greedy_h_weight: float = 1.5 + sbend_offsets: tuple[float, ...] | None = None + bend_radii: tuple[float, ...] = (50.0, 100.0) + sbend_radii: tuple[float, ...] = (10.0,) + bend_collision_type: BendCollisionModel = "arc" + visibility_guidance: str = "tangent_corner" + initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) + object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) + if self.sbend_offsets is not None: + object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) + if self.initial_paths is not None: + object.__setattr__( + self, + "initial_paths", + { + net_id: tuple(path) + for net_id, path in self.initial_paths.items() + }, + ) + + +@dataclass(frozen=True, slots=True) +class CongestionOptions: + max_iterations: int = 10 + base_penalty: float = 100.0 + multiplier: float = 1.5 + use_tiered_strategy: bool = True + warm_start: str | None = "shortest" + shuffle_nets: bool = False + sort_nets: str | None = None + seed: int | None = None + + +@dataclass(frozen=True, slots=True) +class RefinementOptions: + enabled: bool = True + objective: ObjectiveWeights | None = None + + +@dataclass(frozen=True, slots=True) +class DiagnosticsOptions: + capture_expanded: bool = False + + +@dataclass(frozen=True, slots=True) +class RoutingOptions: + search: SearchOptions = field(default_factory=SearchOptions) + objective: ObjectiveWeights = field(default_factory=ObjectiveWeights) + congestion: CongestionOptions = field(default_factory=CongestionOptions) + refinement: RefinementOptions = field(default_factory=RefinementOptions) + diagnostics: DiagnosticsOptions = field(default_factory=DiagnosticsOptions) + + +@dataclass(frozen=True, slots=True) +class RoutingProblem: + bounds: tuple[float, float, float, float] + nets: tuple[NetSpec, ...] = () + static_obstacles: tuple[Polygon, ...] = () + locked_routes: dict[str, LockedRoute] = field(default_factory=dict) + clearance: float = 2.0 + max_net_width: float = 2.0 + safety_zone_radius: float = 0.0021 + + def __post_init__(self) -> None: + object.__setattr__(self, "nets", tuple(self.nets)) + object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) + object.__setattr__( + self, + "locked_routes", + { + net_id: _coerce_locked_route(route) + for net_id, route in self.locked_routes.items() + }, + ) + + +@dataclass(frozen=True, slots=True) +class RoutingRunResult: + results_by_net: dict[str, RoutingResult] + metrics: RouteMetrics + expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py new file mode 100644 index 0000000..be244c8 --- /dev/null +++ b/inire/router/_astar_admission.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import heapq +from typing import TYPE_CHECKING, Literal + +from shapely.geometry import Polygon + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind +from inire.geometry.primitives import Port +from inire.router.refiner import component_hits_ancestor_chain + +from ._astar_types import AStarContext, AStarMetrics, AStarNode + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + + +def process_move( + parent: AStarNode, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + move_class: MoveKind, + params: tuple, + skip_congestion: bool, + bend_collision_type: BendCollisionModel, + max_cost: float | None = None, + self_collision_check: bool = False, +) -> None: + cp = parent.port + coll_type = bend_collision_type + coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type + self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 + + abs_key = ( + cp.as_tuple(), + move_class, + params, + net_width, + coll_key, + self_dilation, + ) + if abs_key in context.move_cache_abs: + res = context.move_cache_abs[abs_key] + else: + context.check_cache_eviction() + base_port = Port(0, 0, cp.r) + rel_key = ( + cp.r, + move_class, + params, + net_width, + coll_key, + self_dilation, + ) + if rel_key in context.move_cache_rel: + res_rel = context.move_cache_rel[rel_key] + else: + try: + if move_class == "straight": + res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation) + elif move_class == "bend90": + res_rel = Bend90.generate( + base_port, + params[0], + net_width, + params[1], + collision_type=coll_type, + dilation=self_dilation, + ) + else: + res_rel = SBend.generate( + base_port, + params[0], + params[1], + net_width, + collision_type=coll_type, + dilation=self_dilation, + ) + except ValueError: + return + context.move_cache_rel[rel_key] = res_rel + res = res_rel.translate(cp.x, cp.y) + context.move_cache_abs[abs_key] = res + + move_radius = params[0] if move_class == "bend90" else (params[1] if move_class == "sbend" else None) + add_node( + parent, + res, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + move_class, + abs_key, + move_radius=move_radius, + skip_congestion=skip_congestion, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + +def add_node( + parent: AStarNode, + result: ComponentResult, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + move_type: MoveKind, + cache_key: tuple, + move_radius: float | None = None, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, +) -> None: + metrics.moves_generated += 1 + metrics.total_moves_generated += 1 + state = result.end_port.as_tuple() + new_lower_bound_g = parent.g_cost + result.length + if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: + metrics.pruned_closed_set += 1 + metrics.total_pruned_closed_set += 1 + return + + parent_p = parent.port + end_p = result.end_port + + if cache_key in context.hard_collision_set: + metrics.pruned_hard_collision += 1 + metrics.total_pruned_hard_collision += 1 + return + + is_static_safe = cache_key in context.static_safe_cache + if not is_static_safe: + ce = context.cost_evaluator.collision_engine + if move_type == "straight": + collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) + else: + collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) + if collision_found: + context.hard_collision_set.add(cache_key) + metrics.pruned_hard_collision += 1 + metrics.total_pruned_hard_collision += 1 + return + context.static_safe_cache.add(cache_key) + + total_overlaps = 0 + if not skip_congestion: + if cache_key in congestion_cache: + total_overlaps = congestion_cache[cache_key] + else: + total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) + congestion_cache[cache_key] = total_overlaps + + if self_collision_check and component_hits_ancestor_chain(result, parent): + return + + penalty = context.cost_evaluator.component_penalty( + move_type, + move_radius=move_radius, + ) + + move_cost = context.cost_evaluator.evaluate_move( + result.collision_geometry, + result.end_port, + net_width, + net_id, + start_port=parent_p, + length=result.length, + dilated_geometry=result.dilated_collision_geometry, + penalty=penalty, + skip_static=True, + skip_congestion=True, + ) + move_cost += total_overlaps * context.cost_evaluator.congestion_penalty + + if max_cost is not None and parent.g_cost + move_cost > max_cost: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + return + if move_cost > 1e12: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + return + + g_cost = parent.g_cost + move_cost + if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: + metrics.pruned_closed_set += 1 + metrics.total_pruned_closed_set += 1 + return + + h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) + heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) + metrics.moves_added += 1 + metrics.total_moves_added += 1 diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py new file mode 100644 index 0000000..d326934 --- /dev/null +++ b/inire/router/_astar_moves.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import math + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import BendCollisionModel, MoveKind +from inire.geometry.primitives import Port + +from ._astar_admission import process_move +from ._astar_types import AStarContext, AStarMetrics, AStarNode + + +def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: + out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01} + return sorted((v for v in out if v > 0), reverse=True) + + +def _sbend_forward_span(offset: float, radius: float) -> float | None: + abs_offset = abs(offset) + if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: + return None + theta = math.acos(1.0 - abs_offset / (2.0 * radius)) + return 2.0 * radius * math.sin(theta) + + +def _visible_straight_candidates( + current: Port, + context: AStarContext, + max_reach: float, + cos_v: float, + sin_v: float, + net_width: float, +) -> list[float]: + search_options = context.options.search + mode = search_options.visibility_guidance + if mode == "off": + return [] + + if mode == "exact_corner": + max_bend_radius = max(search_options.bend_radii, default=0.0) + visibility_reach = max_reach + max_bend_radius + visible_corners = sorted( + context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach), + key=lambda corner: corner[2], + ) + if not visible_corners: + return [] + + candidates: set[int] = set() + for cx, cy, _ in visible_corners[:12]: + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + if local_x <= search_options.min_straight_length: + continue + candidates.add(int(round(local_x))) + return sorted(candidates, reverse=True) + + if mode != "tangent_corner": + return [] + + visibility_manager = context.visibility_manager + visibility_manager._ensure_current() + max_bend_radius = max(search_options.bend_radii, default=0.0) + if max_bend_radius <= 0 or not visibility_manager.corners: + return [] + + reach = max_reach + max_bend_radius + bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach) + candidate_ids = list(visibility_manager.corner_index.intersection(bounds)) + if not candidate_ids: + return [] + + scored: list[tuple[float, float, float, float, float]] = [] + for idx in candidate_ids: + cx, cy = visibility_manager.corners[idx] + dx = cx - current.x + dy = cy - current.y + local_x = dx * cos_v + dy * sin_v + local_y = -dx * sin_v + dy * cos_v + if local_x <= search_options.min_straight_length or local_x > reach + 0.01: + continue + + nearest_radius = min(search_options.bend_radii, key=lambda radius: abs(abs(local_y) - radius)) + tangent_error = abs(abs(local_y) - nearest_radius) + if tangent_error > 2.0: + continue + + length = local_x - nearest_radius + if length <= search_options.min_straight_length or length > max_reach + 0.01: + continue + + scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy)) + + if not scored: + return [] + + collision_engine = context.cost_evaluator.collision_engine + candidates: set[int] = set() + for _, dist, length, dx, dy in sorted(scored)[:4]: + angle = math.degrees(math.atan2(dy, dx)) + corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width) + if corner_reach < dist - 0.01: + continue + qlen = int(round(length)) + if qlen > 0: + candidates.add(qlen) + + return sorted(candidates, reverse=True) + + +def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]: + result = node.component_result + if result is None: + return None, None + move_type = result.move_type + if move_type == "straight": + return move_type, result.length + return move_type, None + + +def expand_moves( + current: AStarNode, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: dict[tuple[int, int, int], float], + context: AStarContext, + metrics: AStarMetrics, + congestion_cache: dict[tuple, int], + bend_collision_type: BendCollisionModel | None = None, + max_cost: float | None = None, + skip_congestion: bool = False, + self_collision_check: bool = False, +) -> None: + search_options = context.options.search + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type + cp = current.port + prev_move_type, prev_straight_length = _previous_move_metadata(current) + dx_t = target.x - cp.x + dy_t = target.y - cp.y + dist_sq = dx_t * dx_t + dy_t * dy_t + + if cp.r == 0: + cos_v, sin_v = 1.0, 0.0 + elif cp.r == 90: + cos_v, sin_v = 0.0, 1.0 + elif cp.r == 180: + cos_v, sin_v = -1.0, 0.0 + else: + cos_v, sin_v = 0.0, -1.0 + + proj_t = dx_t * cos_v + dy_t * sin_v + perp_t = -dx_t * sin_v + dy_t * cos_v + dx_local = proj_t + dy_local = perp_t + + if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r: + max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width) + if max_reach >= proj_t - 0.01 and ( + prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR + ): + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "straight", + (int(round(proj_t)),), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width) + candidate_lengths = [ + search_options.min_straight_length, + max_reach, + max_reach / 2.0, + max_reach - 5.0, + ] + + axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t) + candidate_lengths.append(axis_target_dist) + for radius in search_options.bend_radii: + candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius)) + + candidate_lengths.extend( + _visible_straight_candidates( + cp, + context, + max_reach, + cos_v, + sin_v, + net_width, + ) + ) + + if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR: + for radius in search_options.sbend_radii: + sbend_span = _sbend_forward_span(dy_local, radius) + if sbend_span is None: + continue + candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span)) + + for length in _quantized_lengths(candidate_lengths, max_reach): + if length < search_options.min_straight_length: + continue + if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "straight", + (length,), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + angle_to_target = 0.0 + if dx_t != 0 or dy_t != 0: + angle_to_target = float((round((180.0 / math.pi) * math.atan2(dy_t, dx_t)) + 360.0) % 360.0) + allow_backwards = dist_sq < 150 * 150 + + for radius in search_options.bend_radii: + for direction in ("CW", "CCW"): + if not allow_backwards: + turn = 90 if direction == "CCW" else -90 + new_ori = (cp.r + turn) % 360 + new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0 + if abs(new_diff) > 135.0: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "bend90", + (radius, direction), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) + + max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0 + if max_sbend_r <= 0 or prev_move_type == "sbend": + return + + explicit_offsets = search_options.sbend_offsets + offsets: set[int] = {int(round(v)) for v in explicit_offsets or []} + + if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r and 0 < abs(dy_local) < 2 * max_sbend_r: + offsets.add(int(round(dy_local))) + + if not offsets: + return + + for offset in sorted(offsets): + if offset == 0: + continue + for radius in search_options.sbend_radii: + if abs(offset) >= 2 * radius: + continue + process_move( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + "sbend", + (offset, radius), + skip_congestion, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + self_collision_check=self_collision_check, + ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py new file mode 100644 index 0000000..785b3ae --- /dev/null +++ b/inire/router/_astar_types.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from inire.model import RoutingOptions, RoutingProblem +from inire.router.visibility import VisibilityManager +from inire.router.results import RouteMetrics + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.router.cost import CostEvaluator + + +class AStarNode: + __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") + + def __init__( + self, + port, + g_cost: float, + h_cost: float, + parent: AStarNode | None = None, + component_result: ComponentResult | None = None, + ) -> None: + self.port = port + self.g_cost = g_cost + self.h_cost = h_cost + self.fh_cost = (g_cost + h_cost, h_cost) + self.parent = parent + self.component_result = component_result + + def __lt__(self, other: AStarNode) -> bool: + return self.fh_cost < other.fh_cost + + +class AStarMetrics: + __slots__ = ( + "total_nodes_expanded", + "total_moves_generated", + "total_moves_added", + "total_pruned_closed_set", + "total_pruned_hard_collision", + "total_pruned_cost", + "last_expanded_nodes", + "nodes_expanded", + "moves_generated", + "moves_added", + "pruned_closed_set", + "pruned_hard_collision", + "pruned_cost", + ) + + def __init__(self) -> None: + self.total_nodes_expanded = 0 + self.total_moves_generated = 0 + self.total_moves_added = 0 + self.total_pruned_closed_set = 0 + self.total_pruned_hard_collision = 0 + self.total_pruned_cost = 0 + self.last_expanded_nodes: list[tuple[int, int, int]] = [] + self.nodes_expanded = 0 + self.moves_generated = 0 + self.moves_added = 0 + self.pruned_closed_set = 0 + self.pruned_hard_collision = 0 + self.pruned_cost = 0 + + def reset_totals(self) -> None: + self.total_nodes_expanded = 0 + self.total_moves_generated = 0 + self.total_moves_added = 0 + self.total_pruned_closed_set = 0 + self.total_pruned_hard_collision = 0 + self.total_pruned_cost = 0 + + def reset_per_route(self) -> None: + self.nodes_expanded = 0 + self.moves_generated = 0 + self.moves_added = 0 + self.pruned_closed_set = 0 + self.pruned_hard_collision = 0 + self.pruned_cost = 0 + self.last_expanded_nodes = [] + + def snapshot(self) -> RouteMetrics: + return RouteMetrics( + nodes_expanded=self.total_nodes_expanded, + moves_generated=self.total_moves_generated, + moves_added=self.total_moves_added, + pruned_closed_set=self.total_pruned_closed_set, + pruned_hard_collision=self.total_pruned_hard_collision, + pruned_cost=self.total_pruned_cost, + ) + + +class AStarContext: + __slots__ = ( + "cost_evaluator", + "problem", + "options", + "max_cache_size", + "visibility_manager", + "move_cache_rel", + "move_cache_abs", + "hard_collision_set", + "static_safe_cache", + "static_cache_version", + ) + + def __init__( + self, + cost_evaluator: CostEvaluator, + problem: RoutingProblem, + options: RoutingOptions, + max_cache_size: int = 1000000, + ) -> None: + self.cost_evaluator = cost_evaluator + self.max_cache_size = max_cache_size + self.problem = problem + self.options = options + self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0)) + self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) + self.move_cache_rel: dict[tuple, ComponentResult] = {} + self.move_cache_abs: dict[tuple, ComponentResult] = {} + self.hard_collision_set: set[tuple] = set() + self.static_safe_cache: set[tuple] = set() + self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version() + + def clear_static_caches(self) -> None: + self.hard_collision_set.clear() + self.static_safe_cache.clear() + self.visibility_manager.clear_cache() + self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version() + + def ensure_static_caches_current(self) -> None: + current_version = self.cost_evaluator.collision_engine.get_static_version() + if self.static_cache_version != current_version: + self.clear_static_caches() + + def _evict_cache(self, cache: dict[tuple, ComponentResult]) -> None: + if len(cache) <= self.max_cache_size * 1.2: + return + + num_to_evict = max(1, int(len(cache) * 0.25)) + for idx, key in enumerate(tuple(cache.keys())): + if idx >= num_to_evict: + break + del cache[key] + + def check_cache_eviction(self) -> None: + self._evict_cache(self.move_cache_rel) + self._evict_cache(self.move_cache_abs) diff --git a/inire/router/_router.py b/inire/router/_router.py new file mode 100644 index 0000000..75aef6e --- /dev/null +++ b/inire/router/_router.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import random +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from inire.model import NetSpec, RoutingOptions, RoutingProblem +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._search import route_astar +from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry +from inire.router.refiner import PathRefiner +from inire.router.results import RoutingReport, RoutingResult + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port + from inire.router.cost import CostEvaluator + + +@dataclass(slots=True) +class _RoutingState: + net_specs: dict[str, NetSpec] + ordered_net_ids: list[str] + results: dict[str, RoutingResult] + needs_self_collision_check: set[str] + start_time: float + timeout_s: float + initial_paths: dict[str, tuple[ComponentResult, ...]] | None + accumulated_expanded_nodes: list[tuple[int, int, int]] + + +__all__ = ["PathFinder"] + + +class PathFinder: + __slots__ = ( + "context", + "metrics", + "refiner", + "accumulated_expanded_nodes", + ) + + def __init__( + self, + context: AStarContext, + metrics: AStarMetrics | None = None, + ) -> None: + self.context = context + self.metrics = metrics if metrics is not None else AStarMetrics() + self.refiner = PathRefiner(self.context) + self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] + + @property + def problem(self) -> RoutingProblem: + return self.context.problem + + @property + def options(self) -> RoutingOptions: + return self.context.options + + @property + def cost_evaluator(self) -> CostEvaluator: + return self.context.cost_evaluator + + def _path_cost(self, path: Sequence[ComponentResult]) -> float: + return self.refiner.path_cost(path) + + def _refine_path( + self, + net_id: str, + start: Port, + target: Port, + net_width: float, + path: Sequence[ComponentResult], + ) -> list[ComponentResult]: + return self.refiner.refine_path(net_id, start, target, net_width, path) + + def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]: + all_geoms = [] + all_dilated = [] + for result in path: + all_geoms.extend(result.collision_geometry) + all_dilated.extend(result.dilated_collision_geometry) + return all_geoms, all_dilated + + def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: + all_geoms, all_dilated = self._extract_path_geometry(path) + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + + def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]: + obj_ids: list[int] = [] + for result in path: + for polygon in result.physical_geometry: + obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon)) + return obj_ids + + def _remove_static_obstacles(self, obj_ids: list[int]) -> None: + for obj_id in obj_ids: + self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) + + def _remove_path(self, net_id: str) -> None: + self.cost_evaluator.collision_engine.remove_path(net_id) + + def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport: + return self.cost_evaluator.collision_engine.verify_path_report(net_id, path) + + def _finalize_dynamic_tree(self) -> None: + self.cost_evaluator.collision_engine.rebuild_dynamic_tree() + + def _build_routing_result( + self, + *, + net_id: str, + path: Sequence[ComponentResult], + reached_target: bool | None = None, + report: RoutingReport | None = None, + ) -> RoutingResult: + resolved_reached_target = bool(path) if reached_target is None else reached_target + return RoutingResult( + net_id=net_id, + path=path, + reached_target=resolved_reached_target, + report=report if report is not None else RoutingReport(), + ) + + def _routing_order( + self, + net_specs: dict[str, NetSpec], + order: str, + ) -> list[str]: + ordered_net_ids = list(net_specs.keys()) + if order == "user": + return ordered_net_ids + ordered_net_ids.sort( + key=lambda net_id: abs(net_specs[net_id].target.x - net_specs[net_id].start.x) + + abs(net_specs[net_id].target.y - net_specs[net_id].start.y), + reverse=(order == "longest"), + ) + return ordered_net_ids + + def _build_greedy_warm_start_paths( + self, + net_specs: dict[str, NetSpec], + order: str, + ) -> dict[str, tuple[ComponentResult, ...]]: + greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} + temp_obj_ids: list[int] = [] + greedy_node_limit = min(self.options.search.node_limit, 2000) + for net_id in self._routing_order(net_specs, order): + net = net_specs[net_id] + h_start = self.cost_evaluator.h_manhattan(net.start, net.target) + max_cost_limit = max(h_start * 3.0, 2000.0) + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + skip_congestion=True, + max_cost=max_cost_limit, + self_collision_check=True, + node_limit=greedy_node_limit, + ) + if not path: + continue + greedy_paths[net_id] = tuple(path) + temp_obj_ids.extend(self._stage_path_as_static(path)) + self.context.clear_static_caches() + + self._remove_static_obstacles(temp_obj_ids) + return greedy_paths + + def _prepare_state(self) -> _RoutingState: + problem = self.problem + congestion = self.options.congestion + initial_paths = self.options.search.initial_paths + net_specs = {net.net_id: net for net in problem.nets} + num_nets = len(net_specs) + state = _RoutingState( + net_specs=net_specs, + ordered_net_ids=list(net_specs.keys()), + results={}, + needs_self_collision_check=set(), + start_time=time.monotonic(), + timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations), + initial_paths=initial_paths, + accumulated_expanded_nodes=[], + ) + if state.initial_paths is None: + warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start + if warm_start_order is not None: + state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order) + self.context.clear_static_caches() + + if congestion.sort_nets and congestion.sort_nets != "user": + state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets) + return state + + def _route_net_once( + self, + state: _RoutingState, + iteration: int, + net_id: str, + ) -> RoutingResult: + search = self.options.search + congestion = self.options.congestion + diagnostics = self.options.diagnostics + net = state.net_specs[net_id] + self._remove_path(net_id) + + if iteration == 0 and state.initial_paths and net_id in state.initial_paths: + path: Sequence[ComponentResult] | None = state.initial_paths[net_id] + else: + coll_model = search.bend_collision_type + skip_congestion = False + if congestion.use_tiered_strategy and iteration == 0: + skip_congestion = True + if coll_model == "arc": + coll_model = "clipped_bbox" + + path = route_astar( + net.start, + net.target, + net.width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + bend_collision_type=coll_model, + return_partial=True, + store_expanded=diagnostics.capture_expanded, + skip_congestion=skip_congestion, + self_collision_check=(net_id in state.needs_self_collision_check), + node_limit=search.node_limit, + ) + + if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: + state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) + + if not path: + return self._build_routing_result(net_id=net_id, path=[], reached_target=False) + + reached_target = path[-1].end_port == net.target + report = None + self._install_path(net_id, path) + if reached_target: + report = self._verify_path_report(net_id, path) + if report.self_collision_count > 0: + state.needs_self_collision_check.add(net_id) + + return self._build_routing_result( + net_id=net_id, + path=path, + reached_target=reached_target, + report=report, + ) + + def _run_iteration( + self, + state: _RoutingState, + iteration: int, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, + ) -> dict[str, RoutingOutcome] | None: + outcomes: dict[str, RoutingOutcome] = {} + congestion = self.options.congestion + self.metrics.reset_per_route() + + if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): + iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None + random.Random(iteration_seed).shuffle(state.ordered_net_ids) + + for net_id in state.ordered_net_ids: + if time.monotonic() - state.start_time > state.timeout_s: + self._finalize_dynamic_tree() + return None + + result = self._route_net_once(state, iteration, net_id) + state.results[net_id] = result + outcomes[net_id] = result.outcome + + if iteration_callback: + iteration_callback(iteration, state.results) + return outcomes + + def _run_iterations( + self, + state: _RoutingState, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, + ) -> bool: + congestion = self.options.congestion + for iteration in range(congestion.max_iterations): + outcomes = self._run_iteration(state, iteration, iteration_callback) + if outcomes is None: + return True + if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()): + return False + self.cost_evaluator.congestion_penalty *= congestion.multiplier + return False + + def _refine_results(self, state: _RoutingState) -> None: + if not self.options.refinement.enabled or not state.results: + return + + for net_id in state.ordered_net_ids: + result = state.results.get(net_id) + if not result or not result.path or routing_outcome_needs_retry(result.outcome): + continue + net = state.net_specs[net_id] + self._remove_path(net_id) + refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path) + self._install_path(net_id, refined_path) + report = self._verify_path_report(net_id, refined_path) + state.results[net_id] = self._build_routing_result( + net_id=net_id, + path=refined_path, + reached_target=result.reached_target, + report=report, + ) + + def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: + final_results: dict[str, RoutingResult] = {} + for net in self.problem.nets: + result = state.results.get(net.net_id) + if not result or not result.path: + final_results[net.net_id] = self._build_routing_result( + net_id=net.net_id, + path=[], + reached_target=False, + ) + continue + report = self._verify_path_report(net.net_id, result.path) + final_results[net.net_id] = self._build_routing_result( + net_id=net.net_id, + path=result.path, + reached_target=result.reached_target, + report=report, + ) + return final_results + + def route_all( + self, + *, + iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, + ) -> dict[str, RoutingResult]: + self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty + self.accumulated_expanded_nodes = [] + self.metrics.reset_totals() + self.metrics.reset_per_route() + + state = self._prepare_state() + timed_out = self._run_iterations(state, iteration_callback) + self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes) + + if timed_out: + return self._verify_results(state) + + self._refine_results(state) + self._finalize_dynamic_tree() + return self._verify_results(state) diff --git a/inire/router/_search.py b/inire/router/_search.py new file mode 100644 index 0000000..7816ef3 --- /dev/null +++ b/inire/router/_search.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import heapq +from typing import TYPE_CHECKING + +from inire.constants import TOLERANCE_LINEAR +from inire.geometry.components import BendCollisionModel +from inire.geometry.primitives import Port + +from ._astar_moves import expand_moves as _expand_moves +from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode +from .results import RouteMetrics + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + + +def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]: + path = [] + curr: _AStarNode | None = end_node + while curr and curr.component_result: + path.append(curr.component_result) + curr = curr.parent + return path[::-1] + + +def route_astar( + start: Port, + target: Port, + net_width: float, + context: AStarContext, + metrics: AStarMetrics | None = None, + net_id: str = "default", + bend_collision_type: BendCollisionModel | None = None, + return_partial: bool = False, + store_expanded: bool = False, + skip_congestion: bool = False, + max_cost: float | None = None, + self_collision_check: bool = False, + node_limit: int | None = None, +) -> list[ComponentResult] | None: + if metrics is None: + metrics = AStarMetrics() + metrics.reset_per_route() + search_options = context.options.search + effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type + + context.ensure_static_caches_current() + context.cost_evaluator.set_target(target) + open_set: list[_AStarNode] = [] + closed_set: dict[tuple[int, int, int], float] = {} + congestion_cache: dict[tuple, int] = {} + + start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) + heapq.heappush(open_set, start_node) + best_node = start_node + effective_node_limit = node_limit if node_limit is not None else search_options.node_limit + nodes_expanded = 0 + + while open_set: + if nodes_expanded >= effective_node_limit: + return _reconstruct_path(best_node) if return_partial else None + + current = heapq.heappop(open_set) + if max_cost is not None and current.fh_cost[0] > max_cost: + metrics.pruned_cost += 1 + metrics.total_pruned_cost += 1 + continue + + if current.h_cost < best_node.h_cost: + best_node = current + + state = current.port.as_tuple() + if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR: + continue + closed_set[state] = current.g_cost + + if store_expanded: + metrics.last_expanded_nodes.append(state) + + nodes_expanded += 1 + metrics.total_nodes_expanded += 1 + metrics.nodes_expanded += 1 + + if current.port == target: + return _reconstruct_path(current) + + _expand_moves( + current, + target, + net_width, + net_id, + open_set, + closed_set, + context, + metrics, + congestion_cache, + bend_collision_type=effective_bend_collision_type, + max_cost=max_cost, + skip_congestion=skip_congestion, + self_collision_check=self_collision_check, + ) + + return _reconstruct_path(best_node) if return_partial else None + + +__all__ = [ + "AStarContext", + "AStarMetrics", + "RouteMetrics", + "route_astar", +] diff --git a/inire/router/astar.py b/inire/router/astar.py deleted file mode 100644 index 796775f..0000000 --- a/inire/router/astar.py +++ /dev/null @@ -1,721 +0,0 @@ -from __future__ import annotations - -import heapq -import logging -import math -from typing import TYPE_CHECKING, Any, Literal - -import shapely - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port -from inire.router.config import RouterConfig, VisibilityGuidanceMode -from inire.router.refiner import component_hits_ancestor_chain -from inire.router.visibility import VisibilityManager - -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.router.cost import CostEvaluator - -logger = logging.getLogger(__name__) - - -class AStarNode: - __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") - - def __init__( - self, - port: Port, - g_cost: float, - h_cost: float, - parent: AStarNode | None = None, - component_result: ComponentResult | None = None, - ) -> None: - self.port = port - self.g_cost = g_cost - self.h_cost = h_cost - self.fh_cost = (g_cost + h_cost, h_cost) - self.parent = parent - self.component_result = component_result - - def __lt__(self, other: AStarNode) -> bool: - return self.fh_cost < other.fh_cost - - -class AStarMetrics: - __slots__ = ( - "total_nodes_expanded", - "last_expanded_nodes", - "nodes_expanded", - "moves_generated", - "moves_added", - "pruned_closed_set", - "pruned_hard_collision", - "pruned_cost", - ) - - def __init__(self) -> None: - self.total_nodes_expanded = 0 - self.last_expanded_nodes: list[tuple[int, int, int]] = [] - self.nodes_expanded = 0 - self.moves_generated = 0 - self.moves_added = 0 - self.pruned_closed_set = 0 - self.pruned_hard_collision = 0 - self.pruned_cost = 0 - - def reset_per_route(self) -> None: - self.nodes_expanded = 0 - self.moves_generated = 0 - self.moves_added = 0 - self.pruned_closed_set = 0 - self.pruned_hard_collision = 0 - self.pruned_cost = 0 - self.last_expanded_nodes = [] - - -class AStarContext: - __slots__ = ( - "cost_evaluator", - "config", - "visibility_manager", - "move_cache_rel", - "move_cache_abs", - "hard_collision_set", - "static_safe_cache", - "max_cache_size", - ) - - def __init__( - self, - cost_evaluator: CostEvaluator, - node_limit: int = 1000000, - max_straight_length: float = 2000.0, - min_straight_length: float = 5.0, - bend_radii: list[float] | None = None, - sbend_radii: list[float] | None = None, - sbend_offsets: list[float] | None = None, - bend_penalty: float = 250.0, - sbend_penalty: float | None = None, - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc", - bend_clip_margin: float = 10.0, - visibility_guidance: VisibilityGuidanceMode = "tangent_corner", - max_cache_size: int = 1000000, - ) -> None: - actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty - self.cost_evaluator = cost_evaluator - self.max_cache_size = max_cache_size - self.config = RouterConfig( - node_limit=node_limit, - max_straight_length=max_straight_length, - min_straight_length=min_straight_length, - bend_radii=bend_radii if bend_radii is not None else [50.0, 100.0], - sbend_radii=sbend_radii if sbend_radii is not None else [5.0, 10.0, 50.0, 100.0], - sbend_offsets=sbend_offsets, - bend_penalty=bend_penalty, - sbend_penalty=actual_sbend_penalty, - bend_collision_type=bend_collision_type, - bend_clip_margin=bend_clip_margin, - visibility_guidance=visibility_guidance, - ) - self.cost_evaluator.apply_routing_costs( - bend_penalty=self.config.bend_penalty, - sbend_penalty=self.config.sbend_penalty, - bend_radii=self.config.bend_radii, - ) - - self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) - self.move_cache_rel: dict[tuple, ComponentResult] = {} - self.move_cache_abs: dict[tuple, ComponentResult] = {} - self.hard_collision_set: set[tuple] = set() - self.static_safe_cache: set[tuple] = set() - - def clear_static_caches(self) -> None: - self.hard_collision_set.clear() - self.static_safe_cache.clear() - self.visibility_manager.clear_cache() - - def check_cache_eviction(self) -> None: - if len(self.move_cache_abs) <= self.max_cache_size * 1.2: - return - num_to_evict = int(len(self.move_cache_abs) * 0.25) - for idx, key in enumerate(list(self.move_cache_abs.keys())): - if idx >= num_to_evict: - break - del self.move_cache_abs[key] - - -def route_astar( - start: Port, - target: Port, - net_width: float, - context: AStarContext, - metrics: AStarMetrics | None = None, - net_id: str = "default", - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | None = None, - return_partial: bool = False, - store_expanded: bool = False, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, - node_limit: int | None = None, -) -> list[ComponentResult] | None: - if metrics is None: - metrics = AStarMetrics() - metrics.reset_per_route() - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type - - context.cost_evaluator.set_target(target) - open_set: list[AStarNode] = [] - closed_set: dict[tuple[int, int, int], float] = {} - congestion_cache: dict[tuple, int] = {} - - start_node = AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) - heapq.heappush(open_set, start_node) - best_node = start_node - effective_node_limit = node_limit if node_limit is not None else context.config.node_limit - nodes_expanded = 0 - - while open_set: - if nodes_expanded >= effective_node_limit: - return reconstruct_path(best_node) if return_partial else None - - current = heapq.heappop(open_set) - if max_cost is not None and current.fh_cost[0] > max_cost: - metrics.pruned_cost += 1 - continue - - if current.h_cost < best_node.h_cost: - best_node = current - - state = current.port.as_tuple() - if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR: - continue - closed_set[state] = current.g_cost - - if store_expanded: - metrics.last_expanded_nodes.append(state) - - nodes_expanded += 1 - metrics.total_nodes_expanded += 1 - metrics.nodes_expanded += 1 - - if current.port == target: - return reconstruct_path(current) - - expand_moves( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - effective_bend_collision_type, - max_cost=max_cost, - skip_congestion=skip_congestion, - self_collision_check=self_collision_check, - ) - - return reconstruct_path(best_node) if return_partial else None - - -def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: - out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01} - return sorted((v for v in out if v > 0), reverse=True) - - -def _sbend_forward_span(offset: float, radius: float) -> float | None: - abs_offset = abs(offset) - if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: - return None - theta = __import__("math").acos(1.0 - abs_offset / (2.0 * radius)) - return 2.0 * radius * __import__("math").sin(theta) - - -def _visible_straight_candidates( - current: Port, - context: AStarContext, - max_reach: float, - cos_v: float, - sin_v: float, - net_width: float, -) -> list[float]: - mode = context.config.visibility_guidance - if mode == "off": - return [] - - if mode == "exact_corner": - max_bend_radius = max(context.config.bend_radii, default=0.0) - visibility_reach = max_reach + max_bend_radius - visible_corners = sorted( - context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach), - key=lambda corner: corner[2], - ) - if not visible_corners: - return [] - - candidates: set[int] = set() - for cx, cy, _ in visible_corners[:12]: - dx = cx - current.x - dy = cy - current.y - local_x = dx * cos_v + dy * sin_v - if local_x <= context.config.min_straight_length: - continue - candidates.add(int(round(local_x))) - return sorted(candidates, reverse=True) - - if mode != "tangent_corner": - return [] - - visibility_manager = context.visibility_manager - visibility_manager._ensure_current() - max_bend_radius = max(context.config.bend_radii, default=0.0) - if max_bend_radius <= 0 or not visibility_manager.corners: - return [] - - reach = max_reach + max_bend_radius - bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach) - candidate_ids = list(visibility_manager.corner_index.intersection(bounds)) - if not candidate_ids: - return [] - - scored: list[tuple[float, float, float, float, float]] = [] - for idx in candidate_ids: - cx, cy = visibility_manager.corners[idx] - dx = cx - current.x - dy = cy - current.y - local_x = dx * cos_v + dy * sin_v - local_y = -dx * sin_v + dy * cos_v - if local_x <= context.config.min_straight_length or local_x > reach + 0.01: - continue - - nearest_radius = min(context.config.bend_radii, key=lambda radius: abs(abs(local_y) - radius)) - tangent_error = abs(abs(local_y) - nearest_radius) - if tangent_error > 2.0: - continue - - length = local_x - nearest_radius - if length <= context.config.min_straight_length or length > max_reach + 0.01: - continue - - scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy)) - - if not scored: - return [] - - collision_engine = context.cost_evaluator.collision_engine - candidates: set[int] = set() - for _, dist, length, dx, dy in sorted(scored)[:4]: - angle = math.degrees(math.atan2(dy, dx)) - corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width) - if corner_reach < dist - 0.01: - continue - qlen = int(round(length)) - if qlen > 0: - candidates.add(qlen) - - return sorted(candidates, reverse=True) - - -def _previous_move_metadata(node: AStarNode) -> tuple[str | None, float | None]: - result = node.component_result - if result is None: - return None, None - move_type = result.move_type - if move_type == "Straight": - return move_type, result.length - return move_type, None - - -def expand_moves( - current: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any | None = None, - max_cost: float | None = None, - skip_congestion: bool = False, - self_collision_check: bool = False, -) -> None: - effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type - cp = current.port - prev_move_type, prev_straight_length = _previous_move_metadata(current) - dx_t = target.x - cp.x - dy_t = target.y - cp.y - dist_sq = dx_t * dx_t + dy_t * dy_t - - if cp.r == 0: - cos_v, sin_v = 1.0, 0.0 - elif cp.r == 90: - cos_v, sin_v = 0.0, 1.0 - elif cp.r == 180: - cos_v, sin_v = -1.0, 0.0 - else: - cos_v, sin_v = 0.0, -1.0 - - proj_t = dx_t * cos_v + dy_t * sin_v - perp_t = -dx_t * sin_v + dy_t * cos_v - dx_local = proj_t - dy_local = perp_t - - if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r: - max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width) - if max_reach >= proj_t - 0.01 and ( - prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR - ): - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "S", - (int(round(proj_t)),), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, context.config.max_straight_length, net_width=net_width) - candidate_lengths = [ - context.config.min_straight_length, - max_reach, - max_reach / 2.0, - max_reach - 5.0, - ] - - axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t) - candidate_lengths.append(axis_target_dist) - for radius in context.config.bend_radii: - candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius)) - - candidate_lengths.extend( - _visible_straight_candidates( - cp, - context, - max_reach, - cos_v, - sin_v, - net_width, - ) - ) - - if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR: - for radius in context.config.sbend_radii: - sbend_span = _sbend_forward_span(dy_local, radius) - if sbend_span is None: - continue - candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span)) - - for length in _quantized_lengths(candidate_lengths, max_reach): - if length < context.config.min_straight_length: - continue - if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "S", - (length,), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - angle_to_target = 0.0 - if dx_t != 0 or dy_t != 0: - angle_to_target = float((round((180.0 / 3.141592653589793) * __import__("math").atan2(dy_t, dx_t)) + 360.0) % 360.0) - allow_backwards = dist_sq < 150 * 150 - - for radius in context.config.bend_radii: - for direction in ("CW", "CCW"): - if not allow_backwards: - turn = 90 if direction == "CCW" else -90 - new_ori = (cp.r + turn) % 360 - new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0 - if abs(new_diff) > 135.0: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "B", - (radius, direction), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - max_sbend_r = max(context.config.sbend_radii) if context.config.sbend_radii else 0.0 - if max_sbend_r <= 0 or prev_move_type == "SBend": - return - - explicit_offsets = context.config.sbend_offsets - offsets: set[int] = set(int(round(v)) for v in explicit_offsets or []) - - # S-bends preserve orientation, so the implicit search only makes sense - # when the target is ahead in local coordinates and keeps the same - # orientation. Generating generic speculative offsets on the integer lattice - # explodes the search space without contributing useful moves. - if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r: - if 0 < abs(dy_local) < 2 * max_sbend_r: - offsets.add(int(round(dy_local))) - - if not offsets: - return - - for offset in sorted(offsets): - if offset == 0: - continue - for radius in context.config.sbend_radii: - if abs(offset) >= 2 * radius: - continue - process_move( - current, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - "SB", - (offset, radius), - skip_congestion, - bend_collision_type=effective_bend_collision_type, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - -def process_move( - parent: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - move_class: Literal["S", "B", "SB"], - params: tuple, - skip_congestion: bool, - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any, - max_cost: float | None = None, - self_collision_check: bool = False, -) -> None: - cp = parent.port - coll_type = bend_collision_type - coll_key = id(coll_type) if isinstance(coll_type, shapely.geometry.Polygon) else coll_type - self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 - - abs_key = ( - cp.as_tuple(), - move_class, - params, - net_width, - coll_key, - context.config.bend_clip_margin, - self_dilation, - ) - if abs_key in context.move_cache_abs: - res = context.move_cache_abs[abs_key] - else: - context.check_cache_eviction() - base_port = Port(0, 0, cp.r) - rel_key = ( - cp.r, - move_class, - params, - net_width, - coll_key, - context.config.bend_clip_margin, - self_dilation, - ) - if rel_key in context.move_cache_rel: - res_rel = context.move_cache_rel[rel_key] - else: - try: - if move_class == "S": - res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation) - elif move_class == "B": - res_rel = Bend90.generate( - base_port, - params[0], - net_width, - params[1], - collision_type=coll_type, - clip_margin=context.config.bend_clip_margin, - dilation=self_dilation, - ) - else: - res_rel = SBend.generate( - base_port, - params[0], - params[1], - net_width, - collision_type=coll_type, - clip_margin=context.config.bend_clip_margin, - dilation=self_dilation, - ) - except ValueError: - return - context.move_cache_rel[rel_key] = res_rel - res = res_rel.translate(cp.x, cp.y) - context.move_cache_abs[abs_key] = res - - move_radius = params[0] if move_class == "B" else (params[1] if move_class == "SB" else None) - add_node( - parent, - res, - target, - net_width, - net_id, - open_set, - closed_set, - context, - metrics, - congestion_cache, - move_class, - abs_key, - move_radius=move_radius, - skip_congestion=skip_congestion, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - -def add_node( - parent: AStarNode, - result: ComponentResult, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: dict[tuple[int, int, int], float], - context: AStarContext, - metrics: AStarMetrics, - congestion_cache: dict[tuple, int], - move_type: str, - cache_key: tuple, - move_radius: float | None = None, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, -) -> None: - metrics.moves_generated += 1 - state = result.end_port.as_tuple() - new_lower_bound_g = parent.g_cost + result.length - if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: - metrics.pruned_closed_set += 1 - return - - parent_p = parent.port - end_p = result.end_port - - if cache_key in context.hard_collision_set: - metrics.pruned_hard_collision += 1 - return - - is_static_safe = cache_key in context.static_safe_cache - if not is_static_safe: - ce = context.cost_evaluator.collision_engine - if move_type == "S": - collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) - else: - collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) - if collision_found: - context.hard_collision_set.add(cache_key) - metrics.pruned_hard_collision += 1 - return - context.static_safe_cache.add(cache_key) - - total_overlaps = 0 - if not skip_congestion: - if cache_key in congestion_cache: - total_overlaps = congestion_cache[cache_key] - else: - total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) - congestion_cache[cache_key] = total_overlaps - - if self_collision_check: - if component_hits_ancestor_chain(result, parent): - return - - penalty = 0.0 - if move_type == "SB": - penalty = context.config.sbend_penalty - elif move_type == "B": - penalty = context.config.bend_penalty - if move_radius is not None and move_radius > TOLERANCE_LINEAR: - penalty *= (10.0 / move_radius) ** 0.5 - - move_cost = context.cost_evaluator.evaluate_move( - result.geometry, - result.end_port, - net_width, - net_id, - start_port=parent_p, - length=result.length, - dilated_geometry=result.dilated_geometry, - penalty=penalty, - skip_static=True, - skip_congestion=True, - ) - move_cost += total_overlaps * context.cost_evaluator.congestion_penalty - - if max_cost is not None and parent.g_cost + move_cost > max_cost: - metrics.pruned_cost += 1 - return - if move_cost > 1e12: - metrics.pruned_cost += 1 - return - - g_cost = parent.g_cost + move_cost - if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: - metrics.pruned_closed_set += 1 - return - - h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) - heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) - metrics.moves_added += 1 - - -def reconstruct_path(end_node: AStarNode) -> list[ComponentResult]: - path = [] - curr: AStarNode | None = end_node - while curr and curr.component_result: - path.append(curr.component_result) - curr = curr.parent - return path[::-1] diff --git a/inire/router/config.py b/inire/router/config.py deleted file mode 100644 index aac6264..0000000 --- a/inire/router/config.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Literal, Any - - -VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"] - - - -@dataclass -class RouterConfig: - """Configuration parameters for the A* Router.""" - - node_limit: int = 1000000 - max_straight_length: float = 2000.0 - min_straight_length: float = 5.0 - - sbend_offsets: list[float] | None = None - - bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0]) - sbend_radii: list[float] = field(default_factory=lambda: [10.0]) - snap_to_target_dist: float = 1000.0 - bend_penalty: float = 250.0 - sbend_penalty: float = 500.0 - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc" - bend_clip_margin: float = 10.0 - visibility_guidance: VisibilityGuidanceMode = "tangent_corner" - - -@dataclass -class CostConfig: - """Configuration parameters for the Cost Evaluator.""" - - unit_length_cost: float = 1.0 - greedy_h_weight: float = 1.5 - congestion_penalty: float = 10000.0 - bend_penalty: float = 250.0 - sbend_penalty: float = 500.0 - min_bend_radius: float = 50.0 diff --git a/inire/router/cost.py b/inire/router/cost.py index 94aafe3..8468c3e 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -5,12 +5,15 @@ from typing import TYPE_CHECKING import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.router.config import CostConfig +from inire.model import ObjectiveWeights, RoutingOptions if TYPE_CHECKING: + from collections.abc import Sequence + from shapely.geometry import Polygon - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld + from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap @@ -19,10 +22,12 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "config", - "unit_length_cost", - "greedy_h_weight", - "congestion_penalty", + "_unit_length_cost", + "_greedy_h_weight", + "_bend_penalty", + "_sbend_penalty", + "_danger_weight", + "_congestion_penalty", "_target_x", "_target_y", "_target_r", @@ -33,53 +38,102 @@ class CostEvaluator: def __init__( self, - collision_engine: CollisionEngine, + collision_engine: RoutingWorld, danger_map: DangerMap | None = None, unit_length_cost: float = 1.0, greedy_h_weight: float = 1.5, - congestion_penalty: float = 10000.0, bend_penalty: float = 250.0, sbend_penalty: float | None = None, - min_bend_radius: float = 50.0, + danger_weight: float = 1.0, ) -> None: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty self.collision_engine = collision_engine self.danger_map = danger_map - self.config = CostConfig( - unit_length_cost=unit_length_cost, - greedy_h_weight=greedy_h_weight, - congestion_penalty=congestion_penalty, - bend_penalty=bend_penalty, - sbend_penalty=actual_sbend_penalty, - min_bend_radius=min_bend_radius, - ) - self.unit_length_cost = self.config.unit_length_cost - self.greedy_h_weight = self.config.greedy_h_weight - self.congestion_penalty = self.config.congestion_penalty - self._refresh_cached_config() + self._unit_length_cost = float(unit_length_cost) + self._greedy_h_weight = float(greedy_h_weight) + self._bend_penalty = float(bend_penalty) + self._sbend_penalty = float(actual_sbend_penalty) + self._danger_weight = float(danger_weight) + self._congestion_penalty = 0.0 self._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - def apply_routing_costs( - self, - *, - bend_penalty: float, - sbend_penalty: float, - bend_radii: list[float], - ) -> None: - self.config.bend_penalty = bend_penalty - self.config.sbend_penalty = sbend_penalty - self.config.min_bend_radius = min(bend_radii) if bend_radii else 50.0 - self._refresh_cached_config() + self._min_radius = 50.0 - def _refresh_cached_config(self) -> None: - self._min_radius = self.config.min_bend_radius - self.unit_length_cost = self.config.unit_length_cost - self.greedy_h_weight = self.config.greedy_h_weight - self.congestion_penalty = self.config.congestion_penalty + @property + def unit_length_cost(self) -> float: + return self._unit_length_cost + + @unit_length_cost.setter + def unit_length_cost(self, value: float) -> None: + self._unit_length_cost = float(value) + + @property + def greedy_h_weight(self) -> float: + return self._greedy_h_weight + + @greedy_h_weight.setter + def greedy_h_weight(self, value: float) -> None: + self._greedy_h_weight = float(value) + + @property + def congestion_penalty(self) -> float: + return self._congestion_penalty + + @congestion_penalty.setter + def congestion_penalty(self, value: float) -> None: + self._congestion_penalty = float(value) + + @property + def bend_penalty(self) -> float: + return self._bend_penalty + + @bend_penalty.setter + def bend_penalty(self, value: float) -> None: + self._bend_penalty = float(value) + + @property + def sbend_penalty(self) -> float: + return self._sbend_penalty + + @sbend_penalty.setter + def sbend_penalty(self, value: float) -> None: + self._sbend_penalty = float(value) + + @property + def danger_weight(self) -> float: + return self._danger_weight + + @danger_weight.setter + def danger_weight(self, value: float) -> None: + self._danger_weight = float(value) + + def set_min_bend_radius(self, radius: float) -> None: + self._min_radius = float(radius) if radius > 0 else 50.0 + + def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights: + return ObjectiveWeights( + unit_length_cost=self._unit_length_cost, + bend_penalty=self._bend_penalty, + sbend_penalty=self._sbend_penalty, + danger_weight=self._danger_weight, + congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty), + ) + + def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights: + refinement_objective = options.refinement.objective + if refinement_objective is None: + return ObjectiveWeights( + unit_length_cost=self._unit_length_cost, + bend_penalty=self._bend_penalty, + sbend_penalty=self._sbend_penalty, + danger_weight=self._danger_weight, + congestion_penalty=0.0, + ) + return refinement_objective def set_target(self, target: Port) -> None: self._target_x = target.x @@ -92,7 +146,7 @@ class CostEvaluator: def g_proximity(self, x: float, y: float) -> float: if self.danger_map is None: return 0.0 - return self.danger_map.get_cost(x, y) + return self._danger_weight * self.danger_map.get_cost(x, y) def h_manhattan(self, current: Port, target: Port) -> float: tx, ty = target.x, target.y @@ -102,7 +156,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self.config.bend_penalty + bp = self._bend_penalty penalty = 0.0 curr_r = current.r @@ -132,27 +186,29 @@ class CostEvaluator: if diff == 0 and perp_dist > 0: penalty += 2 * bp - return self.greedy_h_weight * (dist + penalty) + return self._greedy_h_weight * (dist + penalty) def evaluate_move( self, - geometry: list[Polygon] | None, + geometry: Sequence[Polygon] | None, end_port: Port, net_width: float, net_id: str, start_port: Port | None = None, length: float = 0.0, - dilated_geometry: list[Polygon] | None = None, + dilated_geometry: Sequence[Polygon] | None = None, skip_static: bool = False, skip_congestion: bool = False, penalty: float = 0.0, + weights: ObjectiveWeights | None = None, ) -> float: + active_weights = self.objective_weights() if weights is None else weights _ = net_width danger_map = self.danger_map if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - total_cost = length * self.unit_length_cost + penalty + total_cost = length * active_weights.unit_length_cost + penalty if not skip_static or not skip_congestion: if geometry is None: return 1e15 @@ -171,16 +227,71 @@ class CostEvaluator: if not skip_congestion: overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly) if isinstance(overlaps, int) and overlaps > 0: - total_cost += overlaps * self.congestion_penalty + total_cost += overlaps * active_weights.congestion_penalty - if danger_map is not None: + if danger_map is not None and active_weights.danger_weight: cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 cost_e = danger_map.get_cost(end_port.x, end_port.y) if start_port: mid_x = (start_port.x + end_port.x) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0 cost_m = danger_map.get_cost(mid_x, mid_y) - total_cost += length * (cost_s + cost_m + cost_e) / 3.0 + total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += length * cost_e + total_cost += length * active_weights.danger_weight * cost_e return total_cost + + def component_penalty( + self, + move_type: MoveKind, + *, + move_radius: float | None = None, + weights: ObjectiveWeights | None = None, + ) -> float: + active_weights = self.objective_weights() if weights is None else weights + penalty = 0.0 + if move_type == "sbend": + penalty = active_weights.sbend_penalty + elif move_type == "bend90": + penalty = active_weights.bend_penalty + if move_radius is not None and move_radius > TOLERANCE_LINEAR and penalty > 0: + penalty *= (10.0 / move_radius) ** 0.5 + return penalty + + def path_cost( + self, + net_id: str, + start_port: Port, + path: list[ComponentResult], + *, + weights: ObjectiveWeights | None = None, + ) -> float: + active_weights = self.objective_weights() if weights is None else weights + total = 0.0 + current_port = start_port + for component in path: + move_radius = None + if component.move_type == "bend90": + move_radius = component.length * 2.0 / np.pi if component.length > 0 else None + elif component.move_type == "sbend": + move_radius = None + penalty = self.component_penalty( + component.move_type, + move_radius=move_radius, + weights=active_weights, + ) + total += self.evaluate_move( + component.collision_geometry, + component.end_port, + net_width=0.0, + net_id=net_id, + start_port=current_port, + length=component.length, + dilated_geometry=component.dilated_collision_geometry, + skip_static=True, + skip_congestion=(active_weights.congestion_penalty <= 0.0), + penalty=penalty, + weights=active_weights, + ) + current_port = component.end_port + return total diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 4ade2f9..03b8a2a 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -1,21 +1,24 @@ from __future__ import annotations +from collections import OrderedDict from typing import TYPE_CHECKING + import numpy -import shapely from scipy.spatial import cKDTree -from functools import lru_cache if TYPE_CHECKING: from shapely.geometry import Polygon +_COST_CACHE_SIZE = 100000 + + class DangerMap: """ A proximity cost evaluator using a KD-Tree of obstacle boundary points. Scales with obstacle perimeter rather than design area. """ - __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree') + __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache') def __init__( self, @@ -38,6 +41,7 @@ class DangerMap: self.safety_threshold = safety_threshold self.k = k self.tree: cKDTree | None = None + self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict() def precompute(self, obstacles: list[Polygon]) -> None: """ @@ -64,9 +68,8 @@ class DangerMap: self.tree = cKDTree(numpy.array(all_points)) else: self.tree = None - - # Clear cache when tree changes - self._get_cost_quantized.cache_clear() + + self._cost_cache.clear() def is_within_bounds(self, x: float, y: float) -> bool: """ @@ -81,10 +84,18 @@ class DangerMap: """ qx_milli = int(round(x * 1000)) qy_milli = int(round(y * 1000)) - return self._get_cost_quantized(qx_milli, qy_milli) + key = (qx_milli, qy_milli) + if key in self._cost_cache: + self._cost_cache.move_to_end(key) + return self._cost_cache[key] - @lru_cache(maxsize=100000) - def _get_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: + cost = self._compute_cost_quantized(qx_milli, qy_milli) + self._cost_cache[key] = cost + if len(self._cost_cache) > _COST_CACHE_SIZE: + self._cost_cache.popitem(last=False) + return cost + + def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: qx = qx_milli / 1000.0 qy = qy_milli / 1000.0 if not self.is_within_bounds(qx, qy): diff --git a/inire/router/path_state.py b/inire/router/path_state.py deleted file mode 100644 index bcb386e..0000000 --- a/inire/router/path_state.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine, PathVerificationReport - from inire.geometry.components import ComponentResult - - -class PathStateManager: - __slots__ = ("collision_engine",) - - def __init__(self, collision_engine: CollisionEngine) -> None: - self.collision_engine = collision_engine - - def extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: - all_geoms = [] - all_dilated = [] - for res in path: - all_geoms.extend(res.geometry) - if res.dilated_geometry: - all_dilated.extend(res.dilated_geometry) - else: - dilation = self.collision_engine.clearance / 2.0 - all_dilated.extend([poly.buffer(dilation) for poly in res.geometry]) - return all_geoms, all_dilated - - def install_path(self, net_id: str, path: list[ComponentResult]) -> None: - all_geoms, all_dilated = self.extract_geometry(path) - self.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - def stage_path_as_static(self, path: list[ComponentResult]) -> list[int]: - obj_ids: list[int] = [] - for res in path: - geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry - dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry - for index, poly in enumerate(geoms): - dilated = dilated_geoms[index] if dilated_geoms else None - obj_ids.append(self.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated)) - return obj_ids - - def remove_static_obstacles(self, obj_ids: list[int]) -> None: - for obj_id in obj_ids: - self.collision_engine.remove_static_obstacle(obj_id) - - def remove_path(self, net_id: str) -> None: - self.collision_engine.remove_path(net_id) - - def verify_path(self, net_id: str, path: list[ComponentResult]) -> tuple[bool, int]: - return self.collision_engine.verify_path(net_id, path) - - def verify_path_report(self, net_id: str, path: list[ComponentResult]) -> PathVerificationReport: - return self.collision_engine.verify_path_report(net_id, path) - - def finalize_dynamic_tree(self) -> None: - self.collision_engine.rebuild_dynamic_tree() diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py deleted file mode 100644 index 13de9b9..0000000 --- a/inire/router/pathfinder.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal - -from inire.router.astar import AStarMetrics, route_astar -from inire.router.outcomes import RoutingOutcome, infer_routing_outcome, routing_outcome_needs_retry -from inire.router.refiner import PathRefiner -from inire.router.path_state import PathStateManager -from inire.router.session import ( - create_routing_session_state, - finalize_routing_session_results, - prepare_routing_session_state, - refine_routing_session_results, - run_routing_iteration, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.astar import AStarContext - from inire.router.cost import CostEvaluator - -logger = logging.getLogger(__name__) - - -@dataclass -class RoutingResult: - net_id: str - path: list[ComponentResult] - is_valid: bool - collisions: int - reached_target: bool = False - outcome: RoutingOutcome = "unroutable" - -class PathFinder: - __slots__ = ( - "context", - "metrics", - "max_iterations", - "base_congestion_penalty", - "use_tiered_strategy", - "congestion_multiplier", - "accumulated_expanded_nodes", - "warm_start", - "refine_paths", - "refiner", - "path_state", - ) - - def __init__( - self, - context: AStarContext, - metrics: AStarMetrics | None = None, - max_iterations: int = 10, - base_congestion_penalty: float = 100.0, - congestion_multiplier: float = 1.5, - use_tiered_strategy: bool = True, - warm_start: Literal["shortest", "longest", "user"] | None = "shortest", - refine_paths: bool = True, - ) -> None: - self.context = context - self.metrics = metrics if metrics is not None else AStarMetrics() - self.max_iterations = max_iterations - self.base_congestion_penalty = base_congestion_penalty - self.congestion_multiplier = congestion_multiplier - self.use_tiered_strategy = use_tiered_strategy - self.warm_start = warm_start - self.refine_paths = refine_paths - self.refiner = PathRefiner(context) - self.path_state = PathStateManager(context.cost_evaluator.collision_engine) - self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] - - @property - def cost_evaluator(self) -> CostEvaluator: - return self.context.cost_evaluator - - def _build_greedy_warm_start_paths( - self, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - order: Literal["shortest", "longest", "user"], - ) -> dict[str, list[ComponentResult]]: - all_net_ids = list(netlist.keys()) - if order != "user": - all_net_ids.sort( - key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y), - reverse=(order == "longest"), - ) - - greedy_paths: dict[str, list[ComponentResult]] = {} - temp_obj_ids: list[int] = [] - greedy_node_limit = min(self.context.config.node_limit, 2000) - for net_id in all_net_ids: - start, target = netlist[net_id] - width = net_widths.get(net_id, 2.0) - h_start = self.cost_evaluator.h_manhattan(start, target) - max_cost_limit = max(h_start * 3.0, 2000.0) - path = route_astar( - start, - target, - width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - skip_congestion=True, - max_cost=max_cost_limit, - self_collision_check=True, - node_limit=greedy_node_limit, - ) - if not path: - continue - greedy_paths[net_id] = path - temp_obj_ids.extend(self.path_state.stage_path_as_static(path)) - self.context.clear_static_caches() - - self.path_state.remove_static_obstacles(temp_obj_ids) - return greedy_paths - - def _path_cost(self, path: list[ComponentResult]) -> float: - return self.refiner.path_cost(path) - - def _install_path(self, net_id: str, path: list[ComponentResult]) -> None: - self.path_state.install_path(net_id, path) - - def _build_routing_result( - self, - *, - net_id: str, - path: list[ComponentResult], - reached_target: bool, - collisions: int, - outcome: RoutingOutcome | None = None, - ) -> RoutingResult: - resolved_outcome = ( - infer_routing_outcome( - has_path=bool(path), - reached_target=reached_target, - collision_count=collisions, - ) - if outcome is None - else outcome - ) - return RoutingResult( - net_id=net_id, - path=path, - is_valid=(resolved_outcome == "completed"), - collisions=collisions, - reached_target=reached_target, - outcome=resolved_outcome, - ) - - def _refine_path( - self, - net_id: str, - start: Port, - target: Port, - net_width: float, - path: list[ComponentResult], - ) -> list[ComponentResult]: - return self.refiner.refine_path(net_id, start, target, net_width, path) - - def _route_net_once( - self, - net_id: str, - start: Port, - target: Port, - width: float, - iteration: int, - initial_paths: dict[str, list[ComponentResult]] | None, - store_expanded: bool, - needs_self_collision_check: set[str], - ) -> tuple[RoutingResult, RoutingOutcome]: - self.path_state.remove_path(net_id) - path: list[ComponentResult] | None = None - - if iteration == 0 and initial_paths and net_id in initial_paths: - path = initial_paths[net_id] - else: - target_coll_model = self.context.config.bend_collision_type - coll_model = target_coll_model - skip_cong = False - if self.use_tiered_strategy and iteration == 0: - skip_cong = True - if target_coll_model == "arc": - coll_model = "clipped_bbox" - - path = route_astar( - start, - target, - width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - bend_collision_type=coll_model, - return_partial=True, - store_expanded=store_expanded, - skip_congestion=skip_cong, - self_collision_check=(net_id in needs_self_collision_check), - node_limit=self.context.config.node_limit, - ) - - if store_expanded and self.metrics.last_expanded_nodes: - self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) - - if not path: - outcome = infer_routing_outcome(has_path=False, reached_target=False, collision_count=0) - return self._build_routing_result(net_id=net_id, path=[], reached_target=False, collisions=0, outcome=outcome), outcome - - last_p = path[-1].end_port - reached = last_p == target - collision_count = 0 - - self._install_path(net_id, path) - if reached: - report = self.path_state.verify_path_report(net_id, path) - collision_count = report.collision_count - if report.self_collision_count > 0: - needs_self_collision_check.add(net_id) - - outcome = infer_routing_outcome( - has_path=bool(path), - reached_target=reached, - collision_count=collision_count, - ) - return ( - self._build_routing_result( - net_id=net_id, - path=path, - reached_target=reached, - collisions=collision_count, - outcome=outcome, - ), - outcome, - ) - - def route_all( - self, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - store_expanded: bool = False, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, - shuffle_nets: bool = False, - sort_nets: Literal["shortest", "longest", "user", None] = None, - initial_paths: dict[str, list[ComponentResult]] | None = None, - seed: int | None = None, - ) -> dict[str, RoutingResult]: - self.cost_evaluator.congestion_penalty = self.base_congestion_penalty - self.accumulated_expanded_nodes = [] - self.metrics.reset_per_route() - - state = create_routing_session_state( - self, - netlist, - net_widths, - store_expanded=store_expanded, - iteration_callback=iteration_callback, - shuffle_nets=shuffle_nets, - sort_nets=sort_nets, - initial_paths=initial_paths, - seed=seed, - ) - prepare_routing_session_state(self, state) - - for iteration in range(self.max_iterations): - iteration_outcomes = run_routing_iteration(self, state, iteration) - if iteration_outcomes is None: - return self.verify_all_nets(state.results, state.netlist) - if not any(routing_outcome_needs_retry(outcome) for outcome in iteration_outcomes.values()): - break - self.cost_evaluator.congestion_penalty *= self.congestion_multiplier - - refine_routing_session_results(self, state) - return finalize_routing_session_results(self, state) - - def verify_all_nets( - self, - results: dict[str, RoutingResult], - netlist: dict[str, tuple[Port, Port]], - ) -> dict[str, RoutingResult]: - final_results: dict[str, RoutingResult] = {} - for net_id, (_, target_p) in netlist.items(): - res = results.get(net_id) - if not res or not res.path: - final_results[net_id] = self._build_routing_result( - net_id=net_id, - path=[], - reached_target=False, - collisions=0, - ) - continue - last_p = res.path[-1].end_port - reached = last_p == target_p - report = self.path_state.verify_path_report(net_id, res.path) - final_results[net_id] = RoutingResult( - net_id=net_id, - path=res.path, - is_valid=(reached and report.is_valid), - collisions=report.collision_count, - reached_target=reached, - outcome=infer_routing_outcome( - has_path=True, - reached_target=reached, - collision_count=report.collision_count, - ), - ) - return final_results diff --git a/inire/router/refiner.py b/inire/router/refiner.py index cdd6ea1..1f9112e 100644 --- a/inire/router/refiner.py +++ b/inire/router/refiner.py @@ -7,10 +7,12 @@ from inire.geometry.component_overlap import components_overlap, has_self_overla from inire.geometry.components import Bend90, Straight if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine + from collections.abc import Sequence + + from inire.geometry.collision import RoutingWorld from inire.geometry.components import ComponentResult from inire.geometry.primitives import Port - from inire.router.astar import AStarContext + from inire.router._search import AStarContext def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: current = parent_node @@ -22,7 +24,7 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) return False -def has_self_collision(path: list[ComponentResult]) -> bool: +def has_self_collision(path: Sequence[ComponentResult]) -> bool: return has_self_overlap(path) @@ -33,26 +35,26 @@ class PathRefiner: self.context = context @property - def collision_engine(self) -> CollisionEngine: + def collision_engine(self) -> RoutingWorld: return self.context.cost_evaluator.collision_engine - def path_cost(self, path: list[ComponentResult]) -> float: - total = 0.0 - bend_penalty = self.context.config.bend_penalty - sbend_penalty = self.context.config.sbend_penalty - for comp in path: - total += comp.length - if comp.move_type == "Bend90": - radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0 - if radius > 0: - total += bend_penalty * (10.0 / radius) ** 0.5 - else: - total += bend_penalty - elif comp.move_type == "SBend": - total += sbend_penalty - return total + def path_cost( + self, + path: Sequence[ComponentResult], + *, + net_id: str = "default", + start: Port | None = None, + ) -> float: + if not path: + return 0.0 + actual_start = path[0].start_port if start is None else start + return self.score_path(net_id, actual_start, path) - def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]: + def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float: + weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options) + return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights) + + def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: ports = [start] ports.extend(comp.end_port for comp in path) return ports @@ -79,7 +81,7 @@ class PathRefiner: return -dx, -dy return -dy, dx - def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]: + def _window_query_bounds(self, start: Port, target: Port, path: Sequence[ComponentResult], pad: float) -> tuple[float, float, float, float]: min_x = float(min(start.x, target.x)) min_y = float(min(start.y, target.y)) max_x = float(max(start.x, target.x)) @@ -96,7 +98,7 @@ class PathRefiner: self, start: Port, target: Port, - window_path: list[ComponentResult], + window_path: Sequence[ComponentResult], net_width: float, radius: float, ) -> list[float]: @@ -187,7 +189,7 @@ class PathRefiner: second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) if first_straight < -0.01 or second_straight < -0.01: return None - min_straight = self.context.config.min_straight_length + min_straight = self.context.options.search.min_straight_length if 0.01 < first_straight < min_straight - 0.01: return None if 0.01 < second_straight < min_straight - 0.01: @@ -226,16 +228,16 @@ class PathRefiner: return None return path - def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]: + def _iter_refinement_windows(self, start: Port, path: Sequence[ComponentResult]) -> list[tuple[int, int]]: ports = self._path_ports(start, path) windows: list[tuple[int, int]] = [] - min_radius = min(self.context.config.bend_radii, default=0.0) + min_radius = min(self.context.options.search.bend_radii, default=0.0) for window_size in range(len(path), 0, -1): for start_idx in range(len(path) - window_size + 1): end_idx = start_idx + window_size window = path[start_idx:end_idx] - bend_count = sum(1 for comp in window if comp.move_type == "Bend90") + bend_count = sum(1 for comp in window if comp.move_type == "bend90") if bend_count < 4: continue window_start = ports[start_idx] @@ -266,7 +268,7 @@ class PathRefiner: best_path: list[ComponentResult] | None = None best_candidate_cost = best_cost - for radius in self.context.config.bend_radii: + for radius in self.context.options.search.bend_radii: side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius) for side_extent in side_extents: replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent) @@ -297,12 +299,14 @@ class PathRefiner: if not path: return path - bend_count = sum(1 for comp in path if comp.move_type == "Bend90") + path = list(path) + + bend_count = sum(1 for comp in path if comp.move_type == "bend90") if bend_count < 4: return path best_path = path - best_cost = self.path_cost(path) + best_cost = self.score_path(net_id, start, path) for _ in range(3): improved = False diff --git a/inire/router/results.py b/inire/router/results.py new file mode 100644 index 0000000..3548f64 --- /dev/null +++ b/inire/router/results.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from inire.router.outcomes import RoutingOutcome, infer_routing_outcome + +if TYPE_CHECKING: + from inire.geometry.components import ComponentResult + from inire.model import LockedRoute + + +@dataclass(frozen=True, slots=True) +class RoutingReport: + static_collision_count: int = 0 + dynamic_collision_count: int = 0 + self_collision_count: int = 0 + total_length: float = 0.0 + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + nodes_expanded: int + moves_generated: int + moves_added: int + pruned_closed_set: int + pruned_hard_collision: int + pruned_cost: int + + +@dataclass(frozen=True, slots=True) +class RoutingResult: + net_id: str + path: tuple[ComponentResult, ...] + reached_target: bool = False + report: RoutingReport = field(default_factory=RoutingReport) + + def __post_init__(self) -> None: + object.__setattr__(self, "path", tuple(self.path)) + + @property + def collisions(self) -> int: + return self.report.collision_count + + @property + def outcome(self) -> RoutingOutcome: + return infer_routing_outcome( + has_path=bool(self.path), + reached_target=self.reached_target, + collision_count=self.report.collision_count, + ) + + @property + def is_valid(self) -> bool: + return self.outcome == "completed" + + def as_locked_route(self) -> LockedRoute: + from inire.model import LockedRoute + + return LockedRoute.from_path(self.path) diff --git a/inire/router/session.py b/inire/router/session.py deleted file mode 100644 index e037eaf..0000000 --- a/inire/router/session.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import random -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal - -from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry - -if TYPE_CHECKING: - from collections.abc import Callable - - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - from inire.router.pathfinder import PathFinder, RoutingResult - - -@dataclass -class RoutingSessionState: - netlist: dict[str, tuple[Port, Port]] - net_widths: dict[str, float] - results: dict[str, RoutingResult] - all_net_ids: list[str] - needs_self_collision_check: set[str] - start_time: float - session_timeout: float - initial_paths: dict[str, list[ComponentResult]] | None - store_expanded: bool - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None - shuffle_nets: bool - sort_nets: Literal["shortest", "longest", "user", None] - seed: int | None - - -def create_routing_session_state( - finder: PathFinder, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - *, - store_expanded: bool, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, - shuffle_nets: bool, - sort_nets: Literal["shortest", "longest", "user", None], - initial_paths: dict[str, list[ComponentResult]] | None, - seed: int | None, -) -> RoutingSessionState: - num_nets = len(netlist) - return RoutingSessionState( - netlist=netlist, - net_widths=net_widths, - results={}, - all_net_ids=list(netlist.keys()), - needs_self_collision_check=set(), - start_time=time.monotonic(), - session_timeout=max(60.0, 10.0 * num_nets * finder.max_iterations), - initial_paths=initial_paths, - store_expanded=store_expanded, - iteration_callback=iteration_callback, - shuffle_nets=shuffle_nets, - sort_nets=sort_nets, - seed=seed, - ) - - -def prepare_routing_session_state( - finder: PathFinder, - state: RoutingSessionState, -) -> None: - if state.initial_paths is None: - warm_start_order = state.sort_nets if state.sort_nets is not None else finder.warm_start - if warm_start_order is not None: - state.initial_paths = finder._build_greedy_warm_start_paths(state.netlist, state.net_widths, warm_start_order) - finder.context.clear_static_caches() - - if state.sort_nets and state.sort_nets != "user": - state.all_net_ids.sort( - key=lambda net_id: abs(state.netlist[net_id][1].x - state.netlist[net_id][0].x) - + abs(state.netlist[net_id][1].y - state.netlist[net_id][0].y), - reverse=(state.sort_nets == "longest"), - ) - - -def run_routing_iteration( - finder: PathFinder, - state: RoutingSessionState, - iteration: int, -) -> dict[str, RoutingOutcome] | None: - outcomes: dict[str, RoutingOutcome] = {} - finder.accumulated_expanded_nodes = [] - finder.metrics.reset_per_route() - - if state.shuffle_nets and (iteration > 0 or state.initial_paths is None): - iteration_seed = (state.seed + iteration) if state.seed is not None else None - random.Random(iteration_seed).shuffle(state.all_net_ids) - - for net_id in state.all_net_ids: - start, target = state.netlist[net_id] - if time.monotonic() - state.start_time > state.session_timeout: - finder.path_state.finalize_dynamic_tree() - return None - - width = state.net_widths.get(net_id, 2.0) - result, net_congestion = finder._route_net_once( - net_id, - start, - target, - width, - iteration, - state.initial_paths, - state.store_expanded, - state.needs_self_collision_check, - ) - state.results[net_id] = result - outcomes[net_id] = net_congestion - - if state.iteration_callback: - state.iteration_callback(iteration, state.results) - return outcomes - - -def refine_routing_session_results( - finder: PathFinder, - state: RoutingSessionState, -) -> None: - if not finder.refine_paths or not state.results: - return - - for net_id in state.all_net_ids: - res = state.results.get(net_id) - if not res or not res.path or routing_outcome_needs_retry(res.outcome): - continue - start, target = state.netlist[net_id] - width = state.net_widths.get(net_id, 2.0) - finder.path_state.remove_path(net_id) - refined_path = finder._refine_path(net_id, start, target, width, res.path) - finder._install_path(net_id, refined_path) - report = finder.path_state.verify_path_report(net_id, refined_path) - state.results[net_id] = finder._build_routing_result( - net_id=net_id, - path=refined_path, - reached_target=res.reached_target, - collisions=report.collision_count, - ) - - -def finalize_routing_session_results( - finder: PathFinder, - state: RoutingSessionState, -) -> dict[str, RoutingResult]: - finder.path_state.finalize_dynamic_tree() - return finder.verify_all_nets(state.results, state.netlist) diff --git a/inire/router/visibility.py b/inire/router/visibility.py index 38fb6af..ed83d00 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING import rtree if TYPE_CHECKING: - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.primitives import Port @@ -18,7 +18,7 @@ class VisibilityManager: """ __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version") - def __init__(self, collision_engine: CollisionEngine) -> None: + def __init__(self, collision_engine: RoutingWorld) -> None: self.collision_engine = collision_engine self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() @@ -153,10 +153,3 @@ class VisibilityManager: if corner_idx is not None and corner_idx in self._corner_graph: return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] return [] - - def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: - """ - Backward-compatible alias for arbitrary-point visibility queries. - Prefer `get_corner_visibility()` in routing code and `get_point_visibility()` elsewhere. - """ - return self.get_point_visibility(origin, max_dist=max_dist) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py index d13becd..3513c62 100644 --- a/inire/tests/benchmark_scaling.py +++ b/inire/tests/benchmark_scaling.py @@ -1,10 +1,13 @@ import time + +from inire import NetSpec from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.router.danger_map import DangerMap from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.pathfinder import PathFinder +from inire.router._astar_types import AStarMetrics +from inire.router._router import PathFinder +from inire.tests.support import build_context def benchmark_scaling() -> None: print("Starting Scalability Benchmark...") @@ -20,25 +23,33 @@ def benchmark_scaling() -> None: assert mem_gb < 2.0 # 2. Node Expansion Rate (50 nets) - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Use a smaller area for routing benchmark to keep it fast routing_bounds = (0, 0, 1000, 1000) danger_map = DangerMap(bounds=routing_bounds) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - num_nets = 50 netlist = {} for i in range(num_nets): # Parallel nets spaced by 10um netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0)) + metrics = AStarMetrics() + pf = PathFinder( + build_context( + evaluator, + bounds=routing_bounds, + nets=( + NetSpec(net_id=net_id, start=start, target=target, width=2.0) + for net_id, (start, target) in netlist.items() + ), + ), + metrics=metrics, + ) print(f"Routing {num_nets} nets...") start_time = time.monotonic() - results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0)) + results = pf.route_all() end_time = time.monotonic() total_time = end_time - start_time diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index aeffc1c..da4f98c 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -6,12 +6,13 @@ from typing import Callable from shapely.geometry import Polygon, box -from inire.geometry.collision import CollisionEngine +from inire import NetSpec, RoutingResult +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics +from inire.router._astar_types import AStarMetrics from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder, RoutingResult +from inire.tests.support import build_context, build_pathfinder @dataclass(frozen=True) @@ -28,30 +29,6 @@ class ScenarioDefinition: run: Callable[[], ScenarioOutcome] -def _build_router( - *, - bounds: tuple[float, float, float, float], - clearance: float = 2.0, - obstacles: list[Polygon] | None = None, - evaluator_kwargs: dict[str, float] | None = None, - context_kwargs: dict[str, object] | None = None, - pathfinder_kwargs: dict[str, object] | None = None, -) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]: - static_obstacles = obstacles or [] - engine = CollisionEngine(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(static_obstacles) - - evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) - context = AStarContext(evaluator, **(context_kwargs or {})) - metrics = AStarMetrics() - pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {})) - return engine, evaluator, context, metrics, pathfinder - - def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: return ScenarioOutcome( duration_s=duration_s, @@ -70,7 +47,7 @@ def _build_evaluator( sbend_penalty: float = 150.0, ) -> CostEvaluator: static_obstacles = obstacles or [] - engine = CollisionEngine(clearance=clearance) + engine = RoutingWorld(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) @@ -79,92 +56,155 @@ def _build_evaluator( return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty) +def _net_specs( + netlist: dict[str, tuple[Port, Port]], + widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_routing_stack( + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + widths: dict[str, float], + clearance: float = 2.0, + obstacles: list[Polygon] | None = None, + evaluator_kwargs: dict[str, float] | None = None, + request_kwargs: dict[str, object] | None = None, +) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]: + static_obstacles = obstacles or [] + engine = RoutingWorld(clearance=clearance) + for obstacle in static_obstacles: + engine.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=bounds) + danger_map.precompute(static_obstacles) + + evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) + metrics = AStarMetrics() + pathfinder = build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + metrics=metrics, + **(request_kwargs or {}), + ) + return engine, evaluator, metrics, pathfinder + + def run_example_01() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]}) netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))} + widths = {"net1": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, {"net1": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_02() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "bend_penalty": 50.0, - "sbend_penalty": 150.0, - }, - context_kwargs={ - "bend_radii": [10.0], - "sbend_radii": [10.0], - }, - pathfinder_kwargs={"base_congestion_penalty": 1000.0}, - ) netlist = { "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "greedy_h_weight": 1.5, + "bend_penalty": 50.0, + "sbend_penalty": 150.0, + }, + request_kwargs={ + "bend_radii": [10.0], + "sbend_radii": [10.0], + "base_penalty": 1000.0, + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_03() -> ScenarioOutcome: - engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]}) + netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} + widths_a = {"netA": 2.0} + engine, evaluator, _, pathfinder = _build_routing_stack( + bounds=(0, -50, 100, 50), + netlist=netlist_a, + widths=widths_a, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - engine.lock_net("netA") - results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}) + results_a = pathfinder.route_all() + for polygon in results_a["netA"].as_locked_route().geometry: + engine.add_static_obstacle(polygon) + results_b = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}), + bend_radii=[10.0], + ).route_all() t1 = perf_counter() return _summarize({**results_a, **results_b}, t1 - t0) def run_example_04() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "unit_length_cost": 1.0, - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - context_kwargs={ - "node_limit": 50000, - "bend_radii": [10.0, 30.0], - "sbend_offsets": [5.0], - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - ) netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } widths = {"sbend_only": 2.0, "multi_radii": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "unit_length_cost": 1.0, + "bend_penalty": 10.0, + "sbend_penalty": 20.0, + }, + request_kwargs={ + "node_limit": 50000, + "bend_radii": [10.0, 30.0], + "sbend_offsets": [5.0], + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_05() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 200, 200), - evaluator_kwargs={"bend_penalty": 50.0}, - context_kwargs={"bend_radii": [20.0]}, - ) netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 200, 200), + netlist=netlist, + widths=widths, + evaluator_kwargs={"bend_penalty": 50.0}, + request_kwargs={"bend_radii": [20.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) @@ -178,32 +218,35 @@ def run_example_06() -> ScenarioOutcome: ] scenarios = [ ( - AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="arc"), + _build_evaluator(bounds, obstacles=obstacles), {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}, {"arc_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False}, ), ( - AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="bbox"), + _build_evaluator(bounds, obstacles=obstacles), {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}, {"bbox_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False}, ), ( - AStarContext( - _build_evaluator(bounds, obstacles=obstacles), - bend_radii=[10.0], - bend_collision_type="clipped_bbox", - bend_clip_margin=1.0, - ), + _build_evaluator(bounds, obstacles=obstacles), {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, {"clipped_model": 2.0}, + {"bend_radii": [10.0], "bend_collision_type": "clipped_bbox", "use_tiered_strategy": False}, ), ] t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} - for context, netlist, net_widths in scenarios: - pathfinder = PathFinder(context, use_tiered_strategy=False) - combined_results.update(pathfinder.route_all(netlist, net_widths)) + for evaluator, netlist, net_widths, request_kwargs in scenarios: + pathfinder = build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, net_widths), + **request_kwargs, + ) + combined_results.update(pathfinder.route_all()) t1 = perf_counter() return _summarize(combined_results, t1 - t0) @@ -214,29 +257,6 @@ def run_example_07() -> ScenarioOutcome: box(450, 0, 550, 400), box(450, 600, 550, 1000), ] - _, evaluator, _, metrics, pathfinder = _build_router( - bounds=bounds, - clearance=6.0, - obstacles=obstacles, - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "unit_length_cost": 0.1, - "bend_penalty": 100.0, - "sbend_penalty": 400.0, - "congestion_penalty": 100.0, - }, - context_kwargs={ - "node_limit": 2000000, - "bend_radii": [50.0], - "sbend_radii": [50.0], - }, - pathfinder_kwargs={ - "max_iterations": 15, - "base_congestion_penalty": 100.0, - "congestion_multiplier": 1.4, - }, - ) - num_nets = 10 start_x = 50 start_y_base = 500 - (num_nets * 10.0) / 2.0 @@ -249,6 +269,31 @@ def run_example_07() -> ScenarioOutcome: sy = int(round(start_y_base + index * 10.0)) ey = int(round(end_y_base + index * end_y_pitch)) netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0)) + widths = dict.fromkeys(netlist, 2.0) + _, evaluator, metrics, pathfinder = _build_routing_stack( + bounds=bounds, + netlist=netlist, + widths=widths, + clearance=6.0, + obstacles=obstacles, + evaluator_kwargs={ + "greedy_h_weight": 1.5, + "unit_length_cost": 0.1, + "bend_penalty": 100.0, + "sbend_penalty": 400.0, + }, + request_kwargs={ + "node_limit": 2000000, + "bend_radii": [50.0], + "sbend_radii": [50.0], + "max_iterations": 15, + "base_penalty": 100.0, + "multiplier": 1.4, + "capture_expanded": True, + "shuffle_nets": True, + "seed": 42, + }, + ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) @@ -256,14 +301,7 @@ def run_example_07() -> ScenarioOutcome: metrics.reset_per_route() t0 = perf_counter() - results = pathfinder.route_all( - netlist, - dict.fromkeys(netlist, 2.0), - store_expanded=True, - iteration_callback=iteration_callback, - shuffle_nets=True, - seed=42, - ) + results = pathfinder.route_all(iteration_callback=iteration_callback) t1 = perf_counter() return _summarize(results, t1 - t0) @@ -272,21 +310,30 @@ def run_example_08() -> ScenarioOutcome: bounds = (0, 0, 150, 150) netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"custom_bend": 2.0} - - context_std = AStarContext(_build_evaluator(bounds), bend_radii=[10.0], sbend_radii=[]) - context_custom = AStarContext( - _build_evaluator(bounds), - bend_radii=[10.0], - bend_collision_type=Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]), - sbend_radii=[], - ) + custom_model = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) + standard_evaluator = _build_evaluator(bounds) + custom_evaluator = _build_evaluator(bounds) t0 = perf_counter() - results_std = PathFinder(context_std, AStarMetrics(), use_tiered_strategy=False).route_all(netlist, widths) - results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all( - {"custom_model": netlist["custom_bend"]}, - {"custom_model": 2.0}, - ) + results_std = build_pathfinder( + standard_evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + bend_radii=[10.0], + sbend_radii=[], + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() + results_custom = build_pathfinder( + custom_evaluator, + bounds=bounds, + nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + bend_radii=[10.0], + bend_collision_type=custom_model, + sbend_radii=[], + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() t1 = perf_counter() return _summarize({**results_std, **results_custom}, t1 - t0) @@ -296,16 +343,18 @@ def run_example_09() -> ScenarioOutcome: box(35, 35, 45, 65), box(55, 35, 65, 65), ] - _, _, _, _, pathfinder = _build_router( + netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} + widths = {"budget_limited_net": 2.0} + _, _, _, pathfinder = _build_routing_stack( bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - context_kwargs={"node_limit": 3, "bend_radii": [10.0]}, - pathfinder_kwargs={"warm_start": None}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None}, ) - netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} t0 = perf_counter() - results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) @@ -313,7 +362,7 @@ def run_example_09() -> ScenarioOutcome: SCENARIOS: tuple[ScenarioDefinition, ...] = ( ScenarioDefinition("example_01_simple_route", run_example_01), ScenarioDefinition("example_02_congestion_resolution", run_example_02), - ScenarioDefinition("example_03_locked_paths", run_example_03), + ScenarioDefinition("example_03_locked_routes", run_example_03), ScenarioDefinition("example_04_sbends_and_radii", run_example_04), ScenarioDefinition("example_05_orientation_stress", run_example_05), ScenarioDefinition("example_06_bend_collision_models", run_example_06), diff --git a/inire/tests/support.py b/inire/tests/support.py new file mode 100644 index 0000000..3461790 --- /dev/null +++ b/inire/tests/support.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from inire.model import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, +) +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder + + +def build_problem( + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + **overrides: object, +) -> RoutingProblem: + return RoutingProblem( + bounds=bounds, + nets=tuple(nets), + **overrides, + ) + + +def build_request( + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + **overrides: object, +) -> RoutingProblem: + return build_problem(bounds=bounds, nets=nets, **overrides) + + +def build_options( + *, + objective: ObjectiveWeights | None = None, + search: SearchOptions | None = None, + congestion: CongestionOptions | None = None, + refinement: RefinementOptions | None = None, + diagnostics: DiagnosticsOptions | None = None, + **overrides: object, +) -> RoutingOptions: + if objective is None: + objective = ObjectiveWeights() + if search is None: + search = SearchOptions() + if congestion is None: + congestion = CongestionOptions() + if refinement is None: + refinement = RefinementOptions() + if diagnostics is None: + diagnostics = DiagnosticsOptions() + + search_fields = set(SearchOptions.__dataclass_fields__) + congestion_fields = set(CongestionOptions.__dataclass_fields__) + refinement_fields = set(RefinementOptions.__dataclass_fields__) + diagnostics_fields = set(DiagnosticsOptions.__dataclass_fields__) + objective_fields = set(ObjectiveWeights.__dataclass_fields__) + + search_overrides = {key: value for key, value in overrides.items() if key in search_fields} + congestion_overrides = {key: value for key, value in overrides.items() if key in congestion_fields} + refinement_overrides = {key: value for key, value in overrides.items() if key in refinement_fields} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in diagnostics_fields} + objective_overrides = {key: value for key, value in overrides.items() if key in objective_fields} + + unknown = set(overrides) - search_fields - congestion_fields - refinement_fields - diagnostics_fields - objective_fields + if unknown: + unknown_names = ", ".join(sorted(unknown)) + raise TypeError(f"Unsupported RoutingOptions overrides: {unknown_names}") + + resolved_objective = objective if not objective_overrides else ObjectiveWeights( + **{ + field: getattr(objective, field) + for field in objective_fields + } + | objective_overrides + ) + resolved_search = search if not search_overrides else SearchOptions( + **{ + field: getattr(search, field) + for field in search_fields + } + | search_overrides + ) + resolved_congestion = congestion if not congestion_overrides else CongestionOptions( + **{ + field: getattr(congestion, field) + for field in congestion_fields + } + | congestion_overrides + ) + resolved_refinement = refinement if not refinement_overrides else RefinementOptions( + **{ + field: getattr(refinement, field) + for field in refinement_fields + } + | refinement_overrides + ) + resolved_diagnostics = diagnostics if not diagnostics_overrides else DiagnosticsOptions( + **{ + field: getattr(diagnostics, field) + for field in diagnostics_fields + } + | diagnostics_overrides + ) + return RoutingOptions( + search=resolved_search, + objective=resolved_objective, + congestion=resolved_congestion, + refinement=resolved_refinement, + diagnostics=resolved_diagnostics, + ) + + +def build_context( + evaluator, + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + problem: RoutingProblem | None = None, + options: RoutingOptions | None = None, + **overrides: object, +) -> AStarContext: + resolved_problem = problem if problem is not None else build_problem(bounds=bounds, nets=nets) + resolved_options = options if options is not None else build_options(**overrides) + return AStarContext( + evaluator, + resolved_problem, + resolved_options, + ) + + +def build_pathfinder( + evaluator, + *, + bounds: tuple[float, float, float, float], + nets: Iterable[NetSpec] = (), + netlist: dict[str, tuple[object, object]] | None = None, + net_widths: dict[str, float] | None = None, + problem: RoutingProblem | None = None, + options: RoutingOptions | None = None, + **overrides: object, +) -> PathFinder: + resolved_problem = problem + if resolved_problem is None: + resolved_nets = tuple(nets) + if netlist is not None: + widths = {} if net_widths is None else net_widths + resolved_nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + resolved_problem = build_problem(bounds=bounds, nets=resolved_nets) + resolved_options = options if options is not None else build_options(**overrides) + return PathFinder(build_context(evaluator, bounds=bounds, problem=resolved_problem, options=resolved_options)) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py new file mode 100644 index 0000000..8474606 --- /dev/null +++ b/inire/tests/test_api.py @@ -0,0 +1,108 @@ +from shapely.geometry import box + +from inire import ( + CongestionOptions, + DiagnosticsOptions, + LockedRoute, + NetSpec, + ObjectiveWeights, + Port, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, + route, +) +from inire.geometry.components import Straight + + +def test_route_problem_smoke() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + ) + + run = route(problem) + + assert set(run.results_by_net) == {"net1"} + assert run.results_by_net["net1"].is_valid + + +def test_route_problem_supports_configs_and_debug_data() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 10, 0), Port(90, 90, 0), width=2.0),), + static_obstacles=(box(40, 0, 60, 70),), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + node_limit=50000, + greedy_h_weight=1.2, + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(warm_start=None), + refinement=RefinementOptions(enabled=True), + diagnostics=DiagnosticsOptions(capture_expanded=True), + ) + + run = route(problem, options=options) + + assert run.results_by_net["net1"].reached_target + assert run.expanded_nodes + assert run.metrics.nodes_expanded > 0 + + +def test_route_problem_locked_routes_become_static_obstacles() -> None: + locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),) + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),), + locked_routes={"locked": LockedRoute.from_path(locked)}, + ) + options = RoutingOptions( + congestion=CongestionOptions(max_iterations=1, warm_start=None), + refinement=RefinementOptions(enabled=False), + ) + + run = route(problem, options=options) + result = run.results_by_net["crossing"] + + assert not result.is_valid + + +def test_locked_routes_enable_incremental_requests_without_sessions() -> None: + problem_a = RoutingProblem( + bounds=(0, -50, 100, 50), + nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), + ) + options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) + results_a = route(problem_a, options=options) + assert results_a.results_by_net["netA"].is_valid + + problem_b = RoutingProblem( + bounds=(0, -50, 100, 50), + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()}, + ) + results_b = route(problem_b, options=options) + + assert results_b.results_by_net["netB"].is_valid + + +def test_route_results_metrics_are_snapshots() -> None: + problem = RoutingProblem( + bounds=(0, 0, 100, 100), + nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), + ) + options = RoutingOptions() + run1 = route(problem, options=options) + first_metrics = run1.metrics + run2 = route(problem, options=options) + + assert first_metrics == run1.metrics + assert run1.metrics is not run2.metrics + assert first_metrics.nodes_expanded > 0 diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index e93e401..58597fd 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,34 +1,36 @@ import pytest from shapely.geometry import Polygon -import inire.router.astar as astar_module -from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.collision import CollisionEngine +from inire import RoutingResult +from inire.geometry.components import Bend90, Straight +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar -from inire.router.config import CostConfig +from inire.router._astar_types import AStarContext +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import RoutingResult +from inire.tests.support import build_context, build_options, build_problem from inire.utils.validation import validate_routing_result +BOUNDS = (0, -50, 150, 150) + @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, -50, 150, 150)) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) def test_astar_straight(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) + context = build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(50, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -38,14 +40,14 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[10.0]) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0]) start = Port(0, 0, 0) # 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -59,13 +61,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" @@ -74,13 +76,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) + context = build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(10.1, 0, 0) path = route_astar(start, target, net_width=2.0, context=context) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) assert target.x == 10 validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) @@ -89,7 +91,7 @@ def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: def test_validate_routing_result_checks_expected_start() -> None: path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) validation = validate_routing_result( result, @@ -105,7 +107,7 @@ def test_validate_routing_result_checks_expected_start() -> None: def test_validate_routing_result_uses_exact_component_geometry() -> None: bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0) - result = RoutingResult(net_id="test", path=[bend], is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=[bend], reached_target=True) obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) validation = validate_routing_result( @@ -119,18 +121,19 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None: assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" -def test_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0) +def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: + basic_evaluator.bend_penalty = 120.0 + basic_evaluator.sbend_penalty = 240.0 + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0]) - assert isinstance(basic_evaluator.config, CostConfig) - assert basic_evaluator.config is not context.config - assert basic_evaluator.config.bend_penalty == 120.0 - assert basic_evaluator.config.sbend_penalty == 240.0 - assert basic_evaluator.config.min_bend_radius == 5.0 + assert basic_evaluator.bend_penalty == 120.0 + assert basic_evaluator.sbend_penalty == 240.0 + assert context.options.search.bend_radii == (5.0,) + assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[10.0], bend_collision_type="arc") + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc") route_astar( Port(0, 0, 0), @@ -141,254 +144,92 @@ def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: C return_partial=True, ) - assert context.config.bend_collision_type == "arc" + assert context.options.search.bend_collision_type == "arc" -def test_expand_moves_only_shortens_consecutive_straights( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0) - prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) - current = astar_module.AStarNode( - prev_result.end_port, - g_cost=prev_result.length, - h_cost=0.0, - component_result=prev_result, - ) +def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None: + obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) + basic_evaluator.collision_engine.add_static_obstacle(obstacle) + basic_evaluator.danger_map.precompute([obstacle]) + context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2) + start = Port(0, 0, 0) + target = Port(60, 0, 0) - emitted: list[tuple[str, tuple]] = [] + partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True) + no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False) - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(80, 0, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"] - assert straight_lengths - assert all(length < prev_result.length for length in straight_lengths) + assert partial_path is not None + assert partial_path + assert partial_path[-1].end_port != target + assert no_partial_path is None -def test_expand_moves_does_not_chain_sbends( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0) - prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0) - current = astar_module.AStarNode( - prev_result.end_port, - g_cost=prev_result.length, - h_cost=0.0, - component_result=prev_result, - ) - - emitted: list[str] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append(args[9]) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(60, 10, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - assert "SB" not in emitted - assert emitted - - -def test_add_node_rejects_self_collision_against_ancestor( - basic_evaluator: CostEvaluator, -) -> None: - context = AStarContext(basic_evaluator) - metrics = astar_module.AStarMetrics() - target = Port(100, 0, 0) - - root = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) - ancestor = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) - ancestor_node = astar_module.AStarNode( - ancestor.end_port, - g_cost=ancestor.length, - h_cost=0.0, - parent=root, - component_result=ancestor, - ) - parent_result = Straight.generate(Port(30, 0, 0), 10.0, width=2.0, dilation=1.0) - parent_node = astar_module.AStarNode( - parent_result.end_port, - g_cost=ancestor.length + parent_result.length, - h_cost=0.0, - parent=ancestor_node, - component_result=parent_result, - ) - overlapping_move = Straight.generate(Port(5, 0, 0), 10.0, width=2.0, dilation=1.0) - - open_set: list[astar_module.AStarNode] = [] - astar_module.add_node( - parent_node, - overlapping_move, - target, - net_width=2.0, - net_id="test", - open_set=open_set, - closed_set={}, - context=context, - metrics=metrics, - congestion_cache={}, - move_type="S", - cache_key=("self_collision",), - self_collision_check=True, - ) - - assert not open_set - assert metrics.moves_added == 0 - - -def test_expand_moves_adds_sbend_aligned_straight_stop_points( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext( +def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: + context = build_context( basic_evaluator, + bounds=BOUNDS, bend_radii=[10.0], sbend_radii=[10.0], + sbend_offsets=[10.0], max_straight_length=150.0, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + target = Port(100, 10, 0) - emitted: list[tuple[str, tuple]] = [] + path = route_astar(start, target, net_width=2.0, context=context) - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(100, 10, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, + assert path is not None + assert path[-1].end_port == target + assert sum(1 for component in path if component.move_type == "sbend") == 1 + assert not any( + first.move_type == second.move_type == "sbend" + for first, second in zip(path, path[1:], strict=False) ) - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - sbend_span = astar_module._sbend_forward_span(10.0, 10.0) - assert sbend_span is not None - assert int(round(100.0 - sbend_span)) in straight_lengths - assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths - -def test_expand_moves_adds_exact_corner_visibility_stop_points( +@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"]) +def test_route_astar_supports_all_visibility_guidance_modes( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, + visibility_guidance: str, ) -> None: - context = AStarContext( - basic_evaluator, - bend_radii=[10.0], - max_straight_length=150.0, - visibility_guidance="exact_corner", - ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) - - monkeypatch.setattr( - astar_module.VisibilityManager, - "get_corner_visibility", - lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)], - ) - - emitted: list[tuple[str, tuple]] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(120, 20, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - assert 40 in straight_lengths - assert 75 in straight_lengths - - -def test_expand_moves_adds_tangent_corner_visibility_stop_points( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - class DummyCornerIndex: - def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]: - return [0, 1] - - context = AStarContext( + obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)]) + basic_evaluator.collision_engine.add_static_obstacle(obstacle) + basic_evaluator.danger_map.precompute([obstacle]) + context = build_context( basic_evaluator, + bounds=BOUNDS, bend_radii=[10.0], sbend_radii=[], max_straight_length=150.0, - visibility_guidance="tangent_corner", + visibility_guidance=visibility_guidance, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + target = Port(80, 50, 0) - monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None) - context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)] - context.visibility_manager.corner_index = DummyCornerIndex() - monkeypatch.setattr( - type(context.cost_evaluator.collision_engine), - "ray_cast", - lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist, + path = route_astar(start, target, net_width=2.0, context=context) + + assert path is not None + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + assert validation["connectivity_ok"] + + +def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: + context = AStarContext( + basic_evaluator, + build_problem(bounds=BOUNDS), + build_options( + min_straight_length=1.0, + max_straight_length=100.0, + ), + max_cache_size=2, ) + start = Port(0, 0, 0) + targets = [Port(length, 0, 0) for length in range(10, 70, 10)] - emitted: list[tuple[str, tuple]] = [] - - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(120, 20, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, - ) - - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - assert 40 in straight_lengths - assert 70 in straight_lengths + for target in targets: + path = route_astar(start, target, net_width=2.0, context=context) + assert path is not None + assert path[-1].end_port == target diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 3f17b1c..5866019 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,13 +1,13 @@ import pytest import numpy from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder, RoutingResult +from inire import RoutingResult +from inire.tests.support import build_pathfinder def test_clearance_thresholds(): """ @@ -16,12 +16,12 @@ def test_clearance_thresholds(): """ # Clearance = 2.0, Width = 2.0 # Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0 - ce = CollisionEngine(clearance=2.0) + ce = RoutingWorld(clearance=2.0) # Net 1: Centerline at y=0 p1 = Port(0, 0, 0) res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0) - ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry) + ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry) # Net 2: Parallel to Net 1 # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. @@ -47,12 +47,10 @@ def test_verify_all_nets_cases(): """ Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases. """ - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, 0, 100, 100)) danger_map.precompute([]) evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map) - context = AStarContext(cost_evaluator=evaluator) - pf = PathFinder(context, warm_start=None, max_iterations=1) # Case 1: Parallel paths exactly at clearance (Should be VALID) netlist_parallel_ok = { @@ -60,8 +58,15 @@ def test_verify_all_nets_cases(): "net2": (Port(0, 54, 0), Port(100, 54, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} - - results = pf.route_all(netlist_parallel_ok, net_widths) + + results = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_ok, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -74,7 +79,14 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = pf.route_all(netlist_parallel_fail, net_widths) + results_p = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_fail, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() # verify_all_nets should flag both as invalid because they cross-collide assert not results_p["net3"].is_valid assert not results_p["net4"].is_valid @@ -87,6 +99,13 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = pf.route_all(netlist_cross, net_widths) + results_c = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_cross, + net_widths=net_widths, + warm_start=None, + max_iterations=1, + ).route_all() assert not results_c["net5"].is_valid assert not results_c["net6"].is_valid diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index f83bb16..8a8c2b9 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,13 +1,13 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight def test_collision_detection() -> None: # Clearance = 2um - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # 10x10 um obstacle at (10,10) obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) @@ -33,7 +33,7 @@ def test_collision_detection() -> None: def test_safety_zone() -> None: # Use zero clearance for this test to verify the 2nm port safety zone # against the physical obstacle boundary. - engine = CollisionEngine(clearance=0.0) + engine = RoutingWorld(clearance=0.0) obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) engine.add_static_obstacle(obstacle) @@ -50,7 +50,7 @@ def test_safety_zone() -> None: def test_configurable_max_net_width() -> None: # Large max_net_width (10.0) -> large pre-dilation (6.0) - engine = CollisionEngine(clearance=2.0, max_net_width=10.0) + engine = RoutingWorld(clearance=2.0, max_net_width=10.0) obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) engine.add_static_obstacle(obstacle) @@ -65,7 +65,7 @@ def test_configurable_max_net_width() -> None: def test_ray_cast_width_clearance() -> None: # Clearance = 2.0um, Width = 2.0um. # Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um. - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Obstacle at x=10 to 20 obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) @@ -83,7 +83,7 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) engine.add_static_obstacle(obstacle) @@ -103,3 +103,54 @@ def test_check_move_static_clearance() -> None: start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) + + +def test_verify_path_report_preserves_long_net_id() -> None: + engine = RoutingWorld(clearance=2.0) + net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789" + path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + geoms = [poly for component in path for poly in component.collision_geometry] + dilated = [poly for component in path for poly in component.dilated_collision_geometry] + + engine.add_path(net_id, geoms, dilated_geometry=dilated) + report = engine.verify_path_report(net_id, path) + + assert report.dynamic_collision_count == 0 + + +def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None: + engine = RoutingWorld(clearance=2.0) + shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_" + net_a = f"{shared_prefix}A" + net_b = f"{shared_prefix}B" + path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + + engine.add_path( + net_a, + [poly for component in path_a for poly in component.collision_geometry], + dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry], + ) + engine.add_path( + net_b, + [poly for component in path_b for poly in component.collision_geometry], + dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry], + ) + + report = engine.verify_path_report(net_a, path_a) + + assert report.dynamic_collision_count == 1 + + +def test_remove_path_clears_dynamic_path() -> None: + engine = RoutingWorld(clearance=2.0) + path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] + geoms = [poly for component in path for poly in component.collision_geometry] + dilated = [poly for component in path for poly in component.dilated_collision_geometry] + + engine.add_path("netA", geoms, dilated_geometry=dilated) + assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"} + + engine.remove_path("netA") + assert list(engine.iter_dynamic_paths()) == [] + assert len(engine._static_obstacles.geometries) == 0 diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 0c64751..9ac3c98 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,11 +1,12 @@ import pytest +from dataclasses import FrozenInstanceError from shapely.affinity import rotate as shapely_rotate from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port def test_straight_generation() -> None: @@ -16,15 +17,16 @@ def test_straight_generation() -> None: assert result.end_port.x == 10.0 assert result.end_port.y == 0.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 1 + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 1 # Bounds of the polygon - minx, miny, maxx, maxy = result.geometry[0].bounds + minx, miny, maxx, maxy = result.collision_geometry[0].bounds assert minx == 0.0 assert maxx == 10.0 assert miny == -1.0 assert maxy == 1.0 + assert isinstance(result.collision_geometry, tuple) def test_bend90_generation() -> None: @@ -36,13 +38,13 @@ def test_bend90_generation() -> None: result_cw = Bend90.generate(start, radius, width, direction="CW") assert result_cw.end_port.x == 10.0 assert result_cw.end_port.y == -10.0 - assert result_cw.end_port.orientation == 270.0 + assert result_cw.end_port.r == 270.0 # CCW bend result_ccw = Bend90.generate(start, radius, width, direction="CCW") assert result_ccw.end_port.x == 10.0 assert result_ccw.end_port.y == 10.0 - assert result_ccw.end_port.orientation == 90.0 + assert result_ccw.end_port.r == 90.0 def test_sbend_generation() -> None: @@ -53,8 +55,8 @@ def test_sbend_generation() -> None: result = SBend.generate(start, offset, radius, width) assert result.end_port.y == 5.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 2 # Optimization: returns individual arcs + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs # Verify failure for large offset with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"): @@ -70,7 +72,7 @@ def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() -> result = SBend.generate(start, offset, radius, width) assert result.end_port.y == -5.0 - second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds + second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds assert second_arc_maxy <= width / 2.0 + 1e-6 assert second_arc_miny < -width / 2.0 @@ -84,21 +86,21 @@ def test_bend_collision_models() -> None: res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") # Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) - minx, miny, maxx, maxy = res_bbox.geometry[0].bounds + minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds assert minx <= 0.0 + 1e-6 assert maxx >= 10.0 - 1e-6 assert miny <= 0.0 + 1e-6 assert maxy >= 10.0 - 1e-6 # 2. Clipped BBox model - res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) + res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox") # Conservative 8-point approximation should still be tighter than the full bbox. - assert len(res_clipped.geometry[0].exterior.coords) - 1 == 8 - assert res_clipped.geometry[0].area < res_bbox.geometry[0].area + assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8 + assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area # It should also conservatively contain the true arc. res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc") - assert res_clipped.geometry[0].covers(res_arc.geometry[0]) + assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0]) def test_custom_bend_collision_polygon_uses_local_transform() -> None: @@ -119,20 +121,18 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None: expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False) expected = shapely_translate(expected, center_xy[0], center_xy[1]) - assert result.geometry[0].symmetric_difference(expected).area < 1e-6 - assert result.actual_geometry is not None - assert result.actual_geometry[0].symmetric_difference(expected).area < 1e-6 + assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6 + assert result.physical_geometry[0].symmetric_difference(expected).area < 1e-6 -def test_custom_bend_collision_polygon_becomes_actual_geometry() -> None: +def test_custom_bend_collision_polygon_keeps_collision_and_physical_geometry_aligned() -> None: custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0) - assert result.actual_geometry is not None - assert result.dilated_actual_geometry is not None - assert result.geometry[0].symmetric_difference(result.actual_geometry[0]).area < 1e-6 - assert result.dilated_geometry is not None - assert result.dilated_geometry[0].symmetric_difference(result.dilated_actual_geometry[0]).area < 1e-6 + assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area < 1e-6 + assert result.dilated_collision_geometry is not None + assert result.dilated_physical_geometry is not None + assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area < 1e-6 def test_sbend_collision_models() -> None: @@ -143,11 +143,11 @@ def test_sbend_collision_models() -> None: res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") # Geometry should be a list of individual bbox polygons for each arc - assert len(res_bbox.geometry) == 2 + assert len(res_bbox.collision_geometry) == 2 res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") - area_bbox = sum(p.area for p in res_bbox.geometry) - area_arc = sum(p.area for p in res_arc.geometry) + area_bbox = sum(p.area for p in res_bbox.collision_geometry) + area_arc = sum(p.area for p in res_arc.collision_geometry) assert area_bbox > area_arc @@ -161,14 +161,14 @@ def test_sbend_continuity() -> None: res = SBend.generate(start, offset, radius, width) # Target orientation should be same as start - assert abs(res.end_port.orientation - 90.0) < 1e-6 + assert abs(res.end_port.r - 90.0) < 1e-6 # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 # Geometry should be a list of valid polygons - assert len(res.geometry) == 2 - for p in res.geometry: + assert len(res.collision_geometry) == 2 + for p in res.collision_geometry: assert p.is_valid @@ -185,8 +185,8 @@ def test_arc_sagitta_precision() -> None: # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse * 2 @@ -205,12 +205,19 @@ def test_component_transform_invariance() -> None: angle = 90.0 # 1. Transform the generated geometry - p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) + p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle) # 2. Generate at transformed start - start_transformed = rotate_port(translate_port(start0, dx, dy), angle) + start_transformed = start0.translate(dx, dy).rotated(angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 - assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 + assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6 + + +def test_component_result_is_immutable_value_type() -> None: + result = Straight.generate(Port(0, 0, 0), 10.0, 2.0) + + with pytest.raises(FrozenInstanceError): + result.length = 42.0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 53f5400..c5b8491 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,25 +1,28 @@ import pytest from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.router._router import PathFinder +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire.tests.support import build_context, build_pathfinder + +BOUNDS = (0, -40, 100, 40) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Wider bounds to allow going around (y from -40 to 40) - danger_map = DangerMap(bounds=(0, -40, 100, 40)) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0]) + context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0]) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) @@ -32,22 +35,27 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: for res in path: # Check if the end port orientation is same as start # and it's not a single straight (which would have y=0) - if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1: + if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1: found_sbend = True break assert found_sbend def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[5.0, 10.0]) - # Increase base penalty to force detour immediately - pf = PathFinder(context, max_iterations=10, base_congestion_penalty=1000.0) - netlist = { "net1": (Port(0, 0, 0), Port(50, 0, 0)), "net2": (Port(0, 10, 0), Port(50, 10, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} + pf = build_pathfinder( + basic_evaluator, + bounds=BOUNDS, + netlist=netlist, + net_widths=net_widths, + bend_radii=[5.0, 10.0], + max_iterations=10, + base_penalty=1000.0, + ) # Force them into a narrow corridor that only fits ONE. obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall @@ -57,7 +65,7 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) basic_evaluator.danger_map.precompute([obs_top, obs_bottom]) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() assert len(results) == 2 assert results["net1"].reached_target diff --git a/inire/tests/test_cost.py b/inire/tests/test_cost.py index e3c5c26..e6f73c4 100644 --- a/inire/tests/test_cost.py +++ b/inire/tests/test_cost.py @@ -1,12 +1,12 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap def test_cost_calculation() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # 50x50 um area, 1um resolution danger_map = DangerMap(bounds=(0, 0, 50, 50)) danger_map.precompute([]) @@ -61,7 +61,23 @@ def test_danger_map_kd_tree_and_cache() -> None: # We can check if calling it again is fast or just verify it returns same result cost_near_2 = dm.get_cost(100.5, 100.5) assert cost_near_2 == cost_near + assert len(dm._cost_cache) == 2 # 4. Out of bounds assert dm.get_cost(-1, -1) >= 1e12 + +def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None: + obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) + dm_a = DangerMap((0, 0, 100, 100)) + dm_b = DangerMap((0, 0, 100, 100)) + + dm_a.precompute([obstacle]) + dm_b.precompute([]) + + dm_a.get_cost(15.0, 15.0) + assert len(dm_a._cost_cache) == 1 + assert len(dm_b._cost_cache) == 0 + + dm_a.precompute([]) + assert len(dm_a._cost_cache) == 0 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index ea547fd..7583d42 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -16,7 +16,7 @@ REGRESSION_FACTOR = 1.5 BASELINE_SECONDS = { "example_01_simple_route": 0.0035, "example_02_congestion_resolution": 0.2666, - "example_03_locked_paths": 0.2304, + "example_03_locked_routes": 0.2304, "example_04_sbends_and_radii": 1.8734, "example_05_orientation_stress": 0.5630, "example_06_bend_collision_models": 5.2382, @@ -28,7 +28,7 @@ BASELINE_SECONDS = { EXPECTED_OUTCOMES = { "example_01_simple_route": {"total_results": 1, "valid_results": 1, "reached_targets": 1}, "example_02_congestion_resolution": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, - "example_03_locked_paths": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, + "example_03_locked_routes": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_04_sbends_and_radii": {"total_results": 2, "valid_results": 2, "reached_targets": 2}, "example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, "example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3}, diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index 10bce06..4e7e7e3 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,9 +1,8 @@ from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder from inire.router.danger_map import DangerMap +from inire.tests.support import build_pathfinder def test_failed_net_visibility() -> None: """ @@ -12,7 +11,7 @@ def test_failed_net_visibility() -> None: for negotiated congestion. """ # 1. Setup - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Create a simple danger map (bounds 0-100) # We don't strictly need obstacles in it for this test. @@ -32,20 +31,23 @@ def test_failed_net_visibility() -> None: # With obstacle, direct jump fails. A* must search around. # Limit=10 should be enough to fail to find a path around. - context = AStarContext(evaluator, node_limit=10) - - # 3. Configure PathFinder - # max_iterations=1 because we only need to check the state after the first attempt. - pf = PathFinder(context, max_iterations=1, warm_start=None) - netlist = { "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} + pf = build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist, + net_widths=net_widths, + node_limit=10, + max_iterations=1, + warm_start=None, + ) # 4. Route print("\nStarting Route...") - results = pf.route_all(netlist, net_widths) + results = pf.route_all() res = results["net1"] print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index ee5490f..058b277 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -4,11 +4,12 @@ import pytest from hypothesis import given, settings, strategies as st from shapely.geometry import Point, Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap +from inire.tests.support import build_context @st.composite @@ -39,7 +40,7 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: net_width = 2.0 clearance = 2.0 - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) for obs in obstacles: engine.add_static_obstacle(obs) @@ -47,7 +48,7 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, node_limit=5000) # Lower limit for fuzzing stability + context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability # Check if start/target are inside obstacles (safety zone check) # The router should handle this gracefully (either route or return None) diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 326078b..05328ab 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,28 +1,57 @@ import pytest +from shapely.geometry import box -from inire.geometry.collision import CollisionEngine +from inire import NetSpec +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90, Straight from inire.geometry.primitives import Port -from inire.router.astar import AStarContext +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.outcomes import RoutingOutcome -from inire.router.pathfinder import PathFinder, RoutingResult -from inire.router.session import ( - create_routing_session_state, - prepare_routing_session_state, - run_routing_iteration, -) +from inire.tests.support import build_context + +DEFAULT_BOUNDS = (0, 0, 100, 100) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, 0, 100, 100)) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=DEFAULT_BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map) +def _request_nets( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + metrics=None, + **request_overrides: object, +) -> PathFinder: + return PathFinder( + build_context( + evaluator, + bounds=bounds, + nets=_request_nets(netlist, net_widths), + **request_overrides, + ), + metrics=metrics, + ) + + def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list: path = [] curr = start @@ -37,17 +66,22 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[ return path -def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) +def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]: + return [ + (component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple()) + for component in path + ] + +def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: netlist = { "net1": (Port(0, 0, 0), Port(50, 0, 0)), "net2": (Port(0, 10, 0), Port(50, 10, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} + pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() assert len(results) == 2 assert results["net1"].is_valid @@ -57,10 +91,6 @@ def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) - # Force a crossing by setting low iterations and low penalty - pf = PathFinder(context, max_iterations=1, base_congestion_penalty=1.0, warm_start=None) - # Net 1: (0, 25) -> (100, 25) Horizontal # Net 2: (50, 0) -> (50, 50) Vertical netlist = { @@ -68,8 +98,16 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: "net2": (Port(50, 0, 90), Port(50, 50, 90)), } net_widths = {"net1": 2.0, "net2": 2.0} + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths=net_widths, + max_iterations=1, + base_penalty=1.0, + warm_start=None, + ) - results = pf.route_all(netlist, net_widths) + results = pf.route_all() # Both should be invalid because they cross assert not results["net1"].is_valid @@ -78,231 +116,211 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: assert results["net2"].collisions > 0 -def test_prepare_routing_session_state_builds_warm_start_and_sorts_nets( +def test_route_all_respects_requested_net_order_in_callback( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, ) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) - calls: list[tuple[str, list[str]]] = [] - cleared: list[bool] = [] - - def fake_build( - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - order: str, - ) -> dict[str, list]: - calls.append((order, list(netlist.keys()))) - return {"warm": []} - - monkeypatch.setattr(PathFinder, "_build_greedy_warm_start_paths", lambda self, netlist, net_widths, order: fake_build(netlist, net_widths, order)) - monkeypatch.setattr(AStarContext, "clear_static_caches", lambda self: cleared.append(True)) + callback_orders: list[list[str]] = [] netlist = { "short": (Port(0, 0, 0), Port(10, 0, 0)), "long": (Port(0, 0, 0), Port(40, 10, 0)), "mid": (Port(0, 0, 0), Port(20, 0, 0)), } - state = create_routing_session_state( - pf, - netlist, - {net_id: 2.0 for net_id in netlist}, - store_expanded=False, - iteration_callback=None, - shuffle_nets=False, + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths={net_id: 2.0 for net_id in netlist}, + max_iterations=1, + warm_start=None, sort_nets="longest", - initial_paths=None, - seed=None, + enabled=False, + ) + pf.route_all( + iteration_callback=lambda iteration, results: callback_orders.append(list(results)), ) - prepare_routing_session_state(pf, state) - - assert calls == [("longest", ["short", "long", "mid"])] - assert cleared == [True] - assert state.initial_paths == {"warm": []} - assert state.all_net_ids == ["long", "mid", "short"] + assert callback_orders == [["long", "mid", "short"]] -def test_run_routing_iteration_updates_results_and_invokes_callback( +def test_route_all_invokes_iteration_callback_with_results( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, ) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) - callback_results: list[dict[str, RoutingResult]] = [] + callback_results: list[dict[str, object]] = [] + netlist = { + "net1": (Port(0, 0, 0), Port(10, 0, 0)), + "net2": (Port(0, 10, 0), Port(10, 10, 0)), + } + pf = _build_pathfinder( + basic_evaluator, + netlist=netlist, + net_widths={"net1": 2.0, "net2": 2.0}, + ) - def fake_route_once( - net_id: str, - start: Port, - target: Port, - width: float, - iteration: int, - initial_paths: dict[str, list] | None, - store_expanded: bool, - needs_self_collision_check: set[str], - ) -> tuple[RoutingResult, RoutingOutcome]: - _ = (start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check) - result = RoutingResult( - net_id, - [], - net_id == "net1", - int(net_id == "net2"), - reached_target=True, - outcome="completed" if net_id == "net1" else "colliding", + results = pf.route_all( + iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)), + ) + + assert len(callback_results) == 1 + assert set(callback_results[0]) == {"net1", "net2"} + assert callback_results[0]["net1"].is_valid + assert callback_results[0]["net2"].is_valid + assert results["net1"].reached_target + assert results["net2"].reached_target + + +def test_route_all_uses_complete_initial_paths_without_rerouting( + basic_evaluator: CostEvaluator, +) -> None: + start = Port(0, 0, 0) + target = Port(20, 20, 0) + initial_path = _build_manual_path( + start, + 2.0, + basic_evaluator.collision_engine.clearance, + [("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")], + ) + pf = _build_pathfinder( + basic_evaluator, + netlist={"net": (start, target)}, + net_widths={"net": 2.0}, + bend_radii=[5.0], + max_iterations=1, + warm_start=None, + initial_paths={"net": tuple(initial_path)}, + enabled=False, + ) + + result = pf.route_all()["net"] + + assert result.is_valid + assert result.reached_target + assert _path_signature(result.path) == _path_signature(initial_path) + + +def test_route_all_retries_partial_initial_paths_across_iterations( + basic_evaluator: CostEvaluator, +) -> None: + start = Port(0, 0, 0) + target = Port(10, 0, 0) + partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)] + pf = _build_pathfinder( + basic_evaluator, + netlist={"net": (start, target)}, + net_widths={"net": 2.0}, + max_iterations=2, + warm_start=None, + capture_expanded=True, + initial_paths={"net": tuple(partial_path)}, + enabled=False, + ) + iterations: list[int] = [] + + result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"] + + assert iterations == [0, 1] + assert result.is_valid + assert result.reached_target + assert result.outcome == "completed" + assert _path_signature(result.path) != _path_signature(partial_path) + assert pf.accumulated_expanded_nodes + + +def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None: + netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))} + widths = {"net": 2.0} + + def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]: + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=(-20, -20, 60, 60)) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map) + context = build_context( + evaluator, + bounds=(-20, -20, 60, 60), + nets=_request_nets(netlist, widths), + bend_radii=[10.0], + max_straight_length=50.0, + node_limit=50, + warm_start=None, + max_iterations=1, + enabled=False, ) - return result, result.outcome + return engine, context, PathFinder(context) - monkeypatch.setattr( - PathFinder, - "_route_net_once", - lambda self, net_id, start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check: fake_route_once( - net_id, - start, - target, - width, - iteration, - initial_paths, - store_expanded, - needs_self_collision_check, - ), - ) - state = create_routing_session_state( - pf, - {"net1": (Port(0, 0, 0), Port(10, 0, 0)), "net2": (Port(0, 10, 0), Port(10, 10, 0))}, - {"net1": 2.0, "net2": 2.0}, - store_expanded=True, - iteration_callback=lambda iteration, results: callback_results.append(dict(results)), - shuffle_nets=False, - sort_nets=None, - initial_paths={"seeded": []}, - seed=None, - ) + engine_auto, _context_auto, pf_auto = build_router() + assert pf_auto.route_all()["net"].is_valid + engine_auto.add_static_obstacle(box(4, 4, 8, 12)) + auto_result = pf_auto.route_all()["net"] - outcomes = run_routing_iteration(pf, state, iteration=0) + engine_manual, context_manual, pf_manual = build_router() + assert pf_manual.route_all()["net"].is_valid + engine_manual.add_static_obstacle(box(4, 4, 8, 12)) + context_manual.clear_static_caches() + manual_result = pf_manual.route_all()["net"] - assert outcomes == {"net1": "completed", "net2": "colliding"} - assert set(state.results) == {"net1", "net2"} - assert callback_results and set(callback_results[0]) == {"net1", "net2"} - assert state.results["net1"].is_valid - assert not state.results["net2"].is_valid - assert state.results["net2"].outcome == "colliding" - - -def test_run_routing_iteration_timeout_finalizes_tree( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) - finalized: list[bool] = [] - monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: finalized.append(True)) - - state = create_routing_session_state( - pf, - {"net1": (Port(0, 0, 0), Port(10, 0, 0))}, - {"net1": 2.0}, - store_expanded=False, - iteration_callback=None, - shuffle_nets=False, - sort_nets=None, - initial_paths={}, - seed=None, - ) - state.start_time = 0.0 - state.session_timeout = 0.0 - - result = run_routing_iteration(pf, state, iteration=0) - - assert result is None - assert finalized == [True] - - -def test_route_all_retries_partial_paths_across_iterations( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context, max_iterations=3, warm_start=None, refine_paths=False) - calls: list[int] = [] - - class FakeComponent: - def __init__(self, start_port: Port, end_port: Port) -> None: - self.start_port = start_port - self.end_port = end_port - - def fake_route_astar( - start: Port, - target: Port, - width: float, - *, - context: AStarContext, - metrics: object, - net_id: str, - bend_collision_type: str, - return_partial: bool, - store_expanded: bool, - skip_congestion: bool, - self_collision_check: bool, - node_limit: int, - ) -> list[FakeComponent]: - _ = ( - width, - context, - metrics, - net_id, - bend_collision_type, - return_partial, - store_expanded, - skip_congestion, - self_collision_check, - node_limit, - ) - calls.append(len(calls)) - if len(calls) == 1: - return [FakeComponent(start, Port(5, 0, 0))] - return [FakeComponent(start, target)] - - monkeypatch.setattr("inire.router.pathfinder.route_astar", fake_route_astar) - monkeypatch.setattr(type(pf.path_state), "install_path", lambda self, net_id, path: None) - monkeypatch.setattr(type(pf.path_state), "remove_path", lambda self, net_id: None) - monkeypatch.setattr( - type(pf.path_state), - "verify_path_report", - lambda self, net_id, path: basic_evaluator.collision_engine.verify_path_report(net_id, []), - ) - monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: None) - - results = pf.route_all({"net": (Port(0, 0, 0), Port(10, 0, 0))}, {"net": 2.0}) - - assert calls == [0, 1] - assert results["net"].reached_target - assert results["net"].is_valid - assert results["net"].outcome == "completed" + assert auto_result.reached_target == manual_result.reached_target + assert auto_result.collisions == manual_result.collisions + assert auto_result.outcome == manual_result.outcome + assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [ + (comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path + ] def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: bounds = (0, -50, 100, 50) - def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]: - engine = CollisionEngine(clearance=2.0) + def build_pathfinder( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + *, + refinement_enabled: bool, + ) -> tuple[RoutingWorld, PathFinder]: + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0]) - return engine, PathFinder(context, refine_paths=refine_paths) + return engine, _build_pathfinder( + evaluator, + netlist=netlist, + net_widths=net_widths, + bounds=bounds, + bend_radii=[10.0], + enabled=refinement_enabled, + ) - base_engine, base_pf = build_pathfinder(refine_paths=False) - base_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - base_engine.lock_net("netA") - base_result = base_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"] + net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} + width_a = {"netA": 2.0} + net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} + width_b = {"netB": 2.0} - refined_engine, refined_pf = build_pathfinder(refine_paths=True) - refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - refined_engine.lock_net("netA") - refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"] + base_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False) + base_results = base_pf.route_all() + for polygon in base_results["netA"].as_locked_route().geometry: + base_engine.add_static_obstacle(polygon) + base_result = _build_pathfinder( + base_pf.cost_evaluator, + netlist=net_b, + net_widths=width_b, + bounds=bounds, + bend_radii=[10.0], + enabled=False, + ).route_all()["netB"] - base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") - refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") + refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True) + refined_results = refined_pf.route_all() + for polygon in refined_results["netA"].as_locked_route().geometry: + refined_engine.add_static_obstacle(polygon) + refined_result = _build_pathfinder( + refined_pf.cost_evaluator, + netlist=net_b, + net_widths=width_b, + bounds=bounds, + bend_radii=[10.0], + enabled=True, + ).route_all()["netB"] + + base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90") + refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90") assert base_result.is_valid assert refined_result.is_valid @@ -319,22 +337,30 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: } net_widths = {net_id: 2.0 for net_id in netlist} - def build_pathfinder(*, refine_paths: bool) -> PathFinder: - engine = CollisionEngine(clearance=2.0) + def build_pathfinder(*, refinement_enabled: bool) -> PathFinder: + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0]) - return PathFinder(context, base_congestion_penalty=1000.0, refine_paths=refine_paths) + return _build_pathfinder( + evaluator, + netlist=netlist, + net_widths=net_widths, + bounds=bounds, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=refinement_enabled, + ) - base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths) - refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths) + base_results = build_pathfinder(refinement_enabled=False).route_all() + refined_results = build_pathfinder(refinement_enabled=True).route_all() for net_id in ("vertical_up", "vertical_down"): base_result = base_results[net_id] refined_result = refined_results[net_id] - base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") - refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") + base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90") + refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90") assert base_result.is_valid assert refined_result.is_valid @@ -342,12 +368,18 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: def test_refine_path_handles_same_orientation_lateral_offset() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(-20, -20, 120, 120)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[5.0, 10.0]) - pf = PathFinder(context, refine_paths=True) + pf = _build_pathfinder( + evaluator, + netlist={"net": (Port(0, 0, 0), Port(60, 15, 0))}, + net_widths={"net": 2.0}, + bounds=(-20, -20, 120, 120), + bend_radii=[5.0, 10.0], + enabled=True, + ) start = Port(0, 0, 0) width = 2.0 @@ -374,19 +406,25 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None: refined = pf._refine_path("net", start, target, width, path) assert target == Port(60, 15, 0) - assert sum(1 for comp in path if comp.move_type == "Bend90") == 6 - assert sum(1 for comp in refined if comp.move_type == "Bend90") == 4 + assert sum(1 for comp in path if comp.move_type == "bend90") == 6 + assert sum(1 for comp in refined if comp.move_type == "bend90") == 4 assert refined[-1].end_port == target assert pf._path_cost(refined) < pf._path_cost(path) def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(-20, -20, 120, 120)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[5.0, 10.0]) - pf = PathFinder(context, refine_paths=True) + pf = _build_pathfinder( + evaluator, + netlist={"net": (Port(0, 0, 0), Port(65, 30, 90))}, + net_widths={"net": 2.0}, + bounds=(-20, -20, 120, 120), + bend_radii=[5.0, 10.0], + enabled=True, + ) start = Port(0, 0, 0) width = 2.0 @@ -415,7 +453,7 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> refined = pf._refine_path("net", start, target, width, path) assert target == Port(65, 30, 90) - assert sum(1 for comp in path if comp.move_type == "Bend90") == 7 - assert sum(1 for comp in refined if comp.move_type == "Bend90") == 5 + assert sum(1 for comp in path if comp.move_type == "bend90") == 7 + assert sum(1 for comp in refined if comp.move_type == "bend90") == 5 assert refined[-1].end_port == target assert pf._path_cost(refined) < pf._path_cost(path) diff --git a/inire/tests/test_primitives.py b/inire/tests/test_primitives.py index 6e62d45..7e2bd5b 100644 --- a/inire/tests/test_primitives.py +++ b/inire/tests/test_primitives.py @@ -1,8 +1,10 @@ +from dataclasses import FrozenInstanceError from typing import Any from hypothesis import given, strategies as st +import pytest -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port @st.composite @@ -24,11 +26,11 @@ def test_port_transform_invariants(p: Port) -> None: # Rotating 90 degrees 4 times should return to same orientation p_rot = p for _ in range(4): - p_rot = rotate_port(p_rot, 90) + p_rot = p_rot.rotated(90) assert abs(p_rot.x - p.x) < 1e-6 assert abs(p_rot.y - p.y) < 1e-6 - assert (p_rot.orientation % 360) == (p.orientation % 360) + assert (p_rot.r % 360) == (p.r % 360) @given( @@ -37,14 +39,21 @@ def test_port_transform_invariants(p: Port) -> None: dy=st.floats(min_value=-1000, max_value=1000), ) def test_translate_snapping(p: Port, dx: float, dy: float) -> None: - p_trans = translate_port(p, dx, dy) + p_trans = p.translate(dx, dy) assert isinstance(p_trans.x, int) assert isinstance(p_trans.y, int) def test_orientation_normalization() -> None: p = Port(0, 0, 360) - assert p.orientation == 0 + assert p.r == 0 p2 = Port(0, 0, -90) - assert p2.orientation == 270 + assert p2.r == 270 + + +def test_port_is_immutable_value_type() -> None: + p = Port(1, 2, 90) + + with pytest.raises(FrozenInstanceError): + p.x = 3 diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 25c3740..3a735b1 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,10 +1,9 @@ -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90 from inire.geometry.primitives import Port -from inire.router.astar import AStarContext from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire.tests.support import build_pathfinder def test_arc_resolution_sagitta() -> None: @@ -18,34 +17,45 @@ def test_arc_resolution_sagitta() -> None: # Check number of points in the polygon exterior # (num_segments + 1) * 2 points usually - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse -def test_locked_paths() -> None: - engine = CollisionEngine(clearance=2.0) +def test_locked_routes() -> None: + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, -50, 100, 50)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[5.0, 10.0]) - pf = PathFinder(context) # 1. Route Net A netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) + results_a = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_a, + net_widths={"netA": 2.0}, + bend_radii=[5.0, 10.0], + ).route_all() assert results_a["netA"].is_valid - # 2. Lock Net A - engine.lock_net("netA") + # 2. Treat Net A as locked geometry in the next run. + for polygon in results_a["netA"].as_locked_route().geometry: + engine.add_static_obstacle(polygon) # 3. Route Net B through the same space. It should detour or fail. # We'll place Net B's start/target such that it MUST cross Net A's physical path. netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} # Route Net B - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_b, + net_widths={"netB": 2.0}, + bend_radii=[5.0, 10.0], + ).route_all() # Net B should be is_valid (it detoured) or at least not have collisions # with Net A in the dynamic set (because netA is now static). @@ -55,8 +65,8 @@ def test_locked_paths() -> None: assert results_b["netB"].is_valid # Verify geometry doesn't intersect locked netA (physical check) - poly_a = [p.geometry[0] for p in results_a["netA"].path] - poly_b = [p.geometry[0] for p in results_b["netB"].path] + poly_a = [p.physical_geometry[0] for p in results_a["netA"].path] + poly_b = [p.physical_geometry[0] for p in results_b["netB"].path] for pa in poly_a: for pb in poly_b: diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index ea6dee8..fbf6ba5 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,17 +1,20 @@ import unittest + from inire.geometry.primitives import Port -from inire.router.astar import route_astar, AStarContext +from inire.router._search import route_astar from inire.router.cost import CostEvaluator -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld +from inire.tests.support import build_context class TestIntegerPorts(unittest.TestCase): def setUp(self): - self.ce = CollisionEngine(clearance=2.0) + self.ce = RoutingWorld(clearance=2.0) self.cost = CostEvaluator(self.ce) + self.bounds = (0, 0, 100, 100) def test_route_reaches_integer_target(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0, 0, 0) target = Port(12, 0, 0) @@ -24,7 +27,7 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) @@ -36,7 +39,7 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = AStarContext(self.cost) + context = build_context(self.cost, bounds=self.bounds) start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) diff --git a/inire/tests/test_visibility.py b/inire/tests/test_visibility.py index e175d8c..0e2100f 100644 --- a/inire/tests/test_visibility.py +++ b/inire/tests/test_visibility.py @@ -1,12 +1,12 @@ from shapely.geometry import box -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.router.visibility import VisibilityManager def test_point_visibility_cache_respects_max_distance() -> None: - engine = CollisionEngine(clearance=0.0) + engine = RoutingWorld(clearance=0.0) engine.add_static_obstacle(box(10, 20, 20, 30)) engine.add_static_obstacle(box(100, 20, 110, 30)) visibility = VisibilityManager(engine) diff --git a/inire/tests/test_visualization.py b/inire/tests/test_visualization.py index c1a4735..eb139ca 100644 --- a/inire/tests/test_visualization.py +++ b/inire/tests/test_visualization.py @@ -4,13 +4,13 @@ matplotlib.use("Agg") from inire.geometry.components import Bend90 from inire.geometry.primitives import Port -from inire.router.pathfinder import RoutingResult +from inire import RoutingResult from inire.utils.visualization import plot_routing_results def test_plot_routing_results_respects_show_actual() -> None: bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox") - result = RoutingResult("net", [bend], True, 0, reached_target=True) + result = RoutingResult("net", [bend], reached_target=True) fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True) fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False) diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 0894566..9c22dc4 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,12 +3,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any import numpy -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld if TYPE_CHECKING: from shapely.geometry import Polygon from inire.geometry.primitives import Port - from inire.router.pathfinder import RoutingResult + from inire.router.results import RoutingResult def validate_routing_result( @@ -38,21 +38,21 @@ def validate_routing_result( if expected_start: first_port = result.path[0].start_port - dist_to_start = numpy.sqrt(((first_port[:2] - expected_start[:2])**2).sum()) + dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2) if dist_to_start > 0.005: connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") - if abs(first_port[2] - expected_start[2]) > 0.1: - connectivity_errors.append(f"Initial port orientation mismatch: {first_port[2]} vs {expected_start[2]}") + if abs(first_port.r - expected_start.r) > 0.1: + connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") if expected_end: last_port = result.path[-1].end_port - dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum()) + dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2) if dist_to_end > 0.005: connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") - if abs(last_port[2] - expected_end[2]) > 0.1: - connectivity_errors.append(f"Final port orientation mismatch: {last_port[2]} vs {expected_end[2]}") + if abs(last_port.r - expected_end.r) > 0.1: + connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") - engine = CollisionEngine(clearance=clearance) + engine = RoutingWorld(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) report = engine.verify_path_report("validation", result.path) diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 522fd7f..044208a 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap - from inire.router.pathfinder import RoutingResult + from inire.router.results import RoutingResult def plot_routing_results( @@ -51,8 +51,7 @@ def plot_routing_results( label_added = False for comp in res.path: # 1. Plot Collision Geometry (Translucent fill) - # This is the geometry used during search (e.g. proxy or arc) - for poly in comp.geometry: + for poly in comp.collision_geometry: if isinstance(poly, MultiPolygon): geoms = list(poly.geoms) else: @@ -67,13 +66,7 @@ def plot_routing_results( x, y = g.xy ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2) - # 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication) - # Use comp.actual_geometry if it exists (should be the arc) - actual_geoms_to_plot = ( - comp.actual_geometry - if show_actual and comp.actual_geometry is not None - else comp.geometry - ) + actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry for poly in actual_geoms_to_plot: if isinstance(poly, MultiPolygon): @@ -91,21 +84,20 @@ def plot_routing_results( # 3. Plot subtle port orientation arrow p = comp.end_port - rad = numpy.radians(p.orientation) + rad = numpy.radians(p.r) ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4) if not res.path and not res.is_valid: - # Best-effort display: If the path is empty but failed, it might be unroutable. - # We don't have a partial path in RoutingResult currently. + # Empty failed paths are typically unroutable. pass # 4. Plot main arrows for netlist ports if netlist: for _net_id, (start_p, target_p) in netlist.items(): for p in [start_p, target_p]: - rad = numpy.radians(p[2]) - ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black", + rad = numpy.radians(p.r) + ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=25, width=0.004, pivot="tail", zorder=6) ax.set_xlim(bounds[0], bounds[2]) diff --git a/pyproject.toml b/pyproject.toml index afbec0f..efbd939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ lint.ignore = [ "C408", # dict(x=y) instead of {'x': y} "PLR09", # Too many xxx "PLR2004", # magic number - #"PLC0414", # import x as x + "PLC0414", # import x as x "TRY003", # Long exception message ]