diff --git a/DOCS.md b/DOCS.md index d83b018..d458bda 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,106 +1,175 @@ # Inire Configuration & API Documentation -This document describes the user-tunable parameters for the `inire` auto-router. +This document describes the current public API for `inire`. -## 1. AStarContext Parameters +## 1. Primary API -The `AStarContext` stores the configuration and persistent state for the A* search. It is initialized once and passed to `route_astar` or the `PathFinder`. +### `RoutingProblem` -| Parameter | Type | Default | Description | -| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | -| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `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"`. | +`RoutingProblem` describes the physical routing problem: -## 2. AStarMetrics +- `bounds` +- `nets` +- `static_obstacles` +- `initial_paths` +- `clearance` +- `safety_zone_radius` -The `AStarMetrics` object collects performance data during the search. +### `RoutingOptions` -| Property | Type | Description | -| :--------------------- | :---- | :---------------------------------------------------- | -| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. | -| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. | -| `max_depth_reached` | `int` | Deepest point in the search tree reached. | +`RoutingOptions` groups all expert controls for the routing engine: ---- +- `search` +- `objective` +- `congestion` +- `refinement` +- `diagnostics` -## 3. CostEvaluator Parameters +Route a problem with: -The `CostEvaluator` defines the "goodness" of a path. +```python +run = route(problem, options=options) +``` -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- | -| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | -| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. | -| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | +If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. ---- +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. -## 3. PathFinder Parameters +Stable example: -The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. +```python +from inire import route, RoutingOptions, RoutingProblem +``` -| 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.| +Unstable example: ---- +```python +from inire.router._router import PathFinder +``` -## 4. CollisionEngine Parameters +### Incremental routing with locked geometry -| 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. | +For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem: ---- +```python +run_a = route(problem_a) +problem_b = RoutingProblem( + bounds=problem_a.bounds, + nets=(...), + static_obstacles=run_a.results_by_net["netA"].locked_geometry, +) +run_b = route(problem_b) +``` -## 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. +`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. ---- +### Initial paths with `PathSeed` -## 5. Best Practices & Tuning Advice +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. -### 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. +## 2. Search Options -### 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`. +`RoutingOptions.search` is a `SearchOptions` object. -### 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. +| 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. | -### 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. +## 3. Objective Weights -### 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. +`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`. diff --git a/README.md b/README.md index b300c77..a66f699 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # inire: Auto-Routing for Photonic and RF Integrated Circuits -`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) to route multiple nets while maintaining strict geometric fidelity and clearance. +`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance. ## Key Features @@ -9,7 +9,7 @@ * **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths. * **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid. * **Safety & Proximity**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk. -* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions. +* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs. ## Installation @@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib ## Quick Start ```python -from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine -from inire.router.danger_map import DangerMap -from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route -# 1. Setup Environment -engine = CollisionEngine(clearance=2.0) -danger_map = DangerMap(bounds=(0, 0, 1000, 1000)) -danger_map.precompute([]) # Add polygons here for obstacles - -# 2. Configure Router -evaluator = CostEvaluator( - collision_engine=engine, - danger_map=danger_map, - greedy_h_weight=1.2 +problem = RoutingProblem( + bounds=(0, 0, 1000, 1000), + nets=( + NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0), + ), ) -context = AStarContext( - cost_evaluator=evaluator, - bend_penalty=10.0 +options = RoutingOptions( + search=SearchOptions( + bend_radii=(50.0, 100.0), + greedy_h_weight=1.2, + ), + objective=ObjectiveWeights( + bend_penalty=10.0, + ), ) -pf = PathFinder(context) -# 3. Define Netlist -netlist = { - "net1": (Port(0, 0, 0), Port(100, 50, 0)), -} +run = route(problem, options=options) -# 4. Route -results = pf.route_all(netlist, {"net1": 2.0}) - -if results["net1"].is_valid: +if run.results_by_net["net1"].is_valid: print("Successfully routed net1!") ``` +For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`. + ## Usage Examples For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**. @@ -71,10 +61,30 @@ 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: @@ -82,11 +92,11 @@ Full documentation for all user-tunable parameters, cost functions, and collisio 2. **90° Bends**: Fixed-radius PDK cells. 3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$). -For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. +For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings. ## Configuration -`inire` is highly tunable. Every major component (Router, CostEvaluator, PathFinder) accepts explicit named arguments in its constructor to control expansion rules, cost weights, and convergence limits. See `DOCS.md` for a full parameter reference. +`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference. ## License diff --git a/examples/01_simple_route.py b/examples/01_simple_route.py index 96fc4f9..2f43065 100644 --- a/examples/01_simple_route.py +++ b/examples/01_simple_route.py @@ -1,54 +1,29 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 01: Simple Route...") - # 1. Setup Environment - # We define a 100um x 100um routing area bounds = (0, 0, 100, 100) - - # Clearance of 2.0um between waveguides - engine = CollisionEngine(clearance=2.0) - - # Precompute DangerMap for heuristic speedup - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) # No obstacles yet - - # 2. Configure Router - evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # Start at (10, 50) pointing East (0 deg) - # Target at (90, 50) pointing East (0 deg) netlist = { "net1": (Port(10, 50, 0), Port(90, 50, 0)), } - net_widths = {"net1": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("net1", *netlist["net1"], width=2.0),), + ) + options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,))) - # 4. Route - results = pf.route_all(netlist, net_widths) - - # 5. Check Results - res = results["net1"] - if res.is_valid: + run = route(problem, options=options) + result = run.results_by_net["net1"] + if result.is_valid: print("Success! Route found.") - print(f"Path collisions: {res.collisions}") + print(f"Path collisions: {result.collisions}") else: print("Failed to find route.") - # 6. Visualize - # plot_routing_results takes a dict of RoutingResult objects - fig, ax = plot_routing_results(results, [], bounds) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/01_simple_route.png") print("Saved plot to examples/01_simple_route.png") diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index ffe8343..9d003bc 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -1,49 +1,41 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 02: Congestion Resolution (Triple Crossing)...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # Configure a router with high congestion penalties - evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics, base_congestion_penalty=1000.0) - - # 2. Define Netlist - # Three nets that must cross each other in a small area netlist = { "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + sbend_radii=(10.0,), + greedy_h_weight=1.5, + ), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + congestion=CongestionOptions(base_penalty=1000.0), + ) - # 3. Route - # PathFinder uses Negotiated Congestion to resolve overlaps iteratively - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - all_valid = all(res.is_valid for res in results.values()) + run = route(problem, options=options) + all_valid = all(result.is_valid for result in run.results_by_net.values()) if all_valid: print("Success! Congestion resolved for all nets.") else: print("Failed to resolve congestion for some nets.") - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/02_congestion_resolution.png") print("Saved plot to examples/02_congestion_resolution.png") diff --git a/examples/03_locked_paths.png b/examples/03_locked_paths.png index d767df9..687dad6 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 642c6f3..ed309a8 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -1,42 +1,37 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 03: Locked Paths...") - # 1. Setup Environment bounds = (0, -50, 100, 50) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Route Net A and 'Lock' it - # Net A is a straight path blocking the direct route for Net B + options = RoutingOptions( + search=SearchOptions(bend_radii=(10.0,)), + objective=ObjectiveWeights( + bend_penalty=250.0, + sbend_penalty=500.0, + ), + ) print("Routing initial net...") - netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) - - # Locking prevents Net A from being removed or rerouted during NC iterations - engine.lock_net("netA") - print("Initial net locked as static obstacle.") + results_a = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),), + ), + options=options, + ).results_by_net - # 3. Route Net B (forced to detour) print("Routing detour net around locked path...") - netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))} - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = route( + RoutingProblem( + bounds=bounds, + nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), + static_obstacles=results_a["netA"].locked_geometry, + ), + options=options, + ).results_by_net - # 4. Visualize results = {**results_a, **results_b} fig, ax = plot_routing_results(results, [], bounds) fig.savefig("examples/03_locked_paths.png") diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index a88159a..42eac9c 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,60 +1,38 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 04: S-Bends and Multiple Radii...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - # 2. Configure Router - evaluator = CostEvaluator( - engine, - danger_map, - unit_length_cost=1.0, - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - context = AStarContext( - evaluator, - node_limit=50000, - bend_radii=[10.0, 30.0], - sbend_offsets=[5.0], # Use a simpler offset - bend_penalty=10.0, - sbend_penalty=20.0, - ) - - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 3. Define Netlist - # start (10, 50), target (60, 55) -> 5um offset netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } - net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=50000, + bend_radii=(10.0, 30.0), + sbend_offsets=(5.0,), + ), + objective=ObjectiveWeights( + unit_length_cost=1.0, + bend_penalty=10.0, + sbend_penalty=20.0, + ), + ) - # 4. Route - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f"{net_id}: {status}, collisions={result.collisions}") - # 5. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f"{nid}: {status}, collisions={res.collisions}") - - # 6. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/04_sbends_and_radii.png") print("Saved plot to examples/04_sbends_and_radii.png") diff --git a/examples/05_orientation_stress.png b/examples/05_orientation_stress.png index 94bab94..b750c6a 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 4e434c8..eab3c0e 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -1,46 +1,32 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder +from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route from inire.utils.visualization import plot_routing_results def main() -> None: print("Running Example 05: Orientation Stress Test...") - # 1. Setup Environment bounds = (0, 0, 200, 200) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0) - context = AStarContext(evaluator, bend_radii=[20.0]) - metrics = AStarMetrics() - pf = PathFinder(context, metrics) - - # 2. Define Netlist - # Challenging orientation combinations netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + ) + options = RoutingOptions( + search=SearchOptions(bend_radii=(20.0,)), + objective=ObjectiveWeights(bend_penalty=50.0), + ) - # 3. Route print("Routing complex orientation nets...") - results = pf.route_all(netlist, net_widths) + run = route(problem, options=options) + for net_id, result in run.results_by_net.items(): + status = "Success" if result.is_valid else "Failed" + print(f" {net_id}: {status}") - # 4. Check Results - for nid, res in results.items(): - status = "Success" if res.is_valid else "Failed" - print(f" {nid}: {status}") - - # 5. Visualize - fig, ax = plot_routing_results(results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist) fig.savefig("examples/05_orientation_stress.png") print("Saved plot to examples/05_orientation_stress.png") diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index 4036d0d..fa2c49f 100644 Binary files a/examples/06_bend_collision_models.png and b/examples/06_bend_collision_models.png differ diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py index 324743b..8c3c06a 100644 --- a/examples/06_bend_collision_models.py +++ b/examples/06_bend_collision_models.py @@ -1,65 +1,70 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder from inire.utils.visualization import plot_routing_results +def _route_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 = PathFinder(context_arc, use_tiered_strategy=False).route_all(netlist_arc, {"arc_model": 2.0}) - + res_arc = _route_scenario(bounds, obstacles, "arc", 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}) - + res_bbox = _route_scenario(bounds, obstacles, "bbox", 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}) + res_clipped = _route_scenario( + bounds, + obstacles, + "clipped_bbox", + netlist_clipped, + {"clipped_model": 2.0}, + bend_clip_margin=1.0, + ) - # 3. Combine results for visualization all_results = {**res_arc, **res_bbox, **res_clipped} all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} - # 4. Visualize - fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) fig.savefig("examples/06_bend_collision_models.png") print("Saved plot to examples/06_bend_collision_models.png") diff --git a/examples/07_large_scale_routing.py b/examples/07_large_scale_routing.py index ef92ea2..92098fb 100644 --- a/examples/07_large_scale_routing.py +++ b/examples/07_large_scale_routing.py @@ -1,108 +1,120 @@ -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) - for i in range(num_nets): - sy = int(round(start_y_base + i * 10.0)) - ey = int(round(end_y_base + i * end_y_pitch)) - netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0)) + netlist: dict[str, tuple[Port, Port]] = {} + for index in range(num_nets): + start_y = int(round(start_y_base + index * 10.0)) + end_y = int(round(end_y_base + index * end_y_pitch)) + netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0)) - net_widths = {nid: 2.0 for nid in netlist} + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + static_obstacles=tuple(obstacles), + clearance=6.0, + ) + from inire import CongestionOptions, DiagnosticsOptions, ObjectiveWeights, RoutingOptions, SearchOptions - # 3. Route - print(f"Routing {len(netlist)} nets through 200um bottleneck...") + 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 - iteration_stats = [] + iteration_stats: list[dict[str, int]] = [] - 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()) + def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None: + successes = sum(1 for result in current_results.values() if result.is_valid) + total_collisions = sum(result.collisions for result in current_results.values()) total_nodes = metrics.nodes_expanded - - print(f" Iteration {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) + print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}") + new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) evaluator.greedy_h_weight = new_greedy print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}") - - iteration_stats.append({ - 'Iteration': idx, - 'Success': successes, - 'Congestion': total_collisions, - 'Nodes': total_nodes - }) + iteration_stats.append( + { + "Iteration": iteration, + "Success": successes, + "Congestion": total_collisions, + "Nodes": total_nodes, + } + ) metrics.reset_per_route() - t0 = time.perf_counter() - results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42) - t1 = time.perf_counter() + print(f"Routing {len(netlist)} nets through 200um bottleneck...") + start_time = time.perf_counter() + results = finder.route_all(iteration_callback=iteration_callback) + end_time = time.perf_counter() - print(f"Routing took {t1-t0:.4f}s") - - # 4. Check Results + print(f"Routing took {end_time - start_time:.4f}s") print("\n--- Iteration Summary ---") print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}") - print("-" * 40) - for s in iteration_stats: - print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}") + print("-" * 43) + for stats in iteration_stats: + print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8} | {stats['Nodes']:<10}") - success_count = sum(1 for res in results.values() if res.is_valid) + success_count = sum(1 for result in results.values() if result.is_valid) print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.") - - for nid, res in results.items(): - if not res.is_valid: - print(f" FAILED: {nid}, collisions={res.collisions}") + for net_id, result in results.items(): + if not result.is_valid: + print(f" FAILED: {net_id}, collisions={result.collisions}") else: - print(f" {nid}: SUCCESS") - - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) - plot_danger_map(danger_map, ax=ax) + print(f" {net_id}: SUCCESS") + fig, ax = plot_routing_results(results, list(obstacles), bounds, netlist=netlist) + plot_expanded_nodes(list(finder.accumulated_expanded_nodes), ax=ax) fig.savefig("examples/07_large_scale_routing.png") print("Saved plot to examples/07_large_scale_routing.png") + if __name__ == "__main__": main() diff --git a/examples/08_custom_bend_geometry.png b/examples/08_custom_bend_geometry.png index 72560e3..48c2e5c 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 81331be..25e715b 100644 --- a/examples/08_custom_bend_geometry.py +++ b/examples/08_custom_bend_geometry.py @@ -1,54 +1,76 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics, route_astar +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.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 = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[]) metrics = AStarMetrics() - pf = PathFinder(context, metrics) + start = Port(20, 20, 0) + target = Port(100, 100, 90) - # 2. Define Netlist - netlist = { - "custom_bend": (Port(20, 20, 0), Port(100, 100, 90)), - } - net_widths = {"custom_bend": 2.0} - - # 3. Route with standard arc first print("Routing with standard arc...") - results_std = pf.route_all(netlist, net_widths) + 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() - # 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...") - # 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} - ) + 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() - # 5. Visualize all_results = {**results_std, **results_custom} - fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist) + fig, _ax = plot_routing_results( + all_results, + [], + bounds, + netlist={ + "custom_bend": (start, target), + "custom_model": (start, target), + }, + ) fig.savefig("examples/08_custom_bend_geometry.png") print("Saved plot to examples/08_custom_bend_geometry.png") diff --git a/examples/09_unroutable_best_effort.py b/examples/09_unroutable_best_effort.py index 659a16c..1aeb152 100644 --- a/examples/09_unroutable_best_effort.py +++ b/examples/09_unroutable_best_effort.py @@ -1,58 +1,46 @@ -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results from shapely.geometry import box +from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route +from inire.utils.visualization import plot_routing_results + + def main() -> None: print("Running Example 09: Best-Effort Under Tight Search Budget...") - # 1. Setup Environment bounds = (0, 0, 100, 100) - engine = CollisionEngine(clearance=2.0) - - # A small obstacle cluster keeps the partial route visually interesting. obstacles = [ box(35, 35, 45, 65), box(55, 35, 65, 65), ] - for obs in obstacles: - engine.add_static_obstacle(obs) + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),), + static_obstacles=tuple(obstacles), + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=3, + bend_radii=(10.0,), + ), + objective=ObjectiveWeights( + bend_penalty=50.0, + sbend_penalty=150.0, + ), + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), + ) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute(obstacles) - - evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) - # Keep the search budget intentionally tiny so the router returns a partial path. - context = AStarContext(evaluator, node_limit=3, bend_radii=[10.0]) - metrics = AStarMetrics() - - pf = PathFinder(context, metrics, warm_start=None) - - # 2. Define Netlist: reaching the target requires additional turns the search budget cannot afford. - netlist = { - "budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180)), - } - net_widths = {"budget_limited_net": 2.0} - - # 3. Route print("Routing with a deliberately tiny node budget (should return a partial path)...") - results = pf.route_all(netlist, net_widths) - - # 4. Check Results - res = results["budget_limited_net"] - if not res.reached_target: - print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.") + run = route(problem, options=options) + result = run.results_by_net["budget_limited_net"] + if not result.reached_target: + print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.") else: print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.") - # 5. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist) + fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}) fig.savefig("examples/09_unroutable_best_effort.png") print("Saved plot to examples/09_unroutable_best_effort.png") + if __name__ == "__main__": main() diff --git a/examples/README.md b/examples/README.md index 208f127..cfea579 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,8 @@ 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 5fb5ffb..f6ee2d5 100644 --- a/inire/__init__.py +++ b/inire/__init__.py @@ -1,8 +1,59 @@ """ inire Wave-router """ +from collections.abc import Callable + from .geometry.primitives import Port as Port # noqa: PLC0414 -from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 +from .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 __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 cdc2f62..bfbebe6 100644 --- a/inire/constants.py +++ b/inire/constants.py @@ -2,11 +2,5 @@ Centralized constants for the inire routing engine. """ -# Search Grid Snap (5.0 µm default) -# TODO: Make this configurable in 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 5f9fbc6..5d5b13b 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,654 +1,457 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal -import rtree +from typing import TYPE_CHECKING + import numpy -import shapely -from shapely.prepared import prep -from shapely.strtree import STRtree -from shapely.geometry import box, LineString +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 if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from shapely.geometry import Polygon - from shapely.prepared import PreparedGeometry - from inire.geometry.primitives import Port + from shapely.geometry.base import BaseGeometry + from shapely.strtree import STRtree + from inire.geometry.components import ComponentResult + from inire.geometry.primitives import Port -class CollisionEngine: +def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float: + if hasattr(geometry, "geoms"): + return min(_intersection_distance(origin, sub_geometry) for sub_geometry in geometry.geoms) + return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2)) + + +class RoutingWorld: """ - Manages spatial queries for collision detection with unified dilation logic. + Internal spatial state for collision detection, congestion, and verification. """ + __slots__ = ( - 'clearance', 'max_net_width', 'safety_zone_radius', - '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' + "clearance", + "safety_zone_radius", + "grid_cell_size", + "_dynamic_paths", + "_static_obstacles", ) def __init__( - self, - clearance: float, - max_net_width: float = 2.0, - safety_zone_radius: float = 0.0021, - ) -> None: + self, + clearance: float, + 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._inv_grid_cell_size = 1.0 / self.grid_cell_size - self._static_id_counter = 0 + self._static_obstacles = StaticObstacleIndex(self) + self._dynamic_paths = DynamicPathIndex(self) - # 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=' int: + return self._static_obstacles.version - def reset_metrics(self) -> None: - for k in self.metrics: - self.metrics[k] = 0 + def iter_static_dilated_geometries(self) -> Iterable[Polygon]: + return self._static_obstacles.dilated.values() - 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 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 add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: - 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 + return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) def remove_static_obstacle(self, obj_id: int) -> None: - """ - Remove a static obstacle by ID. - """ - 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 + self._static_obstacles.remove_obstacle(obj_id) def _ensure_static_tree(self) -> None: - 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]) + self._static_obstacles.ensure_tree() def _ensure_net_static_tree(self, net_width: float) -> STRtree: - """ - Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance). - """ - 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 + return self._static_obstacles.ensure_net_tree(net_width) def _ensure_static_raw_tree(self) -> None: - 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) + self._static_obstacles.ensure_raw_tree() def _ensure_dynamic_tree(self) -> None: - 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: - 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) + self._dynamic_paths.ensure_grid() - 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 add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: + self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) def remove_path(self, net_id: str) -> None: - 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) + self._dynamic_paths.remove_path(net_id) def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: - self.metrics['static_straight_fast'] += 1 - reach = self.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width) + reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) return reach < length - 0.001 def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool: - """ 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 + 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 + return False - 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 + 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 break - - 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 + + if found_real: + real_hits_count += 1 + + return real_hits_count def check_move_congestion(self, result: ComponentResult, net_id: str) -> int: - if not self.dynamic_geometries: return 0 - tb = result.total_dilated_bounds - if tb is None: return 0 + dynamic_paths = self._dynamic_paths + if not dynamic_paths.geometries: + return 0 + + total_bounds = result.total_dilated_bounds self._ensure_dynamic_grid() - 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 + dynamic_grid = dynamic_paths.grid + if not dynamic_grid: + return 0 + + gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size) + if gx_min == gx_max and gy_min == gy_max: cell = (gx_min, gy_min) if cell in dynamic_grid: for obj_id in dynamic_grid[cell]: - if dynamic_geometries[obj_id][0] != net_id: + if dynamic_paths.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_geometries[obj_id][0] != net_id: + if dynamic_paths.geometries[obj_id][0] != net_id: any_possible = True break - if any_possible: break - if any_possible: break - - if not any_possible: return 0 + if any_possible: + break + if any_possible: + break + + if not any_possible: + return 0 return self._check_real_congestion(result, net_id) - def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: - self.metrics['congestion_tree_queries'] += 1 - self._ensure_dynamic_tree() - 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 + 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) - # 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 + static_obstacles = self._static_obstacles + dynamic_paths = self._dynamic_paths - 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 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 - - 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 - - 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 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 + if static_obstacles.raw_tree is not None: + raw_geometries = static_obstacles.raw_tree.geometries + for component in components: + for polygon in component.physical_geometry: + buffered = polygon.buffer(self.clearance, join_style=2) + hits = static_obstacles.raw_tree.query(buffered, predicate="intersects") + for hit_idx in hits: + obstacle = raw_geometries[hit_idx] + if buffered.touches(obstacle): + continue + + obj_id = static_obstacles.raw_obj_ids[hit_idx] + if not self._is_in_safety_zone(polygon, obj_id, None, None): + static_collision_count += 1 + self._ensure_dynamic_tree() - if 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] + 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 - 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)) + 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 - return (collision_count == 0), collision_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 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) + if component_hits: + dynamic_collision_count += len(numpy.unique(component_hits)) + + for index, component in enumerate(components): + for other_index in range(index + 2, len(components)): + if components_overlap(component, components[other_index], prefer_actual=True): + self_collision_count += 1 + + return RoutingReport( + static_collision_count=static_collision_count, + dynamic_collision_count=dynamic_collision_count, + self_collision_count=self_collision_count, + total_length=total_length, + ) + + def ray_cast( + self, + origin: Port, + angle_deg: float, + max_dist: float = 2000.0, + net_width: float | None = None, + ) -> float: + static_obstacles = self._static_obstacles + + radians = numpy.radians(angle_deg) + cos_v, sin_v = numpy.cos(radians), numpy.sin(radians) dx, dy = max_dist * cos_v, max_dist * sin_v min_x, max_x = sorted([origin.x, origin.x + dx]) min_y, max_y = sorted([origin.y, origin.y + dy]) - - 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_arr = self._net_specific_is_rect[key] - bounds_arr = self._net_specific_bounds[key] - else: - 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 + if net_width is not None: + tree = self._ensure_net_static_tree(net_width) + key = (round(net_width, 4), round(self.clearance, 4)) + is_rect_array = static_obstacles.net_specific_is_rect[key] + bounds_array = static_obstacles.net_specific_bounds[key] + else: + self._ensure_static_tree() + tree = static_obstacles.tree + is_rect_array = static_obstacles.is_rect_array + bounds_array = static_obstacles.bounds_array + + if tree is None: + return max_dist + candidates = tree.query(box(min_x, min_y, max_x, max_y)) - if candidates.size == 0: return max_dist - + if candidates.size == 0: + return max_dist + min_dist = max_dist inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30 inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30 - - tree_geoms = tree.geometries + tree_geometries = tree.geometries ray_line = None - - # 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 + + candidates_bounds = bounds_array[candidates] + dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2 sorted_indices = numpy.argsort(dist_sq) - + for idx in sorted_indices: - 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 + candidate_id = candidates[idx] + bounds = bounds_array[candidate_id] + + if abs(dx) < 1e-12: + if origin.x < bounds[0] or origin.x > bounds[2]: + tx_min, tx_max = 1e30, -1e30 + else: + tx_min, tx_max = -1e30, 1e30 else: - t1, t2 = (b[0] - origin.x) * inv_dx, (b[2] - origin.x) * inv_dx + t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx tx_min, tx_max = min(t1, t2), max(t1, t2) - - if abs(dy) < 1e-12: # 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 + + if abs(dy) < 1e-12: + if origin.y < bounds[1] or origin.y > bounds[3]: + ty_min, ty_max = 1e30, -1e30 + else: + ty_min, ty_max = -1e30, 1e30 else: - t1, t2 = (b[1] - origin.y) * inv_dy, (b[3] - origin.y) * inv_dy + t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy ty_min, ty_max = min(t1, t2), max(t1, t2) - + t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max) - - # 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 + if t_max < 0 or t_min > t_max or t_min > 1.0: + continue + if t_min * max_dist >= min_dist: + continue + + if is_rect_array[candidate_id]: min_dist = max(0.0, t_min * max_dist) continue - - # Fallback to full geometry check for non-rectangles (arcs, etc.) - if ray_line is None: + + if ray_line is None: ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)]) - - 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 - + + obstacle = tree_geometries[candidate_id] + if not obstacle.intersects(ray_line): + continue + + intersection = ray_line.intersection(obstacle) + if intersection.is_empty: + continue + + distance = _intersection_distance(origin, intersection) + min_dist = min(min_dist, distance) + return min_dist diff --git a/inire/geometry/component_overlap.py b/inire/geometry/component_overlap.py new file mode 100644 index 0000000..816508d --- /dev/null +++ b/inire/geometry/component_overlap.py @@ -0,0 +1,51 @@ +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 9e755cf..714ef55 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,67 +1,66 @@ 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, TOLERANCE_LINEAR +from inire.constants import TOLERANCE_ANGULAR +from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed from .primitives import Port, rotation_matrix2 +MoveKind = Literal["straight", "bend90", "sbend"] +BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"] +BendCollisionModel = BendCollisionModelName | Polygon + + def _normalize_length(value: float) -> float: return float(value) +@dataclass(frozen=True, slots=True) class ComponentResult: - __slots__ = ( - "geometry", - "dilated_geometry", - "proxy_geometry", - "actual_geometry", - "dilated_actual_geometry", - "end_port", - "length", - "move_type", - "_bounds", - "_total_bounds", - "_dilated_bounds", - "_total_dilated_bounds", - ) + start_port: Port + collision_geometry: tuple[Polygon, ...] + end_port: Port + length: float + move_type: MoveKind + 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) - 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 + def __post_init__(self) -> None: + collision_geometry = tuple(self.collision_geometry) + physical_geometry = tuple(self.physical_geometry) + dilated_collision_geometry = tuple(self.dilated_collision_geometry) + dilated_physical_geometry = tuple(self.dilated_physical_geometry) - self._bounds = [poly.bounds for poly in self.geometry] - self._total_bounds = _combine_bounds(self._bounds) + object.__setattr__(self, "collision_geometry", collision_geometry) + object.__setattr__(self, "physical_geometry", physical_geometry) + object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry) + object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry) + object.__setattr__(self, "length", float(self.length)) - if self.dilated_geometry is None: - self._dilated_bounds = None - self._total_dilated_bounds = None - else: - self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry] - self._total_dilated_bounds = _combine_bounds(self._dilated_bounds) + bounds = tuple(poly.bounds for poly in collision_geometry) + object.__setattr__(self, "_bounds", bounds) + object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds))) + + dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry) + object.__setattr__(self, "_dilated_bounds", dilated_bounds) + object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds))) @property - def bounds(self) -> list[tuple[float, float, float, float]]: + def bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._bounds @property @@ -69,23 +68,24 @@ class ComponentResult: return self._total_bounds @property - def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None: + def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]: return self._dilated_bounds @property - def total_dilated_bounds(self) -> tuple[float, float, float, float] | None: + def total_dilated_bounds(self) -> tuple[float, float, float, float]: return self._total_dilated_bounds def translate(self, dx: int | float, dy: int | float) -> ComponentResult: return ComponentResult( - geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry], - end_port=self.end_port + [dx, dy, 0], + start_port=self.start_port.translate(dx, dy), + collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry], + end_port=self.end_port.translate(dx, dy), length=self.length, move_type=self.move_type, - dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry], - proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry], - actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry], - dilated_actual_geometry=None if self.dilated_actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry], + 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], ) @@ -134,7 +134,13 @@ def _get_arc_polygons( return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))] -def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon: +def _clip_bbox_legacy( + cxy: tuple[float, float], + radius: float, + width: float, + ts: tuple[float, float], + clip_margin: float, +) -> Polygon: arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0] minx, miny, maxx, maxy = arc_poly.bounds bbox_poly = box(minx, miny, maxx, maxy) @@ -142,17 +148,76 @@ def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[ 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: Literal["arc", "bbox", "clipped_bbox"] | Polygon, + collision_type: BendCollisionModel, 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 [shapely_translate(collision_type, cxy[0], cxy[1])] + return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)] if collision_type == "arc": return [arc_poly] if collision_type == "clipped_bbox": @@ -179,21 +244,31 @@ 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( - geometry=geometry, + start_port=start_port, + collision_geometry=geometry, end_port=end_port, length=abs(length_f), - move_type="Straight", - dilated_geometry=dilated_geometry, - actual_geometry=geometry, - dilated_actual_geometry=dilated_geometry, + move_type="straight", + move_spec=StraightSeed(length=length_f), + physical_geometry=geometry, + dilated_collision_geometry=dilated_geometry, + dilated_physical_geometry=dilated_geometry, ) @@ -205,8 +280,8 @@ class Bend90: width: float, direction: Literal["CW", "CCW"], sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", + clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: rot2 = rotation_matrix2(start_port.r) @@ -229,37 +304,39 @@ 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), ) - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = _apply_collision_model( - arc_polys[0], - "clipped_bbox", + physical_geometry = arc_polys + if dilation > 0: + dilated_physical_geometry = _get_arc_polygons( + (float(center_xy[0]), float(center_xy[1])), radius, width, - (float(center_xy[0]), float(center_xy[1])), - clip_margin, ts, + sagitta, + dilation=dilation, ) - - 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] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = collision_polys return ComponentResult( - geometry=collision_polys, + start_port=start_port, + collision_geometry=collision_polys, end_port=end_port, length=abs(radius) * numpy.pi / 2.0, - move_type="Bend90", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=arc_polys, - dilated_actual_geometry=dilated_actual_geometry, + 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, ) @@ -271,8 +348,8 @@ class SBend: radius: float, width: float, sagitta: float = 0.01, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", - clip_margin: float = 10.0, + collision_type: BendCollisionModel = "arc", + clip_margin: float | None = None, dilation: float = 0.0, ) -> ComponentResult: if abs(offset) >= 2 * radius: @@ -301,33 +378,51 @@ 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])), clip_margin, ts1)[0], - _apply_collision_model(arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], + _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], ] - proxy_geometry = None - if collision_type == "arc": - proxy_geometry = [ - _apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0], - _apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0], - ] - - dilated_actual_geometry = None - dilated_geometry = None + physical_geometry = actual_geometry if dilation > 0: - dilated_actual_geometry = [ + dilated_physical_geometry = [ _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0], _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0], ] - dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + dilated_collision_geometry = ( + dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry] + ) + else: + dilated_physical_geometry = physical_geometry + dilated_collision_geometry = geometry return ComponentResult( - geometry=geometry, + start_port=start_port, + collision_geometry=geometry, end_port=end_port, length=2.0 * radius * theta, - move_type="SBend", - dilated_geometry=dilated_geometry, - proxy_geometry=proxy_geometry, - actual_geometry=actual_geometry, - dilated_actual_geometry=dilated_actual_geometry, + move_type="sbend", + move_spec=SBendSeed(offset=offset, radius=radius), + physical_geometry=physical_geometry, + dilated_collision_geometry=dilated_collision_geometry, + dilated_physical_geometry=dilated_physical_geometry, ) diff --git a/inire/geometry/dynamic_path_index.py b/inire/geometry/dynamic_path_index.py new file mode 100644 index 0000000..d8363f6 --- /dev/null +++ b/inire/geometry/dynamic_path_index.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..dad186b --- /dev/null +++ b/inire/geometry/index_helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import math +from collections.abc import Iterator, Mapping +from typing import TypeVar + +import numpy + +GeometryT = TypeVar("GeometryT") + + +def build_index_payload( + geometries: Mapping[int, GeometryT], +) -> tuple[list[int], list[GeometryT], numpy.ndarray]: + obj_ids = sorted(geometries) + ordered_geometries = [geometries[obj_id] for obj_id in obj_ids] + bounds_array = numpy.array([geometry.bounds for geometry in ordered_geometries], dtype=numpy.float64) + if not ordered_geometries: + bounds_array = bounds_array.reshape(0, 4) + return obj_ids, ordered_geometries, bounds_array + + +def grid_cell_span( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> tuple[int, int, int, int]: + return ( + math.floor(bounds[0] / cell_size), + math.floor(bounds[1] / cell_size), + math.floor(bounds[2] / cell_size), + math.floor(bounds[3] / cell_size), + ) + + +def iter_grid_cells( + bounds: tuple[float, float, float, float], + cell_size: float, +) -> Iterator[tuple[int, int]]: + gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, cell_size) + for gx in range(gx_min, gx_max + 1): + for gy in range(gy_min, gy_max + 1): + yield (gx, gy) + + +def is_axis_aligned_rect(geometry, *, tolerance: float = 1e-4) -> bool: + bounds = geometry.bounds + area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) + return abs(geometry.area - area) < tolerance diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index b6d6b9c..b42b267 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,10 +1,10 @@ from __future__ import annotations -from collections.abc import Iterator +from dataclasses import dataclass from typing import Self import numpy -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray def _normalize_angle(angle_deg: int | float) -> int: @@ -13,119 +13,43 @@ def _normalize_angle(angle_deg: int | float) -> int: raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}") return angle - -def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]: - arr = numpy.asarray(value, dtype=numpy.int32) - if arr.shape != (3,): - raise ValueError(f"Port array must have shape (3,), got {arr.shape}") - arr = arr.copy() - arr[2] = _normalize_angle(int(arr[2])) - return arr - - +@dataclass(frozen=True, slots=True) class Port: """ - Port represented as an ndarray-backed (x, y, r) triple with int32 storage. + Port represented as a normalized integer (x, y, r) triple. """ - __slots__ = ("_xyr",) + x: int | float + y: int | float + r: int | float - def __init__(self, x: int | float, y: int | float, r: int | float) -> None: - self._xyr = numpy.array( - (int(round(x)), int(round(y)), _normalize_angle(r)), - dtype=numpy.int32, - ) - - @classmethod - def from_array(cls, xyr: ArrayLike) -> Self: - obj = cls.__new__(cls) - obj._xyr = _as_int32_triplet(xyr) - return obj - - @property - def x(self) -> int: - return int(self._xyr[0]) - - @x.setter - def x(self, val: int | float) -> None: - self._xyr[0] = int(round(val)) - - @property - def y(self) -> int: - return int(self._xyr[1]) - - @y.setter - def y(self, val: int | float) -> None: - self._xyr[1] = int(round(val)) - - @property - def r(self) -> int: - return int(self._xyr[2]) - - @r.setter - def r(self, val: int | float) -> None: - self._xyr[2] = _normalize_angle(val) - - @property - def orientation(self) -> int: - return self.r - - @orientation.setter - def orientation(self, val: int | float) -> None: - self.r = val - - @property - def xyr(self) -> NDArray[numpy.int32]: - return self._xyr - - @xyr.setter - def xyr(self, val: ArrayLike) -> None: - self._xyr = _as_int32_triplet(val) - - def __repr__(self) -> str: - return f"Port(x={self.x}, y={self.y}, r={self.r})" - - def __iter__(self) -> Iterator[int]: - return iter((self.x, self.y, self.r)) - - def __len__(self) -> int: - return 3 - - def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]: - return self._xyr[item] - - def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]: - return numpy.asarray(self._xyr, dtype=dtype) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Port): - return False - return bool(numpy.array_equal(self._xyr, other._xyr)) - - def __hash__(self) -> int: - return hash(self.as_tuple()) - - def copy(self) -> Self: - return type(self).from_array(self._xyr.copy()) + def __post_init__(self) -> None: + object.__setattr__(self, "x", int(round(self.x))) + object.__setattr__(self, "y", int(round(self.y))) + object.__setattr__(self, "r", _normalize_angle(self.r)) def as_tuple(self) -> tuple[int, int, int]: - return (self.x, self.y, self.r) + return (int(self.x), int(self.y), int(self.r)) - def translate(self, dxy: ArrayLike) -> Self: - dxy_arr = numpy.asarray(dxy, dtype=numpy.int32) - if dxy_arr.shape == (2,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r) - if dxy_arr.shape == (3,): - return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2])) - raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}") + def translate( + self, + dx: int | float = 0, + dy: int | float = 0, + rotation: int | float = 0, + ) -> Self: + return type(self)(self.x + dx, self.y + dy, self.r + rotation) - def __add__(self, other: ArrayLike) -> Self: - return self.translate(other) - - def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]: - if isinstance(other, Port): - return self._xyr - other._xyr - return self._xyr - numpy.asarray(other, dtype=numpy.int32) + def rotated( + self, + angle: int | float, + origin: tuple[int | float, int | float] = (0, 0), + ) -> Self: + angle_i = _normalize_angle(angle) + rot = rotation_matrix2(angle_i) + origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32) + rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy + rotated = origin_xy + rot @ rel + return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i) ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32) @@ -137,24 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32) def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: quadrant = (_normalize_angle(rotation_deg) // 90) % 4 return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] - - -def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]: - rot2 = rotation_matrix2(rotation_deg) - rot3 = numpy.zeros((3, 3), dtype=numpy.int32) - rot3[:2, :2] = rot2 - rot3[2, 2] = 1 - return rot3 - - -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 new file mode 100644 index 0000000..3f3ab38 --- /dev/null +++ b/inire/geometry/static_obstacle_index.py @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..5100899 --- /dev/null +++ b/inire/model.py @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..d16e92c --- /dev/null +++ b/inire/results.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from inire.seeds import PathSeed + +if TYPE_CHECKING: + from shapely.geometry import Polygon + + from inire.geometry.components import ComponentResult + + +RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] + + +@dataclass(frozen=True, slots=True) +class RoutingReport: + static_collision_count: int = 0 + dynamic_collision_count: int = 0 + self_collision_count: int = 0 + total_length: float = 0.0 + + @property + def collision_count(self) -> int: + return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count + + @property + def is_valid(self) -> bool: + return self.collision_count == 0 + + +@dataclass(frozen=True, slots=True) +class RouteMetrics: + nodes_expanded: int + moves_generated: int + moves_added: int + pruned_closed_set: int + pruned_hard_collision: int + pruned_cost: int + + +@dataclass(frozen=True, slots=True) +class RoutingResult: + net_id: str + path: tuple[ComponentResult, ...] + reached_target: bool = False + report: RoutingReport = field(default_factory=RoutingReport) + + def __post_init__(self) -> None: + object.__setattr__(self, "path", tuple(self.path)) + + @property + def collisions(self) -> int: + return self.report.collision_count + + @property + def outcome(self) -> RoutingOutcome: + if not self.path: + return "unroutable" + if not self.reached_target: + return "partial" + if self.report.collision_count > 0: + return "colliding" + return "completed" + + @property + def is_valid(self) -> bool: + return self.outcome == "completed" + + @property + def locked_geometry(self) -> tuple[Polygon, ...]: + polygons = [] + for component in self.path: + polygons.extend(component.physical_geometry) + return tuple(polygons) + + def as_seed(self) -> PathSeed: + return PathSeed(tuple(component.move_spec for component in self.path)) + + +@dataclass(frozen=True, slots=True) +class RoutingRunResult: + results_by_net: dict[str, RoutingResult] + metrics: RouteMetrics + expanded_nodes: tuple[tuple[int, int, int], ...] = () diff --git a/inire/router/_astar_admission.py b/inire/router/_astar_admission.py new file mode 100644 index 0000000..ff075cd --- /dev/null +++ b/inire/router/_astar_admission.py @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000..71ca920 --- /dev/null +++ b/inire/router/_astar_moves.py @@ -0,0 +1,286 @@ +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 new file mode 100644 index 0000000..6bf2b37 --- /dev/null +++ b/inire/router/_astar_types.py @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000..5aaf00c --- /dev/null +++ b/inire/router/_router.py @@ -0,0 +1,318 @@ +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 new file mode 100644 index 0000000..2cf7daa --- /dev/null +++ b/inire/router/_search.py @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..f370db6 --- /dev/null +++ b/inire/router/_seed_materialization.py @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..71aa119 --- /dev/null +++ b/inire/router/_stack.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from inire.model import RoutingOptions, RoutingProblem + + +@dataclass(frozen=True, slots=True) +class RoutingStack: + world: object + danger_map: object + evaluator: object + context: object + finder: object + + +def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack: + from inire.geometry.collision import RoutingWorld + from inire.router._astar_types import AStarContext + from inire.router._router import PathFinder + from inire.router.cost import CostEvaluator + from inire.router.danger_map import DangerMap + + world = RoutingWorld( + clearance=problem.clearance, + safety_zone_radius=problem.safety_zone_radius, + ) + for obstacle in problem.static_obstacles: + world.add_static_obstacle(obstacle) + + danger_map = DangerMap(bounds=problem.bounds) + danger_map.precompute(list(problem.static_obstacles)) + + objective = options.objective + evaluator = CostEvaluator( + world, + danger_map, + unit_length_cost=objective.unit_length_cost, + greedy_h_weight=options.search.greedy_h_weight, + bend_penalty=objective.bend_penalty, + sbend_penalty=objective.sbend_penalty, + danger_weight=objective.danger_weight, + ) + context = AStarContext(evaluator, problem, options) + finder = PathFinder(context) + return RoutingStack( + world=world, + danger_map=danger_map, + evaluator=evaluator, + context=context, + finder=finder, + ) diff --git a/inire/router/astar.py b/inire/router/astar.py deleted file mode 100644 index 44e59da..0000000 --- a/inire/router/astar.py +++ /dev/null @@ -1,721 +0,0 @@ -from __future__ import annotations - -import heapq -import logging -import math -from typing import TYPE_CHECKING, Any, Literal - -import shapely - -from inire.constants import TOLERANCE_LINEAR -from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port -from inire.router.config import RouterConfig, VisibilityGuidanceMode -from inire.router.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 deleted file mode 100644 index 7a49a2f..0000000 --- a/inire/router/config.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Literal, Any - - -VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"] - - - -@dataclass -class RouterConfig: - """Configuration parameters for the A* Router.""" - - node_limit: int = 1000000 - # 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 b4aa53e..c4b62c3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -1,16 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np from inire.constants import TOLERANCE_LINEAR -from inire.router.config import CostConfig +from inire.model import ObjectiveWeights if TYPE_CHECKING: - from shapely.geometry import Polygon - - from inire.geometry.collision import CollisionEngine + from inire.geometry.collision import RoutingWorld + from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.primitives import Port from inire.router.danger_map import DangerMap @@ -19,63 +18,55 @@ class CostEvaluator: __slots__ = ( "collision_engine", "danger_map", - "config", - "unit_length_cost", - "greedy_h_weight", - "congestion_penalty", + "_search_weights", + "_greedy_h_weight", "_target_x", "_target_y", "_target_r", "_target_cos", "_target_sin", - "_min_radius", ) def __init__( self, - collision_engine: CollisionEngine, + collision_engine: RoutingWorld, danger_map: DangerMap | None = None, unit_length_cost: float = 1.0, greedy_h_weight: float = 1.5, - congestion_penalty: float = 10000.0, bend_penalty: float = 250.0, sbend_penalty: float | None = None, - min_bend_radius: float = 50.0, + danger_weight: float = 1.0, ) -> None: actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty self.collision_engine = collision_engine self.danger_map = danger_map - self.config = CostConfig( + self._search_weights = ObjectiveWeights( unit_length_cost=unit_length_cost, - greedy_h_weight=greedy_h_weight, - congestion_penalty=congestion_penalty, bend_penalty=bend_penalty, sbend_penalty=actual_sbend_penalty, - min_bend_radius=min_bend_radius, + danger_weight=danger_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._greedy_h_weight = float(greedy_h_weight) self._target_x = 0.0 self._target_y = 0.0 self._target_r = 0 self._target_cos = 1.0 self._target_sin = 0.0 - def _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 + @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 set_target(self, target: Port) -> None: self._target_x = target.x @@ -85,12 +76,13 @@ class CostEvaluator: self._target_cos = np.cos(rad) self._target_sin = np.sin(rad) - def g_proximity(self, x: float, y: float) -> float: - if self.danger_map is None: - return 0.0 - return self.danger_map.get_cost(x, y) - - def h_manhattan(self, current: Port, target: Port) -> float: + def h_manhattan( + self, + current: Port, + target: Port, + *, + min_bend_radius: float = 50.0, + ) -> float: tx, ty = target.x, target.y if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r: self.set_target(target) @@ -98,7 +90,7 @@ class CostEvaluator: dx = abs(current.x - tx) dy = abs(current.y - ty) dist = dx + dy - bp = self.config.bend_penalty + bp = self._search_weights.bend_penalty penalty = 0.0 curr_r = current.r @@ -110,7 +102,7 @@ class CostEvaluator: v_dy = ty - current.y side_proj = v_dx * self._target_cos + v_dy * self._target_sin perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos) - if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0): + if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0): penalty += 2 * bp if curr_r == 0: @@ -128,55 +120,74 @@ class CostEvaluator: if diff == 0 and perp_dist > 0: penalty += 2 * bp - return self.greedy_h_weight * (dist + penalty) + return self._greedy_h_weight * (dist + penalty) - def evaluate_move( + def score_component( self, - geometry: list[Polygon] | None, - end_port: Port, - net_width: float, - net_id: str, + component: ComponentResult, + *, start_port: Port | None = None, - length: float = 0.0, - dilated_geometry: list[Polygon] | None = None, - skip_static: bool = False, - skip_congestion: bool = False, - penalty: float = 0.0, + weights: ObjectiveWeights | None = None, ) -> float: - _ = net_width + active_weights = self._resolve_weights(weights) danger_map = self.danger_map + end_port = component.end_port if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 - total_cost = length * 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 + move_radius = None + if component.move_type == "bend90": + move_radius = component.length * 2.0 / np.pi if component.length > 0 else None + total_cost = component.length * active_weights.unit_length_cost + self.component_penalty( + component.move_type, + move_radius=move_radius, + weights=active_weights, + ) - if danger_map is not None: + if danger_map is not None and active_weights.danger_weight: cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 cost_e = danger_map.get_cost(end_port.x, end_port.y) if start_port: mid_x = (start_port.x + end_port.x) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0 cost_m = danger_map.get_cost(mid_x, mid_y) - total_cost += length * (cost_s + cost_m + cost_e) / 3.0 + total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 else: - total_cost += length * cost_e + total_cost += component.length * active_weights.danger_weight * cost_e return total_cost + + def component_penalty( + self, + move_type: MoveKind, + *, + move_radius: float | None = None, + weights: ObjectiveWeights | None = None, + ) -> float: + active_weights = self._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 4ade2f9..03b8a2a 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -1,21 +1,24 @@ from __future__ import annotations +from collections import OrderedDict from typing import TYPE_CHECKING + import numpy -import shapely from scipy.spatial import cKDTree -from functools import lru_cache if TYPE_CHECKING: from shapely.geometry import Polygon +_COST_CACHE_SIZE = 100000 + + class DangerMap: """ A proximity cost evaluator using a KD-Tree of obstacle boundary points. Scales with obstacle perimeter rather than design area. """ - __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree') + __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache') def __init__( self, @@ -38,6 +41,7 @@ class DangerMap: self.safety_threshold = safety_threshold self.k = k self.tree: cKDTree | None = None + self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict() def precompute(self, obstacles: list[Polygon]) -> None: """ @@ -64,9 +68,8 @@ class DangerMap: self.tree = cKDTree(numpy.array(all_points)) else: self.tree = None - - # Clear cache when tree changes - self._get_cost_quantized.cache_clear() + + self._cost_cache.clear() def is_within_bounds(self, x: float, y: float) -> bool: """ @@ -81,10 +84,18 @@ class DangerMap: """ qx_milli = int(round(x * 1000)) qy_milli = int(round(y * 1000)) - return self._get_cost_quantized(qx_milli, qy_milli) + key = (qx_milli, qy_milli) + if key in self._cost_cache: + self._cost_cache.move_to_end(key) + return self._cost_cache[key] - @lru_cache(maxsize=100000) - def _get_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: + cost = self._compute_cost_quantized(qx_milli, qy_milli) + self._cost_cache[key] = cost + if len(self._cost_cache) > _COST_CACHE_SIZE: + self._cost_cache.popitem(last=False) + return cost + + def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float: qx = qx_milli / 1000.0 qy = qy_milli / 1000.0 if not self.is_within_bounds(qx, qy): diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py deleted file mode 100644 index 30aca20..0000000 --- a/inire/router/pathfinder.py +++ /dev/null @@ -1,429 +0,0 @@ -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 new file mode 100644 index 0000000..6aa5d1f --- /dev/null +++ b/inire/router/refiner.py @@ -0,0 +1,317 @@ +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 new file mode 100644 index 0000000..a9d3c1f --- /dev/null +++ b/inire/router/results.py @@ -0,0 +1,16 @@ +"""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 d5fa61d..ed83d00 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 CollisionEngine + from inire.geometry.collision import RoutingWorld 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', '_static_visibility_cache', '_built_static_version') + __slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version") - def __init__(self, collision_engine: CollisionEngine) -> None: + def __init__(self, collision_engine: RoutingWorld) -> None: self.collision_engine = collision_engine self.corners: list[tuple[float, float]] = [] self.corner_index = rtree.index.Index() self._corner_graph: dict[int, list[tuple[float, float, float]]] = {} - self._static_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {} + self._point_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._static_visibility_cache = {} + self._point_visibility_cache = {} self._build() def _ensure_current(self) -> None: - if self._built_static_version != self.collision_engine._static_version: + if self._built_static_version != self.collision_engine.get_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._static_version + self._built_static_version = self.collision_engine.get_static_version() raw_corners = [] - for obj_id, poly in self.collision_engine.static_dilated.items(): + for poly in self.collision_engine.iter_static_dilated_geometries(): coords = list(poly.exterior.coords) if coords[0] == coords[-1]: coords = coords[:-1] @@ -83,7 +83,8 @@ 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) @@ -92,53 +93,51 @@ class VisibilityManager: if reach >= dist - 0.01: self._corner_graph[i].append((cx, cy, dist)) - def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: - """ - Find all corners visible from the origin. - Returns list of (x, y, distance). - """ - self._ensure_current() - if max_dist < 0: - return [] - + def _corner_idx_at(self, origin: Port) -> int | None: ox, oy = round(origin.x, 3), round(origin.y, 3) - - # 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] + return idx + return None - # 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] + def get_point_visibility(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. + """ + 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] - # 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._static_visibility_cache[cache_key] = visible + + self._point_visibility_cache[cache_key] = visible return visible def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]: @@ -150,10 +149,7 @@ class VisibilityManager: if max_dist < 0: return [] - 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] + 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] return [] diff --git a/inire/seeds.py b/inire/seeds.py new file mode 100644 index 0000000..635e489 --- /dev/null +++ b/inire/seeds.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +BendDirection = Literal["CW", "CCW"] + + +@dataclass(frozen=True, slots=True) +class StraightSeed: + length: float + + def __post_init__(self) -> None: + object.__setattr__(self, "length", float(self.length)) + + +@dataclass(frozen=True, slots=True) +class Bend90Seed: + radius: float + direction: BendDirection + + def __post_init__(self) -> None: + object.__setattr__(self, "radius", float(self.radius)) + + +@dataclass(frozen=True, slots=True) +class SBendSeed: + offset: float + radius: float + + def __post_init__(self) -> None: + object.__setattr__(self, "offset", float(self.offset)) + object.__setattr__(self, "radius", float(self.radius)) + + +PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed + + +@dataclass(frozen=True, slots=True) +class PathSeed: + segments: tuple[PathSegmentSeed, ...] + + def __post_init__(self) -> None: + segments = tuple(self.segments) + if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments): + raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances") + object.__setattr__(self, "segments", segments) diff --git a/inire/tests/benchmark_scaling.py b/inire/tests/benchmark_scaling.py deleted file mode 100644 index d13becd..0000000 --- a/inire/tests/benchmark_scaling.py +++ /dev/null @@ -1,57 +0,0 @@ -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 40ccdc0..06619c4 100644 --- a/inire/tests/example_scenarios.py +++ b/inire/tests/example_scenarios.py @@ -1,44 +1,120 @@ 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.geometry.collision import CollisionEngine +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, +) +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, AStarMetrics +from inire.router._astar_types import AStarContext, AStarMetrics +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder, RoutingResult + +_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] -@dataclass(frozen=True) -class ScenarioOutcome: - duration_s: float - total_results: int - valid_results: int - reached_targets: int +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 ScenarioDefinition: - name: str - run: Callable[[], ScenarioOutcome] +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) -def _build_router( +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, *, 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, - context_kwargs: dict[str, object] | None = None, - pathfinder_kwargs: dict[str, object] | None = None, -) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]: + request_kwargs: dict[str, object] | None = None, +) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]: static_obstacles = obstacles or [] - engine = CollisionEngine(clearance=clearance) + engine = RoutingWorld(clearance=clearance) for obstacle in static_obstacles: engine.add_static_obstacle(obstacle) @@ -46,107 +122,126 @@ def _build_router( danger_map.precompute(static_obstacles) evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) - context = AStarContext(evaluator, **(context_kwargs or {})) metrics = AStarMetrics() - pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {})) - return engine, evaluator, context, metrics, pathfinder - - -def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: - return ScenarioOutcome( - duration_s=duration_s, - 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), + pathfinder = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + metrics=metrics, + **(request_kwargs or {}), ) + return engine, evaluator, metrics, pathfinder def run_example_01() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]}) netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))} + widths = {"net1": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, {"net1": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_02() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "bend_penalty": 50.0, - "sbend_penalty": 150.0, - }, - context_kwargs={ - "bend_radii": [10.0], - "sbend_radii": [10.0], - }, - pathfinder_kwargs={"base_congestion_penalty": 1000.0}, - ) netlist = { "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "greedy_h_weight": 1.5, + "bend_penalty": 50.0, + "sbend_penalty": 150.0, + }, + request_kwargs={ + "bend_radii": [10.0], + "sbend_radii": [10.0], + "base_penalty": 1000.0, + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_03() -> ScenarioOutcome: - engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]}) + netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))} + widths_a = {"netA": 2.0} + engine, evaluator, _, pathfinder = _build_routing_stack( + bounds=(0, -50, 100, 50), + netlist=netlist_a, + widths=widths_a, + request_kwargs={"bend_radii": [10.0]}, + ) t0 = perf_counter() - results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - engine.lock_net("netA") - results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}) + results_a = pathfinder.route_all() + for polygon in results_a["netA"].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() t1 = perf_counter() return _summarize({**results_a, **results_b}, t1 - t0) def run_example_04() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 100, 100), - evaluator_kwargs={ - "unit_length_cost": 1.0, - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - context_kwargs={ - "node_limit": 50000, - "bend_radii": [10.0, 30.0], - "sbend_offsets": [5.0], - "bend_penalty": 10.0, - "sbend_penalty": 20.0, - }, - ) netlist = { "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } widths = {"sbend_only": 2.0, "multi_radii": 2.0} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, + evaluator_kwargs={ + "unit_length_cost": 1.0, + "bend_penalty": 10.0, + "sbend_penalty": 20.0, + }, + request_kwargs={ + "node_limit": 50000, + "bend_radii": [10.0, 30.0], + "sbend_offsets": [5.0], + }, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) def run_example_05() -> ScenarioOutcome: - _, _, _, _, pathfinder = _build_router( - bounds=(0, 0, 200, 200), - evaluator_kwargs={"bend_penalty": 50.0}, - context_kwargs={"bend_radii": [20.0]}, - ) netlist = { "u_turn": (Port(50, 50, 0), Port(50, 70, 180)), "loop": (Port(100, 100, 90), Port(100, 80, 270)), "zig_zag": (Port(20, 150, 0), Port(180, 150, 0)), } widths = {net_id: 2.0 for net_id in netlist} + _, _, _, pathfinder = _build_routing_stack( + bounds=(0, 0, 200, 200), + netlist=netlist, + widths=widths, + evaluator_kwargs={"bend_penalty": 50.0}, + request_kwargs={"bend_radii": [20.0]}, + ) t0 = perf_counter() - results = pathfinder.route_all(netlist, widths) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) @@ -158,35 +253,42 @@ def run_example_06() -> ScenarioOutcome: box(40, 60, 60, 80), box(40, 10, 60, 30), ] - 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}, + 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, + }, + ), ] t0 = perf_counter() combined_results: dict[str, RoutingResult] = {} - 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)) + for evaluator, netlist, net_widths, request_kwargs in scenarios: + pathfinder = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, net_widths), + **request_kwargs, + ) + combined_results.update(pathfinder.route_all()) t1 = perf_counter() return _summarize(combined_results, t1 - t0) @@ -197,29 +299,6 @@ def run_example_07() -> ScenarioOutcome: box(450, 0, 550, 400), box(450, 600, 550, 1000), ] - _, evaluator, _, metrics, pathfinder = _build_router( - bounds=bounds, - clearance=6.0, - obstacles=obstacles, - evaluator_kwargs={ - "greedy_h_weight": 1.5, - "unit_length_cost": 0.1, - "bend_penalty": 100.0, - "sbend_penalty": 400.0, - "congestion_penalty": 100.0, - }, - context_kwargs={ - "node_limit": 2000000, - "bend_radii": [50.0], - "sbend_radii": [50.0], - }, - pathfinder_kwargs={ - "max_iterations": 15, - "base_congestion_penalty": 100.0, - "congestion_multiplier": 1.4, - }, - ) - num_nets = 10 start_x = 50 start_y_base = 500 - (num_nets * 10.0) / 2.0 @@ -232,49 +311,74 @@ 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( - netlist, - dict.fromkeys(netlist, 2.0), - store_expanded=True, - iteration_callback=iteration_callback, - shuffle_nets=True, - seed=42, - ) + results = pathfinder.route_all(iteration_callback=iteration_callback) t1 = perf_counter() return _summarize(results, t1 - t0) 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} - - 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=[], - ) + custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]) + evaluator = _build_evaluator(bounds) t0 = perf_counter() - 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}, - ) + 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() t1 = perf_counter() return _summarize({**results_std, **results_custom}, t1 - t0) @@ -284,28 +388,30 @@ def run_example_09() -> ScenarioOutcome: box(35, 35, 45, 65), box(55, 35, 65, 65), ] - _, _, _, _, pathfinder = _build_router( + netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} + widths = {"budget_limited_net": 2.0} + _, _, _, pathfinder = _build_routing_stack( bounds=(0, 0, 100, 100), + netlist=netlist, + widths=widths, obstacles=obstacles, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, - context_kwargs={"node_limit": 3, "bend_radii": [10.0]}, - pathfinder_kwargs={"warm_start": None}, + request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1}, ) - netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))} t0 = perf_counter() - results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0}) + results = pathfinder.route_all() t1 = perf_counter() return _summarize(results, t1 - t0) -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), +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), ) diff --git a/inire/tests/test_api.py b/inire/tests/test_api.py new file mode 100644 index 0000000..858cac9 --- /dev/null +++ b/inire/tests/test_api.py @@ -0,0 +1,139 @@ +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 85d9021..3d637b9 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,34 +1,117 @@ +import math + import pytest from shapely.geometry import Polygon -import inire.router.astar as astar_module -from inire.geometry.components import SBend, Straight -from inire.geometry.collision import CollisionEngine +from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions +from inire.geometry.components import Bend90, Straight +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.router._astar_types import AStarContext, SearchRunConfig +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import RoutingResult -from inire.utils.validation import validate_routing_result + +BOUNDS = (0, -50, 150, 150) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, -50, 150, 150)) + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _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 = AStarContext(basic_evaluator) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(50, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None - 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) + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -37,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[10.0]) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,)) start = Port(0, 0, 0) # 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None - 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) + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["connectivity_ok"] @@ -58,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) - context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000) start = Port(0, 0, 0) target = Port(60, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None - 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) + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" # Path should have detoured, so length > 50 @@ -73,217 +156,165 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) + context = _build_context(basic_evaluator, bounds=BOUNDS) start = Port(0, 0, 0) target = Port(10.1, 0, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None - result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + result = RoutingResult(net_id="test", path=path, reached_target=True) assert target.x == 10 - validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + 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_expand_moves_only_shortens_consecutive_straights( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0) - prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0) - current = astar_module.AStarNode( - prev_result.end_port, - g_cost=prev_result.length, - h_cost=0.0, - component_result=prev_result, +def test_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), ) - emitted: list[tuple[str, tuple]] = [] + assert not validation["is_valid"] + assert "Initial port position mismatch" in validation["reason"] - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - monkeypatch.setattr(astar_module, "process_move", fake_process_move) +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)]) - astar_module.expand_moves( - current, - Port(80, 0, 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, - net_id="test", - open_set=[], - closed_set={}, context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, + config=SearchRunConfig.from_options( + context.options, + bend_collision_type="clipped_bbox", + return_partial=True, + ), ) - straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"] - assert straight_lengths - assert all(length < prev_result.length for length in straight_lengths) + assert context.options.search.bend_collision_type == "arc" -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, - ) +def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None: + obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) + basic_evaluator.collision_engine.add_static_obstacle(obstacle) + basic_evaluator.danger_map.precompute([obstacle]) + context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2) + start = Port(0, 0, 0) + target = Port(60, 0, 0) - emitted: list[str] = [] + partial_path = _route(context, start, target, return_partial=True) + no_partial_path = _route(context, start, target, return_partial=False) - 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 + assert partial_path is not None + assert partial_path + assert partial_path[-1].end_port != target + assert no_partial_path is None -def test_expand_moves_adds_sbend_aligned_straight_stop_points( - basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, -) -> None: - context = AStarContext( +def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: + context = _build_context( basic_evaluator, - bend_radii=[10.0], - sbend_radii=[10.0], + bounds=BOUNDS, + bend_radii=(10.0,), + sbend_radii=(10.0,), + sbend_offsets=(10.0,), max_straight_length=150.0, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + target = Port(100, 10, 0) - emitted: list[tuple[str, tuple]] = [] + path = _route(context, start, target) - def fake_process_move(*args, **kwargs) -> None: - emitted.append((args[9], args[10])) - - monkeypatch.setattr(astar_module, "process_move", fake_process_move) - - astar_module.expand_moves( - current, - Port(100, 10, 0), - net_width=2.0, - net_id="test", - open_set=[], - closed_set={}, - context=context, - metrics=astar_module.AStarMetrics(), - congestion_cache={}, + assert path is not None + assert path[-1].end_port == target + assert sum(1 for component in path if component.move_type == "sbend") == 1 + assert not any( + first.move_type == second.move_type == "sbend" + for first, second in zip(path, path[1:], strict=False) ) - straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"} - sbend_span = astar_module._sbend_forward_span(10.0, 10.0) - assert sbend_span is not None - assert int(round(100.0 - sbend_span)) in straight_lengths - assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths - -def test_expand_moves_adds_exact_corner_visibility_stop_points( +@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"]) +def test_route_astar_supports_all_visibility_guidance_modes( basic_evaluator: CostEvaluator, - monkeypatch: pytest.MonkeyPatch, + visibility_guidance: str, ) -> None: + 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, + ) + start = Port(0, 0, 0) + target = Port(80, 50, 0) + + path = _route(context, start, target) + + assert path is not None + result = RoutingResult(net_id="test", path=path, reached_target=True) + validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + assert validation["connectivity_ok"] + + +def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: context = AStarContext( basic_evaluator, - bend_radii=[10.0], - max_straight_length=150.0, - visibility_guidance="exact_corner", + RoutingProblem(bounds=BOUNDS), + _build_options( + min_straight_length=1.0, + max_straight_length=100.0, + ), + max_cache_size=2, ) - current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0) + start = Port(0, 0, 0) + targets = [Port(length, 0, 0) for length in range(10, 70, 10)] - 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 + for target in targets: + path = _route(context, start, target) + assert path is not None + assert path[-1].end_port == target diff --git a/inire/tests/test_clearance_precision.py b/inire/tests/test_clearance_precision.py index 3f17b1c..67264cc 100644 --- a/inire/tests/test_clearance_precision.py +++ b/inire/tests/test_clearance_precision.py @@ -1,13 +1,41 @@ import pytest import numpy from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.geometry.components import Straight +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder, RoutingResult +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, + ), + ), + ) def test_clearance_thresholds(): """ @@ -16,43 +44,41 @@ def test_clearance_thresholds(): """ # Clearance = 2.0, Width = 2.0 # Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0 - ce = CollisionEngine(clearance=2.0) + ce = RoutingWorld(clearance=2.0) # Net 1: Centerline at y=0 p1 = Port(0, 0, 0) res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0) - ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry) + ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry) # Net 2: Parallel to Net 1 # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. p2_ok = Port(0, 5, 0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_ok]) - assert is_v, f"Gap 3 should be valid, but got {count} collisions" + report_ok = ce.verify_path_report("net2", [res2_ok]) + assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions" # 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK. p2_exact = Port(0, 4, 0) res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_exact]) - assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions" + report_exact = ce.verify_path_report("net2", [res2_exact]) + assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions" # 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL. p2_fail = Port(0, 3, 0) res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0) - is_v, count = ce.verify_path("net2", [res2_fail]) - assert not is_v, "Gap 1.999 should be invalid" - assert count > 0 + report_fail = ce.verify_path_report("net2", [res2_fail]) + assert not report_fail.is_valid, "Gap 1.999 should be invalid" + assert report_fail.collision_count > 0 def test_verify_all_nets_cases(): """ Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases. """ - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, 0, 100, 100)) danger_map.precompute([]) evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map) - context = AStarContext(cost_evaluator=evaluator) - pf = PathFinder(context, warm_start=None, max_iterations=1) # Case 1: Parallel paths exactly at clearance (Should be VALID) netlist_parallel_ok = { @@ -60,8 +86,14 @@ def test_verify_all_nets_cases(): "net2": (Port(0, 54, 0), Port(100, 54, 0)), } net_widths = {"net1": 2.0, "net2": 2.0} - - results = pf.route_all(netlist_parallel_ok, net_widths) + + results = _build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_ok, + net_widths=net_widths, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), + ).route_all() assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net2"].is_valid @@ -74,7 +106,13 @@ def test_verify_all_nets_cases(): engine.remove_path("net1") engine.remove_path("net2") - results_p = pf.route_all(netlist_parallel_fail, net_widths) + results_p = _build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_parallel_fail, + net_widths=net_widths, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), + ).route_all() # verify_all_nets should flag both as invalid because they cross-collide assert not results_p["net3"].is_valid assert not results_p["net4"].is_valid @@ -87,6 +125,12 @@ def test_verify_all_nets_cases(): engine.remove_path("net3") engine.remove_path("net4") - results_c = pf.route_all(netlist_cross, net_widths) + results_c = _build_pathfinder( + evaluator, + bounds=(0, 0, 100, 100), + netlist=netlist_cross, + net_widths=net_widths, + congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1), + ).route_all() assert not results_c["net5"].is_valid assert not results_c["net6"].is_valid diff --git a/inire/tests/test_collision.py b/inire/tests/test_collision.py index f83bb16..7eb0e4f 100644 --- a/inire/tests/test_collision.py +++ b/inire/tests/test_collision.py @@ -1,75 +1,51 @@ -from shapely.geometry import Polygon - -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Straight +from inire.geometry.primitives import Port + + +def _install_static_straight( + engine: RoutingWorld, + start: Port, + length: float, + *, + width: float, + dilation: float = 0.0, +) -> None: + obstacle = Straight.generate(start, length, width=width, dilation=dilation) + for polygon in obstacle.physical_geometry: + engine.add_static_obstacle(polygon) def test_collision_detection() -> None: - # Clearance = 2um - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0) - # 10x10 um obstacle at (10,10) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) + direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0) + assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port) - # 1. Direct hit - test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)]) - assert engine.is_collision(test_poly, net_width=2.0) + far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0) + assert not engine.check_move_static(far_away, start_port=far_away.start_port) - # 2. Far away - test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]) - assert not engine.is_collision(test_poly_far, net_width=2.0) - - # 3. Near hit (within clearance) - # Obstacle edge at x=10. - # test_poly edge at x=9. - # Distance = 1.0 um. - # Required distance (Wi+C)/2 = 2.0. Collision! - test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)]) - assert engine.is_collision(test_poly_near, net_width=2.0) + near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0) + assert engine.check_move_static(near_hit, start_port=near_hit.start_port) def test_safety_zone() -> None: - # Use zero clearance for this test to verify the 2nm port safety zone - # against the physical obstacle boundary. - engine = CollisionEngine(clearance=0.0) + engine = RoutingWorld(clearance=0.0) + _install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0) - obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) - engine.add_static_obstacle(obstacle) - - # Port exactly on the boundary start_port = Port(10, 12, 0) - - # Move starting from this port that overlaps the obstacle by 1nm - # (Inside the 2nm safety zone) - test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)]) - - assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port) - - -def test_configurable_max_net_width() -> None: - # Large max_net_width (10.0) -> large pre-dilation (6.0) - engine = 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) + test_move = Straight.generate(start_port, 0.002, width=0.001) + assert not engine.check_move_static(test_move, start_port=start_port) def test_ray_cast_width_clearance() -> None: # Clearance = 2.0um, Width = 2.0um. # Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um. - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Obstacle at x=10 to 20 - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0) # 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK. start_ok = Port(6, 50, 90) @@ -83,23 +59,73 @@ def test_ray_cast_width_clearance() -> None: def test_check_move_static_clearance() -> None: - engine = CollisionEngine(clearance=2.0) - obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) - engine.add_static_obstacle(obstacle) + engine = RoutingWorld(clearance=2.0) + _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0) # Straight move of length 10 at x=8 (Width 2.0) # Gap = 10 - 8 = 2.0 < 3.0. COLLISION. start = Port(8, 0, 90) res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2 - assert engine.check_move_static(res, start_port=start, net_width=2.0) + assert engine.check_move_static(res, start_port=start) # Move at x=7. Gap = 3.0 == minimum. OK. start_ok = Port(7, 0, 90) res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0) + assert not engine.check_move_static(res_ok, start_port=start_ok) # 3. Same exact-boundary case. start_exact = Port(7, 0, 90) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) - assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) + assert not engine.check_move_static(res_exact, start_port=start_exact) + + +def test_verify_path_report_preserves_long_net_id() -> None: + 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 diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index dad6fbf..2708a56 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,7 +1,12 @@ import pytest +from dataclasses import FrozenInstanceError +from shapely.affinity import rotate as shapely_rotate +from shapely.affinity import scale as shapely_scale +from shapely.affinity import translate as shapely_translate +from shapely.geometry import Polygon from inire.geometry.components import Bend90, SBend, Straight -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port def test_straight_generation() -> None: @@ -12,15 +17,16 @@ def test_straight_generation() -> None: assert result.end_port.x == 10.0 assert result.end_port.y == 0.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 1 + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 1 # Bounds of the polygon - minx, miny, maxx, maxy = result.geometry[0].bounds + minx, miny, maxx, maxy = result.collision_geometry[0].bounds assert minx == 0.0 assert maxx == 10.0 assert miny == -1.0 assert maxy == 1.0 + assert isinstance(result.collision_geometry, tuple) def test_bend90_generation() -> None: @@ -32,13 +38,13 @@ def test_bend90_generation() -> None: result_cw = Bend90.generate(start, radius, width, direction="CW") assert result_cw.end_port.x == 10.0 assert result_cw.end_port.y == -10.0 - assert result_cw.end_port.orientation == 270.0 + assert result_cw.end_port.r == 270.0 # CCW bend result_ccw = Bend90.generate(start, radius, width, direction="CCW") assert result_ccw.end_port.x == 10.0 assert result_ccw.end_port.y == 10.0 - assert result_ccw.end_port.orientation == 90.0 + assert result_ccw.end_port.r == 90.0 def test_sbend_generation() -> None: @@ -49,8 +55,8 @@ def test_sbend_generation() -> None: result = SBend.generate(start, offset, radius, width) assert result.end_port.y == 5.0 - assert result.end_port.orientation == 0.0 - assert len(result.geometry) == 2 # Optimization: returns individual arcs + assert result.end_port.r == 0.0 + assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs # Verify failure for large offset with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"): @@ -66,7 +72,7 @@ def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() -> result = SBend.generate(start, offset, radius, width) assert result.end_port.y == -5.0 - second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds + second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds assert second_arc_maxy <= width / 2.0 + 1e-6 assert second_arc_miny < -width / 2.0 @@ -80,16 +86,65 @@ def test_bend_collision_models() -> None: res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") # Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) - minx, miny, maxx, maxy = res_bbox.geometry[0].bounds + minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds assert minx <= 0.0 + 1e-6 assert maxx >= 10.0 - 1e-6 assert miny <= 0.0 + 1e-6 assert maxy >= 10.0 - 1e-6 # 2. Clipped BBox model - res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0) - # Area should be less than full bbox - assert res_clipped.geometry[0].area < res_bbox.geometry[0].area + 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 def test_sbend_collision_models() -> None: @@ -100,11 +155,11 @@ def test_sbend_collision_models() -> None: res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") # Geometry should be a list of individual bbox polygons for each arc - assert len(res_bbox.geometry) == 2 + assert len(res_bbox.collision_geometry) == 2 res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") - area_bbox = sum(p.area for p in res_bbox.geometry) - area_arc = sum(p.area for p in res_arc.geometry) + area_bbox = sum(p.area for p in res_bbox.collision_geometry) + area_arc = sum(p.area for p in res_arc.collision_geometry) assert area_bbox > area_arc @@ -118,14 +173,14 @@ def test_sbend_continuity() -> None: res = SBend.generate(start, offset, radius, width) # Target orientation should be same as start - assert abs(res.end_port.orientation - 90.0) < 1e-6 + assert abs(res.end_port.r - 90.0) < 1e-6 # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 # Geometry should be a list of valid polygons - assert len(res.geometry) == 2 - for p in res.geometry: + assert len(res.collision_geometry) == 2 + for p in res.collision_geometry: assert p.is_valid @@ -142,8 +197,8 @@ def test_arc_sagitta_precision() -> None: # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse * 2 @@ -162,12 +217,19 @@ def test_component_transform_invariance() -> None: angle = 90.0 # 1. Transform the generated geometry - p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) + p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle) # 2. Generate at transformed start - start_transformed = rotate_port(translate_port(start0, dx, dy), angle) + start_transformed = start0.translate(dx, dy).rotated(angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 - assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 + assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6 + + +def test_component_result_is_immutable_value_type() -> None: + result = Straight.generate(Port(0, 0, 0), 10.0, 2.0) + + with pytest.raises(FrozenInstanceError): + result.length = 42.0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 53f5400..7d3f2eb 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -1,30 +1,90 @@ import pytest -from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.model import NetSpec +from inire.router._astar_types import AStarContext, SearchRunConfig +from inire.router._router import PathFinder +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder + +BOUNDS = (0, -40, 100, 40) @pytest.fixture def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # Wider bounds to allow going around (y from -40 to 40) - danger_map = DangerMap(bounds=(0, -40, 100, 40)) + danger_map = DangerMap(bounds=BOUNDS) danger_map.precompute([]) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) +def _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 = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0]) + context = _build_context( + basic_evaluator, + bounds=BOUNDS, + search=SearchOptions(sbend_offsets=(2.0, 5.0)), + ) # Start at (0,0), target at (50, 2) -> 2um lateral offset # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) target = Port(50, 2, 0) - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) assert path is not None # Check if any component in the path is an SBend @@ -32,37 +92,7 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: for res in path: # Check if the end port orientation is same as start # and it's not a single straight (which would have y=0) - if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1: + if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1: found_sbend = True break assert found_sbend - - -def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator, bend_radii=[5.0, 10.0]) - # Increase base penalty to force detour immediately - pf = PathFinder(context, max_iterations=10, base_congestion_penalty=1000.0) - - netlist = { - "net1": (Port(0, 0, 0), Port(50, 0, 0)), - "net2": (Port(0, 10, 0), Port(50, 10, 0)), - } - net_widths = {"net1": 2.0, "net2": 2.0} - - # 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 e3c5c26..6eef3bc 100644 --- a/inire/tests/test_cost.py +++ b/inire/tests/test_cost.py @@ -1,12 +1,12 @@ from shapely.geometry import Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap def test_cost_calculation() -> None: - engine = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) # 50x50 um area, 1um resolution danger_map = DangerMap(bounds=(0, 0, 50, 50)) danger_map.precompute([]) @@ -40,6 +40,18 @@ def test_cost_calculation() -> None: assert h_away >= h_90 +def test_greedy_h_weight_is_mutable() -> None: + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=(0, 0, 50, 50)) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=10.0) + + assert evaluator.greedy_h_weight == 1.5 + evaluator.greedy_h_weight = 1.2 + assert evaluator.greedy_h_weight == 1.2 + assert abs(evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) - 72.0) < 1e-6 + + def test_danger_map_kd_tree_and_cache() -> None: # Test that KD-Tree based danger map works and uses cache bounds = (0, 0, 1000, 1000) @@ -61,7 +73,23 @@ def test_danger_map_kd_tree_and_cache() -> None: # We can check if calling it again is fast or just verify it returns same result cost_near_2 = dm.get_cost(100.5, 100.5) assert cost_near_2 == cost_near + assert len(dm._cost_cache) == 2 # 4. Out of bounds assert dm.get_cost(-1, -1) >= 1e12 + +def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None: + obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) + dm_a = DangerMap((0, 0, 100, 100)) + dm_b = DangerMap((0, 0, 100, 100)) + + dm_a.precompute([obstacle]) + dm_b.precompute([]) + + dm_a.get_cost(15.0, 15.0) + assert len(dm_a._cost_cache) == 1 + assert len(dm_b._cost_cache) == 0 + + dm_a.precompute([]) + assert len(dm_a._cost_cache) == 0 diff --git a/inire/tests/test_example_performance.py b/inire/tests/test_example_performance.py index 4372749..7f8517b 100644 --- a/inire/tests/test_example_performance.py +++ b/inire/tests/test_example_performance.py @@ -2,17 +2,18 @@ from __future__ import annotations import os import statistics +from collections.abc import Callable import pytest -from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome +from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" PERFORMANCE_REPEATS = 3 REGRESSION_FACTOR = 1.5 -# Baselines are measured from the current code path without plotting. +# Baselines are measured from clean 6a28dcf-style runs without plotting. BASELINE_SECONDS = { "example_01_simple_route": 0.0035, "example_02_congestion_resolution": 0.2666, @@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = { def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None: + _, total_results, valid_results, reached_targets = outcome expected = EXPECTED_OUTCOMES[name] - assert outcome.total_results == expected["total_results"] - assert outcome.valid_results == expected["valid_results"] - assert outcome.reached_targets == expected["reached_targets"] + assert total_results == expected["total_results"] + assert valid_results == expected["valid_results"] + assert reached_targets == expected["reached_targets"] @pytest.mark.performance @pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks") -@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS]) -def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None: +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS]) +def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None: + name, run = scenario timings = [] for _ in range(PERFORMANCE_REPEATS): - outcome = scenario.run() - _assert_expected_outcome(scenario.name, outcome) - timings.append(outcome.duration_s) + outcome = run() + _assert_expected_outcome(name, outcome) + timings.append(outcome[0]) median_runtime = statistics.median(timings) - assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, ( - f"{scenario.name} median runtime {median_runtime:.4f}s exceeded " - f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s " + assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, ( + f"{name} median runtime {median_runtime:.4f}s exceeded " + f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s " f"from timings {timings!r}" ) diff --git a/inire/tests/test_example_regressions.py b/inire/tests/test_example_regressions.py new file mode 100644 index 0000000..1a56cd2 --- /dev/null +++ b/inire/tests/test_example_regressions.py @@ -0,0 +1,184 @@ +import pytest +from shapely.geometry import Polygon, box + +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + Port, + RoutingOptions, + RoutingProblem, + SearchOptions, + route, +) +from inire.router._stack import build_routing_stack +from inire.seeds import Bend90Seed, PathSeed, StraightSeed +from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics + + +EXPECTED_OUTCOMES = { + "example_01_simple_route": (1, 1, 1), + "example_02_congestion_resolution": (3, 3, 3), + "example_03_locked_paths": (2, 2, 2), + "example_04_sbends_and_radii": (2, 2, 2), + "example_05_orientation_stress": (3, 3, 3), + "example_06_bend_collision_models": (3, 3, 3), + "example_07_large_scale_routing": (10, 10, 10), + "example_08_custom_bend_geometry": (2, 1, 2), + "example_09_unroutable_best_effort": (1, 0, 0), +} + + +@pytest.mark.parametrize(("name", "run"), SCENARIOS, ids=[name for name, _ in SCENARIOS]) +def test_examples_match_legacy_expected_outcomes(name: str, run) -> None: + outcome = run() + assert outcome[1:] == EXPECTED_OUTCOMES[name] + + +def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: + bounds = (-20, -20, 170, 170) + obstacles = ( + Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]), + Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]), + Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]), + ) + problem = RoutingProblem( + bounds=bounds, + nets=(NetSpec("clipped_model", Port(10, 20, 0), Port(90, 40, 90), width=2.0),), + static_obstacles=obstacles, + ) + common_kwargs = { + "objective": ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0), + "congestion": CongestionOptions(use_tiered_strategy=False), + } + no_margin = route( + problem, + options=RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type="clipped_bbox", + ), + **common_kwargs, + ), + ).results_by_net["clipped_model"] + legacy_margin = route( + problem, + options=RoutingOptions( + search=SearchOptions( + bend_radii=(10.0,), + bend_collision_type="clipped_bbox", + bend_clip_margin=1.0, + ), + **common_kwargs, + ), + ).results_by_net["clipped_model"] + + assert no_margin.is_valid + assert legacy_margin.is_valid + assert legacy_margin.as_seed() != no_margin.as_seed() + assert legacy_margin.as_seed() == PathSeed( + ( + StraightSeed(5.0), + Bend90Seed(10.0, "CW"), + Bend90Seed(10.0, "CCW"), + StraightSeed(45.0), + Bend90Seed(10.0, "CCW"), + StraightSeed(30.0), + ) + ) + + +def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None: + bounds = (0, 0, 500, 300) + obstacles = ( + box(220, 0, 280, 100), + box(220, 200, 280, 300), + ) + netlist = { + "net_00": (Port(30, 130, 0), Port(470, 60, 0)), + "net_01": (Port(30, 140, 0), Port(470, 120, 0)), + "net_02": (Port(30, 150, 0), Port(470, 180, 0)), + "net_03": (Port(30, 160, 0), Port(470, 240, 0)), + } + problem = RoutingProblem( + bounds=bounds, + nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()), + static_obstacles=obstacles, + clearance=6.0, + ) + options = RoutingOptions( + search=SearchOptions( + node_limit=200000, + bend_radii=(30.0,), + sbend_radii=(30.0,), + greedy_h_weight=1.5, + bend_clip_margin=10.0, + ), + objective=ObjectiveWeights( + unit_length_cost=0.1, + bend_penalty=100.0, + sbend_penalty=400.0, + ), + congestion=CongestionOptions( + max_iterations=6, + base_penalty=100.0, + multiplier=1.4, + net_order="shortest", + shuffle_nets=True, + seed=42, + ), + diagnostics=DiagnosticsOptions(capture_expanded=False), + ) + stack = build_routing_stack(problem, options) + evaluator = stack.evaluator + finder = stack.finder + weights: list[float] = [] + + def iteration_callback(iteration: int, current_results: dict[str, object]) -> None: + _ = current_results + new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4) + evaluator.greedy_h_weight = new_greedy + weights.append(new_greedy) + finder.metrics.reset_per_route() + + results = finder.route_all(iteration_callback=iteration_callback) + + assert weights == [1.46] + assert evaluator.greedy_h_weight == 1.46 + assert all(result.is_valid for result in results.values()) + assert all(result.reached_target for result in results.values()) + + +def test_example_08_custom_box_restores_legacy_collision_outcome() -> None: + bounds = (0, 0, 150, 150) + netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))} + widths = {"custom_bend": 2.0} + evaluator = _build_evaluator(bounds) + + standard = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs(netlist, widths), + bend_radii=[10.0], + sbend_radii=[], + max_iterations=1, + metrics=AStarMetrics(), + ).route_all() + custom = _build_pathfinder( + evaluator, + bounds=bounds, + nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), + bend_radii=[10.0], + bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]), + sbend_radii=[], + max_iterations=1, + use_tiered_strategy=False, + metrics=AStarMetrics(), + ).route_all() + + assert standard["custom_bend"].is_valid + assert standard["custom_bend"].reached_target + assert not custom["custom_model"].is_valid + assert custom["custom_model"].reached_target + assert custom["custom_model"].collisions == 2 diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py index fa89bb6..db300ce 100644 --- a/inire/tests/test_failed_net_congestion.py +++ b/inire/tests/test_failed_net_congestion.py @@ -1,70 +1,77 @@ - -import pytest -import numpy +from inire import CongestionOptions, RoutingOptions, RoutingProblem from inire.geometry.primitives import Port -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld +from inire.model import NetSpec +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator -from inire.router.astar import AStarContext -from inire.router.pathfinder import PathFinder from inire.router.danger_map import DangerMap -def test_failed_net_visibility(): +def test_failed_net_visibility() -> None: """ 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 = CollisionEngine(clearance=2.0) - + engine = RoutingWorld(clearance=2.0) + # Create a simple danger map (bounds 0-100) # We don't strictly need obstacles in it for this test. 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(netlist, net_widths) - + results = pf.route_all() + res = results["net1"] print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") - + # 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 = set() - # CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)] - for obj_id, (nid, poly) in engine.dynamic_geometries.items(): - found_nets.add(nid) - + found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} + print(f"Nets found in engine: {found_nets}") - + # 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 ee5490f..7c43251 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -4,9 +4,11 @@ import pytest from hypothesis import given, settings, strategies as st from shapely.geometry import Point, Polygon -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld from inire.geometry.primitives import Port -from inire.router.astar import AStarContext, route_astar +from inire.model import RoutingOptions, RoutingProblem, SearchOptions +from inire.router._astar_types import AStarContext, SearchRunConfig +from inire.router._search import route_astar from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap @@ -34,12 +36,35 @@ 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 = CollisionEngine(clearance=2.0) + engine = RoutingWorld(clearance=2.0) for obs in obstacles: engine.add_static_obstacle(obs) @@ -47,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port danger_map.precompute(obstacles) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, node_limit=5000) # Lower limit for fuzzing stability + context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Check if start/target are inside obstacles (safety zone check) # The router should handle this gracefully (either route or return None) try: - path = route_astar(start, target, net_width=2.0, context=context) + path = _route(context, start, target) # This is a crash-smoke test rather than a full correctness proof. # If a full path is returned, it should at least terminate at the requested target. diff --git a/inire/tests/test_pathfinder.py b/inire/tests/test_pathfinder.py index 252a96e..773bd56 100644 --- a/inire/tests/test_pathfinder.py +++ b/inire/tests/test_pathfinder.py @@ -1,118 +1,231 @@ -import pytest +from shapely.geometry import box -from inire.geometry.collision import CollisionEngine +from inire import ( + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + RefinementOptions, + RoutingOptions, + RoutingProblem, + SearchOptions, +) +from inire.geometry.collision import RoutingWorld +from inire.geometry.components import Bend90, Straight from inire.geometry.primitives import Port -from inire.router.astar import AStarContext +from inire.router._astar_types import AStarContext +from inire.router._router import PathFinder from inire.router.cost import CostEvaluator from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder + +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() + ) -@pytest.fixture -def basic_evaluator() -> CostEvaluator: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=(0, 0, 100, 100)) +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _build_context( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + nets: tuple[NetSpec, ...] = (), + **request_overrides: object, +) -> AStarContext: + problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS} + return AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets, **problem_overrides), + _build_options(**option_overrides), + ) + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + metrics=None, + **request_overrides: object, +) -> PathFinder: + return PathFinder( + _build_context( + evaluator, + bounds=bounds, + nets=_request_nets(netlist, net_widths), + **request_overrides, + ), + metrics=metrics, + ) + + +def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list: + path = [] + curr = start + 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 build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]: + engine = RoutingWorld(clearance=2.0) + danger_map = DangerMap(bounds=(-20, -20, 60, 60)) + danger_map.precompute([]) + evaluator = CostEvaluator(engine, danger_map) + context = _build_context( + evaluator, + bounds=(-20, -20, 60, 60), + nets=_request_nets(netlist, widths), + bend_radii=[10.0], + max_straight_length=50.0, + node_limit=50, + warm_start_enabled=False, + max_iterations=1, + enabled=False, + ) + return engine, context, PathFinder(context) + + 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"] + + engine_manual, context_manual, pf_manual = build_router() + assert pf_manual.route_all()["net"].is_valid + engine_manual.add_static_obstacle(box(4, 4, 8, 12)) + context_manual.clear_static_caches() + manual_result = pf_manual.route_all()["net"] + + assert 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([]) - return CostEvaluator(engine, danger_map) + 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, + ) + + 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) -def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None: - context = AStarContext(basic_evaluator) - pf = PathFinder(context) +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, + ) - 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} + 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 - results = pf.route_all(netlist, net_widths) + refined = pf.refiner.refine_path("net", start, width, path) - assert len(results) == 2 - assert results["net1"].is_valid - assert results["net2"].is_valid - assert results["net1"].collisions == 0 - assert results["net2"].collisions == 0 - - -def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None: - 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 test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None: - bounds = (0, -50, 100, 50) - - def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]: - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - danger_map.precompute([]) - evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0) - context = AStarContext(evaluator, bend_radii=[10.0]) - return engine, PathFinder(context, refine_paths=refine_paths) - - 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"] - - refined_engine, refined_pf = build_pathfinder(refine_paths=True) - refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0}) - refined_engine.lock_net("netA") - refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"] - - base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90") - refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90") - - assert base_result.is_valid - assert refined_result.is_valid - assert refined_bends < base_bends - assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path) - - -def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None: - bounds = (0, 0, 100, 100) - netlist = { - "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), - "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), - "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), - } - net_widths = {net_id: 2.0 for net_id in netlist} - - def build_pathfinder(*, 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) - - base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths) - refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths) - - 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 + 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) diff --git a/inire/tests/test_primitives.py b/inire/tests/test_primitives.py index 6e62d45..7e2bd5b 100644 --- a/inire/tests/test_primitives.py +++ b/inire/tests/test_primitives.py @@ -1,8 +1,10 @@ +from dataclasses import FrozenInstanceError from typing import Any from hypothesis import given, strategies as st +import pytest -from inire.geometry.primitives import Port, rotate_port, translate_port +from inire.geometry.primitives import Port @st.composite @@ -24,11 +26,11 @@ def test_port_transform_invariants(p: Port) -> None: # Rotating 90 degrees 4 times should return to same orientation p_rot = p for _ in range(4): - p_rot = rotate_port(p_rot, 90) + p_rot = p_rot.rotated(90) assert abs(p_rot.x - p.x) < 1e-6 assert abs(p_rot.y - p.y) < 1e-6 - assert (p_rot.orientation % 360) == (p.orientation % 360) + assert (p_rot.r % 360) == (p.r % 360) @given( @@ -37,14 +39,21 @@ def test_port_transform_invariants(p: Port) -> None: dy=st.floats(min_value=-1000, max_value=1000), ) def test_translate_snapping(p: Port, dx: float, dy: float) -> None: - p_trans = translate_port(p, dx, dy) + p_trans = p.translate(dx, dy) assert isinstance(p_trans.x, int) assert isinstance(p_trans.y, int) def test_orientation_normalization() -> None: p = Port(0, 0, 360) - assert p.orientation == 0 + assert p.r == 0 p2 = Port(0, 0, -90) - assert p2.orientation == 270 + assert p2.r == 270 + + +def test_port_is_immutable_value_type() -> None: + p = Port(1, 2, 90) + + with pytest.raises(FrozenInstanceError): + p.x = 3 diff --git a/inire/tests/test_refinements.py b/inire/tests/test_refinements.py index 25c3740..56ce7a4 100644 --- a/inire/tests/test_refinements.py +++ b/inire/tests/test_refinements.py @@ -1,10 +1,33 @@ -from inire.geometry.collision import CollisionEngine +from inire import RoutingOptions, RoutingProblem, SearchOptions +from inire.geometry.collision import RoutingWorld from inire.geometry.components import Bend90 from inire.geometry.primitives import Port -from inire.router.astar import AStarContext +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.router.pathfinder import PathFinder + + +def _build_pathfinder( + evaluator: CostEvaluator, + *, + bounds: tuple[float, float, float, float], + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + search: SearchOptions | None = None, +) -> PathFinder: + nets = tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + return PathFinder( + AStarContext( + evaluator, + RoutingProblem(bounds=bounds, nets=nets), + RoutingOptions(search=SearchOptions() if search is None else search), + ), + ) def test_arc_resolution_sagitta() -> None: @@ -18,34 +41,45 @@ def test_arc_resolution_sagitta() -> None: # Check number of points in the polygon exterior # (num_segments + 1) * 2 points usually - pts_coarse = len(res_coarse.geometry[0].exterior.coords) - pts_fine = len(res_fine.geometry[0].exterior.coords) + pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords) + pts_fine = len(res_fine.collision_geometry[0].exterior.coords) assert pts_fine > pts_coarse -def test_locked_paths() -> None: - engine = CollisionEngine(clearance=2.0) +def test_locked_routes() -> None: + engine = RoutingWorld(clearance=2.0) danger_map = DangerMap(bounds=(0, -50, 100, 50)) danger_map.precompute([]) evaluator = CostEvaluator(engine, danger_map) - context = AStarContext(evaluator, bend_radii=[5.0, 10.0]) - pf = PathFinder(context) # 1. Route Net A netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} - results_a = pf.route_all(netlist_a, {"netA": 2.0}) + results_a = _build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_a, + net_widths={"netA": 2.0}, + search=SearchOptions(bend_radii=(5.0, 10.0)), + ).route_all() assert results_a["netA"].is_valid - # 2. Lock Net A - engine.lock_net("netA") + # 2. Treat Net A as locked geometry in the next run. + for polygon in results_a["netA"].locked_geometry: + engine.add_static_obstacle(polygon) # 3. Route Net B through the same space. It should detour or fail. # We'll place Net B's start/target such that it MUST cross Net A's physical path. netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} # Route Net B - results_b = pf.route_all(netlist_b, {"netB": 2.0}) + results_b = _build_pathfinder( + evaluator, + bounds=(0, -50, 100, 50), + netlist=netlist_b, + net_widths={"netB": 2.0}, + search=SearchOptions(bend_radii=(5.0, 10.0)), + ).route_all() # Net B should be is_valid (it detoured) or at least not have collisions # with Net A in the dynamic set (because netA is now static). @@ -55,8 +89,8 @@ def test_locked_paths() -> None: assert results_b["netB"].is_valid # Verify geometry doesn't intersect locked netA (physical check) - poly_a = [p.geometry[0] for p in results_a["netA"].path] - poly_b = [p.geometry[0] for p in results_b["netB"].path] + poly_a = [p.physical_geometry[0] for p in results_a["netA"].path] + poly_b = [p.physical_geometry[0] for p in results_b["netB"].path] for pa in poly_a: for pb in poly_b: diff --git a/inire/tests/test_route_behavior.py b/inire/tests/test_route_behavior.py new file mode 100644 index 0000000..8664a12 --- /dev/null +++ b/inire/tests/test_route_behavior.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from shapely.geometry import Polygon + +from inire import ( + Bend90Seed, + CongestionOptions, + DiagnosticsOptions, + NetSpec, + ObjectiveWeights, + PathSeed, + Port, + RefinementOptions, + RoutingOptions, + RoutingProblem, + RoutingResult, + SearchOptions, + StraightSeed, + route, +) + +DEFAULT_BOUNDS = (0, 0, 100, 100) + +_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"} +_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__) +_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__) +_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__) +_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__) +_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__) + + +def _request_nets( + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], +) -> tuple[NetSpec, ...]: + return tuple( + NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0)) + for net_id, (start, target) in netlist.items() + ) + + +def _build_options(**overrides: object) -> RoutingOptions: + search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS} + congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS} + refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS} + diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS} + objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS} + return RoutingOptions( + search=SearchOptions(**search_overrides), + congestion=CongestionOptions(**congestion_overrides), + refinement=RefinementOptions(**refinement_overrides), + diagnostics=DiagnosticsOptions(**diagnostics_overrides), + objective=ObjectiveWeights(**objective_overrides), + ) + + +def _route_problem( + *, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS, + static_obstacles: tuple[Polygon, ...] = (), + iteration_callback=None, + **overrides: object, +): + problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS} + option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS} + problem = RoutingProblem( + bounds=bounds, + nets=_request_nets(netlist, net_widths), + static_obstacles=static_obstacles, + **problem_overrides, + ) + return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback) + + +def _bend_count(result: RoutingResult) -> int: + return sum(1 for component in result.path if component.move_type == "bend90") + + +def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed: + segments = [] + for kind, value in steps: + if kind == "B": + segments.append(Bend90Seed(radius=5.0, direction=value)) + else: + segments.append(StraightSeed(length=value)) + return PathSeed(tuple(segments)) + + +def test_route_parallel_nets_are_valid() -> None: + run = _route_problem( + netlist={ + "net1": (Port(0, 0, 0), Port(50, 0, 0)), + "net2": (Port(0, 10, 0), Port(50, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + ) + + assert len(run.results_by_net) == 2 + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions == 0 + assert run.results_by_net["net2"].collisions == 0 + + +def test_route_reports_crossing_nets_without_congestion_resolution() -> None: + run = _route_problem( + netlist={ + "net1": (Port(0, 25, 0), Port(100, 25, 0)), + "net2": (Port(50, 0, 90), Port(50, 50, 90)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + max_iterations=1, + base_penalty=1.0, + warm_start_enabled=False, + ) + + assert not run.results_by_net["net1"].is_valid + assert not run.results_by_net["net2"].is_valid + assert run.results_by_net["net1"].collisions > 0 + assert run.results_by_net["net2"].collisions > 0 + + +def test_route_callback_respects_requested_net_order() -> None: + callback_orders: list[list[str]] = [] + + _route_problem( + netlist={ + "short": (Port(0, 0, 0), Port(10, 0, 0)), + "long": (Port(0, 0, 0), Port(40, 10, 0)), + "mid": (Port(0, 0, 0), Port(20, 0, 0)), + }, + net_widths={"short": 2.0, "long": 2.0, "mid": 2.0}, + max_iterations=1, + warm_start_enabled=False, + net_order="longest", + enabled=False, + iteration_callback=lambda iteration, results: callback_orders.append(list(results)), + ) + + assert callback_orders == [["long", "mid", "short"]] + + +def test_route_callback_receives_iteration_results() -> None: + callback_results: list[dict[str, RoutingResult]] = [] + + run = _route_problem( + netlist={ + "net1": (Port(0, 0, 0), Port(10, 0, 0)), + "net2": (Port(0, 10, 0), Port(10, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + iteration_callback=lambda iteration, results: callback_results.append(dict(results)), + ) + + assert len(callback_results) == 1 + assert set(callback_results[0]) == {"net1", "net2"} + assert callback_results[0]["net1"].is_valid + assert callback_results[0]["net2"].is_valid + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + + +def test_route_uses_complete_initial_paths_without_rerouting() -> None: + initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")]) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))}, + net_widths={"net": 2.0}, + bend_radii=[5.0], + max_iterations=1, + warm_start_enabled=False, + initial_paths={"net": initial_seed}, + enabled=False, + ) + + result = run.results_by_net["net"] + assert result.is_valid + assert result.reached_target + assert result.as_seed() == initial_seed + + +def test_route_retries_partial_initial_paths_across_iterations() -> None: + iterations: list[int] = [] + partial_seed = PathSeed((StraightSeed(length=5.0),)) + run = _route_problem( + netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))}, + net_widths={"net": 2.0}, + max_iterations=2, + warm_start_enabled=False, + capture_expanded=True, + initial_paths={"net": partial_seed}, + enabled=False, + iteration_callback=lambda iteration, results: iterations.append(iteration), + ) + + result = run.results_by_net["net"] + assert iterations == [0, 1] + assert result.is_valid + assert result.reached_target + assert result.outcome == "completed" + assert result.as_seed() != partial_seed + assert run.expanded_nodes + + +def test_route_negotiated_congestion_resolution() -> None: + obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) + obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) + run = _route_problem( + bounds=(0, -40, 100, 40), + netlist={ + "net1": (Port(0, 0, 0), Port(50, 0, 0)), + "net2": (Port(0, 10, 0), Port(50, 10, 0)), + }, + net_widths={"net1": 2.0, "net2": 2.0}, + static_obstacles=(obs_top, obs_bottom), + bend_radii=(5.0, 10.0), + max_iterations=10, + base_penalty=1000.0, + ) + + assert run.results_by_net["net1"].reached_target + assert run.results_by_net["net2"].reached_target + assert run.results_by_net["net1"].is_valid + assert run.results_by_net["net2"].is_valid + + +def test_route_refinement_reduces_locked_detour_bends() -> None: + route_a = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))}, + net_widths={"netA": 2.0}, + bend_radii=[10.0], + enabled=False, + ) + locked_geometry = route_a.results_by_net["netA"].locked_geometry + + base_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=False, + ) + refined_run = _route_problem( + bounds=(0, -50, 100, 50), + netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))}, + net_widths={"netB": 2.0}, + static_obstacles=locked_geometry, + bend_radii=[10.0], + enabled=True, + ) + + base_result = base_run.results_by_net["netB"] + refined_result = refined_run.results_by_net["netB"] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) + + +def test_route_refinement_simplifies_triple_crossing_detours() -> None: + base_run = _route_problem( + bounds=(0, 0, 100, 100), + netlist={ + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + }, + net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=False, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + refined_run = _route_problem( + bounds=(0, 0, 100, 100), + netlist={ + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), + "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), + }, + net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0}, + bend_radii=[10.0], + sbend_radii=[10.0], + base_penalty=1000.0, + enabled=True, + greedy_h_weight=1.5, + bend_penalty=250.0, + sbend_penalty=500.0, + ) + + for net_id in ("vertical_up", "vertical_down"): + base_result = base_run.results_by_net[net_id] + refined_result = refined_run.results_by_net[net_id] + assert base_result.is_valid + assert refined_result.is_valid + assert _bend_count(refined_result) < _bend_count(base_result) diff --git a/inire/tests/test_variable_grid.py b/inire/tests/test_variable_grid.py index ea6dee8..e0acd71 100644 --- a/inire/tests/test_variable_grid.py +++ b/inire/tests/test_variable_grid.py @@ -1,21 +1,41 @@ import unittest + from inire.geometry.primitives import Port -from inire.router.astar import route_astar, AStarContext +from inire.model import RoutingOptions, RoutingProblem +from inire.router._astar_types import AStarContext, SearchRunConfig +from inire.router._search import route_astar from inire.router.cost import CostEvaluator -from inire.geometry.collision import CollisionEngine +from inire.geometry.collision import RoutingWorld class TestIntegerPorts(unittest.TestCase): def setUp(self): - self.ce = CollisionEngine(clearance=2.0) + self.ce = RoutingWorld(clearance=2.0) self.cost = CostEvaluator(self.ce) + self.bounds = (0, 0, 100, 100) + + def _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 = AStarContext(self.cost) + context = self._build_context() start = Port(0, 0, 0) target = Port(12, 0, 0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) last_port = path[-1].end_port @@ -24,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.r, 0) def test_port_constructor_rounds_to_integer_lattice(self): - context = AStarContext(self.cost) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(12.3, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 12) @@ -36,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase): self.assertEqual(last_port.x, 12) def test_half_step_inputs_use_integerized_targets(self): - context = AStarContext(self.cost) + context = self._build_context() start = Port(0.0, 0.0, 0.0) target = Port(7.5, 0.0, 0.0) - path = route_astar(start, target, net_width=1.0, context=context) + path = self._route(context, start, target) self.assertIsNotNone(path) self.assertEqual(target.x, 8) diff --git a/inire/tests/test_visibility.py b/inire/tests/test_visibility.py new file mode 100644 index 0000000..0e2100f --- /dev/null +++ b/inire/tests/test_visibility.py @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..eb139ca --- /dev/null +++ b/inire/tests/test_visualization.py @@ -0,0 +1,26 @@ +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 deleted file mode 100644 index a044854..0000000 --- a/inire/utils/validation.py +++ /dev/null @@ -1,104 +0,0 @@ -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 8e2a0d8..8268c47 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -10,7 +10,8 @@ if TYPE_CHECKING: from matplotlib.figure import Figure from inire.geometry.primitives import Port - from inire.router.pathfinder import RoutingResult + from inire.router.danger_map import DangerMap + from inire.results import RoutingResult def plot_routing_results( @@ -50,8 +51,7 @@ def plot_routing_results( label_added = False for comp in res.path: # 1. Plot Collision Geometry (Translucent fill) - # This is the geometry used during search (e.g. proxy or arc) - for poly in comp.geometry: + for poly in comp.collision_geometry: if isinstance(poly, MultiPolygon): geoms = list(poly.geoms) else: @@ -66,9 +66,7 @@ def plot_routing_results( x, y = g.xy ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2) - # 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication) - # Use comp.actual_geometry if it exists (should be the arc) - actual_geoms_to_plot = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry + actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry for poly in actual_geoms_to_plot: if isinstance(poly, MultiPolygon): @@ -86,27 +84,29 @@ def plot_routing_results( # 3. Plot subtle port orientation arrow p = comp.end_port - rad = numpy.radians(p.orientation) + rad = numpy.radians(p.r) ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4) if not res.path and not res.is_valid: - # Best-effort display: If the path is empty but failed, it might be unroutable. - # We don't have a partial path in RoutingResult currently. + # Empty failed paths are typically unroutable. pass # 4. Plot main arrows for netlist ports if netlist: - for net_id, (start_p, target_p) in netlist.items(): + for _net_id, (start_p, target_p) in netlist.items(): for p in [start_p, target_p]: - rad = numpy.radians(p[2]) - ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black", + rad = numpy.radians(p.r) + ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black", scale=25, width=0.004, pivot="tail", zorder=6) ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3]) ax.set_aspect("equal") - ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") + if show_actual: + ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)") + else: + ax.set_title("Inire Routing Results (Proxy 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) + x, y, _ = zip(*nodes, strict=False) 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) + x, y, _ = zip(*nodes, strict=False) # Create 2D histogram h, xedges, yedges = numpy.histogram2d( diff --git a/pyproject.toml b/pyproject.toml index afbec0f..efbd939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ lint.ignore = [ "C408", # dict(x=y) instead of {'x': y} "PLR09", # Too many xxx "PLR2004", # magic number - #"PLC0414", # import x as x + "PLC0414", # import x as x "TRY003", # Long exception message ]