rework structure of everything

This commit is contained in:
Jan Petykiewicz 2026-03-30 15:32:29 -07:00
commit 941d3e01df
64 changed files with 3819 additions and 3559 deletions

210
DOCS.md
View file

@ -1,107 +1,159 @@
# Inire Configuration & API Documentation
This document describes the user-tunable parameters for the `inire` auto-router.
This document describes the current public API for `inire`.
## 1. AStarContext Parameters
## 1. Primary API
The `AStarContext` stores the configuration and persistent state for the A* search. It is initialized once and passed to `route_astar` or the `PathFinder`.
### `RoutingProblem`
| Parameter | Type | Default | Description |
| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ |
| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. |
| `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. |
| `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. |
| `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). |
| `sbend_radii` | `list[float]` | `[5.0, 10.0, 50.0, 100.0]` | Available radii for S-bends (µm). |
| `sbend_offsets` | `list[float] \| None` | `None` (Auto) | Lateral offsets for parametric S-bends. |
| `bend_penalty` | `float` | 250.0 | Flat cost added for every 90-degree bend. |
| `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. |
| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"` (an 8-point conservative arc proxy). |
| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide for clipped models. |
| `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. |
`RoutingProblem` describes the physical routing problem:
## 2. AStarMetrics
- `bounds`
- `nets`
- `static_obstacles`
- `locked_routes`
- `clearance`
- `max_net_width`
- `safety_zone_radius`
The `AStarMetrics` object collects performance data during the search.
### `RoutingOptions`
| Property | Type | Description |
| :--------------------- | :---- | :---------------------------------------------------- |
| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. |
| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. |
| `max_depth_reached` | `int` | Deepest point in the search tree reached. |
`RoutingOptions` groups all expert controls for the routing engine:
---
- `search`
- `objective`
- `congestion`
- `refinement`
- `diagnostics`
## 3. CostEvaluator Parameters
Route a problem with:
The `CostEvaluator` defines the "goodness" of a path.
```python
run = route(problem, options=options)
```
| Parameter | Type | Default | Description |
| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- |
| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. |
| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. |
| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. |
If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults.
---
### Incremental routing with `LockedRoute`
## 3. PathFinder Parameters
For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem:
The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm.
```python
run_a = route(problem_a)
problem_b = RoutingProblem(
bounds=problem_a.bounds,
nets=(...),
locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()},
)
run_b = route(problem_b)
```
| Parameter | Type | Default | Description |
| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- |
| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. |
| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.|
| `refine_paths` | `bool` | `True` | Run the post-route path simplifier that removes unnecessary bend ladders when it finds a valid lower-cost replacement. |
`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle.
---
## 2. Search Options
## 4. CollisionEngine Parameters
`RoutingOptions.search` is a `SearchOptions` object.
| Parameter | Type | Default | Description |
| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ |
| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). |
| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. |
| Field | Default | Description |
| :-- | :-- | :-- |
| `node_limit` | `1_000_000` | Maximum number of states to explore per net. |
| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. |
| `min_straight_length` | `5.0` | Minimum length of a single straight segment. |
| `greedy_h_weight` | `1.5` | Heuristic weight. `1.0` is optimal but slower. |
| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. |
| `sbend_radii` | `(10.0,)` | Available radii for S-bends. |
| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. |
| `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. |
| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. |
| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. |
---
## 3. Objective Weights
## 4. Physical Units & Precision
- **Coordinates**: Micrometers (µm).
- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves.
- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**.
- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost.
`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`.
---
| Field | Default | Description |
| :-- | :-- | :-- |
| `unit_length_cost` | `1.0` | Cost per unit length. |
| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. |
| `sbend_penalty` | `500.0` | Flat S-bend penalty. |
| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. |
| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. |
## 5. Best Practices & Tuning Advice
## 4. Congestion Options
### Speed vs. Optimality
The `greedy_h_weight` is your primary lever for search performance.
- **`1.0`**: Dijkstra-like behavior. Guarantees the shortest path but is very slow.
- **`1.1` to `1.2`**: Recommended range. Balances wire length with fast convergence.
- **`> 1.5`**: Extremely fast "greedy" search. May produce zig-zags or suboptimal detours.
`RoutingOptions.congestion` is a `CongestionOptions` object.
### Avoiding "Zig-Zags"
If the router produces many small bends instead of a long straight line:
1. Increase `bend_penalty` (e.g., set to `100.0` or higher).
2. Increase available `bend_radii` if larger turns are physically acceptable.
3. Decrease `greedy_h_weight` closer to `1.0`.
| Field | Default | Description |
| :-- | :-- | :-- |
| `max_iterations` | `10` | Maximum rip-up and reroute iterations. |
| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. |
| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. |
| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. |
| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. |
| `shuffle_nets` | `False` | Shuffle routing order between iterations. |
| `sort_nets` | `None` | Optional deterministic routing order. |
| `seed` | `None` | RNG seed for shuffled routing order. |
### Visibility Guidance
The router can bias straight stop points using static obstacle corners.
- **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space.
- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner.
- **`"off"`**: Disables visibility-derived straight candidates entirely.
The arbitrary-point visibility scan remains available for diagnostics, but the router hot path intentionally uses the exact-corner / tangent-corner forms only.
## 5. Refinement Options
### Handling Congestion
In multi-net designs, if nets are overlapping:
1. Increase `congestion_penalty` in `CostEvaluator`.
2. Increase `max_iterations` in `PathFinder`.
3. If a solution is still not found, check if the `clearance` is physically possible given the design's narrowest bottlenecks.
`RoutingOptions.refinement` is a `RefinementOptions` object.
### S-Bend Usage
Parametric S-bends bridge lateral gaps without changing the waveguide's orientation.
- **Automatic Selection**: If `sbend_offsets` is set to `None` (the default), the router automatically chooses from a set of "natural" offsets (Fibonacci-aligned grid steps) and the offset needed to hit the target.
- **Specific Offsets**: To use specific offsets (e.g., 5.86µm for a 45° switchover), provide them in the `sbend_offsets` list. The router will prioritize these but will still try to align with the target if possible.
- **Constraints**: S-bends are only used for offsets $O < 2R$. For larger shifts, the router naturally combines two 90° bends and a straight segment.
| Field | Default | Description |
| :-- | :-- | :-- |
| `enabled` | `True` | Enable post-route refinement. |
| `objective` | `None` | Optional override objective for refinement. `None` reuses the search objective. |
## 6. Diagnostics Options
`RoutingOptions.diagnostics` is a `DiagnosticsOptions` object.
| Field | Default | Description |
| :-- | :-- | :-- |
| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. |
## 7. RouteMetrics
`RoutingRunResult.metrics` is an immutable per-run snapshot.
| Field | Type | Description |
| :-- | :-- | :-- |
| `nodes_expanded` | `int` | Total nodes expanded during the run. |
| `moves_generated` | `int` | Total candidate moves generated during the run. |
| `moves_added` | `int` | Total candidate moves admitted to the open set during the run. |
| `pruned_closed_set` | `int` | Total moves pruned because the state was already closed at lower cost. |
| `pruned_hard_collision` | `int` | Total moves pruned by hard collision checks. |
| `pruned_cost` | `int` | Total moves pruned by cost ceilings or invalid costs. |
## 8. Internal Modules
Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`.
## 9. Tuning Notes
### Speed vs. optimality
- Lower `search.greedy_h_weight` toward `1.0` for better optimality.
- Raise `search.greedy_h_weight` for faster, greedier routing.
### Congestion handling
- Increase `congestion.base_penalty` to separate nets more aggressively in the first iteration.
- Increase `congestion.max_iterations` if congestion needs more reroute passes.
- Increase `congestion.multiplier` if later iterations need to escalate more quickly.
### Bend-heavy routes
- Increase `objective.bend_penalty` to discourage ladders of small bends.
- Increase available `search.bend_radii` when larger turns are physically acceptable.
### Visibility guidance
- `"tangent_corner"` is the default and best general-purpose setting in obstacle-dense layouts.
- `"exact_corner"` is more conservative.
- `"off"` disables visibility-derived straight candidates.
### S-bends
- Leave `search.sbend_offsets=None` to let the router derive natural offsets automatically.
- Provide explicit `search.sbend_offsets` for known process-preferred offsets.
- S-bends are only used for offsets smaller than `2R`.

View file

@ -1,6 +1,6 @@
# inire: Auto-Routing for Photonic and RF Integrated Circuits
`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) to route multiple nets while maintaining strict geometric fidelity and clearance.
`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance.
## Key Features
@ -9,7 +9,7 @@
* **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths.
* **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid.
* **Safety & Proximity**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk.
* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions.
* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs.
## Installation
@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib
## Quick Start
```python
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarContext
from inire.router.pathfinder import PathFinder
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
# 1. Setup Environment
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 1000, 1000))
danger_map.precompute([]) # Add polygons here for obstacles
# 2. Configure Router
evaluator = CostEvaluator(
collision_engine=engine,
danger_map=danger_map,
greedy_h_weight=1.2
problem = RoutingProblem(
bounds=(0, 0, 1000, 1000),
nets=(
NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0),
),
)
context = AStarContext(
cost_evaluator=evaluator,
bend_penalty=10.0
options = RoutingOptions(
search=SearchOptions(
bend_radii=(50.0, 100.0),
greedy_h_weight=1.2,
),
objective=ObjectiveWeights(
bend_penalty=10.0,
),
)
pf = PathFinder(context)
# 3. Define Netlist
netlist = {
"net1": (Port(0, 0, 0), Port(100, 50, 0)),
}
run = route(problem, options=options)
# 4. Route
results = pf.route_all(netlist, {"net1": 2.0})
if results["net1"].is_valid:
if run.results_by_net["net1"].is_valid:
print("Successfully routed net1!")
```
For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`.
## Usage Examples
For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**.
@ -82,11 +72,11 @@ Full documentation for all user-tunable parameters, cost functions, and collisio
2. **90° Bends**: Fixed-radius PDK cells.
3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$).
For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings.
For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings.
## Configuration
`inire` is highly tunable. Every major component (Router, CostEvaluator, PathFinder) accepts explicit named arguments in its constructor to control expansion rules, cost weights, and convergence limits. See `DOCS.md` for a full parameter reference.
`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference.
## License

View file

@ -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")

View file

@ -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")

View file

@ -1,42 +1,37 @@
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, AStarMetrics
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 03: Locked Paths...")
print("Running Example 03: Locked Routes...")
# 1. Setup Environment
bounds = (0, -50, 100, 50)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[10.0])
metrics = AStarMetrics()
pf = PathFinder(context, metrics)
# 2. Route Net A and 'Lock' it
# Net A is a straight path blocking the direct route for Net B
options = RoutingOptions(
search=SearchOptions(bend_radii=(10.0,)),
objective=ObjectiveWeights(
bend_penalty=250.0,
sbend_penalty=500.0,
),
)
print("Routing initial net...")
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
results_a = pf.route_all(netlist_a, {"netA": 2.0})
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
# Locking prevents Net A from being removed or rerouted during NC iterations
engine.lock_net("netA")
print("Initial net locked as static obstacle.")
# 3. Route Net B (forced to detour)
print("Routing detour net around locked path...")
netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))}
results_b = pf.route_all(netlist_b, {"netB": 2.0})
results_b = route(
RoutingProblem(
bounds=bounds,
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
locked_routes={"netA": results_a["netA"].as_locked_route()},
),
options=options,
).results_by_net
# 4. Visualize
results = {**results_a, **results_b}
fig, ax = plot_routing_results(results, [], bounds)
fig.savefig("examples/03_locked_paths.png")

View file

@ -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")

View file

@ -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")

View file

@ -1,11 +1,7 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.utils.visualization import plot_routing_results
@ -15,34 +11,30 @@ def _route_scenario(
bend_collision_type: str,
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
*,
bend_clip_margin: float = 10.0,
) -> dict[str, object]:
engine = CollisionEngine(clearance=2.0)
for obstacle in obstacles:
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
context = AStarContext(
evaluator,
bend_radii=[10.0],
bend_collision_type=bend_collision_type,
bend_clip_margin=bend_clip_margin,
) -> dict[str, RoutingResult]:
problem = RoutingProblem(
bounds=bounds,
nets=tuple(NetSpec(net_id, start, target, width=widths[net_id]) for net_id, (start, target) in netlist.items()),
static_obstacles=tuple(obstacles),
)
return PathFinder(context, use_tiered_strategy=False).route_all(netlist, widths)
options = RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
bend_collision_type=bend_collision_type,
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(use_tiered_strategy=False),
)
return route(problem, options=options).results_by_net
def main() -> None:
print("Running Example 06: Bend Collision Models...")
# 1. Setup Environment
# Give room for 10um bends near the edges
bounds = (-20, -20, 170, 170)
# Create three scenarios with identical obstacles
# We'll space them out vertically
obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)])
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
@ -52,29 +44,17 @@ def main() -> None:
netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}
netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}
# 2. Route each scenario
print("Routing Scenario 1 (Arc)...")
res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0})
print("Routing Scenario 2 (BBox)...")
res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0})
print("Routing Scenario 3 (Clipped BBox)...")
res_clipped = _route_scenario(
bounds,
obstacles,
"clipped_bbox",
netlist_clipped,
{"clipped_model": 2.0},
bend_clip_margin=1.0,
)
res_clipped = _route_scenario(bounds, obstacles, "clipped_bbox", netlist_clipped, {"clipped_model": 2.0})
# 3. Combine results for visualization
all_results = {**res_arc, **res_bbox, **res_clipped}
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped}
# 4. Visualize
fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
fig.savefig("examples/06_bend_collision_models.png")
print("Saved plot to examples/06_bend_collision_models.png")

View file

@ -1,108 +1,111 @@
import numpy as np
import time
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, AStarMetrics
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.utils.visualization import plot_routing_results, plot_danger_map, plot_expanded_nodes, plot_expansion_density
from shapely.geometry import box
from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
Port,
RoutingOptions,
RoutingProblem,
RoutingResult,
SearchOptions,
route,
)
from inire.utils.visualization import plot_expanded_nodes, plot_routing_results
def main() -> None:
print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...")
# 1. Setup Environment
bounds = (0, 0, 1000, 1000)
engine = CollisionEngine(clearance=6.0)
# Bottleneck at x=500, 200um gap
obstacles = [
box(450, 0, 550, 400),
box(450, 600, 550, 1000),
]
for obs in obstacles:
engine.add_static_obstacle(obs)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, unit_length_cost=0.1, bend_penalty=100.0, sbend_penalty=400.0, congestion_penalty=100.0)
context = AStarContext(evaluator, node_limit=2000000, bend_radii=[50.0], sbend_radii=[50.0])
metrics = AStarMetrics()
pf = PathFinder(context, metrics, max_iterations=15, base_congestion_penalty=100.0, congestion_multiplier=1.4)
# 2. Define Netlist
netlist = {}
num_nets = 10
start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950
end_y_base = 100
end_y_pitch = 800.0 / (num_nets - 1)
for i in range(num_nets):
sy = int(round(start_y_base + i * 10.0))
ey = int(round(end_y_base + i * end_y_pitch))
netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
netlist: dict[str, tuple[Port, Port]] = {}
for index in range(num_nets):
start_y = int(round(start_y_base + index * 10.0))
end_y = int(round(end_y_base + index * end_y_pitch))
netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0))
net_widths = {nid: 2.0 for nid in netlist}
problem = RoutingProblem(
bounds=bounds,
nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()),
static_obstacles=tuple(obstacles),
clearance=6.0,
)
options = RoutingOptions(
search=SearchOptions(
node_limit=2_000_000,
bend_radii=(50.0,),
sbend_radii=(50.0,),
greedy_h_weight=1.5,
),
objective=ObjectiveWeights(
unit_length_cost=0.1,
bend_penalty=100.0,
sbend_penalty=400.0,
),
congestion=CongestionOptions(
max_iterations=15,
base_penalty=100.0,
multiplier=1.4,
shuffle_nets=True,
seed=42,
),
diagnostics=DiagnosticsOptions(capture_expanded=True),
)
iteration_stats: list[dict[str, int]] = []
def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None:
successes = sum(1 for result in current_results.values() if result.is_valid)
total_collisions = sum(result.collisions for result in current_results.values())
print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
iteration_stats.append(
{
"Iteration": iteration,
"Success": successes,
"Congestion": total_collisions,
}
)
# 3. Route
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
start_time = time.perf_counter()
run = route(problem, options=options, iteration_callback=iteration_callback)
end_time = time.perf_counter()
iteration_stats = []
def iteration_callback(idx, current_results):
successes = sum(1 for r in current_results.values() if r.is_valid)
total_collisions = sum(r.collisions for r in current_results.values())
total_nodes = metrics.nodes_expanded
print(f" Iteration {idx} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
# Adaptive Greediness: Decay from 1.5 to 1.1 over 10 iterations
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
evaluator.greedy_h_weight = new_greedy
print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}")
iteration_stats.append({
'Iteration': idx,
'Success': successes,
'Congestion': total_collisions,
'Nodes': total_nodes
})
metrics.reset_per_route()
t0 = time.perf_counter()
results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42)
t1 = time.perf_counter()
print(f"Routing took {t1-t0:.4f}s")
# 4. Check Results
print(f"Routing took {end_time - start_time:.4f}s")
print("\n--- Iteration Summary ---")
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}")
print("-" * 40)
for s in iteration_stats:
print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}")
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8}")
print("-" * 30)
for stats in iteration_stats:
print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8}")
success_count = sum(1 for res in results.values() if res.is_valid)
success_count = sum(1 for result in run.results_by_net.values() if result.is_valid)
print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.")
for nid, res in results.items():
if not res.is_valid:
print(f" FAILED: {nid}, collisions={res.collisions}")
for net_id, result in run.results_by_net.items():
if not result.is_valid:
print(f" FAILED: {net_id}, collisions={result.collisions}")
else:
print(f" {nid}: SUCCESS")
# 5. Visualize
fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
plot_danger_map(danger_map, ax=ax)
print(f" {net_id}: SUCCESS")
fig, ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist=netlist)
plot_expanded_nodes(list(run.expanded_nodes), ax=ax)
fig.savefig("examples/07_large_scale_routing.png")
print("Saved plot to examples/07_large_scale_routing.png")
if __name__ == "__main__":
main()

View file

@ -1,59 +1,61 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, AStarMetrics
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.utils.visualization import plot_routing_results
def _route_with_context(
context: AStarContext,
metrics: AStarMetrics,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
) -> dict[str, object]:
return PathFinder(context, metrics, use_tiered_strategy=False).route_all(netlist, net_widths)
def _run_request(
bounds: tuple[float, float, float, float],
bend_collision_type: object,
net_id: str,
start: Port,
target: Port,
) -> dict[str, RoutingResult]:
problem = RoutingProblem(
bounds=bounds,
nets=(NetSpec(net_id, start, target, width=2.0),),
)
options = RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
bend_collision_type=bend_collision_type,
sbend_radii=(),
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(use_tiered_strategy=False),
)
return route(problem, options=options).results_by_net
def main() -> None:
print("Running Example 08: Custom Bend Geometry...")
# 1. Setup Environment
bounds = (0, 0, 150, 150)
start = Port(20, 20, 0)
target = Port(100, 100, 90)
# 2. Define Netlist
netlist = {
"custom_bend": (Port(20, 20, 0), Port(100, 100, 90)),
}
net_widths = {"custom_bend": 2.0}
def build_context(bend_collision_type: object = "arc") -> tuple[AStarContext, AStarMetrics]:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
return AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=bend_collision_type, sbend_radii=[]), AStarMetrics()
# 3. Route with standard arc first
print("Routing with standard arc...")
context_std, metrics_std = build_context()
results_std = _route_with_context(context_std, metrics_std, netlist, net_widths)
results_std = _run_request(bounds, "arc", "custom_bend", start, target)
# 4. Define a custom Manhattan 90-degree bend proxy in bend-local coordinates.
# The polygon origin is the bend center. It is mirrored for CW bends and
# rotated with the bend orientation before being translated into place.
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
print("Routing with custom bend geometry...")
context_custom, metrics_custom = build_context(custom_poly)
results_custom = _route_with_context(context_custom, metrics_custom, {"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0})
results_custom = _run_request(bounds, custom_poly, "custom_model", start, target)
# 5. Visualize
all_results = {**results_std, **results_custom}
fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist)
fig, _ax = plot_routing_results(
all_results,
[],
bounds,
netlist={
"custom_bend": (start, target),
"custom_model": (start, target),
},
)
fig.savefig("examples/08_custom_bend_geometry.png")
print("Saved plot to examples/08_custom_bend_geometry.png")

View file

@ -1,58 +1,46 @@
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, AStarMetrics
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.utils.visualization import plot_routing_results
from shapely.geometry import box
from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 09: Best-Effort Under Tight Search Budget...")
# 1. Setup Environment
bounds = (0, 0, 100, 100)
engine = CollisionEngine(clearance=2.0)
# A small obstacle cluster keeps the partial route visually interesting.
obstacles = [
box(35, 35, 45, 65),
box(55, 35, 65, 65),
]
for obs in obstacles:
engine.add_static_obstacle(obs)
problem = RoutingProblem(
bounds=bounds,
nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),),
static_obstacles=tuple(obstacles),
)
options = RoutingOptions(
search=SearchOptions(
node_limit=3,
bend_radii=(10.0,),
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(warm_start=None),
)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
# Keep the search budget intentionally tiny so the router returns a partial path.
context = AStarContext(evaluator, node_limit=3, bend_radii=[10.0])
metrics = AStarMetrics()
pf = PathFinder(context, metrics, warm_start=None)
# 2. Define Netlist: reaching the target requires additional turns the search budget cannot afford.
netlist = {
"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180)),
}
net_widths = {"budget_limited_net": 2.0}
# 3. Route
print("Routing with a deliberately tiny node budget (should return a partial path)...")
results = pf.route_all(netlist, net_widths)
# 4. Check Results
res = results["budget_limited_net"]
if not res.reached_target:
print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.")
run = route(problem, options=options)
result = run.results_by_net["budget_limited_net"]
if not result.reached_target:
print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.")
else:
print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.")
# 5. Visualize
fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))})
fig.savefig("examples/09_unroutable_best_effort.png")
print("Saved plot to examples/09_unroutable_best_effort.png")
if __name__ == "__main__":
main()

View file

@ -1,8 +1,43 @@
"""
inire Wave-router
"""
from .api import (
CongestionOptions as CongestionOptions,
DiagnosticsOptions as DiagnosticsOptions,
LockedRoute as LockedRoute,
NetSpec as NetSpec,
ObjectiveWeights as ObjectiveWeights,
RefinementOptions as RefinementOptions,
RoutingOptions as RoutingOptions,
RoutingProblem as RoutingProblem,
RoutingRunResult as RoutingRunResult,
SearchOptions as SearchOptions,
route as route,
) # noqa: PLC0414
from .geometry.primitives import Port as Port # noqa: PLC0414
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414
from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414
__author__ = 'Jan Petykiewicz'
__version__ = '0.1'
__all__ = [
"Bend90",
"CongestionOptions",
"DiagnosticsOptions",
"LockedRoute",
"NetSpec",
"ObjectiveWeights",
"Port",
"RefinementOptions",
"RoutingOptions",
"RoutingProblem",
"RoutingReport",
"RoutingResult",
"RoutingRunResult",
"RouteMetrics",
"SBend",
"SearchOptions",
"Straight",
"route",
]

97
inire/api.py Normal file
View file

@ -0,0 +1,97 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from inire.geometry.collision import RoutingWorld
from inire.model import (
CongestionOptions,
DiagnosticsOptions,
LockedRoute,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
RoutingRunResult,
SearchOptions,
)
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.results import RouteMetrics, RoutingReport, RoutingResult
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from shapely.geometry import Polygon
__all__ = [
"CongestionOptions",
"DiagnosticsOptions",
"LockedRoute",
"NetSpec",
"ObjectiveWeights",
"RefinementOptions",
"RouteMetrics",
"RoutingOptions",
"RoutingProblem",
"RoutingReport",
"RoutingResult",
"RoutingRunResult",
"SearchOptions",
"route",
]
def _iter_locked_polygons(
locked_routes: dict[str, LockedRoute],
) -> Iterable[Polygon]:
for route in locked_routes.values():
yield from route.geometry
def _build_context(problem: RoutingProblem, options: RoutingOptions) -> AStarContext:
world = RoutingWorld(
clearance=problem.clearance,
max_net_width=problem.max_net_width,
safety_zone_radius=problem.safety_zone_radius,
)
for obstacle in problem.static_obstacles:
world.add_static_obstacle(obstacle)
for polygon in _iter_locked_polygons(problem.locked_routes):
world.add_static_obstacle(polygon)
danger_obstacles = list(problem.static_obstacles)
danger_obstacles.extend(_iter_locked_polygons(problem.locked_routes))
danger_map = DangerMap(bounds=problem.bounds)
danger_map.precompute(danger_obstacles)
objective = options.objective
evaluator = CostEvaluator(
world,
danger_map,
unit_length_cost=objective.unit_length_cost,
greedy_h_weight=options.search.greedy_h_weight,
bend_penalty=objective.bend_penalty,
sbend_penalty=objective.sbend_penalty,
danger_weight=objective.danger_weight,
)
return AStarContext(evaluator, problem, options)
def route(
problem: RoutingProblem,
*,
options: RoutingOptions | None = None,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
) -> RoutingRunResult:
resolved_options = RoutingOptions() if options is None else options
finder = PathFinder(_build_context(problem, resolved_options))
results = finder.route_all(iteration_callback=iteration_callback)
return RoutingRunResult(
results_by_net=results,
metrics=finder.metrics.snapshot(),
expanded_nodes=tuple(finder.accumulated_expanded_nodes),
)

View file

@ -3,7 +3,7 @@ Centralized constants for the inire routing engine.
"""
# Search Grid Snap (5.0 µm default)
# TODO: Make this configurable in RouterConfig and define tolerances relative to the grid.
# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid.
DEFAULT_SEARCH_GRID_SNAP_UM = 5.0
# Tolerances

View file

@ -3,67 +3,69 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy
from shapely.geometry import LineString, box
from inire.geometry.collision_query_checker import CollisionQueryChecker
from inire.geometry.dynamic_congestion_checker import DynamicCongestionChecker
from inire.geometry.component_overlap import components_overlap
from inire.geometry.dynamic_path_index import DynamicPathIndex
from inire.geometry.path_verifier import PathVerificationReport, PathVerifier
from inire.geometry.ray_caster import RayCaster
from inire.geometry.index_helpers import grid_cell_span
from inire.geometry.static_obstacle_index import StaticObstacleIndex
from inire.geometry.static_move_checker import StaticMoveChecker
from inire.router.results import RoutingReport
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry
from shapely.strtree import STRtree
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
class CollisionEngine:
def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float:
if hasattr(geometry, "geoms"):
return min(_intersection_distance(origin, sub_geometry) for sub_geometry in geometry.geoms)
return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2))
class RoutingWorld:
"""
Manages spatial queries for collision detection with unified dilation logic.
Internal spatial state for collision detection, congestion, and verification.
"""
__slots__ = (
'clearance', 'max_net_width', 'safety_zone_radius',
'metrics', 'grid_cell_size', '_inv_grid_cell_size', '_dynamic_bounds_array',
'_path_verifier', '_dynamic_paths', '_static_obstacles', '_ray_caster', '_static_move_checker',
'_dynamic_congestion_checker', '_collision_query_checker',
"clearance",
"max_net_width",
"safety_zone_radius",
"metrics",
"grid_cell_size",
"_dynamic_paths",
"_static_obstacles",
)
def __init__(
self,
clearance: float,
max_net_width: float = 2.0,
safety_zone_radius: float = 0.0021,
) -> None:
self,
clearance: float,
max_net_width: float = 2.0,
safety_zone_radius: float = 0.0021,
) -> None:
self.clearance = clearance
self.max_net_width = max_net_width
self.safety_zone_radius = safety_zone_radius
self.grid_cell_size = 50.0
self._inv_grid_cell_size = 1.0 / self.grid_cell_size
self._static_obstacles = StaticObstacleIndex(self)
self._dynamic_bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4)
self._dynamic_paths = DynamicPathIndex(self)
self.metrics = {
'static_cache_hits': 0,
'static_grid_skips': 0,
'static_tree_queries': 0,
'static_straight_fast': 0,
'congestion_grid_skips': 0,
'congestion_tree_queries': 0,
'safety_zone_checks': 0
"static_cache_hits": 0,
"static_grid_skips": 0,
"static_tree_queries": 0,
"static_straight_fast": 0,
"congestion_grid_skips": 0,
"congestion_tree_queries": 0,
"safety_zone_checks": 0,
}
self._path_verifier = PathVerifier(self)
self._ray_caster = RayCaster(self)
self._static_move_checker = StaticMoveChecker(self)
self._dynamic_congestion_checker = DynamicCongestionChecker(self)
self._collision_query_checker = CollisionQueryChecker(self)
def get_static_version(self) -> int:
return self._static_obstacles.version
@ -89,23 +91,22 @@ class CollisionEngine:
return self._dynamic_paths.geometries.values()
def reset_metrics(self) -> None:
for k in self.metrics:
self.metrics[k] = 0
for key in self.metrics:
self.metrics[key] = 0
def get_metrics_summary(self) -> str:
m = self.metrics
return (f"Collision Performance: \n"
f" Static: {m['static_tree_queries']} checks\n"
f" Congestion: {m['congestion_tree_queries']} checks\n"
f" Safety Zone: {m['safety_zone_checks']} full intersections performed")
metrics = self.metrics
return (
"Collision Performance: \n"
f" Static: {metrics['static_tree_queries']} checks\n"
f" Congestion: {metrics['congestion_tree_queries']} checks\n"
f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed"
)
def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry)
def remove_static_obstacle(self, obj_id: int) -> None:
"""
Remove a static obstacle by ID.
"""
self._static_obstacles.remove_obstacle(obj_id)
def _invalidate_static_caches(self) -> None:
@ -115,9 +116,6 @@ class CollisionEngine:
self._static_obstacles.ensure_tree()
def _ensure_net_static_tree(self, net_width: float) -> STRtree:
"""
Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance).
"""
return self._static_obstacles.ensure_net_tree(net_width)
def _ensure_static_raw_tree(self) -> None:
@ -125,7 +123,6 @@ class CollisionEngine:
def _ensure_dynamic_tree(self) -> None:
self._dynamic_paths.ensure_tree()
self._dynamic_bounds_array = self._dynamic_paths.bounds_array
def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid()
@ -134,45 +131,28 @@ class CollisionEngine:
self._dynamic_paths.tree = None
self._ensure_dynamic_tree()
def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None:
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry)
def remove_path(self, net_id: str) -> None:
self._dynamic_paths.remove_path(net_id)
def lock_net(self, net_id: str) -> None:
""" Convert a routed net into static obstacles. """
self._dynamic_paths.lock_net(net_id)
def unlock_net(self, net_id: str) -> None:
self._dynamic_paths.unlock_net(net_id)
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
return self._static_move_checker.check_move_straight_static(start_port, length, net_width)
self.metrics["static_straight_fast"] += 1
reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width)
return reach < length - 0.001
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
return self._static_move_checker.is_in_safety_zone_fast(idx, start_port, end_port)
def check_move_static(
self,
result: ComponentResult,
start_port: Port | None = None,
end_port: Port | None = None,
net_width: float | None = None,
) -> bool:
return self._static_move_checker.check_move_static(
result,
start_port=start_port,
end_port=end_port,
net_width=net_width,
bounds = self._static_obstacles.bounds_array[idx]
safety_zone = self.safety_zone_radius
if start_port and bounds[0] - safety_zone <= start_port.x <= bounds[2] + safety_zone and bounds[1] - safety_zone <= start_port.y <= bounds[3] + safety_zone:
return True
return bool(
end_port
and bounds[0] - safety_zone <= end_port.x <= bounds[2] + safety_zone
and bounds[1] - safety_zone <= end_port.y <= bounds[3] + safety_zone
)
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
return self._dynamic_congestion_checker.check_move_congestion(result, net_id)
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
return self._dynamic_congestion_checker.check_real_congestion(result, net_id)
def _is_in_safety_zone(
self,
geometry: Polygon,
@ -180,7 +160,247 @@ class CollisionEngine:
start_port: Port | None,
end_port: Port | None,
) -> bool:
return self._static_move_checker.is_in_safety_zone(geometry, obj_id, start_port, end_port)
raw_obstacle = self._static_obstacles.geometries[obj_id]
safety_zone = self.safety_zone_radius
obstacle_bounds = raw_obstacle.bounds
near_start = start_port and (
obstacle_bounds[0] - safety_zone <= start_port.x <= obstacle_bounds[2] + safety_zone
and obstacle_bounds[1] - safety_zone <= start_port.y <= obstacle_bounds[3] + safety_zone
)
near_end = end_port and (
obstacle_bounds[0] - safety_zone <= end_port.x <= obstacle_bounds[2] + safety_zone
and obstacle_bounds[1] - safety_zone <= end_port.y <= obstacle_bounds[3] + safety_zone
)
if not near_start and not near_end:
return False
if not geometry.intersects(raw_obstacle):
return False
self.metrics["safety_zone_checks"] += 1
intersection = geometry.intersection(raw_obstacle)
if intersection.is_empty:
return False
ix_bounds = intersection.bounds
if (
start_port
and near_start
and abs(ix_bounds[0] - start_port.x) < safety_zone
and abs(ix_bounds[1] - start_port.y) < safety_zone
and abs(ix_bounds[2] - start_port.x) < safety_zone
and abs(ix_bounds[3] - start_port.y) < safety_zone
):
return True
return bool(
end_port
and near_end
and abs(ix_bounds[0] - end_port.x) < safety_zone
and abs(ix_bounds[1] - end_port.y) < safety_zone
and abs(ix_bounds[2] - end_port.x) < safety_zone
and abs(ix_bounds[3] - end_port.y) < safety_zone
)
def check_move_static(
self,
result: ComponentResult,
start_port: Port | None = None,
end_port: Port | None = None,
net_width: float | None = None,
) -> bool:
del net_width
static_obstacles = self._static_obstacles
if not static_obstacles.dilated:
return False
self.metrics["static_tree_queries"] += 1
self._ensure_static_tree()
hits = static_obstacles.tree.query(box(*result.total_dilated_bounds))
if hits.size == 0:
return False
static_bounds = static_obstacles.bounds_array
move_poly_bounds = result.dilated_bounds
for hit_idx in hits:
obstacle_bounds = static_bounds[hit_idx]
poly_hits_obstacle_aabb = False
for poly_bounds in move_poly_bounds:
if (
poly_bounds[0] < obstacle_bounds[2]
and poly_bounds[2] > obstacle_bounds[0]
and poly_bounds[1] < obstacle_bounds[3]
and poly_bounds[3] > obstacle_bounds[1]
):
poly_hits_obstacle_aabb = True
break
if not poly_hits_obstacle_aabb:
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if self._is_in_safety_zone_fast(hit_idx, start_port, end_port):
collision_found = False
for polygon in result.collision_geometry:
if not self._is_in_safety_zone(polygon, obj_id, start_port, end_port):
collision_found = True
break
if collision_found:
return True
continue
static_obstacle = static_obstacles.dilated[obj_id]
for polygon in result.dilated_collision_geometry:
if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle):
return True
return False
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
dynamic_paths = self._dynamic_paths
self.metrics["congestion_tree_queries"] += 1
self._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
total_bounds = result.total_dilated_bounds
dynamic_bounds = dynamic_paths.bounds_array
possible_total = (
(total_bounds[0] < dynamic_bounds[:, 2])
& (total_bounds[2] > dynamic_bounds[:, 0])
& (total_bounds[1] < dynamic_bounds[:, 3])
& (total_bounds[3] > dynamic_bounds[:, 1])
)
valid_hits_mask = dynamic_paths.net_ids_array != net_id
if not numpy.any(possible_total & valid_hits_mask):
return 0
geometries_to_test = result.dilated_collision_geometry
res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects")
if tree_indices.size == 0:
return 0
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
if unique_other_nets.size == 0:
return 0
tree_geometries = dynamic_paths.tree.geometries
real_hits_count = 0
for other_net_id in unique_other_nets:
other_mask = hit_net_ids == other_net_id
sub_tree_indices = tree_indices[other_mask]
sub_res_indices = res_indices[other_mask]
found_real = False
for index in range(len(sub_tree_indices)):
test_geometry = geometries_to_test[sub_res_indices[index]]
tree_geometry = tree_geometries[sub_tree_indices[index]]
if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7:
found_real = True
break
if found_real:
real_hits_count += 1
return real_hits_count
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.geometries:
return 0
total_bounds = result.total_dilated_bounds
self._ensure_dynamic_grid()
dynamic_grid = dynamic_paths.grid
if not dynamic_grid:
return 0
gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size)
if gx_min == gx_max and gy_min == gy_max:
cell = (gx_min, gy_min)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
if dynamic_paths.geometries[obj_id][0] != net_id:
return self._check_real_congestion(result, net_id)
return 0
any_possible = False
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
cell = (gx, gy)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
if dynamic_paths.geometries[obj_id][0] != net_id:
any_possible = True
break
if any_possible:
break
if any_possible:
break
if not any_possible:
return 0
return self._check_real_congestion(result, net_id)
def _check_static_collision(
self,
geometry: Polygon,
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
) -> bool:
static_obstacles = self._static_obstacles
self._ensure_static_tree()
if static_obstacles.tree is None:
return False
if dilated_geometry is not None:
test_geometry = dilated_geometry
else:
distance = self.clearance / 2.0
test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry
hits = static_obstacles.tree.query(test_geometry, predicate="intersects")
tree_geometries = static_obstacles.tree.geometries
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if self._is_in_safety_zone(geometry, obj_id, start_port, end_port):
continue
return True
return False
def _check_dynamic_collision(
self,
geometry: Polygon,
net_id: str,
dilated_geometry: Polygon | None = None,
) -> int:
dynamic_paths = self._dynamic_paths
self._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0)
hits = dynamic_paths.tree.query(test_geometry, predicate="intersects")
tree_geometries = dynamic_paths.tree.geometries
hit_net_ids: list[str] = []
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = dynamic_paths.obj_ids[hit_idx]
other_net_id = dynamic_paths.geometries[obj_id][0]
if other_net_id != net_id:
hit_net_ids.append(other_net_id)
if not hit_net_ids:
return 0
return len(numpy.unique(hit_net_ids))
def check_collision(
self,
@ -193,16 +413,16 @@ class CollisionEngine:
bounds: tuple[float, float, float, float] | None = None,
net_width: float | None = None,
) -> bool | int:
return self._collision_query_checker.check_collision(
geometry,
net_id,
buffer_mode=buffer_mode,
start_port=start_port,
end_port=end_port,
dilated_geometry=dilated_geometry,
bounds=bounds,
net_width=net_width,
)
del bounds, net_width
if buffer_mode == "static":
return self._check_static_collision(
geometry,
start_port=start_port,
end_port=end_port,
dilated_geometry=dilated_geometry,
)
return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry)
def is_collision(
self,
@ -212,7 +432,6 @@ class CollisionEngine:
start_port: Port | None = None,
end_port: Port | None = None,
) -> bool:
""" Unified entry point for static collision checks. """
result = self.check_collision(
geometry,
net_id,
@ -223,12 +442,157 @@ class CollisionEngine:
)
return bool(result)
def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport:
return self._path_verifier.verify_path_report(net_id, components)
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
static_collision_count = 0
dynamic_collision_count = 0
self_collision_count = 0
total_length = sum(component.length for component in components)
def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]:
static_obstacles = self._static_obstacles
dynamic_paths = self._dynamic_paths
self._ensure_static_raw_tree()
if static_obstacles.raw_tree is not None:
raw_geometries = static_obstacles.raw_tree.geometries
for component in components:
for polygon in component.physical_geometry:
buffered = polygon.buffer(self.clearance, join_style=2)
hits = static_obstacles.raw_tree.query(buffered, predicate="intersects")
for hit_idx in hits:
obstacle = raw_geometries[hit_idx]
if buffered.touches(obstacle):
continue
obj_id = static_obstacles.raw_obj_ids[hit_idx]
if not self._is_in_safety_zone(polygon, obj_id, None, None):
static_collision_count += 1
self._ensure_dynamic_tree()
if dynamic_paths.tree is not None:
tree_geometries = dynamic_paths.tree.geometries
for component in components:
test_geometries = component.dilated_physical_geometry
res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects")
if tree_indices.size == 0:
continue
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
component_hits = []
for index in range(len(tree_indices)):
if hit_net_ids[index] == str(net_id):
continue
new_geometry = test_geometries[res_indices[index]]
tree_geometry = tree_geometries[tree_indices[index]]
if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7:
component_hits.append(hit_net_ids[index])
if component_hits:
dynamic_collision_count += len(numpy.unique(component_hits))
for index, component in enumerate(components):
for other_index in range(index + 2, len(components)):
if components_overlap(component, components[other_index], prefer_actual=True):
self_collision_count += 1
return RoutingReport(
static_collision_count=static_collision_count,
dynamic_collision_count=dynamic_collision_count,
self_collision_count=self_collision_count,
total_length=total_length,
)
def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]:
report = self.verify_path_report(net_id, components)
return report.is_valid, report.collision_count
def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float:
return self._ray_caster.ray_cast(origin, angle_deg, max_dist=max_dist, net_width=net_width)
def ray_cast(
self,
origin: Port,
angle_deg: float,
max_dist: float = 2000.0,
net_width: float | None = None,
) -> float:
static_obstacles = self._static_obstacles
radians = numpy.radians(angle_deg)
cos_v, sin_v = numpy.cos(radians), numpy.sin(radians)
dx, dy = max_dist * cos_v, max_dist * sin_v
min_x, max_x = sorted([origin.x, origin.x + dx])
min_y, max_y = sorted([origin.y, origin.y + dy])
if net_width is not None:
tree = self._ensure_net_static_tree(net_width)
key = (round(net_width, 4), round(self.clearance, 4))
is_rect_array = static_obstacles.net_specific_is_rect[key]
bounds_array = static_obstacles.net_specific_bounds[key]
else:
self._ensure_static_tree()
tree = static_obstacles.tree
is_rect_array = static_obstacles.is_rect_array
bounds_array = static_obstacles.bounds_array
if tree is None:
return max_dist
candidates = tree.query(box(min_x, min_y, max_x, max_y))
if candidates.size == 0:
return max_dist
min_dist = max_dist
inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30
inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30
tree_geometries = tree.geometries
ray_line = None
candidates_bounds = bounds_array[candidates]
dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2
sorted_indices = numpy.argsort(dist_sq)
for idx in sorted_indices:
candidate_id = candidates[idx]
bounds = bounds_array[candidate_id]
if abs(dx) < 1e-12:
if origin.x < bounds[0] or origin.x > bounds[2]:
tx_min, tx_max = 1e30, -1e30
else:
tx_min, tx_max = -1e30, 1e30
else:
t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx
tx_min, tx_max = min(t1, t2), max(t1, t2)
if abs(dy) < 1e-12:
if origin.y < bounds[1] or origin.y > bounds[3]:
ty_min, ty_max = 1e30, -1e30
else:
ty_min, ty_max = -1e30, 1e30
else:
t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy
ty_min, ty_max = min(t1, t2), max(t1, t2)
t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max)
if t_max < 0 or t_min > t_max or t_min > 1.0:
continue
if t_min * max_dist >= min_dist:
continue
if is_rect_array[candidate_id]:
min_dist = max(0.0, t_min * max_dist)
continue
if ray_line is None:
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
obstacle = tree_geometries[candidate_id]
if not obstacle.intersects(ray_line):
continue
intersection = ray_line.intersection(obstacle)
if intersection.is_empty:
continue
distance = _intersection_distance(origin, intersection)
min_dist = min(min_dist, distance)
return min_dist

View file

@ -1,97 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
class CollisionQueryChecker:
__slots__ = ("engine",)
def __init__(self, engine: CollisionEngine) -> None:
self.engine = engine
def check_collision(
self,
geometry: Polygon,
net_id: str,
buffer_mode: Literal["static", "congestion"] = "static",
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
bounds: tuple[float, float, float, float] | None = None,
net_width: float | None = None,
) -> bool | int:
del bounds, net_width
if buffer_mode == "static":
return self._check_static_collision(
geometry,
start_port=start_port,
end_port=end_port,
dilated_geometry=dilated_geometry,
)
return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry)
def _check_static_collision(
self,
geometry: Polygon,
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
) -> bool:
engine = self.engine
static_obstacles = engine._static_obstacles
engine._ensure_static_tree()
if static_obstacles.tree is None:
return False
if dilated_geometry is not None:
test_geometry = dilated_geometry
else:
distance = engine.clearance / 2.0
test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry
hits = static_obstacles.tree.query(test_geometry, predicate="intersects")
tree_geometries = static_obstacles.tree.geometries
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if engine._is_in_safety_zone(geometry, obj_id, start_port, end_port):
continue
return True
return False
def _check_dynamic_collision(
self,
geometry: Polygon,
net_id: str,
dilated_geometry: Polygon | None = None,
) -> int:
engine = self.engine
dynamic_paths = engine._dynamic_paths
engine._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(engine.clearance / 2.0)
hits = dynamic_paths.tree.query(test_geometry, predicate="intersects")
tree_geometries = dynamic_paths.tree.geometries
hit_net_ids: list[str] = []
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = dynamic_paths.obj_ids[hit_idx]
other_net_id = dynamic_paths.geometries[obj_id][0]
if other_net_id != net_id:
hit_net_ids.append(other_net_id)
if not hit_net_ids:
return 0
return len(numpy.unique(hit_net_ids))

View file

@ -9,9 +9,9 @@ if TYPE_CHECKING:
def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]:
if prefer_actual and component.actual_geometry is not None:
return component.actual_geometry
return component.geometry
if prefer_actual:
return list(component.physical_geometry)
return list(component.collision_geometry)
def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
import numpy
@ -12,61 +13,52 @@ from inire.constants import TOLERANCE_ANGULAR
from .primitives import Port, rotation_matrix2
MoveKind = Literal["straight", "bend90", "sbend"]
BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"]
BendCollisionModel = BendCollisionModelName | Polygon
def _normalize_length(value: float) -> float:
return float(value)
@dataclass(frozen=True, slots=True)
class ComponentResult:
__slots__ = (
"start_port",
"geometry",
"dilated_geometry",
"proxy_geometry",
"actual_geometry",
"dilated_actual_geometry",
"end_port",
"length",
"move_type",
"_bounds",
"_total_bounds",
"_dilated_bounds",
"_total_dilated_bounds",
)
start_port: Port
collision_geometry: tuple[Polygon, ...]
end_port: Port
length: float
move_type: MoveKind
physical_geometry: tuple[Polygon, ...]
dilated_collision_geometry: tuple[Polygon, ...]
dilated_physical_geometry: tuple[Polygon, ...]
_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
_total_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
_dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
_total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
def __init__(
self,
start_port: Port,
geometry: list[Polygon],
end_port: Port,
length: float,
move_type: str,
dilated_geometry: list[Polygon] | None = None,
proxy_geometry: list[Polygon] | None = None,
actual_geometry: list[Polygon] | None = None,
dilated_actual_geometry: list[Polygon] | None = None,
) -> None:
self.start_port = start_port
self.geometry = geometry
self.dilated_geometry = dilated_geometry
self.proxy_geometry = proxy_geometry
self.actual_geometry = actual_geometry
self.dilated_actual_geometry = dilated_actual_geometry
self.end_port = end_port
self.length = float(length)
self.move_type = move_type
def __post_init__(self) -> None:
collision_geometry = tuple(self.collision_geometry)
physical_geometry = tuple(self.physical_geometry)
dilated_collision_geometry = tuple(self.dilated_collision_geometry)
dilated_physical_geometry = tuple(self.dilated_physical_geometry)
self._bounds = [poly.bounds for poly in self.geometry]
self._total_bounds = _combine_bounds(self._bounds)
object.__setattr__(self, "collision_geometry", collision_geometry)
object.__setattr__(self, "physical_geometry", physical_geometry)
object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry)
object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry)
object.__setattr__(self, "length", float(self.length))
if self.dilated_geometry is None:
self._dilated_bounds = None
self._total_dilated_bounds = None
else:
self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry]
self._total_dilated_bounds = _combine_bounds(self._dilated_bounds)
bounds = tuple(poly.bounds for poly in collision_geometry)
object.__setattr__(self, "_bounds", bounds)
object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds)))
dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry)
object.__setattr__(self, "_dilated_bounds", dilated_bounds)
object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds)))
@property
def bounds(self) -> list[tuple[float, float, float, float]]:
def bounds(self) -> tuple[tuple[float, float, float, float], ...]:
return self._bounds
@property
@ -74,28 +66,23 @@ class ComponentResult:
return self._total_bounds
@property
def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None:
def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]:
return self._dilated_bounds
@property
def total_dilated_bounds(self) -> tuple[float, float, float, float] | None:
def total_dilated_bounds(self) -> tuple[float, float, float, float]:
return self._total_dilated_bounds
def translate(self, dx: int | float, dy: int | float) -> ComponentResult:
return ComponentResult(
start_port=self.start_port + [dx, dy, 0],
geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry],
end_port=self.end_port + [dx, dy, 0],
start_port=self.start_port.translate(dx, dy),
collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry],
end_port=self.end_port.translate(dx, dy),
length=self.length,
move_type=self.move_type,
dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry],
proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry],
actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry],
dilated_actual_geometry=(
None
if self.dilated_actual_geometry is None
else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry]
),
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry],
dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry],
)
@ -144,16 +131,13 @@ def _get_arc_polygons(
return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))]
def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon:
def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon:
"""Return a conservative 8-point polygonal proxy for the arc.
The polygon uses 4 points along the outer edge and 4 along the inner edge.
The outer edge is a circumscribed polyline and the inner edge is an
inscribed polyline, so the result conservatively contains the true arc.
`clip_margin` is kept for API compatibility but is not used by this proxy.
"""
del clip_margin
cx, cy = cxy
sample_count = 4
angle_span = abs(float(ts[1]) - float(ts[0]))
@ -194,11 +178,10 @@ def _transform_custom_collision_polygon(
def _apply_collision_model(
arc_poly: Polygon,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
collision_type: BendCollisionModel,
radius: float,
width: float,
cxy: tuple[float, float],
clip_margin: float,
ts: tuple[float, float],
rotation_deg: float = 0.0,
mirror_y: bool = False,
@ -208,7 +191,7 @@ def _apply_collision_model(
if collision_type == "arc":
return [arc_poly]
if collision_type == "clipped_bbox":
clipped = _clip_bbox(cxy, radius, width, ts, clip_margin)
clipped = _clip_bbox(cxy, radius, width, ts)
return [clipped if not clipped.is_empty else box(*arc_poly.bounds)]
return [box(*arc_poly.bounds)]
@ -231,7 +214,6 @@ class Straight:
poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y))
geometry = [Polygon(poly_points)]
dilated_geometry = None
if dilation > 0:
half_w_d = half_w + dilation
pts_d = numpy.array(
@ -244,16 +226,18 @@ class Straight:
)
poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y))
dilated_geometry = [Polygon(poly_points_d)]
else:
dilated_geometry = geometry
return ComponentResult(
start_port=start_port,
geometry=geometry,
collision_geometry=geometry,
end_port=end_port,
length=abs(length_f),
move_type="Straight",
dilated_geometry=dilated_geometry,
actual_geometry=geometry,
dilated_actual_geometry=dilated_geometry,
move_type="straight",
physical_geometry=geometry,
dilated_collision_geometry=dilated_geometry,
dilated_physical_geometry=dilated_geometry,
)
@ -265,8 +249,7 @@ class Bend90:
width: float,
direction: Literal["CW", "CCW"],
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0,
collision_type: BendCollisionModel = "arc",
dilation: float = 0.0,
) -> ComponentResult:
rot2 = rotation_matrix2(start_port.r)
@ -290,32 +273,18 @@ class Bend90:
radius,
width,
(float(center_xy[0]), float(center_xy[1])),
clip_margin,
ts,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
)
proxy_geometry = None
if collision_type == "arc":
proxy_geometry = _apply_collision_model(
arc_polys[0],
"clipped_bbox",
radius,
width,
(float(center_xy[0]), float(center_xy[1])),
clip_margin,
ts,
)
dilated_actual_geometry = None
dilated_geometry = None
physical_geometry = collision_polys if uses_custom_geometry else arc_polys
if dilation > 0:
if uses_custom_geometry:
dilated_actual_geometry = [poly.buffer(dilation) for poly in collision_polys]
dilated_geometry = dilated_actual_geometry
dilated_physical_geometry = [poly.buffer(dilation) for poly in collision_polys]
dilated_collision_geometry = dilated_physical_geometry
else:
dilated_actual_geometry = _get_arc_polygons(
dilated_physical_geometry = _get_arc_polygons(
(float(center_xy[0]), float(center_xy[1])),
radius,
width,
@ -323,18 +292,22 @@ class Bend90:
sagitta,
dilation=dilation,
)
dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
dilated_collision_geometry = (
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
)
else:
dilated_physical_geometry = physical_geometry
dilated_collision_geometry = collision_polys
return ComponentResult(
start_port=start_port,
geometry=collision_polys,
collision_geometry=collision_polys,
end_port=end_port,
length=abs(radius) * numpy.pi / 2.0,
move_type="Bend90",
dilated_geometry=dilated_geometry,
proxy_geometry=proxy_geometry,
actual_geometry=collision_polys if uses_custom_geometry else arc_polys,
dilated_actual_geometry=dilated_actual_geometry,
move_type="bend90",
physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry,
)
@ -346,8 +319,7 @@ class SBend:
radius: float,
width: float,
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0,
collision_type: BendCollisionModel = "arc",
dilation: float = 0.0,
) -> ComponentResult:
if abs(offset) >= 2 * radius:
@ -383,7 +355,6 @@ class SBend:
radius,
width,
(float(c1_xy[0]), float(c1_xy[1])),
clip_margin,
ts1,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
@ -394,41 +365,36 @@ class SBend:
radius,
width,
(float(c2_xy[0]), float(c2_xy[1])),
clip_margin,
ts2,
rotation_deg=float(start_port.r),
mirror_y=(sign > 0),
)[0],
]
proxy_geometry = None
if collision_type == "arc":
proxy_geometry = [
_apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0],
_apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0],
]
dilated_actual_geometry = None
dilated_geometry = None
physical_geometry = geometry if uses_custom_geometry else actual_geometry
if dilation > 0:
if uses_custom_geometry:
dilated_actual_geometry = [poly.buffer(dilation) for poly in geometry]
dilated_geometry = dilated_actual_geometry
dilated_physical_geometry = [poly.buffer(dilation) for poly in geometry]
dilated_collision_geometry = dilated_physical_geometry
else:
dilated_actual_geometry = [
dilated_physical_geometry = [
_get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0],
_get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0],
]
dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry]
dilated_collision_geometry = (
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry]
)
else:
dilated_physical_geometry = physical_geometry
dilated_collision_geometry = geometry
return ComponentResult(
start_port=start_port,
geometry=geometry,
collision_geometry=geometry,
end_port=end_port,
length=2.0 * radius * theta,
move_type="SBend",
dilated_geometry=dilated_geometry,
proxy_geometry=proxy_geometry,
actual_geometry=geometry if uses_custom_geometry else actual_geometry,
dilated_actual_geometry=dilated_actual_geometry,
move_type="sbend",
physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry,
)

View file

@ -1,117 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy
if TYPE_CHECKING:
from inire.geometry.collision import CollisionEngine
from inire.geometry.components import ComponentResult
class DynamicCongestionChecker:
__slots__ = ("engine",)
def __init__(self, engine: CollisionEngine) -> None:
self.engine = engine
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
engine = self.engine
dynamic_paths = engine._dynamic_paths
if not dynamic_paths.geometries:
return 0
total_bounds = result.total_dilated_bounds
if total_bounds is None:
return 0
engine._ensure_dynamic_grid()
dynamic_grid = dynamic_paths.grid
if not dynamic_grid:
return 0
cell_size_inv = engine._inv_grid_cell_size
gx_min = int(total_bounds[0] * cell_size_inv)
gy_min = int(total_bounds[1] * cell_size_inv)
gx_max = int(total_bounds[2] * cell_size_inv)
gy_max = int(total_bounds[3] * cell_size_inv)
dynamic_geometries = dynamic_paths.geometries
if gx_min == gx_max and gy_min == gy_max:
cell = (gx_min, gy_min)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
if dynamic_geometries[obj_id][0] != net_id:
return self.check_real_congestion(result, net_id)
return 0
any_possible = False
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
cell = (gx, gy)
if cell in dynamic_grid:
for obj_id in dynamic_grid[cell]:
if dynamic_geometries[obj_id][0] != net_id:
any_possible = True
break
if any_possible:
break
if any_possible:
break
if not any_possible:
return 0
return self.check_real_congestion(result, net_id)
def check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
engine = self.engine
dynamic_paths = engine._dynamic_paths
engine.metrics["congestion_tree_queries"] += 1
engine._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
total_bounds = result.total_dilated_bounds
dynamic_bounds = engine._dynamic_bounds_array
possible_total = (
(total_bounds[0] < dynamic_bounds[:, 2])
& (total_bounds[2] > dynamic_bounds[:, 0])
& (total_bounds[1] < dynamic_bounds[:, 3])
& (total_bounds[3] > dynamic_bounds[:, 1])
)
valid_hits_mask = dynamic_paths.net_ids_array != net_id
if not numpy.any(possible_total & valid_hits_mask):
return 0
geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry
res_indices, tree_indices = dynamic_paths.tree.query(geoms_to_test, predicate="intersects")
if tree_indices.size == 0:
return 0
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
if unique_other_nets.size == 0:
return 0
tree_geometries = dynamic_paths.tree.geometries
real_hits_count = 0
for other_net_id in unique_other_nets:
other_mask = hit_net_ids == other_net_id
sub_tree_indices = tree_indices[other_mask]
sub_res_indices = res_indices[other_mask]
found_real = False
for index in range(len(sub_tree_indices)):
test_geometry = geoms_to_test[sub_res_indices[index]]
tree_geometry = tree_geometries[sub_tree_indices[index]]
if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7:
found_real = True
break
if found_real:
real_hits_count += 1
return real_hits_count

View file

@ -6,11 +6,14 @@ import numpy
import rtree
from shapely.strtree import STRtree
if TYPE_CHECKING:
from shapely.geometry import Polygon
from shapely.prepared import PreparedGeometry
from inire.geometry.index_helpers import build_index_payload, iter_grid_cells
from inire.geometry.collision import CollisionEngine
if TYPE_CHECKING:
from collections.abc import Sequence
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld
class DynamicPathIndex:
@ -19,47 +22,38 @@ class DynamicPathIndex:
"index",
"geometries",
"dilated",
"prepared",
"tree",
"obj_ids",
"grid",
"id_counter",
"tree_dirty",
"net_ids_array",
"bounds_array",
"locked_nets",
)
def __init__(self, engine: CollisionEngine) -> None:
def __init__(self, engine: RoutingWorld) -> None:
self.engine = engine
self.index = rtree.index.Index()
self.geometries: dict[int, tuple[str, Polygon]] = {}
self.dilated: dict[int, Polygon] = {}
self.prepared: dict[int, PreparedGeometry] = {}
self.tree: STRtree | None = None
self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
self.grid: dict[tuple[int, int], list[int]] = {}
self.id_counter = 0
self.tree_dirty = True
self.net_ids_array = numpy.array([], dtype="<U32")
self.net_ids_array = numpy.array([], dtype=object)
self.bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4)
self.locked_nets: set[str] = set()
def invalidate_queries(self) -> None:
self.tree = None
self.grid = {}
self.tree_dirty = True
def ensure_tree(self) -> None:
if self.tree is None and self.dilated:
ids = sorted(self.dilated.keys())
geometries = [self.dilated[i] for i in ids]
ids, geometries, bounds_array = build_index_payload(self.dilated)
self.tree = STRtree(geometries)
self.obj_ids = numpy.array(ids, dtype=numpy.int32)
self.bounds_array = numpy.array([geometry.bounds for geometry in geometries])
self.bounds_array = bounds_array
net_ids = [self.geometries[obj_id][0] for obj_id in self.obj_ids]
self.net_ids_array = numpy.array(net_ids, dtype="<U32")
self.tree_dirty = False
self.net_ids_array = numpy.array(net_ids, dtype=object)
def ensure_grid(self) -> None:
if self.grid or not self.dilated:
@ -67,27 +61,20 @@ class DynamicPathIndex:
cell_size = self.engine.grid_cell_size
for obj_id, polygon in self.dilated.items():
bounds = polygon.bounds
for gx in range(int(bounds[0] / cell_size), int(bounds[2] / cell_size) + 1):
for gy in range(int(bounds[1] / cell_size), int(bounds[3] / cell_size) + 1):
cell = (gx, gy)
self.grid.setdefault(cell, []).append(obj_id)
for cell in iter_grid_cells(polygon.bounds, cell_size):
self.grid.setdefault(cell, []).append(obj_id)
def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None:
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self.invalidate_queries()
dilation = self.engine.clearance / 2.0
for index, polygon in enumerate(geometry):
obj_id = self.id_counter
self.id_counter += 1
dilated = dilated_geometry[index] if dilated_geometry else polygon.buffer(dilation)
dilated = dilated_geometry[index]
self.geometries[obj_id] = (net_id, polygon)
self.dilated[obj_id] = dilated
self.index.insert(obj_id, dilated.bounds)
def remove_path(self, net_id: str) -> None:
if net_id in self.locked_nets:
return
to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id]
self.remove_obj_ids(to_remove)
@ -101,14 +88,7 @@ class DynamicPathIndex:
del self.geometries[obj_id]
del self.dilated[obj_id]
def lock_net(self, net_id: str) -> None:
self.locked_nets.add(net_id)
to_move = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id]
for obj_id in to_move:
polygon = self.geometries[obj_id][1]
dilated = self.dilated[obj_id]
self.engine.add_static_obstacle(polygon, dilated_geometry=dilated)
self.remove_obj_ids(to_move)
def unlock_net(self, net_id: str) -> None:
self.locked_nets.discard(net_id)
def clear_paths(self) -> None:
if not self.geometries:
return
self.remove_obj_ids(list(self.geometries))

View file

@ -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

View file

@ -1,112 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy
from inire.geometry.component_overlap import components_overlap
if TYPE_CHECKING:
from inire.geometry.collision import CollisionEngine
from inire.geometry.components import ComponentResult
@dataclass(frozen=True)
class PathVerificationReport:
static_collision_count: int
dynamic_collision_count: int
self_collision_count: int
total_length: float
@property
def collision_count(self) -> int:
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
@property
def is_valid(self) -> bool:
return self.collision_count == 0
class PathVerifier:
__slots__ = ("engine",)
def __init__(self, engine: CollisionEngine) -> None:
self.engine = engine
def verify_path_report(self, net_id: str, components: list[ComponentResult]) -> PathVerificationReport:
"""
Non-approximated, full-polygon intersection check of a path against all
static obstacles, other nets, and itself.
"""
static_collision_count = 0
dynamic_collision_count = 0
self_collision_count = 0
total_length = sum(component.length for component in components)
engine = self.engine
static_obstacles = engine._static_obstacles
dynamic_paths = engine._dynamic_paths
# 1. Check against static obstacles.
engine._ensure_static_raw_tree()
if static_obstacles.raw_tree is not None:
raw_geoms = static_obstacles.raw_tree.geometries
for comp in components:
polygons = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry
for polygon in polygons:
# Physical separation must be >= clearance.
buffered = polygon.buffer(engine.clearance, join_style=2)
hits = static_obstacles.raw_tree.query(buffered, predicate="intersects")
for hit_idx in hits:
obstacle = raw_geoms[hit_idx]
# If they only touch, gap is exactly clearance. Valid.
if buffered.touches(obstacle):
continue
obj_id = static_obstacles.raw_obj_ids[hit_idx]
if not engine._is_in_safety_zone(polygon, obj_id, None, None):
static_collision_count += 1
# 2. Check against other nets.
engine._ensure_dynamic_tree()
if dynamic_paths.tree is not None:
tree_geoms = dynamic_paths.tree.geometries
for comp in components:
# Robust fallback chain to ensure crossings are caught even with zero clearance.
test_geometries = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry
if not test_geometries:
continue
if not isinstance(test_geometries, list | tuple | numpy.ndarray):
test_geometries = [test_geometries]
res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects")
if tree_indices.size == 0:
continue
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
comp_hits = []
for i in range(len(tree_indices)):
if hit_net_ids[i] == str(net_id):
continue
p_new = test_geometries[res_indices[i]]
p_tree = tree_geoms[tree_indices[i]]
if not p_new.touches(p_tree) and p_new.intersection(p_tree).area > 1e-7:
comp_hits.append(hit_net_ids[i])
if comp_hits:
dynamic_collision_count += len(numpy.unique(comp_hits))
# 3. Check for self collisions between non-adjacent components.
for i, comp_i in enumerate(components):
for j in range(i + 2, len(components)):
if components_overlap(comp_i, components[j], prefer_actual=True):
self_collision_count += 1
return PathVerificationReport(
static_collision_count=static_collision_count,
dynamic_collision_count=dynamic_collision_count,
self_collision_count=self_collision_count,
total_length=total_length,
)

View file

@ -1,10 +1,10 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Self
import numpy
from numpy.typing import ArrayLike, NDArray
from numpy.typing import NDArray
def _normalize_angle(angle_deg: int | float) -> int:
@ -13,119 +13,43 @@ def _normalize_angle(angle_deg: int | float) -> int:
raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}")
return angle
def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]:
arr = numpy.asarray(value, dtype=numpy.int32)
if arr.shape != (3,):
raise ValueError(f"Port array must have shape (3,), got {arr.shape}")
arr = arr.copy()
arr[2] = _normalize_angle(int(arr[2]))
return arr
@dataclass(frozen=True, slots=True)
class Port:
"""
Port represented as an ndarray-backed (x, y, r) triple with int32 storage.
Port represented as a normalized integer (x, y, r) triple.
"""
__slots__ = ("_xyr",)
x: int | float
y: int | float
r: int | float
def __init__(self, x: int | float, y: int | float, r: int | float) -> None:
self._xyr = numpy.array(
(int(round(x)), int(round(y)), _normalize_angle(r)),
dtype=numpy.int32,
)
@classmethod
def from_array(cls, xyr: ArrayLike) -> Self:
obj = cls.__new__(cls)
obj._xyr = _as_int32_triplet(xyr)
return obj
@property
def x(self) -> int:
return int(self._xyr[0])
@x.setter
def x(self, val: int | float) -> None:
self._xyr[0] = int(round(val))
@property
def y(self) -> int:
return int(self._xyr[1])
@y.setter
def y(self, val: int | float) -> None:
self._xyr[1] = int(round(val))
@property
def r(self) -> int:
return int(self._xyr[2])
@r.setter
def r(self, val: int | float) -> None:
self._xyr[2] = _normalize_angle(val)
@property
def orientation(self) -> int:
return self.r
@orientation.setter
def orientation(self, val: int | float) -> None:
self.r = val
@property
def xyr(self) -> NDArray[numpy.int32]:
return self._xyr
@xyr.setter
def xyr(self, val: ArrayLike) -> None:
self._xyr = _as_int32_triplet(val)
def __repr__(self) -> str:
return f"Port(x={self.x}, y={self.y}, r={self.r})"
def __iter__(self) -> Iterator[int]:
return iter((self.x, self.y, self.r))
def __len__(self) -> int:
return 3
def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]:
return self._xyr[item]
def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]:
return numpy.asarray(self._xyr, dtype=dtype)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Port):
return False
return bool(numpy.array_equal(self._xyr, other._xyr))
def __hash__(self) -> int:
return hash(self.as_tuple())
def copy(self) -> Self:
return type(self).from_array(self._xyr.copy())
def __post_init__(self) -> None:
object.__setattr__(self, "x", int(round(self.x)))
object.__setattr__(self, "y", int(round(self.y)))
object.__setattr__(self, "r", _normalize_angle(self.r))
def as_tuple(self) -> tuple[int, int, int]:
return (self.x, self.y, self.r)
return (int(self.x), int(self.y), int(self.r))
def translate(self, dxy: ArrayLike) -> Self:
dxy_arr = numpy.asarray(dxy, dtype=numpy.int32)
if dxy_arr.shape == (2,):
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r)
if dxy_arr.shape == (3,):
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2]))
raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}")
def translate(
self,
dx: int | float = 0,
dy: int | float = 0,
rotation: int | float = 0,
) -> Self:
return type(self)(self.x + dx, self.y + dy, self.r + rotation)
def __add__(self, other: ArrayLike) -> Self:
return self.translate(other)
def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]:
if isinstance(other, Port):
return self._xyr - other._xyr
return self._xyr - numpy.asarray(other, dtype=numpy.int32)
def rotated(
self,
angle: int | float,
origin: tuple[int | float, int | float] = (0, 0),
) -> Self:
angle_i = _normalize_angle(angle)
rot = rotation_matrix2(angle_i)
origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32)
rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy
rotated = origin_xy + rot @ rel
return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i)
ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32)
@ -145,16 +69,3 @@ def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]:
rot3[:2, :2] = rot2
rot3[2, 2] = 1
return rot3
def translate_port(port: Port, dx: int | float, dy: int | float) -> Port:
return Port(port.x + dx, port.y + dy, port.r)
def rotate_port(port: Port, angle: int | float, origin: tuple[int | float, int | float] = (0, 0)) -> Port:
angle_i = _normalize_angle(angle)
rot = rotation_matrix2(angle_i)
origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32)
rel = numpy.array((port.x, port.y), dtype=numpy.int32) - origin_xy
rotated = origin_xy + rot @ rel
return Port(int(rotated[0]), int(rotated[1]), port.r + angle_i)

View file

@ -1,112 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy
from shapely.geometry import LineString, box
if TYPE_CHECKING:
from shapely.geometry.base import BaseGeometry
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
class RayCaster:
__slots__ = ("engine",)
def __init__(self, engine: CollisionEngine) -> None:
self.engine = engine
def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float:
engine = self.engine
static_obstacles = engine._static_obstacles
rad = numpy.radians(angle_deg)
cos_v, sin_v = numpy.cos(rad), numpy.sin(rad)
dx, dy = max_dist * cos_v, max_dist * sin_v
min_x, max_x = sorted([origin.x, origin.x + dx])
min_y, max_y = sorted([origin.y, origin.y + dy])
key = None
if net_width is not None:
tree = engine._ensure_net_static_tree(net_width)
key = (round(net_width, 4), round(engine.clearance, 4))
is_rect_arr = static_obstacles.net_specific_is_rect[key]
bounds_arr = static_obstacles.net_specific_bounds[key]
else:
engine._ensure_static_tree()
tree = static_obstacles.tree
is_rect_arr = static_obstacles.is_rect_array
bounds_arr = static_obstacles.bounds_array
if tree is None:
return max_dist
candidates = tree.query(box(min_x, min_y, max_x, max_y))
if candidates.size == 0:
return max_dist
min_dist = max_dist
inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30
inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30
tree_geoms = tree.geometries
ray_line = None
# Distance to the AABB min corner is a cheap ordering heuristic.
candidates_bounds = bounds_arr[candidates]
dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2
sorted_indices = numpy.argsort(dist_sq)
for idx in sorted_indices:
candidate_id = candidates[idx]
bounds = bounds_arr[candidate_id]
if abs(dx) < 1e-12:
if origin.x < bounds[0] or origin.x > bounds[2]:
tx_min, tx_max = 1e30, -1e30
else:
tx_min, tx_max = -1e30, 1e30
else:
t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx
tx_min, tx_max = min(t1, t2), max(t1, t2)
if abs(dy) < 1e-12:
if origin.y < bounds[1] or origin.y > bounds[3]:
ty_min, ty_max = 1e30, -1e30
else:
ty_min, ty_max = -1e30, 1e30
else:
t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy
ty_min, ty_max = min(t1, t2), max(t1, t2)
t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max)
if t_max < 0 or t_min > t_max or t_min > 1.0:
continue
if t_min * max_dist >= min_dist:
continue
if is_rect_arr[candidate_id]:
min_dist = max(0.0, t_min * max_dist)
continue
if ray_line is None:
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
obstacle = tree_geoms[candidate_id]
if not obstacle.intersects(ray_line):
continue
intersection = ray_line.intersection(obstacle)
if intersection.is_empty:
continue
distance = self._intersection_distance(origin, intersection)
min_dist = min(min_dist, distance)
return min_dist
def _intersection_distance(self, origin: Port, geometry: BaseGeometry) -> float:
if hasattr(geometry, "geoms"):
return min(self._intersection_distance(origin, sub_geom) for sub_geom in geometry.geoms)
return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2))

View file

@ -1,146 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from shapely.geometry import box
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
class StaticMoveChecker:
__slots__ = ("engine",)
def __init__(self, engine: CollisionEngine) -> None:
self.engine = engine
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
engine = self.engine
engine.metrics["static_straight_fast"] += 1
reach = engine.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width)
return reach < length - 0.001
def is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
engine = self.engine
sz = engine.safety_zone_radius
bounds = engine._static_obstacles.bounds_array[idx]
if start_port and bounds[0] - sz <= start_port.x <= bounds[2] + sz and bounds[1] - sz <= start_port.y <= bounds[3] + sz:
return True
return bool(
end_port
and bounds[0] - sz <= end_port.x <= bounds[2] + sz
and bounds[1] - sz <= end_port.y <= bounds[3] + sz
)
def check_move_static(
self,
result: ComponentResult,
start_port: Port | None = None,
end_port: Port | None = None,
net_width: float | None = None,
) -> bool:
del net_width
engine = self.engine
static_obstacles = engine._static_obstacles
if not static_obstacles.dilated:
return False
engine.metrics["static_tree_queries"] += 1
engine._ensure_static_tree()
total_bounds = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds
hits = static_obstacles.tree.query(box(*total_bounds))
if hits.size == 0:
return False
static_bounds = static_obstacles.bounds_array
move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds
for hit_idx in hits:
obstacle_bounds = static_bounds[hit_idx]
poly_hits_obstacle_aabb = False
for poly_bounds in move_poly_bounds:
if (
poly_bounds[0] < obstacle_bounds[2]
and poly_bounds[2] > obstacle_bounds[0]
and poly_bounds[1] < obstacle_bounds[3]
and poly_bounds[3] > obstacle_bounds[1]
):
poly_hits_obstacle_aabb = True
break
if not poly_hits_obstacle_aabb:
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if self.is_in_safety_zone_fast(hit_idx, start_port, end_port):
collision_found = False
for polygon in result.geometry:
if not self.is_in_safety_zone(polygon, obj_id, start_port, end_port):
collision_found = True
break
if collision_found:
return True
continue
test_geometries = result.dilated_geometry if result.dilated_geometry else result.geometry
static_obstacle = static_obstacles.dilated[obj_id]
for polygon in test_geometries:
if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle):
return True
return False
def is_in_safety_zone(
self,
geometry: Polygon,
obj_id: int,
start_port: Port | None,
end_port: Port | None,
) -> bool:
engine = self.engine
raw_obstacle = engine._static_obstacles.geometries[obj_id]
sz = engine.safety_zone_radius
obstacle_bounds = raw_obstacle.bounds
near_start = start_port and (
obstacle_bounds[0] - sz <= start_port.x <= obstacle_bounds[2] + sz
and obstacle_bounds[1] - sz <= start_port.y <= obstacle_bounds[3] + sz
)
near_end = end_port and (
obstacle_bounds[0] - sz <= end_port.x <= obstacle_bounds[2] + sz
and obstacle_bounds[1] - sz <= end_port.y <= obstacle_bounds[3] + sz
)
if not near_start and not near_end:
return False
if not geometry.intersects(raw_obstacle):
return False
engine.metrics["safety_zone_checks"] += 1
intersection = geometry.intersection(raw_obstacle)
if intersection.is_empty:
return False
ix_bounds = intersection.bounds
if (
start_port
and near_start
and abs(ix_bounds[0] - start_port.x) < sz
and abs(ix_bounds[1] - start_port.y) < sz
and abs(ix_bounds[2] - start_port.x) < sz
and abs(ix_bounds[3] - start_port.y) < sz
):
return True
return bool(
end_port
and near_end
and abs(ix_bounds[0] - end_port.x) < sz
and abs(ix_bounds[1] - end_port.y) < sz
and abs(ix_bounds[2] - end_port.x) < sz
and abs(ix_bounds[3] - end_port.y) < sz
)

View file

@ -4,14 +4,14 @@ from typing import TYPE_CHECKING
import numpy
import rtree
from shapely.prepared import prep
from shapely.strtree import STRtree
from inire.geometry.index_helpers import build_index_payload, is_axis_aligned_rect
if TYPE_CHECKING:
from shapely.geometry import Polygon
from shapely.prepared import PreparedGeometry
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
class StaticObstacleIndex:
@ -20,7 +20,6 @@ class StaticObstacleIndex:
"index",
"geometries",
"dilated",
"prepared",
"is_rect",
"tree",
"obj_ids",
@ -31,18 +30,15 @@ class StaticObstacleIndex:
"net_specific_trees",
"net_specific_is_rect",
"net_specific_bounds",
"safe_cache",
"grid",
"id_counter",
"version",
)
def __init__(self, engine: CollisionEngine) -> None:
def __init__(self, engine: RoutingWorld) -> None:
self.engine = engine
self.index = rtree.index.Index()
self.geometries: dict[int, Polygon] = {}
self.dilated: dict[int, Polygon] = {}
self.prepared: dict[int, PreparedGeometry] = {}
self.is_rect: dict[int, bool] = {}
self.tree: STRtree | None = None
self.obj_ids: list[int] = []
@ -53,8 +49,6 @@ class StaticObstacleIndex:
self.net_specific_trees: dict[tuple[float, float], STRtree] = {}
self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {}
self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {}
self.safe_cache: set[tuple] = set()
self.grid: dict[tuple[int, int], list[int]] = {}
self.id_counter = 0
self.version = 0
@ -69,12 +63,9 @@ class StaticObstacleIndex:
self.geometries[obj_id] = polygon
self.dilated[obj_id] = dilated
self.prepared[obj_id] = prep(dilated)
self.is_rect[obj_id] = is_axis_aligned_rect(dilated)
self.index.insert(obj_id, dilated.bounds)
self.invalidate_caches()
bounds = dilated.bounds
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
self.is_rect[obj_id] = abs(dilated.area - area) < 1e-4
return obj_id
def remove_obstacle(self, obj_id: int) -> None:
@ -85,7 +76,6 @@ class StaticObstacleIndex:
self.index.delete(obj_id, bounds)
del self.geometries[obj_id]
del self.dilated[obj_id]
del self.prepared[obj_id]
del self.is_rect[obj_id]
self.invalidate_caches()
@ -96,19 +86,15 @@ class StaticObstacleIndex:
self.obj_ids = []
self.raw_tree = None
self.raw_obj_ids = []
self.grid = {}
self.net_specific_trees.clear()
self.net_specific_is_rect.clear()
self.net_specific_bounds.clear()
self.safe_cache.clear()
self.version += 1
def ensure_tree(self) -> None:
if self.tree is None and self.dilated:
self.obj_ids = sorted(self.dilated.keys())
geometries = [self.dilated[i] for i in self.obj_ids]
self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated)
self.tree = STRtree(geometries)
self.bounds_array = numpy.array([geometry.bounds for geometry in geometries])
self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids])
def ensure_net_tree(self, net_width: float) -> STRtree:
@ -125,19 +111,16 @@ class StaticObstacleIndex:
polygon = self.geometries[obj_id]
dilated = polygon.buffer(total_dilation, join_style=2)
geometries.append(dilated)
bounds = dilated.bounds
bounds_list.append(bounds)
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
is_rect_list.append(abs(dilated.area - area) < 1e-4)
bounds_list.append(dilated.bounds)
is_rect_list.append(is_axis_aligned_rect(dilated))
tree = STRtree(geometries)
self.net_specific_trees[key] = tree
self.net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool)
self.net_specific_bounds[key] = numpy.array(bounds_list)
self.net_specific_bounds[key] = numpy.array(bounds_list, dtype=numpy.float64)
return tree
def ensure_raw_tree(self) -> None:
if self.raw_tree is None and self.geometries:
self.raw_obj_ids = sorted(self.geometries.keys())
geometries = [self.geometries[i] for i in self.raw_obj_ids]
self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries)
self.raw_tree = STRtree(geometries)

145
inire/model.py Normal file
View file

@ -0,0 +1,145 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from inire.geometry.components import BendCollisionModel
from inire.router.results import RouteMetrics, RoutingResult
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
@dataclass(frozen=True, slots=True)
class NetSpec:
net_id: str
start: Port
target: Port
width: float = 2.0
@dataclass(frozen=True, slots=True)
class LockedRoute:
geometry: tuple[Polygon, ...]
def __post_init__(self) -> None:
object.__setattr__(self, "geometry", tuple(self.geometry))
@classmethod
def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute:
polygons = []
for component in path:
polygons.extend(component.physical_geometry)
return cls(geometry=tuple(polygons))
def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute:
if isinstance(route, LockedRoute):
return route
route_items = tuple(route)
if route_items and hasattr(route_items[0], "physical_geometry"):
return LockedRoute.from_path(route_items) # type: ignore[arg-type]
return LockedRoute(geometry=route_items)
@dataclass(frozen=True, slots=True)
class ObjectiveWeights:
unit_length_cost: float = 1.0
bend_penalty: float = 250.0
sbend_penalty: float = 500.0
danger_weight: float = 1.0
congestion_penalty: float = 0.0
@dataclass(frozen=True, slots=True)
class SearchOptions:
node_limit: int = 1000000
max_straight_length: float = 2000.0
min_straight_length: float = 5.0
greedy_h_weight: float = 1.5
sbend_offsets: tuple[float, ...] | None = None
bend_radii: tuple[float, ...] = (50.0, 100.0)
sbend_radii: tuple[float, ...] = (10.0,)
bend_collision_type: BendCollisionModel = "arc"
visibility_guidance: str = "tangent_corner"
initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "bend_radii", tuple(self.bend_radii))
object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii))
if self.sbend_offsets is not None:
object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets))
if self.initial_paths is not None:
object.__setattr__(
self,
"initial_paths",
{
net_id: tuple(path)
for net_id, path in self.initial_paths.items()
},
)
@dataclass(frozen=True, slots=True)
class CongestionOptions:
max_iterations: int = 10
base_penalty: float = 100.0
multiplier: float = 1.5
use_tiered_strategy: bool = True
warm_start: str | None = "shortest"
shuffle_nets: bool = False
sort_nets: str | None = None
seed: int | None = None
@dataclass(frozen=True, slots=True)
class RefinementOptions:
enabled: bool = True
objective: ObjectiveWeights | None = None
@dataclass(frozen=True, slots=True)
class DiagnosticsOptions:
capture_expanded: bool = False
@dataclass(frozen=True, slots=True)
class RoutingOptions:
search: SearchOptions = field(default_factory=SearchOptions)
objective: ObjectiveWeights = field(default_factory=ObjectiveWeights)
congestion: CongestionOptions = field(default_factory=CongestionOptions)
refinement: RefinementOptions = field(default_factory=RefinementOptions)
diagnostics: DiagnosticsOptions = field(default_factory=DiagnosticsOptions)
@dataclass(frozen=True, slots=True)
class RoutingProblem:
bounds: tuple[float, float, float, float]
nets: tuple[NetSpec, ...] = ()
static_obstacles: tuple[Polygon, ...] = ()
locked_routes: dict[str, LockedRoute] = field(default_factory=dict)
clearance: float = 2.0
max_net_width: float = 2.0
safety_zone_radius: float = 0.0021
def __post_init__(self) -> None:
object.__setattr__(self, "nets", tuple(self.nets))
object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles))
object.__setattr__(
self,
"locked_routes",
{
net_id: _coerce_locked_route(route)
for net_id, route in self.locked_routes.items()
},
)
@dataclass(frozen=True, slots=True)
class RoutingRunResult:
results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = ()

View file

@ -0,0 +1,210 @@
from __future__ import annotations
import heapq
from typing import TYPE_CHECKING, Literal
from shapely.geometry import Polygon
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind
from inire.geometry.primitives import Port
from inire.router.refiner import component_hits_ancestor_chain
from ._astar_types import AStarContext, AStarMetrics, AStarNode
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
def process_move(
parent: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
move_class: MoveKind,
params: tuple,
skip_congestion: bool,
bend_collision_type: BendCollisionModel,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None:
cp = parent.port
coll_type = bend_collision_type
coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
abs_key = (
cp.as_tuple(),
move_class,
params,
net_width,
coll_key,
self_dilation,
)
if abs_key in context.move_cache_abs:
res = context.move_cache_abs[abs_key]
else:
context.check_cache_eviction()
base_port = Port(0, 0, cp.r)
rel_key = (
cp.r,
move_class,
params,
net_width,
coll_key,
self_dilation,
)
if rel_key in context.move_cache_rel:
res_rel = context.move_cache_rel[rel_key]
else:
try:
if move_class == "straight":
res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation)
elif move_class == "bend90":
res_rel = Bend90.generate(
base_port,
params[0],
net_width,
params[1],
collision_type=coll_type,
dilation=self_dilation,
)
else:
res_rel = SBend.generate(
base_port,
params[0],
params[1],
net_width,
collision_type=coll_type,
dilation=self_dilation,
)
except ValueError:
return
context.move_cache_rel[rel_key] = res_rel
res = res_rel.translate(cp.x, cp.y)
context.move_cache_abs[abs_key] = res
move_radius = params[0] if move_class == "bend90" else (params[1] if move_class == "sbend" else None)
add_node(
parent,
res,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
move_class,
abs_key,
move_radius=move_radius,
skip_congestion=skip_congestion,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
def add_node(
parent: AStarNode,
result: ComponentResult,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
move_type: MoveKind,
cache_key: tuple,
move_radius: float | None = None,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None:
metrics.moves_generated += 1
metrics.total_moves_generated += 1
state = result.end_port.as_tuple()
new_lower_bound_g = parent.g_cost + result.length
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1
return
parent_p = parent.port
end_p = result.end_port
if cache_key in context.hard_collision_set:
metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1
return
is_static_safe = cache_key in context.static_safe_cache
if not is_static_safe:
ce = context.cost_evaluator.collision_engine
if move_type == "straight":
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
else:
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width)
if collision_found:
context.hard_collision_set.add(cache_key)
metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1
return
context.static_safe_cache.add(cache_key)
total_overlaps = 0
if not skip_congestion:
if cache_key in congestion_cache:
total_overlaps = congestion_cache[cache_key]
else:
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
congestion_cache[cache_key] = total_overlaps
if self_collision_check and component_hits_ancestor_chain(result, parent):
return
penalty = context.cost_evaluator.component_penalty(
move_type,
move_radius=move_radius,
)
move_cost = context.cost_evaluator.evaluate_move(
result.collision_geometry,
result.end_port,
net_width,
net_id,
start_port=parent_p,
length=result.length,
dilated_geometry=result.dilated_collision_geometry,
penalty=penalty,
skip_static=True,
skip_congestion=True,
)
move_cost += total_overlaps * context.cost_evaluator.congestion_penalty
if max_cost is not None and parent.g_cost + move_cost > max_cost:
metrics.pruned_cost += 1
metrics.total_pruned_cost += 1
return
if move_cost > 1e12:
metrics.pruned_cost += 1
metrics.total_pruned_cost += 1
return
g_cost = parent.g_cost + move_cost
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1
return
h_cost = context.cost_evaluator.h_manhattan(result.end_port, target)
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
metrics.moves_added += 1
metrics.total_moves_added += 1

View file

@ -0,0 +1,302 @@
from __future__ import annotations
import math
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import BendCollisionModel, MoveKind
from inire.geometry.primitives import Port
from ._astar_admission import process_move
from ._astar_types import AStarContext, AStarMetrics, AStarNode
def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01}
return sorted((v for v in out if v > 0), reverse=True)
def _sbend_forward_span(offset: float, radius: float) -> float | None:
abs_offset = abs(offset)
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
return None
theta = math.acos(1.0 - abs_offset / (2.0 * radius))
return 2.0 * radius * math.sin(theta)
def _visible_straight_candidates(
current: Port,
context: AStarContext,
max_reach: float,
cos_v: float,
sin_v: float,
net_width: float,
) -> list[float]:
search_options = context.options.search
mode = search_options.visibility_guidance
if mode == "off":
return []
if mode == "exact_corner":
max_bend_radius = max(search_options.bend_radii, default=0.0)
visibility_reach = max_reach + max_bend_radius
visible_corners = sorted(
context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach),
key=lambda corner: corner[2],
)
if not visible_corners:
return []
candidates: set[int] = set()
for cx, cy, _ in visible_corners[:12]:
dx = cx - current.x
dy = cy - current.y
local_x = dx * cos_v + dy * sin_v
if local_x <= search_options.min_straight_length:
continue
candidates.add(int(round(local_x)))
return sorted(candidates, reverse=True)
if mode != "tangent_corner":
return []
visibility_manager = context.visibility_manager
visibility_manager._ensure_current()
max_bend_radius = max(search_options.bend_radii, default=0.0)
if max_bend_radius <= 0 or not visibility_manager.corners:
return []
reach = max_reach + max_bend_radius
bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
if not candidate_ids:
return []
scored: list[tuple[float, float, float, float, float]] = []
for idx in candidate_ids:
cx, cy = visibility_manager.corners[idx]
dx = cx - current.x
dy = cy - current.y
local_x = dx * cos_v + dy * sin_v
local_y = -dx * sin_v + dy * cos_v
if local_x <= search_options.min_straight_length or local_x > reach + 0.01:
continue
nearest_radius = min(search_options.bend_radii, key=lambda radius: abs(abs(local_y) - radius))
tangent_error = abs(abs(local_y) - nearest_radius)
if tangent_error > 2.0:
continue
length = local_x - nearest_radius
if length <= search_options.min_straight_length or length > max_reach + 0.01:
continue
scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy))
if not scored:
return []
collision_engine = context.cost_evaluator.collision_engine
candidates: set[int] = set()
for _, dist, length, dx, dy in sorted(scored)[:4]:
angle = math.degrees(math.atan2(dy, dx))
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
if corner_reach < dist - 0.01:
continue
qlen = int(round(length))
if qlen > 0:
candidates.add(qlen)
return sorted(candidates, reverse=True)
def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]:
result = node.component_result
if result is None:
return None, None
move_type = result.move_type
if move_type == "straight":
return move_type, result.length
return move_type, None
def expand_moves(
current: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
bend_collision_type: BendCollisionModel | None = None,
max_cost: float | None = None,
skip_congestion: bool = False,
self_collision_check: bool = False,
) -> None:
search_options = context.options.search
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type
cp = current.port
prev_move_type, prev_straight_length = _previous_move_metadata(current)
dx_t = target.x - cp.x
dy_t = target.y - cp.y
dist_sq = dx_t * dx_t + dy_t * dy_t
if cp.r == 0:
cos_v, sin_v = 1.0, 0.0
elif cp.r == 90:
cos_v, sin_v = 0.0, 1.0
elif cp.r == 180:
cos_v, sin_v = -1.0, 0.0
else:
cos_v, sin_v = 0.0, -1.0
proj_t = dx_t * cos_v + dy_t * sin_v
perp_t = -dx_t * sin_v + dy_t * cos_v
dx_local = proj_t
dy_local = perp_t
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
if max_reach >= proj_t - 0.01 and (
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
):
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"straight",
(int(round(proj_t)),),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width)
candidate_lengths = [
search_options.min_straight_length,
max_reach,
max_reach / 2.0,
max_reach - 5.0,
]
axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t)
candidate_lengths.append(axis_target_dist)
for radius in search_options.bend_radii:
candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius))
candidate_lengths.extend(
_visible_straight_candidates(
cp,
context,
max_reach,
cos_v,
sin_v,
net_width,
)
)
if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR:
for radius in search_options.sbend_radii:
sbend_span = _sbend_forward_span(dy_local, radius)
if sbend_span is None:
continue
candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span))
for length in _quantized_lengths(candidate_lengths, max_reach):
if length < search_options.min_straight_length:
continue
if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"straight",
(length,),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
angle_to_target = 0.0
if dx_t != 0 or dy_t != 0:
angle_to_target = float((round((180.0 / math.pi) * math.atan2(dy_t, dx_t)) + 360.0) % 360.0)
allow_backwards = dist_sq < 150 * 150
for radius in search_options.bend_radii:
for direction in ("CW", "CCW"):
if not allow_backwards:
turn = 90 if direction == "CCW" else -90
new_ori = (cp.r + turn) % 360
new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0
if abs(new_diff) > 135.0:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"bend90",
(radius, direction),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0
if max_sbend_r <= 0 or prev_move_type == "sbend":
return
explicit_offsets = search_options.sbend_offsets
offsets: set[int] = {int(round(v)) for v in explicit_offsets or []}
if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r and 0 < abs(dy_local) < 2 * max_sbend_r:
offsets.add(int(round(dy_local)))
if not offsets:
return
for offset in sorted(offsets):
if offset == 0:
continue
for radius in search_options.sbend_radii:
if abs(offset) >= 2 * radius:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"sbend",
(offset, radius),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)

View file

@ -0,0 +1,152 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from inire.model import RoutingOptions, RoutingProblem
from inire.router.visibility import VisibilityManager
from inire.router.results import RouteMetrics
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
from inire.router.cost import CostEvaluator
class AStarNode:
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
def __init__(
self,
port,
g_cost: float,
h_cost: float,
parent: AStarNode | None = None,
component_result: ComponentResult | None = None,
) -> None:
self.port = port
self.g_cost = g_cost
self.h_cost = h_cost
self.fh_cost = (g_cost + h_cost, h_cost)
self.parent = parent
self.component_result = component_result
def __lt__(self, other: AStarNode) -> bool:
return self.fh_cost < other.fh_cost
class AStarMetrics:
__slots__ = (
"total_nodes_expanded",
"total_moves_generated",
"total_moves_added",
"total_pruned_closed_set",
"total_pruned_hard_collision",
"total_pruned_cost",
"last_expanded_nodes",
"nodes_expanded",
"moves_generated",
"moves_added",
"pruned_closed_set",
"pruned_hard_collision",
"pruned_cost",
)
def __init__(self) -> None:
self.total_nodes_expanded = 0
self.total_moves_generated = 0
self.total_moves_added = 0
self.total_pruned_closed_set = 0
self.total_pruned_hard_collision = 0
self.total_pruned_cost = 0
self.last_expanded_nodes: list[tuple[int, int, int]] = []
self.nodes_expanded = 0
self.moves_generated = 0
self.moves_added = 0
self.pruned_closed_set = 0
self.pruned_hard_collision = 0
self.pruned_cost = 0
def reset_totals(self) -> None:
self.total_nodes_expanded = 0
self.total_moves_generated = 0
self.total_moves_added = 0
self.total_pruned_closed_set = 0
self.total_pruned_hard_collision = 0
self.total_pruned_cost = 0
def reset_per_route(self) -> None:
self.nodes_expanded = 0
self.moves_generated = 0
self.moves_added = 0
self.pruned_closed_set = 0
self.pruned_hard_collision = 0
self.pruned_cost = 0
self.last_expanded_nodes = []
def snapshot(self) -> RouteMetrics:
return RouteMetrics(
nodes_expanded=self.total_nodes_expanded,
moves_generated=self.total_moves_generated,
moves_added=self.total_moves_added,
pruned_closed_set=self.total_pruned_closed_set,
pruned_hard_collision=self.total_pruned_hard_collision,
pruned_cost=self.total_pruned_cost,
)
class AStarContext:
__slots__ = (
"cost_evaluator",
"problem",
"options",
"max_cache_size",
"visibility_manager",
"move_cache_rel",
"move_cache_abs",
"hard_collision_set",
"static_safe_cache",
"static_cache_version",
)
def __init__(
self,
cost_evaluator: CostEvaluator,
problem: RoutingProblem,
options: RoutingOptions,
max_cache_size: int = 1000000,
) -> None:
self.cost_evaluator = cost_evaluator
self.max_cache_size = max_cache_size
self.problem = problem
self.options = options
self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0))
self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine)
self.move_cache_rel: dict[tuple, ComponentResult] = {}
self.move_cache_abs: dict[tuple, ComponentResult] = {}
self.hard_collision_set: set[tuple] = set()
self.static_safe_cache: set[tuple] = set()
self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version()
def clear_static_caches(self) -> None:
self.hard_collision_set.clear()
self.static_safe_cache.clear()
self.visibility_manager.clear_cache()
self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version()
def ensure_static_caches_current(self) -> None:
current_version = self.cost_evaluator.collision_engine.get_static_version()
if self.static_cache_version != current_version:
self.clear_static_caches()
def _evict_cache(self, cache: dict[tuple, ComponentResult]) -> None:
if len(cache) <= self.max_cache_size * 1.2:
return
num_to_evict = max(1, int(len(cache) * 0.25))
for idx, key in enumerate(tuple(cache.keys())):
if idx >= num_to_evict:
break
del cache[key]
def check_cache_eviction(self) -> None:
self._evict_cache(self.move_cache_rel)
self._evict_cache(self.move_cache_abs)

362
inire/router/_router.py Normal file
View file

@ -0,0 +1,362 @@
from __future__ import annotations
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from inire.model import NetSpec, RoutingOptions, RoutingProblem
from inire.router._astar_types import AStarContext, AStarMetrics
from inire.router._search import route_astar
from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry
from inire.router.refiner import PathRefiner
from inire.router.results import RoutingReport, RoutingResult
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
@dataclass(slots=True)
class _RoutingState:
net_specs: dict[str, NetSpec]
ordered_net_ids: list[str]
results: dict[str, RoutingResult]
needs_self_collision_check: set[str]
start_time: float
timeout_s: float
initial_paths: dict[str, tuple[ComponentResult, ...]] | None
accumulated_expanded_nodes: list[tuple[int, int, int]]
__all__ = ["PathFinder"]
class PathFinder:
__slots__ = (
"context",
"metrics",
"refiner",
"accumulated_expanded_nodes",
)
def __init__(
self,
context: AStarContext,
metrics: AStarMetrics | None = None,
) -> None:
self.context = context
self.metrics = metrics if metrics is not None else AStarMetrics()
self.refiner = PathRefiner(self.context)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
@property
def problem(self) -> RoutingProblem:
return self.context.problem
@property
def options(self) -> RoutingOptions:
return self.context.options
@property
def cost_evaluator(self) -> CostEvaluator:
return self.context.cost_evaluator
def _path_cost(self, path: Sequence[ComponentResult]) -> float:
return self.refiner.path_cost(path)
def _refine_path(
self,
net_id: str,
start: Port,
target: Port,
net_width: float,
path: Sequence[ComponentResult],
) -> list[ComponentResult]:
return self.refiner.refine_path(net_id, start, target, net_width, path)
def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]:
all_geoms = []
all_dilated = []
for result in path:
all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry)
return all_geoms, all_dilated
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
all_geoms, all_dilated = self._extract_path_geometry(path)
self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]:
obj_ids: list[int] = []
for result in path:
for polygon in result.physical_geometry:
obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon))
return obj_ids
def _remove_static_obstacles(self, obj_ids: list[int]) -> None:
for obj_id in obj_ids:
self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id)
def _remove_path(self, net_id: str) -> None:
self.cost_evaluator.collision_engine.remove_path(net_id)
def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport:
return self.cost_evaluator.collision_engine.verify_path_report(net_id, path)
def _finalize_dynamic_tree(self) -> None:
self.cost_evaluator.collision_engine.rebuild_dynamic_tree()
def _build_routing_result(
self,
*,
net_id: str,
path: Sequence[ComponentResult],
reached_target: bool | None = None,
report: RoutingReport | None = None,
) -> RoutingResult:
resolved_reached_target = bool(path) if reached_target is None else reached_target
return RoutingResult(
net_id=net_id,
path=path,
reached_target=resolved_reached_target,
report=report if report is not None else RoutingReport(),
)
def _routing_order(
self,
net_specs: dict[str, NetSpec],
order: str,
) -> list[str]:
ordered_net_ids = list(net_specs.keys())
if order == "user":
return ordered_net_ids
ordered_net_ids.sort(
key=lambda net_id: abs(net_specs[net_id].target.x - net_specs[net_id].start.x)
+ abs(net_specs[net_id].target.y - net_specs[net_id].start.y),
reverse=(order == "longest"),
)
return ordered_net_ids
def _build_greedy_warm_start_paths(
self,
net_specs: dict[str, NetSpec],
order: str,
) -> dict[str, tuple[ComponentResult, ...]]:
greedy_paths: dict[str, tuple[ComponentResult, ...]] = {}
temp_obj_ids: list[int] = []
greedy_node_limit = min(self.options.search.node_limit, 2000)
for net_id in self._routing_order(net_specs, order):
net = net_specs[net_id]
h_start = self.cost_evaluator.h_manhattan(net.start, net.target)
max_cost_limit = max(h_start * 3.0, 2000.0)
path = route_astar(
net.start,
net.target,
net.width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
skip_congestion=True,
max_cost=max_cost_limit,
self_collision_check=True,
node_limit=greedy_node_limit,
)
if not path:
continue
greedy_paths[net_id] = tuple(path)
temp_obj_ids.extend(self._stage_path_as_static(path))
self.context.clear_static_caches()
self._remove_static_obstacles(temp_obj_ids)
return greedy_paths
def _prepare_state(self) -> _RoutingState:
problem = self.problem
congestion = self.options.congestion
initial_paths = self.options.search.initial_paths
net_specs = {net.net_id: net for net in problem.nets}
num_nets = len(net_specs)
state = _RoutingState(
net_specs=net_specs,
ordered_net_ids=list(net_specs.keys()),
results={},
needs_self_collision_check=set(),
start_time=time.monotonic(),
timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations),
initial_paths=initial_paths,
accumulated_expanded_nodes=[],
)
if state.initial_paths is None:
warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start
if warm_start_order is not None:
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order)
self.context.clear_static_caches()
if congestion.sort_nets and congestion.sort_nets != "user":
state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets)
return state
def _route_net_once(
self,
state: _RoutingState,
iteration: int,
net_id: str,
) -> RoutingResult:
search = self.options.search
congestion = self.options.congestion
diagnostics = self.options.diagnostics
net = state.net_specs[net_id]
self._remove_path(net_id)
if iteration == 0 and state.initial_paths and net_id in state.initial_paths:
path: Sequence[ComponentResult] | None = state.initial_paths[net_id]
else:
coll_model = search.bend_collision_type
skip_congestion = False
if congestion.use_tiered_strategy and iteration == 0:
skip_congestion = True
if coll_model == "arc":
coll_model = "clipped_bbox"
path = route_astar(
net.start,
net.target,
net.width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
bend_collision_type=coll_model,
return_partial=True,
store_expanded=diagnostics.capture_expanded,
skip_congestion=skip_congestion,
self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=search.node_limit,
)
if diagnostics.capture_expanded and self.metrics.last_expanded_nodes:
state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
if not path:
return self._build_routing_result(net_id=net_id, path=[], reached_target=False)
reached_target = path[-1].end_port == net.target
report = None
self._install_path(net_id, path)
if reached_target:
report = self._verify_path_report(net_id, path)
if report.self_collision_count > 0:
state.needs_self_collision_check.add(net_id)
return self._build_routing_result(
net_id=net_id,
path=path,
reached_target=reached_target,
report=report,
)
def _run_iteration(
self,
state: _RoutingState,
iteration: int,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {}
congestion = self.options.congestion
self.metrics.reset_per_route()
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None
random.Random(iteration_seed).shuffle(state.ordered_net_ids)
for net_id in state.ordered_net_ids:
if time.monotonic() - state.start_time > state.timeout_s:
self._finalize_dynamic_tree()
return None
result = self._route_net_once(state, iteration, net_id)
state.results[net_id] = result
outcomes[net_id] = result.outcome
if iteration_callback:
iteration_callback(iteration, state.results)
return outcomes
def _run_iterations(
self,
state: _RoutingState,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> bool:
congestion = self.options.congestion
for iteration in range(congestion.max_iterations):
outcomes = self._run_iteration(state, iteration, iteration_callback)
if outcomes is None:
return True
if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()):
return False
self.cost_evaluator.congestion_penalty *= congestion.multiplier
return False
def _refine_results(self, state: _RoutingState) -> None:
if not self.options.refinement.enabled or not state.results:
return
for net_id in state.ordered_net_ids:
result = state.results.get(net_id)
if not result or not result.path or routing_outcome_needs_retry(result.outcome):
continue
net = state.net_specs[net_id]
self._remove_path(net_id)
refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path)
self._install_path(net_id, refined_path)
report = self._verify_path_report(net_id, refined_path)
state.results[net_id] = self._build_routing_result(
net_id=net_id,
path=refined_path,
reached_target=result.reached_target,
report=report,
)
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {}
for net in self.problem.nets:
result = state.results.get(net.net_id)
if not result or not result.path:
final_results[net.net_id] = self._build_routing_result(
net_id=net.net_id,
path=[],
reached_target=False,
)
continue
report = self._verify_path_report(net.net_id, result.path)
final_results[net.net_id] = self._build_routing_result(
net_id=net.net_id,
path=result.path,
reached_target=result.reached_target,
report=report,
)
return final_results
def route_all(
self,
*,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
) -> dict[str, RoutingResult]:
self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty
self.accumulated_expanded_nodes = []
self.metrics.reset_totals()
self.metrics.reset_per_route()
state = self._prepare_state()
timed_out = self._run_iterations(state, iteration_callback)
self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes)
if timed_out:
return self._verify_results(state)
self._refine_results(state)
self._finalize_dynamic_tree()
return self._verify_results(state)

112
inire/router/_search.py Normal file
View file

@ -0,0 +1,112 @@
from __future__ import annotations
import heapq
from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import BendCollisionModel
from inire.geometry.primitives import Port
from ._astar_moves import expand_moves as _expand_moves
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode
from .results import RouteMetrics
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]:
path = []
curr: _AStarNode | None = end_node
while curr and curr.component_result:
path.append(curr.component_result)
curr = curr.parent
return path[::-1]
def route_astar(
start: Port,
target: Port,
net_width: float,
context: AStarContext,
metrics: AStarMetrics | None = None,
net_id: str = "default",
bend_collision_type: BendCollisionModel | None = None,
return_partial: bool = False,
store_expanded: bool = False,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
node_limit: int | None = None,
) -> list[ComponentResult] | None:
if metrics is None:
metrics = AStarMetrics()
metrics.reset_per_route()
search_options = context.options.search
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type
context.ensure_static_caches_current()
context.cost_evaluator.set_target(target)
open_set: list[_AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
congestion_cache: dict[tuple, int] = {}
start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target))
heapq.heappush(open_set, start_node)
best_node = start_node
effective_node_limit = node_limit if node_limit is not None else search_options.node_limit
nodes_expanded = 0
while open_set:
if nodes_expanded >= effective_node_limit:
return _reconstruct_path(best_node) if return_partial else None
current = heapq.heappop(open_set)
if max_cost is not None and current.fh_cost[0] > max_cost:
metrics.pruned_cost += 1
metrics.total_pruned_cost += 1
continue
if current.h_cost < best_node.h_cost:
best_node = current
state = current.port.as_tuple()
if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR:
continue
closed_set[state] = current.g_cost
if store_expanded:
metrics.last_expanded_nodes.append(state)
nodes_expanded += 1
metrics.total_nodes_expanded += 1
metrics.nodes_expanded += 1
if current.port == target:
return _reconstruct_path(current)
_expand_moves(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
skip_congestion=skip_congestion,
self_collision_check=self_collision_check,
)
return _reconstruct_path(best_node) if return_partial else None
__all__ = [
"AStarContext",
"AStarMetrics",
"RouteMetrics",
"route_astar",
]

View file

@ -1,721 +0,0 @@
from __future__ import annotations
import heapq
import logging
import math
from typing import TYPE_CHECKING, Any, Literal
import shapely
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port
from inire.router.config import RouterConfig, VisibilityGuidanceMode
from inire.router.refiner import component_hits_ancestor_chain
from inire.router.visibility import VisibilityManager
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
from inire.router.cost import CostEvaluator
logger = logging.getLogger(__name__)
class AStarNode:
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
def __init__(
self,
port: Port,
g_cost: float,
h_cost: float,
parent: AStarNode | None = None,
component_result: ComponentResult | None = None,
) -> None:
self.port = port
self.g_cost = g_cost
self.h_cost = h_cost
self.fh_cost = (g_cost + h_cost, h_cost)
self.parent = parent
self.component_result = component_result
def __lt__(self, other: AStarNode) -> bool:
return self.fh_cost < other.fh_cost
class AStarMetrics:
__slots__ = (
"total_nodes_expanded",
"last_expanded_nodes",
"nodes_expanded",
"moves_generated",
"moves_added",
"pruned_closed_set",
"pruned_hard_collision",
"pruned_cost",
)
def __init__(self) -> None:
self.total_nodes_expanded = 0
self.last_expanded_nodes: list[tuple[int, int, int]] = []
self.nodes_expanded = 0
self.moves_generated = 0
self.moves_added = 0
self.pruned_closed_set = 0
self.pruned_hard_collision = 0
self.pruned_cost = 0
def reset_per_route(self) -> None:
self.nodes_expanded = 0
self.moves_generated = 0
self.moves_added = 0
self.pruned_closed_set = 0
self.pruned_hard_collision = 0
self.pruned_cost = 0
self.last_expanded_nodes = []
class AStarContext:
__slots__ = (
"cost_evaluator",
"config",
"visibility_manager",
"move_cache_rel",
"move_cache_abs",
"hard_collision_set",
"static_safe_cache",
"max_cache_size",
)
def __init__(
self,
cost_evaluator: CostEvaluator,
node_limit: int = 1000000,
max_straight_length: float = 2000.0,
min_straight_length: float = 5.0,
bend_radii: list[float] | None = None,
sbend_radii: list[float] | None = None,
sbend_offsets: list[float] | None = None,
bend_penalty: float = 250.0,
sbend_penalty: float | None = None,
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc",
bend_clip_margin: float = 10.0,
visibility_guidance: VisibilityGuidanceMode = "tangent_corner",
max_cache_size: int = 1000000,
) -> None:
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
self.cost_evaluator = cost_evaluator
self.max_cache_size = max_cache_size
self.config = RouterConfig(
node_limit=node_limit,
max_straight_length=max_straight_length,
min_straight_length=min_straight_length,
bend_radii=bend_radii if bend_radii is not None else [50.0, 100.0],
sbend_radii=sbend_radii if sbend_radii is not None else [5.0, 10.0, 50.0, 100.0],
sbend_offsets=sbend_offsets,
bend_penalty=bend_penalty,
sbend_penalty=actual_sbend_penalty,
bend_collision_type=bend_collision_type,
bend_clip_margin=bend_clip_margin,
visibility_guidance=visibility_guidance,
)
self.cost_evaluator.apply_routing_costs(
bend_penalty=self.config.bend_penalty,
sbend_penalty=self.config.sbend_penalty,
bend_radii=self.config.bend_radii,
)
self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine)
self.move_cache_rel: dict[tuple, ComponentResult] = {}
self.move_cache_abs: dict[tuple, ComponentResult] = {}
self.hard_collision_set: set[tuple] = set()
self.static_safe_cache: set[tuple] = set()
def clear_static_caches(self) -> None:
self.hard_collision_set.clear()
self.static_safe_cache.clear()
self.visibility_manager.clear_cache()
def check_cache_eviction(self) -> None:
if len(self.move_cache_abs) <= self.max_cache_size * 1.2:
return
num_to_evict = int(len(self.move_cache_abs) * 0.25)
for idx, key in enumerate(list(self.move_cache_abs.keys())):
if idx >= num_to_evict:
break
del self.move_cache_abs[key]
def route_astar(
start: Port,
target: Port,
net_width: float,
context: AStarContext,
metrics: AStarMetrics | None = None,
net_id: str = "default",
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | None = None,
return_partial: bool = False,
store_expanded: bool = False,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
node_limit: int | None = None,
) -> list[ComponentResult] | None:
if metrics is None:
metrics = AStarMetrics()
metrics.reset_per_route()
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type
context.cost_evaluator.set_target(target)
open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
congestion_cache: dict[tuple, int] = {}
start_node = AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target))
heapq.heappush(open_set, start_node)
best_node = start_node
effective_node_limit = node_limit if node_limit is not None else context.config.node_limit
nodes_expanded = 0
while open_set:
if nodes_expanded >= effective_node_limit:
return reconstruct_path(best_node) if return_partial else None
current = heapq.heappop(open_set)
if max_cost is not None and current.fh_cost[0] > max_cost:
metrics.pruned_cost += 1
continue
if current.h_cost < best_node.h_cost:
best_node = current
state = current.port.as_tuple()
if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR:
continue
closed_set[state] = current.g_cost
if store_expanded:
metrics.last_expanded_nodes.append(state)
nodes_expanded += 1
metrics.total_nodes_expanded += 1
metrics.nodes_expanded += 1
if current.port == target:
return reconstruct_path(current)
expand_moves(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
effective_bend_collision_type,
max_cost=max_cost,
skip_congestion=skip_congestion,
self_collision_check=self_collision_check,
)
return reconstruct_path(best_node) if return_partial else None
def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01}
return sorted((v for v in out if v > 0), reverse=True)
def _sbend_forward_span(offset: float, radius: float) -> float | None:
abs_offset = abs(offset)
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
return None
theta = __import__("math").acos(1.0 - abs_offset / (2.0 * radius))
return 2.0 * radius * __import__("math").sin(theta)
def _visible_straight_candidates(
current: Port,
context: AStarContext,
max_reach: float,
cos_v: float,
sin_v: float,
net_width: float,
) -> list[float]:
mode = context.config.visibility_guidance
if mode == "off":
return []
if mode == "exact_corner":
max_bend_radius = max(context.config.bend_radii, default=0.0)
visibility_reach = max_reach + max_bend_radius
visible_corners = sorted(
context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach),
key=lambda corner: corner[2],
)
if not visible_corners:
return []
candidates: set[int] = set()
for cx, cy, _ in visible_corners[:12]:
dx = cx - current.x
dy = cy - current.y
local_x = dx * cos_v + dy * sin_v
if local_x <= context.config.min_straight_length:
continue
candidates.add(int(round(local_x)))
return sorted(candidates, reverse=True)
if mode != "tangent_corner":
return []
visibility_manager = context.visibility_manager
visibility_manager._ensure_current()
max_bend_radius = max(context.config.bend_radii, default=0.0)
if max_bend_radius <= 0 or not visibility_manager.corners:
return []
reach = max_reach + max_bend_radius
bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
if not candidate_ids:
return []
scored: list[tuple[float, float, float, float, float]] = []
for idx in candidate_ids:
cx, cy = visibility_manager.corners[idx]
dx = cx - current.x
dy = cy - current.y
local_x = dx * cos_v + dy * sin_v
local_y = -dx * sin_v + dy * cos_v
if local_x <= context.config.min_straight_length or local_x > reach + 0.01:
continue
nearest_radius = min(context.config.bend_radii, key=lambda radius: abs(abs(local_y) - radius))
tangent_error = abs(abs(local_y) - nearest_radius)
if tangent_error > 2.0:
continue
length = local_x - nearest_radius
if length <= context.config.min_straight_length or length > max_reach + 0.01:
continue
scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy))
if not scored:
return []
collision_engine = context.cost_evaluator.collision_engine
candidates: set[int] = set()
for _, dist, length, dx, dy in sorted(scored)[:4]:
angle = math.degrees(math.atan2(dy, dx))
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
if corner_reach < dist - 0.01:
continue
qlen = int(round(length))
if qlen > 0:
candidates.add(qlen)
return sorted(candidates, reverse=True)
def _previous_move_metadata(node: AStarNode) -> tuple[str | None, float | None]:
result = node.component_result
if result is None:
return None, None
move_type = result.move_type
if move_type == "Straight":
return move_type, result.length
return move_type, None
def expand_moves(
current: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any | None = None,
max_cost: float | None = None,
skip_congestion: bool = False,
self_collision_check: bool = False,
) -> None:
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else context.config.bend_collision_type
cp = current.port
prev_move_type, prev_straight_length = _previous_move_metadata(current)
dx_t = target.x - cp.x
dy_t = target.y - cp.y
dist_sq = dx_t * dx_t + dy_t * dy_t
if cp.r == 0:
cos_v, sin_v = 1.0, 0.0
elif cp.r == 90:
cos_v, sin_v = 0.0, 1.0
elif cp.r == 180:
cos_v, sin_v = -1.0, 0.0
else:
cos_v, sin_v = 0.0, -1.0
proj_t = dx_t * cos_v + dy_t * sin_v
perp_t = -dx_t * sin_v + dy_t * cos_v
dx_local = proj_t
dy_local = perp_t
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
if max_reach >= proj_t - 0.01 and (
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
):
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"S",
(int(round(proj_t)),),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, context.config.max_straight_length, net_width=net_width)
candidate_lengths = [
context.config.min_straight_length,
max_reach,
max_reach / 2.0,
max_reach - 5.0,
]
axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t)
candidate_lengths.append(axis_target_dist)
for radius in context.config.bend_radii:
candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius))
candidate_lengths.extend(
_visible_straight_candidates(
cp,
context,
max_reach,
cos_v,
sin_v,
net_width,
)
)
if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR:
for radius in context.config.sbend_radii:
sbend_span = _sbend_forward_span(dy_local, radius)
if sbend_span is None:
continue
candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span))
for length in _quantized_lengths(candidate_lengths, max_reach):
if length < context.config.min_straight_length:
continue
if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"S",
(length,),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
angle_to_target = 0.0
if dx_t != 0 or dy_t != 0:
angle_to_target = float((round((180.0 / 3.141592653589793) * __import__("math").atan2(dy_t, dx_t)) + 360.0) % 360.0)
allow_backwards = dist_sq < 150 * 150
for radius in context.config.bend_radii:
for direction in ("CW", "CCW"):
if not allow_backwards:
turn = 90 if direction == "CCW" else -90
new_ori = (cp.r + turn) % 360
new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0
if abs(new_diff) > 135.0:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"B",
(radius, direction),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
max_sbend_r = max(context.config.sbend_radii) if context.config.sbend_radii else 0.0
if max_sbend_r <= 0 or prev_move_type == "SBend":
return
explicit_offsets = context.config.sbend_offsets
offsets: set[int] = set(int(round(v)) for v in explicit_offsets or [])
# S-bends preserve orientation, so the implicit search only makes sense
# when the target is ahead in local coordinates and keeps the same
# orientation. Generating generic speculative offsets on the integer lattice
# explodes the search space without contributing useful moves.
if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r:
if 0 < abs(dy_local) < 2 * max_sbend_r:
offsets.add(int(round(dy_local)))
if not offsets:
return
for offset in sorted(offsets):
if offset == 0:
continue
for radius in context.config.sbend_radii:
if abs(offset) >= 2 * radius:
continue
process_move(
current,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
"SB",
(offset, radius),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
def process_move(
parent: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
move_class: Literal["S", "B", "SB"],
params: tuple,
skip_congestion: bool,
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None:
cp = parent.port
coll_type = bend_collision_type
coll_key = id(coll_type) if isinstance(coll_type, shapely.geometry.Polygon) else coll_type
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
abs_key = (
cp.as_tuple(),
move_class,
params,
net_width,
coll_key,
context.config.bend_clip_margin,
self_dilation,
)
if abs_key in context.move_cache_abs:
res = context.move_cache_abs[abs_key]
else:
context.check_cache_eviction()
base_port = Port(0, 0, cp.r)
rel_key = (
cp.r,
move_class,
params,
net_width,
coll_key,
context.config.bend_clip_margin,
self_dilation,
)
if rel_key in context.move_cache_rel:
res_rel = context.move_cache_rel[rel_key]
else:
try:
if move_class == "S":
res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation)
elif move_class == "B":
res_rel = Bend90.generate(
base_port,
params[0],
net_width,
params[1],
collision_type=coll_type,
clip_margin=context.config.bend_clip_margin,
dilation=self_dilation,
)
else:
res_rel = SBend.generate(
base_port,
params[0],
params[1],
net_width,
collision_type=coll_type,
clip_margin=context.config.bend_clip_margin,
dilation=self_dilation,
)
except ValueError:
return
context.move_cache_rel[rel_key] = res_rel
res = res_rel.translate(cp.x, cp.y)
context.move_cache_abs[abs_key] = res
move_radius = params[0] if move_class == "B" else (params[1] if move_class == "SB" else None)
add_node(
parent,
res,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
move_class,
abs_key,
move_radius=move_radius,
skip_congestion=skip_congestion,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
def add_node(
parent: AStarNode,
result: ComponentResult,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
move_type: str,
cache_key: tuple,
move_radius: float | None = None,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None:
metrics.moves_generated += 1
state = result.end_port.as_tuple()
new_lower_bound_g = parent.g_cost + result.length
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
metrics.pruned_closed_set += 1
return
parent_p = parent.port
end_p = result.end_port
if cache_key in context.hard_collision_set:
metrics.pruned_hard_collision += 1
return
is_static_safe = cache_key in context.static_safe_cache
if not is_static_safe:
ce = context.cost_evaluator.collision_engine
if move_type == "S":
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
else:
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width)
if collision_found:
context.hard_collision_set.add(cache_key)
metrics.pruned_hard_collision += 1
return
context.static_safe_cache.add(cache_key)
total_overlaps = 0
if not skip_congestion:
if cache_key in congestion_cache:
total_overlaps = congestion_cache[cache_key]
else:
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
congestion_cache[cache_key] = total_overlaps
if self_collision_check:
if component_hits_ancestor_chain(result, parent):
return
penalty = 0.0
if move_type == "SB":
penalty = context.config.sbend_penalty
elif move_type == "B":
penalty = context.config.bend_penalty
if move_radius is not None and move_radius > TOLERANCE_LINEAR:
penalty *= (10.0 / move_radius) ** 0.5
move_cost = context.cost_evaluator.evaluate_move(
result.geometry,
result.end_port,
net_width,
net_id,
start_port=parent_p,
length=result.length,
dilated_geometry=result.dilated_geometry,
penalty=penalty,
skip_static=True,
skip_congestion=True,
)
move_cost += total_overlaps * context.cost_evaluator.congestion_penalty
if max_cost is not None and parent.g_cost + move_cost > max_cost:
metrics.pruned_cost += 1
return
if move_cost > 1e12:
metrics.pruned_cost += 1
return
g_cost = parent.g_cost + move_cost
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
metrics.pruned_closed_set += 1
return
h_cost = context.cost_evaluator.h_manhattan(result.end_port, target)
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
metrics.moves_added += 1
def reconstruct_path(end_node: AStarNode) -> list[ComponentResult]:
path = []
curr: AStarNode | None = end_node
while curr and curr.component_result:
path.append(curr.component_result)
curr = curr.parent
return path[::-1]

View file

@ -1,40 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Any
VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"]
@dataclass
class RouterConfig:
"""Configuration parameters for the A* Router."""
node_limit: int = 1000000
max_straight_length: float = 2000.0
min_straight_length: float = 5.0
sbend_offsets: list[float] | None = None
bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0])
sbend_radii: list[float] = field(default_factory=lambda: [10.0])
snap_to_target_dist: float = 1000.0
bend_penalty: float = 250.0
sbend_penalty: float = 500.0
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc"
bend_clip_margin: float = 10.0
visibility_guidance: VisibilityGuidanceMode = "tangent_corner"
@dataclass
class CostConfig:
"""Configuration parameters for the Cost Evaluator."""
unit_length_cost: float = 1.0
greedy_h_weight: float = 1.5
congestion_penalty: float = 10000.0
bend_penalty: float = 250.0
sbend_penalty: float = 500.0
min_bend_radius: float = 50.0

View file

@ -5,12 +5,15 @@ from typing import TYPE_CHECKING
import numpy as np
from inire.constants import TOLERANCE_LINEAR
from inire.router.config import CostConfig
from inire.model import ObjectiveWeights, RoutingOptions
if TYPE_CHECKING:
from collections.abc import Sequence
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult, MoveKind
from inire.geometry.primitives import Port
from inire.router.danger_map import DangerMap
@ -19,10 +22,12 @@ class CostEvaluator:
__slots__ = (
"collision_engine",
"danger_map",
"config",
"unit_length_cost",
"greedy_h_weight",
"congestion_penalty",
"_unit_length_cost",
"_greedy_h_weight",
"_bend_penalty",
"_sbend_penalty",
"_danger_weight",
"_congestion_penalty",
"_target_x",
"_target_y",
"_target_r",
@ -33,53 +38,102 @@ class CostEvaluator:
def __init__(
self,
collision_engine: CollisionEngine,
collision_engine: RoutingWorld,
danger_map: DangerMap | None = None,
unit_length_cost: float = 1.0,
greedy_h_weight: float = 1.5,
congestion_penalty: float = 10000.0,
bend_penalty: float = 250.0,
sbend_penalty: float | None = None,
min_bend_radius: float = 50.0,
danger_weight: float = 1.0,
) -> None:
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
self.collision_engine = collision_engine
self.danger_map = danger_map
self.config = CostConfig(
unit_length_cost=unit_length_cost,
greedy_h_weight=greedy_h_weight,
congestion_penalty=congestion_penalty,
bend_penalty=bend_penalty,
sbend_penalty=actual_sbend_penalty,
min_bend_radius=min_bend_radius,
)
self.unit_length_cost = self.config.unit_length_cost
self.greedy_h_weight = self.config.greedy_h_weight
self.congestion_penalty = self.config.congestion_penalty
self._refresh_cached_config()
self._unit_length_cost = float(unit_length_cost)
self._greedy_h_weight = float(greedy_h_weight)
self._bend_penalty = float(bend_penalty)
self._sbend_penalty = float(actual_sbend_penalty)
self._danger_weight = float(danger_weight)
self._congestion_penalty = 0.0
self._target_x = 0.0
self._target_y = 0.0
self._target_r = 0
self._target_cos = 1.0
self._target_sin = 0.0
def apply_routing_costs(
self,
*,
bend_penalty: float,
sbend_penalty: float,
bend_radii: list[float],
) -> None:
self.config.bend_penalty = bend_penalty
self.config.sbend_penalty = sbend_penalty
self.config.min_bend_radius = min(bend_radii) if bend_radii else 50.0
self._refresh_cached_config()
self._min_radius = 50.0
def _refresh_cached_config(self) -> None:
self._min_radius = self.config.min_bend_radius
self.unit_length_cost = self.config.unit_length_cost
self.greedy_h_weight = self.config.greedy_h_weight
self.congestion_penalty = self.config.congestion_penalty
@property
def unit_length_cost(self) -> float:
return self._unit_length_cost
@unit_length_cost.setter
def unit_length_cost(self, value: float) -> None:
self._unit_length_cost = float(value)
@property
def greedy_h_weight(self) -> float:
return self._greedy_h_weight
@greedy_h_weight.setter
def greedy_h_weight(self, value: float) -> None:
self._greedy_h_weight = float(value)
@property
def congestion_penalty(self) -> float:
return self._congestion_penalty
@congestion_penalty.setter
def congestion_penalty(self, value: float) -> None:
self._congestion_penalty = float(value)
@property
def bend_penalty(self) -> float:
return self._bend_penalty
@bend_penalty.setter
def bend_penalty(self, value: float) -> None:
self._bend_penalty = float(value)
@property
def sbend_penalty(self) -> float:
return self._sbend_penalty
@sbend_penalty.setter
def sbend_penalty(self, value: float) -> None:
self._sbend_penalty = float(value)
@property
def danger_weight(self) -> float:
return self._danger_weight
@danger_weight.setter
def danger_weight(self, value: float) -> None:
self._danger_weight = float(value)
def set_min_bend_radius(self, radius: float) -> None:
self._min_radius = float(radius) if radius > 0 else 50.0
def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights:
return ObjectiveWeights(
unit_length_cost=self._unit_length_cost,
bend_penalty=self._bend_penalty,
sbend_penalty=self._sbend_penalty,
danger_weight=self._danger_weight,
congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty),
)
def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights:
refinement_objective = options.refinement.objective
if refinement_objective is None:
return ObjectiveWeights(
unit_length_cost=self._unit_length_cost,
bend_penalty=self._bend_penalty,
sbend_penalty=self._sbend_penalty,
danger_weight=self._danger_weight,
congestion_penalty=0.0,
)
return refinement_objective
def set_target(self, target: Port) -> None:
self._target_x = target.x
@ -92,7 +146,7 @@ class CostEvaluator:
def g_proximity(self, x: float, y: float) -> float:
if self.danger_map is None:
return 0.0
return self.danger_map.get_cost(x, y)
return self._danger_weight * self.danger_map.get_cost(x, y)
def h_manhattan(self, current: Port, target: Port) -> float:
tx, ty = target.x, target.y
@ -102,7 +156,7 @@ class CostEvaluator:
dx = abs(current.x - tx)
dy = abs(current.y - ty)
dist = dx + dy
bp = self.config.bend_penalty
bp = self._bend_penalty
penalty = 0.0
curr_r = current.r
@ -132,27 +186,29 @@ class CostEvaluator:
if diff == 0 and perp_dist > 0:
penalty += 2 * bp
return self.greedy_h_weight * (dist + penalty)
return self._greedy_h_weight * (dist + penalty)
def evaluate_move(
self,
geometry: list[Polygon] | None,
geometry: Sequence[Polygon] | None,
end_port: Port,
net_width: float,
net_id: str,
start_port: Port | None = None,
length: float = 0.0,
dilated_geometry: list[Polygon] | None = None,
dilated_geometry: Sequence[Polygon] | None = None,
skip_static: bool = False,
skip_congestion: bool = False,
penalty: float = 0.0,
weights: ObjectiveWeights | None = None,
) -> float:
active_weights = self.objective_weights() if weights is None else weights
_ = net_width
danger_map = self.danger_map
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
return 1e15
total_cost = length * self.unit_length_cost + penalty
total_cost = length * active_weights.unit_length_cost + penalty
if not skip_static or not skip_congestion:
if geometry is None:
return 1e15
@ -171,16 +227,71 @@ class CostEvaluator:
if not skip_congestion:
overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly)
if isinstance(overlaps, int) and overlaps > 0:
total_cost += overlaps * self.congestion_penalty
total_cost += overlaps * active_weights.congestion_penalty
if danger_map is not None:
if danger_map is not None and active_weights.danger_weight:
cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0
cost_e = danger_map.get_cost(end_port.x, end_port.y)
if start_port:
mid_x = (start_port.x + end_port.x) / 2.0
mid_y = (start_port.y + end_port.y) / 2.0
cost_m = danger_map.get_cost(mid_x, mid_y)
total_cost += length * (cost_s + cost_m + cost_e) / 3.0
total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
else:
total_cost += length * cost_e
total_cost += length * active_weights.danger_weight * cost_e
return total_cost
def component_penalty(
self,
move_type: MoveKind,
*,
move_radius: float | None = None,
weights: ObjectiveWeights | None = None,
) -> float:
active_weights = self.objective_weights() if weights is None else weights
penalty = 0.0
if move_type == "sbend":
penalty = active_weights.sbend_penalty
elif move_type == "bend90":
penalty = active_weights.bend_penalty
if move_radius is not None and move_radius > TOLERANCE_LINEAR and penalty > 0:
penalty *= (10.0 / move_radius) ** 0.5
return penalty
def path_cost(
self,
net_id: str,
start_port: Port,
path: list[ComponentResult],
*,
weights: ObjectiveWeights | None = None,
) -> float:
active_weights = self.objective_weights() if weights is None else weights
total = 0.0
current_port = start_port
for component in path:
move_radius = None
if component.move_type == "bend90":
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
elif component.move_type == "sbend":
move_radius = None
penalty = self.component_penalty(
component.move_type,
move_radius=move_radius,
weights=active_weights,
)
total += self.evaluate_move(
component.collision_geometry,
component.end_port,
net_width=0.0,
net_id=net_id,
start_port=current_port,
length=component.length,
dilated_geometry=component.dilated_collision_geometry,
skip_static=True,
skip_congestion=(active_weights.congestion_penalty <= 0.0),
penalty=penalty,
weights=active_weights,
)
current_port = component.end_port
return total

View file

@ -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:
"""
@ -65,8 +69,7 @@ class DangerMap:
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):

View file

@ -1,56 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from inire.geometry.collision import CollisionEngine, PathVerificationReport
from inire.geometry.components import ComponentResult
class PathStateManager:
__slots__ = ("collision_engine",)
def __init__(self, collision_engine: CollisionEngine) -> None:
self.collision_engine = collision_engine
def extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]:
all_geoms = []
all_dilated = []
for res in path:
all_geoms.extend(res.geometry)
if res.dilated_geometry:
all_dilated.extend(res.dilated_geometry)
else:
dilation = self.collision_engine.clearance / 2.0
all_dilated.extend([poly.buffer(dilation) for poly in res.geometry])
return all_geoms, all_dilated
def install_path(self, net_id: str, path: list[ComponentResult]) -> None:
all_geoms, all_dilated = self.extract_geometry(path)
self.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
def stage_path_as_static(self, path: list[ComponentResult]) -> list[int]:
obj_ids: list[int] = []
for res in path:
geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry
dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry
for index, poly in enumerate(geoms):
dilated = dilated_geoms[index] if dilated_geoms else None
obj_ids.append(self.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated))
return obj_ids
def remove_static_obstacles(self, obj_ids: list[int]) -> None:
for obj_id in obj_ids:
self.collision_engine.remove_static_obstacle(obj_id)
def remove_path(self, net_id: str) -> None:
self.collision_engine.remove_path(net_id)
def verify_path(self, net_id: str, path: list[ComponentResult]) -> tuple[bool, int]:
return self.collision_engine.verify_path(net_id, path)
def verify_path_report(self, net_id: str, path: list[ComponentResult]) -> PathVerificationReport:
return self.collision_engine.verify_path_report(net_id, path)
def finalize_dynamic_tree(self) -> None:
self.collision_engine.rebuild_dynamic_tree()

View file

@ -1,310 +0,0 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from inire.router.astar import AStarMetrics, route_astar
from inire.router.outcomes import RoutingOutcome, infer_routing_outcome, routing_outcome_needs_retry
from inire.router.refiner import PathRefiner
from inire.router.path_state import PathStateManager
from inire.router.session import (
create_routing_session_state,
finalize_routing_session_results,
prepare_routing_session_state,
refine_routing_session_results,
run_routing_iteration,
)
if TYPE_CHECKING:
from collections.abc import Callable
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router.cost import CostEvaluator
logger = logging.getLogger(__name__)
@dataclass
class RoutingResult:
net_id: str
path: list[ComponentResult]
is_valid: bool
collisions: int
reached_target: bool = False
outcome: RoutingOutcome = "unroutable"
class PathFinder:
__slots__ = (
"context",
"metrics",
"max_iterations",
"base_congestion_penalty",
"use_tiered_strategy",
"congestion_multiplier",
"accumulated_expanded_nodes",
"warm_start",
"refine_paths",
"refiner",
"path_state",
)
def __init__(
self,
context: AStarContext,
metrics: AStarMetrics | None = None,
max_iterations: int = 10,
base_congestion_penalty: float = 100.0,
congestion_multiplier: float = 1.5,
use_tiered_strategy: bool = True,
warm_start: Literal["shortest", "longest", "user"] | None = "shortest",
refine_paths: bool = True,
) -> None:
self.context = context
self.metrics = metrics if metrics is not None else AStarMetrics()
self.max_iterations = max_iterations
self.base_congestion_penalty = base_congestion_penalty
self.congestion_multiplier = congestion_multiplier
self.use_tiered_strategy = use_tiered_strategy
self.warm_start = warm_start
self.refine_paths = refine_paths
self.refiner = PathRefiner(context)
self.path_state = PathStateManager(context.cost_evaluator.collision_engine)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
@property
def cost_evaluator(self) -> CostEvaluator:
return self.context.cost_evaluator
def _build_greedy_warm_start_paths(
self,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
order: Literal["shortest", "longest", "user"],
) -> dict[str, list[ComponentResult]]:
all_net_ids = list(netlist.keys())
if order != "user":
all_net_ids.sort(
key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y),
reverse=(order == "longest"),
)
greedy_paths: dict[str, list[ComponentResult]] = {}
temp_obj_ids: list[int] = []
greedy_node_limit = min(self.context.config.node_limit, 2000)
for net_id in all_net_ids:
start, target = netlist[net_id]
width = net_widths.get(net_id, 2.0)
h_start = self.cost_evaluator.h_manhattan(start, target)
max_cost_limit = max(h_start * 3.0, 2000.0)
path = route_astar(
start,
target,
width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
skip_congestion=True,
max_cost=max_cost_limit,
self_collision_check=True,
node_limit=greedy_node_limit,
)
if not path:
continue
greedy_paths[net_id] = path
temp_obj_ids.extend(self.path_state.stage_path_as_static(path))
self.context.clear_static_caches()
self.path_state.remove_static_obstacles(temp_obj_ids)
return greedy_paths
def _path_cost(self, path: list[ComponentResult]) -> float:
return self.refiner.path_cost(path)
def _install_path(self, net_id: str, path: list[ComponentResult]) -> None:
self.path_state.install_path(net_id, path)
def _build_routing_result(
self,
*,
net_id: str,
path: list[ComponentResult],
reached_target: bool,
collisions: int,
outcome: RoutingOutcome | None = None,
) -> RoutingResult:
resolved_outcome = (
infer_routing_outcome(
has_path=bool(path),
reached_target=reached_target,
collision_count=collisions,
)
if outcome is None
else outcome
)
return RoutingResult(
net_id=net_id,
path=path,
is_valid=(resolved_outcome == "completed"),
collisions=collisions,
reached_target=reached_target,
outcome=resolved_outcome,
)
def _refine_path(
self,
net_id: str,
start: Port,
target: Port,
net_width: float,
path: list[ComponentResult],
) -> list[ComponentResult]:
return self.refiner.refine_path(net_id, start, target, net_width, path)
def _route_net_once(
self,
net_id: str,
start: Port,
target: Port,
width: float,
iteration: int,
initial_paths: dict[str, list[ComponentResult]] | None,
store_expanded: bool,
needs_self_collision_check: set[str],
) -> tuple[RoutingResult, RoutingOutcome]:
self.path_state.remove_path(net_id)
path: list[ComponentResult] | None = None
if iteration == 0 and initial_paths and net_id in initial_paths:
path = initial_paths[net_id]
else:
target_coll_model = self.context.config.bend_collision_type
coll_model = target_coll_model
skip_cong = False
if self.use_tiered_strategy and iteration == 0:
skip_cong = True
if target_coll_model == "arc":
coll_model = "clipped_bbox"
path = route_astar(
start,
target,
width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
bend_collision_type=coll_model,
return_partial=True,
store_expanded=store_expanded,
skip_congestion=skip_cong,
self_collision_check=(net_id in needs_self_collision_check),
node_limit=self.context.config.node_limit,
)
if store_expanded and self.metrics.last_expanded_nodes:
self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
if not path:
outcome = infer_routing_outcome(has_path=False, reached_target=False, collision_count=0)
return self._build_routing_result(net_id=net_id, path=[], reached_target=False, collisions=0, outcome=outcome), outcome
last_p = path[-1].end_port
reached = last_p == target
collision_count = 0
self._install_path(net_id, path)
if reached:
report = self.path_state.verify_path_report(net_id, path)
collision_count = report.collision_count
if report.self_collision_count > 0:
needs_self_collision_check.add(net_id)
outcome = infer_routing_outcome(
has_path=bool(path),
reached_target=reached,
collision_count=collision_count,
)
return (
self._build_routing_result(
net_id=net_id,
path=path,
reached_target=reached,
collisions=collision_count,
outcome=outcome,
),
outcome,
)
def route_all(
self,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
store_expanded: bool = False,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
shuffle_nets: bool = False,
sort_nets: Literal["shortest", "longest", "user", None] = None,
initial_paths: dict[str, list[ComponentResult]] | None = None,
seed: int | None = None,
) -> dict[str, RoutingResult]:
self.cost_evaluator.congestion_penalty = self.base_congestion_penalty
self.accumulated_expanded_nodes = []
self.metrics.reset_per_route()
state = create_routing_session_state(
self,
netlist,
net_widths,
store_expanded=store_expanded,
iteration_callback=iteration_callback,
shuffle_nets=shuffle_nets,
sort_nets=sort_nets,
initial_paths=initial_paths,
seed=seed,
)
prepare_routing_session_state(self, state)
for iteration in range(self.max_iterations):
iteration_outcomes = run_routing_iteration(self, state, iteration)
if iteration_outcomes is None:
return self.verify_all_nets(state.results, state.netlist)
if not any(routing_outcome_needs_retry(outcome) for outcome in iteration_outcomes.values()):
break
self.cost_evaluator.congestion_penalty *= self.congestion_multiplier
refine_routing_session_results(self, state)
return finalize_routing_session_results(self, state)
def verify_all_nets(
self,
results: dict[str, RoutingResult],
netlist: dict[str, tuple[Port, Port]],
) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {}
for net_id, (_, target_p) in netlist.items():
res = results.get(net_id)
if not res or not res.path:
final_results[net_id] = self._build_routing_result(
net_id=net_id,
path=[],
reached_target=False,
collisions=0,
)
continue
last_p = res.path[-1].end_port
reached = last_p == target_p
report = self.path_state.verify_path_report(net_id, res.path)
final_results[net_id] = RoutingResult(
net_id=net_id,
path=res.path,
is_valid=(reached and report.is_valid),
collisions=report.collision_count,
reached_target=reached,
outcome=infer_routing_outcome(
has_path=True,
reached_target=reached,
collision_count=report.collision_count,
),
)
return final_results

View file

@ -7,10 +7,12 @@ from inire.geometry.component_overlap import components_overlap, has_self_overla
from inire.geometry.components import Bend90, Straight
if TYPE_CHECKING:
from inire.geometry.collision import CollisionEngine
from collections.abc import Sequence
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router._search import AStarContext
def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool:
current = parent_node
@ -22,7 +24,7 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any)
return False
def has_self_collision(path: list[ComponentResult]) -> bool:
def has_self_collision(path: Sequence[ComponentResult]) -> bool:
return has_self_overlap(path)
@ -33,26 +35,26 @@ class PathRefiner:
self.context = context
@property
def collision_engine(self) -> CollisionEngine:
def collision_engine(self) -> RoutingWorld:
return self.context.cost_evaluator.collision_engine
def path_cost(self, path: list[ComponentResult]) -> float:
total = 0.0
bend_penalty = self.context.config.bend_penalty
sbend_penalty = self.context.config.sbend_penalty
for comp in path:
total += comp.length
if comp.move_type == "Bend90":
radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0
if radius > 0:
total += bend_penalty * (10.0 / radius) ** 0.5
else:
total += bend_penalty
elif comp.move_type == "SBend":
total += sbend_penalty
return total
def path_cost(
self,
path: Sequence[ComponentResult],
*,
net_id: str = "default",
start: Port | None = None,
) -> float:
if not path:
return 0.0
actual_start = path[0].start_port if start is None else start
return self.score_path(net_id, actual_start, path)
def _path_ports(self, start: Port, path: list[ComponentResult]) -> list[Port]:
def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float:
weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options)
return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights)
def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]:
ports = [start]
ports.extend(comp.end_port for comp in path)
return ports
@ -79,7 +81,7 @@ class PathRefiner:
return -dx, -dy
return -dy, dx
def _window_query_bounds(self, start: Port, target: Port, path: list[ComponentResult], pad: float) -> tuple[float, float, float, float]:
def _window_query_bounds(self, start: Port, target: Port, path: Sequence[ComponentResult], pad: float) -> tuple[float, float, float, float]:
min_x = float(min(start.x, target.x))
min_y = float(min(start.y, target.y))
max_x = float(max(start.x, target.x))
@ -96,7 +98,7 @@ class PathRefiner:
self,
start: Port,
target: Port,
window_path: list[ComponentResult],
window_path: Sequence[ComponentResult],
net_width: float,
radius: float,
) -> list[float]:
@ -187,7 +189,7 @@ class PathRefiner:
second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent)
if first_straight < -0.01 or second_straight < -0.01:
return None
min_straight = self.context.config.min_straight_length
min_straight = self.context.options.search.min_straight_length
if 0.01 < first_straight < min_straight - 0.01:
return None
if 0.01 < second_straight < min_straight - 0.01:
@ -226,16 +228,16 @@ class PathRefiner:
return None
return path
def _iter_refinement_windows(self, start: Port, path: list[ComponentResult]) -> list[tuple[int, int]]:
def _iter_refinement_windows(self, start: Port, path: Sequence[ComponentResult]) -> list[tuple[int, int]]:
ports = self._path_ports(start, path)
windows: list[tuple[int, int]] = []
min_radius = min(self.context.config.bend_radii, default=0.0)
min_radius = min(self.context.options.search.bend_radii, default=0.0)
for window_size in range(len(path), 0, -1):
for start_idx in range(len(path) - window_size + 1):
end_idx = start_idx + window_size
window = path[start_idx:end_idx]
bend_count = sum(1 for comp in window if comp.move_type == "Bend90")
bend_count = sum(1 for comp in window if comp.move_type == "bend90")
if bend_count < 4:
continue
window_start = ports[start_idx]
@ -266,7 +268,7 @@ class PathRefiner:
best_path: list[ComponentResult] | None = None
best_candidate_cost = best_cost
for radius in self.context.config.bend_radii:
for radius in self.context.options.search.bend_radii:
side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius)
for side_extent in side_extents:
replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent)
@ -297,12 +299,14 @@ class PathRefiner:
if not path:
return path
bend_count = sum(1 for comp in path if comp.move_type == "Bend90")
path = list(path)
bend_count = sum(1 for comp in path if comp.move_type == "bend90")
if bend_count < 4:
return path
best_path = path
best_cost = self.path_cost(path)
best_cost = self.score_path(net_id, start, path)
for _ in range(3):
improved = False

68
inire/router/results.py Normal file
View file

@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from inire.router.outcomes import RoutingOutcome, infer_routing_outcome
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
from inire.model import LockedRoute
@dataclass(frozen=True, slots=True)
class RoutingReport:
static_collision_count: int = 0
dynamic_collision_count: int = 0
self_collision_count: int = 0
total_length: float = 0.0
@property
def collision_count(self) -> int:
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
@property
def is_valid(self) -> bool:
return self.collision_count == 0
@dataclass(frozen=True, slots=True)
class RouteMetrics:
nodes_expanded: int
moves_generated: int
moves_added: int
pruned_closed_set: int
pruned_hard_collision: int
pruned_cost: int
@dataclass(frozen=True, slots=True)
class RoutingResult:
net_id: str
path: tuple[ComponentResult, ...]
reached_target: bool = False
report: RoutingReport = field(default_factory=RoutingReport)
def __post_init__(self) -> None:
object.__setattr__(self, "path", tuple(self.path))
@property
def collisions(self) -> int:
return self.report.collision_count
@property
def outcome(self) -> RoutingOutcome:
return infer_routing_outcome(
has_path=bool(self.path),
reached_target=self.reached_target,
collision_count=self.report.collision_count,
)
@property
def is_valid(self) -> bool:
return self.outcome == "completed"
def as_locked_route(self) -> LockedRoute:
from inire.model import LockedRoute
return LockedRoute.from_path(self.path)

View file

@ -1,151 +0,0 @@
from __future__ import annotations
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry
if TYPE_CHECKING:
from collections.abc import Callable
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
from inire.router.pathfinder import PathFinder, RoutingResult
@dataclass
class RoutingSessionState:
netlist: dict[str, tuple[Port, Port]]
net_widths: dict[str, float]
results: dict[str, RoutingResult]
all_net_ids: list[str]
needs_self_collision_check: set[str]
start_time: float
session_timeout: float
initial_paths: dict[str, list[ComponentResult]] | None
store_expanded: bool
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None
shuffle_nets: bool
sort_nets: Literal["shortest", "longest", "user", None]
seed: int | None
def create_routing_session_state(
finder: PathFinder,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
*,
store_expanded: bool,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
shuffle_nets: bool,
sort_nets: Literal["shortest", "longest", "user", None],
initial_paths: dict[str, list[ComponentResult]] | None,
seed: int | None,
) -> RoutingSessionState:
num_nets = len(netlist)
return RoutingSessionState(
netlist=netlist,
net_widths=net_widths,
results={},
all_net_ids=list(netlist.keys()),
needs_self_collision_check=set(),
start_time=time.monotonic(),
session_timeout=max(60.0, 10.0 * num_nets * finder.max_iterations),
initial_paths=initial_paths,
store_expanded=store_expanded,
iteration_callback=iteration_callback,
shuffle_nets=shuffle_nets,
sort_nets=sort_nets,
seed=seed,
)
def prepare_routing_session_state(
finder: PathFinder,
state: RoutingSessionState,
) -> None:
if state.initial_paths is None:
warm_start_order = state.sort_nets if state.sort_nets is not None else finder.warm_start
if warm_start_order is not None:
state.initial_paths = finder._build_greedy_warm_start_paths(state.netlist, state.net_widths, warm_start_order)
finder.context.clear_static_caches()
if state.sort_nets and state.sort_nets != "user":
state.all_net_ids.sort(
key=lambda net_id: abs(state.netlist[net_id][1].x - state.netlist[net_id][0].x)
+ abs(state.netlist[net_id][1].y - state.netlist[net_id][0].y),
reverse=(state.sort_nets == "longest"),
)
def run_routing_iteration(
finder: PathFinder,
state: RoutingSessionState,
iteration: int,
) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {}
finder.accumulated_expanded_nodes = []
finder.metrics.reset_per_route()
if state.shuffle_nets and (iteration > 0 or state.initial_paths is None):
iteration_seed = (state.seed + iteration) if state.seed is not None else None
random.Random(iteration_seed).shuffle(state.all_net_ids)
for net_id in state.all_net_ids:
start, target = state.netlist[net_id]
if time.monotonic() - state.start_time > state.session_timeout:
finder.path_state.finalize_dynamic_tree()
return None
width = state.net_widths.get(net_id, 2.0)
result, net_congestion = finder._route_net_once(
net_id,
start,
target,
width,
iteration,
state.initial_paths,
state.store_expanded,
state.needs_self_collision_check,
)
state.results[net_id] = result
outcomes[net_id] = net_congestion
if state.iteration_callback:
state.iteration_callback(iteration, state.results)
return outcomes
def refine_routing_session_results(
finder: PathFinder,
state: RoutingSessionState,
) -> None:
if not finder.refine_paths or not state.results:
return
for net_id in state.all_net_ids:
res = state.results.get(net_id)
if not res or not res.path or routing_outcome_needs_retry(res.outcome):
continue
start, target = state.netlist[net_id]
width = state.net_widths.get(net_id, 2.0)
finder.path_state.remove_path(net_id)
refined_path = finder._refine_path(net_id, start, target, width, res.path)
finder._install_path(net_id, refined_path)
report = finder.path_state.verify_path_report(net_id, refined_path)
state.results[net_id] = finder._build_routing_result(
net_id=net_id,
path=refined_path,
reached_target=res.reached_target,
collisions=report.collision_count,
)
def finalize_routing_session_results(
finder: PathFinder,
state: RoutingSessionState,
) -> dict[str, RoutingResult]:
finder.path_state.finalize_dynamic_tree()
return finder.verify_all_nets(state.results, state.netlist)

View file

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
import rtree
if TYPE_CHECKING:
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.primitives import Port
@ -18,7 +18,7 @@ class VisibilityManager:
"""
__slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version")
def __init__(self, collision_engine: CollisionEngine) -> None:
def __init__(self, collision_engine: RoutingWorld) -> None:
self.collision_engine = collision_engine
self.corners: list[tuple[float, float]] = []
self.corner_index = rtree.index.Index()
@ -153,10 +153,3 @@ class VisibilityManager:
if corner_idx is not None and corner_idx in self._corner_graph:
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
return []
def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
"""
Backward-compatible alias for arbitrary-point visibility queries.
Prefer `get_corner_visibility()` in routing code and `get_point_visibility()` elsewhere.
"""
return self.get_point_visibility(origin, max_dist=max_dist)

View file

@ -1,10 +1,13 @@
import time
from inire import NetSpec
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarContext, AStarMetrics
from inire.router.pathfinder import PathFinder
from inire.router._astar_types import AStarMetrics
from inire.router._router import PathFinder
from inire.tests.support import build_context
def benchmark_scaling() -> None:
print("Starting Scalability Benchmark...")
@ -20,25 +23,33 @@ def benchmark_scaling() -> None:
assert mem_gb < 2.0
# 2. Node Expansion Rate (50 nets)
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# Use a smaller area for routing benchmark to keep it fast
routing_bounds = (0, 0, 1000, 1000)
danger_map = DangerMap(bounds=routing_bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
context = AStarContext(evaluator)
metrics = AStarMetrics()
pf = PathFinder(context, metrics)
num_nets = 50
netlist = {}
for i in range(num_nets):
# Parallel nets spaced by 10um
netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0))
metrics = AStarMetrics()
pf = PathFinder(
build_context(
evaluator,
bounds=routing_bounds,
nets=(
NetSpec(net_id=net_id, start=start, target=target, width=2.0)
for net_id, (start, target) in netlist.items()
),
),
metrics=metrics,
)
print(f"Routing {num_nets} nets...")
start_time = time.monotonic()
results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0))
results = pf.route_all()
end_time = time.monotonic()
total_time = end_time - start_time

View file

@ -6,12 +6,13 @@ from typing import Callable
from shapely.geometry import Polygon, box
from inire.geometry.collision import CollisionEngine
from inire import NetSpec, RoutingResult
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, AStarMetrics
from inire.router._astar_types import AStarMetrics
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder, RoutingResult
from inire.tests.support import build_context, build_pathfinder
@dataclass(frozen=True)
@ -28,30 +29,6 @@ class ScenarioDefinition:
run: Callable[[], ScenarioOutcome]
def _build_router(
*,
bounds: tuple[float, float, float, float],
clearance: float = 2.0,
obstacles: list[Polygon] | None = None,
evaluator_kwargs: dict[str, float] | None = None,
context_kwargs: dict[str, object] | None = None,
pathfinder_kwargs: dict[str, object] | None = None,
) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]:
static_obstacles = obstacles or []
engine = CollisionEngine(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(static_obstacles)
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
context = AStarContext(evaluator, **(context_kwargs or {}))
metrics = AStarMetrics()
pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {}))
return engine, evaluator, context, metrics, pathfinder
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
return ScenarioOutcome(
duration_s=duration_s,
@ -70,7 +47,7 @@ def _build_evaluator(
sbend_penalty: float = 150.0,
) -> CostEvaluator:
static_obstacles = obstacles or []
engine = CollisionEngine(clearance=clearance)
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
@ -79,92 +56,155 @@ def _build_evaluator(
return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty)
def _net_specs(
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
) -> tuple[NetSpec, ...]:
return tuple(
NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
def _build_routing_stack(
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
clearance: float = 2.0,
obstacles: list[Polygon] | None = None,
evaluator_kwargs: dict[str, float] | None = None,
request_kwargs: dict[str, object] | None = None,
) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]:
static_obstacles = obstacles or []
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(static_obstacles)
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
metrics = AStarMetrics()
pathfinder = build_pathfinder(
evaluator,
bounds=bounds,
nets=_net_specs(netlist, widths),
metrics=metrics,
**(request_kwargs or {}),
)
return engine, evaluator, metrics, pathfinder
def run_example_01() -> ScenarioOutcome:
_, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]})
netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))}
widths = {"net1": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
request_kwargs={"bend_radii": [10.0]},
)
t0 = perf_counter()
results = pathfinder.route_all(netlist, {"net1": 2.0})
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
def run_example_02() -> ScenarioOutcome:
_, _, _, _, pathfinder = _build_router(
bounds=(0, 0, 100, 100),
evaluator_kwargs={
"greedy_h_weight": 1.5,
"bend_penalty": 50.0,
"sbend_penalty": 150.0,
},
context_kwargs={
"bend_radii": [10.0],
"sbend_radii": [10.0],
},
pathfinder_kwargs={"base_congestion_penalty": 1000.0},
)
netlist = {
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
}
widths = {net_id: 2.0 for net_id in netlist}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"bend_penalty": 50.0,
"sbend_penalty": 150.0,
},
request_kwargs={
"bend_radii": [10.0],
"sbend_radii": [10.0],
"base_penalty": 1000.0,
},
)
t0 = perf_counter()
results = pathfinder.route_all(netlist, widths)
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
def run_example_03() -> ScenarioOutcome:
engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]})
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
widths_a = {"netA": 2.0}
engine, evaluator, _, pathfinder = _build_routing_stack(
bounds=(0, -50, 100, 50),
netlist=netlist_a,
widths=widths_a,
request_kwargs={"bend_radii": [10.0]},
)
t0 = perf_counter()
results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
engine.lock_net("netA")
results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})
results_a = pathfinder.route_all()
for polygon in results_a["netA"].as_locked_route().geometry:
engine.add_static_obstacle(polygon)
results_b = build_pathfinder(
evaluator,
bounds=(0, -50, 100, 50),
nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}),
bend_radii=[10.0],
).route_all()
t1 = perf_counter()
return _summarize({**results_a, **results_b}, t1 - t0)
def run_example_04() -> ScenarioOutcome:
_, _, _, _, pathfinder = _build_router(
bounds=(0, 0, 100, 100),
evaluator_kwargs={
"unit_length_cost": 1.0,
"bend_penalty": 10.0,
"sbend_penalty": 20.0,
},
context_kwargs={
"node_limit": 50000,
"bend_radii": [10.0, 30.0],
"sbend_offsets": [5.0],
"bend_penalty": 10.0,
"sbend_penalty": 20.0,
},
)
netlist = {
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
}
widths = {"sbend_only": 2.0, "multi_radii": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
evaluator_kwargs={
"unit_length_cost": 1.0,
"bend_penalty": 10.0,
"sbend_penalty": 20.0,
},
request_kwargs={
"node_limit": 50000,
"bend_radii": [10.0, 30.0],
"sbend_offsets": [5.0],
},
)
t0 = perf_counter()
results = pathfinder.route_all(netlist, widths)
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
def run_example_05() -> ScenarioOutcome:
_, _, _, _, pathfinder = _build_router(
bounds=(0, 0, 200, 200),
evaluator_kwargs={"bend_penalty": 50.0},
context_kwargs={"bend_radii": [20.0]},
)
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
widths = {net_id: 2.0 for net_id in netlist}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 200, 200),
netlist=netlist,
widths=widths,
evaluator_kwargs={"bend_penalty": 50.0},
request_kwargs={"bend_radii": [20.0]},
)
t0 = perf_counter()
results = pathfinder.route_all(netlist, widths)
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
@ -178,32 +218,35 @@ def run_example_06() -> ScenarioOutcome:
]
scenarios = [
(
AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="arc"),
_build_evaluator(bounds, obstacles=obstacles),
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
{"arc_model": 2.0},
{"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False},
),
(
AStarContext(_build_evaluator(bounds, obstacles=obstacles), bend_radii=[10.0], bend_collision_type="bbox"),
_build_evaluator(bounds, obstacles=obstacles),
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
{"bbox_model": 2.0},
{"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False},
),
(
AStarContext(
_build_evaluator(bounds, obstacles=obstacles),
bend_radii=[10.0],
bend_collision_type="clipped_bbox",
bend_clip_margin=1.0,
),
_build_evaluator(bounds, obstacles=obstacles),
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
{"clipped_model": 2.0},
{"bend_radii": [10.0], "bend_collision_type": "clipped_bbox", "use_tiered_strategy": False},
),
]
t0 = perf_counter()
combined_results: dict[str, RoutingResult] = {}
for context, netlist, net_widths in scenarios:
pathfinder = PathFinder(context, use_tiered_strategy=False)
combined_results.update(pathfinder.route_all(netlist, net_widths))
for evaluator, netlist, net_widths, request_kwargs in scenarios:
pathfinder = build_pathfinder(
evaluator,
bounds=bounds,
nets=_net_specs(netlist, net_widths),
**request_kwargs,
)
combined_results.update(pathfinder.route_all())
t1 = perf_counter()
return _summarize(combined_results, t1 - t0)
@ -214,29 +257,6 @@ def run_example_07() -> ScenarioOutcome:
box(450, 0, 550, 400),
box(450, 600, 550, 1000),
]
_, evaluator, _, metrics, pathfinder = _build_router(
bounds=bounds,
clearance=6.0,
obstacles=obstacles,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"unit_length_cost": 0.1,
"bend_penalty": 100.0,
"sbend_penalty": 400.0,
"congestion_penalty": 100.0,
},
context_kwargs={
"node_limit": 2000000,
"bend_radii": [50.0],
"sbend_radii": [50.0],
},
pathfinder_kwargs={
"max_iterations": 15,
"base_congestion_penalty": 100.0,
"congestion_multiplier": 1.4,
},
)
num_nets = 10
start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0
@ -249,6 +269,31 @@ def run_example_07() -> ScenarioOutcome:
sy = int(round(start_y_base + index * 10.0))
ey = int(round(end_y_base + index * end_y_pitch))
netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
widths = dict.fromkeys(netlist, 2.0)
_, evaluator, metrics, pathfinder = _build_routing_stack(
bounds=bounds,
netlist=netlist,
widths=widths,
clearance=6.0,
obstacles=obstacles,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"unit_length_cost": 0.1,
"bend_penalty": 100.0,
"sbend_penalty": 400.0,
},
request_kwargs={
"node_limit": 2000000,
"bend_radii": [50.0],
"sbend_radii": [50.0],
"max_iterations": 15,
"base_penalty": 100.0,
"multiplier": 1.4,
"capture_expanded": True,
"shuffle_nets": True,
"seed": 42,
},
)
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
@ -256,14 +301,7 @@ def run_example_07() -> ScenarioOutcome:
metrics.reset_per_route()
t0 = perf_counter()
results = pathfinder.route_all(
netlist,
dict.fromkeys(netlist, 2.0),
store_expanded=True,
iteration_callback=iteration_callback,
shuffle_nets=True,
seed=42,
)
results = pathfinder.route_all(iteration_callback=iteration_callback)
t1 = perf_counter()
return _summarize(results, t1 - t0)
@ -272,21 +310,30 @@ def run_example_08() -> ScenarioOutcome:
bounds = (0, 0, 150, 150)
netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
widths = {"custom_bend": 2.0}
context_std = AStarContext(_build_evaluator(bounds), bend_radii=[10.0], sbend_radii=[])
context_custom = AStarContext(
_build_evaluator(bounds),
bend_radii=[10.0],
bend_collision_type=Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)]),
sbend_radii=[],
)
custom_model = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
standard_evaluator = _build_evaluator(bounds)
custom_evaluator = _build_evaluator(bounds)
t0 = perf_counter()
results_std = PathFinder(context_std, AStarMetrics(), use_tiered_strategy=False).route_all(netlist, widths)
results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all(
{"custom_model": netlist["custom_bend"]},
{"custom_model": 2.0},
)
results_std = build_pathfinder(
standard_evaluator,
bounds=bounds,
nets=_net_specs(netlist, widths),
bend_radii=[10.0],
sbend_radii=[],
use_tiered_strategy=False,
metrics=AStarMetrics(),
).route_all()
results_custom = build_pathfinder(
custom_evaluator,
bounds=bounds,
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
bend_radii=[10.0],
bend_collision_type=custom_model,
sbend_radii=[],
use_tiered_strategy=False,
metrics=AStarMetrics(),
).route_all()
t1 = perf_counter()
return _summarize({**results_std, **results_custom}, t1 - t0)
@ -296,16 +343,18 @@ def run_example_09() -> ScenarioOutcome:
box(35, 35, 45, 65),
box(55, 35, 65, 65),
]
_, _, _, _, pathfinder = _build_router(
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
widths = {"budget_limited_net": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
obstacles=obstacles,
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
context_kwargs={"node_limit": 3, "bend_radii": [10.0]},
pathfinder_kwargs={"warm_start": None},
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None},
)
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
t0 = perf_counter()
results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0})
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
@ -313,7 +362,7 @@ def run_example_09() -> ScenarioOutcome:
SCENARIOS: tuple[ScenarioDefinition, ...] = (
ScenarioDefinition("example_01_simple_route", run_example_01),
ScenarioDefinition("example_02_congestion_resolution", run_example_02),
ScenarioDefinition("example_03_locked_paths", run_example_03),
ScenarioDefinition("example_03_locked_routes", run_example_03),
ScenarioDefinition("example_04_sbends_and_radii", run_example_04),
ScenarioDefinition("example_05_orientation_stress", run_example_05),
ScenarioDefinition("example_06_bend_collision_models", run_example_06),

162
inire/tests/support.py Normal file
View file

@ -0,0 +1,162 @@
from __future__ import annotations
from collections.abc import Iterable
from inire.model import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
SearchOptions,
)
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
def build_problem(
*,
bounds: tuple[float, float, float, float],
nets: Iterable[NetSpec] = (),
**overrides: object,
) -> RoutingProblem:
return RoutingProblem(
bounds=bounds,
nets=tuple(nets),
**overrides,
)
def build_request(
*,
bounds: tuple[float, float, float, float],
nets: Iterable[NetSpec] = (),
**overrides: object,
) -> RoutingProblem:
return build_problem(bounds=bounds, nets=nets, **overrides)
def build_options(
*,
objective: ObjectiveWeights | None = None,
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
refinement: RefinementOptions | None = None,
diagnostics: DiagnosticsOptions | None = None,
**overrides: object,
) -> RoutingOptions:
if objective is None:
objective = ObjectiveWeights()
if search is None:
search = SearchOptions()
if congestion is None:
congestion = CongestionOptions()
if refinement is None:
refinement = RefinementOptions()
if diagnostics is None:
diagnostics = DiagnosticsOptions()
search_fields = set(SearchOptions.__dataclass_fields__)
congestion_fields = set(CongestionOptions.__dataclass_fields__)
refinement_fields = set(RefinementOptions.__dataclass_fields__)
diagnostics_fields = set(DiagnosticsOptions.__dataclass_fields__)
objective_fields = set(ObjectiveWeights.__dataclass_fields__)
search_overrides = {key: value for key, value in overrides.items() if key in search_fields}
congestion_overrides = {key: value for key, value in overrides.items() if key in congestion_fields}
refinement_overrides = {key: value for key, value in overrides.items() if key in refinement_fields}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in diagnostics_fields}
objective_overrides = {key: value for key, value in overrides.items() if key in objective_fields}
unknown = set(overrides) - search_fields - congestion_fields - refinement_fields - diagnostics_fields - objective_fields
if unknown:
unknown_names = ", ".join(sorted(unknown))
raise TypeError(f"Unsupported RoutingOptions overrides: {unknown_names}")
resolved_objective = objective if not objective_overrides else ObjectiveWeights(
**{
field: getattr(objective, field)
for field in objective_fields
}
| objective_overrides
)
resolved_search = search if not search_overrides else SearchOptions(
**{
field: getattr(search, field)
for field in search_fields
}
| search_overrides
)
resolved_congestion = congestion if not congestion_overrides else CongestionOptions(
**{
field: getattr(congestion, field)
for field in congestion_fields
}
| congestion_overrides
)
resolved_refinement = refinement if not refinement_overrides else RefinementOptions(
**{
field: getattr(refinement, field)
for field in refinement_fields
}
| refinement_overrides
)
resolved_diagnostics = diagnostics if not diagnostics_overrides else DiagnosticsOptions(
**{
field: getattr(diagnostics, field)
for field in diagnostics_fields
}
| diagnostics_overrides
)
return RoutingOptions(
search=resolved_search,
objective=resolved_objective,
congestion=resolved_congestion,
refinement=resolved_refinement,
diagnostics=resolved_diagnostics,
)
def build_context(
evaluator,
*,
bounds: tuple[float, float, float, float],
nets: Iterable[NetSpec] = (),
problem: RoutingProblem | None = None,
options: RoutingOptions | None = None,
**overrides: object,
) -> AStarContext:
resolved_problem = problem if problem is not None else build_problem(bounds=bounds, nets=nets)
resolved_options = options if options is not None else build_options(**overrides)
return AStarContext(
evaluator,
resolved_problem,
resolved_options,
)
def build_pathfinder(
evaluator,
*,
bounds: tuple[float, float, float, float],
nets: Iterable[NetSpec] = (),
netlist: dict[str, tuple[object, object]] | None = None,
net_widths: dict[str, float] | None = None,
problem: RoutingProblem | None = None,
options: RoutingOptions | None = None,
**overrides: object,
) -> PathFinder:
resolved_problem = problem
if resolved_problem is None:
resolved_nets = tuple(nets)
if netlist is not None:
widths = {} if net_widths is None else net_widths
resolved_nets = tuple(
NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
resolved_problem = build_problem(bounds=bounds, nets=resolved_nets)
resolved_options = options if options is not None else build_options(**overrides)
return PathFinder(build_context(evaluator, bounds=bounds, problem=resolved_problem, options=resolved_options))

108
inire/tests/test_api.py Normal file
View file

@ -0,0 +1,108 @@
from shapely.geometry import box
from inire import (
CongestionOptions,
DiagnosticsOptions,
LockedRoute,
NetSpec,
ObjectiveWeights,
Port,
RefinementOptions,
RoutingOptions,
RoutingProblem,
SearchOptions,
route,
)
from inire.geometry.components import Straight
def test_route_problem_smoke() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
run = route(problem)
assert set(run.results_by_net) == {"net1"}
assert run.results_by_net["net1"].is_valid
def test_route_problem_supports_configs_and_debug_data() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("net1", Port(10, 10, 0), Port(90, 90, 0), width=2.0),),
static_obstacles=(box(40, 0, 60, 70),),
)
options = RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
node_limit=50000,
greedy_h_weight=1.2,
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(warm_start=None),
refinement=RefinementOptions(enabled=True),
diagnostics=DiagnosticsOptions(capture_expanded=True),
)
run = route(problem, options=options)
assert run.results_by_net["net1"].reached_target
assert run.expanded_nodes
assert run.metrics.nodes_expanded > 0
def test_route_problem_locked_routes_become_static_obstacles() -> None:
locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),)
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),),
locked_routes={"locked": LockedRoute.from_path(locked)},
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start=None),
refinement=RefinementOptions(enabled=False),
)
run = route(problem, options=options)
result = run.results_by_net["crossing"]
assert not result.is_valid
def test_locked_routes_enable_incremental_requests_without_sessions() -> None:
problem_a = RoutingProblem(
bounds=(0, -50, 100, 50),
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
)
options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,)))
results_a = route(problem_a, options=options)
assert results_a.results_by_net["netA"].is_valid
problem_b = RoutingProblem(
bounds=(0, -50, 100, 50),
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()},
)
results_b = route(problem_b, options=options)
assert results_b.results_by_net["netB"].is_valid
def test_route_results_metrics_are_snapshots() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions()
run1 = route(problem, options=options)
first_metrics = run1.metrics
run2 = route(problem, options=options)
assert first_metrics == run1.metrics
assert run1.metrics is not run2.metrics
assert first_metrics.nodes_expanded > 0

View file

@ -1,34 +1,36 @@
import pytest
from shapely.geometry import Polygon
import inire.router.astar as astar_module
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.collision import CollisionEngine
from inire import RoutingResult
from inire.geometry.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, route_astar
from inire.router.config import CostConfig
from inire.router._astar_types import AStarContext
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import RoutingResult
from inire.tests.support import build_context, build_options, build_problem
from inire.utils.validation import validate_routing_result
BOUNDS = (0, -50, 150, 150)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 150, 150))
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([])
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
context = build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0)
target = Port(50, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
@ -38,14 +40,14 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator, bend_radii=[10.0])
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0])
start = Port(0, 0, 0)
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
target = Port(20, 20, 0)
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
@ -59,13 +61,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000)
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000)
start = Port(0, 0, 0)
target = Port(60, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
@ -74,13 +76,13 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
context = build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0)
target = Port(10.1, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=path, reached_target=True)
assert target.x == 10
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
@ -89,7 +91,7 @@ def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
def test_validate_routing_result_checks_expected_start() -> None:
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(
result,
@ -105,7 +107,7 @@ def test_validate_routing_result_checks_expected_start() -> None:
def test_validate_routing_result_uses_exact_component_geometry() -> None:
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0)
result = RoutingResult(net_id="test", path=[bend], is_valid=True, collisions=0)
result = RoutingResult(net_id="test", path=[bend], reached_target=True)
obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)])
validation = validate_routing_result(
@ -119,18 +121,19 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None:
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
def test_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0)
def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None:
basic_evaluator.bend_penalty = 120.0
basic_evaluator.sbend_penalty = 240.0
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0])
assert isinstance(basic_evaluator.config, CostConfig)
assert basic_evaluator.config is not context.config
assert basic_evaluator.config.bend_penalty == 120.0
assert basic_evaluator.config.sbend_penalty == 240.0
assert basic_evaluator.config.min_bend_radius == 5.0
assert basic_evaluator.bend_penalty == 120.0
assert basic_evaluator.sbend_penalty == 240.0
assert context.options.search.bend_radii == (5.0,)
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator, bend_radii=[10.0], bend_collision_type="arc")
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc")
route_astar(
Port(0, 0, 0),
@ -141,254 +144,92 @@ def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: C
return_partial=True,
)
assert context.config.bend_collision_type == "arc"
assert context.options.search.bend_collision_type == "arc"
def test_expand_moves_only_shortens_consecutive_straights(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0)
prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
current = astar_module.AStarNode(
prev_result.end_port,
g_cost=prev_result.length,
h_cost=0.0,
component_result=prev_result,
)
def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None:
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2)
start = Port(0, 0, 0)
target = Port(60, 0, 0)
emitted: list[tuple[str, tuple]] = []
partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True)
no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False)
def fake_process_move(*args, **kwargs) -> None:
emitted.append((args[9], args[10]))
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
astar_module.expand_moves(
current,
Port(80, 0, 0),
net_width=2.0,
net_id="test",
open_set=[],
closed_set={},
context=context,
metrics=astar_module.AStarMetrics(),
congestion_cache={},
)
straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"]
assert straight_lengths
assert all(length < prev_result.length for length in straight_lengths)
assert partial_path is not None
assert partial_path
assert partial_path[-1].end_port != target
assert no_partial_path is None
def test_expand_moves_does_not_chain_sbends(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0)
prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0)
current = astar_module.AStarNode(
prev_result.end_port,
g_cost=prev_result.length,
h_cost=0.0,
component_result=prev_result,
)
emitted: list[str] = []
def fake_process_move(*args, **kwargs) -> None:
emitted.append(args[9])
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
astar_module.expand_moves(
current,
Port(60, 10, 0),
net_width=2.0,
net_id="test",
open_set=[],
closed_set={},
context=context,
metrics=astar_module.AStarMetrics(),
congestion_cache={},
)
assert "SB" not in emitted
assert emitted
def test_add_node_rejects_self_collision_against_ancestor(
basic_evaluator: CostEvaluator,
) -> None:
context = AStarContext(basic_evaluator)
metrics = astar_module.AStarMetrics()
target = Port(100, 0, 0)
root = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
ancestor = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
ancestor_node = astar_module.AStarNode(
ancestor.end_port,
g_cost=ancestor.length,
h_cost=0.0,
parent=root,
component_result=ancestor,
)
parent_result = Straight.generate(Port(30, 0, 0), 10.0, width=2.0, dilation=1.0)
parent_node = astar_module.AStarNode(
parent_result.end_port,
g_cost=ancestor.length + parent_result.length,
h_cost=0.0,
parent=ancestor_node,
component_result=parent_result,
)
overlapping_move = Straight.generate(Port(5, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[astar_module.AStarNode] = []
astar_module.add_node(
parent_node,
overlapping_move,
target,
net_width=2.0,
net_id="test",
open_set=open_set,
closed_set={},
context=context,
metrics=metrics,
congestion_cache={},
move_type="S",
cache_key=("self_collision",),
self_collision_check=True,
)
assert not open_set
assert metrics.moves_added == 0
def test_expand_moves_adds_sbend_aligned_straight_stop_points(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
context = build_context(
basic_evaluator,
bounds=BOUNDS,
bend_radii=[10.0],
sbend_radii=[10.0],
sbend_offsets=[10.0],
max_straight_length=150.0,
)
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
start = Port(0, 0, 0)
target = Port(100, 10, 0)
emitted: list[tuple[str, tuple]] = []
path = route_astar(start, target, net_width=2.0, context=context)
def fake_process_move(*args, **kwargs) -> None:
emitted.append((args[9], args[10]))
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
astar_module.expand_moves(
current,
Port(100, 10, 0),
net_width=2.0,
net_id="test",
open_set=[],
closed_set={},
context=context,
metrics=astar_module.AStarMetrics(),
congestion_cache={},
assert path is not None
assert path[-1].end_port == target
assert sum(1 for component in path if component.move_type == "sbend") == 1
assert not any(
first.move_type == second.move_type == "sbend"
for first, second in zip(path, path[1:], strict=False)
)
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
sbend_span = astar_module._sbend_forward_span(10.0, 10.0)
assert sbend_span is not None
assert int(round(100.0 - sbend_span)) in straight_lengths
assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths
def test_expand_moves_adds_exact_corner_visibility_stop_points(
@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"])
def test_route_astar_supports_all_visibility_guidance_modes(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
visibility_guidance: str,
) -> None:
context = AStarContext(
basic_evaluator,
bend_radii=[10.0],
max_straight_length=150.0,
visibility_guidance="exact_corner",
)
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
monkeypatch.setattr(
astar_module.VisibilityManager,
"get_corner_visibility",
lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)],
)
emitted: list[tuple[str, tuple]] = []
def fake_process_move(*args, **kwargs) -> None:
emitted.append((args[9], args[10]))
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
astar_module.expand_moves(
current,
Port(120, 20, 0),
net_width=2.0,
net_id="test",
open_set=[],
closed_set={},
context=context,
metrics=astar_module.AStarMetrics(),
congestion_cache={},
)
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
assert 40 in straight_lengths
assert 75 in straight_lengths
def test_expand_moves_adds_tangent_corner_visibility_stop_points(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class DummyCornerIndex:
def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]:
return [0, 1]
context = AStarContext(
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = build_context(
basic_evaluator,
bounds=BOUNDS,
bend_radii=[10.0],
sbend_radii=[],
max_straight_length=150.0,
visibility_guidance="tangent_corner",
visibility_guidance=visibility_guidance,
)
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
start = Port(0, 0, 0)
target = Port(80, 50, 0)
monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None)
context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)]
context.visibility_manager.corner_index = DummyCornerIndex()
monkeypatch.setattr(
type(context.cost_evaluator.collision_engine),
"ray_cast",
lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist,
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(
basic_evaluator,
build_problem(bounds=BOUNDS),
build_options(
min_straight_length=1.0,
max_straight_length=100.0,
),
max_cache_size=2,
)
start = Port(0, 0, 0)
targets = [Port(length, 0, 0) for length in range(10, 70, 10)]
emitted: list[tuple[str, tuple]] = []
def fake_process_move(*args, **kwargs) -> None:
emitted.append((args[9], args[10]))
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
astar_module.expand_moves(
current,
Port(120, 20, 0),
net_width=2.0,
net_id="test",
open_set=[],
closed_set={},
context=context,
metrics=astar_module.AStarMetrics(),
congestion_cache={},
)
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
assert 40 in straight_lengths
assert 70 in straight_lengths
for target in targets:
path = route_astar(start, target, net_width=2.0, context=context)
assert path is not None
assert path[-1].end_port == target

View file

@ -1,13 +1,13 @@
import pytest
import numpy
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.components import Straight
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.astar import AStarContext
from inire.router.pathfinder import PathFinder, RoutingResult
from inire import RoutingResult
from inire.tests.support import build_pathfinder
def test_clearance_thresholds():
"""
@ -16,12 +16,12 @@ def test_clearance_thresholds():
"""
# Clearance = 2.0, Width = 2.0
# Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0
ce = CollisionEngine(clearance=2.0)
ce = RoutingWorld(clearance=2.0)
# Net 1: Centerline at y=0
p1 = Port(0, 0, 0)
res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0)
ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry)
ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry)
# Net 2: Parallel to Net 1
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
@ -47,12 +47,10 @@ def test_verify_all_nets_cases():
"""
Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases.
"""
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map)
context = AStarContext(cost_evaluator=evaluator)
pf = PathFinder(context, warm_start=None, max_iterations=1)
# Case 1: Parallel paths exactly at clearance (Should be VALID)
netlist_parallel_ok = {
@ -61,7 +59,14 @@ def test_verify_all_nets_cases():
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist_parallel_ok, net_widths)
results = build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_parallel_ok,
net_widths=net_widths,
warm_start=None,
max_iterations=1,
).route_all()
assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
assert results["net2"].is_valid
@ -74,7 +79,14 @@ def test_verify_all_nets_cases():
engine.remove_path("net1")
engine.remove_path("net2")
results_p = pf.route_all(netlist_parallel_fail, net_widths)
results_p = build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_parallel_fail,
net_widths=net_widths,
warm_start=None,
max_iterations=1,
).route_all()
# verify_all_nets should flag both as invalid because they cross-collide
assert not results_p["net3"].is_valid
assert not results_p["net4"].is_valid
@ -87,6 +99,13 @@ def test_verify_all_nets_cases():
engine.remove_path("net3")
engine.remove_path("net4")
results_c = pf.route_all(netlist_cross, net_widths)
results_c = build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_cross,
net_widths=net_widths,
warm_start=None,
max_iterations=1,
).route_all()
assert not results_c["net5"].is_valid
assert not results_c["net6"].is_valid

View file

@ -1,13 +1,13 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.components import Straight
def test_collision_detection() -> None:
# Clearance = 2um
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# 10x10 um obstacle at (10,10)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
@ -33,7 +33,7 @@ def test_collision_detection() -> None:
def test_safety_zone() -> None:
# Use zero clearance for this test to verify the 2nm port safety zone
# against the physical obstacle boundary.
engine = CollisionEngine(clearance=0.0)
engine = RoutingWorld(clearance=0.0)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
engine.add_static_obstacle(obstacle)
@ -50,7 +50,7 @@ def test_safety_zone() -> None:
def test_configurable_max_net_width() -> None:
# Large max_net_width (10.0) -> large pre-dilation (6.0)
engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
engine = RoutingWorld(clearance=2.0, max_net_width=10.0)
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
engine.add_static_obstacle(obstacle)
@ -65,7 +65,7 @@ def test_configurable_max_net_width() -> None:
def test_ray_cast_width_clearance() -> None:
# Clearance = 2.0um, Width = 2.0um.
# Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um.
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# Obstacle at x=10 to 20
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
@ -83,7 +83,7 @@ def test_ray_cast_width_clearance() -> None:
def test_check_move_static_clearance() -> None:
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
engine.add_static_obstacle(obstacle)
@ -103,3 +103,54 @@ def test_check_move_static_clearance() -> None:
start_exact = Port(7, 0, 90)
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0)
def test_verify_path_report_preserves_long_net_id() -> None:
engine = RoutingWorld(clearance=2.0)
net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789"
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
geoms = [poly for component in path for poly in component.collision_geometry]
dilated = [poly for component in path for poly in component.dilated_collision_geometry]
engine.add_path(net_id, geoms, dilated_geometry=dilated)
report = engine.verify_path_report(net_id, path)
assert report.dynamic_collision_count == 0
def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None:
engine = RoutingWorld(clearance=2.0)
shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_"
net_a = f"{shared_prefix}A"
net_b = f"{shared_prefix}B"
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
net_a,
[poly for component in path_a for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry],
)
engine.add_path(
net_b,
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
)
report = engine.verify_path_report(net_a, path_a)
assert report.dynamic_collision_count == 1
def test_remove_path_clears_dynamic_path() -> None:
engine = RoutingWorld(clearance=2.0)
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
geoms = [poly for component in path for poly in component.collision_geometry]
dilated = [poly for component in path for poly in component.dilated_collision_geometry]
engine.add_path("netA", geoms, dilated_geometry=dilated)
assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"}
engine.remove_path("netA")
assert list(engine.iter_dynamic_paths()) == []
assert len(engine._static_obstacles.geometries) == 0

View file

@ -1,11 +1,12 @@
import pytest
from dataclasses import FrozenInstanceError
from shapely.affinity import rotate as shapely_rotate
from shapely.affinity import scale as shapely_scale
from shapely.affinity import translate as shapely_translate
from shapely.geometry import Polygon
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port, rotate_port, translate_port
from inire.geometry.primitives import Port
def test_straight_generation() -> None:
@ -16,15 +17,16 @@ def test_straight_generation() -> None:
assert result.end_port.x == 10.0
assert result.end_port.y == 0.0
assert result.end_port.orientation == 0.0
assert len(result.geometry) == 1
assert result.end_port.r == 0.0
assert len(result.collision_geometry) == 1
# Bounds of the polygon
minx, miny, maxx, maxy = result.geometry[0].bounds
minx, miny, maxx, maxy = result.collision_geometry[0].bounds
assert minx == 0.0
assert maxx == 10.0
assert miny == -1.0
assert maxy == 1.0
assert isinstance(result.collision_geometry, tuple)
def test_bend90_generation() -> None:
@ -36,13 +38,13 @@ def test_bend90_generation() -> None:
result_cw = Bend90.generate(start, radius, width, direction="CW")
assert result_cw.end_port.x == 10.0
assert result_cw.end_port.y == -10.0
assert result_cw.end_port.orientation == 270.0
assert result_cw.end_port.r == 270.0
# CCW bend
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
assert result_ccw.end_port.x == 10.0
assert result_ccw.end_port.y == 10.0
assert result_ccw.end_port.orientation == 90.0
assert result_ccw.end_port.r == 90.0
def test_sbend_generation() -> None:
@ -53,8 +55,8 @@ def test_sbend_generation() -> None:
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.0
assert len(result.geometry) == 2 # Optimization: returns individual arcs
assert result.end_port.r == 0.0
assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs
# Verify failure for large offset
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
@ -70,7 +72,7 @@ def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() ->
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == -5.0
second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds
second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds
assert second_arc_maxy <= width / 2.0 + 1e-6
assert second_arc_miny < -width / 2.0
@ -84,21 +86,21 @@ def test_bend_collision_models() -> None:
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
# Arc CCW R=10 from (0,0,0) ends at (10,10,90).
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
minx, miny, maxx, maxy = res_bbox.geometry[0].bounds
minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds
assert minx <= 0.0 + 1e-6
assert maxx >= 10.0 - 1e-6
assert miny <= 0.0 + 1e-6
assert maxy >= 10.0 - 1e-6
# 2. Clipped BBox model
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0)
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox")
# Conservative 8-point approximation should still be tighter than the full bbox.
assert len(res_clipped.geometry[0].exterior.coords) - 1 == 8
assert res_clipped.geometry[0].area < res_bbox.geometry[0].area
assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8
assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area
# It should also conservatively contain the true arc.
res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc")
assert res_clipped.geometry[0].covers(res_arc.geometry[0])
assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0])
def test_custom_bend_collision_polygon_uses_local_transform() -> None:
@ -119,20 +121,18 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None:
expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False)
expected = shapely_translate(expected, center_xy[0], center_xy[1])
assert result.geometry[0].symmetric_difference(expected).area < 1e-6
assert result.actual_geometry is not None
assert result.actual_geometry[0].symmetric_difference(expected).area < 1e-6
assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6
assert result.physical_geometry[0].symmetric_difference(expected).area < 1e-6
def test_custom_bend_collision_polygon_becomes_actual_geometry() -> None:
def test_custom_bend_collision_polygon_keeps_collision_and_physical_geometry_aligned() -> None:
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0)
assert result.actual_geometry is not None
assert result.dilated_actual_geometry is not None
assert result.geometry[0].symmetric_difference(result.actual_geometry[0]).area < 1e-6
assert result.dilated_geometry is not None
assert result.dilated_geometry[0].symmetric_difference(result.dilated_actual_geometry[0]).area < 1e-6
assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area < 1e-6
assert result.dilated_collision_geometry is not None
assert result.dilated_physical_geometry is not None
assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area < 1e-6
def test_sbend_collision_models() -> None:
@ -143,11 +143,11 @@ def test_sbend_collision_models() -> None:
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
# Geometry should be a list of individual bbox polygons for each arc
assert len(res_bbox.geometry) == 2
assert len(res_bbox.collision_geometry) == 2
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
area_bbox = sum(p.area for p in res_bbox.geometry)
area_arc = sum(p.area for p in res_arc.geometry)
area_bbox = sum(p.area for p in res_bbox.collision_geometry)
area_arc = sum(p.area for p in res_arc.collision_geometry)
assert area_bbox > area_arc
@ -161,14 +161,14 @@ def test_sbend_continuity() -> None:
res = SBend.generate(start, offset, radius, width)
# Target orientation should be same as start
assert abs(res.end_port.orientation - 90.0) < 1e-6
assert abs(res.end_port.r - 90.0) < 1e-6
# For a port at 90 deg, +offset is a shift in -x direction
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
# Geometry should be a list of valid polygons
assert len(res.geometry) == 2
for p in res.geometry:
assert len(res.collision_geometry) == 2
for p in res.collision_geometry:
assert p.is_valid
@ -185,8 +185,8 @@ def test_arc_sagitta_precision() -> None:
# Number of segments should be significantly higher for fine
# Exterior points = (segments + 1) * 2
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
pts_fine = len(res_fine.geometry[0].exterior.coords)
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
assert pts_fine > pts_coarse * 2
@ -205,12 +205,19 @@ def test_component_transform_invariance() -> None:
angle = 90.0
# 1. Transform the generated geometry
p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle)
p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle)
# 2. Generate at transformed start
start_transformed = rotate_port(translate_port(start0, dx, dy), angle)
start_transformed = start0.translate(dx, dy).rotated(angle)
res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW")
assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6
assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6
assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6
assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6
def test_component_result_is_immutable_value_type() -> None:
result = Straight.generate(Port(0, 0, 0), 10.0, 2.0)
with pytest.raises(FrozenInstanceError):
result.length = 42.0

View file

@ -1,25 +1,28 @@
import pytest
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, route_astar
from inire.router._router import PathFinder
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.tests.support import build_context, build_pathfinder
BOUNDS = (0, -40, 100, 40)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# Wider bounds to allow going around (y from -40 to 40)
danger_map = DangerMap(bounds=(0, -40, 100, 40))
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([])
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0])
context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0])
# Start at (0,0), target at (50, 2) -> 2um lateral offset
# This matches one of our discretized SBend offsets.
start = Port(0, 0, 0)
@ -32,22 +35,27 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
for res in path:
# Check if the end port orientation is same as start
# and it's not a single straight (which would have y=0)
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1:
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1:
found_sbend = True
break
assert found_sbend
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator, bend_radii=[5.0, 10.0])
# Increase base penalty to force detour immediately
pf = PathFinder(context, max_iterations=10, base_congestion_penalty=1000.0)
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = build_pathfinder(
basic_evaluator,
bounds=BOUNDS,
netlist=netlist,
net_widths=net_widths,
bend_radii=[5.0, 10.0],
max_iterations=10,
base_penalty=1000.0,
)
# Force them into a narrow corridor that only fits ONE.
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall
@ -57,7 +65,7 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
basic_evaluator.danger_map.precompute([obs_top, obs_bottom])
results = pf.route_all(netlist, net_widths)
results = pf.route_all()
assert len(results) == 2
assert results["net1"].reached_target

View file

@ -1,12 +1,12 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
def test_cost_calculation() -> None:
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50))
danger_map.precompute([])
@ -61,7 +61,23 @@ def test_danger_map_kd_tree_and_cache() -> None:
# We can check if calling it again is fast or just verify it returns same result
cost_near_2 = dm.get_cost(100.5, 100.5)
assert cost_near_2 == cost_near
assert len(dm._cost_cache) == 2
# 4. Out of bounds
assert dm.get_cost(-1, -1) >= 1e12
def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None:
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
dm_a = DangerMap((0, 0, 100, 100))
dm_b = DangerMap((0, 0, 100, 100))
dm_a.precompute([obstacle])
dm_b.precompute([])
dm_a.get_cost(15.0, 15.0)
assert len(dm_a._cost_cache) == 1
assert len(dm_b._cost_cache) == 0
dm_a.precompute([])
assert len(dm_a._cost_cache) == 0

View file

@ -16,7 +16,7 @@ REGRESSION_FACTOR = 1.5
BASELINE_SECONDS = {
"example_01_simple_route": 0.0035,
"example_02_congestion_resolution": 0.2666,
"example_03_locked_paths": 0.2304,
"example_03_locked_routes": 0.2304,
"example_04_sbends_and_radii": 1.8734,
"example_05_orientation_stress": 0.5630,
"example_06_bend_collision_models": 5.2382,
@ -28,7 +28,7 @@ BASELINE_SECONDS = {
EXPECTED_OUTCOMES = {
"example_01_simple_route": {"total_results": 1, "valid_results": 1, "reached_targets": 1},
"example_02_congestion_resolution": {"total_results": 3, "valid_results": 3, "reached_targets": 3},
"example_03_locked_paths": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_03_locked_routes": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_04_sbends_and_radii": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3},
"example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3},

View file

@ -1,9 +1,8 @@
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarContext
from inire.router.pathfinder import PathFinder
from inire.router.danger_map import DangerMap
from inire.tests.support import build_pathfinder
def test_failed_net_visibility() -> None:
"""
@ -12,7 +11,7 @@ def test_failed_net_visibility() -> None:
for negotiated congestion.
"""
# 1. Setup
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
# Create a simple danger map (bounds 0-100)
# We don't strictly need obstacles in it for this test.
@ -32,20 +31,23 @@ def test_failed_net_visibility() -> None:
# With obstacle, direct jump fails. A* must search around.
# Limit=10 should be enough to fail to find a path around.
context = AStarContext(evaluator, node_limit=10)
# 3. Configure PathFinder
# max_iterations=1 because we only need to check the state after the first attempt.
pf = PathFinder(context, max_iterations=1, warm_start=None)
netlist = {
"net1": (Port(0, 0, 0), Port(100, 0, 0))
}
net_widths = {"net1": 1.0}
pf = build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist,
net_widths=net_widths,
node_limit=10,
max_iterations=1,
warm_start=None,
)
# 4. Route
print("\nStarting Route...")
results = pf.route_all(netlist, net_widths)
results = pf.route_all()
res = results["net1"]
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")

View file

@ -4,11 +4,12 @@ import pytest
from hypothesis import given, settings, strategies as st
from shapely.geometry import Point, Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext, route_astar
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.tests.support import build_context
@st.composite
@ -39,7 +40,7 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
net_width = 2.0
clearance = 2.0
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
for obs in obstacles:
engine.add_static_obstacle(obs)
@ -47,7 +48,7 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map)
context = AStarContext(evaluator, node_limit=5000) # Lower limit for fuzzing stability
context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability
# Check if start/target are inside obstacles (safety zone check)
# The router should handle this gracefully (either route or return None)

View file

@ -1,28 +1,57 @@
import pytest
from shapely.geometry import box
from inire.geometry.collision import CollisionEngine
from inire import NetSpec
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90, Straight
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.outcomes import RoutingOutcome
from inire.router.pathfinder import PathFinder, RoutingResult
from inire.router.session import (
create_routing_session_state,
prepare_routing_session_state,
run_routing_iteration,
)
from inire.tests.support import build_context
DEFAULT_BOUNDS = (0, 0, 100, 100)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=DEFAULT_BOUNDS)
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def _request_nets(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
) -> tuple[NetSpec, ...]:
return tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
metrics=None,
**request_overrides: object,
) -> PathFinder:
return PathFinder(
build_context(
evaluator,
bounds=bounds,
nets=_request_nets(netlist, net_widths),
**request_overrides,
),
metrics=metrics,
)
def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list:
path = []
curr = start
@ -37,17 +66,22 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[
return path
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]:
return [
(component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple())
for component in path
]
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths)
results = pf.route_all(netlist, net_widths)
results = pf.route_all()
assert len(results) == 2
assert results["net1"].is_valid
@ -57,10 +91,6 @@ def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(basic_evaluator)
# Force a crossing by setting low iterations and low penalty
pf = PathFinder(context, max_iterations=1, base_congestion_penalty=1.0, warm_start=None)
# Net 1: (0, 25) -> (100, 25) Horizontal
# Net 2: (50, 0) -> (50, 50) Vertical
netlist = {
@ -68,8 +98,16 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths=net_widths,
max_iterations=1,
base_penalty=1.0,
warm_start=None,
)
results = pf.route_all(netlist, net_widths)
results = pf.route_all()
# Both should be invalid because they cross
assert not results["net1"].is_valid
@ -78,231 +116,211 @@ def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
assert results["net2"].collisions > 0
def test_prepare_routing_session_state_builds_warm_start_and_sorts_nets(
def test_route_all_respects_requested_net_order_in_callback(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
calls: list[tuple[str, list[str]]] = []
cleared: list[bool] = []
def fake_build(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
order: str,
) -> dict[str, list]:
calls.append((order, list(netlist.keys())))
return {"warm": []}
monkeypatch.setattr(PathFinder, "_build_greedy_warm_start_paths", lambda self, netlist, net_widths, order: fake_build(netlist, net_widths, order))
monkeypatch.setattr(AStarContext, "clear_static_caches", lambda self: cleared.append(True))
callback_orders: list[list[str]] = []
netlist = {
"short": (Port(0, 0, 0), Port(10, 0, 0)),
"long": (Port(0, 0, 0), Port(40, 10, 0)),
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
}
state = create_routing_session_state(
pf,
netlist,
{net_id: 2.0 for net_id in netlist},
store_expanded=False,
iteration_callback=None,
shuffle_nets=False,
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths={net_id: 2.0 for net_id in netlist},
max_iterations=1,
warm_start=None,
sort_nets="longest",
initial_paths=None,
seed=None,
enabled=False,
)
pf.route_all(
iteration_callback=lambda iteration, results: callback_orders.append(list(results)),
)
prepare_routing_session_state(pf, state)
assert calls == [("longest", ["short", "long", "mid"])]
assert cleared == [True]
assert state.initial_paths == {"warm": []}
assert state.all_net_ids == ["long", "mid", "short"]
assert callback_orders == [["long", "mid", "short"]]
def test_run_routing_iteration_updates_results_and_invokes_callback(
def test_route_all_invokes_iteration_callback_with_results(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
callback_results: list[dict[str, RoutingResult]] = []
callback_results: list[dict[str, object]] = []
netlist = {
"net1": (Port(0, 0, 0), Port(10, 0, 0)),
"net2": (Port(0, 10, 0), Port(10, 10, 0)),
}
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths={"net1": 2.0, "net2": 2.0},
)
def fake_route_once(
net_id: str,
start: Port,
target: Port,
width: float,
iteration: int,
initial_paths: dict[str, list] | None,
store_expanded: bool,
needs_self_collision_check: set[str],
) -> tuple[RoutingResult, RoutingOutcome]:
_ = (start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check)
result = RoutingResult(
net_id,
[],
net_id == "net1",
int(net_id == "net2"),
reached_target=True,
outcome="completed" if net_id == "net1" else "colliding",
results = pf.route_all(
iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)),
)
assert len(callback_results) == 1
assert set(callback_results[0]) == {"net1", "net2"}
assert callback_results[0]["net1"].is_valid
assert callback_results[0]["net2"].is_valid
assert results["net1"].reached_target
assert results["net2"].reached_target
def test_route_all_uses_complete_initial_paths_without_rerouting(
basic_evaluator: CostEvaluator,
) -> None:
start = Port(0, 0, 0)
target = Port(20, 20, 0)
initial_path = _build_manual_path(
start,
2.0,
basic_evaluator.collision_engine.clearance,
[("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")],
)
pf = _build_pathfinder(
basic_evaluator,
netlist={"net": (start, target)},
net_widths={"net": 2.0},
bend_radii=[5.0],
max_iterations=1,
warm_start=None,
initial_paths={"net": tuple(initial_path)},
enabled=False,
)
result = pf.route_all()["net"]
assert result.is_valid
assert result.reached_target
assert _path_signature(result.path) == _path_signature(initial_path)
def test_route_all_retries_partial_initial_paths_across_iterations(
basic_evaluator: CostEvaluator,
) -> None:
start = Port(0, 0, 0)
target = Port(10, 0, 0)
partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)]
pf = _build_pathfinder(
basic_evaluator,
netlist={"net": (start, target)},
net_widths={"net": 2.0},
max_iterations=2,
warm_start=None,
capture_expanded=True,
initial_paths={"net": tuple(partial_path)},
enabled=False,
)
iterations: list[int] = []
result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"]
assert iterations == [0, 1]
assert result.is_valid
assert result.reached_target
assert result.outcome == "completed"
assert _path_signature(result.path) != _path_signature(partial_path)
assert pf.accumulated_expanded_nodes
def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None:
netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))}
widths = {"net": 2.0}
def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 60, 60))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
context = build_context(
evaluator,
bounds=(-20, -20, 60, 60),
nets=_request_nets(netlist, widths),
bend_radii=[10.0],
max_straight_length=50.0,
node_limit=50,
warm_start=None,
max_iterations=1,
enabled=False,
)
return result, result.outcome
return engine, context, PathFinder(context)
monkeypatch.setattr(
PathFinder,
"_route_net_once",
lambda self, net_id, start, target, width, iteration, initial_paths, store_expanded, needs_self_collision_check: fake_route_once(
net_id,
start,
target,
width,
iteration,
initial_paths,
store_expanded,
needs_self_collision_check,
),
)
state = create_routing_session_state(
pf,
{"net1": (Port(0, 0, 0), Port(10, 0, 0)), "net2": (Port(0, 10, 0), Port(10, 10, 0))},
{"net1": 2.0, "net2": 2.0},
store_expanded=True,
iteration_callback=lambda iteration, results: callback_results.append(dict(results)),
shuffle_nets=False,
sort_nets=None,
initial_paths={"seeded": []},
seed=None,
)
engine_auto, _context_auto, pf_auto = build_router()
assert pf_auto.route_all()["net"].is_valid
engine_auto.add_static_obstacle(box(4, 4, 8, 12))
auto_result = pf_auto.route_all()["net"]
outcomes = run_routing_iteration(pf, state, iteration=0)
engine_manual, context_manual, pf_manual = build_router()
assert pf_manual.route_all()["net"].is_valid
engine_manual.add_static_obstacle(box(4, 4, 8, 12))
context_manual.clear_static_caches()
manual_result = pf_manual.route_all()["net"]
assert outcomes == {"net1": "completed", "net2": "colliding"}
assert set(state.results) == {"net1", "net2"}
assert callback_results and set(callback_results[0]) == {"net1", "net2"}
assert state.results["net1"].is_valid
assert not state.results["net2"].is_valid
assert state.results["net2"].outcome == "colliding"
def test_run_routing_iteration_timeout_finalizes_tree(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context)
finalized: list[bool] = []
monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: finalized.append(True))
state = create_routing_session_state(
pf,
{"net1": (Port(0, 0, 0), Port(10, 0, 0))},
{"net1": 2.0},
store_expanded=False,
iteration_callback=None,
shuffle_nets=False,
sort_nets=None,
initial_paths={},
seed=None,
)
state.start_time = 0.0
state.session_timeout = 0.0
result = run_routing_iteration(pf, state, iteration=0)
assert result is None
assert finalized == [True]
def test_route_all_retries_partial_paths_across_iterations(
basic_evaluator: CostEvaluator,
monkeypatch: pytest.MonkeyPatch,
) -> None:
context = AStarContext(basic_evaluator)
pf = PathFinder(context, max_iterations=3, warm_start=None, refine_paths=False)
calls: list[int] = []
class FakeComponent:
def __init__(self, start_port: Port, end_port: Port) -> None:
self.start_port = start_port
self.end_port = end_port
def fake_route_astar(
start: Port,
target: Port,
width: float,
*,
context: AStarContext,
metrics: object,
net_id: str,
bend_collision_type: str,
return_partial: bool,
store_expanded: bool,
skip_congestion: bool,
self_collision_check: bool,
node_limit: int,
) -> list[FakeComponent]:
_ = (
width,
context,
metrics,
net_id,
bend_collision_type,
return_partial,
store_expanded,
skip_congestion,
self_collision_check,
node_limit,
)
calls.append(len(calls))
if len(calls) == 1:
return [FakeComponent(start, Port(5, 0, 0))]
return [FakeComponent(start, target)]
monkeypatch.setattr("inire.router.pathfinder.route_astar", fake_route_astar)
monkeypatch.setattr(type(pf.path_state), "install_path", lambda self, net_id, path: None)
monkeypatch.setattr(type(pf.path_state), "remove_path", lambda self, net_id: None)
monkeypatch.setattr(
type(pf.path_state),
"verify_path_report",
lambda self, net_id, path: basic_evaluator.collision_engine.verify_path_report(net_id, []),
)
monkeypatch.setattr(type(pf.path_state), "finalize_dynamic_tree", lambda self: None)
results = pf.route_all({"net": (Port(0, 0, 0), Port(10, 0, 0))}, {"net": 2.0})
assert calls == [0, 1]
assert results["net"].reached_target
assert results["net"].is_valid
assert results["net"].outcome == "completed"
assert auto_result.reached_target == manual_result.reached_target
assert auto_result.collisions == manual_result.collisions
assert auto_result.outcome == manual_result.outcome
assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [
(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path
]
def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None:
bounds = (0, -50, 100, 50)
def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]:
engine = CollisionEngine(clearance=2.0)
def build_pathfinder(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
*,
refinement_enabled: bool,
) -> tuple[RoutingWorld, PathFinder]:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[10.0])
return engine, PathFinder(context, refine_paths=refine_paths)
return engine, _build_pathfinder(
evaluator,
netlist=netlist,
net_widths=net_widths,
bounds=bounds,
bend_radii=[10.0],
enabled=refinement_enabled,
)
base_engine, base_pf = build_pathfinder(refine_paths=False)
base_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
base_engine.lock_net("netA")
base_result = base_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
width_a = {"netA": 2.0}
net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))}
width_b = {"netB": 2.0}
refined_engine, refined_pf = build_pathfinder(refine_paths=True)
refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
refined_engine.lock_net("netA")
refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
base_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False)
base_results = base_pf.route_all()
for polygon in base_results["netA"].as_locked_route().geometry:
base_engine.add_static_obstacle(polygon)
base_result = _build_pathfinder(
base_pf.cost_evaluator,
netlist=net_b,
net_widths=width_b,
bounds=bounds,
bend_radii=[10.0],
enabled=False,
).route_all()["netB"]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True)
refined_results = refined_pf.route_all()
for polygon in refined_results["netA"].as_locked_route().geometry:
refined_engine.add_static_obstacle(polygon)
refined_result = _build_pathfinder(
refined_pf.cost_evaluator,
netlist=net_b,
net_widths=width_b,
bounds=bounds,
bend_radii=[10.0],
enabled=True,
).route_all()["netB"]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90")
assert base_result.is_valid
assert refined_result.is_valid
@ -319,22 +337,30 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None:
}
net_widths = {net_id: 2.0 for net_id in netlist}
def build_pathfinder(*, refine_paths: bool) -> PathFinder:
engine = CollisionEngine(clearance=2.0)
def build_pathfinder(*, refinement_enabled: bool) -> PathFinder:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0])
return PathFinder(context, base_congestion_penalty=1000.0, refine_paths=refine_paths)
return _build_pathfinder(
evaluator,
netlist=netlist,
net_widths=net_widths,
bounds=bounds,
bend_radii=[10.0],
sbend_radii=[10.0],
base_penalty=1000.0,
enabled=refinement_enabled,
)
base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths)
refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths)
base_results = build_pathfinder(refinement_enabled=False).route_all()
refined_results = build_pathfinder(refinement_enabled=True).route_all()
for net_id in ("vertical_up", "vertical_down"):
base_result = base_results[net_id]
refined_result = refined_results[net_id]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90")
assert base_result.is_valid
assert refined_result.is_valid
@ -342,12 +368,18 @@ def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None:
def test_refine_path_handles_same_orientation_lateral_offset() -> None:
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
pf = PathFinder(context, refine_paths=True)
pf = _build_pathfinder(
evaluator,
netlist={"net": (Port(0, 0, 0), Port(60, 15, 0))},
net_widths={"net": 2.0},
bounds=(-20, -20, 120, 120),
bend_radii=[5.0, 10.0],
enabled=True,
)
start = Port(0, 0, 0)
width = 2.0
@ -374,19 +406,25 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None:
refined = pf._refine_path("net", start, target, width, path)
assert target == Port(60, 15, 0)
assert sum(1 for comp in path if comp.move_type == "Bend90") == 6
assert sum(1 for comp in refined if comp.move_type == "Bend90") == 4
assert sum(1 for comp in path if comp.move_type == "bend90") == 6
assert sum(1 for comp in refined if comp.move_type == "bend90") == 4
assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path)
def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
engine = CollisionEngine(clearance=2.0)
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
pf = PathFinder(context, refine_paths=True)
pf = _build_pathfinder(
evaluator,
netlist={"net": (Port(0, 0, 0), Port(65, 30, 90))},
net_widths={"net": 2.0},
bounds=(-20, -20, 120, 120),
bend_radii=[5.0, 10.0],
enabled=True,
)
start = Port(0, 0, 0)
width = 2.0
@ -415,7 +453,7 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() ->
refined = pf._refine_path("net", start, target, width, path)
assert target == Port(65, 30, 90)
assert sum(1 for comp in path if comp.move_type == "Bend90") == 7
assert sum(1 for comp in refined if comp.move_type == "Bend90") == 5
assert sum(1 for comp in path if comp.move_type == "bend90") == 7
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path)

View file

@ -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

View file

@ -1,10 +1,9 @@
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90
from inire.geometry.primitives import Port
from inire.router.astar import AStarContext
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.tests.support import build_pathfinder
def test_arc_resolution_sagitta() -> None:
@ -18,34 +17,45 @@ def test_arc_resolution_sagitta() -> None:
# Check number of points in the polygon exterior
# (num_segments + 1) * 2 points usually
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
pts_fine = len(res_fine.geometry[0].exterior.coords)
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
assert pts_fine > pts_coarse
def test_locked_paths() -> None:
engine = CollisionEngine(clearance=2.0)
def test_locked_routes() -> None:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 100, 50))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
pf = PathFinder(context)
# 1. Route Net A
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
results_a = pf.route_all(netlist_a, {"netA": 2.0})
results_a = build_pathfinder(
evaluator,
bounds=(0, -50, 100, 50),
netlist=netlist_a,
net_widths={"netA": 2.0},
bend_radii=[5.0, 10.0],
).route_all()
assert results_a["netA"].is_valid
# 2. Lock Net A
engine.lock_net("netA")
# 2. Treat Net A as locked geometry in the next run.
for polygon in results_a["netA"].as_locked_route().geometry:
engine.add_static_obstacle(polygon)
# 3. Route Net B through the same space. It should detour or fail.
# We'll place Net B's start/target such that it MUST cross Net A's physical path.
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
# Route Net B
results_b = pf.route_all(netlist_b, {"netB": 2.0})
results_b = build_pathfinder(
evaluator,
bounds=(0, -50, 100, 50),
netlist=netlist_b,
net_widths={"netB": 2.0},
bend_radii=[5.0, 10.0],
).route_all()
# Net B should be is_valid (it detoured) or at least not have collisions
# with Net A in the dynamic set (because netA is now static).
@ -55,8 +65,8 @@ def test_locked_paths() -> None:
assert results_b["netB"].is_valid
# Verify geometry doesn't intersect locked netA (physical check)
poly_a = [p.geometry[0] for p in results_a["netA"].path]
poly_b = [p.geometry[0] for p in results_b["netB"].path]
poly_a = [p.physical_geometry[0] for p in results_a["netA"].path]
poly_b = [p.physical_geometry[0] for p in results_b["netB"].path]
for pa in poly_a:
for pb in poly_b:

View file

@ -1,17 +1,20 @@
import unittest
from inire.geometry.primitives import Port
from inire.router.astar import route_astar, AStarContext
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.tests.support import build_context
class TestIntegerPorts(unittest.TestCase):
def setUp(self):
self.ce = CollisionEngine(clearance=2.0)
self.ce = RoutingWorld(clearance=2.0)
self.cost = CostEvaluator(self.ce)
self.bounds = (0, 0, 100, 100)
def test_route_reaches_integer_target(self):
context = AStarContext(self.cost)
context = build_context(self.cost, bounds=self.bounds)
start = Port(0, 0, 0)
target = Port(12, 0, 0)
@ -24,7 +27,7 @@ class TestIntegerPorts(unittest.TestCase):
self.assertEqual(last_port.r, 0)
def test_port_constructor_rounds_to_integer_lattice(self):
context = AStarContext(self.cost)
context = build_context(self.cost, bounds=self.bounds)
start = Port(0.0, 0.0, 0.0)
target = Port(12.3, 0.0, 0.0)
@ -36,7 +39,7 @@ class TestIntegerPorts(unittest.TestCase):
self.assertEqual(last_port.x, 12)
def test_half_step_inputs_use_integerized_targets(self):
context = AStarContext(self.cost)
context = build_context(self.cost, bounds=self.bounds)
start = Port(0.0, 0.0, 0.0)
target = Port(7.5, 0.0, 0.0)

View file

@ -1,12 +1,12 @@
from shapely.geometry import box
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.visibility import VisibilityManager
def test_point_visibility_cache_respects_max_distance() -> None:
engine = CollisionEngine(clearance=0.0)
engine = RoutingWorld(clearance=0.0)
engine.add_static_obstacle(box(10, 20, 20, 30))
engine.add_static_obstacle(box(100, 20, 110, 30))
visibility = VisibilityManager(engine)

View file

@ -4,13 +4,13 @@ matplotlib.use("Agg")
from inire.geometry.components import Bend90
from inire.geometry.primitives import Port
from inire.router.pathfinder import RoutingResult
from inire import RoutingResult
from inire.utils.visualization import plot_routing_results
def test_plot_routing_results_respects_show_actual() -> None:
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox")
result = RoutingResult("net", [bend], True, 0, reached_target=True)
result = RoutingResult("net", [bend], reached_target=True)
fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True)
fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False)

View file

@ -3,12 +3,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy
from inire.geometry.collision import CollisionEngine
from inire.geometry.collision import RoutingWorld
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.router.pathfinder import RoutingResult
from inire.router.results import RoutingResult
def validate_routing_result(
@ -38,21 +38,21 @@ def validate_routing_result(
if expected_start:
first_port = result.path[0].start_port
dist_to_start = numpy.sqrt(((first_port[:2] - expected_start[:2])**2).sum())
dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2)
if dist_to_start > 0.005:
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
if abs(first_port[2] - expected_start[2]) > 0.1:
connectivity_errors.append(f"Initial port orientation mismatch: {first_port[2]} vs {expected_start[2]}")
if abs(first_port.r - expected_start.r) > 0.1:
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
if expected_end:
last_port = result.path[-1].end_port
dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum())
dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2)
if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
if abs(last_port[2] - expected_end[2]) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port[2]} vs {expected_end[2]}")
if abs(last_port.r - expected_end.r) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
engine = CollisionEngine(clearance=clearance)
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
report = engine.verify_path_report("validation", result.path)

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from inire.geometry.primitives import Port
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import RoutingResult
from inire.router.results import RoutingResult
def plot_routing_results(
@ -51,8 +51,7 @@ def plot_routing_results(
label_added = False
for comp in res.path:
# 1. Plot Collision Geometry (Translucent fill)
# This is the geometry used during search (e.g. proxy or arc)
for poly in comp.geometry:
for poly in comp.collision_geometry:
if isinstance(poly, MultiPolygon):
geoms = list(poly.geoms)
else:
@ -67,13 +66,7 @@ def plot_routing_results(
x, y = g.xy
ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2)
# 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication)
# Use comp.actual_geometry if it exists (should be the arc)
actual_geoms_to_plot = (
comp.actual_geometry
if show_actual and comp.actual_geometry is not None
else comp.geometry
)
actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry
for poly in actual_geoms_to_plot:
if isinstance(poly, MultiPolygon):
@ -91,21 +84,20 @@ def plot_routing_results(
# 3. Plot subtle port orientation arrow
p = comp.end_port
rad = numpy.radians(p.orientation)
rad = numpy.radians(p.r)
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4)
if not res.path and not res.is_valid:
# Best-effort display: If the path is empty but failed, it might be unroutable.
# We don't have a partial path in RoutingResult currently.
# Empty failed paths are typically unroutable.
pass
# 4. Plot main arrows for netlist ports
if netlist:
for _net_id, (start_p, target_p) in netlist.items():
for p in [start_p, target_p]:
rad = numpy.radians(p[2])
ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black",
rad = numpy.radians(p.r)
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
scale=25, width=0.004, pivot="tail", zorder=6)
ax.set_xlim(bounds[0], bounds[2])

View file

@ -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
]