diff --git a/DOCS.md b/DOCS.md index d458bda..d83b018 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,175 +1,106 @@ # Inire Configuration & API Documentation -This document describes the current public API for `inire`. +This document describes the user-tunable parameters for the `inire` auto-router. -## 1. Primary API +## 1. AStarContext Parameters -### `RoutingProblem` +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` describes the physical routing problem: +| Parameter | Type | Default | Description | +| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | +| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | +| `snap_size` | `float` | 5.0 | Grid size (µm) for expansion moves. Larger values speed up search. | +| `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. | +| `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. | +| `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). | +| `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"`. | +| `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"`. | -- `bounds` -- `nets` -- `static_obstacles` -- `initial_paths` -- `clearance` -- `safety_zone_radius` +## 2. AStarMetrics -### `RoutingOptions` +The `AStarMetrics` object collects performance data during the search. -`RoutingOptions` groups all expert controls for the routing engine: +| 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. | -- `search` -- `objective` -- `congestion` -- `refinement` -- `diagnostics` +--- -Route a problem with: +## 3. CostEvaluator Parameters -```python -run = route(problem, options=options) -``` +The `CostEvaluator` defines the "goodness" of a path. -If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. +| 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. | -The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. +--- -Stable example: +## 3. PathFinder Parameters -```python -from inire import route, RoutingOptions, RoutingProblem -``` +The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. -Unstable example: +| 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.| -```python -from inire.router._router import PathFinder -``` +--- -### Incremental routing with locked geometry +## 4. CollisionEngine Parameters -For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem: +| 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. | -```python -run_a = route(problem_a) -problem_b = RoutingProblem( - bounds=problem_a.bounds, - nets=(...), - static_obstacles=run_a.results_by_net["netA"].locked_geometry, -) -run_b = route(problem_b) -``` +--- -`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. +## 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. -### Initial paths with `PathSeed` +--- -Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are materialized with the current width, clearance, and bend collision settings for the run, and partial seeds are retried by normal routing in later iterations. +## 5. Best Practices & Tuning Advice -## 2. Search 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.search` is a `SearchOptions` 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. Ensure `straight_lengths` includes larger values like `25.0` or `100.0`. +3. Decrease `greedy_h_weight` closer to `1.0`. -| 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. | -| `bend_clip_margin` | `None` | Optional legacy shrink margin for `"clipped_bbox"`. Leave `None` for the default 8-point proxy. | -| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | +### 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. -## 3. Objective Weights +### 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.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. | - -## 4. Congestion Options - -`RoutingOptions.congestion` is a `CongestionOptions` object. - -| 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. | -| `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. | -| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. | -| `shuffle_nets` | `False` | Shuffle routing order between iterations. | -| `seed` | `None` | RNG seed for shuffled routing order. | - -## 5. Refinement Options - -`RoutingOptions.refinement` is a `RefinementOptions` object. - -| 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 semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`. - -## 9. Tuning Notes - -### 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`. +### 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. diff --git a/README.md b/README.md index a66f699..b300c77 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 uses a Hybrid State-Lattice A* search combined with negotiated congestion 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 utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) 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 Routes**: Supports treating prior routed nets as fixed obstacles in later runs. +* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions. ## Installation @@ -26,32 +26,42 @@ pip install numpy scipy shapely rtree matplotlib ## Quick Start ```python -from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 -problem = RoutingProblem( - bounds=(0, 0, 1000, 1000), - nets=( - NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0), - ), +# 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 ) -options = RoutingOptions( - search=SearchOptions( - bend_radii=(50.0, 100.0), - greedy_h_weight=1.2, - ), - objective=ObjectiveWeights( - bend_penalty=10.0, - ), +context = AStarContext( + cost_evaluator=evaluator, + bend_penalty=10.0 ) +pf = PathFinder(context) -run = route(problem, options=options) +# 3. Define Netlist +netlist = { + "net1": (Port(0, 0, 0), Port(100, 50, 0)), +} -if run.results_by_net["net1"].is_valid: +# 4. Route +results = pf.route_all(netlist, {"net1": 2.0}) + +if results["net1"].is_valid: print("Successfully routed net1!") ``` -For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`. - ## Usage Examples For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**. @@ -61,30 +71,10 @@ Check the `examples/` directory for ready-to-run scripts. To run an example: python3 examples/01_simple_route.py ``` -## Testing - -Run the default correctness suite with: - -```bash -python3 -m pytest -``` - -Runtime regression checks for the example scenarios are opt-in and require: - -```bash -INIRE_RUN_PERFORMANCE=1 python3 -m pytest -q inire/tests/test_example_performance.py -``` - ## Documentation Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. -## API Stability - -The stable API lives at the package root and is centered on `route(problem, options=...)`. - -Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice. - ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: @@ -92,11 +82,11 @@ Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router. 2. **90° Bends**: Fixed-radius PDK cells. 3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$). -For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. +For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. ## Configuration -`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference. +`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. ## License diff --git a/examples/01_simple_route.py b/examples/01_simple_route.py index 2f43065..96fc4f9 100644 --- a/examples/01_simple_route.py +++ b/examples/01_simple_route.py @@ -1,29 +1,54 @@ -from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 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)), } - problem = RoutingProblem( - bounds=bounds, - nets=(NetSpec("net1", *netlist["net1"], width=2.0),), - ) - options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) + net_widths = {"net1": 2.0} - run = route(problem, options=options) - result = run.results_by_net["net1"] - if result.is_valid: + # 4. Route + results = pf.route_all(netlist, net_widths) + + # 5. Check Results + res = results["net1"] + if res.is_valid: print("Success! Route found.") - print(f"Path collisions: {result.collisions}") + print(f"Path collisions: {res.collisions}") else: print("Failed to find route.") - fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) + # 6. Visualize + # plot_routing_results takes a dict of RoutingResult objects + fig, ax = plot_routing_results(results, [], bounds) 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 9d003bc..ffe8343 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -1,41 +1,49 @@ -from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 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)), } - 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), - ) + net_widths = {nid: 2.0 for nid in netlist} - run = route(problem, options=options) - all_valid = all(result.is_valid for result in run.results_by_net.values()) + # 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()) if all_valid: print("Success! Congestion resolved for all nets.") else: print("Failed to resolve congestion for some nets.") - fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) + # 5. Visualize + fig, ax = plot_routing_results(results, [], 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.png b/examples/03_locked_paths.png index 687dad6..d767df9 100644 Binary files a/examples/03_locked_paths.png and b/examples/03_locked_paths.png differ diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index ed309a8..642c6f3 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -1,37 +1,42 @@ -from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 def main() -> None: print("Running Example 03: Locked Paths...") + # 1. Setup Environment bounds = (0, -50, 100, 50) - options = RoutingOptions( - search=SearchOptions(bend_radii=(10.0,)), - objective=ObjectiveWeights( - bend_penalty=250.0, - sbend_penalty=500.0, - ), - ) + 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 print("Routing initial net...") - 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 + 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.") + # 3. Route Net B (forced to detour) print("Routing detour net around locked path...") - results_b = route( - RoutingProblem( - bounds=bounds, - nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), - static_obstacles=results_a["netA"].locked_geometry, - ), - options=options, - ).results_by_net + netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} + results_b = pf.route_all(netlist_b, {"netB": 2.0}) + # 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 42eac9c..a88159a 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,38 +1,60 @@ -from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 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)), } - 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, - ), - ) + net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} - 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}") + # 4. Route + results = pf.route_all(netlist, net_widths) - fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) + # 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.savefig("examples/04_sbends_and_radii.png") print("Saved plot to examples/04_sbends_and_radii.png") diff --git a/examples/05_orientation_stress.png b/examples/05_orientation_stress.png index b750c6a..94bab94 100644 Binary files a/examples/05_orientation_stress.png and b/examples/05_orientation_stress.png differ diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index eab3c0e..4e434c8 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -1,32 +1,46 @@ -from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 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)), } - 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), - ) + net_widths = {nid: 2.0 for nid in netlist} + # 3. Route print("Routing complex orientation nets...") - 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}") + results = pf.route_all(netlist, net_widths) - fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) + # 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.savefig("examples/05_orientation_stress.png") print("Saved plot to examples/05_orientation_stress.png") diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index fa2c49f..4036d0d 100644 Binary files a/examples/06_bend_collision_models.png and b/examples/06_bend_collision_models.png differ diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index 8c3c06a..324743b 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -1,70 +1,65 @@ from shapely.geometry import Polygon -from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route +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 -def _route_scenario( - bounds: tuple[float, float, float, float], - obstacles: list[Polygon], - bend_collision_type: str, - netlist: dict[str, tuple[Port, Port]], - widths: dict[str, float], - *, - bend_clip_margin: float | None = None, -) -> 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), - ) - options = RoutingOptions( - search=SearchOptions( - bend_radii=(10.0,), - bend_collision_type=bend_collision_type, - bend_clip_margin=bend_clip_margin, - ), - 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) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + + # 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)]) obstacles = [obs_arc, obs_bbox, obs_clipped] + for obs in obstacles: + engine.add_static_obstacle(obs) + danger_map.precompute(obstacles) + + # We'll run three separate routers since collision_type is a router-level config + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + + # Scenario 1: Standard 'arc' model (High fidelity) + context_arc = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc") netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} + + # Scenario 2: 'bbox' model (Conservative axis-aligned box) + context_bbox = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox") netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} + + # Scenario 3: 'clipped_bbox' model (Balanced) + context_clipped = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0) netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} + # 2. Route each scenario print("Routing Scenario 1 (Arc)...") - res_arc = _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_arc = PathFinder(context_arc, use_tiered_strategy=False).route_all(netlist_arc, {"arc_model": 2.0}) + print("Routing Scenario 2 (BBox)...") + res_bbox = PathFinder(context_bbox, use_tiered_strategy=False).route_all(netlist_bbox, {"bbox_model": 2.0}) + + print("Routing Scenario 3 (Clipped BBox)...") + res_clipped = PathFinder(context_clipped, use_tiered_strategy=False).route_all(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} - fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + # 4. Visualize + 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 92098fb..ef92ea2 100644 --- a/examples/07_large_scale_routing.py +++ b/examples/07_large_scale_routing.py @@ -1,120 +1,108 @@ +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 ( - NetSpec, - Port, - RoutingProblem, - RoutingResult, -) -from inire.router._stack import build_routing_stack -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) - 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)) + 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)) - 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, - ) - from inire import CongestionOptions, DiagnosticsOptions, ObjectiveWeights, RoutingOptions, SearchOptions + net_widths = {nid: 2.0 for nid in netlist} - options = RoutingOptions( - search=SearchOptions( - node_limit=2_000_000, - bend_radii=(50.0,), - sbend_radii=(50.0,), - greedy_h_weight=1.5, - bend_clip_margin=10.0, - ), - objective=ObjectiveWeights( - unit_length_cost=0.1, - bend_penalty=100.0, - sbend_penalty=400.0, - ), - congestion=CongestionOptions( - max_iterations=15, - base_penalty=100.0, - multiplier=1.4, - net_order="shortest", - shuffle_nets=True, - seed=42, - ), - diagnostics=DiagnosticsOptions(capture_expanded=True), - ) - stack = build_routing_stack(problem, options) - evaluator = stack.evaluator - finder = stack.finder - metrics = finder.metrics + # 3. Route + print(f"Routing {len(netlist)} nets through 200um bottleneck...") - iteration_stats: list[dict[str, int]] = [] + iteration_stats = [] - 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()) + 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 {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") - new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) + + 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": iteration, - "Success": successes, - "Congestion": total_collisions, - "Nodes": total_nodes, - } - ) + + iteration_stats.append({ + 'Iteration': idx, + 'Success': successes, + 'Congestion': total_collisions, + 'Nodes': total_nodes + }) metrics.reset_per_route() - print(f"Routing {len(netlist)} nets through 200um bottleneck...") - start_time = time.perf_counter() - results = finder.route_all(iteration_callback=iteration_callback) - end_time = time.perf_counter() + 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 {end_time - start_time:.4f}s") + print(f"Routing took {t1-t0:.4f}s") + + # 4. Check Results print("\n--- Iteration Summary ---") print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}") - print("-" * 43) - for stats in iteration_stats: - print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8} | {stats['Nodes']:<10}") + print("-" * 40) + for s in iteration_stats: + print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}") - success_count = sum(1 for result in results.values() if result.is_valid) + success_count = sum(1 for res in results.values() if res.is_valid) print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.") - for net_id, result in results.items(): - if not result.is_valid: - print(f" FAILED: {net_id}, collisions={result.collisions}") - else: - print(f" {net_id}: SUCCESS") - fig, ax = plot_routing_results(results, list(obstacles), bounds, netlist=netlist) - plot_expanded_nodes(list(finder.accumulated_expanded_nodes), ax=ax) + for nid, res in results.items(): + if not res.is_valid: + print(f" FAILED: {nid}, collisions={res.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) + 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.png b/examples/08_custom_bend_geometry.png index 48c2e5c..72560e3 100644 Binary files a/examples/08_custom_bend_geometry.png and b/examples/08_custom_bend_geometry.png differ diff --git a/examples/08_custom_bend_geometry.py b/examples/08_custom_bend_geometry.py index 25e715b..81331be 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,76 +1,54 @@ from shapely.geometry import Polygon -from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext, AStarMetrics -from inire.router._router import PathFinder +from inire.router.astar import AStarContext, AStarMetrics, route_astar 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 main() -> None: print("Running Example 08: Custom Bend Geometry...") + # 1. Setup Environment bounds = (0, 0, 150, 150) - engine = RoutingWorld(clearance=2.0) + engine = CollisionEngine(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[]) metrics = AStarMetrics() - start = Port(20, 20, 0) - target = Port(100, 100, 90) + pf = PathFinder(context, metrics) + # 2. Define Netlist + netlist = { + "custom_bend": (Port(20, 20, 0), Port(100, 100, 90)), + } + net_widths = {"custom_bend": 2.0} + + # 3. Route with standard arc first print("Routing with standard arc...") - results_std = PathFinder( - AStarContext( - evaluator, - RoutingProblem( - bounds=bounds, - nets=(NetSpec("custom_bend", start, target, width=2.0),), - ), - RoutingOptions( - search=SearchOptions(bend_radii=(10.0,), sbend_radii=()), - congestion=CongestionOptions(max_iterations=1), - ), - ), - metrics=metrics, - ).route_all() + results_std = pf.route_all(netlist, net_widths) + # 4. Define a custom 'trapezoid' bend model + # (Just for demonstration - we override the collision model during search) + # Define a custom centered 20x20 box custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) print("Routing with custom collision model...") - results_custom = PathFinder( - AStarContext( - evaluator, - RoutingProblem( - bounds=bounds, - nets=(NetSpec("custom_model", start, target, width=2.0),), - ), - RoutingOptions( - search=SearchOptions( - bend_radii=(10.0,), - bend_collision_type=custom_poly, - sbend_radii=(), - ), - congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False), - ), - ), - metrics=AStarMetrics(), - use_tiered_strategy=False, - ).route_all() - - all_results = {**results_std, **results_custom} - fig, _ax = plot_routing_results( - all_results, - [], - bounds, - netlist={ - "custom_bend": (start, target), - "custom_model": (start, target), - }, + # Override bend_collision_type with a literal Polygon + context_custom = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=custom_poly, sbend_radii=[]) + metrics_custom = AStarMetrics() + results_custom = PathFinder(context_custom, metrics_custom, use_tiered_strategy=False).route_all( + {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0} ) + + # 5. Visualize + all_results = {**results_std, **results_custom} + fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist) 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 1aeb152..659a16c 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -1,46 +1,58 @@ -from shapely.geometry import box - -from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +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 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), ] - 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_enabled=False, max_iterations=1), - ) + for obs in obstacles: + engine.add_static_obstacle(obs) + 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)...") - 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.") + 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.") else: print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.") - fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}) + # 5. Visualize + fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) 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/examples/README.md b/examples/README.md index cfea579..208f127 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,8 +20,6 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting * **BBox**: Simple axis-aligned bounding box (Fastest search). * **Clipped BBox**: A balanced model that clips the corners of the AABB to better fit the arc (Optimal performance). -Example 08 also demonstrates a custom polygonal bend geometry. It uses a centered `20x20` box as a custom bend collision model. - ![Custom Bend Geometry](08_custom_bend_geometry.png) ## 3. Unroutable Nets & Best-Effort Display diff --git a/inire/__init__.py b/inire/__init__.py index f6ee2d5..5fb5ffb 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,59 +1,8 @@ """ inire Wave-router """ -from collections.abc import Callable - from .geometry.primitives import Port as Port # noqa: PLC0414 -from .model import ( - CongestionOptions as CongestionOptions, - DiagnosticsOptions as DiagnosticsOptions, - NetSpec as NetSpec, - ObjectiveWeights as ObjectiveWeights, - RefinementOptions as RefinementOptions, - RoutingOptions as RoutingOptions, - RoutingProblem as RoutingProblem, - SearchOptions as SearchOptions, -) # noqa: PLC0414 -from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414 -from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414 +from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 __author__ = 'Jan Petykiewicz' __version__ = '0.1' - - -def route( - problem: RoutingProblem, - *, - options: RoutingOptions | None = None, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, -) -> RoutingRunResult: - from .router._stack import build_routing_stack - - resolved_options = RoutingOptions() if options is None else options - stack = build_routing_stack(problem, resolved_options) - finder = stack.finder - results = finder.route_all(iteration_callback=iteration_callback) - return RoutingRunResult( - results_by_net=results, - metrics=finder.metrics.snapshot(), - expanded_nodes=tuple(finder.accumulated_expanded_nodes), - ) - -__all__ = [ - "Bend90Seed", - "CongestionOptions", - "DiagnosticsOptions", - "NetSpec", - "ObjectiveWeights", - "PathSeed", - "Port", - "RefinementOptions", - "RoutingOptions", - "RoutingProblem", - "RoutingResult", - "RoutingRunResult", - "SBendSeed", - "SearchOptions", - "StraightSeed", - "route", -] diff --git a/inire/constants.py b/inire/constants.py index bfbebe6..cdc2f62 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -2,5 +2,11 @@ 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. +DEFAULT_SEARCH_GRID_SNAP_UM = 5.0 + +# Tolerances TOLERANCE_LINEAR = 1e-6 TOLERANCE_ANGULAR = 1e-3 +TOLERANCE_GRID = 1e-6 diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 5d5b13b..5f9fbc6 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,457 +1,654 @@ from __future__ import annotations -from typing import TYPE_CHECKING - +from typing import TYPE_CHECKING, Literal +import rtree import numpy -from shapely.geometry import LineString, box - -from inire.geometry.component_overlap import components_overlap -from inire.geometry.dynamic_path_index import DynamicPathIndex -from inire.geometry.index_helpers import grid_cell_span -from inire.results import RoutingReport -from inire.geometry.static_obstacle_index import StaticObstacleIndex +import shapely +from shapely.prepared import prep +from shapely.strtree import STRtree +from shapely.geometry import box, LineString if TYPE_CHECKING: - 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 shapely.prepared import PreparedGeometry from inire.geometry.primitives import Port + from inire.geometry.components import ComponentResult -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: +class CollisionEngine: """ - Internal spatial state for collision detection, congestion, and verification. + Manages spatial queries for collision detection with unified dilation logic. """ - __slots__ = ( - "clearance", - "safety_zone_radius", - "grid_cell_size", - "_dynamic_paths", - "_static_obstacles", + 'clearance', 'max_net_width', 'safety_zone_radius', + 'static_index', 'static_geometries', 'static_dilated', 'static_prepared', + 'static_is_rect', 'static_tree', 'static_obj_ids', 'static_safe_cache', + 'static_grid', 'grid_cell_size', '_static_id_counter', '_net_specific_trees', + '_net_specific_is_rect', '_net_specific_bounds', + 'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', 'dynamic_prepared', + 'dynamic_tree', 'dynamic_obj_ids', 'dynamic_grid', '_dynamic_id_counter', + 'metrics', '_dynamic_tree_dirty', '_dynamic_net_ids_array', '_inv_grid_cell_size', + '_static_bounds_array', '_static_is_rect_array', '_locked_nets', + '_static_raw_tree', '_static_raw_obj_ids', '_dynamic_bounds_array', '_static_version' ) def __init__( - self, - clearance: float, - 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 + # Static obstacles + self.static_index = rtree.index.Index() + self.static_geometries: dict[int, Polygon] = {} + self.static_dilated: dict[int, Polygon] = {} + self.static_prepared: dict[int, PreparedGeometry] = {} + self.static_is_rect: dict[int, bool] = {} + self.static_tree: STRtree | None = None + self.static_obj_ids: list[int] = [] + self._static_bounds_array: numpy.ndarray | None = None + self._static_is_rect_array: numpy.ndarray | None = None + self._static_raw_tree: STRtree | None = None + self._static_raw_obj_ids: list[int] = [] + self._net_specific_trees: dict[tuple[float, float], STRtree] = {} + self._net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} + self._net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} + self._static_version = 0 + + self.static_safe_cache: set[tuple] = set() + self.static_grid: dict[tuple[int, int], list[int]] = {} self.grid_cell_size = 50.0 - self._static_obstacles = StaticObstacleIndex(self) - self._dynamic_paths = DynamicPathIndex(self) + self._inv_grid_cell_size = 1.0 / self.grid_cell_size + self._static_id_counter = 0 - def get_static_version(self) -> int: - return self._static_obstacles.version + # Dynamic paths + self.dynamic_index = rtree.index.Index() + self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {} + self.dynamic_dilated: dict[int, Polygon] = {} + self.dynamic_prepared: dict[int, PreparedGeometry] = {} + self.dynamic_tree: STRtree | None = None + self.dynamic_obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32) + self.dynamic_grid: dict[tuple[int, int], list[int]] = {} + + self._dynamic_id_counter = 0 + self._dynamic_tree_dirty = True + self._dynamic_net_ids_array = numpy.array([], dtype=' Iterable[Polygon]: - return self._static_obstacles.dilated.values() + def reset_metrics(self) -> None: + for k in self.metrics: + self.metrics[k] = 0 - def iter_static_obstacle_bounds( - self, - query_bounds: tuple[float, float, float, float], - ) -> Iterable[tuple[float, float, float, float]]: - for obj_id in self._static_obstacles.index.intersection(query_bounds): - yield self._static_obstacles.geometries[obj_id].bounds - - def iter_dynamic_path_bounds( - self, - query_bounds: tuple[float, float, float, float], - ) -> Iterable[tuple[float, float, float, float]]: - for obj_id in self._dynamic_paths.index.intersection(query_bounds): - yield self._dynamic_paths.geometries[obj_id][1].bounds + def 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") def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: - return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) + obj_id = self._static_id_counter + self._static_id_counter += 1 + + # Preserve existing dilation if provided, else use default C/2 + if dilated_geometry is not None: + dilated = dilated_geometry + else: + dilated = polygon.buffer(self.clearance / 2.0, join_style=2) + + self.static_geometries[obj_id] = polygon + self.static_dilated[obj_id] = dilated + self.static_prepared[obj_id] = prep(dilated) + self.static_index.insert(obj_id, dilated.bounds) + self._invalidate_static_caches() + b = dilated.bounds + area = (b[2] - b[0]) * (b[3] - b[1]) + self.static_is_rect[obj_id] = (abs(dilated.area - area) < 1e-4) + return obj_id def remove_static_obstacle(self, obj_id: int) -> None: - self._static_obstacles.remove_obstacle(obj_id) + """ + Remove a static obstacle by ID. + """ + if obj_id not in self.static_geometries: + return + + bounds = self.static_dilated[obj_id].bounds + self.static_index.delete(obj_id, bounds) + + del self.static_geometries[obj_id] + del self.static_dilated[obj_id] + del self.static_prepared[obj_id] + del self.static_is_rect[obj_id] + self._invalidate_static_caches() + + def _invalidate_static_caches(self) -> None: + self.static_tree = None + self._static_bounds_array = None + self._static_is_rect_array = None + self.static_obj_ids = [] + self._static_raw_tree = None + self._static_raw_obj_ids = [] + self.static_grid = {} + self._net_specific_trees.clear() + self._net_specific_is_rect.clear() + self._net_specific_bounds.clear() + self.static_safe_cache.clear() + self._static_version += 1 def _ensure_static_tree(self) -> None: - self._static_obstacles.ensure_tree() + if self.static_tree is None and self.static_dilated: + self.static_obj_ids = sorted(self.static_dilated.keys()) + geoms = [self.static_dilated[i] for i in self.static_obj_ids] + self.static_tree = STRtree(geoms) + self._static_bounds_array = numpy.array([g.bounds for g in geoms]) + self._static_is_rect_array = numpy.array([self.static_is_rect[i] for i in self.static_obj_ids]) def _ensure_net_static_tree(self, net_width: float) -> STRtree: - return self._static_obstacles.ensure_net_tree(net_width) + """ + Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance). + """ + key = (round(net_width, 4), round(self.clearance, 4)) + if key in self._net_specific_trees: + return self._net_specific_trees[key] + + # Physical separation must be >= clearance. + # Centerline to raw obstacle edge must be >= net_width/2 + clearance. + total_dilation = net_width / 2.0 + self.clearance + geoms = [] + is_rect_list = [] + bounds_list = [] + + for obj_id in sorted(self.static_geometries.keys()): + poly = self.static_geometries[obj_id] + dilated = poly.buffer(total_dilation, join_style=2) + geoms.append(dilated) + + b = dilated.bounds + bounds_list.append(b) + area = (b[2] - b[0]) * (b[3] - b[1]) + is_rect_list.append(abs(dilated.area - area) < 1e-4) + + tree = STRtree(geoms) + self._net_specific_trees[key] = tree + self._net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool) + self._net_specific_bounds[key] = numpy.array(bounds_list) + return tree def _ensure_static_raw_tree(self) -> None: - self._static_obstacles.ensure_raw_tree() + if self._static_raw_tree is None and self.static_geometries: + self._static_raw_obj_ids = sorted(self.static_geometries.keys()) + geoms = [self.static_geometries[i] for i in self._static_raw_obj_ids] + self._static_raw_tree = STRtree(geoms) def _ensure_dynamic_tree(self) -> None: - self._dynamic_paths.ensure_tree() + if self.dynamic_tree is None and self.dynamic_dilated: + ids = sorted(self.dynamic_dilated.keys()) + geoms = [self.dynamic_dilated[i] for i in ids] + self.dynamic_tree = STRtree(geoms) + self.dynamic_obj_ids = numpy.array(ids, dtype=numpy.int32) + self._dynamic_bounds_array = numpy.array([g.bounds for g in geoms]) + nids = [self.dynamic_geometries[obj_id][0] for obj_id in self.dynamic_obj_ids] + self._dynamic_net_ids_array = numpy.array(nids, dtype=' None: - self._dynamic_paths.ensure_grid() + if not self.dynamic_grid and self.dynamic_dilated: + cs = self.grid_cell_size + for obj_id, poly in self.dynamic_dilated.items(): + b = poly.bounds + for gx in range(int(b[0] / cs), int(b[2] / cs) + 1): + for gy in range(int(b[1] / cs), int(b[3] / cs) + 1): + cell = (gx, gy) + if cell not in self.dynamic_grid: self.dynamic_grid[cell] = [] + self.dynamic_grid[cell].append(obj_id) - 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 add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None: + self.dynamic_tree = None + self.dynamic_grid = {} + self._dynamic_tree_dirty = True + dilation = self.clearance / 2.0 + for i, poly in enumerate(geometry): + obj_id = self._dynamic_id_counter + self._dynamic_id_counter += 1 + dilated = dilated_geometry[i] if dilated_geometry else poly.buffer(dilation) + self.dynamic_geometries[obj_id] = (net_id, poly) + self.dynamic_dilated[obj_id] = dilated + self.dynamic_index.insert(obj_id, dilated.bounds) def remove_path(self, net_id: str) -> None: - self._dynamic_paths.remove_path(net_id) + if net_id in self._locked_nets: return + to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] + if not to_remove: return + self.dynamic_tree = None + self.dynamic_grid = {} + self._dynamic_tree_dirty = True + for obj_id in to_remove: + self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds) + del self.dynamic_geometries[obj_id] + del self.dynamic_dilated[obj_id] + + def lock_net(self, net_id: str) -> None: + """ Convert a routed net into static obstacles. """ + self._locked_nets.add(net_id) + + # Move all segments of this net to static obstacles + to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] + for obj_id in to_move: + poly = self.dynamic_geometries[obj_id][1] + dilated = self.dynamic_dilated[obj_id] + # Preserve dilation for perfect consistency + self.add_static_obstacle(poly, dilated_geometry=dilated) + + # Remove from dynamic index (without triggering the locked-net guard) + self.dynamic_tree = None + self.dynamic_grid = {} + self._dynamic_tree_dirty = True + for obj_id in to_move: + self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds) + del self.dynamic_geometries[obj_id] + del self.dynamic_dilated[obj_id] + + def unlock_net(self, net_id: str) -> None: + self._locked_nets.discard(net_id) def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) + self.metrics['static_straight_fast'] += 1 + reach = self.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) return reach < length - 0.001 def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - bounds = self._static_obstacles.bounds_array[idx] - 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 _is_in_safety_zone( - self, - geometry: Polygon, - obj_id: int, - start_port: Port | None, - end_port: Port | None, - ) -> bool: - 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 - - 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, - ) -> bool: - # TODO: If static buffering becomes net-width-specific, add dedicated - # width-aware geometry/index handling instead of reviving dead args here. - static_obstacles = self._static_obstacles - if not static_obstacles.dilated: - return False - - self._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 - + """ Fast port-based check to see if a collision might be in a safety zone. """ + sz = self.safety_zone_radius + b = self._static_bounds_array[idx] + if start_port: + if (b[0]-sz <= start_port.x <= b[2]+sz and + b[1]-sz <= start_port.y <= b[3]+sz): return True + if end_port: + if (b[0]-sz <= end_port.x <= b[2]+sz and + b[1]-sz <= end_port.y <= b[3]+sz): return True return False - def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - dynamic_paths = self._dynamic_paths - 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 + def check_move_static(self, result: ComponentResult, start_port: Port | None = None, end_port: Port | None = None, net_width: float | None = None) -> bool: + if not self.static_dilated: return False + self.metrics['static_tree_queries'] += 1 + self._ensure_static_tree() + + # 1. Fast total bounds check (Use dilated bounds to ensure clearance is caught) + tb = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds + hits = self.static_tree.query(box(*tb)) + if hits.size == 0: return False + + # 2. Per-hit check + s_bounds = self._static_bounds_array + move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds + for hit_idx in hits: + obs_b = s_bounds[hit_idx] + + # Check if any polygon in the move actually hits THIS obstacle's AABB + poly_hits_obs_aabb = False + for pb in move_poly_bounds: + if (pb[0] < obs_b[2] and pb[2] > obs_b[0] and + pb[1] < obs_b[3] and pb[3] > obs_b[1]): + poly_hits_obs_aabb = True break - - if found_real: - real_hits_count += 1 - - return real_hits_count + + if not poly_hits_obs_aabb: continue + + # Safety zone check (Fast port-based) + if self._is_in_safety_zone_fast(hit_idx, start_port, end_port): + # If near port, we must use the high-precision check + obj_id = self.static_obj_ids[hit_idx] + collision_found = False + for p_move in result.geometry: + if not self._is_in_safety_zone(p_move, obj_id, start_port, end_port): + collision_found = True; break + if not collision_found: continue + return True + + # Not in safety zone and AABBs overlap - check real intersection + obj_id = self.static_obj_ids[hit_idx] + # Use dilated geometry (Wi/2 + C/2) against static_dilated (C/2) to get Wi/2 + C. + # Touching means gap is exactly C. Intersection without touches means gap < C. + test_geoms = result.dilated_geometry if result.dilated_geometry else result.geometry + static_obs_dilated = self.static_dilated[obj_id] + + for i, p_test in enumerate(test_geoms): + if p_test.intersects(static_obs_dilated) and not p_test.touches(static_obs_dilated): + return True + return False def check_move_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 + if not self.dynamic_geometries: return 0 + tb = result.total_dilated_bounds + if tb is None: return 0 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) - + dynamic_grid = self.dynamic_grid + if not dynamic_grid: return 0 + + cs_inv = self._inv_grid_cell_size + gx_min = int(tb[0] * cs_inv) + gy_min = int(tb[1] * cs_inv) + gx_max = int(tb[2] * cs_inv) + gy_max = int(tb[3] * cs_inv) + + dynamic_geometries = self.dynamic_geometries + + # Fast path for single cell if gx_min == gx_max and gy_min == gy_max: cell = (gx_min, gy_min) if cell in dynamic_grid: for obj_id in dynamic_grid[cell]: - if dynamic_paths.geometries[obj_id][0] != net_id: + if dynamic_geometries[obj_id][0] != net_id: return self._check_real_congestion(result, net_id) return 0 + # General case any_possible = False for gx in range(gx_min, gx_max + 1): for gy in range(gy_min, gy_max + 1): cell = (gx, gy) if cell in dynamic_grid: for obj_id in dynamic_grid[cell]: - if dynamic_paths.geometries[obj_id][0] != net_id: + 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 + if any_possible: break + if any_possible: break + + if not any_possible: return 0 return self._check_real_congestion(result, net_id) - 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) - - 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 - + def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: + self.metrics['congestion_tree_queries'] += 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 + if self.dynamic_tree is None: return 0 + + # 1. Fast total bounds check (LAZY SAFE) + tb = result.total_dilated_bounds + d_bounds = self._dynamic_bounds_array + possible_total = (tb[0] < d_bounds[:, 2]) & (tb[2] > d_bounds[:, 0]) & \ + (tb[1] < d_bounds[:, 3]) & (tb[3] > d_bounds[:, 1]) + + valid_hits_mask = (self._dynamic_net_ids_array != net_id) + if not numpy.any(possible_total & valid_hits_mask): + return 0 - 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 + # 2. Per-polygon check using query + geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry + res_indices, tree_indices = self.dynamic_tree.query(geoms_to_test, predicate='intersects') + + if tree_indices.size == 0: + return 0 + + hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices) + + # Group by other net_id to minimize 'touches' calls + unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id]) + if unique_other_nets.size == 0: + return 0 + + tree_geoms = self.dynamic_tree.geometries + real_hits_count = 0 + + for other_nid in unique_other_nets: + other_mask = (hit_net_ids == other_nid) + sub_tree_indices = tree_indices[other_mask] + sub_res_indices = res_indices[other_mask] + + # Check if ANY hit for THIS other net is a real collision + found_real = False + for j in range(len(sub_tree_indices)): + p_test = geoms_to_test[sub_res_indices[j]] + p_tree = tree_geoms[sub_tree_indices[j]] + if not p_test.touches(p_tree): + # Add small area tolerance for numerical precision + if p_test.intersection(p_tree).area > 1e-7: + found_real = True + break + + if found_real: + real_hits_count += 1 + + return real_hits_count - 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]) + def _is_in_safety_zone(self, geometry: Polygon, obj_id: int, start_port: Port | None, end_port: Port | None) -> bool: + """ + Only returns True if the collision is ACTUALLY inside a safety zone. + """ + raw_obstacle = self.static_geometries[obj_id] + sz = self.safety_zone_radius + + # Fast path: check if ports are even near the obstacle + obs_b = raw_obstacle.bounds + near_start = start_port and (obs_b[0]-sz <= start_port.x <= obs_b[2]+sz and + obs_b[1]-sz <= start_port.y <= obs_b[3]+sz) + near_end = end_port and (obs_b[0]-sz <= end_port.x <= obs_b[2]+sz and + obs_b[1]-sz <= end_port.y <= obs_b[3]+sz) + + if not near_start and not near_end: + return False - if component_hits: - dynamic_collision_count += len(numpy.unique(component_hits)) + if not geometry.intersects(raw_obstacle): + return False + + self.metrics['safety_zone_checks'] += 1 + intersection = geometry.intersection(raw_obstacle) + if intersection.is_empty: return False + + ix_bounds = intersection.bounds + if start_port and near_start: + if (abs(ix_bounds[0] - start_port.x) < sz and abs(ix_bounds[1] - start_port.y) < sz and + abs(ix_bounds[2] - start_port.x) < sz and abs(ix_bounds[3] - start_port.y) < sz): return True + if end_port and near_end: + if (abs(ix_bounds[0] - end_port.x) < sz and abs(ix_bounds[1] - end_port.y) < sz and + abs(ix_bounds[2] - end_port.x) < sz and abs(ix_bounds[3] - end_port.y) < sz): return True + return False - 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 + def check_collision(self, geometry: Polygon, net_id: str, buffer_mode: Literal['static', 'congestion'] = 'static', start_port: Port | None = None, end_port: Port | None = None, dilated_geometry: Polygon | None = None, bounds: tuple[float, float, float, float] | None = None, net_width: float | None = None) -> bool | int: + if buffer_mode == 'static': + self._ensure_static_tree() + if self.static_tree is None: return False + + # Separation needed: Centerline-to-WallEdge >= Wi/2 + C. + # static_tree has obstacles buffered by C/2. + # geometry is physical waveguide (Wi/2 from centerline). + # So we buffer geometry by C/2 to get Wi/2 + C/2. + # Intersection means separation < (Wi/2 + C/2) + C/2 = Wi/2 + C. + if dilated_geometry is not None: + test_geom = dilated_geometry + else: + dist = self.clearance / 2.0 + test_geom = geometry.buffer(dist + 1e-7, join_style=2) if dist > 0 else geometry + + hits = self.static_tree.query(test_geom, predicate='intersects') + tree_geoms = self.static_tree.geometries + for hit_idx in hits: + if test_geom.touches(tree_geoms[hit_idx]): continue + obj_id = self.static_obj_ids[hit_idx] + if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): continue + return True + return False + + self._ensure_dynamic_tree() + if self.dynamic_tree is None: return 0 + test_poly = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0) + hits = self.dynamic_tree.query(test_poly, predicate='intersects') + tree_geoms = self.dynamic_tree.geometries + hit_net_ids = [] + for hit_idx in hits: + if test_poly.touches(tree_geoms[hit_idx]): continue + obj_id = self.dynamic_obj_ids[hit_idx] + other_id = self.dynamic_geometries[obj_id][0] + if other_id != net_id: + hit_net_ids.append(other_id) + return len(numpy.unique(hit_net_ids)) if hit_net_ids else 0 - 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 is_collision(self, geometry: Polygon, net_id: str = 'default', net_width: float | None = None, start_port: Port | None = None, end_port: Port | None = None) -> bool: + """ Unified entry point for static collision checks. """ + result = self.check_collision(geometry, net_id, buffer_mode='static', start_port=start_port, end_port=end_port, net_width=net_width) + return bool(result) - 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 + def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]: + """ + Non-approximated, full-polygon intersection check of a path against all + static obstacles and other nets. + """ + collision_count = 0 + + # 1. Check against static obstacles + self._ensure_static_raw_tree() + if self._static_raw_tree is not None: + raw_geoms = self._static_raw_tree.geometries + for comp in components: + # Use ACTUAL geometry, not dilated/proxy + actual_geoms = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry + for p_actual in actual_geoms: + # Physical separation must be >= clearance. + p_verify = p_actual.buffer(self.clearance, join_style=2) + hits = self._static_raw_tree.query(p_verify, predicate='intersects') + for hit_idx in hits: + p_obs = raw_geoms[hit_idx] + # If they ONLY touch, gap is exactly clearance. Valid. + if p_verify.touches(p_obs): continue + + obj_id = self._static_raw_obj_ids[hit_idx] + if not self._is_in_safety_zone(p_actual, obj_id, None, None): + collision_count += 1 + + # 2. Check against other nets + self._ensure_dynamic_tree() + if self.dynamic_tree is not None: + tree_geoms = self.dynamic_tree.geometries + for comp in components: + # Robust fallback chain to ensure crossings are caught even with zero clearance + d_geoms = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry + if not d_geoms: continue + + # Ensure d_geoms is a list/array for STRtree.query + if not isinstance(d_geoms, (list, tuple, numpy.ndarray)): + d_geoms = [d_geoms] - radians = numpy.radians(angle_deg) - cos_v, sin_v = numpy.cos(radians), numpy.sin(radians) + res_indices, tree_indices = self.dynamic_tree.query(d_geoms, predicate='intersects') + if tree_indices.size > 0: + hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices) + net_id_str = str(net_id) + + comp_hits = [] + for i in range(len(tree_indices)): + if hit_net_ids[i] == net_id_str: continue + + p_new = d_geoms[res_indices[i]] + p_tree = tree_geoms[tree_indices[i]] + if not p_new.touches(p_tree): + # Numerical tolerance for area overlap + if p_new.intersection(p_tree).area > 1e-7: + comp_hits.append(hit_net_ids[i]) + + if comp_hits: + collision_count += len(numpy.unique(comp_hits)) + + return (collision_count == 0), collision_count + + def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float: + rad = numpy.radians(angle_deg) + cos_v, sin_v = numpy.cos(rad), numpy.sin(rad) dx, dy = max_dist * cos_v, max_dist * sin_v min_x, max_x = sorted([origin.x, origin.x + dx]) min_y, max_y = sorted([origin.y, origin.y + dy]) - + + key = None if net_width is not None: - tree = self._ensure_net_static_tree(net_width) - key = (round(net_width, 4), round(self.clearance, 4)) - is_rect_array = static_obstacles.net_specific_is_rect[key] - bounds_array = static_obstacles.net_specific_bounds[key] + tree = self._ensure_net_static_tree(net_width) + key = (round(net_width, 4), round(self.clearance, 4)) + is_rect_arr = self._net_specific_is_rect[key] + bounds_arr = self._net_specific_bounds[key] else: - self._ensure_static_tree() - tree = static_obstacles.tree - is_rect_array = static_obstacles.is_rect_array - bounds_array = static_obstacles.bounds_array - - if tree is None: - return max_dist + self._ensure_static_tree() + tree = self.static_tree + is_rect_arr = self._static_is_rect_array + bounds_arr = self._static_bounds_array + if tree is None: return max_dist candidates = tree.query(box(min_x, min_y, max_x, max_y)) - if candidates.size == 0: - return max_dist - + 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 + + tree_geoms = tree.geometries ray_line = None - - candidates_bounds = bounds_array[candidates] - dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 + + # Fast AABB-based pre-sort + candidates_bounds = bounds_arr[candidates] + # Distance to AABB min corner as heuristic + dist_sq = (candidates_bounds[:, 0] - origin.x)**2 + (candidates_bounds[:, 1] - origin.y)**2 sorted_indices = numpy.argsort(dist_sq) - + for idx in sorted_indices: - 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 + c = candidates[idx] + b = bounds_arr[c] + + # Fast axis-aligned ray-AABB intersection + # (Standard Slab method) + if abs(dx) < 1e-12: # Vertical ray + if origin.x < b[0] or origin.x > b[2]: tx_min, tx_max = 1e30, -1e30 + else: tx_min, tx_max = -1e30, 1e30 else: - t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx + t1, t2 = (b[0] - origin.x) * inv_dx, (b[2] - origin.x) * inv_dx tx_min, tx_max = min(t1, t2), max(t1, t2) - - if abs(dy) < 1e-12: - if origin.y < bounds[1] or origin.y > bounds[3]: - ty_min, ty_max = 1e30, -1e30 - else: - ty_min, ty_max = -1e30, 1e30 + + if abs(dy) < 1e-12: # Horizontal ray + if origin.y < b[1] or origin.y > b[3]: ty_min, ty_max = 1e30, -1e30 + else: ty_min, ty_max = -1e30, 1e30 else: - t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy + t1, t2 = (b[1] - origin.y) * inv_dy, (b[3] - origin.y) * inv_dy ty_min, ty_max = min(t1, t2), max(t1, t2) - + t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) - 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]: + + # Intersection conditions + if t_max < 0 or t_min > t_max or t_min > 1.0: continue + + # If hit is further than current min_dist, skip + if t_min * max_dist >= min_dist: continue + + # HIGH PRECISION CHECK + if is_rect_arr[c]: + # Rectangles are perfectly described by their AABB min_dist = max(0.0, t_min * max_dist) continue - - if ray_line is None: + + # Fallback to full geometry check for non-rectangles (arcs, etc.) + if ray_line is None: ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) - - 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) - + + obs_dilated = tree_geoms[c] + if obs_dilated.intersects(ray_line): + intersection = ray_line.intersection(obs_dilated) + if intersection.is_empty: continue + + def get_dist(geom): + if hasattr(geom, 'geoms'): return min(get_dist(g) for g in geom.geoms) + return numpy.sqrt((geom.coords[0][0] - origin.x)**2 + (geom.coords[0][1] - origin.y)**2) + + d = get_dist(intersection) + if d < min_dist: min_dist = d + return min_dist diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py deleted file mode 100644 index 816508d..0000000 --- a/inire/geometry/component_overlap.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.components import ComponentResult - - -def components_overlap( - component_a: ComponentResult, - component_b: ComponentResult, - prefer_actual: bool = False, -) -> bool: - polygons_a: tuple[Polygon, ...] - polygons_b: tuple[Polygon, ...] - if prefer_actual: - polygons_a = component_a.physical_geometry - polygons_b = component_b.physical_geometry - bounds_a = ( - min(polygon.bounds[0] for polygon in polygons_a), - min(polygon.bounds[1] for polygon in polygons_a), - max(polygon.bounds[2] for polygon in polygons_a), - max(polygon.bounds[3] for polygon in polygons_a), - ) - bounds_b = ( - min(polygon.bounds[0] for polygon in polygons_b), - min(polygon.bounds[1] for polygon in polygons_b), - max(polygon.bounds[2] for polygon in polygons_b), - max(polygon.bounds[3] for polygon in polygons_b), - ) - else: - polygons_a = component_a.collision_geometry - polygons_b = component_b.collision_geometry - bounds_a = component_a.total_bounds - bounds_b = component_b.total_bounds - - if not ( - bounds_a[0] < bounds_b[2] - and bounds_a[2] > bounds_b[0] - and bounds_a[1] < bounds_b[3] - and bounds_a[3] > bounds_b[1] - ): - return False - - for polygon_a in polygons_a: - for polygon_b in polygons_b: - if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): - return True - return False diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 714ef55..9e755cf 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,66 +1,67 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import Literal import numpy -from shapely.affinity import rotate as shapely_rotate -from shapely.affinity import scale as shapely_scale from shapely.affinity import translate as shapely_translate from shapely.geometry import Polygon, box -from inire.constants import TOLERANCE_ANGULAR -from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed +from inire.constants import TOLERANCE_ANGULAR, TOLERANCE_LINEAR 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: - start_port: Port - collision_geometry: tuple[Polygon, ...] - end_port: Port - length: float - move_type: MoveKind - move_spec: PathSegmentSeed - physical_geometry: tuple[Polygon, ...] - dilated_collision_geometry: tuple[Polygon, ...] - dilated_physical_geometry: tuple[Polygon, ...] - _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) + __slots__ = ( + "geometry", + "dilated_geometry", + "proxy_geometry", + "actual_geometry", + "dilated_actual_geometry", + "end_port", + "length", + "move_type", + "_bounds", + "_total_bounds", + "_dilated_bounds", + "_total_dilated_bounds", + ) - 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) + def __init__( + self, + 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.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 - 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)) + self._bounds = [poly.bounds for poly in self.geometry] + self._total_bounds = _combine_bounds(self._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))) + 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) @property - def bounds(self) -> tuple[tuple[float, float, float, float], ...]: + def bounds(self) -> list[tuple[float, float, float, float]]: return self._bounds @property @@ -68,24 +69,23 @@ class ComponentResult: return self._total_bounds @property - def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]: + def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None: return self._dilated_bounds @property - def total_dilated_bounds(self) -> tuple[float, float, float, float]: + def total_dilated_bounds(self) -> tuple[float, float, float, float] | None: return self._total_dilated_bounds def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( - 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), + geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry], + end_port=self.end_port + [dx, dy, 0], length=self.length, move_type=self.move_type, - move_spec=self.move_spec, - physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], - dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], - dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], + 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], ) @@ -134,13 +134,7 @@ def _get_arc_polygons( return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] -def _clip_bbox_legacy( - 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], clip_margin: float) -> Polygon: arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0] minx, miny, maxx, maxy = arc_poly.bounds bbox_poly = box(minx, miny, maxx, maxy) @@ -148,76 +142,17 @@ def _clip_bbox_legacy( return bbox_poly.buffer(-shrink, join_style=2) if shrink > 0 else bbox_poly -def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon: - """Return a conservative 8-point polygonal proxy for the arc. - - The polygon uses 4 points along the outer edge and 4 along the inner edge. - The outer edge is a circumscribed polyline and the inner edge is an - inscribed polyline, so the result conservatively contains the true arc. - """ - cx, cy = cxy - sample_count = 4 - angle_span = abs(float(ts[1]) - float(ts[0])) - if angle_span < TOLERANCE_ANGULAR: - return box(*_get_arc_polygons(cxy, radius, width, ts)[0].bounds) - - segment_half_angle = numpy.radians(angle_span / (2.0 * (sample_count - 1))) - cos_half = max(float(numpy.cos(segment_half_angle)), 1e-9) - - inner_radius = max(0.0, radius - width / 2.0) - outer_radius = radius + width / 2.0 - tolerance = max(1e-3, radius * 1e-4) - conservative_inner_radius = max(0.0, inner_radius * cos_half - tolerance) - conservative_outer_radius = outer_radius / cos_half + tolerance - - angles = numpy.radians(numpy.linspace(ts[0], ts[1], sample_count)) - cos_a = numpy.cos(angles) - sin_a = numpy.sin(angles) - - outer_points = numpy.column_stack((cx + conservative_outer_radius * cos_a, cy + conservative_outer_radius * sin_a)) - inner_points = numpy.column_stack((cx + conservative_inner_radius * cos_a[::-1], cy + conservative_inner_radius * sin_a[::-1])) - return Polygon(numpy.concatenate((outer_points, inner_points), axis=0)) - - -def _clip_bbox( - cxy: tuple[float, float], - radius: float, - width: float, - ts: tuple[float, float], - clip_margin: float | None, -) -> Polygon: - if clip_margin is not None: - return _clip_bbox_legacy(cxy, radius, width, ts, clip_margin) - return _clip_bbox_polygonal(cxy, radius, width, ts) - - -def _transform_custom_collision_polygon( - collision_poly: Polygon, - cxy: tuple[float, float], - rotation_deg: float, - mirror_y: bool, -) -> Polygon: - poly = collision_poly - if mirror_y: - poly = shapely_scale(poly, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0)) - if rotation_deg % 360: - poly = shapely_rotate(poly, rotation_deg, origin=(0.0, 0.0), use_radians=False) - return shapely_translate(poly, cxy[0], cxy[1]) - - def _apply_collision_model( arc_poly: Polygon, - collision_type: BendCollisionModel, + collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, radius: float, width: float, cxy: tuple[float, float], + clip_margin: float, ts: tuple[float, float], - clip_margin: float | None = None, - rotation_deg: float = 0.0, - mirror_y: bool = False, ) -> list[Polygon]: if isinstance(collision_type, Polygon): - return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)] + return [shapely_translate(collision_type, cxy[0], cxy[1])] if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": @@ -244,31 +179,21 @@ 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( - ( - (-dilation, half_w_d), - (length_f + dilation, half_w_d), - (length_f + dilation, -half_w_d), - (-dilation, -half_w_d), - ) - ) + pts_d = numpy.array(((-dilation, half_w_d), (length_f + dilation, half_w_d), (length_f + dilation, -half_w_d), (-dilation, -half_w_d))) poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y)) dilated_geometry = [Polygon(poly_points_d)] - else: - dilated_geometry = geometry return ComponentResult( - start_port=start_port, - collision_geometry=geometry, + geometry=geometry, end_port=end_port, length=abs(length_f), - move_type="straight", - move_spec=StraightSeed(length=length_f), - physical_geometry=geometry, - dilated_collision_geometry=dilated_geometry, - dilated_physical_geometry=dilated_geometry, + move_type="Straight", + dilated_geometry=dilated_geometry, + actual_geometry=geometry, + dilated_actual_geometry=dilated_geometry, ) @@ -280,8 +205,8 @@ class Bend90: width: float, direction: Literal["CW", "CCW"], sagitta: float = 0.01, - collision_type: BendCollisionModel = "arc", - clip_margin: float | None = None, + collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", + clip_margin: float = 10.0, dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) @@ -304,39 +229,37 @@ class Bend90: radius, width, (float(center_xy[0]), float(center_xy[1])), + clip_margin, ts, - clip_margin=clip_margin, - rotation_deg=float(start_port.r), - mirror_y=(sign < 0), ) - physical_geometry = arc_polys - if dilation > 0: - dilated_physical_geometry = _get_arc_polygons( - (float(center_xy[0]), float(center_xy[1])), + 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, - sagitta, - dilation=dilation, ) - dilated_collision_geometry = ( - dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] - ) - else: - dilated_physical_geometry = physical_geometry - dilated_collision_geometry = collision_polys + + dilated_actual_geometry = None + dilated_geometry = None + if dilation > 0: + dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation) + dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] return ComponentResult( - start_port=start_port, - collision_geometry=collision_polys, + geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, - move_type="bend90", - move_spec=Bend90Seed(radius=radius, direction=direction), - physical_geometry=physical_geometry, - dilated_collision_geometry=dilated_collision_geometry, - dilated_physical_geometry=dilated_physical_geometry, + move_type="Bend90", + dilated_geometry=dilated_geometry, + proxy_geometry=proxy_geometry, + actual_geometry=arc_polys, + dilated_actual_geometry=dilated_actual_geometry, ) @@ -348,8 +271,8 @@ class SBend: radius: float, width: float, sagitta: float = 0.01, - collision_type: BendCollisionModel = "arc", - clip_margin: float | None = None, + collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", + clip_margin: float = 10.0, dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: @@ -378,51 +301,33 @@ class SBend: arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0] actual_geometry = [arc1, arc2] geometry = [ - _apply_collision_model( - arc1, - collision_type, - radius, - width, - (float(c1_xy[0]), float(c1_xy[1])), - ts1, - clip_margin=clip_margin, - rotation_deg=float(start_port.r), - mirror_y=(sign < 0), - )[0], - _apply_collision_model( - arc2, - collision_type, - radius, - width, - (float(c2_xy[0]), float(c2_xy[1])), - ts2, - clip_margin=clip_margin, - rotation_deg=float(start_port.r), - mirror_y=(sign > 0), - )[0], + _apply_collision_model(arc1, collision_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0], + _apply_collision_model(arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], ] - physical_geometry = actual_geometry + 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 if dilation > 0: - dilated_physical_geometry = [ + dilated_actual_geometry = [ _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], ] - dilated_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 + dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] return ComponentResult( - start_port=start_port, - collision_geometry=geometry, + geometry=geometry, end_port=end_port, length=2.0 * radius * theta, - move_type="sbend", - move_spec=SBendSeed(offset=offset, radius=radius), - physical_geometry=physical_geometry, - dilated_collision_geometry=dilated_collision_geometry, - dilated_physical_geometry=dilated_physical_geometry, + move_type="SBend", + dilated_geometry=dilated_geometry, + proxy_geometry=proxy_geometry, + actual_geometry=actual_geometry, + dilated_actual_geometry=dilated_actual_geometry, ) diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py deleted file mode 100644 index d8363f6..0000000 --- a/inire/geometry/dynamic_path_index.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy -import rtree -from shapely.strtree import STRtree - -from inire.geometry.index_helpers import build_index_payload, iter_grid_cells - -if TYPE_CHECKING: - from collections.abc import Sequence - - from shapely.geometry import Polygon - - from inire.geometry.collision import RoutingWorld - - -class DynamicPathIndex: - __slots__ = ( - "engine", - "index", - "geometries", - "dilated", - "tree", - "obj_ids", - "grid", - "id_counter", - "net_ids_array", - "bounds_array", - ) - - 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.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.net_ids_array = numpy.array([], dtype=object) - self.bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4) - - def invalidate_queries(self) -> None: - self.tree = None - self.grid = {} - - def ensure_tree(self) -> None: - if self.tree is None and self.dilated: - 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 = 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=object) - - def ensure_grid(self) -> None: - if self.grid or not self.dilated: - return - - cell_size = self.engine.grid_cell_size - for obj_id, polygon in self.dilated.items(): - for cell in iter_grid_cells(polygon.bounds, cell_size): - self.grid.setdefault(cell, []).append(obj_id) - - def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: - self.invalidate_queries() - for index, polygon in enumerate(geometry): - obj_id = self.id_counter - self.id_counter += 1 - 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: - to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id] - self.remove_obj_ids(to_remove) - - def remove_obj_ids(self, obj_ids: list[int]) -> None: - if not obj_ids: - return - - self.invalidate_queries() - for obj_id in obj_ids: - self.index.delete(obj_id, self.dilated[obj_id].bounds) - del self.geometries[obj_id] - del self.dilated[obj_id] diff --git a/inire/geometry/index_helpers.py b/inire/geometry/index_helpers.py deleted file mode 100644 index dad186b..0000000 --- a/inire/geometry/index_helpers.py +++ /dev/null @@ -1,48 +0,0 @@ -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/primitives.py b/inire/geometry/primitives.py index b42b267..b6d6b9c 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,10 +1,10 @@ from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Iterator from typing import Self import numpy -from numpy.typing import NDArray +from numpy.typing import ArrayLike, NDArray def _normalize_angle(angle_deg: int | float) -> int: @@ -13,43 +13,119 @@ 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 -@dataclass(frozen=True, slots=True) + +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 + + class Port: """ - Port represented as a normalized integer (x, y, r) triple. + Port represented as an ndarray-backed (x, y, r) triple with int32 storage. """ - x: int | float - y: int | float - r: int | float + __slots__ = ("_xyr",) - 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 __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 as_tuple(self) -> tuple[int, int, int]: - return (int(self.x), int(self.y), int(self.r)) + return (self.x, self.y, self.r) - 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 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 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) + 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) ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32) @@ -61,3 +137,24 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32) def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: quadrant = (_normalize_angle(rotation_deg) // 90) % 4 return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] + + +def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: + rot2 = rotation_matrix2(rotation_deg) + rot3 = numpy.zeros((3, 3), dtype=numpy.int32) + rot3[:2, :2] = rot2 + rot3[2, 2] = 1 + return rot3 + + +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/static_obstacle_index.py b/inire/geometry/static_obstacle_index.py deleted file mode 100644 index 3f3ab38..0000000 --- a/inire/geometry/static_obstacle_index.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy -import rtree -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 inire.geometry.collision import RoutingWorld - - -class StaticObstacleIndex: - __slots__ = ( - "engine", - "index", - "geometries", - "dilated", - "is_rect", - "tree", - "obj_ids", - "bounds_array", - "is_rect_array", - "raw_tree", - "raw_obj_ids", - "net_specific_trees", - "net_specific_is_rect", - "net_specific_bounds", - "id_counter", - "version", - ) - - 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.is_rect: dict[int, bool] = {} - self.tree: STRtree | None = None - self.obj_ids: list[int] = [] - self.bounds_array: numpy.ndarray | None = None - self.is_rect_array: numpy.ndarray | None = None - self.raw_tree: STRtree | None = None - self.raw_obj_ids: list[int] = [] - self.net_specific_trees: dict[tuple[float, float], STRtree] = {} - self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {} - self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {} - self.id_counter = 0 - self.version = 0 - - def add_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: - obj_id = self.id_counter - self.id_counter += 1 - - if dilated_geometry is not None: - dilated = dilated_geometry - else: - dilated = polygon.buffer(self.engine.clearance / 2.0, join_style=2) - - self.geometries[obj_id] = polygon - self.dilated[obj_id] = dilated - self.is_rect[obj_id] = is_axis_aligned_rect(dilated) - self.index.insert(obj_id, dilated.bounds) - self.invalidate_caches() - return obj_id - - def remove_obstacle(self, obj_id: int) -> None: - if obj_id not in self.geometries: - return - - bounds = self.dilated[obj_id].bounds - self.index.delete(obj_id, bounds) - del self.geometries[obj_id] - del self.dilated[obj_id] - del self.is_rect[obj_id] - self.invalidate_caches() - - def invalidate_caches(self) -> None: - self.tree = None - self.bounds_array = None - self.is_rect_array = None - self.obj_ids = [] - self.raw_tree = None - self.raw_obj_ids = [] - self.net_specific_trees.clear() - self.net_specific_is_rect.clear() - self.net_specific_bounds.clear() - self.version += 1 - - def ensure_tree(self) -> None: - if self.tree is None and self.dilated: - self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated) - self.tree = STRtree(geometries) - self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids]) - - def ensure_net_tree(self, net_width: float) -> STRtree: - key = (round(net_width, 4), round(self.engine.clearance, 4)) - if key in self.net_specific_trees: - return self.net_specific_trees[key] - - total_dilation = net_width / 2.0 + self.engine.clearance - geometries = [] - is_rect_list = [] - bounds_list = [] - - for obj_id in sorted(self.geometries.keys()): - polygon = self.geometries[obj_id] - dilated = polygon.buffer(total_dilation, join_style=2) - geometries.append(dilated) - bounds_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, dtype=numpy.float64) - return tree - - def ensure_raw_tree(self) -> None: - if self.raw_tree is None and self.geometries: - 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 deleted file mode 100644 index 5100899..0000000 --- a/inire/model.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -from inire.geometry.components import BendCollisionModel -from inire.seeds import PathSeed - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.components import BendCollisionModel - from inire.geometry.primitives import Port - - -NetOrder = Literal["user", "shortest", "longest"] -VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"] - - -@dataclass(frozen=True, slots=True) -class NetSpec: - net_id: str - start: Port - target: Port - width: float = 2.0 - - -@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 - - -@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" - bend_clip_margin: float | None = None - visibility_guidance: VisibilityGuidance = "tangent_corner" - - def __post_init__(self) -> None: - object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) - object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) - if self.sbend_offsets is not None: - object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) - - -@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 - net_order: NetOrder = "user" - warm_start_enabled: bool = True - shuffle_nets: bool = False - 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, ...] = () - initial_paths: dict[str, PathSeed] = field(default_factory=dict) - clearance: float = 2.0 - safety_zone_radius: float = 0.0021 - - def __post_init__(self) -> None: - object.__setattr__(self, "nets", tuple(self.nets)) - object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) - initial_paths = dict(self.initial_paths) - if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()): - raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances") - object.__setattr__( - self, - "initial_paths", - initial_paths, - ) diff --git a/inire/results.py b/inire/results.py deleted file mode 100644 index d16e92c..0000000 --- a/inire/results.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -from inire.seeds import PathSeed - -if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.components import ComponentResult - - -RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] - - -@dataclass(frozen=True, slots=True) -class RoutingReport: - static_collision_count: int = 0 - dynamic_collision_count: int = 0 - self_collision_count: int = 0 - total_length: float = 0.0 - - @property - def collision_count(self) -> int: - return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count - - @property - def is_valid(self) -> bool: - return self.collision_count == 0 - - -@dataclass(frozen=True, slots=True) -class RouteMetrics: - nodes_expanded: int - moves_generated: int - moves_added: int - pruned_closed_set: int - pruned_hard_collision: int - pruned_cost: int - - -@dataclass(frozen=True, slots=True) -class RoutingResult: - net_id: str - path: tuple[ComponentResult, ...] - reached_target: bool = False - report: RoutingReport = field(default_factory=RoutingReport) - - def __post_init__(self) -> None: - object.__setattr__(self, "path", tuple(self.path)) - - @property - def collisions(self) -> int: - return self.report.collision_count - - @property - def outcome(self) -> RoutingOutcome: - if not self.path: - return "unroutable" - if not self.reached_target: - return "partial" - if self.report.collision_count > 0: - return "colliding" - return "completed" - - @property - def is_valid(self) -> bool: - return self.outcome == "completed" - - @property - def locked_geometry(self) -> tuple[Polygon, ...]: - polygons = [] - for component in self.path: - polygons.extend(component.physical_geometry) - return tuple(polygons) - - def as_seed(self) -> PathSeed: - return PathSeed(tuple(component.move_spec for component in self.path)) - - -@dataclass(frozen=True, slots=True) -class RoutingRunResult: - results_by_net: dict[str, RoutingResult] - metrics: RouteMetrics - expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py deleted file mode 100644 index ff075cd..0000000 --- a/inire/router/_astar_admission.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -import heapq -from typing import TYPE_CHECKING - -from shapely.geometry import Polygon - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight, MoveKind -from inire.geometry.primitives import Port -from inire.router.refiner import component_hits_ancestor_chain - -from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig - -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], - config: SearchRunConfig, - move_class: MoveKind, - params: tuple, -) -> None: - cp = parent.port - coll_type = config.bend_collision_type - coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type - self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 - - 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, - clip_margin=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=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 == "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, - config, - move_class, - abs_key, - ) - - -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], - config: SearchRunConfig, - move_type: MoveKind, - cache_key: tuple, -) -> 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) - 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 config.skip_congestion: - if cache_key in congestion_cache: - total_overlaps = congestion_cache[cache_key] - else: - total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) - congestion_cache[cache_key] = total_overlaps - - if config.self_collision_check and component_hits_ancestor_chain(result, parent): - return - - move_cost = context.cost_evaluator.score_component( - result, - start_port=parent_p, - ) - move_cost += total_overlaps * context.congestion_penalty - - if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost: - metrics.pruned_cost += 1 - metrics.total_pruned_cost += 1 - return - 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, - min_bend_radius=context.min_bend_radius, - ) - heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) - metrics.moves_added += 1 - metrics.total_moves_added += 1 diff --git a/inire/router/_astar_moves.py b/inire/router/_astar_moves.py deleted file mode 100644 index 71ca920..0000000 --- a/inire/router/_astar_moves.py +++ /dev/null @@ -1,286 +0,0 @@ -from __future__ import annotations - -import math - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import MoveKind -from inire.geometry.primitives import Port - -from ._astar_admission import process_move -from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig - - -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], - config: SearchRunConfig, -) -> None: - search_options = context.options.search - 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, - config, - "straight", - (int(round(proj_t)),), - ) - - 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, - config, - "straight", - (length,), - ) - - 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, - config, - "bend90", - (radius, direction), - ) - - 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, - config, - "sbend", - (offset, radius), - ) diff --git a/inire/router/_astar_types.py b/inire/router/_astar_types.py deleted file mode 100644 index 6bf2b37..0000000 --- a/inire/router/_astar_types.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from inire.geometry.components import BendCollisionModel -from inire.model import RoutingOptions, RoutingProblem -from inire.results import RouteMetrics -from inire.router.visibility import VisibilityManager - -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.router.cost import CostEvaluator - - -@dataclass(frozen=True, slots=True) -class SearchRunConfig: - bend_collision_type: BendCollisionModel - bend_clip_margin: float | None - node_limit: int - return_partial: bool = False - store_expanded: bool = False - skip_congestion: bool = False - max_cost: float | None = None - self_collision_check: bool = False - - @classmethod - def from_options( - cls, - options: RoutingOptions, - *, - bend_collision_type: BendCollisionModel | None = None, - node_limit: int | None = None, - return_partial: bool = False, - store_expanded: bool = False, - skip_congestion: bool = False, - max_cost: float | None = None, - self_collision_check: bool = False, - ) -> SearchRunConfig: - search = options.search - return cls( - bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type, - bend_clip_margin=search.bend_clip_margin, - node_limit=search.node_limit if node_limit is None else node_limit, - return_partial=return_partial, - store_expanded=store_expanded, - skip_congestion=skip_congestion, - max_cost=max_cost, - self_collision_check=self_collision_check, - ) - - -class AStarNode: - __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") - - 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", - "congestion_penalty", - "min_bend_radius", - "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.congestion_penalty = 0.0 - self.max_cache_size = max_cache_size - self.problem = problem - self.options = options - self.min_bend_radius = min(self.options.search.bend_radii, default=50.0) - self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) - self.move_cache_rel: dict[tuple, ComponentResult] = {} - self.move_cache_abs: dict[tuple, ComponentResult] = {} - 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 deleted file mode 100644 index 5aaf00c..0000000 --- a/inire/router/_router.py +++ /dev/null @@ -1,318 +0,0 @@ -from __future__ import annotations - -import random -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from inire.model import NetOrder, NetSpec -from inire.results import RoutingOutcome, RoutingReport, RoutingResult -from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig -from inire.router._search import route_astar -from inire.router._seed_materialization import materialize_path_seed -from inire.router.refiner import PathRefiner - -if TYPE_CHECKING: - from collections.abc import Callable, Sequence - - from inire.geometry.components import ComponentResult - - -@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]] - -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]] = [] - - def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: - all_geoms = [] - all_dilated = [] - for result in path: - all_geoms.extend(result.collision_geometry) - all_dilated.extend(result.dilated_collision_geometry) - self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) - - def _routing_order( - self, - net_specs: dict[str, NetSpec], - order: NetOrder, - ) -> 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: NetOrder, - ) -> dict[str, tuple[ComponentResult, ...]]: - greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} - temp_obj_ids: list[int] = [] - greedy_node_limit = min(self.context.options.search.node_limit, 2000) - for net_id in self._routing_order(net_specs, order): - net = net_specs[net_id] - h_start = self.context.cost_evaluator.h_manhattan( - net.start, - net.target, - min_bend_radius=self.context.min_bend_radius, - ) - max_cost_limit = max(h_start * 3.0, 2000.0) - run_config = SearchRunConfig.from_options( - self.context.options, - skip_congestion=True, - max_cost=max_cost_limit, - self_collision_check=True, - node_limit=greedy_node_limit, - ) - path = route_astar( - net.start, - net.target, - net.width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - config=run_config, - ) - if not path: - continue - greedy_paths[net_id] = tuple(path) - for result in path: - for polygon in result.physical_geometry: - temp_obj_ids.append(self.context.cost_evaluator.collision_engine.add_static_obstacle(polygon)) - self.context.clear_static_caches() - - for obj_id in temp_obj_ids: - self.context.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) - return greedy_paths - - def _prepare_state(self) -> _RoutingState: - problem = self.context.problem - congestion = self.context.options.congestion - initial_paths = self._materialize_problem_initial_paths() - net_specs = {net.net_id: net for net in problem.nets} - num_nets = len(net_specs) - state = _RoutingState( - 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 and congestion.warm_start_enabled: - state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order) - self.context.clear_static_caches() - - if congestion.net_order != "user": - state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order) - return state - - def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None: - if not self.context.problem.initial_paths: - return None - - search = self.context.options.search - net_specs = {net.net_id: net for net in self.context.problem.nets} - initial_paths: dict[str, tuple[ComponentResult, ...]] = {} - for net_id, seed in self.context.problem.initial_paths.items(): - if net_id not in net_specs: - raise ValueError(f"Initial path provided for unknown net: {net_id}") - net = net_specs[net_id] - initial_paths[net_id] = materialize_path_seed( - seed, - start=net.start, - net_width=net.width, - search=search, - clearance=self.context.cost_evaluator.collision_engine.clearance, - ) - return initial_paths - - def _route_net_once( - self, - state: _RoutingState, - iteration: int, - net_id: str, - ) -> RoutingResult: - search = self.context.options.search - congestion = self.context.options.congestion - diagnostics = self.context.options.diagnostics - net = state.net_specs[net_id] - self.context.cost_evaluator.collision_engine.remove_path(net_id) - - if iteration == 0 and state.initial_paths and net_id in state.initial_paths: - path: Sequence[ComponentResult] | None = state.initial_paths[net_id] - 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" - - run_config = SearchRunConfig.from_options( - self.context.options, - 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, - ) - path = route_astar( - net.start, - net.target, - net.width, - context=self.context, - metrics=self.metrics, - net_id=net_id, - config=run_config, - ) - - if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: - state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) - - if not path: - return RoutingResult(net_id=net_id, path=(), reached_target=False) - - reached_target = path[-1].end_port == net.target - report = None - self._install_path(net_id, path) - if reached_target: - report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, path) - if report.self_collision_count > 0: - state.needs_self_collision_check.add(net_id) - - return RoutingResult( - net_id=net_id, - path=path, - reached_target=reached_target, - report=RoutingReport() if report is None else 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.context.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: - 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.context.options.congestion - for iteration in range(congestion.max_iterations): - outcomes = self._run_iteration(state, iteration, iteration_callback) - if outcomes is None: - return True - if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()): - return False - self.context.congestion_penalty *= congestion.multiplier - return False - - def _refine_results(self, state: _RoutingState) -> None: - if not self.context.options.refinement.enabled or not state.results: - return - - for net_id in state.ordered_net_ids: - result = state.results.get(net_id) - if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}: - continue - net = state.net_specs[net_id] - self.context.cost_evaluator.collision_engine.remove_path(net_id) - refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path) - self._install_path(net_id, refined_path) - report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path) - state.results[net_id] = RoutingResult( - net_id=net_id, - path=refined_path, - reached_target=result.reached_target, - report=report, - ) - - def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: - final_results: dict[str, RoutingResult] = {} - for net in self.context.problem.nets: - result = state.results.get(net.net_id) - if not result or not result.path: - final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False) - continue - report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path) - final_results[net.net_id] = RoutingResult( - net_id=net.net_id, - path=result.path, - reached_target=result.reached_target, - report=report, - ) - return final_results - - def route_all( - self, - *, - iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, - ) -> dict[str, RoutingResult]: - self.context.congestion_penalty = self.context.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) - return self._verify_results(state) diff --git a/inire/router/_search.py b/inire/router/_search.py deleted file mode 100644 index 2cf7daa..0000000 --- a/inire/router/_search.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import heapq -from typing import TYPE_CHECKING - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.primitives import Port - -from ._astar_moves import expand_moves as _expand_moves -from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig - -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - - -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", - config: SearchRunConfig, -) -> list[ComponentResult] | None: - if metrics is None: - metrics = AStarMetrics() - metrics.reset_per_route() - - 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, min_bend_radius=context.min_bend_radius), - ) - heapq.heappush(open_set, start_node) - best_node = start_node - nodes_expanded = 0 - - while open_set: - if nodes_expanded >= config.node_limit: - return _reconstruct_path(best_node) if config.return_partial else None - - current = heapq.heappop(open_set) - if config.max_cost is not None and current.fh_cost[0] > config.max_cost: - metrics.pruned_cost += 1 - metrics.total_pruned_cost += 1 - continue - - 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 config.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, - config=config, - ) - - return _reconstruct_path(best_node) if config.return_partial else None diff --git a/inire/router/_seed_materialization.py b/inire/router/_seed_materialization.py deleted file mode 100644 index f370db6..0000000 --- a/inire/router/_seed_materialization.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from inire.model import SearchOptions -from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed - -if TYPE_CHECKING: - from inire.geometry.components import ComponentResult - from inire.geometry.primitives import Port - - -def materialize_path_seed( - seed: PathSeed, - *, - start: Port, - net_width: float, - search: SearchOptions, - clearance: float, -) -> tuple[ComponentResult, ...]: - from inire.geometry.components import Bend90, SBend, Straight - - path: list[ComponentResult] = [] - current = start - dilation = clearance / 2.0 - bend_collision_type = search.bend_collision_type - bend_clip_margin = search.bend_clip_margin - - for segment in seed.segments: - if isinstance(segment, StraightSeed): - component = Straight.generate(current, segment.length, net_width, dilation=dilation) - elif isinstance(segment, Bend90Seed): - component = Bend90.generate( - current, - segment.radius, - net_width, - segment.direction, - collision_type=bend_collision_type, - clip_margin=bend_clip_margin, - dilation=dilation, - ) - elif isinstance(segment, SBendSeed): - component = SBend.generate( - current, - segment.offset, - segment.radius, - net_width, - collision_type=bend_collision_type, - clip_margin=bend_clip_margin, - dilation=dilation, - ) - else: - raise TypeError(f"Unsupported seed segment: {type(segment)!r}") - path.append(component) - current = component.end_port - return tuple(path) diff --git a/inire/router/_stack.py b/inire/router/_stack.py deleted file mode 100644 index 71aa119..0000000 --- a/inire/router/_stack.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from inire.model import RoutingOptions, RoutingProblem - - -@dataclass(frozen=True, slots=True) -class RoutingStack: - world: object - danger_map: object - evaluator: object - context: object - finder: object - - -def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack: - from inire.geometry.collision import RoutingWorld - from inire.router._astar_types import AStarContext - from inire.router._router import PathFinder - from inire.router.cost import CostEvaluator - from inire.router.danger_map import DangerMap - - world = RoutingWorld( - clearance=problem.clearance, - safety_zone_radius=problem.safety_zone_radius, - ) - for obstacle in problem.static_obstacles: - world.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=problem.bounds) - danger_map.precompute(list(problem.static_obstacles)) - - objective = options.objective - evaluator = CostEvaluator( - world, - danger_map, - unit_length_cost=objective.unit_length_cost, - greedy_h_weight=options.search.greedy_h_weight, - bend_penalty=objective.bend_penalty, - sbend_penalty=objective.sbend_penalty, - danger_weight=objective.danger_weight, - ) - context = AStarContext(evaluator, problem, options) - finder = PathFinder(context) - return RoutingStack( - world=world, - danger_map=danger_map, - evaluator=evaluator, - context=context, - finder=finder, - ) diff --git a/inire/router/astar.py b/inire/router/astar.py new file mode 100644 index 0000000..44e59da --- /dev/null +++ b/inire/router/astar.py @@ -0,0 +1,721 @@ +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.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.config = self.config + self.cost_evaluator._refresh_cached_config() + + 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() + + if bend_collision_type is not None: + context.config.bend_collision_type = 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, + 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], + max_cost: float | None = None, + skip_congestion: bool = False, + self_collision_check: bool = False, +) -> None: + 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, + 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, + 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, + 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, + 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, + max_cost: float | None = None, + self_collision_check: bool = False, +) -> None: + cp = parent.port + coll_type = context.config.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=context.config.bend_collision_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=context.config.bend_collision_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: + curr_p = parent + new_tb = result.total_bounds + while curr_p and curr_p.parent: + ancestor_res = curr_p.component_result + if ancestor_res: + anc_tb = ancestor_res.total_bounds + if new_tb[0] < anc_tb[2] and new_tb[2] > anc_tb[0] and new_tb[1] < anc_tb[3] and new_tb[3] > anc_tb[1]: + for p_anc in ancestor_res.geometry: + for p_new in result.geometry: + if p_new.intersects(p_anc) and not p_new.touches(p_anc): + return + curr_p = curr_p.parent + + 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 new file mode 100644 index 0000000..7a49a2f --- /dev/null +++ b/inire/router/config.py @@ -0,0 +1,46 @@ +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 + # Sparse Sampling Configuration + max_straight_length: float = 2000.0 + num_straight_samples: int = 5 + min_straight_length: float = 5.0 + + # Offsets for SBends (None = automatic grid-based selection) + sbend_offsets: list[float] | None = None + + # Deprecated but kept for compatibility during refactor + straight_lengths: list[float] = field(default_factory=list) + + bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0]) + sbend_radii: list[float] = field(default_factory=lambda: [10.0]) + snap_to_target_dist: float = 1000.0 + 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 c4b62c3..b4aa53e 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -1,15 +1,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.model import ObjectiveWeights +from inire.router.config import CostConfig if TYPE_CHECKING: - from inire.geometry.collision import RoutingWorld - from inire.geometry.components import ComponentResult, MoveKind + from shapely.geometry import Polygon + + from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap @@ -18,55 +19,63 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "_search_weights", - "_greedy_h_weight", + "config", + "unit_length_cost", + "greedy_h_weight", + "congestion_penalty", "_target_x", "_target_y", "_target_r", "_target_cos", "_target_sin", + "_min_radius", ) def __init__( self, - collision_engine: RoutingWorld, + collision_engine: CollisionEngine, 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, - danger_weight: float = 1.0, + min_bend_radius: float = 50.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._search_weights = ObjectiveWeights( + 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, - danger_weight=danger_weight, + min_bend_radius=min_bend_radius, ) - self._greedy_h_weight = float(greedy_h_weight) + 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._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - @property - def default_weights(self) -> ObjectiveWeights: - return self._search_weights - - @property - def greedy_h_weight(self) -> float: - return self._greedy_h_weight - - @greedy_h_weight.setter - def greedy_h_weight(self, value: float) -> None: - self._greedy_h_weight = float(value) - - def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights: - return self._search_weights if weights is None else weights + def _refresh_cached_config(self) -> None: + if hasattr(self.config, "min_bend_radius"): + self._min_radius = self.config.min_bend_radius + elif hasattr(self.config, "bend_radii") and self.config.bend_radii: + self._min_radius = min(self.config.bend_radii) + else: + self._min_radius = 50.0 + if hasattr(self.config, "unit_length_cost"): + self.unit_length_cost = self.config.unit_length_cost + if hasattr(self.config, "greedy_h_weight"): + self.greedy_h_weight = self.config.greedy_h_weight + if hasattr(self.config, "congestion_penalty"): + self.congestion_penalty = self.config.congestion_penalty def set_target(self, target: Port) -> None: self._target_x = target.x @@ -76,13 +85,12 @@ class CostEvaluator: self._target_cos = np.cos(rad) self._target_sin = np.sin(rad) - def h_manhattan( - self, - current: Port, - target: Port, - *, - min_bend_radius: float = 50.0, - ) -> float: + 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) + + def h_manhattan(self, current: Port, target: Port) -> float: tx, ty = target.x, target.y if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r: self.set_target(target) @@ -90,7 +98,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self._search_weights.bend_penalty + bp = self.config.bend_penalty penalty = 0.0 curr_r = current.r @@ -102,7 +110,7 @@ class CostEvaluator: v_dy = ty - current.y side_proj = v_dx * self._target_cos + v_dy * self._target_sin perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos) - if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0): + if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0): penalty += 2 * bp if curr_r == 0: @@ -120,74 +128,55 @@ 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 score_component( + def evaluate_move( self, - component: ComponentResult, - *, + geometry: list[Polygon] | None, + end_port: Port, + net_width: float, + net_id: str, start_port: Port | None = None, - weights: ObjectiveWeights | None = None, + length: float = 0.0, + dilated_geometry: list[Polygon] | None = None, + skip_static: bool = False, + skip_congestion: bool = False, + penalty: float = 0.0, ) -> float: - active_weights = self._resolve_weights(weights) + _ = net_width danger_map = self.danger_map - end_port = component.end_port if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - move_radius = None - if component.move_type == "bend90": - move_radius = component.length * 2.0 / np.pi if component.length > 0 else None - total_cost = component.length * active_weights.unit_length_cost + self.component_penalty( - component.move_type, - move_radius=move_radius, - weights=active_weights, - ) + total_cost = length * self.unit_length_cost + penalty + if not skip_static or not skip_congestion: + if geometry is None: + return 1e15 + collision_engine = self.collision_engine + for i, poly in enumerate(geometry): + dil_poly = dilated_geometry[i] if dilated_geometry else None + if not skip_static and collision_engine.check_collision( + poly, + net_id, + buffer_mode="static", + start_port=start_port, + end_port=end_port, + dilated_geometry=dil_poly, + ): + return 1e15 + if not skip_congestion: + overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly) + if isinstance(overlaps, int) and overlaps > 0: + total_cost += overlaps * self.congestion_penalty - if danger_map is not None and active_weights.danger_weight: + if danger_map is not None: 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 += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 + total_cost += length * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += component.length * active_weights.danger_weight * cost_e + total_cost += length * 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._resolve_weights(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, - start_port: Port, - path: list[ComponentResult], - *, - weights: ObjectiveWeights | None = None, - ) -> float: - active_weights = self._resolve_weights(weights) - total = 0.0 - current_port = start_port - for component in path: - total += self.score_component( - component, - start_port=current_port, - 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 03b8a2a..4ade2f9 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -1,24 +1,21 @@ 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', '_cost_cache') + __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree') def __init__( self, @@ -41,7 +38,6 @@ 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: """ @@ -68,8 +64,9 @@ class DangerMap: self.tree = cKDTree(numpy.array(all_points)) else: self.tree = None - - self._cost_cache.clear() + + # Clear cache when tree changes + self._get_cost_quantized.cache_clear() def is_within_bounds(self, x: float, y: float) -> bool: """ @@ -84,18 +81,10 @@ class DangerMap: """ qx_milli = int(round(x * 1000)) qy_milli = int(round(y * 1000)) - key = (qx_milli, qy_milli) - if key in self._cost_cache: - self._cost_cache.move_to_end(key) - return self._cost_cache[key] + return self._get_cost_quantized(qx_milli, qy_milli) - 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: + @lru_cache(maxsize=100000) + def _get_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/pathfinder.py b/inire/router/pathfinder.py new file mode 100644 index 0000000..30aca20 --- /dev/null +++ b/inire/router/pathfinder.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import logging +import math +import random +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Literal + +import numpy + +from inire.geometry.components import Bend90, Straight +from inire.router.astar import AStarMetrics, route_astar + +if TYPE_CHECKING: + 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 + + +class PathFinder: + __slots__ = ( + "context", + "metrics", + "max_iterations", + "base_congestion_penalty", + "use_tiered_strategy", + "congestion_multiplier", + "accumulated_expanded_nodes", + "warm_start", + "refine_paths", + ) + + 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 = False, + ) -> 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.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] + + @property + def cost_evaluator(self) -> CostEvaluator: + return self.context.cost_evaluator + + def _perform_greedy_pass( + 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 + for res in path: + geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry + dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry + for i, poly in enumerate(geoms): + dilated = dilated_geoms[i] if dilated_geoms else None + obj_id = self.cost_evaluator.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated) + temp_obj_ids.append(obj_id) + self.context.clear_static_caches() + + for obj_id in temp_obj_ids: + self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id) + return greedy_paths + + def _has_self_collision(self, path: list[ComponentResult]) -> bool: + for i, comp_i in enumerate(path): + tb_i = comp_i.total_bounds + for j in range(i + 2, len(path)): + comp_j = path[j] + tb_j = comp_j.total_bounds + if tb_i[0] < tb_j[2] and tb_i[2] > tb_j[0] and tb_i[1] < tb_j[3] and tb_i[3] > tb_j[1]: + for p_i in comp_i.geometry: + for p_j in comp_j.geometry: + if p_i.intersects(p_j) and not p_i.touches(p_j): + return True + return False + + 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 _extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]: + all_geoms = [] + all_dilated = [] + for res in path: + all_geoms.extend(res.geometry) + if res.dilated_geometry: + all_dilated.extend(res.dilated_geometry) + else: + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + all_dilated.extend([p.buffer(dilation) for p in res.geometry]) + return all_geoms, all_dilated + + def _to_local(self, start: Port, point: Port) -> tuple[int, int]: + dx = point.x - start.x + dy = point.y - start.y + if start.r == 0: + return dx, dy + if start.r == 90: + return dy, -dx + if start.r == 180: + return -dx, -dy + return -dy, dx + + def _build_same_orientation_dogleg( + self, + start: Port, + target: Port, + net_width: float, + radius: float, + side_extent: float, + ) -> list[ComponentResult] | None: + local_dx, local_dy = self._to_local(start, target) + if abs(local_dy) > 0 or local_dx < 4.0 * radius - 0.01: + return None + + side_abs = abs(side_extent) + side_length = side_abs - 2.0 * radius + if side_length < self.context.config.min_straight_length - 0.01: + return None + + forward_length = local_dx - 4.0 * radius + if forward_length < -0.01: + return None + + first_dir = "CCW" if side_extent > 0 else "CW" + second_dir = "CW" if side_extent > 0 else "CCW" + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + + path: list[ComponentResult] = [] + curr = start + + for direction, straight_len in ( + (first_dir, side_length), + (second_dir, forward_length), + (second_dir, side_length), + (first_dir, None), + ): + bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) + path.append(bend) + curr = bend.end_port + if straight_len is None: + continue + if straight_len > 0.01: + straight = Straight.generate(curr, straight_len, net_width, dilation=dilation) + path.append(straight) + curr = straight.end_port + + if curr != target: + return None + return path + + def _refine_path( + self, + net_id: str, + start: Port, + target: Port, + net_width: float, + path: list[ComponentResult], + ) -> list[ComponentResult]: + if not path or start.r != target.r: + return path + + bend_count = sum(1 for comp in path if comp.move_type == "Bend90") + if bend_count < 5: + return path + + side_extents = [] + local_points = [self._to_local(start, start)] + local_points.extend(self._to_local(start, comp.end_port) for comp in path) + min_side = min(point[1] for point in local_points) + max_side = max(point[1] for point in local_points) + if min_side < -0.01: + side_extents.append(float(min_side)) + if max_side > 0.01: + side_extents.append(float(max_side)) + if not side_extents: + return path + + best_path = path + best_cost = self._path_cost(path) + collision_engine = self.cost_evaluator.collision_engine + + for radius in self.context.config.bend_radii: + for side_extent in side_extents: + candidate = self._build_same_orientation_dogleg(start, target, net_width, radius, side_extent) + if candidate is None: + continue + is_valid, collisions = collision_engine.verify_path(net_id, candidate) + if not is_valid or collisions != 0: + continue + candidate_cost = self._path_cost(candidate) + if candidate_cost + 1e-6 < best_cost: + best_cost = candidate_cost + best_path = candidate + + return best_path + + 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]: + results: dict[str, RoutingResult] = {} + self.cost_evaluator.congestion_penalty = self.base_congestion_penalty + self.accumulated_expanded_nodes = [] + self.metrics.reset_per_route() + + start_time = time.monotonic() + num_nets = len(netlist) + session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations) + all_net_ids = list(netlist.keys()) + needs_sc: set[str] = set() + + if initial_paths is None: + ws_order = sort_nets if sort_nets is not None else self.warm_start + if ws_order is not None: + initial_paths = self._perform_greedy_pass(netlist, net_widths, ws_order) + self.context.clear_static_caches() + + if sort_nets and sort_nets != "user": + all_net_ids.sort( + key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y), + reverse=(sort_nets == "longest"), + ) + + for iteration in range(self.max_iterations): + any_congestion = False + self.accumulated_expanded_nodes = [] + self.metrics.reset_per_route() + + if shuffle_nets and (iteration > 0 or initial_paths is None): + it_seed = (seed + iteration) if seed is not None else None + random.Random(it_seed).shuffle(all_net_ids) + + for net_id in all_net_ids: + start, target = netlist[net_id] + if time.monotonic() - start_time > session_timeout: + self.cost_evaluator.collision_engine.dynamic_tree = None + self.cost_evaluator.collision_engine._ensure_dynamic_tree() + return self.verify_all_nets(results, netlist) + + width = net_widths.get(net_id, 2.0) + self.cost_evaluator.collision_engine.remove_path(net_id) + path: list[ComponentResult] | None = None + + if iteration == 0 and initial_paths and net_id in initial_paths: + path = initial_paths[net_id] + else: + target_coll_model = self.context.config.bend_collision_type + coll_model = target_coll_model + skip_cong = False + if self.use_tiered_strategy and iteration == 0: + skip_cong = True + if target_coll_model == "arc": + coll_model = "clipped_bbox" + + path = route_astar( + start, + target, + width, + context=self.context, + metrics=self.metrics, + net_id=net_id, + bend_collision_type=coll_model, + return_partial=True, + store_expanded=store_expanded, + skip_congestion=skip_cong, + self_collision_check=(net_id in needs_sc), + node_limit=self.context.config.node_limit, + ) + + if store_expanded and self.metrics.last_expanded_nodes: + self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) + + if not path: + results[net_id] = RoutingResult(net_id, [], False, 0, reached_target=False) + any_congestion = True + continue + + last_p = path[-1].end_port + reached = last_p == target + + if reached and net_id not in needs_sc and self._has_self_collision(path): + needs_sc.add(net_id) + any_congestion = True + + all_geoms = [] + all_dilated = [] + for res in path: + all_geoms.extend(res.geometry) + if res.dilated_geometry: + all_dilated.extend(res.dilated_geometry) + else: + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + all_dilated.extend([p.buffer(dilation) for p in res.geometry]) + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + + collision_count = 0 + if reached: + is_valid, collision_count = self.cost_evaluator.collision_engine.verify_path(net_id, path) + any_congestion = any_congestion or not is_valid + + results[net_id] = RoutingResult(net_id, path, reached and collision_count == 0, collision_count, reached_target=reached) + + if iteration_callback: + iteration_callback(iteration, results) + if not any_congestion: + break + self.cost_evaluator.congestion_penalty *= self.congestion_multiplier + + if self.refine_paths and results: + for net_id in all_net_ids: + res = results.get(net_id) + if not res or not res.path or not res.reached_target or not res.is_valid: + continue + start, target = netlist[net_id] + width = net_widths.get(net_id, 2.0) + self.cost_evaluator.collision_engine.remove_path(net_id) + refined_path = self._refine_path(net_id, start, target, width, res.path) + all_geoms, all_dilated = self._extract_geometry(refined_path) + self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated) + results[net_id] = RoutingResult( + net_id=net_id, + path=refined_path, + is_valid=res.is_valid, + collisions=res.collisions, + reached_target=res.reached_target, + ) + + self.cost_evaluator.collision_engine.dynamic_tree = None + self.cost_evaluator.collision_engine._ensure_dynamic_tree() + return self.verify_all_nets(results, netlist) + + 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] = RoutingResult(net_id, [], False, 0) + continue + last_p = res.path[-1].end_port + reached = last_p == target_p + is_valid, collisions = self.cost_evaluator.collision_engine.verify_path(net_id, res.path) + final_results[net_id] = RoutingResult( + net_id=net_id, + path=res.path, + is_valid=(is_valid and reached), + collisions=collisions, + reached_target=reached, + ) + return final_results diff --git a/inire/router/refiner.py b/inire/router/refiner.py deleted file mode 100644 index 6aa5d1f..0000000 --- a/inire/router/refiner.py +++ /dev/null @@ -1,317 +0,0 @@ -from __future__ import annotations - -import math -from typing import TYPE_CHECKING, Any - -from inire.geometry.component_overlap import components_overlap -from inire.geometry.components import Bend90, Straight - -if TYPE_CHECKING: - 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_types import AStarContext - - -def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: - current = parent_node - while current and current.parent: - ancestor_component = current.component_result - if ancestor_component and components_overlap(component, ancestor_component): - return True - current = current.parent - return False - - -class PathRefiner: - __slots__ = ("context",) - - def __init__(self, context: AStarContext) -> None: - self.context = context - - @property - def collision_engine(self) -> RoutingWorld: - return self.context.cost_evaluator.collision_engine - - def path_cost( - self, - path: Sequence[ComponentResult], - *, - 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(actual_start, path) - - def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float: - weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights - return self.context.cost_evaluator.path_cost(start, path, weights=weights) - - def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: - ports = [start] - ports.extend(comp.end_port for comp in path) - return ports - - def _to_local(self, start: Port, point: Port) -> tuple[int, int]: - dx = point.x - start.x - dy = point.y - start.y - if start.r == 0: - return dx, dy - if start.r == 90: - return dy, -dx - if start.r == 180: - return -dx, -dy - return -dy, dx - - def _to_local_xy(self, start: Port, x: float, y: float) -> tuple[float, float]: - dx = float(x) - start.x - dy = float(y) - start.y - if start.r == 0: - return dx, dy - if start.r == 90: - return dy, -dx - if start.r == 180: - return -dx, -dy - return -dy, dx - - def _window_query_bounds(self, start: Port, target: Port, path: 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)) - max_y = float(max(start.y, target.y)) - for comp in path: - bounds = comp.total_bounds - min_x = min(min_x, bounds[0]) - min_y = min(min_y, bounds[1]) - max_x = max(max_x, bounds[2]) - max_y = max(max_y, bounds[3]) - return (min_x - pad, min_y - pad, max_x + pad, max_y + pad) - - def _candidate_side_extents( - self, - start: Port, - target: Port, - window_path: Sequence[ComponentResult], - net_width: float, - radius: float, - ) -> list[float]: - local_dx, local_dy = self._to_local(start, target) - if local_dx < 4.0 * radius - 0.01: - return [] - - local_points = [self._to_local(start, start)] - local_points.extend(self._to_local(start, comp.end_port) for comp in window_path) - min_side = float(min(point[1] for point in local_points)) - max_side = float(max(point[1] for point in local_points)) - - positive_anchors: set[float] = set() - negative_anchors: set[float] = set() - direct_extents: set[float] = set() - - if max_side > 0.01: - positive_anchors.add(max_side) - direct_extents.add(max_side) - if min_side < -0.01: - negative_anchors.add(min_side) - direct_extents.add(min_side) - if local_dy > 0: - positive_anchors.add(float(local_dy)) - elif local_dy < 0: - negative_anchors.add(float(local_dy)) - - pad = 2.0 * radius + self.collision_engine.clearance + net_width - query_bounds = self._window_query_bounds(start, target, window_path, pad) - x_min = min(0.0, float(local_dx)) - 0.01 - x_max = max(0.0, float(local_dx)) + 0.01 - - for bounds in self.collision_engine.iter_static_obstacle_bounds(query_bounds): - local_corners = ( - self._to_local_xy(start, bounds[0], bounds[1]), - self._to_local_xy(start, bounds[0], bounds[3]), - self._to_local_xy(start, bounds[2], bounds[1]), - self._to_local_xy(start, bounds[2], bounds[3]), - ) - obs_min_x = min(pt[0] for pt in local_corners) - obs_max_x = max(pt[0] for pt in local_corners) - if obs_max_x < x_min or obs_min_x > x_max: - continue - obs_min_y = min(pt[1] for pt in local_corners) - obs_max_y = max(pt[1] for pt in local_corners) - positive_anchors.add(obs_max_y) - negative_anchors.add(obs_min_y) - - for bounds in self.collision_engine.iter_dynamic_path_bounds(query_bounds): - local_corners = ( - self._to_local_xy(start, bounds[0], bounds[1]), - self._to_local_xy(start, bounds[0], bounds[3]), - self._to_local_xy(start, bounds[2], bounds[1]), - self._to_local_xy(start, bounds[2], bounds[3]), - ) - obs_min_x = min(pt[0] for pt in local_corners) - obs_max_x = max(pt[0] for pt in local_corners) - if obs_max_x < x_min or obs_min_x > x_max: - continue - obs_min_y = min(pt[1] for pt in local_corners) - obs_max_y = max(pt[1] for pt in local_corners) - positive_anchors.add(obs_max_y) - negative_anchors.add(obs_min_y) - - for anchor in tuple(positive_anchors): - if anchor > max(0.0, float(local_dy)) - 0.01: - direct_extents.add(anchor + pad) - for anchor in tuple(negative_anchors): - if anchor < min(0.0, float(local_dy)) + 0.01: - direct_extents.add(anchor - pad) - - return sorted(direct_extents, key=lambda value: (abs(value), value)) - - def _build_same_orientation_dogleg( - self, - start: Port, - target: Port, - net_width: float, - radius: float, - side_extent: float, - ) -> list[ComponentResult] | None: - local_dx, local_dy = self._to_local(start, target) - if local_dx < 4.0 * radius - 0.01 or abs(side_extent) < 0.01: - return None - - side_abs = abs(side_extent) - first_straight = side_abs - 2.0 * radius - second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent) - if first_straight < -0.01 or second_straight < -0.01: - return None - min_straight = self.context.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: - return None - - forward_length = local_dx - 4.0 * radius - if forward_length < -0.01: - return None - if 0.01 < forward_length < min_straight - 0.01: - return None - - first_dir = "CCW" if side_extent > 0 else "CW" - second_dir = "CW" if side_extent > 0 else "CCW" - dilation = self.collision_engine.clearance / 2.0 - - path: list[ComponentResult] = [] - curr = start - - for direction, straight_len in ( - (first_dir, first_straight), - (second_dir, forward_length), - (second_dir, second_straight), - (first_dir, None), - ): - bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation) - path.append(bend) - curr = bend.end_port - if straight_len is None: - continue - if straight_len > 0.01: - straight = Straight.generate(curr, straight_len, net_width, dilation=dilation) - path.append(straight) - curr = straight.end_port - - if curr != target: - return None - return path - - def _iter_refinement_windows(self, start: Port, path: Sequence[ComponentResult]) -> list[tuple[int, int]]: - ports = self._path_ports(start, path) - windows: list[tuple[int, int]] = [] - 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") - if bend_count < 4: - continue - window_start = ports[start_idx] - window_end = ports[end_idx] - if window_start.r != window_end.r: - continue - local_dx, _ = self._to_local(window_start, window_end) - if local_dx < 4.0 * min_radius - 0.01: - continue - windows.append((start_idx, end_idx)) - return windows - - def _try_refine_window( - self, - net_id: str, - start: Port, - net_width: float, - path: list[ComponentResult], - start_idx: int, - end_idx: int, - best_cost: float, - ) -> tuple[list[ComponentResult], float] | None: - ports = self._path_ports(start, path) - window_start = ports[start_idx] - window_end = ports[end_idx] - window_path = path[start_idx:end_idx] - - best_path: list[ComponentResult] | None = None - best_candidate_cost = best_cost - - for radius in self.context.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) - if replacement is None: - continue - candidate_path = path[:start_idx] + replacement + path[end_idx:] - report = self.collision_engine.verify_path_report(net_id, candidate_path) - if not report.is_valid: - continue - candidate_cost = self.path_cost(candidate_path) - if candidate_cost + 1e-6 < best_candidate_cost: - best_candidate_cost = candidate_cost - best_path = candidate_path - - if best_path is None: - return None - return best_path, best_candidate_cost - - def refine_path( - self, - net_id: str, - start: Port, - net_width: float, - path: list[ComponentResult], - ) -> list[ComponentResult]: - if not path: - return path - - 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.score_path(start, path) - - for _ in range(3): - improved = False - for start_idx, end_idx in self._iter_refinement_windows(start, best_path): - refined = self._try_refine_window(net_id, start, net_width, best_path, start_idx, end_idx, best_cost) - if refined is None: - continue - best_path, best_cost = refined - improved = True - break - if not improved: - break - - return best_path diff --git a/inire/router/results.py b/inire/router/results.py deleted file mode 100644 index a9d3c1f..0000000 --- a/inire/router/results.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Semi-private compatibility exports for router result types. - -These deep-module imports remain accessible for advanced use, but they are -unstable and may change without notice. Prefer importing public result types -from ``inire`` or ``inire.results``. -""" - -from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult - -__all__ = [ - "RouteMetrics", - "RoutingOutcome", - "RoutingReport", - "RoutingResult", - "RoutingRunResult", -] diff --git a/inire/router/visibility.py b/inire/router/visibility.py index ed83d00..d5fa61d 100644 --- a/inire/router/visibility.py +++ b/inire/router/visibility.py @@ -2,28 +2,28 @@ from __future__ import annotations import numpy from typing import TYPE_CHECKING - import rtree +from shapely.geometry import Point, LineString if TYPE_CHECKING: - from inire.geometry.collision import RoutingWorld + from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.geometry.primitives import Port +from inire.geometry.primitives import Port class VisibilityManager: """ Manages corners of static obstacles for sparse A* / Visibility Graph jumps. """ - __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version") + __slots__ = ('collision_engine', 'corners', 'corner_index', '_corner_graph', '_static_visibility_cache', '_built_static_version') - def __init__(self, collision_engine: RoutingWorld) -> None: + def __init__(self, collision_engine: CollisionEngine) -> None: self.collision_engine = collision_engine self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() self._corner_graph: dict[int, list[tuple[float, float, float]]] = {} - self._point_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} + self._static_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} self._built_static_version = -1 self._build() @@ -34,20 +34,20 @@ class VisibilityManager: self.corners = [] self.corner_index = rtree.index.Index() self._corner_graph = {} - self._point_visibility_cache = {} + self._static_visibility_cache = {} self._build() def _ensure_current(self) -> None: - if self._built_static_version != self.collision_engine.get_static_version(): + if self._built_static_version != self.collision_engine._static_version: self.clear_cache() def _build(self) -> None: """ Extract corners and pre-compute corner-to-corner visibility. """ - self._built_static_version = self.collision_engine.get_static_version() + self._built_static_version = self.collision_engine._static_version raw_corners = [] - for poly in self.collision_engine.iter_static_dilated_geometries(): + for obj_id, poly in self.collision_engine.static_dilated.items(): coords = list(poly.exterior.coords) if coords[0] == coords[-1]: coords = coords[:-1] @@ -83,8 +83,7 @@ class VisibilityManager: self._corner_graph[i] = [] p1 = Port(self.corners[i][0], self.corners[i][1], 0) for j in range(num_corners): - if i == j: - continue + if i == j: continue cx, cy = self.corners[j] dx, dy = cx - p1.x, cy - p1.y dist = numpy.sqrt(dx**2 + dy**2) @@ -93,51 +92,53 @@ class VisibilityManager: if reach >= dist - 0.01: self._corner_graph[i].append((cx, cy, dist)) - def _corner_idx_at(self, origin: Port) -> int | None: - ox, oy = round(origin.x, 3), round(origin.y, 3) - nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) - for idx in nearby: - cx, cy = self.corners[idx] - if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4: - return idx - return None - - def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: + def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: """ - Find visible corners from an arbitrary point. - This may perform direct ray-cast scans and is not intended for hot search paths. + Find all corners visible from the origin. + Returns list of (x, y, distance). """ self._ensure_current() if max_dist < 0: return [] - - corner_idx = self._corner_idx_at(origin) - if corner_idx is not None and corner_idx in self._corner_graph: - return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] - + ox, oy = round(origin.x, 3), round(origin.y, 3) - cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000))) - if cache_key in self._point_visibility_cache: - return self._point_visibility_cache[cache_key] + + # 1. Exact corner check + # Use spatial index to find if origin is AT a corner + nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) + for idx in nearby: + cx, cy = self.corners[idx] + if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4: + # We are at a corner! Return pre-computed graph (filtered by max_dist) + if idx in self._corner_graph: + return [c for c in self._corner_graph[idx] if c[2] <= max_dist] + # 2. Cache check for arbitrary points + # Grid-based caching for arbitrary points is tricky, + # but since static obstacles don't change, we can cache exact coordinates. + cache_key = (int(ox * 1000), int(oy * 1000)) + if cache_key in self._static_visibility_cache: + return self._static_visibility_cache[cache_key] + + # 3. Full visibility check bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist) candidates = list(self.corner_index.intersection(bounds)) - + visible = [] for i in candidates: cx, cy = self.corners[i] dx, dy = cx - origin.x, cy - origin.y dist = numpy.sqrt(dx**2 + dy**2) - + if dist > max_dist or dist < 1e-3: continue - + angle = numpy.degrees(numpy.arctan2(dy, dx)) reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05) if reach >= dist - 0.01: visible.append((cx, cy, dist)) - - self._point_visibility_cache[cache_key] = visible + + self._static_visibility_cache[cache_key] = visible return visible def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: @@ -149,7 +150,10 @@ class VisibilityManager: if max_dist < 0: return [] - corner_idx = self._corner_idx_at(origin) - if corner_idx is not None and corner_idx in self._corner_graph: - return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist] + ox, oy = round(origin.x, 3), round(origin.y, 3) + nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001))) + for idx in nearby: + cx, cy = self.corners[idx] + if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4 and idx in self._corner_graph: + return [corner for corner in self._corner_graph[idx] if corner[2] <= max_dist] return [] diff --git a/inire/seeds.py b/inire/seeds.py deleted file mode 100644 index 635e489..0000000 --- a/inire/seeds.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - - -BendDirection = Literal["CW", "CCW"] - - -@dataclass(frozen=True, slots=True) -class StraightSeed: - length: float - - def __post_init__(self) -> None: - object.__setattr__(self, "length", float(self.length)) - - -@dataclass(frozen=True, slots=True) -class Bend90Seed: - radius: float - direction: BendDirection - - def __post_init__(self) -> None: - object.__setattr__(self, "radius", float(self.radius)) - - -@dataclass(frozen=True, slots=True) -class SBendSeed: - offset: float - radius: float - - def __post_init__(self) -> None: - object.__setattr__(self, "offset", float(self.offset)) - object.__setattr__(self, "radius", float(self.radius)) - - -PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed - - -@dataclass(frozen=True, slots=True) -class PathSeed: - segments: tuple[PathSegmentSeed, ...] - - def __post_init__(self) -> None: - segments = tuple(self.segments) - if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments): - raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances") - object.__setattr__(self, "segments", segments) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py new file mode 100644 index 0000000..d13becd --- /dev/null +++ b/inire/tests/benchmark_scaling.py @@ -0,0 +1,57 @@ +import time +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, AStarMetrics +from inire.router.pathfinder import PathFinder + +def benchmark_scaling() -> None: + print("Starting Scalability Benchmark...") + + # 1. Memory Verification (20x20mm) + # Resolution 1um -> 20000 x 20000 grid + bounds = (0, 0, 20000, 20000) + print(f"Initializing DangerMap for {bounds} area...") + dm = DangerMap(bounds=bounds, resolution=1.0) + # nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB + mem_gb = dm.grid.nbytes / (1024**3) + print(f"DangerMap memory usage: {mem_gb:.2f} GB") + assert mem_gb < 2.0 + + # 2. Node Expansion Rate (50 nets) + engine = CollisionEngine(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)) + + print(f"Routing {num_nets} nets...") + start_time = time.monotonic() + results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0)) + end_time = time.monotonic() + + total_time = end_time - start_time + print(f"Total routing time: {total_time:.2f} s") + print(f"Time per net: {total_time/num_nets:.4f} s") + + if total_time > 0: + nodes_per_sec = metrics.total_nodes_expanded / total_time + print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s") + + # Success rate + successes = sum(1 for r in results.values() if r.is_valid) + print(f"Success rate: {successes/num_nets * 100:.1f}%") + +if __name__ == "__main__": + benchmark_scaling() diff --git a/inire/tests/example_scenarios.py b/inire/tests/example_scenarios.py index 06619c4..40ccdc0 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,120 +1,44 @@ from __future__ import annotations +from dataclasses import dataclass from time import perf_counter from typing import Callable from shapely.geometry import Polygon, box -from inire import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - RoutingResult, - SearchOptions, -) -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext, AStarMetrics -from inire.router._router import PathFinder +from inire.router.astar import AStarContext, AStarMetrics from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap - -_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__) - -ScenarioOutcome = tuple[float, int, int, int] -ScenarioRun = Callable[[], ScenarioOutcome] +from inire.router.pathfinder import PathFinder, RoutingResult -def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: - return ( - duration_s, - len(results), - sum(1 for result in results.values() if result.is_valid), - sum(1 for result in results.values() if result.reached_target), - ) +@dataclass(frozen=True) +class ScenarioOutcome: + duration_s: float + total_results: int + valid_results: int + reached_targets: int -def _build_evaluator( - bounds: tuple[float, float, float, float], - *, - clearance: float = 2.0, - obstacles: list[Polygon] | None = None, - bend_penalty: float = 50.0, - sbend_penalty: float = 150.0, -) -> CostEvaluator: - static_obstacles = obstacles or [] - engine = RoutingWorld(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(static_obstacles) - return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty) +@dataclass(frozen=True) +class ScenarioDefinition: + name: str + run: Callable[[], ScenarioOutcome] -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_options(**overrides: object) -> RoutingOptions: - search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} - congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} - refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} - diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} - objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} - return RoutingOptions( - search=SearchOptions(**search_overrides), - congestion=CongestionOptions(**congestion_overrides), - refinement=RefinementOptions(**refinement_overrides), - diagnostics=DiagnosticsOptions(**diagnostics_overrides), - objective=ObjectiveWeights(**objective_overrides), - ) - - -def _build_pathfinder( - evaluator: CostEvaluator, +def _build_router( *, bounds: tuple[float, float, float, float], - nets: tuple[NetSpec, ...], - metrics: AStarMetrics | None = None, - **request_kwargs: object, -) -> PathFinder: - return PathFinder( - AStarContext( - evaluator, - RoutingProblem(bounds=bounds, nets=nets), - _build_options(**request_kwargs), - ), - metrics=metrics, - ) - - -def _build_routing_stack( - *, - bounds: tuple[float, float, float, float], - 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]: + 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 = RoutingWorld(clearance=clearance) + engine = CollisionEngine(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) @@ -122,126 +46,107 @@ def _build_routing_stack( danger_map.precompute(static_obstacles) evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) + context = AStarContext(evaluator, **(context_kwargs or {})) metrics = AStarMetrics() - pathfinder = _build_pathfinder( - evaluator, - bounds=bounds, - nets=_net_specs(netlist, widths), - metrics=metrics, - **(request_kwargs or {}), + 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, + total_results=len(results), + valid_results=sum(1 for result in results.values() if result.is_valid), + reached_targets=sum(1 for result in results.values() if result.reached_target), ) - return 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() + results = pathfinder.route_all(netlist, {"net1": 2.0}) 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() + results = pathfinder.route_all(netlist, widths) t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_03() -> ScenarioOutcome: - 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]}, - ) + engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]}) t0 = perf_counter() - results_a = pathfinder.route_all() - for polygon in results_a["netA"].locked_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() + 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}) t1 = perf_counter() return _summarize({**results_a, **results_b}, t1 - t0) def run_example_04() -> ScenarioOutcome: - 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( + _, _, _, _, pathfinder = _build_router( 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={ + 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} t0 = perf_counter() - results = pathfinder.route_all() + results = pathfinder.route_all(netlist, widths) 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() + results = pathfinder.route_all(netlist, widths) t1 = perf_counter() return _summarize(results, t1 - t0) @@ -253,42 +158,35 @@ def run_example_06() -> ScenarioOutcome: box(40, 60, 60, 80), box(40, 10, 60, 30), ] - scenarios = [ - ( - _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}, - ), - ( - _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}, - ), - ( - _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", - "bend_clip_margin": 1.0, - "use_tiered_strategy": False, - }, - ), + engine = CollisionEngine(clearance=2.0) + for obstacle in obstacles: + engine.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=bounds) + danger_map.precompute(obstacles) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + + contexts = [ + AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc"), + AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox"), + AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0), + ] + netlists = [ + {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}, + {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}, + {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}, + ] + widths = [ + {"arc_model": 2.0}, + {"bbox_model": 2.0}, + {"clipped_model": 2.0}, ] t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} - 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()) + for context, netlist, net_widths in zip(contexts, netlists, widths, strict=True): + pathfinder = PathFinder(context, use_tiered_strategy=False) + combined_results.update(pathfinder.route_all(netlist, net_widths)) t1 = perf_counter() return _summarize(combined_results, t1 - t0) @@ -299,6 +197,29 @@ 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 @@ -311,74 +232,49 @@ 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], - "bend_clip_margin": 10.0, - "max_iterations": 15, - "base_penalty": 100.0, - "multiplier": 1.4, - "net_order": "shortest", - "capture_expanded": True, - "shuffle_nets": True, - "seed": 42, - }, - ) def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: - _ = current_results new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) evaluator.greedy_h_weight = new_greedy metrics.reset_per_route() t0 = perf_counter() - results = pathfinder.route_all(iteration_callback=iteration_callback) + results = pathfinder.route_all( + netlist, + dict.fromkeys(netlist, 2.0), + store_expanded=True, + iteration_callback=iteration_callback, + shuffle_nets=True, + seed=42, + ) t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_08() -> ScenarioOutcome: bounds = (0, 0, 150, 150) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) + metrics = AStarMetrics() netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} widths = {"custom_bend": 2.0} - custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) - evaluator = _build_evaluator(bounds) + + context_std = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[]) + context_custom = AStarContext( + evaluator, + bend_radii=[10.0], + bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), + sbend_radii=[], + ) t0 = perf_counter() - results_std = _build_pathfinder( - evaluator, - bounds=bounds, - nets=_net_specs(netlist, widths), - bend_radii=[10.0], - sbend_radii=[], - max_iterations=1, - metrics=AStarMetrics(), - ).route_all() - results_custom = _build_pathfinder( - evaluator, - bounds=bounds, - nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), - bend_radii=[10.0], - bend_collision_type=custom_model, - sbend_radii=[], - max_iterations=1, - use_tiered_strategy=False, - metrics=AStarMetrics(), - ).route_all() + results_std = PathFinder(context_std, metrics).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}, + ) t1 = perf_counter() return _summarize({**results_std, **results_custom}, t1 - t0) @@ -388,30 +284,28 @@ def run_example_09() -> ScenarioOutcome: box(35, 35, 45, 65), box(55, 35, 65, 65), ] - netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} - widths = {"budget_limited_net": 2.0} - _, _, _, pathfinder = _build_routing_stack( + _, _, _, _, pathfinder = _build_router( bounds=(0, 0, 100, 100), - netlist=netlist, - widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1}, + context_kwargs={"node_limit": 3, "bend_radii": [10.0]}, + pathfinder_kwargs={"warm_start": None}, ) + netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} t0 = perf_counter() - results = pathfinder.route_all() + results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0}) t1 = perf_counter() return _summarize(results, t1 - t0) -SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = ( - ("example_01_simple_route", run_example_01), - ("example_02_congestion_resolution", run_example_02), - ("example_03_locked_paths", run_example_03), - ("example_04_sbends_and_radii", run_example_04), - ("example_05_orientation_stress", run_example_05), - ("example_06_bend_collision_models", run_example_06), - ("example_07_large_scale_routing", run_example_07), - ("example_08_custom_bend_geometry", run_example_08), - ("example_09_unroutable_best_effort", run_example_09), +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_04_sbends_and_radii", run_example_04), + ScenarioDefinition("example_05_orientation_stress", run_example_05), + ScenarioDefinition("example_06_bend_collision_models", run_example_06), + ScenarioDefinition("example_07_large_scale_routing", run_example_07), + ScenarioDefinition("example_08_custom_bend_geometry", run_example_08), + ScenarioDefinition("example_09_unroutable_best_effort", run_example_09), ) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py deleted file mode 100644 index 858cac9..0000000 --- a/inire/tests/test_api.py +++ /dev/null @@ -1,139 +0,0 @@ -import importlib - -import pytest -from shapely.geometry import box - -from inire import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - Port, - RefinementOptions, - RoutingOptions, - RoutingProblem, - SearchOptions, - route, -) -from inire.geometry.components import Straight - - -def test_root_module_exports_only_stable_surface() -> None: - import inire - - assert not hasattr(inire, "RoutingWorld") - assert not hasattr(inire, "AStarContext") - assert not hasattr(inire, "PathFinder") - assert not hasattr(inire, "CostEvaluator") - assert not hasattr(inire, "DangerMap") - - -def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None: - router_module = importlib.import_module("inire.router._router") - search_module = importlib.import_module("inire.router._search") - collision_module = importlib.import_module("inire.geometry.collision") - - assert hasattr(router_module, "PathFinder") - assert hasattr(search_module, "route_astar") - assert hasattr(collision_module, "RoutingWorld") - - -def test_route_problem_smoke() -> None: - problem = RoutingProblem( - bounds=(0, 0, 100, 100), - 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_enabled=False), - 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),), - static_obstacles=tuple(polygon for component in locked for polygon in component.physical_geometry), - ) - options = RoutingOptions( - congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), - 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),), - static_obstacles=results_a.results_by_net["netA"].locked_geometry, - ) - results_b = route(problem_b, options=options) - - assert results_b.results_by_net["netB"].is_valid - - -def test_route_problem_rejects_untyped_initial_paths() -> None: - with pytest.raises(TypeError): - RoutingProblem( - bounds=(0, 0, 100, 100), - nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),), - initial_paths={"net1": (object(),)}, # type: ignore[dict-item] - ) - - -def test_route_results_metrics_are_snapshots() -> None: - problem = RoutingProblem( - bounds=(0, 0, 100, 100), - 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 3d637b9..85d9021 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,117 +1,34 @@ -import math - import pytest from shapely.geometry import Polygon -from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions -from inire.geometry.components import Bend90, Straight -from inire.geometry.collision import RoutingWorld +import inire.router.astar as astar_module +from inire.geometry.components import SBend, Straight +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext, SearchRunConfig -from inire.router._search import route_astar +from inire.router.astar import AStarContext, route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap - -BOUNDS = (0, -50, 150, 150) +from inire.router.pathfinder import RoutingResult +from inire.utils.validation import validate_routing_result @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=BOUNDS) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=(0, -50, 150, 150)) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) -def _build_options(**search_overrides: object) -> RoutingOptions: - return RoutingOptions(search=SearchOptions(**search_overrides)) - - -def _build_context( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - **search_overrides: object, -) -> AStarContext: - return AStarContext( - evaluator, - RoutingProblem(bounds=bounds), - _build_options(**search_overrides), - ) - - -def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object): - return route_astar( - start, - target, - net_width=2.0, - context=context, - config=SearchRunConfig.from_options(context.options, **config_overrides), - ) - - -def _validate_routing_result( - result: RoutingResult, - static_obstacles: list[Polygon], - clearance: float, - expected_start: Port | None = None, - expected_end: Port | None = None, -) -> dict[str, object]: - if not result.path: - return {"is_valid": False, "reason": "No path found"} - - connectivity_errors: list[str] = [] - if expected_start: - first_port = result.path[0].start_port - dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y) - if dist_to_start > 0.005: - connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm") - if abs(first_port.r - expected_start.r) > 0.1: - connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}") - - if expected_end: - last_port = result.path[-1].end_port - dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y) - if dist_to_end > 0.005: - connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") - if abs(last_port.r - expected_end.r) > 0.1: - connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}") - - engine = RoutingWorld(clearance=clearance) - for obstacle in static_obstacles: - engine.add_static_obstacle(obstacle) - report = engine.verify_path_report("validation", result.path) - is_valid = report.is_valid and not connectivity_errors - - reasons = [] - if report.static_collision_count: - reasons.append(f"Found {report.static_collision_count} obstacle collisions.") - if report.dynamic_collision_count: - reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.") - if report.self_collision_count: - reasons.append(f"Found {report.self_collision_count} self-intersections.") - reasons.extend(connectivity_errors) - - return { - "is_valid": is_valid, - "reason": " ".join(reasons), - "obstacle_collisions": report.static_collision_count, - "dynamic_collisions": report.dynamic_collision_count, - "self_intersections": report.self_collision_count, - "total_length": report.total_length, - "connectivity_ok": not connectivity_errors, - } - - def test_astar_straight(basic_evaluator: CostEvaluator) -> None: - context = _build_context(basic_evaluator, bounds=BOUNDS) + context = AStarContext(basic_evaluator) start = Port(0, 0, 0) target = Port(50, 0, 0) - path = _route(context, start, target) + 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, [], clearance=2.0, expected_start=start, expected_end=target) + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -120,15 +37,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,)) + context = AStarContext(basic_evaluator, 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(context, start, target) + 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, [], clearance=2.0, expected_start=start, expected_end=target) + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -141,14 +58,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000) + context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) - path = _route(context, start, target) + 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) + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" # Path should have detoured, so length > 50 @@ -156,165 +73,217 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = _build_context(basic_evaluator, bounds=BOUNDS) + context = AStarContext(basic_evaluator) start = Port(0, 0, 0) target = Port(10.1, 0, 0) - path = _route(context, start, target) + 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) + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) assert target.x == 10 - validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" -def test_validate_routing_result_checks_expected_start() -> None: - path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] - result = RoutingResult(net_id="test", path=path, reached_target=True) - - validation = _validate_routing_result( - result, - [], - clearance=2.0, - expected_start=Port(0, 0, 0), - expected_end=Port(110, 0, 0), - ) - - assert not validation["is_valid"] - assert "Initial port position mismatch" in validation["reason"] - - -def test_validate_routing_result_uses_exact_component_geometry() -> None: - bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0) - result = RoutingResult(net_id="test", path=[bend], 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( - result, - [obstacle], - clearance=2.0, - expected_start=Port(0, 0, 0), - expected_end=bend.end_port, - ) - - assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" - - -def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: - basic_evaluator = CostEvaluator( - basic_evaluator.collision_engine, - basic_evaluator.danger_map, - bend_penalty=120.0, - sbend_penalty=240.0, - ) - context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,)) - - assert context.options.search.bend_radii == (5.0,) - assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 - - -def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: - context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc") - - route_astar( - Port(0, 0, 0), - Port(30, 10, 0), - net_width=2.0, - context=context, - config=SearchRunConfig.from_options( - context.options, - bend_collision_type="clipped_bbox", - return_partial=True, - ), - ) - - assert context.options.search.bend_collision_type == "arc" - - -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) - - partial_path = _route(context, start, target, return_partial=True) - no_partial_path = _route(context, start, target, return_partial=False) - - assert partial_path is not None - assert partial_path - assert partial_path[-1].end_port != target - assert no_partial_path is None - - -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, - ) - start = Port(0, 0, 0) - target = Port(100, 10, 0) - - path = _route(context, start, target) - - 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) - ) - - -@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"]) -def test_route_astar_supports_all_visibility_guidance_modes( +def test_expand_moves_only_shortens_consecutive_straights( basic_evaluator: CostEvaluator, - visibility_guidance: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: - 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=visibility_guidance, + 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, ) - start = Port(0, 0, 0) - target = Port(80, 50, 0) - path = _route(context, start, target) + emitted: list[tuple[str, tuple]] = [] - 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) + def fake_process_move(*args, **kwargs) -> None: + emitted.append((args[9], args[10])) - assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" - assert validation["connectivity_ok"] + 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) -def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> 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_expand_moves_adds_sbend_aligned_straight_stop_points( + basic_evaluator: CostEvaluator, + monkeypatch: pytest.MonkeyPatch, +) -> None: context = AStarContext( basic_evaluator, - RoutingProblem(bounds=BOUNDS), - _build_options( - min_straight_length=1.0, - max_straight_length=100.0, - ), - max_cache_size=2, + bend_radii=[10.0], + sbend_radii=[10.0], + max_straight_length=150.0, ) - start = Port(0, 0, 0) - targets = [Port(length, 0, 0) for length in range(10, 70, 10)] + current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) - for target in targets: - path = _route(context, start, target) - assert path is not None - assert path[-1].end_port == target + 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(100, 10, 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"} + 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( + basic_evaluator: CostEvaluator, + monkeypatch: pytest.MonkeyPatch, +) -> 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( + basic_evaluator, + bend_radii=[10.0], + sbend_radii=[], + max_straight_length=150.0, + visibility_guidance="tangent_corner", + ) + current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.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, + ) + + 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 diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 67264cc..3f17b1c 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,41 +1,13 @@ import pytest import numpy from shapely.geometry import Polygon -from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port from inire.geometry.components import Straight -from inire.model import NetSpec -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire import RoutingResult - - -def _build_pathfinder( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - search: SearchOptions | None = None, - congestion: CongestionOptions | None = None, -) -> PathFinder: - nets = tuple( - NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - return PathFinder( - AStarContext( - evaluator, - RoutingProblem(bounds=bounds, nets=nets), - RoutingOptions( - search=SearchOptions() if search is None else search, - congestion=CongestionOptions() if congestion is None else congestion, - ), - ), - ) +from inire.router.astar import AStarContext +from inire.router.pathfinder import PathFinder, RoutingResult def test_clearance_thresholds(): """ @@ -44,41 +16,43 @@ def test_clearance_thresholds(): """ # Clearance = 2.0, Width = 2.0 # Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0 - ce = RoutingWorld(clearance=2.0) + ce = CollisionEngine(clearance=2.0) # Net 1: Centerline at y=0 p1 = Port(0, 0, 0) res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0) - ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry) + ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry) # Net 2: Parallel to Net 1 # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. p2_ok = Port(0, 5, 0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) - report_ok = ce.verify_path_report("net2", [res2_ok]) - assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions" + is_v, count = ce.verify_path("net2", [res2_ok]) + assert is_v, f"Gap 3 should be valid, but got {count} collisions" # 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK. p2_exact = Port(0, 4, 0) res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0) - report_exact = ce.verify_path_report("net2", [res2_exact]) - assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions" + is_v, count = ce.verify_path("net2", [res2_exact]) + assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions" # 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL. p2_fail = Port(0, 3, 0) res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0) - report_fail = ce.verify_path_report("net2", [res2_fail]) - assert not report_fail.is_valid, "Gap 1.999 should be invalid" - assert report_fail.collision_count > 0 + is_v, count = ce.verify_path("net2", [res2_fail]) + assert not is_v, "Gap 1.999 should be invalid" + assert count > 0 def test_verify_all_nets_cases(): """ Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases. """ - engine = RoutingWorld(clearance=2.0) + engine = CollisionEngine(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 = { @@ -86,14 +60,8 @@ def test_verify_all_nets_cases(): "net2": (Port(0, 54, 0), Port(100, 54, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} - - results = _build_pathfinder( - evaluator, - bounds=(0, 0, 100, 100), - netlist=netlist_parallel_ok, - net_widths=net_widths, - congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), - ).route_all() + + results = pf.route_all(netlist_parallel_ok, net_widths) assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -106,13 +74,7 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = _build_pathfinder( - evaluator, - bounds=(0, 0, 100, 100), - netlist=netlist_parallel_fail, - net_widths=net_widths, - congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), - ).route_all() + results_p = pf.route_all(netlist_parallel_fail, net_widths) # 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 @@ -125,12 +87,6 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = _build_pathfinder( - evaluator, - bounds=(0, 0, 100, 100), - netlist=netlist_cross, - net_widths=net_widths, - congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), - ).route_all() + results_c = pf.route_all(netlist_cross, net_widths) 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 7eb0e4f..f83bb16 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,51 +1,75 @@ -from inire.geometry.collision import RoutingWorld -from inire.geometry.components import Straight +from shapely.geometry import Polygon + +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port - - -def _install_static_straight( - engine: RoutingWorld, - start: Port, - length: float, - *, - width: float, - dilation: float = 0.0, -) -> None: - obstacle = Straight.generate(start, length, width=width, dilation=dilation) - for polygon in obstacle.physical_geometry: - engine.add_static_obstacle(polygon) +from inire.geometry.components import Straight def test_collision_detection() -> None: - engine = RoutingWorld(clearance=2.0) - _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0) + # Clearance = 2um + engine = CollisionEngine(clearance=2.0) - direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0) - assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port) + # 10x10 um obstacle at (10,10) + obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) + engine.add_static_obstacle(obstacle) - far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0) - assert not engine.check_move_static(far_away, start_port=far_away.start_port) + # 1. Direct hit + test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)]) + assert engine.is_collision(test_poly, net_width=2.0) - near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0) - assert engine.check_move_static(near_hit, start_port=near_hit.start_port) + # 2. Far away + test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]) + assert not engine.is_collision(test_poly_far, net_width=2.0) + + # 3. Near hit (within clearance) + # Obstacle edge at x=10. + # test_poly edge at x=9. + # Distance = 1.0 um. + # Required distance (Wi+C)/2 = 2.0. Collision! + test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)]) + assert engine.is_collision(test_poly_near, net_width=2.0) def test_safety_zone() -> None: - engine = RoutingWorld(clearance=0.0) - _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0) + # Use zero clearance for this test to verify the 2nm port safety zone + # against the physical obstacle boundary. + engine = CollisionEngine(clearance=0.0) + obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) + engine.add_static_obstacle(obstacle) + + # Port exactly on the boundary start_port = Port(10, 12, 0) - test_move = Straight.generate(start_port, 0.002, width=0.001) - assert not engine.check_move_static(test_move, start_port=start_port) + + # Move starting from this port that overlaps the obstacle by 1nm + # (Inside the 2nm safety zone) + test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)]) + + assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port) + + +def test_configurable_max_net_width() -> None: + # Large max_net_width (10.0) -> large pre-dilation (6.0) + engine = CollisionEngine(clearance=2.0, max_net_width=10.0) + + obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) + engine.add_static_obstacle(obstacle) + + test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)]) + # physical check: dilated test_poly by C/2 = 1.0. + # Dilated test_poly bounds: (14, 19, 17, 26). + # obstacle: (20, 20, 25, 25). No physical collision. + assert not engine.is_collision(test_poly, net_width=2.0) def test_ray_cast_width_clearance() -> None: # Clearance = 2.0um, Width = 2.0um. # Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um. - engine = RoutingWorld(clearance=2.0) + engine = CollisionEngine(clearance=2.0) # Obstacle at x=10 to 20 - _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0) + obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) + engine.add_static_obstacle(obstacle) # 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK. start_ok = Port(6, 50, 90) @@ -59,73 +83,23 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: - engine = RoutingWorld(clearance=2.0) - _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0) + engine = CollisionEngine(clearance=2.0) + obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) + engine.add_static_obstacle(obstacle) # Straight move of length 10 at x=8 (Width 2.0) # Gap = 10 - 8 = 2.0 < 3.0. COLLISION. start = Port(8, 0, 90) res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2 - assert engine.check_move_static(res, start_port=start) + assert engine.check_move_static(res, start_port=start, net_width=2.0) # Move at x=7. Gap = 3.0 == minimum. OK. start_ok = Port(7, 0, 90) res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_ok, start_port=start_ok) + assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0) # 3. Same exact-boundary case. start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_exact, start_port=start_exact) - - -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._dynamic_paths.geometries.values()} == {"netA"} - - engine.remove_path("netA") - assert list(engine._dynamic_paths.geometries.values()) == [] - assert len(engine._static_obstacles.geometries) == 0 + assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 2708a56..dad6fbf 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,12 +1,7 @@ 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 +from inire.geometry.primitives import Port, rotate_port, translate_port def test_straight_generation() -> None: @@ -17,16 +12,15 @@ def test_straight_generation() -> None: assert result.end_port.x == 10.0 assert result.end_port.y == 0.0 - assert result.end_port.r == 0.0 - assert len(result.collision_geometry) == 1 + assert result.end_port.orientation == 0.0 + assert len(result.geometry) == 1 # Bounds of the polygon - minx, miny, maxx, maxy = result.collision_geometry[0].bounds + minx, miny, maxx, maxy = result.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: @@ -38,13 +32,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.r == 270.0 + assert result_cw.end_port.orientation == 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.r == 90.0 + assert result_ccw.end_port.orientation == 90.0 def test_sbend_generation() -> None: @@ -55,8 +49,8 @@ def test_sbend_generation() -> None: result = SBend.generate(start, offset, radius, width) assert result.end_port.y == 5.0 - assert result.end_port.r == 0.0 - assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs + assert result.end_port.orientation == 0.0 + assert len(result.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"): @@ -72,7 +66,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.collision_geometry[1].bounds + second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds assert second_arc_maxy <= width / 2.0 + 1e-6 assert second_arc_miny < -width / 2.0 @@ -86,65 +80,16 @@ 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.collision_geometry[0].bounds + minx, miny, maxx, maxy = res_bbox.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") - # Conservative 8-point approximation should still be tighter than the full bbox. - 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.collision_geometry[0].covers(res_arc.collision_geometry[0]) - - # 3. Legacy clip-margin mode should still be available when explicitly requested. - res_clipped_margin = Bend90.generate( - start, - radius, - width, - direction="CCW", - collision_type="clipped_bbox", - clip_margin=1.0, - ) - assert len(res_clipped_margin.collision_geometry[0].exterior.coords) - 1 == 4 - assert abs(res_clipped_margin.collision_geometry[0].area - 81.0) < 1e-6 - assert res_clipped_margin.collision_geometry[0].area > res_clipped.collision_geometry[0].area - - -def test_custom_bend_collision_polygon_uses_local_transform() -> None: - custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) - - cases = [ - (Port(0, 0, 0), "CCW", (0.0, 10.0), 0.0, False), - (Port(0, 0, 0), "CW", (0.0, -10.0), 0.0, True), - (Port(0, 0, 90), "CCW", (-10.0, 0.0), 90.0, False), - ] - - for start, direction, center_xy, rotation_deg, mirror_y in cases: - result = Bend90.generate(start, 10.0, 2.0, direction=direction, collision_type=custom_poly) - expected = custom_poly - if mirror_y: - expected = shapely_scale(expected, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0)) - if rotation_deg: - expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False) - expected = shapely_translate(expected, center_xy[0], center_xy[1]) - - assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6 - - -def test_custom_bend_collision_polygon_only_overrides_search_geometry() -> None: - custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]) - result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0) - - assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area > 1e-6 - assert result.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 + res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) + # Area should be less than full bbox + assert res_clipped.geometry[0].area < res_bbox.geometry[0].area def test_sbend_collision_models() -> None: @@ -155,11 +100,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.collision_geometry) == 2 + assert len(res_bbox.geometry) == 2 res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") - area_bbox = sum(p.area for p in res_bbox.collision_geometry) - area_arc = sum(p.area for p in res_arc.collision_geometry) + area_bbox = sum(p.area for p in res_bbox.geometry) + area_arc = sum(p.area for p in res_arc.geometry) assert area_bbox > area_arc @@ -173,14 +118,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.r - 90.0) < 1e-6 + assert abs(res.end_port.orientation - 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.collision_geometry) == 2 - for p in res.collision_geometry: + assert len(res.geometry) == 2 + for p in res.geometry: assert p.is_valid @@ -197,8 +142,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.collision_geometry[0].exterior.coords) - pts_fine = len(res_fine.collision_geometry[0].exterior.coords) + pts_coarse = len(res_coarse.geometry[0].exterior.coords) + pts_fine = len(res_fine.geometry[0].exterior.coords) assert pts_fine > pts_coarse * 2 @@ -217,19 +162,12 @@ def test_component_transform_invariance() -> None: angle = 90.0 # 1. Transform the generated geometry - p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle) + p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) # 2. Generate at transformed start - start_transformed = start0.translate(dx, dy).rotated(angle) + start_transformed = rotate_port(translate_port(start0, dx, dy), 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.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 + assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 7d3f2eb..53f5400 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,90 +1,30 @@ import pytest +from shapely.geometry import Polygon -from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.model import NetSpec -from inire.router._astar_types import AStarContext, SearchRunConfig -from inire.router._router import PathFinder -from inire.router._search import route_astar +from inire.router.astar import AStarContext, route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap - -BOUNDS = (0, -40, 100, 40) +from inire.router.pathfinder import PathFinder @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = RoutingWorld(clearance=2.0) + engine = CollisionEngine(clearance=2.0) # Wider bounds to allow going around (y from -40 to 40) - danger_map = DangerMap(bounds=BOUNDS) + danger_map = DangerMap(bounds=(0, -40, 100, 40)) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) -def _build_context( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - nets: tuple[NetSpec, ...] = (), - search: SearchOptions | None = None, - congestion: CongestionOptions | None = None, -) -> AStarContext: - return AStarContext( - evaluator, - RoutingProblem(bounds=bounds, nets=nets), - RoutingOptions( - search=SearchOptions() if search is None else search, - congestion=CongestionOptions() if congestion is None else congestion, - ), - ) - - -def _build_pathfinder( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - search: SearchOptions | None = None, - congestion: CongestionOptions | None = None, -) -> PathFinder: - nets = tuple( - NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - return PathFinder( - _build_context( - evaluator, - bounds=bounds, - nets=nets, - search=search, - congestion=congestion, - ), - ) - - -def _route(context: AStarContext, start: Port, target: Port) -> object: - return route_astar( - start, - target, - net_width=2.0, - context=context, - config=SearchRunConfig.from_options(context.options), - ) - - def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: - context = _build_context( - basic_evaluator, - bounds=BOUNDS, - search=SearchOptions(sbend_offsets=(2.0, 5.0)), - ) + context = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0]) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) target = Port(50, 2, 0) - path = _route(context, start, target) + path = route_astar(start, target, net_width=2.0, context=context) assert path is not None # Check if any component in the path is an SBend @@ -92,7 +32,37 @@ 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.r - start.r) < 0.1: + if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 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} + + # Force them into a narrow corridor that only fits ONE. + obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall + obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) + + basic_evaluator.collision_engine.add_static_obstacle(obs_top) + basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) + basic_evaluator.danger_map.precompute([obs_top, obs_bottom]) + + results = pf.route_all(netlist, net_widths) + + assert len(results) == 2 + assert results["net1"].reached_target + assert results["net2"].reached_target + assert results["net1"].is_valid + assert results["net2"].is_valid + assert results["net1"].collisions == 0 + assert results["net2"].collisions == 0 diff --git a/inire/tests/test_cost.py b/inire/tests/test_cost.py index 6eef3bc..e3c5c26 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 RoutingWorld +from inire.geometry.collision import CollisionEngine 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 = RoutingWorld(clearance=2.0) + engine = CollisionEngine(clearance=2.0) # 50x50 um area, 1um resolution danger_map = DangerMap(bounds=(0, 0, 50, 50)) danger_map.precompute([]) @@ -40,18 +40,6 @@ def test_cost_calculation() -> None: assert h_away >= h_90 -def test_greedy_h_weight_is_mutable() -> None: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=(0, 0, 50, 50)) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=10.0) - - assert evaluator.greedy_h_weight == 1.5 - evaluator.greedy_h_weight = 1.2 - assert evaluator.greedy_h_weight == 1.2 - assert abs(evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) - 72.0) < 1e-6 - - def test_danger_map_kd_tree_and_cache() -> None: # Test that KD-Tree based danger map works and uses cache bounds = (0, 0, 1000, 1000) @@ -73,23 +61,7 @@ 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 7f8517b..4372749 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -2,18 +2,17 @@ from __future__ import annotations import os import statistics -from collections.abc import Callable import pytest -from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome +from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" PERFORMANCE_REPEATS = 3 REGRESSION_FACTOR = 1.5 -# Baselines are measured from clean 6a28dcf-style runs without plotting. +# Baselines are measured from the current code path without plotting. BASELINE_SECONDS = { "example_01_simple_route": 0.0035, "example_02_congestion_resolution": 0.2666, @@ -40,27 +39,25 @@ EXPECTED_OUTCOMES = { def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None: - _, total_results, valid_results, reached_targets = outcome expected = EXPECTED_OUTCOMES[name] - assert total_results == expected["total_results"] - assert valid_results == expected["valid_results"] - assert reached_targets == expected["reached_targets"] + assert outcome.total_results == expected["total_results"] + assert outcome.valid_results == expected["valid_results"] + assert outcome.reached_targets == expected["reached_targets"] @pytest.mark.performance @pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks") -@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS]) -def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None: - name, run = scenario +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS]) +def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None: timings = [] for _ in range(PERFORMANCE_REPEATS): - outcome = run() - _assert_expected_outcome(name, outcome) - timings.append(outcome[0]) + outcome = scenario.run() + _assert_expected_outcome(scenario.name, outcome) + timings.append(outcome.duration_s) median_runtime = statistics.median(timings) - assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, ( - f"{name} median runtime {median_runtime:.4f}s exceeded " - f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s " + assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, ( + f"{scenario.name} median runtime {median_runtime:.4f}s exceeded " + f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s " f"from timings {timings!r}" ) diff --git a/inire/tests/test_example_regressions.py b/inire/tests/test_example_regressions.py deleted file mode 100644 index 1a56cd2..0000000 --- a/inire/tests/test_example_regressions.py +++ /dev/null @@ -1,184 +0,0 @@ -import pytest -from shapely.geometry import Polygon, box - -from inire import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - Port, - RoutingOptions, - RoutingProblem, - SearchOptions, - route, -) -from inire.router._stack import build_routing_stack -from inire.seeds import Bend90Seed, PathSeed, StraightSeed -from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics - - -EXPECTED_OUTCOMES = { - "example_01_simple_route": (1, 1, 1), - "example_02_congestion_resolution": (3, 3, 3), - "example_03_locked_paths": (2, 2, 2), - "example_04_sbends_and_radii": (2, 2, 2), - "example_05_orientation_stress": (3, 3, 3), - "example_06_bend_collision_models": (3, 3, 3), - "example_07_large_scale_routing": (10, 10, 10), - "example_08_custom_bend_geometry": (2, 1, 2), - "example_09_unroutable_best_effort": (1, 0, 0), -} - - -@pytest.mark.parametrize(("name", "run"), SCENARIOS, ids=[name for name, _ in SCENARIOS]) -def test_examples_match_legacy_expected_outcomes(name: str, run) -> None: - outcome = run() - assert outcome[1:] == EXPECTED_OUTCOMES[name] - - -def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: - bounds = (-20, -20, 170, 170) - obstacles = ( - Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]), - Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]), - Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]), - ) - problem = RoutingProblem( - bounds=bounds, - nets=(NetSpec("clipped_model", Port(10, 20, 0), Port(90, 40, 90), width=2.0),), - static_obstacles=obstacles, - ) - common_kwargs = { - "objective": ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0), - "congestion": CongestionOptions(use_tiered_strategy=False), - } - no_margin = route( - problem, - options=RoutingOptions( - search=SearchOptions( - bend_radii=(10.0,), - bend_collision_type="clipped_bbox", - ), - **common_kwargs, - ), - ).results_by_net["clipped_model"] - legacy_margin = route( - problem, - options=RoutingOptions( - search=SearchOptions( - bend_radii=(10.0,), - bend_collision_type="clipped_bbox", - bend_clip_margin=1.0, - ), - **common_kwargs, - ), - ).results_by_net["clipped_model"] - - assert no_margin.is_valid - assert legacy_margin.is_valid - assert legacy_margin.as_seed() != no_margin.as_seed() - assert legacy_margin.as_seed() == PathSeed( - ( - StraightSeed(5.0), - Bend90Seed(10.0, "CW"), - Bend90Seed(10.0, "CCW"), - StraightSeed(45.0), - Bend90Seed(10.0, "CCW"), - StraightSeed(30.0), - ) - ) - - -def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None: - bounds = (0, 0, 500, 300) - obstacles = ( - box(220, 0, 280, 100), - box(220, 200, 280, 300), - ) - netlist = { - "net_00": (Port(30, 130, 0), Port(470, 60, 0)), - "net_01": (Port(30, 140, 0), Port(470, 120, 0)), - "net_02": (Port(30, 150, 0), Port(470, 180, 0)), - "net_03": (Port(30, 160, 0), Port(470, 240, 0)), - } - problem = RoutingProblem( - bounds=bounds, - nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), - static_obstacles=obstacles, - clearance=6.0, - ) - options = RoutingOptions( - search=SearchOptions( - node_limit=200000, - bend_radii=(30.0,), - sbend_radii=(30.0,), - greedy_h_weight=1.5, - bend_clip_margin=10.0, - ), - objective=ObjectiveWeights( - unit_length_cost=0.1, - bend_penalty=100.0, - sbend_penalty=400.0, - ), - congestion=CongestionOptions( - max_iterations=6, - base_penalty=100.0, - multiplier=1.4, - net_order="shortest", - shuffle_nets=True, - seed=42, - ), - diagnostics=DiagnosticsOptions(capture_expanded=False), - ) - stack = build_routing_stack(problem, options) - evaluator = stack.evaluator - finder = stack.finder - weights: list[float] = [] - - def iteration_callback(iteration: int, current_results: dict[str, object]) -> None: - _ = current_results - new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) - evaluator.greedy_h_weight = new_greedy - weights.append(new_greedy) - finder.metrics.reset_per_route() - - results = finder.route_all(iteration_callback=iteration_callback) - - assert weights == [1.46] - assert evaluator.greedy_h_weight == 1.46 - assert all(result.is_valid for result in results.values()) - assert all(result.reached_target for result in results.values()) - - -def test_example_08_custom_box_restores_legacy_collision_outcome() -> None: - bounds = (0, 0, 150, 150) - netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} - widths = {"custom_bend": 2.0} - evaluator = _build_evaluator(bounds) - - standard = _build_pathfinder( - evaluator, - bounds=bounds, - nets=_net_specs(netlist, widths), - bend_radii=[10.0], - sbend_radii=[], - max_iterations=1, - metrics=AStarMetrics(), - ).route_all() - custom = _build_pathfinder( - evaluator, - bounds=bounds, - nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), - bend_radii=[10.0], - bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), - sbend_radii=[], - max_iterations=1, - use_tiered_strategy=False, - metrics=AStarMetrics(), - ).route_all() - - assert standard["custom_bend"].is_valid - assert standard["custom_bend"].reached_target - assert not custom["custom_model"].is_valid - assert custom["custom_model"].reached_target - assert custom["custom_model"].collisions == 2 diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index db300ce..fa89bb6 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,77 +1,70 @@ -from inire import CongestionOptions, RoutingOptions, RoutingProblem + +import pytest +import numpy from inire.geometry.primitives import Port -from inire.geometry.collision import RoutingWorld -from inire.model import NetSpec -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder +from inire.geometry.collision import CollisionEngine 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 -def test_failed_net_visibility() -> None: +def test_failed_net_visibility(): """ Verifies that nets that fail to reach their target (return partial paths) ARE added to the collision engine, making them visible to other nets for negotiated congestion. """ # 1. Setup - engine = RoutingWorld(clearance=2.0) - + engine = CollisionEngine(clearance=2.0) + # Create a simple danger map (bounds 0-100) # We don't strictly need obstacles in it for this test. dm = DangerMap(bounds=(0, 0, 100, 100)) - + evaluator = CostEvaluator(engine, dm) - + # 2. Configure Router with low limit to FORCE failure # node_limit=10 is extremely low, likely allowing only a few moves. # Start (0,0) -> Target (100,0) is 100um away. - + # Let's add a static obstacle that blocks the direct path. from shapely.geometry import box - obstacle = box(40, -10, 60, 10) # Wall at x=50 engine.add_static_obstacle(obstacle) - + # With obstacle, direct jump fails. A* must search around. # Limit=10 should be enough to fail to find a path around. + context = AStarContext(evaluator, node_limit=10) + + # 3. Configure PathFinder + # max_iterations=1 because we only need to check the state after the first attempt. + pf = PathFinder(context, max_iterations=1, warm_start=None) + netlist = { "net1": (Port(0, 0, 0), Port(100, 0, 0)) } net_widths = {"net1": 1.0} - pf = PathFinder( - AStarContext( - evaluator, - RoutingProblem( - bounds=(0, 0, 100, 100), - nets=tuple( - NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ), - ), - RoutingOptions( - search=RoutingOptions().search.__class__(node_limit=10), - congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False), - ), - ), - ) - + # 4. Route print("\nStarting Route...") - results = pf.route_all() - + results = pf.route_all(netlist, net_widths) + res = results["net1"] print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") - + # 5. Verify Failure Condition # We expect reached_target to be False because of node_limit + obstacle assert not res.reached_target, "Test setup failed: Net reached target despite low limit!" assert len(res.path) > 0, "Test setup failed: No partial path returned!" - + # 6. Verify Visibility # Check if net1 is in the collision engine - found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} - + found_nets = set() + # CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)] + for obj_id, (nid, poly) in engine.dynamic_geometries.items(): + found_nets.add(nid) + print(f"Nets found in engine: {found_nets}") - + # The FIX Expectation: "net1" SHOULD be present assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!" diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index 7c43251..ee5490f 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -4,11 +4,9 @@ import pytest from hypothesis import given, settings, strategies as st from shapely.geometry import Point, Polygon -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.model import RoutingOptions, RoutingProblem, SearchOptions -from inire.router._astar_types import AStarContext, SearchRunConfig -from inire.router._search import route_astar +from inire.router.astar import AStarContext, route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap @@ -36,35 +34,12 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance return all(point.distance(obstacle) >= required_gap for obstacle in obstacles) -def _build_context( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - **search_overrides: object, -) -> AStarContext: - return AStarContext( - evaluator, - RoutingProblem(bounds=bounds), - RoutingOptions(search=SearchOptions(**search_overrides)), - ) - - -def _route(context: AStarContext, start: Port, target: Port): - return route_astar( - start, - target, - net_width=2.0, - context=context, - config=SearchRunConfig.from_options(context.options), - ) - - @settings(max_examples=3, deadline=None) @given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port()) def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: net_width = 2.0 clearance = 2.0 - engine = RoutingWorld(clearance=2.0) + engine = CollisionEngine(clearance=2.0) for obs in obstacles: engine.add_static_obstacle(obs) @@ -72,12 +47,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) + context = AStarContext(evaluator, 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) try: - path = _route(context, start, target) + path = route_astar(start, target, net_width=2.0, context=context) # This is a crash-smoke test rather than a full correctness proof. # If a full path is returned, it should at least terminate at the requested target. diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 773bd56..252a96e 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,231 +1,118 @@ -from shapely.geometry import box +import pytest -from inire import ( - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - RefinementOptions, - RoutingOptions, - RoutingProblem, - SearchOptions, -) -from inire.geometry.collision import RoutingWorld -from inire.geometry.components import Bend90, Straight +from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder +from inire.router.astar import AStarContext from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap - -DEFAULT_BOUNDS = (0, 0, 100, 100) - -_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"} -_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) -_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) -_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) -_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) -_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) - -def _request_nets( - netlist: dict[str, tuple[Port, Port]], - 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() - ) +from inire.router.pathfinder import PathFinder -def _build_options(**overrides: object) -> RoutingOptions: - search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} - congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} - refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} - diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} - objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} - return RoutingOptions( - search=SearchOptions(**search_overrides), - congestion=CongestionOptions(**congestion_overrides), - refinement=RefinementOptions(**refinement_overrides), - diagnostics=DiagnosticsOptions(**diagnostics_overrides), - objective=ObjectiveWeights(**objective_overrides), - ) +@pytest.fixture +def basic_evaluator() -> CostEvaluator: + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=(0, 0, 100, 100)) + danger_map.precompute([]) + return CostEvaluator(engine, danger_map) -def _build_context( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - nets: tuple[NetSpec, ...] = (), - **request_overrides: object, -) -> AStarContext: - problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS} - option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS} - return AStarContext( - evaluator, - RoutingProblem(bounds=bounds, nets=nets, **problem_overrides), - _build_options(**option_overrides), - ) +def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: + context = AStarContext(basic_evaluator) + pf = PathFinder(context) + + 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} + + results = pf.route_all(netlist, net_widths) + + assert len(results) == 2 + assert results["net1"].is_valid + assert results["net2"].is_valid + assert results["net1"].collisions == 0 + assert results["net2"].collisions == 0 -def _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 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 = { + "net1": (Port(0, 25, 0), Port(100, 25, 0)), + "net2": (Port(50, 0, 90), Port(50, 50, 90)), + } + net_widths = {"net1": 2.0, "net2": 2.0} + + results = pf.route_all(netlist, net_widths) + + # Both should be invalid because they cross + assert not results["net1"].is_valid + assert not results["net2"].is_valid + assert results["net1"].collisions > 0 + assert results["net2"].collisions > 0 -def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list: - path = [] - curr = start - dilation = clearance / 2.0 - for kind, value in steps: - if kind == "B": - comp = Bend90.generate(curr, 5.0, width, value, dilation=dilation) - else: - comp = Straight.generate(curr, value, width, dilation=dilation) - path.append(comp) - curr = comp.end_port - return path -def test_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 test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: + bounds = (0, -50, 100, 50) - def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]: - engine = RoutingWorld(clearance=2.0) - danger_map = DangerMap(bounds=(-20, -20, 60, 60)) + def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]: + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) 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_enabled=False, - max_iterations=1, - enabled=False, - ) - return engine, context, PathFinder(context) + 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) - 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"] + 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"] - 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"] + 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"] - 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_refine_path_handles_same_orientation_lateral_offset() -> None: - 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) - 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, - ) + 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") - start = Port(0, 0, 0) - width = 2.0 - path = _build_manual_path( - start, - width, - engine.clearance, - [ - ("B", "CCW"), - ("S", 10.0), - ("B", "CW"), - ("S", 20.0), - ("B", "CW"), - ("S", 10.0), - ("B", "CCW"), - ("S", 10.0), - ("B", "CCW"), - ("S", 5.0), - ("B", "CW"), - ], - ) - target = path[-1].end_port - - refined = pf.refiner.refine_path("net", start, width, path) - - assert target == Port(60, 15, 0) - assert sum(1 for comp in path if comp.move_type == "bend90") == 6 - assert sum(1 for comp in refined if comp.move_type == "bend90") == 4 - assert refined[-1].end_port == target - assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) + assert base_result.is_valid + assert refined_result.is_valid + assert refined_bends < base_bends + assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path) -def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None: - 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) - 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, - ) +def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: + bounds = (0, 0, 100, 100) + netlist = { + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + } + net_widths = {net_id: 2.0 for net_id in netlist} - start = Port(0, 0, 0) - width = 2.0 - path = _build_manual_path( - start, - width, - engine.clearance, - [ - ("B", "CCW"), - ("S", 10.0), - ("B", "CW"), - ("S", 20.0), - ("B", "CW"), - ("S", 10.0), - ("B", "CCW"), - ("S", 10.0), - ("B", "CCW"), - ("S", 5.0), - ("B", "CW"), - ("B", "CCW"), - ("S", 10.0), - ], - ) - target = path[-1].end_port + def build_pathfinder(*, refine_paths: bool) -> PathFinder: + engine = CollisionEngine(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) - refined = pf.refiner.refine_path("net", start, width, path) + base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths) + refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths) - assert target == Port(65, 30, 90) - assert sum(1 for comp in path if comp.move_type == "bend90") == 7 - assert sum(1 for comp in refined if comp.move_type == "bend90") == 5 - assert refined[-1].end_port == target - assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path) + for net_id in ("vertical_up", "vertical_down"): + base_result = base_results[net_id] + refined_result = refined_results[net_id] + base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") + refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") + + assert base_result.is_valid + assert refined_result.is_valid + assert refined_bends < base_bends diff --git a/inire/tests/test_primitives.py b/inire/tests/test_primitives.py index 7e2bd5b..6e62d45 100644 --- a/inire/tests/test_primitives.py +++ b/inire/tests/test_primitives.py @@ -1,10 +1,8 @@ -from dataclasses import FrozenInstanceError from typing import Any from hypothesis import given, strategies as st -import pytest -from inire.geometry.primitives import Port +from inire.geometry.primitives import Port, rotate_port, translate_port @st.composite @@ -26,11 +24,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 = p_rot.rotated(90) + p_rot = rotate_port(p_rot, 90) assert abs(p_rot.x - p.x) < 1e-6 assert abs(p_rot.y - p.y) < 1e-6 - assert (p_rot.r % 360) == (p.r % 360) + assert (p_rot.orientation % 360) == (p.orientation % 360) @given( @@ -39,21 +37,14 @@ 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 = p.translate(dx, dy) + p_trans = translate_port(p, 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.r == 0 + assert p.orientation == 0 p2 = Port(0, 0, -90) - assert p2.r == 270 - - -def test_port_is_immutable_value_type() -> None: - p = Port(1, 2, 90) - - with pytest.raises(FrozenInstanceError): - p.x = 3 + assert p2.orientation == 270 diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 56ce7a4..25c3740 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,33 +1,10 @@ -from inire import RoutingOptions, RoutingProblem, SearchOptions -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine from inire.geometry.components import Bend90 from inire.geometry.primitives import Port -from inire.model import NetSpec -from inire.router._astar_types import AStarContext -from inire.router._router import PathFinder +from inire.router.astar import AStarContext from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap - - -def _build_pathfinder( - evaluator: CostEvaluator, - *, - bounds: tuple[float, float, float, float], - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - search: SearchOptions | None = None, -) -> PathFinder: - nets = tuple( - NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - return PathFinder( - AStarContext( - evaluator, - RoutingProblem(bounds=bounds, nets=nets), - RoutingOptions(search=SearchOptions() if search is None else search), - ), - ) +from inire.router.pathfinder import PathFinder def test_arc_resolution_sagitta() -> None: @@ -41,45 +18,34 @@ 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.collision_geometry[0].exterior.coords) - pts_fine = len(res_fine.collision_geometry[0].exterior.coords) + pts_coarse = len(res_coarse.geometry[0].exterior.coords) + pts_fine = len(res_fine.geometry[0].exterior.coords) assert pts_fine > pts_coarse -def test_locked_routes() -> None: - engine = RoutingWorld(clearance=2.0) +def test_locked_paths() -> None: + engine = CollisionEngine(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 = _build_pathfinder( - evaluator, - bounds=(0, -50, 100, 50), - netlist=netlist_a, - net_widths={"netA": 2.0}, - search=SearchOptions(bend_radii=(5.0, 10.0)), - ).route_all() + results_a = pf.route_all(netlist_a, {"netA": 2.0}) assert results_a["netA"].is_valid - # 2. Treat Net A as locked geometry in the next run. - for polygon in results_a["netA"].locked_geometry: - engine.add_static_obstacle(polygon) + # 2. Lock Net A + engine.lock_net("netA") # 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 = _build_pathfinder( - evaluator, - bounds=(0, -50, 100, 50), - netlist=netlist_b, - net_widths={"netB": 2.0}, - search=SearchOptions(bend_radii=(5.0, 10.0)), - ).route_all() + results_b = pf.route_all(netlist_b, {"netB": 2.0}) # 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). @@ -89,8 +55,8 @@ def test_locked_routes() -> None: assert results_b["netB"].is_valid # Verify geometry doesn't intersect locked netA (physical check) - 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] + poly_a = [p.geometry[0] for p in results_a["netA"].path] + poly_b = [p.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_route_behavior.py b/inire/tests/test_route_behavior.py deleted file mode 100644 index 8664a12..0000000 --- a/inire/tests/test_route_behavior.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -from shapely.geometry import Polygon - -from inire import ( - Bend90Seed, - CongestionOptions, - DiagnosticsOptions, - NetSpec, - ObjectiveWeights, - PathSeed, - Port, - RefinementOptions, - RoutingOptions, - RoutingProblem, - RoutingResult, - SearchOptions, - StraightSeed, - route, -) - -DEFAULT_BOUNDS = (0, 0, 100, 100) - -_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"} -_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) -_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) -_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) -_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) -_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) - - -def _request_nets( - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], -) -> tuple[NetSpec, ...]: - return tuple( - NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) - for net_id, (start, target) in netlist.items() - ) - - -def _build_options(**overrides: object) -> RoutingOptions: - search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} - congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} - refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} - diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} - objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} - return RoutingOptions( - search=SearchOptions(**search_overrides), - congestion=CongestionOptions(**congestion_overrides), - refinement=RefinementOptions(**refinement_overrides), - diagnostics=DiagnosticsOptions(**diagnostics_overrides), - objective=ObjectiveWeights(**objective_overrides), - ) - - -def _route_problem( - *, - netlist: dict[str, tuple[Port, Port]], - net_widths: dict[str, float], - bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, - static_obstacles: tuple[Polygon, ...] = (), - iteration_callback=None, - **overrides: object, -): - problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS} - option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS} - problem = RoutingProblem( - bounds=bounds, - nets=_request_nets(netlist, net_widths), - static_obstacles=static_obstacles, - **problem_overrides, - ) - return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback) - - -def _bend_count(result: RoutingResult) -> int: - return sum(1 for component in result.path if component.move_type == "bend90") - - -def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed: - segments = [] - for kind, value in steps: - if kind == "B": - segments.append(Bend90Seed(radius=5.0, direction=value)) - else: - segments.append(StraightSeed(length=value)) - return PathSeed(tuple(segments)) - - -def test_route_parallel_nets_are_valid() -> None: - run = _route_problem( - netlist={ - "net1": (Port(0, 0, 0), Port(50, 0, 0)), - "net2": (Port(0, 10, 0), Port(50, 10, 0)), - }, - net_widths={"net1": 2.0, "net2": 2.0}, - ) - - assert len(run.results_by_net) == 2 - assert run.results_by_net["net1"].is_valid - assert run.results_by_net["net2"].is_valid - assert run.results_by_net["net1"].collisions == 0 - assert run.results_by_net["net2"].collisions == 0 - - -def test_route_reports_crossing_nets_without_congestion_resolution() -> None: - run = _route_problem( - netlist={ - "net1": (Port(0, 25, 0), Port(100, 25, 0)), - "net2": (Port(50, 0, 90), Port(50, 50, 90)), - }, - net_widths={"net1": 2.0, "net2": 2.0}, - max_iterations=1, - base_penalty=1.0, - warm_start_enabled=False, - ) - - assert not run.results_by_net["net1"].is_valid - assert not run.results_by_net["net2"].is_valid - assert run.results_by_net["net1"].collisions > 0 - assert run.results_by_net["net2"].collisions > 0 - - -def test_route_callback_respects_requested_net_order() -> None: - callback_orders: list[list[str]] = [] - - _route_problem( - netlist={ - "short": (Port(0, 0, 0), Port(10, 0, 0)), - "long": (Port(0, 0, 0), Port(40, 10, 0)), - "mid": (Port(0, 0, 0), Port(20, 0, 0)), - }, - net_widths={"short": 2.0, "long": 2.0, "mid": 2.0}, - max_iterations=1, - warm_start_enabled=False, - net_order="longest", - enabled=False, - iteration_callback=lambda iteration, results: callback_orders.append(list(results)), - ) - - assert callback_orders == [["long", "mid", "short"]] - - -def test_route_callback_receives_iteration_results() -> None: - callback_results: list[dict[str, RoutingResult]] = [] - - run = _route_problem( - netlist={ - "net1": (Port(0, 0, 0), Port(10, 0, 0)), - "net2": (Port(0, 10, 0), Port(10, 10, 0)), - }, - net_widths={"net1": 2.0, "net2": 2.0}, - iteration_callback=lambda iteration, results: callback_results.append(dict(results)), - ) - - assert len(callback_results) == 1 - assert set(callback_results[0]) == {"net1", "net2"} - assert callback_results[0]["net1"].is_valid - assert callback_results[0]["net2"].is_valid - assert run.results_by_net["net1"].reached_target - assert run.results_by_net["net2"].reached_target - - -def test_route_uses_complete_initial_paths_without_rerouting() -> None: - initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")]) - run = _route_problem( - netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))}, - net_widths={"net": 2.0}, - bend_radii=[5.0], - max_iterations=1, - warm_start_enabled=False, - initial_paths={"net": initial_seed}, - enabled=False, - ) - - result = run.results_by_net["net"] - assert result.is_valid - assert result.reached_target - assert result.as_seed() == initial_seed - - -def test_route_retries_partial_initial_paths_across_iterations() -> None: - iterations: list[int] = [] - partial_seed = PathSeed((StraightSeed(length=5.0),)) - run = _route_problem( - netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))}, - net_widths={"net": 2.0}, - max_iterations=2, - warm_start_enabled=False, - capture_expanded=True, - initial_paths={"net": partial_seed}, - enabled=False, - iteration_callback=lambda iteration, results: iterations.append(iteration), - ) - - result = run.results_by_net["net"] - assert iterations == [0, 1] - assert result.is_valid - assert result.reached_target - assert result.outcome == "completed" - assert result.as_seed() != partial_seed - assert run.expanded_nodes - - -def test_route_negotiated_congestion_resolution() -> None: - obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) - obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) - run = _route_problem( - bounds=(0, -40, 100, 40), - netlist={ - "net1": (Port(0, 0, 0), Port(50, 0, 0)), - "net2": (Port(0, 10, 0), Port(50, 10, 0)), - }, - net_widths={"net1": 2.0, "net2": 2.0}, - static_obstacles=(obs_top, obs_bottom), - bend_radii=(5.0, 10.0), - max_iterations=10, - base_penalty=1000.0, - ) - - assert run.results_by_net["net1"].reached_target - assert run.results_by_net["net2"].reached_target - assert run.results_by_net["net1"].is_valid - assert run.results_by_net["net2"].is_valid - - -def test_route_refinement_reduces_locked_detour_bends() -> None: - route_a = _route_problem( - bounds=(0, -50, 100, 50), - netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))}, - net_widths={"netA": 2.0}, - bend_radii=[10.0], - enabled=False, - ) - locked_geometry = route_a.results_by_net["netA"].locked_geometry - - base_run = _route_problem( - bounds=(0, -50, 100, 50), - netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, - net_widths={"netB": 2.0}, - static_obstacles=locked_geometry, - bend_radii=[10.0], - enabled=False, - ) - refined_run = _route_problem( - bounds=(0, -50, 100, 50), - netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, - net_widths={"netB": 2.0}, - static_obstacles=locked_geometry, - bend_radii=[10.0], - enabled=True, - ) - - base_result = base_run.results_by_net["netB"] - refined_result = refined_run.results_by_net["netB"] - assert base_result.is_valid - assert refined_result.is_valid - assert _bend_count(refined_result) < _bend_count(base_result) - - -def test_route_refinement_simplifies_triple_crossing_detours() -> None: - base_run = _route_problem( - bounds=(0, 0, 100, 100), - netlist={ - "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), - "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), - "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), - }, - net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, - bend_radii=[10.0], - sbend_radii=[10.0], - base_penalty=1000.0, - enabled=False, - greedy_h_weight=1.5, - bend_penalty=250.0, - sbend_penalty=500.0, - ) - refined_run = _route_problem( - bounds=(0, 0, 100, 100), - netlist={ - "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), - "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), - "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), - }, - net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, - bend_radii=[10.0], - sbend_radii=[10.0], - base_penalty=1000.0, - enabled=True, - greedy_h_weight=1.5, - bend_penalty=250.0, - sbend_penalty=500.0, - ) - - for net_id in ("vertical_up", "vertical_down"): - base_result = base_run.results_by_net[net_id] - refined_result = refined_run.results_by_net[net_id] - assert base_result.is_valid - assert refined_result.is_valid - assert _bend_count(refined_result) < _bend_count(base_result) diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index e0acd71..ea6dee8 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,41 +1,21 @@ import unittest - from inire.geometry.primitives import Port -from inire.model import RoutingOptions, RoutingProblem -from inire.router._astar_types import AStarContext, SearchRunConfig -from inire.router._search import route_astar +from inire.router.astar import route_astar, AStarContext from inire.router.cost import CostEvaluator -from inire.geometry.collision import RoutingWorld +from inire.geometry.collision import CollisionEngine class TestIntegerPorts(unittest.TestCase): def setUp(self): - self.ce = RoutingWorld(clearance=2.0) + self.ce = CollisionEngine(clearance=2.0) self.cost = CostEvaluator(self.ce) - self.bounds = (0, 0, 100, 100) - - def _build_context(self) -> AStarContext: - return AStarContext( - self.cost, - RoutingProblem(bounds=self.bounds), - RoutingOptions(), - ) - - def _route(self, context: AStarContext, start: Port, target: Port): - return route_astar( - start, - target, - net_width=1.0, - context=context, - config=SearchRunConfig.from_options(context.options), - ) def test_route_reaches_integer_target(self): - context = self._build_context() + context = AStarContext(self.cost) start = Port(0, 0, 0) target = Port(12, 0, 0) - path = self._route(context, start, target) + path = route_astar(start, target, net_width=1.0, context=context) self.assertIsNotNone(path) last_port = path[-1].end_port @@ -44,11 +24,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = self._build_context() + context = AStarContext(self.cost) start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) - path = self._route(context, start, target) + path = route_astar(start, target, net_width=1.0, context=context) self.assertIsNotNone(path) self.assertEqual(target.x, 12) @@ -56,11 +36,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = self._build_context() + context = AStarContext(self.cost) start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) - path = self._route(context, start, target) + path = route_astar(start, target, net_width=1.0, context=context) self.assertIsNotNone(path) self.assertEqual(target.x, 8) diff --git a/inire/tests/test_visibility.py b/inire/tests/test_visibility.py deleted file mode 100644 index 0e2100f..0000000 --- a/inire/tests/test_visibility.py +++ /dev/null @@ -1,20 +0,0 @@ -from shapely.geometry import box - -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 = 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) - origin = Port(0, 0, 0) - - near_corners = visibility.get_point_visibility(origin, max_dist=40.0) - far_corners = visibility.get_point_visibility(origin, max_dist=200.0) - - assert len(near_corners) == 3 - assert len(far_corners) > len(near_corners) - assert any(corner[0] >= 100.0 for corner in far_corners) diff --git a/inire/tests/test_visualization.py b/inire/tests/test_visualization.py deleted file mode 100644 index eb139ca..0000000 --- a/inire/tests/test_visualization.py +++ /dev/null @@ -1,26 +0,0 @@ -import matplotlib - -matplotlib.use("Agg") - -from inire.geometry.components import Bend90 -from inire.geometry.primitives import Port -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], reached_target=True) - - fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True) - fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False) - - actual_line_points = max(len(line.get_xdata()) for line in ax_actual.lines) - proxy_line_points = max(len(line.get_xdata()) for line in ax_proxy.lines) - - assert actual_line_points > proxy_line_points - assert ax_actual.get_title().endswith("Actual Geometry)") - assert ax_proxy.get_title().endswith("(Proxy Geometry)") - - fig_actual.clf() - fig_proxy.clf() diff --git a/inire/utils/validation.py b/inire/utils/validation.py new file mode 100644 index 0000000..a044854 --- /dev/null +++ b/inire/utils/validation.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +import numpy + +from inire.constants import TOLERANCE_LINEAR + +if TYPE_CHECKING: + from shapely.geometry import Polygon + from inire.geometry.primitives import Port + from inire.router.pathfinder import RoutingResult + + +def validate_routing_result( + result: RoutingResult, + static_obstacles: list[Polygon], + clearance: float, + expected_start: Port | None = None, + expected_end: Port | None = None, + ) -> dict[str, Any]: + """ + Perform a high-precision validation of a routed path. + + Args: + result: The routing result to validate. + static_obstacles: List of static obstacle geometries. + clearance: Required minimum distance. + expected_start: Optional expected start port. + expected_end: Optional expected end port. + + Returns: + A dictionary with validation results. + """ + _ = expected_start + if not result.path: + return {"is_valid": False, "reason": "No path found"} + + obstacle_collision_geoms = [] + self_intersection_geoms = [] + connectivity_errors = [] + + # 1. Connectivity Check + total_length = 0.0 + for comp in result.path: + total_length += comp.length + + # Boundary check + if expected_end: + last_port = result.path[-1].end_port + dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum()) + 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]}") + + # 2. Geometry Buffering + dilation_half = clearance / 2.0 + dilation_full = clearance + + dilated_for_self = [] + + for comp in result.path: + for poly in comp.geometry: + # Check against obstacles + d_full = poly.buffer(dilation_full) + for obs in static_obstacles: + if d_full.intersects(obs): + intersection = d_full.intersection(obs) + if intersection.area > 1e-9: + obstacle_collision_geoms.append(intersection) + + # Save for self-intersection check + dilated_for_self.append(poly.buffer(dilation_half)) + + # 3. Self-intersection + for i, seg_i in enumerate(dilated_for_self): + for j, seg_j in enumerate(dilated_for_self): + if j > i + 1 and seg_i.intersects(seg_j): # Non-adjacent + overlap = seg_i.intersection(seg_j) + if overlap.area > TOLERANCE_LINEAR: + self_intersection_geoms.append((i, j, overlap)) + + is_valid = (len(obstacle_collision_geoms) == 0 and + len(self_intersection_geoms) == 0 and + len(connectivity_errors) == 0) + + reasons = [] + if obstacle_collision_geoms: + reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.") + if self_intersection_geoms: + # report which indices + idx_str = ", ".join([f"{i}-{j}" for i, j, _ in self_intersection_geoms[:5]]) + reasons.append(f"Found {len(self_intersection_geoms)} self-intersections (e.g. {idx_str}).") + if connectivity_errors: + reasons.extend(connectivity_errors) + + return { + "is_valid": is_valid, + "reason": " ".join(reasons), + "obstacle_collisions": obstacle_collision_geoms, + "self_intersections": self_intersection_geoms, + "total_length": total_length, + "connectivity_ok": len(connectivity_errors) == 0, + } diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 8268c47..8e2a0d8 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -10,8 +10,7 @@ if TYPE_CHECKING: from matplotlib.figure import Figure from inire.geometry.primitives import Port - from inire.router.danger_map import DangerMap - from inire.results import RoutingResult + from inire.router.pathfinder import RoutingResult def plot_routing_results( @@ -51,7 +50,8 @@ def plot_routing_results( label_added = False for comp in res.path: # 1. Plot Collision Geometry (Translucent fill) - for poly in comp.collision_geometry: + # This is the geometry used during search (e.g. proxy or arc) + for poly in comp.geometry: if isinstance(poly, MultiPolygon): geoms = list(poly.geoms) else: @@ -66,7 +66,9 @@ def plot_routing_results( x, y = g.xy ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2) - actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry + # 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication) + # Use comp.actual_geometry if it exists (should be the arc) + actual_geoms_to_plot = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry for poly in actual_geoms_to_plot: if isinstance(poly, MultiPolygon): @@ -84,29 +86,27 @@ def plot_routing_results( # 3. Plot subtle port orientation arrow p = comp.end_port - rad = numpy.radians(p.r) + rad = numpy.radians(p.orientation) 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: - # Empty failed paths are typically unroutable. + # Best-effort display: If the path is empty but failed, it might be unroutable. + # We don't have a partial path in RoutingResult currently. pass # 4. Plot main arrows for netlist ports if netlist: - for _net_id, (start_p, target_p) in netlist.items(): + for net_id, (start_p, target_p) in netlist.items(): for p in [start_p, target_p]: - rad = numpy.radians(p.r) - ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", + rad = numpy.radians(p[2]) + ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black", scale=25, width=0.004, pivot="tail", zorder=6) ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3]) ax.set_aspect("equal") - if show_actual: - ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") - else: - ax.set_title("Inire Routing Results (Proxy Geometry)") + ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") # Legend handling for many nets if len(results) < 25: @@ -181,7 +181,7 @@ def plot_expanded_nodes( if not nodes: return fig, ax - x, y, _ = zip(*nodes, strict=False) + x, y, _ = zip(*nodes) ax.scatter(x, y, s=1, c=color, alpha=alpha, zorder=0) return fig, ax @@ -212,7 +212,7 @@ def plot_expansion_density( ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes) return fig, ax - x, y, _ = zip(*nodes, strict=False) + x, y, _ = zip(*nodes) # Create 2D histogram h, xedges, yedges = numpy.histogram2d( diff --git a/pyproject.toml b/pyproject.toml index efbd939..afbec0f 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 ]