Compare commits
6 commits
6a28dcf312
...
e11132b51d
| Author | SHA1 | Date | |
|---|---|---|---|
| e11132b51d | |||
| bc218a416b | |||
| 941d3e01df | |||
| dcc4d6436c | |||
| 0c432bd229 | |||
| f2b2bf22f9 |
225
DOCS.md
|
|
@ -1,106 +1,175 @@
|
|||
# Inire Configuration & API Documentation
|
||||
|
||||
This document describes the user-tunable parameters for the `inire` auto-router.
|
||||
This document describes the current public API for `inire`.
|
||||
|
||||
## 1. AStarContext Parameters
|
||||
## 1. Primary API
|
||||
|
||||
The `AStarContext` stores the configuration and persistent state for the A* search. It is initialized once and passed to `route_astar` or the `PathFinder`.
|
||||
### `RoutingProblem`
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ |
|
||||
| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. |
|
||||
| `snap_size` | `float` | 5.0 | Grid size (µm) for expansion moves. Larger values speed up search. |
|
||||
| `max_straight_length` | `float` | 2000.0 | Maximum length (µm) of a single straight segment. |
|
||||
| `min_straight_length` | `float` | 5.0 | Minimum length (µm) of a single straight segment. |
|
||||
| `bend_radii` | `list[float]` | `[50.0, 100.0]` | Available radii for 90-degree turns (µm). |
|
||||
| `sbend_radii` | `list[float]` | `[5.0, 10.0, 50.0, 100.0]` | Available radii for S-bends (µm). |
|
||||
| `sbend_offsets` | `list[float] \| None` | `None` (Auto) | Lateral offsets for parametric S-bends. |
|
||||
| `bend_penalty` | `float` | 250.0 | Flat cost added for every 90-degree bend. |
|
||||
| `sbend_penalty` | `float` | 500.0 | Flat cost added for every S-bend. |
|
||||
| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. |
|
||||
| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide for clipped models. |
|
||||
| `visibility_guidance` | `str` | `"tangent_corner"` | Visibility-driven straight candidate mode: `"off"`, `"exact_corner"`, or `"tangent_corner"`. |
|
||||
`RoutingProblem` describes the physical routing problem:
|
||||
|
||||
## 2. AStarMetrics
|
||||
- `bounds`
|
||||
- `nets`
|
||||
- `static_obstacles`
|
||||
- `initial_paths`
|
||||
- `clearance`
|
||||
- `safety_zone_radius`
|
||||
|
||||
The `AStarMetrics` object collects performance data during the search.
|
||||
### `RoutingOptions`
|
||||
|
||||
| Property | Type | Description |
|
||||
| :--------------------- | :---- | :---------------------------------------------------- |
|
||||
| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. |
|
||||
| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. |
|
||||
| `max_depth_reached` | `int` | Deepest point in the search tree reached. |
|
||||
`RoutingOptions` groups all expert controls for the routing engine:
|
||||
|
||||
---
|
||||
- `search`
|
||||
- `objective`
|
||||
- `congestion`
|
||||
- `refinement`
|
||||
- `diagnostics`
|
||||
|
||||
## 3. CostEvaluator Parameters
|
||||
Route a problem with:
|
||||
|
||||
The `CostEvaluator` defines the "goodness" of a path.
|
||||
```python
|
||||
run = route(problem, options=options)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- |
|
||||
| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. |
|
||||
| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. |
|
||||
| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. |
|
||||
If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults.
|
||||
|
||||
---
|
||||
The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
|
||||
|
||||
## 3. PathFinder Parameters
|
||||
Stable example:
|
||||
|
||||
The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm.
|
||||
```python
|
||||
from inire import route, RoutingOptions, RoutingProblem
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- |
|
||||
| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. |
|
||||
| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.|
|
||||
Unstable example:
|
||||
|
||||
---
|
||||
```python
|
||||
from inire.router._router import PathFinder
|
||||
```
|
||||
|
||||
## 4. CollisionEngine Parameters
|
||||
### Incremental routing with locked geometry
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ |
|
||||
| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). |
|
||||
| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. |
|
||||
For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem:
|
||||
|
||||
---
|
||||
```python
|
||||
run_a = route(problem_a)
|
||||
problem_b = RoutingProblem(
|
||||
bounds=problem_a.bounds,
|
||||
nets=(...),
|
||||
static_obstacles=run_a.results_by_net["netA"].locked_geometry,
|
||||
)
|
||||
run_b = route(problem_b)
|
||||
```
|
||||
|
||||
## 4. Physical Units & Precision
|
||||
- **Coordinates**: Micrometers (µm).
|
||||
- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves.
|
||||
- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**.
|
||||
- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost.
|
||||
`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle.
|
||||
|
||||
---
|
||||
### Initial paths with `PathSeed`
|
||||
|
||||
## 5. Best Practices & Tuning Advice
|
||||
Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are materialized with the current width, clearance, and bend collision settings for the run, and partial seeds are retried by normal routing in later iterations.
|
||||
|
||||
### Speed vs. Optimality
|
||||
The `greedy_h_weight` is your primary lever for search performance.
|
||||
- **`1.0`**: Dijkstra-like behavior. Guarantees the shortest path but is very slow.
|
||||
- **`1.1` to `1.2`**: Recommended range. Balances wire length with fast convergence.
|
||||
- **`> 1.5`**: Extremely fast "greedy" search. May produce zig-zags or suboptimal detours.
|
||||
## 2. Search Options
|
||||
|
||||
### Avoiding "Zig-Zags"
|
||||
If the router produces many small bends instead of a long straight line:
|
||||
1. Increase `bend_penalty` (e.g., set to `100.0` or higher).
|
||||
2. Ensure `straight_lengths` includes larger values like `25.0` or `100.0`.
|
||||
3. Decrease `greedy_h_weight` closer to `1.0`.
|
||||
`RoutingOptions.search` is a `SearchOptions` object.
|
||||
|
||||
### Visibility Guidance
|
||||
The router can bias straight stop points using static obstacle corners.
|
||||
- **`"tangent_corner"`**: Default. Proposes straight lengths that set up a clean tangent bend around nearby visible corners. This helps obstacle-dense layouts more than open space.
|
||||
- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner.
|
||||
- **`"off"`**: Disables visibility-derived straight candidates entirely.
|
||||
| Field | Default | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `node_limit` | `1_000_000` | Maximum number of states to explore per net. |
|
||||
| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. |
|
||||
| `min_straight_length` | `5.0` | Minimum length of a single straight segment. |
|
||||
| `greedy_h_weight` | `1.5` | Heuristic weight. `1.0` is optimal but slower. |
|
||||
| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. |
|
||||
| `sbend_radii` | `(10.0,)` | Available radii for S-bends. |
|
||||
| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. |
|
||||
| `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. |
|
||||
| `bend_clip_margin` | `None` | Optional legacy shrink margin for `"clipped_bbox"`. Leave `None` for the default 8-point proxy. |
|
||||
| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. |
|
||||
|
||||
### Handling Congestion
|
||||
In multi-net designs, if nets are overlapping:
|
||||
1. Increase `congestion_penalty` in `CostEvaluator`.
|
||||
2. Increase `max_iterations` in `PathFinder`.
|
||||
3. If a solution is still not found, check if the `clearance` is physically possible given the design's narrowest bottlenecks.
|
||||
## 3. Objective Weights
|
||||
|
||||
### S-Bend Usage
|
||||
Parametric S-bends bridge lateral gaps without changing the waveguide's orientation.
|
||||
- **Automatic Selection**: If `sbend_offsets` is set to `None` (the default), the router automatically chooses from a set of "natural" offsets (Fibonacci-aligned grid steps) and the offset needed to hit the target.
|
||||
- **Specific Offsets**: To use specific offsets (e.g., 5.86µm for a 45° switchover), provide them in the `sbend_offsets` list. The router will prioritize these but will still try to align with the target if possible.
|
||||
- **Constraints**: S-bends are only used for offsets $O < 2R$. For larger shifts, the router naturally combines two 90° bends and a straight segment.
|
||||
`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`.
|
||||
|
||||
| Field | Default | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `unit_length_cost` | `1.0` | Cost per unit length. |
|
||||
| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. |
|
||||
| `sbend_penalty` | `500.0` | Flat S-bend penalty. |
|
||||
| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. |
|
||||
|
||||
## 4. Congestion Options
|
||||
|
||||
`RoutingOptions.congestion` is a `CongestionOptions` object.
|
||||
|
||||
| Field | Default | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `max_iterations` | `10` | Maximum rip-up and reroute iterations. |
|
||||
| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. |
|
||||
| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. |
|
||||
| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. |
|
||||
| `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. |
|
||||
| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. |
|
||||
| `shuffle_nets` | `False` | Shuffle routing order between iterations. |
|
||||
| `seed` | `None` | RNG seed for shuffled routing order. |
|
||||
|
||||
## 5. Refinement Options
|
||||
|
||||
`RoutingOptions.refinement` is a `RefinementOptions` object.
|
||||
|
||||
| Field | Default | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `enabled` | `True` | Enable post-route refinement. |
|
||||
| `objective` | `None` | Optional override objective for refinement. `None` reuses the search objective. |
|
||||
|
||||
## 6. Diagnostics Options
|
||||
|
||||
`RoutingOptions.diagnostics` is a `DiagnosticsOptions` object.
|
||||
|
||||
| Field | Default | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. |
|
||||
|
||||
## 7. RouteMetrics
|
||||
|
||||
`RoutingRunResult.metrics` is an immutable per-run snapshot.
|
||||
|
||||
| Field | Type | Description |
|
||||
| :-- | :-- | :-- |
|
||||
| `nodes_expanded` | `int` | Total nodes expanded during the run. |
|
||||
| `moves_generated` | `int` | Total candidate moves generated during the run. |
|
||||
| `moves_added` | `int` | Total candidate moves admitted to the open set during the run. |
|
||||
| `pruned_closed_set` | `int` | Total moves pruned because the state was already closed at lower cost. |
|
||||
| `pruned_hard_collision` | `int` | Total moves pruned by hard collision checks. |
|
||||
| `pruned_cost` | `int` | Total moves pruned by cost ceilings or invalid costs. |
|
||||
|
||||
## 8. Internal Modules
|
||||
|
||||
Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
|
||||
|
||||
## 9. Tuning Notes
|
||||
|
||||
### Speed vs. optimality
|
||||
|
||||
- Lower `search.greedy_h_weight` toward `1.0` for better optimality.
|
||||
- Raise `search.greedy_h_weight` for faster, greedier routing.
|
||||
|
||||
### Congestion handling
|
||||
|
||||
- Increase `congestion.base_penalty` to separate nets more aggressively in the first iteration.
|
||||
- Increase `congestion.max_iterations` if congestion needs more reroute passes.
|
||||
- Increase `congestion.multiplier` if later iterations need to escalate more quickly.
|
||||
|
||||
### Bend-heavy routes
|
||||
|
||||
- Increase `objective.bend_penalty` to discourage ladders of small bends.
|
||||
- Increase available `search.bend_radii` when larger turns are physically acceptable.
|
||||
|
||||
### Visibility guidance
|
||||
|
||||
- `"tangent_corner"` is the default and best general-purpose setting in obstacle-dense layouts.
|
||||
- `"exact_corner"` is more conservative.
|
||||
- `"off"` disables visibility-derived straight candidates.
|
||||
|
||||
### S-bends
|
||||
|
||||
- Leave `search.sbend_offsets=None` to let the router derive natural offsets automatically.
|
||||
- Provide explicit `search.sbend_offsets` for known process-preferred offsets.
|
||||
- S-bends are only used for offsets smaller than `2R`.
|
||||
|
|
|
|||
74
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# inire: Auto-Routing for Photonic and RF Integrated Circuits
|
||||
|
||||
`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It utilizes a Hybrid State-Lattice A* search combined with "Negotiated Congestion" (PathFinder) to route multiple nets while maintaining strict geometric fidelity and clearance.
|
||||
`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
* **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths.
|
||||
* **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid.
|
||||
* **Safety & Proximity**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk.
|
||||
* **Locked Paths**: Supports treating existing geometries as fixed obstacles for incremental routing sessions.
|
||||
* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib
|
|||
## Quick Start
|
||||
|
||||
```python
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
|
||||
|
||||
# 1. Setup Environment
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 1000, 1000))
|
||||
danger_map.precompute([]) # Add polygons here for obstacles
|
||||
|
||||
# 2. Configure Router
|
||||
evaluator = CostEvaluator(
|
||||
collision_engine=engine,
|
||||
danger_map=danger_map,
|
||||
greedy_h_weight=1.2
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 1000, 1000),
|
||||
nets=(
|
||||
NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0),
|
||||
),
|
||||
)
|
||||
context = AStarContext(
|
||||
cost_evaluator=evaluator,
|
||||
bend_penalty=10.0
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(50.0, 100.0),
|
||||
greedy_h_weight=1.2,
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
bend_penalty=10.0,
|
||||
),
|
||||
)
|
||||
pf = PathFinder(context)
|
||||
|
||||
# 3. Define Netlist
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(100, 50, 0)),
|
||||
}
|
||||
run = route(problem, options=options)
|
||||
|
||||
# 4. Route
|
||||
results = pf.route_all(netlist, {"net1": 2.0})
|
||||
|
||||
if results["net1"].is_valid:
|
||||
if run.results_by_net["net1"].is_valid:
|
||||
print("Successfully routed net1!")
|
||||
```
|
||||
|
||||
For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**.
|
||||
|
|
@ -71,10 +61,30 @@ Check the `examples/` directory for ready-to-run scripts. To run an example:
|
|||
python3 examples/01_simple_route.py
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the default correctness suite with:
|
||||
|
||||
```bash
|
||||
python3 -m pytest
|
||||
```
|
||||
|
||||
Runtime regression checks for the example scenarios are opt-in and require:
|
||||
|
||||
```bash
|
||||
INIRE_RUN_PERFORMANCE=1 python3 -m pytest -q inire/tests/test_example_performance.py
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**.
|
||||
|
||||
## API Stability
|
||||
|
||||
The stable API lives at the package root and is centered on `route(problem, options=...)`.
|
||||
|
||||
Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
|
||||
|
||||
## Architecture
|
||||
|
||||
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:
|
||||
|
|
@ -82,11 +92,11 @@ Full documentation for all user-tunable parameters, cost functions, and collisio
|
|||
2. **90° Bends**: Fixed-radius PDK cells.
|
||||
3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$).
|
||||
|
||||
For multi-net problems, the **PathFinder** loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings.
|
||||
For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic, ensuring that paths find the globally optimal configuration without crossings.
|
||||
|
||||
## Configuration
|
||||
|
||||
`inire` is highly tunable. Every major component (Router, CostEvaluator, PathFinder) accepts explicit named arguments in its constructor to control expansion rules, cost weights, and convergence limits. See `DOCS.md` for a full parameter reference.
|
||||
`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 64 KiB |
|
|
@ -1,42 +1,37 @@
|
|||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Example 03: Locked Paths...")
|
||||
|
||||
# 1. Setup Environment
|
||||
bounds = (0, -50, 100, 50)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute([])
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
|
||||
context = AStarContext(evaluator, bend_radii=[10.0])
|
||||
metrics = AStarMetrics()
|
||||
pf = PathFinder(context, metrics)
|
||||
|
||||
# 2. Route Net A and 'Lock' it
|
||||
# Net A is a straight path blocking the direct route for Net B
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(bend_radii=(10.0,)),
|
||||
objective=ObjectiveWeights(
|
||||
bend_penalty=250.0,
|
||||
sbend_penalty=500.0,
|
||||
),
|
||||
)
|
||||
print("Routing initial net...")
|
||||
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
|
||||
results_a = pf.route_all(netlist_a, {"netA": 2.0})
|
||||
|
||||
# Locking prevents Net A from being removed or rerouted during NC iterations
|
||||
engine.lock_net("netA")
|
||||
print("Initial net locked as static obstacle.")
|
||||
results_a = route(
|
||||
RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
|
||||
),
|
||||
options=options,
|
||||
).results_by_net
|
||||
|
||||
# 3. Route Net B (forced to detour)
|
||||
print("Routing detour net around locked path...")
|
||||
netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))}
|
||||
results_b = pf.route_all(netlist_b, {"netB": 2.0})
|
||||
results_b = route(
|
||||
RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
|
||||
static_obstacles=results_a["netA"].locked_geometry,
|
||||
),
|
||||
options=options,
|
||||
).results_by_net
|
||||
|
||||
# 4. Visualize
|
||||
results = {**results_a, **results_b}
|
||||
fig, ax = plot_routing_results(results, [], bounds)
|
||||
fig.savefig("examples/03_locked_paths.png")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 85 KiB |
|
|
@ -1,65 +1,70 @@
|
|||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
|
||||
|
||||
def _route_scenario(
|
||||
bounds: tuple[float, float, float, float],
|
||||
obstacles: list[Polygon],
|
||||
bend_collision_type: str,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
widths: dict[str, float],
|
||||
*,
|
||||
bend_clip_margin: float | None = None,
|
||||
) -> dict[str, RoutingResult]:
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=tuple(NetSpec(net_id, start, target, width=widths[net_id]) for net_id, (start, target) in netlist.items()),
|
||||
static_obstacles=tuple(obstacles),
|
||||
)
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(10.0,),
|
||||
bend_collision_type=bend_collision_type,
|
||||
bend_clip_margin=bend_clip_margin,
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
bend_penalty=50.0,
|
||||
sbend_penalty=150.0,
|
||||
),
|
||||
congestion=CongestionOptions(use_tiered_strategy=False),
|
||||
)
|
||||
return route(problem, options=options).results_by_net
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Example 06: Bend Collision Models...")
|
||||
|
||||
# 1. Setup Environment
|
||||
# Give room for 10um bends near the edges
|
||||
bounds = (-20, -20, 170, 170)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
|
||||
# Create three scenarios with identical obstacles
|
||||
# We'll space them out vertically
|
||||
obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)])
|
||||
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
|
||||
obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
|
||||
|
||||
obstacles = [obs_arc, obs_bbox, obs_clipped]
|
||||
for obs in obstacles:
|
||||
engine.add_static_obstacle(obs)
|
||||
danger_map.precompute(obstacles)
|
||||
|
||||
# We'll run three separate routers since collision_type is a router-level config
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
|
||||
# Scenario 1: Standard 'arc' model (High fidelity)
|
||||
context_arc = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc")
|
||||
netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}
|
||||
|
||||
# Scenario 2: 'bbox' model (Conservative axis-aligned box)
|
||||
context_bbox = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox")
|
||||
netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}
|
||||
|
||||
# Scenario 3: 'clipped_bbox' model (Balanced)
|
||||
context_clipped = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0)
|
||||
netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}
|
||||
|
||||
# 2. Route each scenario
|
||||
print("Routing Scenario 1 (Arc)...")
|
||||
res_arc = PathFinder(context_arc, use_tiered_strategy=False).route_all(netlist_arc, {"arc_model": 2.0})
|
||||
|
||||
res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0})
|
||||
print("Routing Scenario 2 (BBox)...")
|
||||
res_bbox = PathFinder(context_bbox, use_tiered_strategy=False).route_all(netlist_bbox, {"bbox_model": 2.0})
|
||||
|
||||
res_bbox = _route_scenario(bounds, obstacles, "bbox", netlist_bbox, {"bbox_model": 2.0})
|
||||
print("Routing Scenario 3 (Clipped BBox)...")
|
||||
res_clipped = PathFinder(context_clipped, use_tiered_strategy=False).route_all(netlist_clipped, {"clipped_model": 2.0})
|
||||
res_clipped = _route_scenario(
|
||||
bounds,
|
||||
obstacles,
|
||||
"clipped_bbox",
|
||||
netlist_clipped,
|
||||
{"clipped_model": 2.0},
|
||||
bend_clip_margin=1.0,
|
||||
)
|
||||
|
||||
# 3. Combine results for visualization
|
||||
all_results = {**res_arc, **res_bbox, **res_clipped}
|
||||
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped}
|
||||
|
||||
# 4. Visualize
|
||||
fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
|
||||
fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
|
||||
fig.savefig("examples/06_bend_collision_models.png")
|
||||
print("Saved plot to examples/06_bend_collision_models.png")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,108 +1,120 @@
|
|||
import numpy as np
|
||||
import time
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.utils.visualization import plot_routing_results, plot_danger_map, plot_expanded_nodes, plot_expansion_density
|
||||
|
||||
from shapely.geometry import box
|
||||
|
||||
from inire import (
|
||||
NetSpec,
|
||||
Port,
|
||||
RoutingProblem,
|
||||
RoutingResult,
|
||||
)
|
||||
from inire.router._stack import build_routing_stack
|
||||
from inire.utils.visualization import plot_expanded_nodes, plot_routing_results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...")
|
||||
|
||||
# 1. Setup Environment
|
||||
bounds = (0, 0, 1000, 1000)
|
||||
engine = CollisionEngine(clearance=6.0)
|
||||
|
||||
# Bottleneck at x=500, 200um gap
|
||||
obstacles = [
|
||||
box(450, 0, 550, 400),
|
||||
box(450, 600, 550, 1000),
|
||||
]
|
||||
for obs in obstacles:
|
||||
engine.add_static_obstacle(obs)
|
||||
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute(obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, unit_length_cost=0.1, bend_penalty=100.0, sbend_penalty=400.0, congestion_penalty=100.0)
|
||||
|
||||
context = AStarContext(evaluator, node_limit=2000000, bend_radii=[50.0], sbend_radii=[50.0])
|
||||
metrics = AStarMetrics()
|
||||
pf = PathFinder(context, metrics, max_iterations=15, base_congestion_penalty=100.0, congestion_multiplier=1.4)
|
||||
|
||||
# 2. Define Netlist
|
||||
netlist = {}
|
||||
num_nets = 10
|
||||
start_x = 50
|
||||
start_y_base = 500 - (num_nets * 10.0) / 2.0
|
||||
|
||||
end_x = 950
|
||||
end_y_base = 100
|
||||
end_y_pitch = 800.0 / (num_nets - 1)
|
||||
|
||||
for i in range(num_nets):
|
||||
sy = int(round(start_y_base + i * 10.0))
|
||||
ey = int(round(end_y_base + i * end_y_pitch))
|
||||
netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
|
||||
netlist: dict[str, tuple[Port, Port]] = {}
|
||||
for index in range(num_nets):
|
||||
start_y = int(round(start_y_base + index * 10.0))
|
||||
end_y = int(round(end_y_base + index * end_y_pitch))
|
||||
netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0))
|
||||
|
||||
net_widths = {nid: 2.0 for nid in netlist}
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()),
|
||||
static_obstacles=tuple(obstacles),
|
||||
clearance=6.0,
|
||||
)
|
||||
from inire import CongestionOptions, DiagnosticsOptions, ObjectiveWeights, RoutingOptions, SearchOptions
|
||||
|
||||
# 3. Route
|
||||
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
node_limit=2_000_000,
|
||||
bend_radii=(50.0,),
|
||||
sbend_radii=(50.0,),
|
||||
greedy_h_weight=1.5,
|
||||
bend_clip_margin=10.0,
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
unit_length_cost=0.1,
|
||||
bend_penalty=100.0,
|
||||
sbend_penalty=400.0,
|
||||
),
|
||||
congestion=CongestionOptions(
|
||||
max_iterations=15,
|
||||
base_penalty=100.0,
|
||||
multiplier=1.4,
|
||||
net_order="shortest",
|
||||
shuffle_nets=True,
|
||||
seed=42,
|
||||
),
|
||||
diagnostics=DiagnosticsOptions(capture_expanded=True),
|
||||
)
|
||||
stack = build_routing_stack(problem, options)
|
||||
evaluator = stack.evaluator
|
||||
finder = stack.finder
|
||||
metrics = finder.metrics
|
||||
|
||||
iteration_stats = []
|
||||
iteration_stats: list[dict[str, int]] = []
|
||||
|
||||
def iteration_callback(idx, current_results):
|
||||
successes = sum(1 for r in current_results.values() if r.is_valid)
|
||||
total_collisions = sum(r.collisions for r in current_results.values())
|
||||
def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None:
|
||||
successes = sum(1 for result in current_results.values() if result.is_valid)
|
||||
total_collisions = sum(result.collisions for result in current_results.values())
|
||||
total_nodes = metrics.nodes_expanded
|
||||
|
||||
print(f" Iteration {idx} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
|
||||
|
||||
# Adaptive Greediness: Decay from 1.5 to 1.1 over 10 iterations
|
||||
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
|
||||
print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
|
||||
new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4)
|
||||
evaluator.greedy_h_weight = new_greedy
|
||||
print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}")
|
||||
|
||||
iteration_stats.append({
|
||||
'Iteration': idx,
|
||||
'Success': successes,
|
||||
'Congestion': total_collisions,
|
||||
'Nodes': total_nodes
|
||||
})
|
||||
iteration_stats.append(
|
||||
{
|
||||
"Iteration": iteration,
|
||||
"Success": successes,
|
||||
"Congestion": total_collisions,
|
||||
"Nodes": total_nodes,
|
||||
}
|
||||
)
|
||||
metrics.reset_per_route()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42)
|
||||
t1 = time.perf_counter()
|
||||
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
|
||||
start_time = time.perf_counter()
|
||||
results = finder.route_all(iteration_callback=iteration_callback)
|
||||
end_time = time.perf_counter()
|
||||
|
||||
print(f"Routing took {t1-t0:.4f}s")
|
||||
|
||||
# 4. Check Results
|
||||
print(f"Routing took {end_time - start_time:.4f}s")
|
||||
print("\n--- Iteration Summary ---")
|
||||
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}")
|
||||
print("-" * 40)
|
||||
for s in iteration_stats:
|
||||
print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}")
|
||||
print("-" * 43)
|
||||
for stats in iteration_stats:
|
||||
print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8} | {stats['Nodes']:<10}")
|
||||
|
||||
success_count = sum(1 for res in results.values() if res.is_valid)
|
||||
success_count = sum(1 for result in results.values() if result.is_valid)
|
||||
print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.")
|
||||
|
||||
for nid, res in results.items():
|
||||
if not res.is_valid:
|
||||
print(f" FAILED: {nid}, collisions={res.collisions}")
|
||||
for net_id, result in results.items():
|
||||
if not result.is_valid:
|
||||
print(f" FAILED: {net_id}, collisions={result.collisions}")
|
||||
else:
|
||||
print(f" {nid}: SUCCESS")
|
||||
|
||||
# 5. Visualize
|
||||
fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
|
||||
plot_danger_map(danger_map, ax=ax)
|
||||
print(f" {net_id}: SUCCESS")
|
||||
|
||||
fig, ax = plot_routing_results(results, list(obstacles), bounds, netlist=netlist)
|
||||
plot_expanded_nodes(list(finder.accumulated_expanded_nodes), ax=ax)
|
||||
fig.savefig("examples/07_large_scale_routing.png")
|
||||
print("Saved plot to examples/07_large_scale_routing.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -1,54 +1,76 @@
|
|||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics, route_astar
|
||||
from inire.router._astar_types import AStarContext, AStarMetrics
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Example 08: Custom Bend Geometry...")
|
||||
|
||||
# 1. Setup Environment
|
||||
bounds = (0, 0, 150, 150)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute([])
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[])
|
||||
metrics = AStarMetrics()
|
||||
pf = PathFinder(context, metrics)
|
||||
start = Port(20, 20, 0)
|
||||
target = Port(100, 100, 90)
|
||||
|
||||
# 2. Define Netlist
|
||||
netlist = {
|
||||
"custom_bend": (Port(20, 20, 0), Port(100, 100, 90)),
|
||||
}
|
||||
net_widths = {"custom_bend": 2.0}
|
||||
|
||||
# 3. Route with standard arc first
|
||||
print("Routing with standard arc...")
|
||||
results_std = pf.route_all(netlist, net_widths)
|
||||
results_std = PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("custom_bend", start, target, width=2.0),),
|
||||
),
|
||||
RoutingOptions(
|
||||
search=SearchOptions(bend_radii=(10.0,), sbend_radii=()),
|
||||
congestion=CongestionOptions(max_iterations=1),
|
||||
),
|
||||
),
|
||||
metrics=metrics,
|
||||
).route_all()
|
||||
|
||||
# 4. Define a custom 'trapezoid' bend model
|
||||
# (Just for demonstration - we override the collision model during search)
|
||||
# Define a custom centered 20x20 box
|
||||
custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
|
||||
|
||||
print("Routing with custom collision model...")
|
||||
# Override bend_collision_type with a literal Polygon
|
||||
context_custom = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=custom_poly, sbend_radii=[])
|
||||
metrics_custom = AStarMetrics()
|
||||
results_custom = PathFinder(context_custom, metrics_custom, use_tiered_strategy=False).route_all(
|
||||
{"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}
|
||||
)
|
||||
results_custom = PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("custom_model", start, target, width=2.0),),
|
||||
),
|
||||
RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(10.0,),
|
||||
bend_collision_type=custom_poly,
|
||||
sbend_radii=(),
|
||||
),
|
||||
congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False),
|
||||
),
|
||||
),
|
||||
metrics=AStarMetrics(),
|
||||
use_tiered_strategy=False,
|
||||
).route_all()
|
||||
|
||||
# 5. Visualize
|
||||
all_results = {**results_std, **results_custom}
|
||||
fig, ax = plot_routing_results(all_results, [], bounds, netlist=netlist)
|
||||
fig, _ax = plot_routing_results(
|
||||
all_results,
|
||||
[],
|
||||
bounds,
|
||||
netlist={
|
||||
"custom_bend": (start, target),
|
||||
"custom_model": (start, target),
|
||||
},
|
||||
)
|
||||
fig.savefig("examples/08_custom_bend_geometry.png")
|
||||
print("Saved plot to examples/08_custom_bend_geometry.png")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,46 @@
|
|||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
from shapely.geometry import box
|
||||
|
||||
from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Example 09: Best-Effort Under Tight Search Budget...")
|
||||
|
||||
# 1. Setup Environment
|
||||
bounds = (0, 0, 100, 100)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
|
||||
# A small obstacle cluster keeps the partial route visually interesting.
|
||||
obstacles = [
|
||||
box(35, 35, 45, 65),
|
||||
box(55, 35, 65, 65),
|
||||
]
|
||||
for obs in obstacles:
|
||||
engine.add_static_obstacle(obs)
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),),
|
||||
static_obstacles=tuple(obstacles),
|
||||
)
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
node_limit=3,
|
||||
bend_radii=(10.0,),
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
bend_penalty=50.0,
|
||||
sbend_penalty=150.0,
|
||||
),
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
)
|
||||
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute(obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
# Keep the search budget intentionally tiny so the router returns a partial path.
|
||||
context = AStarContext(evaluator, node_limit=3, bend_radii=[10.0])
|
||||
metrics = AStarMetrics()
|
||||
|
||||
pf = PathFinder(context, metrics, warm_start=None)
|
||||
|
||||
# 2. Define Netlist: reaching the target requires additional turns the search budget cannot afford.
|
||||
netlist = {
|
||||
"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180)),
|
||||
}
|
||||
net_widths = {"budget_limited_net": 2.0}
|
||||
|
||||
# 3. Route
|
||||
print("Routing with a deliberately tiny node budget (should return a partial path)...")
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
# 4. Check Results
|
||||
res = results["budget_limited_net"]
|
||||
if not res.reached_target:
|
||||
print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.")
|
||||
run = route(problem, options=options)
|
||||
result = run.results_by_net["budget_limited_net"]
|
||||
if not result.reached_target:
|
||||
print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.")
|
||||
else:
|
||||
print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.")
|
||||
|
||||
# 5. Visualize
|
||||
fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
|
||||
fig, _ax = plot_routing_results(run.results_by_net, list(obstacles), bounds, netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))})
|
||||
fig.savefig("examples/09_unroutable_best_effort.png")
|
||||
print("Saved plot to examples/09_unroutable_best_effort.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting
|
|||
* **BBox**: Simple axis-aligned bounding box (Fastest search).
|
||||
* **Clipped BBox**: A balanced model that clips the corners of the AABB to better fit the arc (Optimal performance).
|
||||
|
||||
Example 08 also demonstrates a custom polygonal bend geometry. It uses a centered `20x20` box as a custom bend collision model.
|
||||
|
||||

|
||||
|
||||
## 3. Unroutable Nets & Best-Effort Display
|
||||
|
|
|
|||
|
|
@ -1,8 +1,59 @@
|
|||
"""
|
||||
inire Wave-router
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
from .geometry.primitives import Port as Port # noqa: PLC0414
|
||||
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414
|
||||
from .model import (
|
||||
CongestionOptions as CongestionOptions,
|
||||
DiagnosticsOptions as DiagnosticsOptions,
|
||||
NetSpec as NetSpec,
|
||||
ObjectiveWeights as ObjectiveWeights,
|
||||
RefinementOptions as RefinementOptions,
|
||||
RoutingOptions as RoutingOptions,
|
||||
RoutingProblem as RoutingProblem,
|
||||
SearchOptions as SearchOptions,
|
||||
) # noqa: PLC0414
|
||||
from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414
|
||||
from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414
|
||||
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
__version__ = '0.1'
|
||||
|
||||
|
||||
def route(
|
||||
problem: RoutingProblem,
|
||||
*,
|
||||
options: RoutingOptions | None = None,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
|
||||
) -> RoutingRunResult:
|
||||
from .router._stack import build_routing_stack
|
||||
|
||||
resolved_options = RoutingOptions() if options is None else options
|
||||
stack = build_routing_stack(problem, resolved_options)
|
||||
finder = stack.finder
|
||||
results = finder.route_all(iteration_callback=iteration_callback)
|
||||
return RoutingRunResult(
|
||||
results_by_net=results,
|
||||
metrics=finder.metrics.snapshot(),
|
||||
expanded_nodes=tuple(finder.accumulated_expanded_nodes),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Bend90Seed",
|
||||
"CongestionOptions",
|
||||
"DiagnosticsOptions",
|
||||
"NetSpec",
|
||||
"ObjectiveWeights",
|
||||
"PathSeed",
|
||||
"Port",
|
||||
"RefinementOptions",
|
||||
"RoutingOptions",
|
||||
"RoutingProblem",
|
||||
"RoutingResult",
|
||||
"RoutingRunResult",
|
||||
"SBendSeed",
|
||||
"SearchOptions",
|
||||
"StraightSeed",
|
||||
"route",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,11 +2,5 @@
|
|||
Centralized constants for the inire routing engine.
|
||||
"""
|
||||
|
||||
# Search Grid Snap (5.0 µm default)
|
||||
# TODO: Make this configurable in RouterConfig and define tolerances relative to the grid.
|
||||
DEFAULT_SEARCH_GRID_SNAP_UM = 5.0
|
||||
|
||||
# Tolerances
|
||||
TOLERANCE_LINEAR = 1e-6
|
||||
TOLERANCE_ANGULAR = 1e-3
|
||||
TOLERANCE_GRID = 1e-6
|
||||
|
|
|
|||
51
inire/geometry/component_overlap.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
def components_overlap(
|
||||
component_a: ComponentResult,
|
||||
component_b: ComponentResult,
|
||||
prefer_actual: bool = False,
|
||||
) -> bool:
|
||||
polygons_a: tuple[Polygon, ...]
|
||||
polygons_b: tuple[Polygon, ...]
|
||||
if prefer_actual:
|
||||
polygons_a = component_a.physical_geometry
|
||||
polygons_b = component_b.physical_geometry
|
||||
bounds_a = (
|
||||
min(polygon.bounds[0] for polygon in polygons_a),
|
||||
min(polygon.bounds[1] for polygon in polygons_a),
|
||||
max(polygon.bounds[2] for polygon in polygons_a),
|
||||
max(polygon.bounds[3] for polygon in polygons_a),
|
||||
)
|
||||
bounds_b = (
|
||||
min(polygon.bounds[0] for polygon in polygons_b),
|
||||
min(polygon.bounds[1] for polygon in polygons_b),
|
||||
max(polygon.bounds[2] for polygon in polygons_b),
|
||||
max(polygon.bounds[3] for polygon in polygons_b),
|
||||
)
|
||||
else:
|
||||
polygons_a = component_a.collision_geometry
|
||||
polygons_b = component_b.collision_geometry
|
||||
bounds_a = component_a.total_bounds
|
||||
bounds_b = component_b.total_bounds
|
||||
|
||||
if not (
|
||||
bounds_a[0] < bounds_b[2]
|
||||
and bounds_a[2] > bounds_b[0]
|
||||
and bounds_a[1] < bounds_b[3]
|
||||
and bounds_a[3] > bounds_b[1]
|
||||
):
|
||||
return False
|
||||
|
||||
for polygon_a in polygons_a:
|
||||
for polygon_b in polygons_b:
|
||||
if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b):
|
||||
return True
|
||||
return False
|
||||
|
|
@ -1,67 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
import numpy
|
||||
from shapely.affinity import rotate as shapely_rotate
|
||||
from shapely.affinity import scale as shapely_scale
|
||||
from shapely.affinity import translate as shapely_translate
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from inire.constants import TOLERANCE_ANGULAR, TOLERANCE_LINEAR
|
||||
from inire.constants import TOLERANCE_ANGULAR
|
||||
from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed
|
||||
from .primitives import Port, rotation_matrix2
|
||||
|
||||
|
||||
MoveKind = Literal["straight", "bend90", "sbend"]
|
||||
BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"]
|
||||
BendCollisionModel = BendCollisionModelName | Polygon
|
||||
|
||||
|
||||
def _normalize_length(value: float) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ComponentResult:
|
||||
__slots__ = (
|
||||
"geometry",
|
||||
"dilated_geometry",
|
||||
"proxy_geometry",
|
||||
"actual_geometry",
|
||||
"dilated_actual_geometry",
|
||||
"end_port",
|
||||
"length",
|
||||
"move_type",
|
||||
"_bounds",
|
||||
"_total_bounds",
|
||||
"_dilated_bounds",
|
||||
"_total_dilated_bounds",
|
||||
)
|
||||
start_port: Port
|
||||
collision_geometry: tuple[Polygon, ...]
|
||||
end_port: Port
|
||||
length: float
|
||||
move_type: MoveKind
|
||||
move_spec: PathSegmentSeed
|
||||
physical_geometry: tuple[Polygon, ...]
|
||||
dilated_collision_geometry: tuple[Polygon, ...]
|
||||
dilated_physical_geometry: tuple[Polygon, ...]
|
||||
_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
|
||||
_total_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
|
||||
_dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
|
||||
_total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
geometry: list[Polygon],
|
||||
end_port: Port,
|
||||
length: float,
|
||||
move_type: str,
|
||||
dilated_geometry: list[Polygon] | None = None,
|
||||
proxy_geometry: list[Polygon] | None = None,
|
||||
actual_geometry: list[Polygon] | None = None,
|
||||
dilated_actual_geometry: list[Polygon] | None = None,
|
||||
) -> None:
|
||||
self.geometry = geometry
|
||||
self.dilated_geometry = dilated_geometry
|
||||
self.proxy_geometry = proxy_geometry
|
||||
self.actual_geometry = actual_geometry
|
||||
self.dilated_actual_geometry = dilated_actual_geometry
|
||||
self.end_port = end_port
|
||||
self.length = float(length)
|
||||
self.move_type = move_type
|
||||
def __post_init__(self) -> None:
|
||||
collision_geometry = tuple(self.collision_geometry)
|
||||
physical_geometry = tuple(self.physical_geometry)
|
||||
dilated_collision_geometry = tuple(self.dilated_collision_geometry)
|
||||
dilated_physical_geometry = tuple(self.dilated_physical_geometry)
|
||||
|
||||
self._bounds = [poly.bounds for poly in self.geometry]
|
||||
self._total_bounds = _combine_bounds(self._bounds)
|
||||
object.__setattr__(self, "collision_geometry", collision_geometry)
|
||||
object.__setattr__(self, "physical_geometry", physical_geometry)
|
||||
object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry)
|
||||
object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry)
|
||||
object.__setattr__(self, "length", float(self.length))
|
||||
|
||||
if self.dilated_geometry is None:
|
||||
self._dilated_bounds = None
|
||||
self._total_dilated_bounds = None
|
||||
else:
|
||||
self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry]
|
||||
self._total_dilated_bounds = _combine_bounds(self._dilated_bounds)
|
||||
bounds = tuple(poly.bounds for poly in collision_geometry)
|
||||
object.__setattr__(self, "_bounds", bounds)
|
||||
object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds)))
|
||||
|
||||
dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry)
|
||||
object.__setattr__(self, "_dilated_bounds", dilated_bounds)
|
||||
object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds)))
|
||||
|
||||
@property
|
||||
def bounds(self) -> list[tuple[float, float, float, float]]:
|
||||
def bounds(self) -> tuple[tuple[float, float, float, float], ...]:
|
||||
return self._bounds
|
||||
|
||||
@property
|
||||
|
|
@ -69,23 +68,24 @@ class ComponentResult:
|
|||
return self._total_bounds
|
||||
|
||||
@property
|
||||
def dilated_bounds(self) -> list[tuple[float, float, float, float]] | None:
|
||||
def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]:
|
||||
return self._dilated_bounds
|
||||
|
||||
@property
|
||||
def total_dilated_bounds(self) -> tuple[float, float, float, float] | None:
|
||||
def total_dilated_bounds(self) -> tuple[float, float, float, float]:
|
||||
return self._total_dilated_bounds
|
||||
|
||||
def translate(self, dx: int | float, dy: int | float) -> ComponentResult:
|
||||
return ComponentResult(
|
||||
geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry],
|
||||
end_port=self.end_port + [dx, dy, 0],
|
||||
start_port=self.start_port.translate(dx, dy),
|
||||
collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry],
|
||||
end_port=self.end_port.translate(dx, dy),
|
||||
length=self.length,
|
||||
move_type=self.move_type,
|
||||
dilated_geometry=None if self.dilated_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_geometry],
|
||||
proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry],
|
||||
actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry],
|
||||
dilated_actual_geometry=None if self.dilated_actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.dilated_actual_geometry],
|
||||
move_spec=self.move_spec,
|
||||
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
|
||||
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry],
|
||||
dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -134,7 +134,13 @@ def _get_arc_polygons(
|
|||
return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))]
|
||||
|
||||
|
||||
def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float], clip_margin: float) -> Polygon:
|
||||
def _clip_bbox_legacy(
|
||||
cxy: tuple[float, float],
|
||||
radius: float,
|
||||
width: float,
|
||||
ts: tuple[float, float],
|
||||
clip_margin: float,
|
||||
) -> Polygon:
|
||||
arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0]
|
||||
minx, miny, maxx, maxy = arc_poly.bounds
|
||||
bbox_poly = box(minx, miny, maxx, maxy)
|
||||
|
|
@ -142,17 +148,76 @@ def _clip_bbox(cxy: tuple[float, float], radius: float, width: float, ts: tuple[
|
|||
return bbox_poly.buffer(-shrink, join_style=2) if shrink > 0 else bbox_poly
|
||||
|
||||
|
||||
def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon:
|
||||
"""Return a conservative 8-point polygonal proxy for the arc.
|
||||
|
||||
The polygon uses 4 points along the outer edge and 4 along the inner edge.
|
||||
The outer edge is a circumscribed polyline and the inner edge is an
|
||||
inscribed polyline, so the result conservatively contains the true arc.
|
||||
"""
|
||||
cx, cy = cxy
|
||||
sample_count = 4
|
||||
angle_span = abs(float(ts[1]) - float(ts[0]))
|
||||
if angle_span < TOLERANCE_ANGULAR:
|
||||
return box(*_get_arc_polygons(cxy, radius, width, ts)[0].bounds)
|
||||
|
||||
segment_half_angle = numpy.radians(angle_span / (2.0 * (sample_count - 1)))
|
||||
cos_half = max(float(numpy.cos(segment_half_angle)), 1e-9)
|
||||
|
||||
inner_radius = max(0.0, radius - width / 2.0)
|
||||
outer_radius = radius + width / 2.0
|
||||
tolerance = max(1e-3, radius * 1e-4)
|
||||
conservative_inner_radius = max(0.0, inner_radius * cos_half - tolerance)
|
||||
conservative_outer_radius = outer_radius / cos_half + tolerance
|
||||
|
||||
angles = numpy.radians(numpy.linspace(ts[0], ts[1], sample_count))
|
||||
cos_a = numpy.cos(angles)
|
||||
sin_a = numpy.sin(angles)
|
||||
|
||||
outer_points = numpy.column_stack((cx + conservative_outer_radius * cos_a, cy + conservative_outer_radius * sin_a))
|
||||
inner_points = numpy.column_stack((cx + conservative_inner_radius * cos_a[::-1], cy + conservative_inner_radius * sin_a[::-1]))
|
||||
return Polygon(numpy.concatenate((outer_points, inner_points), axis=0))
|
||||
|
||||
|
||||
def _clip_bbox(
|
||||
cxy: tuple[float, float],
|
||||
radius: float,
|
||||
width: float,
|
||||
ts: tuple[float, float],
|
||||
clip_margin: float | None,
|
||||
) -> Polygon:
|
||||
if clip_margin is not None:
|
||||
return _clip_bbox_legacy(cxy, radius, width, ts, clip_margin)
|
||||
return _clip_bbox_polygonal(cxy, radius, width, ts)
|
||||
|
||||
|
||||
def _transform_custom_collision_polygon(
|
||||
collision_poly: Polygon,
|
||||
cxy: tuple[float, float],
|
||||
rotation_deg: float,
|
||||
mirror_y: bool,
|
||||
) -> Polygon:
|
||||
poly = collision_poly
|
||||
if mirror_y:
|
||||
poly = shapely_scale(poly, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0))
|
||||
if rotation_deg % 360:
|
||||
poly = shapely_rotate(poly, rotation_deg, origin=(0.0, 0.0), use_radians=False)
|
||||
return shapely_translate(poly, cxy[0], cxy[1])
|
||||
|
||||
|
||||
def _apply_collision_model(
|
||||
arc_poly: Polygon,
|
||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
|
||||
collision_type: BendCollisionModel,
|
||||
radius: float,
|
||||
width: float,
|
||||
cxy: tuple[float, float],
|
||||
clip_margin: float,
|
||||
ts: tuple[float, float],
|
||||
clip_margin: float | None = None,
|
||||
rotation_deg: float = 0.0,
|
||||
mirror_y: bool = False,
|
||||
) -> list[Polygon]:
|
||||
if isinstance(collision_type, Polygon):
|
||||
return [shapely_translate(collision_type, cxy[0], cxy[1])]
|
||||
return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)]
|
||||
if collision_type == "arc":
|
||||
return [arc_poly]
|
||||
if collision_type == "clipped_bbox":
|
||||
|
|
@ -179,21 +244,31 @@ class Straight:
|
|||
poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y))
|
||||
geometry = [Polygon(poly_points)]
|
||||
|
||||
dilated_geometry = None
|
||||
if dilation > 0:
|
||||
half_w_d = half_w + dilation
|
||||
pts_d = numpy.array(((-dilation, half_w_d), (length_f + dilation, half_w_d), (length_f + dilation, -half_w_d), (-dilation, -half_w_d)))
|
||||
pts_d = numpy.array(
|
||||
(
|
||||
(-dilation, half_w_d),
|
||||
(length_f + dilation, half_w_d),
|
||||
(length_f + dilation, -half_w_d),
|
||||
(-dilation, -half_w_d),
|
||||
)
|
||||
)
|
||||
poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y))
|
||||
dilated_geometry = [Polygon(poly_points_d)]
|
||||
else:
|
||||
dilated_geometry = geometry
|
||||
|
||||
return ComponentResult(
|
||||
geometry=geometry,
|
||||
start_port=start_port,
|
||||
collision_geometry=geometry,
|
||||
end_port=end_port,
|
||||
length=abs(length_f),
|
||||
move_type="Straight",
|
||||
dilated_geometry=dilated_geometry,
|
||||
actual_geometry=geometry,
|
||||
dilated_actual_geometry=dilated_geometry,
|
||||
move_type="straight",
|
||||
move_spec=StraightSeed(length=length_f),
|
||||
physical_geometry=geometry,
|
||||
dilated_collision_geometry=dilated_geometry,
|
||||
dilated_physical_geometry=dilated_geometry,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -205,8 +280,8 @@ class Bend90:
|
|||
width: float,
|
||||
direction: Literal["CW", "CCW"],
|
||||
sagitta: float = 0.01,
|
||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
|
||||
clip_margin: float = 10.0,
|
||||
collision_type: BendCollisionModel = "arc",
|
||||
clip_margin: float | None = None,
|
||||
dilation: float = 0.0,
|
||||
) -> ComponentResult:
|
||||
rot2 = rotation_matrix2(start_port.r)
|
||||
|
|
@ -229,37 +304,39 @@ class Bend90:
|
|||
radius,
|
||||
width,
|
||||
(float(center_xy[0]), float(center_xy[1])),
|
||||
clip_margin,
|
||||
ts,
|
||||
clip_margin=clip_margin,
|
||||
rotation_deg=float(start_port.r),
|
||||
mirror_y=(sign < 0),
|
||||
)
|
||||
|
||||
proxy_geometry = None
|
||||
if collision_type == "arc":
|
||||
proxy_geometry = _apply_collision_model(
|
||||
arc_polys[0],
|
||||
"clipped_bbox",
|
||||
physical_geometry = arc_polys
|
||||
if dilation > 0:
|
||||
dilated_physical_geometry = _get_arc_polygons(
|
||||
(float(center_xy[0]), float(center_xy[1])),
|
||||
radius,
|
||||
width,
|
||||
(float(center_xy[0]), float(center_xy[1])),
|
||||
clip_margin,
|
||||
ts,
|
||||
sagitta,
|
||||
dilation=dilation,
|
||||
)
|
||||
|
||||
dilated_actual_geometry = None
|
||||
dilated_geometry = None
|
||||
if dilation > 0:
|
||||
dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation)
|
||||
dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
|
||||
dilated_collision_geometry = (
|
||||
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
|
||||
)
|
||||
else:
|
||||
dilated_physical_geometry = physical_geometry
|
||||
dilated_collision_geometry = collision_polys
|
||||
|
||||
return ComponentResult(
|
||||
geometry=collision_polys,
|
||||
start_port=start_port,
|
||||
collision_geometry=collision_polys,
|
||||
end_port=end_port,
|
||||
length=abs(radius) * numpy.pi / 2.0,
|
||||
move_type="Bend90",
|
||||
dilated_geometry=dilated_geometry,
|
||||
proxy_geometry=proxy_geometry,
|
||||
actual_geometry=arc_polys,
|
||||
dilated_actual_geometry=dilated_actual_geometry,
|
||||
move_type="bend90",
|
||||
move_spec=Bend90Seed(radius=radius, direction=direction),
|
||||
physical_geometry=physical_geometry,
|
||||
dilated_collision_geometry=dilated_collision_geometry,
|
||||
dilated_physical_geometry=dilated_physical_geometry,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -271,8 +348,8 @@ class SBend:
|
|||
radius: float,
|
||||
width: float,
|
||||
sagitta: float = 0.01,
|
||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
|
||||
clip_margin: float = 10.0,
|
||||
collision_type: BendCollisionModel = "arc",
|
||||
clip_margin: float | None = None,
|
||||
dilation: float = 0.0,
|
||||
) -> ComponentResult:
|
||||
if abs(offset) >= 2 * radius:
|
||||
|
|
@ -301,33 +378,51 @@ class SBend:
|
|||
arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0]
|
||||
actual_geometry = [arc1, arc2]
|
||||
geometry = [
|
||||
_apply_collision_model(arc1, collision_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0],
|
||||
_apply_collision_model(arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0],
|
||||
_apply_collision_model(
|
||||
arc1,
|
||||
collision_type,
|
||||
radius,
|
||||
width,
|
||||
(float(c1_xy[0]), float(c1_xy[1])),
|
||||
ts1,
|
||||
clip_margin=clip_margin,
|
||||
rotation_deg=float(start_port.r),
|
||||
mirror_y=(sign < 0),
|
||||
)[0],
|
||||
_apply_collision_model(
|
||||
arc2,
|
||||
collision_type,
|
||||
radius,
|
||||
width,
|
||||
(float(c2_xy[0]), float(c2_xy[1])),
|
||||
ts2,
|
||||
clip_margin=clip_margin,
|
||||
rotation_deg=float(start_port.r),
|
||||
mirror_y=(sign > 0),
|
||||
)[0],
|
||||
]
|
||||
|
||||
proxy_geometry = None
|
||||
if collision_type == "arc":
|
||||
proxy_geometry = [
|
||||
_apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0],
|
||||
_apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0],
|
||||
]
|
||||
|
||||
dilated_actual_geometry = None
|
||||
dilated_geometry = None
|
||||
physical_geometry = actual_geometry
|
||||
if dilation > 0:
|
||||
dilated_actual_geometry = [
|
||||
dilated_physical_geometry = [
|
||||
_get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0],
|
||||
_get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0],
|
||||
]
|
||||
dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry]
|
||||
dilated_collision_geometry = (
|
||||
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry]
|
||||
)
|
||||
else:
|
||||
dilated_physical_geometry = physical_geometry
|
||||
dilated_collision_geometry = geometry
|
||||
|
||||
return ComponentResult(
|
||||
geometry=geometry,
|
||||
start_port=start_port,
|
||||
collision_geometry=geometry,
|
||||
end_port=end_port,
|
||||
length=2.0 * radius * theta,
|
||||
move_type="SBend",
|
||||
dilated_geometry=dilated_geometry,
|
||||
proxy_geometry=proxy_geometry,
|
||||
actual_geometry=actual_geometry,
|
||||
dilated_actual_geometry=dilated_actual_geometry,
|
||||
move_type="sbend",
|
||||
move_spec=SBendSeed(offset=offset, radius=radius),
|
||||
physical_geometry=physical_geometry,
|
||||
dilated_collision_geometry=dilated_collision_geometry,
|
||||
dilated_physical_geometry=dilated_physical_geometry,
|
||||
)
|
||||
|
|
|
|||
89
inire/geometry/dynamic_path_index.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy
|
||||
import rtree
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from inire.geometry.index_helpers import build_index_payload, iter_grid_cells
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
|
||||
|
||||
class DynamicPathIndex:
|
||||
__slots__ = (
|
||||
"engine",
|
||||
"index",
|
||||
"geometries",
|
||||
"dilated",
|
||||
"tree",
|
||||
"obj_ids",
|
||||
"grid",
|
||||
"id_counter",
|
||||
"net_ids_array",
|
||||
"bounds_array",
|
||||
)
|
||||
|
||||
def __init__(self, engine: RoutingWorld) -> None:
|
||||
self.engine = engine
|
||||
self.index = rtree.index.Index()
|
||||
self.geometries: dict[int, tuple[str, Polygon]] = {}
|
||||
self.dilated: dict[int, Polygon] = {}
|
||||
self.tree: STRtree | None = None
|
||||
self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
|
||||
self.grid: dict[tuple[int, int], list[int]] = {}
|
||||
self.id_counter = 0
|
||||
self.net_ids_array = numpy.array([], dtype=object)
|
||||
self.bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4)
|
||||
|
||||
def invalidate_queries(self) -> None:
|
||||
self.tree = None
|
||||
self.grid = {}
|
||||
|
||||
def ensure_tree(self) -> None:
|
||||
if self.tree is None and self.dilated:
|
||||
ids, geometries, bounds_array = build_index_payload(self.dilated)
|
||||
self.tree = STRtree(geometries)
|
||||
self.obj_ids = numpy.array(ids, dtype=numpy.int32)
|
||||
self.bounds_array = bounds_array
|
||||
net_ids = [self.geometries[obj_id][0] for obj_id in self.obj_ids]
|
||||
self.net_ids_array = numpy.array(net_ids, dtype=object)
|
||||
|
||||
def ensure_grid(self) -> None:
|
||||
if self.grid or not self.dilated:
|
||||
return
|
||||
|
||||
cell_size = self.engine.grid_cell_size
|
||||
for obj_id, polygon in self.dilated.items():
|
||||
for cell in iter_grid_cells(polygon.bounds, cell_size):
|
||||
self.grid.setdefault(cell, []).append(obj_id)
|
||||
|
||||
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
|
||||
self.invalidate_queries()
|
||||
for index, polygon in enumerate(geometry):
|
||||
obj_id = self.id_counter
|
||||
self.id_counter += 1
|
||||
dilated = dilated_geometry[index]
|
||||
self.geometries[obj_id] = (net_id, polygon)
|
||||
self.dilated[obj_id] = dilated
|
||||
self.index.insert(obj_id, dilated.bounds)
|
||||
|
||||
def remove_path(self, net_id: str) -> None:
|
||||
to_remove = [obj_id for obj_id, (existing_net_id, _) in self.geometries.items() if existing_net_id == net_id]
|
||||
self.remove_obj_ids(to_remove)
|
||||
|
||||
def remove_obj_ids(self, obj_ids: list[int]) -> None:
|
||||
if not obj_ids:
|
||||
return
|
||||
|
||||
self.invalidate_queries()
|
||||
for obj_id in obj_ids:
|
||||
self.index.delete(obj_id, self.dilated[obj_id].bounds)
|
||||
del self.geometries[obj_id]
|
||||
del self.dilated[obj_id]
|
||||
48
inire/geometry/index_helpers.py
Normal 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
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Self
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
def _normalize_angle(angle_deg: int | float) -> int:
|
||||
|
|
@ -13,119 +13,43 @@ def _normalize_angle(angle_deg: int | float) -> int:
|
|||
raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}")
|
||||
return angle
|
||||
|
||||
|
||||
def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]:
|
||||
arr = numpy.asarray(value, dtype=numpy.int32)
|
||||
if arr.shape != (3,):
|
||||
raise ValueError(f"Port array must have shape (3,), got {arr.shape}")
|
||||
arr = arr.copy()
|
||||
arr[2] = _normalize_angle(int(arr[2]))
|
||||
return arr
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Port:
|
||||
"""
|
||||
Port represented as an ndarray-backed (x, y, r) triple with int32 storage.
|
||||
Port represented as a normalized integer (x, y, r) triple.
|
||||
"""
|
||||
|
||||
__slots__ = ("_xyr",)
|
||||
x: int | float
|
||||
y: int | float
|
||||
r: int | float
|
||||
|
||||
def __init__(self, x: int | float, y: int | float, r: int | float) -> None:
|
||||
self._xyr = numpy.array(
|
||||
(int(round(x)), int(round(y)), _normalize_angle(r)),
|
||||
dtype=numpy.int32,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_array(cls, xyr: ArrayLike) -> Self:
|
||||
obj = cls.__new__(cls)
|
||||
obj._xyr = _as_int32_triplet(xyr)
|
||||
return obj
|
||||
|
||||
@property
|
||||
def x(self) -> int:
|
||||
return int(self._xyr[0])
|
||||
|
||||
@x.setter
|
||||
def x(self, val: int | float) -> None:
|
||||
self._xyr[0] = int(round(val))
|
||||
|
||||
@property
|
||||
def y(self) -> int:
|
||||
return int(self._xyr[1])
|
||||
|
||||
@y.setter
|
||||
def y(self, val: int | float) -> None:
|
||||
self._xyr[1] = int(round(val))
|
||||
|
||||
@property
|
||||
def r(self) -> int:
|
||||
return int(self._xyr[2])
|
||||
|
||||
@r.setter
|
||||
def r(self, val: int | float) -> None:
|
||||
self._xyr[2] = _normalize_angle(val)
|
||||
|
||||
@property
|
||||
def orientation(self) -> int:
|
||||
return self.r
|
||||
|
||||
@orientation.setter
|
||||
def orientation(self, val: int | float) -> None:
|
||||
self.r = val
|
||||
|
||||
@property
|
||||
def xyr(self) -> NDArray[numpy.int32]:
|
||||
return self._xyr
|
||||
|
||||
@xyr.setter
|
||||
def xyr(self, val: ArrayLike) -> None:
|
||||
self._xyr = _as_int32_triplet(val)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Port(x={self.x}, y={self.y}, r={self.r})"
|
||||
|
||||
def __iter__(self) -> Iterator[int]:
|
||||
return iter((self.x, self.y, self.r))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return 3
|
||||
|
||||
def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]:
|
||||
return self._xyr[item]
|
||||
|
||||
def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]:
|
||||
return numpy.asarray(self._xyr, dtype=dtype)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Port):
|
||||
return False
|
||||
return bool(numpy.array_equal(self._xyr, other._xyr))
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.as_tuple())
|
||||
|
||||
def copy(self) -> Self:
|
||||
return type(self).from_array(self._xyr.copy())
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "x", int(round(self.x)))
|
||||
object.__setattr__(self, "y", int(round(self.y)))
|
||||
object.__setattr__(self, "r", _normalize_angle(self.r))
|
||||
|
||||
def as_tuple(self) -> tuple[int, int, int]:
|
||||
return (self.x, self.y, self.r)
|
||||
return (int(self.x), int(self.y), int(self.r))
|
||||
|
||||
def translate(self, dxy: ArrayLike) -> Self:
|
||||
dxy_arr = numpy.asarray(dxy, dtype=numpy.int32)
|
||||
if dxy_arr.shape == (2,):
|
||||
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r)
|
||||
if dxy_arr.shape == (3,):
|
||||
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2]))
|
||||
raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}")
|
||||
def translate(
|
||||
self,
|
||||
dx: int | float = 0,
|
||||
dy: int | float = 0,
|
||||
rotation: int | float = 0,
|
||||
) -> Self:
|
||||
return type(self)(self.x + dx, self.y + dy, self.r + rotation)
|
||||
|
||||
def __add__(self, other: ArrayLike) -> Self:
|
||||
return self.translate(other)
|
||||
|
||||
def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]:
|
||||
if isinstance(other, Port):
|
||||
return self._xyr - other._xyr
|
||||
return self._xyr - numpy.asarray(other, dtype=numpy.int32)
|
||||
def rotated(
|
||||
self,
|
||||
angle: int | float,
|
||||
origin: tuple[int | float, int | float] = (0, 0),
|
||||
) -> Self:
|
||||
angle_i = _normalize_angle(angle)
|
||||
rot = rotation_matrix2(angle_i)
|
||||
origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32)
|
||||
rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy
|
||||
rotated = origin_xy + rot @ rel
|
||||
return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i)
|
||||
|
||||
|
||||
ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32)
|
||||
|
|
@ -137,24 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32)
|
|||
def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]:
|
||||
quadrant = (_normalize_angle(rotation_deg) // 90) % 4
|
||||
return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant]
|
||||
|
||||
|
||||
def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]:
|
||||
rot2 = rotation_matrix2(rotation_deg)
|
||||
rot3 = numpy.zeros((3, 3), dtype=numpy.int32)
|
||||
rot3[:2, :2] = rot2
|
||||
rot3[2, 2] = 1
|
||||
return rot3
|
||||
|
||||
|
||||
def translate_port(port: Port, dx: int | float, dy: int | float) -> Port:
|
||||
return Port(port.x + dx, port.y + dy, port.r)
|
||||
|
||||
|
||||
def rotate_port(port: Port, angle: int | float, origin: tuple[int | float, int | float] = (0, 0)) -> Port:
|
||||
angle_i = _normalize_angle(angle)
|
||||
rot = rotation_matrix2(angle_i)
|
||||
origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32)
|
||||
rel = numpy.array((port.x, port.y), dtype=numpy.int32) - origin_xy
|
||||
rotated = origin_xy + rot @ rel
|
||||
return Port(int(rotated[0]), int(rotated[1]), port.r + angle_i)
|
||||
|
|
|
|||
126
inire/geometry/static_obstacle_index.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy
|
||||
import rtree
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from inire.geometry.index_helpers import build_index_payload, is_axis_aligned_rect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
|
||||
|
||||
class StaticObstacleIndex:
|
||||
__slots__ = (
|
||||
"engine",
|
||||
"index",
|
||||
"geometries",
|
||||
"dilated",
|
||||
"is_rect",
|
||||
"tree",
|
||||
"obj_ids",
|
||||
"bounds_array",
|
||||
"is_rect_array",
|
||||
"raw_tree",
|
||||
"raw_obj_ids",
|
||||
"net_specific_trees",
|
||||
"net_specific_is_rect",
|
||||
"net_specific_bounds",
|
||||
"id_counter",
|
||||
"version",
|
||||
)
|
||||
|
||||
def __init__(self, engine: RoutingWorld) -> None:
|
||||
self.engine = engine
|
||||
self.index = rtree.index.Index()
|
||||
self.geometries: dict[int, Polygon] = {}
|
||||
self.dilated: dict[int, Polygon] = {}
|
||||
self.is_rect: dict[int, bool] = {}
|
||||
self.tree: STRtree | None = None
|
||||
self.obj_ids: list[int] = []
|
||||
self.bounds_array: numpy.ndarray | None = None
|
||||
self.is_rect_array: numpy.ndarray | None = None
|
||||
self.raw_tree: STRtree | None = None
|
||||
self.raw_obj_ids: list[int] = []
|
||||
self.net_specific_trees: dict[tuple[float, float], STRtree] = {}
|
||||
self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {}
|
||||
self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {}
|
||||
self.id_counter = 0
|
||||
self.version = 0
|
||||
|
||||
def add_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
|
||||
obj_id = self.id_counter
|
||||
self.id_counter += 1
|
||||
|
||||
if dilated_geometry is not None:
|
||||
dilated = dilated_geometry
|
||||
else:
|
||||
dilated = polygon.buffer(self.engine.clearance / 2.0, join_style=2)
|
||||
|
||||
self.geometries[obj_id] = polygon
|
||||
self.dilated[obj_id] = dilated
|
||||
self.is_rect[obj_id] = is_axis_aligned_rect(dilated)
|
||||
self.index.insert(obj_id, dilated.bounds)
|
||||
self.invalidate_caches()
|
||||
return obj_id
|
||||
|
||||
def remove_obstacle(self, obj_id: int) -> None:
|
||||
if obj_id not in self.geometries:
|
||||
return
|
||||
|
||||
bounds = self.dilated[obj_id].bounds
|
||||
self.index.delete(obj_id, bounds)
|
||||
del self.geometries[obj_id]
|
||||
del self.dilated[obj_id]
|
||||
del self.is_rect[obj_id]
|
||||
self.invalidate_caches()
|
||||
|
||||
def invalidate_caches(self) -> None:
|
||||
self.tree = None
|
||||
self.bounds_array = None
|
||||
self.is_rect_array = None
|
||||
self.obj_ids = []
|
||||
self.raw_tree = None
|
||||
self.raw_obj_ids = []
|
||||
self.net_specific_trees.clear()
|
||||
self.net_specific_is_rect.clear()
|
||||
self.net_specific_bounds.clear()
|
||||
self.version += 1
|
||||
|
||||
def ensure_tree(self) -> None:
|
||||
if self.tree is None and self.dilated:
|
||||
self.obj_ids, geometries, self.bounds_array = build_index_payload(self.dilated)
|
||||
self.tree = STRtree(geometries)
|
||||
self.is_rect_array = numpy.array([self.is_rect[i] for i in self.obj_ids])
|
||||
|
||||
def ensure_net_tree(self, net_width: float) -> STRtree:
|
||||
key = (round(net_width, 4), round(self.engine.clearance, 4))
|
||||
if key in self.net_specific_trees:
|
||||
return self.net_specific_trees[key]
|
||||
|
||||
total_dilation = net_width / 2.0 + self.engine.clearance
|
||||
geometries = []
|
||||
is_rect_list = []
|
||||
bounds_list = []
|
||||
|
||||
for obj_id in sorted(self.geometries.keys()):
|
||||
polygon = self.geometries[obj_id]
|
||||
dilated = polygon.buffer(total_dilation, join_style=2)
|
||||
geometries.append(dilated)
|
||||
bounds_list.append(dilated.bounds)
|
||||
is_rect_list.append(is_axis_aligned_rect(dilated))
|
||||
|
||||
tree = STRtree(geometries)
|
||||
self.net_specific_trees[key] = tree
|
||||
self.net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool)
|
||||
self.net_specific_bounds[key] = numpy.array(bounds_list, dtype=numpy.float64)
|
||||
return tree
|
||||
|
||||
def ensure_raw_tree(self) -> None:
|
||||
if self.raw_tree is None and self.geometries:
|
||||
self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries)
|
||||
self.raw_tree = STRtree(geometries)
|
||||
107
inire/model.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.seeds import PathSeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
NetOrder = Literal["user", "shortest", "longest"]
|
||||
VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NetSpec:
|
||||
net_id: str
|
||||
start: Port
|
||||
target: Port
|
||||
width: float = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectiveWeights:
|
||||
unit_length_cost: float = 1.0
|
||||
bend_penalty: float = 250.0
|
||||
sbend_penalty: float = 500.0
|
||||
danger_weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchOptions:
|
||||
node_limit: int = 1000000
|
||||
max_straight_length: float = 2000.0
|
||||
min_straight_length: float = 5.0
|
||||
greedy_h_weight: float = 1.5
|
||||
sbend_offsets: tuple[float, ...] | None = None
|
||||
bend_radii: tuple[float, ...] = (50.0, 100.0)
|
||||
sbend_radii: tuple[float, ...] = (10.0,)
|
||||
bend_collision_type: BendCollisionModel = "arc"
|
||||
bend_clip_margin: float | None = None
|
||||
visibility_guidance: VisibilityGuidance = "tangent_corner"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "bend_radii", tuple(self.bend_radii))
|
||||
object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii))
|
||||
if self.sbend_offsets is not None:
|
||||
object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CongestionOptions:
|
||||
max_iterations: int = 10
|
||||
base_penalty: float = 100.0
|
||||
multiplier: float = 1.5
|
||||
use_tiered_strategy: bool = True
|
||||
net_order: NetOrder = "user"
|
||||
warm_start_enabled: bool = True
|
||||
shuffle_nets: bool = False
|
||||
seed: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RefinementOptions:
|
||||
enabled: bool = True
|
||||
objective: ObjectiveWeights | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DiagnosticsOptions:
|
||||
capture_expanded: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingOptions:
|
||||
search: SearchOptions = field(default_factory=SearchOptions)
|
||||
objective: ObjectiveWeights = field(default_factory=ObjectiveWeights)
|
||||
congestion: CongestionOptions = field(default_factory=CongestionOptions)
|
||||
refinement: RefinementOptions = field(default_factory=RefinementOptions)
|
||||
diagnostics: DiagnosticsOptions = field(default_factory=DiagnosticsOptions)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingProblem:
|
||||
bounds: tuple[float, float, float, float]
|
||||
nets: tuple[NetSpec, ...] = ()
|
||||
static_obstacles: tuple[Polygon, ...] = ()
|
||||
initial_paths: dict[str, PathSeed] = field(default_factory=dict)
|
||||
clearance: float = 2.0
|
||||
safety_zone_radius: float = 0.0021
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "nets", tuple(self.nets))
|
||||
object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles))
|
||||
initial_paths = dict(self.initial_paths)
|
||||
if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()):
|
||||
raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"initial_paths",
|
||||
initial_paths,
|
||||
)
|
||||
86
inire/results.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from inire.seeds import PathSeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingReport:
|
||||
static_collision_count: int = 0
|
||||
dynamic_collision_count: int = 0
|
||||
self_collision_count: int = 0
|
||||
total_length: float = 0.0
|
||||
|
||||
@property
|
||||
def collision_count(self) -> int:
|
||||
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.collision_count == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteMetrics:
|
||||
nodes_expanded: int
|
||||
moves_generated: int
|
||||
moves_added: int
|
||||
pruned_closed_set: int
|
||||
pruned_hard_collision: int
|
||||
pruned_cost: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingResult:
|
||||
net_id: str
|
||||
path: tuple[ComponentResult, ...]
|
||||
reached_target: bool = False
|
||||
report: RoutingReport = field(default_factory=RoutingReport)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "path", tuple(self.path))
|
||||
|
||||
@property
|
||||
def collisions(self) -> int:
|
||||
return self.report.collision_count
|
||||
|
||||
@property
|
||||
def outcome(self) -> RoutingOutcome:
|
||||
if not self.path:
|
||||
return "unroutable"
|
||||
if not self.reached_target:
|
||||
return "partial"
|
||||
if self.report.collision_count > 0:
|
||||
return "colliding"
|
||||
return "completed"
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.outcome == "completed"
|
||||
|
||||
@property
|
||||
def locked_geometry(self) -> tuple[Polygon, ...]:
|
||||
polygons = []
|
||||
for component in self.path:
|
||||
polygons.extend(component.physical_geometry)
|
||||
return tuple(polygons)
|
||||
|
||||
def as_seed(self) -> PathSeed:
|
||||
return PathSeed(tuple(component.move_spec for component in self.path))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingRunResult:
|
||||
results_by_net: dict[str, RoutingResult]
|
||||
metrics: RouteMetrics
|
||||
expanded_nodes: tuple[tuple[int, int, int], ...] = ()
|
||||
194
inire/router/_astar_admission.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.geometry.components import Bend90, SBend, Straight, MoveKind
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.refiner import component_hits_ancestor_chain
|
||||
|
||||
from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
def process_move(
|
||||
parent: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
config: SearchRunConfig,
|
||||
move_class: MoveKind,
|
||||
params: tuple,
|
||||
) -> None:
|
||||
cp = parent.port
|
||||
coll_type = config.bend_collision_type
|
||||
coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type
|
||||
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
|
||||
|
||||
abs_key = (
|
||||
cp.as_tuple(),
|
||||
move_class,
|
||||
params,
|
||||
net_width,
|
||||
coll_key,
|
||||
self_dilation,
|
||||
)
|
||||
if abs_key in context.move_cache_abs:
|
||||
res = context.move_cache_abs[abs_key]
|
||||
else:
|
||||
context.check_cache_eviction()
|
||||
base_port = Port(0, 0, cp.r)
|
||||
rel_key = (
|
||||
cp.r,
|
||||
move_class,
|
||||
params,
|
||||
net_width,
|
||||
coll_key,
|
||||
self_dilation,
|
||||
)
|
||||
if rel_key in context.move_cache_rel:
|
||||
res_rel = context.move_cache_rel[rel_key]
|
||||
else:
|
||||
try:
|
||||
if move_class == "straight":
|
||||
res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation)
|
||||
elif move_class == "bend90":
|
||||
res_rel = Bend90.generate(
|
||||
base_port,
|
||||
params[0],
|
||||
net_width,
|
||||
params[1],
|
||||
collision_type=coll_type,
|
||||
clip_margin=config.bend_clip_margin,
|
||||
dilation=self_dilation,
|
||||
)
|
||||
else:
|
||||
res_rel = SBend.generate(
|
||||
base_port,
|
||||
params[0],
|
||||
params[1],
|
||||
net_width,
|
||||
collision_type=coll_type,
|
||||
clip_margin=config.bend_clip_margin,
|
||||
dilation=self_dilation,
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
context.move_cache_rel[rel_key] = res_rel
|
||||
res = res_rel.translate(cp.x, cp.y)
|
||||
context.move_cache_abs[abs_key] = res
|
||||
|
||||
move_radius = params[0] if move_class == "bend90" else (params[1] if move_class == "sbend" else None)
|
||||
add_node(
|
||||
parent,
|
||||
res,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
move_class,
|
||||
abs_key,
|
||||
)
|
||||
|
||||
|
||||
def add_node(
|
||||
parent: AStarNode,
|
||||
result: ComponentResult,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
config: SearchRunConfig,
|
||||
move_type: MoveKind,
|
||||
cache_key: tuple,
|
||||
) -> None:
|
||||
metrics.moves_generated += 1
|
||||
metrics.total_moves_generated += 1
|
||||
state = result.end_port.as_tuple()
|
||||
new_lower_bound_g = parent.g_cost + result.length
|
||||
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
|
||||
metrics.pruned_closed_set += 1
|
||||
metrics.total_pruned_closed_set += 1
|
||||
return
|
||||
|
||||
parent_p = parent.port
|
||||
end_p = result.end_port
|
||||
|
||||
if cache_key in context.hard_collision_set:
|
||||
metrics.pruned_hard_collision += 1
|
||||
metrics.total_pruned_hard_collision += 1
|
||||
return
|
||||
|
||||
is_static_safe = cache_key in context.static_safe_cache
|
||||
if not is_static_safe:
|
||||
ce = context.cost_evaluator.collision_engine
|
||||
if move_type == "straight":
|
||||
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
|
||||
else:
|
||||
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
|
||||
if collision_found:
|
||||
context.hard_collision_set.add(cache_key)
|
||||
metrics.pruned_hard_collision += 1
|
||||
metrics.total_pruned_hard_collision += 1
|
||||
return
|
||||
context.static_safe_cache.add(cache_key)
|
||||
|
||||
total_overlaps = 0
|
||||
if not config.skip_congestion:
|
||||
if cache_key in congestion_cache:
|
||||
total_overlaps = congestion_cache[cache_key]
|
||||
else:
|
||||
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
|
||||
congestion_cache[cache_key] = total_overlaps
|
||||
|
||||
if config.self_collision_check and component_hits_ancestor_chain(result, parent):
|
||||
return
|
||||
|
||||
move_cost = context.cost_evaluator.score_component(
|
||||
result,
|
||||
start_port=parent_p,
|
||||
)
|
||||
move_cost += total_overlaps * context.congestion_penalty
|
||||
|
||||
if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost:
|
||||
metrics.pruned_cost += 1
|
||||
metrics.total_pruned_cost += 1
|
||||
return
|
||||
if move_cost > 1e12:
|
||||
metrics.pruned_cost += 1
|
||||
metrics.total_pruned_cost += 1
|
||||
return
|
||||
|
||||
g_cost = parent.g_cost + move_cost
|
||||
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
|
||||
metrics.pruned_closed_set += 1
|
||||
metrics.total_pruned_closed_set += 1
|
||||
return
|
||||
|
||||
h_cost = context.cost_evaluator.h_manhattan(
|
||||
result.end_port,
|
||||
target,
|
||||
min_bend_radius=context.min_bend_radius,
|
||||
)
|
||||
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
|
||||
metrics.moves_added += 1
|
||||
metrics.total_moves_added += 1
|
||||
286
inire/router/_astar_moves.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.geometry.components import MoveKind
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
from ._astar_admission import process_move
|
||||
from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
|
||||
|
||||
|
||||
def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
|
||||
out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01}
|
||||
return sorted((v for v in out if v > 0), reverse=True)
|
||||
|
||||
|
||||
def _sbend_forward_span(offset: float, radius: float) -> float | None:
|
||||
abs_offset = abs(offset)
|
||||
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
|
||||
return None
|
||||
theta = math.acos(1.0 - abs_offset / (2.0 * radius))
|
||||
return 2.0 * radius * math.sin(theta)
|
||||
|
||||
|
||||
def _visible_straight_candidates(
|
||||
current: Port,
|
||||
context: AStarContext,
|
||||
max_reach: float,
|
||||
cos_v: float,
|
||||
sin_v: float,
|
||||
net_width: float,
|
||||
) -> list[float]:
|
||||
search_options = context.options.search
|
||||
mode = search_options.visibility_guidance
|
||||
if mode == "off":
|
||||
return []
|
||||
|
||||
if mode == "exact_corner":
|
||||
max_bend_radius = max(search_options.bend_radii, default=0.0)
|
||||
visibility_reach = max_reach + max_bend_radius
|
||||
visible_corners = sorted(
|
||||
context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach),
|
||||
key=lambda corner: corner[2],
|
||||
)
|
||||
if not visible_corners:
|
||||
return []
|
||||
|
||||
candidates: set[int] = set()
|
||||
for cx, cy, _ in visible_corners[:12]:
|
||||
dx = cx - current.x
|
||||
dy = cy - current.y
|
||||
local_x = dx * cos_v + dy * sin_v
|
||||
if local_x <= search_options.min_straight_length:
|
||||
continue
|
||||
candidates.add(int(round(local_x)))
|
||||
return sorted(candidates, reverse=True)
|
||||
|
||||
if mode != "tangent_corner":
|
||||
return []
|
||||
|
||||
visibility_manager = context.visibility_manager
|
||||
visibility_manager._ensure_current()
|
||||
max_bend_radius = max(search_options.bend_radii, default=0.0)
|
||||
if max_bend_radius <= 0 or not visibility_manager.corners:
|
||||
return []
|
||||
|
||||
reach = max_reach + max_bend_radius
|
||||
bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
|
||||
candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
|
||||
if not candidate_ids:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, float, float, float, float]] = []
|
||||
for idx in candidate_ids:
|
||||
cx, cy = visibility_manager.corners[idx]
|
||||
dx = cx - current.x
|
||||
dy = cy - current.y
|
||||
local_x = dx * cos_v + dy * sin_v
|
||||
local_y = -dx * sin_v + dy * cos_v
|
||||
if local_x <= search_options.min_straight_length or local_x > reach + 0.01:
|
||||
continue
|
||||
|
||||
nearest_radius = min(search_options.bend_radii, key=lambda radius: abs(abs(local_y) - radius))
|
||||
tangent_error = abs(abs(local_y) - nearest_radius)
|
||||
if tangent_error > 2.0:
|
||||
continue
|
||||
|
||||
length = local_x - nearest_radius
|
||||
if length <= search_options.min_straight_length or length > max_reach + 0.01:
|
||||
continue
|
||||
|
||||
scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy))
|
||||
|
||||
if not scored:
|
||||
return []
|
||||
|
||||
collision_engine = context.cost_evaluator.collision_engine
|
||||
candidates: set[int] = set()
|
||||
for _, dist, length, dx, dy in sorted(scored)[:4]:
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
|
||||
if corner_reach < dist - 0.01:
|
||||
continue
|
||||
qlen = int(round(length))
|
||||
if qlen > 0:
|
||||
candidates.add(qlen)
|
||||
|
||||
return sorted(candidates, reverse=True)
|
||||
|
||||
|
||||
def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]:
|
||||
result = node.component_result
|
||||
if result is None:
|
||||
return None, None
|
||||
move_type = result.move_type
|
||||
if move_type == "straight":
|
||||
return move_type, result.length
|
||||
return move_type, None
|
||||
|
||||
|
||||
def expand_moves(
|
||||
current: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
config: SearchRunConfig,
|
||||
) -> None:
|
||||
search_options = context.options.search
|
||||
cp = current.port
|
||||
prev_move_type, prev_straight_length = _previous_move_metadata(current)
|
||||
dx_t = target.x - cp.x
|
||||
dy_t = target.y - cp.y
|
||||
dist_sq = dx_t * dx_t + dy_t * dy_t
|
||||
|
||||
if cp.r == 0:
|
||||
cos_v, sin_v = 1.0, 0.0
|
||||
elif cp.r == 90:
|
||||
cos_v, sin_v = 0.0, 1.0
|
||||
elif cp.r == 180:
|
||||
cos_v, sin_v = -1.0, 0.0
|
||||
else:
|
||||
cos_v, sin_v = 0.0, -1.0
|
||||
|
||||
proj_t = dx_t * cos_v + dy_t * sin_v
|
||||
perp_t = -dx_t * sin_v + dy_t * cos_v
|
||||
dx_local = proj_t
|
||||
dy_local = perp_t
|
||||
|
||||
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
|
||||
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
|
||||
if max_reach >= proj_t - 0.01 and (
|
||||
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
|
||||
):
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"straight",
|
||||
(int(round(proj_t)),),
|
||||
)
|
||||
|
||||
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width)
|
||||
candidate_lengths = [
|
||||
search_options.min_straight_length,
|
||||
max_reach,
|
||||
max_reach / 2.0,
|
||||
max_reach - 5.0,
|
||||
]
|
||||
|
||||
axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t)
|
||||
candidate_lengths.append(axis_target_dist)
|
||||
for radius in search_options.bend_radii:
|
||||
candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius))
|
||||
|
||||
candidate_lengths.extend(
|
||||
_visible_straight_candidates(
|
||||
cp,
|
||||
context,
|
||||
max_reach,
|
||||
cos_v,
|
||||
sin_v,
|
||||
net_width,
|
||||
)
|
||||
)
|
||||
|
||||
if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR:
|
||||
for radius in search_options.sbend_radii:
|
||||
sbend_span = _sbend_forward_span(dy_local, radius)
|
||||
if sbend_span is None:
|
||||
continue
|
||||
candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span))
|
||||
|
||||
for length in _quantized_lengths(candidate_lengths, max_reach):
|
||||
if length < search_options.min_straight_length:
|
||||
continue
|
||||
if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"straight",
|
||||
(length,),
|
||||
)
|
||||
|
||||
angle_to_target = 0.0
|
||||
if dx_t != 0 or dy_t != 0:
|
||||
angle_to_target = float((round((180.0 / math.pi) * math.atan2(dy_t, dx_t)) + 360.0) % 360.0)
|
||||
allow_backwards = dist_sq < 150 * 150
|
||||
|
||||
for radius in search_options.bend_radii:
|
||||
for direction in ("CW", "CCW"):
|
||||
if not allow_backwards:
|
||||
turn = 90 if direction == "CCW" else -90
|
||||
new_ori = (cp.r + turn) % 360
|
||||
new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0
|
||||
if abs(new_diff) > 135.0:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"bend90",
|
||||
(radius, direction),
|
||||
)
|
||||
|
||||
max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0
|
||||
if max_sbend_r <= 0 or prev_move_type == "sbend":
|
||||
return
|
||||
|
||||
explicit_offsets = search_options.sbend_offsets
|
||||
offsets: set[int] = {int(round(v)) for v in explicit_offsets or []}
|
||||
|
||||
if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r and 0 < abs(dy_local) < 2 * max_sbend_r:
|
||||
offsets.add(int(round(dy_local)))
|
||||
|
||||
if not offsets:
|
||||
return
|
||||
|
||||
for offset in sorted(offsets):
|
||||
if offset == 0:
|
||||
continue
|
||||
for radius in search_options.sbend_radii:
|
||||
if abs(offset) >= 2 * radius:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"sbend",
|
||||
(offset, radius),
|
||||
)
|
||||
194
inire/router/_astar_types.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
from inire.results import RouteMetrics
|
||||
from inire.router.visibility import VisibilityManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchRunConfig:
|
||||
bend_collision_type: BendCollisionModel
|
||||
bend_clip_margin: float | None
|
||||
node_limit: int
|
||||
return_partial: bool = False
|
||||
store_expanded: bool = False
|
||||
skip_congestion: bool = False
|
||||
max_cost: float | None = None
|
||||
self_collision_check: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_options(
|
||||
cls,
|
||||
options: RoutingOptions,
|
||||
*,
|
||||
bend_collision_type: BendCollisionModel | None = None,
|
||||
node_limit: int | None = None,
|
||||
return_partial: bool = False,
|
||||
store_expanded: bool = False,
|
||||
skip_congestion: bool = False,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
) -> SearchRunConfig:
|
||||
search = options.search
|
||||
return cls(
|
||||
bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type,
|
||||
bend_clip_margin=search.bend_clip_margin,
|
||||
node_limit=search.node_limit if node_limit is None else node_limit,
|
||||
return_partial=return_partial,
|
||||
store_expanded=store_expanded,
|
||||
skip_congestion=skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
|
||||
class AStarNode:
|
||||
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port,
|
||||
g_cost: float,
|
||||
h_cost: float,
|
||||
parent: AStarNode | None = None,
|
||||
component_result: ComponentResult | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.g_cost = g_cost
|
||||
self.h_cost = h_cost
|
||||
self.fh_cost = (g_cost + h_cost, h_cost)
|
||||
self.parent = parent
|
||||
self.component_result = component_result
|
||||
|
||||
def __lt__(self, other: AStarNode) -> bool:
|
||||
return self.fh_cost < other.fh_cost
|
||||
|
||||
|
||||
class AStarMetrics:
|
||||
__slots__ = (
|
||||
"total_nodes_expanded",
|
||||
"total_moves_generated",
|
||||
"total_moves_added",
|
||||
"total_pruned_closed_set",
|
||||
"total_pruned_hard_collision",
|
||||
"total_pruned_cost",
|
||||
"last_expanded_nodes",
|
||||
"nodes_expanded",
|
||||
"moves_generated",
|
||||
"moves_added",
|
||||
"pruned_closed_set",
|
||||
"pruned_hard_collision",
|
||||
"pruned_cost",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.total_nodes_expanded = 0
|
||||
self.total_moves_generated = 0
|
||||
self.total_moves_added = 0
|
||||
self.total_pruned_closed_set = 0
|
||||
self.total_pruned_hard_collision = 0
|
||||
self.total_pruned_cost = 0
|
||||
self.last_expanded_nodes: list[tuple[int, int, int]] = []
|
||||
self.nodes_expanded = 0
|
||||
self.moves_generated = 0
|
||||
self.moves_added = 0
|
||||
self.pruned_closed_set = 0
|
||||
self.pruned_hard_collision = 0
|
||||
self.pruned_cost = 0
|
||||
|
||||
def reset_totals(self) -> None:
|
||||
self.total_nodes_expanded = 0
|
||||
self.total_moves_generated = 0
|
||||
self.total_moves_added = 0
|
||||
self.total_pruned_closed_set = 0
|
||||
self.total_pruned_hard_collision = 0
|
||||
self.total_pruned_cost = 0
|
||||
|
||||
def reset_per_route(self) -> None:
|
||||
self.nodes_expanded = 0
|
||||
self.moves_generated = 0
|
||||
self.moves_added = 0
|
||||
self.pruned_closed_set = 0
|
||||
self.pruned_hard_collision = 0
|
||||
self.pruned_cost = 0
|
||||
self.last_expanded_nodes = []
|
||||
|
||||
def snapshot(self) -> RouteMetrics:
|
||||
return RouteMetrics(
|
||||
nodes_expanded=self.total_nodes_expanded,
|
||||
moves_generated=self.total_moves_generated,
|
||||
moves_added=self.total_moves_added,
|
||||
pruned_closed_set=self.total_pruned_closed_set,
|
||||
pruned_hard_collision=self.total_pruned_hard_collision,
|
||||
pruned_cost=self.total_pruned_cost,
|
||||
)
|
||||
|
||||
|
||||
class AStarContext:
|
||||
__slots__ = (
|
||||
"cost_evaluator",
|
||||
"congestion_penalty",
|
||||
"min_bend_radius",
|
||||
"problem",
|
||||
"options",
|
||||
"max_cache_size",
|
||||
"visibility_manager",
|
||||
"move_cache_rel",
|
||||
"move_cache_abs",
|
||||
"hard_collision_set",
|
||||
"static_safe_cache",
|
||||
"static_cache_version",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cost_evaluator: CostEvaluator,
|
||||
problem: RoutingProblem,
|
||||
options: RoutingOptions,
|
||||
max_cache_size: int = 1000000,
|
||||
) -> None:
|
||||
self.cost_evaluator = cost_evaluator
|
||||
self.congestion_penalty = 0.0
|
||||
self.max_cache_size = max_cache_size
|
||||
self.problem = problem
|
||||
self.options = options
|
||||
self.min_bend_radius = min(self.options.search.bend_radii, default=50.0)
|
||||
self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine)
|
||||
self.move_cache_rel: dict[tuple, ComponentResult] = {}
|
||||
self.move_cache_abs: dict[tuple, ComponentResult] = {}
|
||||
self.hard_collision_set: set[tuple] = set()
|
||||
self.static_safe_cache: set[tuple] = set()
|
||||
self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version()
|
||||
|
||||
def clear_static_caches(self) -> None:
|
||||
self.hard_collision_set.clear()
|
||||
self.static_safe_cache.clear()
|
||||
self.visibility_manager.clear_cache()
|
||||
self.static_cache_version = self.cost_evaluator.collision_engine.get_static_version()
|
||||
|
||||
def ensure_static_caches_current(self) -> None:
|
||||
current_version = self.cost_evaluator.collision_engine.get_static_version()
|
||||
if self.static_cache_version != current_version:
|
||||
self.clear_static_caches()
|
||||
|
||||
def _evict_cache(self, cache: dict[tuple, ComponentResult]) -> None:
|
||||
if len(cache) <= self.max_cache_size * 1.2:
|
||||
return
|
||||
|
||||
num_to_evict = max(1, int(len(cache) * 0.25))
|
||||
for idx, key in enumerate(tuple(cache.keys())):
|
||||
if idx >= num_to_evict:
|
||||
break
|
||||
del cache[key]
|
||||
|
||||
def check_cache_eviction(self) -> None:
|
||||
self._evict_cache(self.move_cache_rel)
|
||||
self._evict_cache(self.move_cache_abs)
|
||||
318
inire/router/_router.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.model import NetOrder, NetSpec
|
||||
from inire.results import RoutingOutcome, RoutingReport, RoutingResult
|
||||
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router._seed_materialization import materialize_path_seed
|
||||
from inire.router.refiner import PathRefiner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RoutingState:
|
||||
net_specs: dict[str, NetSpec]
|
||||
ordered_net_ids: list[str]
|
||||
results: dict[str, RoutingResult]
|
||||
needs_self_collision_check: set[str]
|
||||
start_time: float
|
||||
timeout_s: float
|
||||
initial_paths: dict[str, tuple[ComponentResult, ...]] | None
|
||||
accumulated_expanded_nodes: list[tuple[int, int, int]]
|
||||
|
||||
class PathFinder:
|
||||
__slots__ = (
|
||||
"context",
|
||||
"metrics",
|
||||
"refiner",
|
||||
"accumulated_expanded_nodes",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics | None = None,
|
||||
) -> None:
|
||||
self.context = context
|
||||
self.metrics = metrics if metrics is not None else AStarMetrics()
|
||||
self.refiner = PathRefiner(self.context)
|
||||
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
|
||||
|
||||
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
|
||||
all_geoms = []
|
||||
all_dilated = []
|
||||
for result in path:
|
||||
all_geoms.extend(result.collision_geometry)
|
||||
all_dilated.extend(result.dilated_collision_geometry)
|
||||
self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
|
||||
|
||||
def _routing_order(
|
||||
self,
|
||||
net_specs: dict[str, NetSpec],
|
||||
order: NetOrder,
|
||||
) -> list[str]:
|
||||
ordered_net_ids = list(net_specs.keys())
|
||||
if order == "user":
|
||||
return ordered_net_ids
|
||||
ordered_net_ids.sort(
|
||||
key=lambda net_id: abs(net_specs[net_id].target.x - net_specs[net_id].start.x)
|
||||
+ abs(net_specs[net_id].target.y - net_specs[net_id].start.y),
|
||||
reverse=(order == "longest"),
|
||||
)
|
||||
return ordered_net_ids
|
||||
|
||||
def _build_greedy_warm_start_paths(
|
||||
self,
|
||||
net_specs: dict[str, NetSpec],
|
||||
order: NetOrder,
|
||||
) -> dict[str, tuple[ComponentResult, ...]]:
|
||||
greedy_paths: dict[str, tuple[ComponentResult, ...]] = {}
|
||||
temp_obj_ids: list[int] = []
|
||||
greedy_node_limit = min(self.context.options.search.node_limit, 2000)
|
||||
for net_id in self._routing_order(net_specs, order):
|
||||
net = net_specs[net_id]
|
||||
h_start = self.context.cost_evaluator.h_manhattan(
|
||||
net.start,
|
||||
net.target,
|
||||
min_bend_radius=self.context.min_bend_radius,
|
||||
)
|
||||
max_cost_limit = max(h_start * 3.0, 2000.0)
|
||||
run_config = SearchRunConfig.from_options(
|
||||
self.context.options,
|
||||
skip_congestion=True,
|
||||
max_cost=max_cost_limit,
|
||||
self_collision_check=True,
|
||||
node_limit=greedy_node_limit,
|
||||
)
|
||||
path = route_astar(
|
||||
net.start,
|
||||
net.target,
|
||||
net.width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
config=run_config,
|
||||
)
|
||||
if not path:
|
||||
continue
|
||||
greedy_paths[net_id] = tuple(path)
|
||||
for result in path:
|
||||
for polygon in result.physical_geometry:
|
||||
temp_obj_ids.append(self.context.cost_evaluator.collision_engine.add_static_obstacle(polygon))
|
||||
self.context.clear_static_caches()
|
||||
|
||||
for obj_id in temp_obj_ids:
|
||||
self.context.cost_evaluator.collision_engine.remove_static_obstacle(obj_id)
|
||||
return greedy_paths
|
||||
|
||||
def _prepare_state(self) -> _RoutingState:
|
||||
problem = self.context.problem
|
||||
congestion = self.context.options.congestion
|
||||
initial_paths = self._materialize_problem_initial_paths()
|
||||
net_specs = {net.net_id: net for net in problem.nets}
|
||||
num_nets = len(net_specs)
|
||||
state = _RoutingState(
|
||||
net_specs=net_specs,
|
||||
ordered_net_ids=list(net_specs.keys()),
|
||||
results={},
|
||||
needs_self_collision_check=set(),
|
||||
start_time=time.monotonic(),
|
||||
timeout_s=max(60.0, 10.0 * num_nets * congestion.max_iterations),
|
||||
initial_paths=initial_paths,
|
||||
accumulated_expanded_nodes=[],
|
||||
)
|
||||
if state.initial_paths is None and congestion.warm_start_enabled:
|
||||
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order)
|
||||
self.context.clear_static_caches()
|
||||
|
||||
if congestion.net_order != "user":
|
||||
state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order)
|
||||
return state
|
||||
|
||||
def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None:
|
||||
if not self.context.problem.initial_paths:
|
||||
return None
|
||||
|
||||
search = self.context.options.search
|
||||
net_specs = {net.net_id: net for net in self.context.problem.nets}
|
||||
initial_paths: dict[str, tuple[ComponentResult, ...]] = {}
|
||||
for net_id, seed in self.context.problem.initial_paths.items():
|
||||
if net_id not in net_specs:
|
||||
raise ValueError(f"Initial path provided for unknown net: {net_id}")
|
||||
net = net_specs[net_id]
|
||||
initial_paths[net_id] = materialize_path_seed(
|
||||
seed,
|
||||
start=net.start,
|
||||
net_width=net.width,
|
||||
search=search,
|
||||
clearance=self.context.cost_evaluator.collision_engine.clearance,
|
||||
)
|
||||
return initial_paths
|
||||
|
||||
def _route_net_once(
|
||||
self,
|
||||
state: _RoutingState,
|
||||
iteration: int,
|
||||
net_id: str,
|
||||
) -> RoutingResult:
|
||||
search = self.context.options.search
|
||||
congestion = self.context.options.congestion
|
||||
diagnostics = self.context.options.diagnostics
|
||||
net = state.net_specs[net_id]
|
||||
self.context.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
|
||||
if iteration == 0 and state.initial_paths and net_id in state.initial_paths:
|
||||
path: Sequence[ComponentResult] | None = state.initial_paths[net_id]
|
||||
else:
|
||||
coll_model = search.bend_collision_type
|
||||
skip_congestion = False
|
||||
if congestion.use_tiered_strategy and iteration == 0:
|
||||
skip_congestion = True
|
||||
if coll_model == "arc":
|
||||
coll_model = "clipped_bbox"
|
||||
|
||||
run_config = SearchRunConfig.from_options(
|
||||
self.context.options,
|
||||
bend_collision_type=coll_model,
|
||||
return_partial=True,
|
||||
store_expanded=diagnostics.capture_expanded,
|
||||
skip_congestion=skip_congestion,
|
||||
self_collision_check=(net_id in state.needs_self_collision_check),
|
||||
node_limit=search.node_limit,
|
||||
)
|
||||
path = route_astar(
|
||||
net.start,
|
||||
net.target,
|
||||
net.width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
config=run_config,
|
||||
)
|
||||
|
||||
if diagnostics.capture_expanded and self.metrics.last_expanded_nodes:
|
||||
state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
|
||||
|
||||
if not path:
|
||||
return RoutingResult(net_id=net_id, path=(), reached_target=False)
|
||||
|
||||
reached_target = path[-1].end_port == net.target
|
||||
report = None
|
||||
self._install_path(net_id, path)
|
||||
if reached_target:
|
||||
report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, path)
|
||||
if report.self_collision_count > 0:
|
||||
state.needs_self_collision_check.add(net_id)
|
||||
|
||||
return RoutingResult(
|
||||
net_id=net_id,
|
||||
path=path,
|
||||
reached_target=reached_target,
|
||||
report=RoutingReport() if report is None else report,
|
||||
)
|
||||
|
||||
def _run_iteration(
|
||||
self,
|
||||
state: _RoutingState,
|
||||
iteration: int,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
|
||||
) -> dict[str, RoutingOutcome] | None:
|
||||
outcomes: dict[str, RoutingOutcome] = {}
|
||||
congestion = self.context.options.congestion
|
||||
self.metrics.reset_per_route()
|
||||
|
||||
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
|
||||
iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None
|
||||
random.Random(iteration_seed).shuffle(state.ordered_net_ids)
|
||||
|
||||
for net_id in state.ordered_net_ids:
|
||||
if time.monotonic() - state.start_time > state.timeout_s:
|
||||
return None
|
||||
|
||||
result = self._route_net_once(state, iteration, net_id)
|
||||
state.results[net_id] = result
|
||||
outcomes[net_id] = result.outcome
|
||||
|
||||
if iteration_callback:
|
||||
iteration_callback(iteration, state.results)
|
||||
return outcomes
|
||||
|
||||
def _run_iterations(
|
||||
self,
|
||||
state: _RoutingState,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
|
||||
) -> bool:
|
||||
congestion = self.context.options.congestion
|
||||
for iteration in range(congestion.max_iterations):
|
||||
outcomes = self._run_iteration(state, iteration, iteration_callback)
|
||||
if outcomes is None:
|
||||
return True
|
||||
if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()):
|
||||
return False
|
||||
self.context.congestion_penalty *= congestion.multiplier
|
||||
return False
|
||||
|
||||
def _refine_results(self, state: _RoutingState) -> None:
|
||||
if not self.context.options.refinement.enabled or not state.results:
|
||||
return
|
||||
|
||||
for net_id in state.ordered_net_ids:
|
||||
result = state.results.get(net_id)
|
||||
if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}:
|
||||
continue
|
||||
net = state.net_specs[net_id]
|
||||
self.context.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
|
||||
self._install_path(net_id, refined_path)
|
||||
report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
|
||||
state.results[net_id] = RoutingResult(
|
||||
net_id=net_id,
|
||||
path=refined_path,
|
||||
reached_target=result.reached_target,
|
||||
report=report,
|
||||
)
|
||||
|
||||
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
|
||||
final_results: dict[str, RoutingResult] = {}
|
||||
for net in self.context.problem.nets:
|
||||
result = state.results.get(net.net_id)
|
||||
if not result or not result.path:
|
||||
final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
|
||||
continue
|
||||
report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path)
|
||||
final_results[net.net_id] = RoutingResult(
|
||||
net_id=net.net_id,
|
||||
path=result.path,
|
||||
reached_target=result.reached_target,
|
||||
report=report,
|
||||
)
|
||||
return final_results
|
||||
|
||||
def route_all(
|
||||
self,
|
||||
*,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
|
||||
) -> dict[str, RoutingResult]:
|
||||
self.context.congestion_penalty = self.context.options.congestion.base_penalty
|
||||
self.accumulated_expanded_nodes = []
|
||||
self.metrics.reset_totals()
|
||||
self.metrics.reset_per_route()
|
||||
|
||||
state = self._prepare_state()
|
||||
timed_out = self._run_iterations(state, iteration_callback)
|
||||
self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes)
|
||||
|
||||
if timed_out:
|
||||
return self._verify_results(state)
|
||||
|
||||
self._refine_results(state)
|
||||
return self._verify_results(state)
|
||||
95
inire/router/_search.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
from ._astar_moves import expand_moves as _expand_moves
|
||||
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]:
|
||||
path = []
|
||||
curr: _AStarNode | None = end_node
|
||||
while curr and curr.component_result:
|
||||
path.append(curr.component_result)
|
||||
curr = curr.parent
|
||||
return path[::-1]
|
||||
|
||||
|
||||
def route_astar(
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
context: AStarContext,
|
||||
*,
|
||||
metrics: AStarMetrics | None = None,
|
||||
net_id: str = "default",
|
||||
config: SearchRunConfig,
|
||||
) -> list[ComponentResult] | None:
|
||||
if metrics is None:
|
||||
metrics = AStarMetrics()
|
||||
metrics.reset_per_route()
|
||||
|
||||
context.ensure_static_caches_current()
|
||||
context.cost_evaluator.set_target(target)
|
||||
open_set: list[_AStarNode] = []
|
||||
closed_set: dict[tuple[int, int, int], float] = {}
|
||||
congestion_cache: dict[tuple, int] = {}
|
||||
|
||||
start_node = _AStarNode(
|
||||
start,
|
||||
0.0,
|
||||
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
|
||||
)
|
||||
heapq.heappush(open_set, start_node)
|
||||
best_node = start_node
|
||||
nodes_expanded = 0
|
||||
|
||||
while open_set:
|
||||
if nodes_expanded >= config.node_limit:
|
||||
return _reconstruct_path(best_node) if config.return_partial else None
|
||||
|
||||
current = heapq.heappop(open_set)
|
||||
if config.max_cost is not None and current.fh_cost[0] > config.max_cost:
|
||||
metrics.pruned_cost += 1
|
||||
metrics.total_pruned_cost += 1
|
||||
continue
|
||||
|
||||
if current.h_cost < best_node.h_cost:
|
||||
best_node = current
|
||||
|
||||
state = current.port.as_tuple()
|
||||
if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR:
|
||||
continue
|
||||
closed_set[state] = current.g_cost
|
||||
|
||||
if config.store_expanded:
|
||||
metrics.last_expanded_nodes.append(state)
|
||||
|
||||
nodes_expanded += 1
|
||||
metrics.total_nodes_expanded += 1
|
||||
metrics.nodes_expanded += 1
|
||||
|
||||
if current.port == target:
|
||||
return _reconstruct_path(current)
|
||||
|
||||
_expand_moves(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config=config,
|
||||
)
|
||||
|
||||
return _reconstruct_path(best_node) if config.return_partial else None
|
||||
56
inire/router/_seed_materialization.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.model import SearchOptions
|
||||
from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
def materialize_path_seed(
|
||||
seed: PathSeed,
|
||||
*,
|
||||
start: Port,
|
||||
net_width: float,
|
||||
search: SearchOptions,
|
||||
clearance: float,
|
||||
) -> tuple[ComponentResult, ...]:
|
||||
from inire.geometry.components import Bend90, SBend, Straight
|
||||
|
||||
path: list[ComponentResult] = []
|
||||
current = start
|
||||
dilation = clearance / 2.0
|
||||
bend_collision_type = search.bend_collision_type
|
||||
bend_clip_margin = search.bend_clip_margin
|
||||
|
||||
for segment in seed.segments:
|
||||
if isinstance(segment, StraightSeed):
|
||||
component = Straight.generate(current, segment.length, net_width, dilation=dilation)
|
||||
elif isinstance(segment, Bend90Seed):
|
||||
component = Bend90.generate(
|
||||
current,
|
||||
segment.radius,
|
||||
net_width,
|
||||
segment.direction,
|
||||
collision_type=bend_collision_type,
|
||||
clip_margin=bend_clip_margin,
|
||||
dilation=dilation,
|
||||
)
|
||||
elif isinstance(segment, SBendSeed):
|
||||
component = SBend.generate(
|
||||
current,
|
||||
segment.offset,
|
||||
segment.radius,
|
||||
net_width,
|
||||
collision_type=bend_collision_type,
|
||||
clip_margin=bend_clip_margin,
|
||||
dilation=dilation,
|
||||
)
|
||||
else:
|
||||
raise TypeError(f"Unsupported seed segment: {type(segment)!r}")
|
||||
path.append(component)
|
||||
current = component.end_port
|
||||
return tuple(path)
|
||||
52
inire/router/_stack.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingStack:
|
||||
world: object
|
||||
danger_map: object
|
||||
evaluator: object
|
||||
context: object
|
||||
finder: object
|
||||
|
||||
|
||||
def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack:
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
world = RoutingWorld(
|
||||
clearance=problem.clearance,
|
||||
safety_zone_radius=problem.safety_zone_radius,
|
||||
)
|
||||
for obstacle in problem.static_obstacles:
|
||||
world.add_static_obstacle(obstacle)
|
||||
|
||||
danger_map = DangerMap(bounds=problem.bounds)
|
||||
danger_map.precompute(list(problem.static_obstacles))
|
||||
|
||||
objective = options.objective
|
||||
evaluator = CostEvaluator(
|
||||
world,
|
||||
danger_map,
|
||||
unit_length_cost=objective.unit_length_cost,
|
||||
greedy_h_weight=options.search.greedy_h_weight,
|
||||
bend_penalty=objective.bend_penalty,
|
||||
sbend_penalty=objective.sbend_penalty,
|
||||
danger_weight=objective.danger_weight,
|
||||
)
|
||||
context = AStarContext(evaluator, problem, options)
|
||||
finder = PathFinder(context)
|
||||
return RoutingStack(
|
||||
world=world,
|
||||
danger_map=danger_map,
|
||||
evaluator=evaluator,
|
||||
context=context,
|
||||
finder=finder,
|
||||
)
|
||||
|
|
@ -1,721 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import shapely
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.geometry.components import Bend90, SBend, Straight
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.config import RouterConfig, VisibilityGuidanceMode
|
||||
from inire.router.visibility import VisibilityManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AStarNode:
|
||||
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: Port,
|
||||
g_cost: float,
|
||||
h_cost: float,
|
||||
parent: AStarNode | None = None,
|
||||
component_result: ComponentResult | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.g_cost = g_cost
|
||||
self.h_cost = h_cost
|
||||
self.fh_cost = (g_cost + h_cost, h_cost)
|
||||
self.parent = parent
|
||||
self.component_result = component_result
|
||||
|
||||
def __lt__(self, other: AStarNode) -> bool:
|
||||
return self.fh_cost < other.fh_cost
|
||||
|
||||
|
||||
class AStarMetrics:
|
||||
__slots__ = (
|
||||
"total_nodes_expanded",
|
||||
"last_expanded_nodes",
|
||||
"nodes_expanded",
|
||||
"moves_generated",
|
||||
"moves_added",
|
||||
"pruned_closed_set",
|
||||
"pruned_hard_collision",
|
||||
"pruned_cost",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.total_nodes_expanded = 0
|
||||
self.last_expanded_nodes: list[tuple[int, int, int]] = []
|
||||
self.nodes_expanded = 0
|
||||
self.moves_generated = 0
|
||||
self.moves_added = 0
|
||||
self.pruned_closed_set = 0
|
||||
self.pruned_hard_collision = 0
|
||||
self.pruned_cost = 0
|
||||
|
||||
def reset_per_route(self) -> None:
|
||||
self.nodes_expanded = 0
|
||||
self.moves_generated = 0
|
||||
self.moves_added = 0
|
||||
self.pruned_closed_set = 0
|
||||
self.pruned_hard_collision = 0
|
||||
self.pruned_cost = 0
|
||||
self.last_expanded_nodes = []
|
||||
|
||||
|
||||
class AStarContext:
|
||||
__slots__ = (
|
||||
"cost_evaluator",
|
||||
"config",
|
||||
"visibility_manager",
|
||||
"move_cache_rel",
|
||||
"move_cache_abs",
|
||||
"hard_collision_set",
|
||||
"static_safe_cache",
|
||||
"max_cache_size",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cost_evaluator: CostEvaluator,
|
||||
node_limit: int = 1000000,
|
||||
max_straight_length: float = 2000.0,
|
||||
min_straight_length: float = 5.0,
|
||||
bend_radii: list[float] | None = None,
|
||||
sbend_radii: list[float] | None = None,
|
||||
sbend_offsets: list[float] | None = None,
|
||||
bend_penalty: float = 250.0,
|
||||
sbend_penalty: float | None = None,
|
||||
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc",
|
||||
bend_clip_margin: float = 10.0,
|
||||
visibility_guidance: VisibilityGuidanceMode = "tangent_corner",
|
||||
max_cache_size: int = 1000000,
|
||||
) -> None:
|
||||
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
|
||||
self.cost_evaluator = cost_evaluator
|
||||
self.max_cache_size = max_cache_size
|
||||
self.config = RouterConfig(
|
||||
node_limit=node_limit,
|
||||
max_straight_length=max_straight_length,
|
||||
min_straight_length=min_straight_length,
|
||||
bend_radii=bend_radii if bend_radii is not None else [50.0, 100.0],
|
||||
sbend_radii=sbend_radii if sbend_radii is not None else [5.0, 10.0, 50.0, 100.0],
|
||||
sbend_offsets=sbend_offsets,
|
||||
bend_penalty=bend_penalty,
|
||||
sbend_penalty=actual_sbend_penalty,
|
||||
bend_collision_type=bend_collision_type,
|
||||
bend_clip_margin=bend_clip_margin,
|
||||
visibility_guidance=visibility_guidance,
|
||||
)
|
||||
self.cost_evaluator.config = self.config
|
||||
self.cost_evaluator._refresh_cached_config()
|
||||
|
||||
self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine)
|
||||
self.move_cache_rel: dict[tuple, ComponentResult] = {}
|
||||
self.move_cache_abs: dict[tuple, ComponentResult] = {}
|
||||
self.hard_collision_set: set[tuple] = set()
|
||||
self.static_safe_cache: set[tuple] = set()
|
||||
|
||||
def clear_static_caches(self) -> None:
|
||||
self.hard_collision_set.clear()
|
||||
self.static_safe_cache.clear()
|
||||
self.visibility_manager.clear_cache()
|
||||
|
||||
def check_cache_eviction(self) -> None:
|
||||
if len(self.move_cache_abs) <= self.max_cache_size * 1.2:
|
||||
return
|
||||
num_to_evict = int(len(self.move_cache_abs) * 0.25)
|
||||
for idx, key in enumerate(list(self.move_cache_abs.keys())):
|
||||
if idx >= num_to_evict:
|
||||
break
|
||||
del self.move_cache_abs[key]
|
||||
|
||||
|
||||
def route_astar(
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics | None = None,
|
||||
net_id: str = "default",
|
||||
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | None = None,
|
||||
return_partial: bool = False,
|
||||
store_expanded: bool = False,
|
||||
skip_congestion: bool = False,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
node_limit: int | None = None,
|
||||
) -> list[ComponentResult] | None:
|
||||
if metrics is None:
|
||||
metrics = AStarMetrics()
|
||||
metrics.reset_per_route()
|
||||
|
||||
if bend_collision_type is not None:
|
||||
context.config.bend_collision_type = bend_collision_type
|
||||
|
||||
context.cost_evaluator.set_target(target)
|
||||
open_set: list[AStarNode] = []
|
||||
closed_set: dict[tuple[int, int, int], float] = {}
|
||||
congestion_cache: dict[tuple, int] = {}
|
||||
|
||||
start_node = AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target))
|
||||
heapq.heappush(open_set, start_node)
|
||||
best_node = start_node
|
||||
effective_node_limit = node_limit if node_limit is not None else context.config.node_limit
|
||||
nodes_expanded = 0
|
||||
|
||||
while open_set:
|
||||
if nodes_expanded >= effective_node_limit:
|
||||
return reconstruct_path(best_node) if return_partial else None
|
||||
|
||||
current = heapq.heappop(open_set)
|
||||
if max_cost is not None and current.fh_cost[0] > max_cost:
|
||||
metrics.pruned_cost += 1
|
||||
continue
|
||||
|
||||
if current.h_cost < best_node.h_cost:
|
||||
best_node = current
|
||||
|
||||
state = current.port.as_tuple()
|
||||
if state in closed_set and closed_set[state] <= current.g_cost + TOLERANCE_LINEAR:
|
||||
continue
|
||||
closed_set[state] = current.g_cost
|
||||
|
||||
if store_expanded:
|
||||
metrics.last_expanded_nodes.append(state)
|
||||
|
||||
nodes_expanded += 1
|
||||
metrics.total_nodes_expanded += 1
|
||||
metrics.nodes_expanded += 1
|
||||
|
||||
if current.port == target:
|
||||
return reconstruct_path(current)
|
||||
|
||||
expand_moves(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
max_cost=max_cost,
|
||||
skip_congestion=skip_congestion,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
return reconstruct_path(best_node) if return_partial else None
|
||||
|
||||
|
||||
def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
|
||||
out = {int(round(v)) for v in values if v > 0 and v <= max_reach + 0.01}
|
||||
return sorted((v for v in out if v > 0), reverse=True)
|
||||
|
||||
|
||||
def _sbend_forward_span(offset: float, radius: float) -> float | None:
|
||||
abs_offset = abs(offset)
|
||||
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
|
||||
return None
|
||||
theta = __import__("math").acos(1.0 - abs_offset / (2.0 * radius))
|
||||
return 2.0 * radius * __import__("math").sin(theta)
|
||||
|
||||
|
||||
def _visible_straight_candidates(
|
||||
current: Port,
|
||||
context: AStarContext,
|
||||
max_reach: float,
|
||||
cos_v: float,
|
||||
sin_v: float,
|
||||
net_width: float,
|
||||
) -> list[float]:
|
||||
mode = context.config.visibility_guidance
|
||||
if mode == "off":
|
||||
return []
|
||||
|
||||
if mode == "exact_corner":
|
||||
max_bend_radius = max(context.config.bend_radii, default=0.0)
|
||||
visibility_reach = max_reach + max_bend_radius
|
||||
visible_corners = sorted(
|
||||
context.visibility_manager.get_corner_visibility(current, max_dist=visibility_reach),
|
||||
key=lambda corner: corner[2],
|
||||
)
|
||||
if not visible_corners:
|
||||
return []
|
||||
|
||||
candidates: set[int] = set()
|
||||
for cx, cy, _ in visible_corners[:12]:
|
||||
dx = cx - current.x
|
||||
dy = cy - current.y
|
||||
local_x = dx * cos_v + dy * sin_v
|
||||
if local_x <= context.config.min_straight_length:
|
||||
continue
|
||||
candidates.add(int(round(local_x)))
|
||||
return sorted(candidates, reverse=True)
|
||||
|
||||
if mode != "tangent_corner":
|
||||
return []
|
||||
|
||||
visibility_manager = context.visibility_manager
|
||||
visibility_manager._ensure_current()
|
||||
max_bend_radius = max(context.config.bend_radii, default=0.0)
|
||||
if max_bend_radius <= 0 or not visibility_manager.corners:
|
||||
return []
|
||||
|
||||
reach = max_reach + max_bend_radius
|
||||
bounds = (current.x - reach, current.y - reach, current.x + reach, current.y + reach)
|
||||
candidate_ids = list(visibility_manager.corner_index.intersection(bounds))
|
||||
if not candidate_ids:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, float, float, float, float]] = []
|
||||
for idx in candidate_ids:
|
||||
cx, cy = visibility_manager.corners[idx]
|
||||
dx = cx - current.x
|
||||
dy = cy - current.y
|
||||
local_x = dx * cos_v + dy * sin_v
|
||||
local_y = -dx * sin_v + dy * cos_v
|
||||
if local_x <= context.config.min_straight_length or local_x > reach + 0.01:
|
||||
continue
|
||||
|
||||
nearest_radius = min(context.config.bend_radii, key=lambda radius: abs(abs(local_y) - radius))
|
||||
tangent_error = abs(abs(local_y) - nearest_radius)
|
||||
if tangent_error > 2.0:
|
||||
continue
|
||||
|
||||
length = local_x - nearest_radius
|
||||
if length <= context.config.min_straight_length or length > max_reach + 0.01:
|
||||
continue
|
||||
|
||||
scored.append((tangent_error, math.hypot(dx, dy), length, dx, dy))
|
||||
|
||||
if not scored:
|
||||
return []
|
||||
|
||||
collision_engine = context.cost_evaluator.collision_engine
|
||||
candidates: set[int] = set()
|
||||
for _, dist, length, dx, dy in sorted(scored)[:4]:
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
|
||||
if corner_reach < dist - 0.01:
|
||||
continue
|
||||
qlen = int(round(length))
|
||||
if qlen > 0:
|
||||
candidates.add(qlen)
|
||||
|
||||
return sorted(candidates, reverse=True)
|
||||
|
||||
|
||||
def _previous_move_metadata(node: AStarNode) -> tuple[str | None, float | None]:
|
||||
result = node.component_result
|
||||
if result is None:
|
||||
return None, None
|
||||
move_type = result.move_type
|
||||
if move_type == "Straight":
|
||||
return move_type, result.length
|
||||
return move_type, None
|
||||
|
||||
|
||||
def expand_moves(
|
||||
current: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
max_cost: float | None = None,
|
||||
skip_congestion: bool = False,
|
||||
self_collision_check: bool = False,
|
||||
) -> None:
|
||||
cp = current.port
|
||||
prev_move_type, prev_straight_length = _previous_move_metadata(current)
|
||||
dx_t = target.x - cp.x
|
||||
dy_t = target.y - cp.y
|
||||
dist_sq = dx_t * dx_t + dy_t * dy_t
|
||||
|
||||
if cp.r == 0:
|
||||
cos_v, sin_v = 1.0, 0.0
|
||||
elif cp.r == 90:
|
||||
cos_v, sin_v = 0.0, 1.0
|
||||
elif cp.r == 180:
|
||||
cos_v, sin_v = -1.0, 0.0
|
||||
else:
|
||||
cos_v, sin_v = 0.0, -1.0
|
||||
|
||||
proj_t = dx_t * cos_v + dy_t * sin_v
|
||||
perp_t = -dx_t * sin_v + dy_t * cos_v
|
||||
dx_local = proj_t
|
||||
dy_local = perp_t
|
||||
|
||||
if proj_t > 0 and abs(perp_t) < 1e-6 and cp.r == target.r:
|
||||
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, proj_t + 1.0, net_width=net_width)
|
||||
if max_reach >= proj_t - 0.01 and (
|
||||
prev_straight_length is None or proj_t < prev_straight_length - TOLERANCE_LINEAR
|
||||
):
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
"S",
|
||||
(int(round(proj_t)),),
|
||||
skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, context.config.max_straight_length, net_width=net_width)
|
||||
candidate_lengths = [
|
||||
context.config.min_straight_length,
|
||||
max_reach,
|
||||
max_reach / 2.0,
|
||||
max_reach - 5.0,
|
||||
]
|
||||
|
||||
axis_target_dist = abs(dx_t) if cp.r in (0, 180) else abs(dy_t)
|
||||
candidate_lengths.append(axis_target_dist)
|
||||
for radius in context.config.bend_radii:
|
||||
candidate_lengths.extend((max_reach - radius, axis_target_dist - radius, axis_target_dist - 2.0 * radius))
|
||||
|
||||
candidate_lengths.extend(
|
||||
_visible_straight_candidates(
|
||||
cp,
|
||||
context,
|
||||
max_reach,
|
||||
cos_v,
|
||||
sin_v,
|
||||
net_width,
|
||||
)
|
||||
)
|
||||
|
||||
if cp.r == target.r and dx_local > 0 and abs(dy_local) > TOLERANCE_LINEAR:
|
||||
for radius in context.config.sbend_radii:
|
||||
sbend_span = _sbend_forward_span(dy_local, radius)
|
||||
if sbend_span is None:
|
||||
continue
|
||||
candidate_lengths.extend((dx_local - sbend_span, dx_local - 2.0 * sbend_span))
|
||||
|
||||
for length in _quantized_lengths(candidate_lengths, max_reach):
|
||||
if length < context.config.min_straight_length:
|
||||
continue
|
||||
if prev_straight_length is not None and length >= prev_straight_length - TOLERANCE_LINEAR:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
"S",
|
||||
(length,),
|
||||
skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
angle_to_target = 0.0
|
||||
if dx_t != 0 or dy_t != 0:
|
||||
angle_to_target = float((round((180.0 / 3.141592653589793) * __import__("math").atan2(dy_t, dx_t)) + 360.0) % 360.0)
|
||||
allow_backwards = dist_sq < 150 * 150
|
||||
|
||||
for radius in context.config.bend_radii:
|
||||
for direction in ("CW", "CCW"):
|
||||
if not allow_backwards:
|
||||
turn = 90 if direction == "CCW" else -90
|
||||
new_ori = (cp.r + turn) % 360
|
||||
new_diff = (angle_to_target - new_ori + 180.0) % 360.0 - 180.0
|
||||
if abs(new_diff) > 135.0:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
"B",
|
||||
(radius, direction),
|
||||
skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
max_sbend_r = max(context.config.sbend_radii) if context.config.sbend_radii else 0.0
|
||||
if max_sbend_r <= 0 or prev_move_type == "SBend":
|
||||
return
|
||||
|
||||
explicit_offsets = context.config.sbend_offsets
|
||||
offsets: set[int] = set(int(round(v)) for v in explicit_offsets or [])
|
||||
|
||||
# S-bends preserve orientation, so the implicit search only makes sense
|
||||
# when the target is ahead in local coordinates and keeps the same
|
||||
# orientation. Generating generic speculative offsets on the integer lattice
|
||||
# explodes the search space without contributing useful moves.
|
||||
if target.r == cp.r and 0 < dx_local <= 4 * max_sbend_r:
|
||||
if 0 < abs(dy_local) < 2 * max_sbend_r:
|
||||
offsets.add(int(round(dy_local)))
|
||||
|
||||
if not offsets:
|
||||
return
|
||||
|
||||
for offset in sorted(offsets):
|
||||
if offset == 0:
|
||||
continue
|
||||
for radius in context.config.sbend_radii:
|
||||
if abs(offset) >= 2 * radius:
|
||||
continue
|
||||
process_move(
|
||||
current,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
"SB",
|
||||
(offset, radius),
|
||||
skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
|
||||
def process_move(
|
||||
parent: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
move_class: Literal["S", "B", "SB"],
|
||||
params: tuple,
|
||||
skip_congestion: bool,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
) -> None:
|
||||
cp = parent.port
|
||||
coll_type = context.config.bend_collision_type
|
||||
coll_key = id(coll_type) if isinstance(coll_type, shapely.geometry.Polygon) else coll_type
|
||||
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
|
||||
|
||||
abs_key = (
|
||||
cp.as_tuple(),
|
||||
move_class,
|
||||
params,
|
||||
net_width,
|
||||
coll_key,
|
||||
context.config.bend_clip_margin,
|
||||
self_dilation,
|
||||
)
|
||||
if abs_key in context.move_cache_abs:
|
||||
res = context.move_cache_abs[abs_key]
|
||||
else:
|
||||
context.check_cache_eviction()
|
||||
base_port = Port(0, 0, cp.r)
|
||||
rel_key = (
|
||||
cp.r,
|
||||
move_class,
|
||||
params,
|
||||
net_width,
|
||||
coll_key,
|
||||
context.config.bend_clip_margin,
|
||||
self_dilation,
|
||||
)
|
||||
if rel_key in context.move_cache_rel:
|
||||
res_rel = context.move_cache_rel[rel_key]
|
||||
else:
|
||||
try:
|
||||
if move_class == "S":
|
||||
res_rel = Straight.generate(base_port, params[0], net_width, dilation=self_dilation)
|
||||
elif move_class == "B":
|
||||
res_rel = Bend90.generate(
|
||||
base_port,
|
||||
params[0],
|
||||
net_width,
|
||||
params[1],
|
||||
collision_type=context.config.bend_collision_type,
|
||||
clip_margin=context.config.bend_clip_margin,
|
||||
dilation=self_dilation,
|
||||
)
|
||||
else:
|
||||
res_rel = SBend.generate(
|
||||
base_port,
|
||||
params[0],
|
||||
params[1],
|
||||
net_width,
|
||||
collision_type=context.config.bend_collision_type,
|
||||
clip_margin=context.config.bend_clip_margin,
|
||||
dilation=self_dilation,
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
context.move_cache_rel[rel_key] = res_rel
|
||||
res = res_rel.translate(cp.x, cp.y)
|
||||
context.move_cache_abs[abs_key] = res
|
||||
|
||||
move_radius = params[0] if move_class == "B" else (params[1] if move_class == "SB" else None)
|
||||
add_node(
|
||||
parent,
|
||||
res,
|
||||
target,
|
||||
net_width,
|
||||
net_id,
|
||||
open_set,
|
||||
closed_set,
|
||||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
move_class,
|
||||
abs_key,
|
||||
move_radius=move_radius,
|
||||
skip_congestion=skip_congestion,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
||||
|
||||
def add_node(
|
||||
parent: AStarNode,
|
||||
result: ComponentResult,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
closed_set: dict[tuple[int, int, int], float],
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
move_type: str,
|
||||
cache_key: tuple,
|
||||
move_radius: float | None = None,
|
||||
skip_congestion: bool = False,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
) -> None:
|
||||
metrics.moves_generated += 1
|
||||
state = result.end_port.as_tuple()
|
||||
new_lower_bound_g = parent.g_cost + result.length
|
||||
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
|
||||
metrics.pruned_closed_set += 1
|
||||
return
|
||||
|
||||
parent_p = parent.port
|
||||
end_p = result.end_port
|
||||
|
||||
if cache_key in context.hard_collision_set:
|
||||
metrics.pruned_hard_collision += 1
|
||||
return
|
||||
|
||||
is_static_safe = cache_key in context.static_safe_cache
|
||||
if not is_static_safe:
|
||||
ce = context.cost_evaluator.collision_engine
|
||||
if move_type == "S":
|
||||
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
|
||||
else:
|
||||
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width)
|
||||
if collision_found:
|
||||
context.hard_collision_set.add(cache_key)
|
||||
metrics.pruned_hard_collision += 1
|
||||
return
|
||||
context.static_safe_cache.add(cache_key)
|
||||
|
||||
total_overlaps = 0
|
||||
if not skip_congestion:
|
||||
if cache_key in congestion_cache:
|
||||
total_overlaps = congestion_cache[cache_key]
|
||||
else:
|
||||
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
|
||||
congestion_cache[cache_key] = total_overlaps
|
||||
|
||||
if self_collision_check:
|
||||
curr_p = parent
|
||||
new_tb = result.total_bounds
|
||||
while curr_p and curr_p.parent:
|
||||
ancestor_res = curr_p.component_result
|
||||
if ancestor_res:
|
||||
anc_tb = ancestor_res.total_bounds
|
||||
if new_tb[0] < anc_tb[2] and new_tb[2] > anc_tb[0] and new_tb[1] < anc_tb[3] and new_tb[3] > anc_tb[1]:
|
||||
for p_anc in ancestor_res.geometry:
|
||||
for p_new in result.geometry:
|
||||
if p_new.intersects(p_anc) and not p_new.touches(p_anc):
|
||||
return
|
||||
curr_p = curr_p.parent
|
||||
|
||||
penalty = 0.0
|
||||
if move_type == "SB":
|
||||
penalty = context.config.sbend_penalty
|
||||
elif move_type == "B":
|
||||
penalty = context.config.bend_penalty
|
||||
if move_radius is not None and move_radius > TOLERANCE_LINEAR:
|
||||
penalty *= (10.0 / move_radius) ** 0.5
|
||||
|
||||
move_cost = context.cost_evaluator.evaluate_move(
|
||||
result.geometry,
|
||||
result.end_port,
|
||||
net_width,
|
||||
net_id,
|
||||
start_port=parent_p,
|
||||
length=result.length,
|
||||
dilated_geometry=result.dilated_geometry,
|
||||
penalty=penalty,
|
||||
skip_static=True,
|
||||
skip_congestion=True,
|
||||
)
|
||||
move_cost += total_overlaps * context.cost_evaluator.congestion_penalty
|
||||
|
||||
if max_cost is not None and parent.g_cost + move_cost > max_cost:
|
||||
metrics.pruned_cost += 1
|
||||
return
|
||||
if move_cost > 1e12:
|
||||
metrics.pruned_cost += 1
|
||||
return
|
||||
|
||||
g_cost = parent.g_cost + move_cost
|
||||
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
|
||||
metrics.pruned_closed_set += 1
|
||||
return
|
||||
|
||||
h_cost = context.cost_evaluator.h_manhattan(result.end_port, target)
|
||||
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
|
||||
metrics.moves_added += 1
|
||||
|
||||
|
||||
def reconstruct_path(end_node: AStarNode) -> list[ComponentResult]:
|
||||
path = []
|
||||
curr: AStarNode | None = end_node
|
||||
while curr and curr.component_result:
|
||||
path.append(curr.component_result)
|
||||
curr = curr.parent
|
||||
return path[::-1]
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Any
|
||||
|
||||
|
||||
VisibilityGuidanceMode = Literal["off", "exact_corner", "tangent_corner"]
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterConfig:
|
||||
"""Configuration parameters for the A* Router."""
|
||||
|
||||
node_limit: int = 1000000
|
||||
# Sparse Sampling Configuration
|
||||
max_straight_length: float = 2000.0
|
||||
num_straight_samples: int = 5
|
||||
min_straight_length: float = 5.0
|
||||
|
||||
# Offsets for SBends (None = automatic grid-based selection)
|
||||
sbend_offsets: list[float] | None = None
|
||||
|
||||
# Deprecated but kept for compatibility during refactor
|
||||
straight_lengths: list[float] = field(default_factory=list)
|
||||
|
||||
bend_radii: list[float] = field(default_factory=lambda: [50.0, 100.0])
|
||||
sbend_radii: list[float] = field(default_factory=lambda: [10.0])
|
||||
snap_to_target_dist: float = 1000.0
|
||||
bend_penalty: float = 250.0
|
||||
sbend_penalty: float = 500.0
|
||||
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc"
|
||||
bend_clip_margin: float = 10.0
|
||||
visibility_guidance: VisibilityGuidanceMode = "tangent_corner"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostConfig:
|
||||
"""Configuration parameters for the Cost Evaluator."""
|
||||
|
||||
unit_length_cost: float = 1.0
|
||||
greedy_h_weight: float = 1.5
|
||||
congestion_penalty: float = 10000.0
|
||||
bend_penalty: float = 250.0
|
||||
sbend_penalty: float = 500.0
|
||||
min_bend_radius: float = 50.0
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.router.config import CostConfig
|
||||
from inire.model import ObjectiveWeights
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import ComponentResult, MoveKind
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
|
|
@ -19,63 +18,55 @@ class CostEvaluator:
|
|||
__slots__ = (
|
||||
"collision_engine",
|
||||
"danger_map",
|
||||
"config",
|
||||
"unit_length_cost",
|
||||
"greedy_h_weight",
|
||||
"congestion_penalty",
|
||||
"_search_weights",
|
||||
"_greedy_h_weight",
|
||||
"_target_x",
|
||||
"_target_y",
|
||||
"_target_r",
|
||||
"_target_cos",
|
||||
"_target_sin",
|
||||
"_min_radius",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collision_engine: CollisionEngine,
|
||||
collision_engine: RoutingWorld,
|
||||
danger_map: DangerMap | None = None,
|
||||
unit_length_cost: float = 1.0,
|
||||
greedy_h_weight: float = 1.5,
|
||||
congestion_penalty: float = 10000.0,
|
||||
bend_penalty: float = 250.0,
|
||||
sbend_penalty: float | None = None,
|
||||
min_bend_radius: float = 50.0,
|
||||
danger_weight: float = 1.0,
|
||||
) -> None:
|
||||
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
|
||||
self.collision_engine = collision_engine
|
||||
self.danger_map = danger_map
|
||||
self.config = CostConfig(
|
||||
self._search_weights = ObjectiveWeights(
|
||||
unit_length_cost=unit_length_cost,
|
||||
greedy_h_weight=greedy_h_weight,
|
||||
congestion_penalty=congestion_penalty,
|
||||
bend_penalty=bend_penalty,
|
||||
sbend_penalty=actual_sbend_penalty,
|
||||
min_bend_radius=min_bend_radius,
|
||||
danger_weight=danger_weight,
|
||||
)
|
||||
self.unit_length_cost = self.config.unit_length_cost
|
||||
self.greedy_h_weight = self.config.greedy_h_weight
|
||||
self.congestion_penalty = self.config.congestion_penalty
|
||||
self._refresh_cached_config()
|
||||
self._greedy_h_weight = float(greedy_h_weight)
|
||||
self._target_x = 0.0
|
||||
self._target_y = 0.0
|
||||
self._target_r = 0
|
||||
self._target_cos = 1.0
|
||||
self._target_sin = 0.0
|
||||
|
||||
def _refresh_cached_config(self) -> None:
|
||||
if hasattr(self.config, "min_bend_radius"):
|
||||
self._min_radius = self.config.min_bend_radius
|
||||
elif hasattr(self.config, "bend_radii") and self.config.bend_radii:
|
||||
self._min_radius = min(self.config.bend_radii)
|
||||
else:
|
||||
self._min_radius = 50.0
|
||||
if hasattr(self.config, "unit_length_cost"):
|
||||
self.unit_length_cost = self.config.unit_length_cost
|
||||
if hasattr(self.config, "greedy_h_weight"):
|
||||
self.greedy_h_weight = self.config.greedy_h_weight
|
||||
if hasattr(self.config, "congestion_penalty"):
|
||||
self.congestion_penalty = self.config.congestion_penalty
|
||||
@property
|
||||
def default_weights(self) -> ObjectiveWeights:
|
||||
return self._search_weights
|
||||
|
||||
@property
|
||||
def greedy_h_weight(self) -> float:
|
||||
return self._greedy_h_weight
|
||||
|
||||
@greedy_h_weight.setter
|
||||
def greedy_h_weight(self, value: float) -> None:
|
||||
self._greedy_h_weight = float(value)
|
||||
|
||||
def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights:
|
||||
return self._search_weights if weights is None else weights
|
||||
|
||||
def set_target(self, target: Port) -> None:
|
||||
self._target_x = target.x
|
||||
|
|
@ -85,12 +76,13 @@ class CostEvaluator:
|
|||
self._target_cos = np.cos(rad)
|
||||
self._target_sin = np.sin(rad)
|
||||
|
||||
def g_proximity(self, x: float, y: float) -> float:
|
||||
if self.danger_map is None:
|
||||
return 0.0
|
||||
return self.danger_map.get_cost(x, y)
|
||||
|
||||
def h_manhattan(self, current: Port, target: Port) -> float:
|
||||
def h_manhattan(
|
||||
self,
|
||||
current: Port,
|
||||
target: Port,
|
||||
*,
|
||||
min_bend_radius: float = 50.0,
|
||||
) -> float:
|
||||
tx, ty = target.x, target.y
|
||||
if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r:
|
||||
self.set_target(target)
|
||||
|
|
@ -98,7 +90,7 @@ class CostEvaluator:
|
|||
dx = abs(current.x - tx)
|
||||
dy = abs(current.y - ty)
|
||||
dist = dx + dy
|
||||
bp = self.config.bend_penalty
|
||||
bp = self._search_weights.bend_penalty
|
||||
penalty = 0.0
|
||||
|
||||
curr_r = current.r
|
||||
|
|
@ -110,7 +102,7 @@ class CostEvaluator:
|
|||
v_dy = ty - current.y
|
||||
side_proj = v_dx * self._target_cos + v_dy * self._target_sin
|
||||
perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos)
|
||||
if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0):
|
||||
if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0):
|
||||
penalty += 2 * bp
|
||||
|
||||
if curr_r == 0:
|
||||
|
|
@ -128,55 +120,74 @@ class CostEvaluator:
|
|||
if diff == 0 and perp_dist > 0:
|
||||
penalty += 2 * bp
|
||||
|
||||
return self.greedy_h_weight * (dist + penalty)
|
||||
return self._greedy_h_weight * (dist + penalty)
|
||||
|
||||
def evaluate_move(
|
||||
def score_component(
|
||||
self,
|
||||
geometry: list[Polygon] | None,
|
||||
end_port: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
component: ComponentResult,
|
||||
*,
|
||||
start_port: Port | None = None,
|
||||
length: float = 0.0,
|
||||
dilated_geometry: list[Polygon] | None = None,
|
||||
skip_static: bool = False,
|
||||
skip_congestion: bool = False,
|
||||
penalty: float = 0.0,
|
||||
weights: ObjectiveWeights | None = None,
|
||||
) -> float:
|
||||
_ = net_width
|
||||
active_weights = self._resolve_weights(weights)
|
||||
danger_map = self.danger_map
|
||||
end_port = component.end_port
|
||||
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
|
||||
return 1e15
|
||||
|
||||
total_cost = length * self.unit_length_cost + penalty
|
||||
if not skip_static or not skip_congestion:
|
||||
if geometry is None:
|
||||
return 1e15
|
||||
collision_engine = self.collision_engine
|
||||
for i, poly in enumerate(geometry):
|
||||
dil_poly = dilated_geometry[i] if dilated_geometry else None
|
||||
if not skip_static and collision_engine.check_collision(
|
||||
poly,
|
||||
net_id,
|
||||
buffer_mode="static",
|
||||
start_port=start_port,
|
||||
end_port=end_port,
|
||||
dilated_geometry=dil_poly,
|
||||
):
|
||||
return 1e15
|
||||
if not skip_congestion:
|
||||
overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly)
|
||||
if isinstance(overlaps, int) and overlaps > 0:
|
||||
total_cost += overlaps * self.congestion_penalty
|
||||
move_radius = None
|
||||
if component.move_type == "bend90":
|
||||
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
|
||||
total_cost = component.length * active_weights.unit_length_cost + self.component_penalty(
|
||||
component.move_type,
|
||||
move_radius=move_radius,
|
||||
weights=active_weights,
|
||||
)
|
||||
|
||||
if danger_map is not None:
|
||||
if danger_map is not None and active_weights.danger_weight:
|
||||
cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0
|
||||
cost_e = danger_map.get_cost(end_port.x, end_port.y)
|
||||
if start_port:
|
||||
mid_x = (start_port.x + end_port.x) / 2.0
|
||||
mid_y = (start_port.y + end_port.y) / 2.0
|
||||
cost_m = danger_map.get_cost(mid_x, mid_y)
|
||||
total_cost += length * (cost_s + cost_m + cost_e) / 3.0
|
||||
total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
|
||||
else:
|
||||
total_cost += length * cost_e
|
||||
total_cost += component.length * active_weights.danger_weight * cost_e
|
||||
return total_cost
|
||||
|
||||
def component_penalty(
|
||||
self,
|
||||
move_type: MoveKind,
|
||||
*,
|
||||
move_radius: float | None = None,
|
||||
weights: ObjectiveWeights | None = None,
|
||||
) -> float:
|
||||
active_weights = self._resolve_weights(weights)
|
||||
penalty = 0.0
|
||||
if move_type == "sbend":
|
||||
penalty = active_weights.sbend_penalty
|
||||
elif move_type == "bend90":
|
||||
penalty = active_weights.bend_penalty
|
||||
if move_radius is not None and move_radius > TOLERANCE_LINEAR and penalty > 0:
|
||||
penalty *= (10.0 / move_radius) ** 0.5
|
||||
return penalty
|
||||
|
||||
def path_cost(
|
||||
self,
|
||||
start_port: Port,
|
||||
path: list[ComponentResult],
|
||||
*,
|
||||
weights: ObjectiveWeights | None = None,
|
||||
) -> float:
|
||||
active_weights = self._resolve_weights(weights)
|
||||
total = 0.0
|
||||
current_port = start_port
|
||||
for component in path:
|
||||
total += self.score_component(
|
||||
component,
|
||||
start_port=current_port,
|
||||
weights=active_weights,
|
||||
)
|
||||
current_port = component.end_port
|
||||
return total
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy
|
||||
import shapely
|
||||
from scipy.spatial import cKDTree
|
||||
from functools import lru_cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
||||
_COST_CACHE_SIZE = 100000
|
||||
|
||||
|
||||
class DangerMap:
|
||||
"""
|
||||
A proximity cost evaluator using a KD-Tree of obstacle boundary points.
|
||||
Scales with obstacle perimeter rather than design area.
|
||||
"""
|
||||
__slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree')
|
||||
__slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache')
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -38,6 +41,7 @@ class DangerMap:
|
|||
self.safety_threshold = safety_threshold
|
||||
self.k = k
|
||||
self.tree: cKDTree | None = None
|
||||
self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict()
|
||||
|
||||
def precompute(self, obstacles: list[Polygon]) -> None:
|
||||
"""
|
||||
|
|
@ -64,9 +68,8 @@ class DangerMap:
|
|||
self.tree = cKDTree(numpy.array(all_points))
|
||||
else:
|
||||
self.tree = None
|
||||
|
||||
# Clear cache when tree changes
|
||||
self._get_cost_quantized.cache_clear()
|
||||
|
||||
self._cost_cache.clear()
|
||||
|
||||
def is_within_bounds(self, x: float, y: float) -> bool:
|
||||
"""
|
||||
|
|
@ -81,10 +84,18 @@ class DangerMap:
|
|||
"""
|
||||
qx_milli = int(round(x * 1000))
|
||||
qy_milli = int(round(y * 1000))
|
||||
return self._get_cost_quantized(qx_milli, qy_milli)
|
||||
key = (qx_milli, qy_milli)
|
||||
if key in self._cost_cache:
|
||||
self._cost_cache.move_to_end(key)
|
||||
return self._cost_cache[key]
|
||||
|
||||
@lru_cache(maxsize=100000)
|
||||
def _get_cost_quantized(self, qx_milli: int, qy_milli: int) -> float:
|
||||
cost = self._compute_cost_quantized(qx_milli, qy_milli)
|
||||
self._cost_cache[key] = cost
|
||||
if len(self._cost_cache) > _COST_CACHE_SIZE:
|
||||
self._cost_cache.popitem(last=False)
|
||||
return cost
|
||||
|
||||
def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float:
|
||||
qx = qx_milli / 1000.0
|
||||
qy = qy_milli / 1000.0
|
||||
if not self.is_within_bounds(qx, qy):
|
||||
|
|
|
|||
|
|
@ -1,429 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Callable, Literal
|
||||
|
||||
import numpy
|
||||
|
||||
from inire.geometry.components import Bend90, Straight
|
||||
from inire.router.astar import AStarMetrics, route_astar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingResult:
|
||||
net_id: str
|
||||
path: list[ComponentResult]
|
||||
is_valid: bool
|
||||
collisions: int
|
||||
reached_target: bool = False
|
||||
|
||||
|
||||
class PathFinder:
|
||||
__slots__ = (
|
||||
"context",
|
||||
"metrics",
|
||||
"max_iterations",
|
||||
"base_congestion_penalty",
|
||||
"use_tiered_strategy",
|
||||
"congestion_multiplier",
|
||||
"accumulated_expanded_nodes",
|
||||
"warm_start",
|
||||
"refine_paths",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: AStarContext,
|
||||
metrics: AStarMetrics | None = None,
|
||||
max_iterations: int = 10,
|
||||
base_congestion_penalty: float = 100.0,
|
||||
congestion_multiplier: float = 1.5,
|
||||
use_tiered_strategy: bool = True,
|
||||
warm_start: Literal["shortest", "longest", "user"] | None = "shortest",
|
||||
refine_paths: bool = False,
|
||||
) -> None:
|
||||
self.context = context
|
||||
self.metrics = metrics if metrics is not None else AStarMetrics()
|
||||
self.max_iterations = max_iterations
|
||||
self.base_congestion_penalty = base_congestion_penalty
|
||||
self.congestion_multiplier = congestion_multiplier
|
||||
self.use_tiered_strategy = use_tiered_strategy
|
||||
self.warm_start = warm_start
|
||||
self.refine_paths = refine_paths
|
||||
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
|
||||
|
||||
@property
|
||||
def cost_evaluator(self) -> CostEvaluator:
|
||||
return self.context.cost_evaluator
|
||||
|
||||
def _perform_greedy_pass(
|
||||
self,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
order: Literal["shortest", "longest", "user"],
|
||||
) -> dict[str, list[ComponentResult]]:
|
||||
all_net_ids = list(netlist.keys())
|
||||
if order != "user":
|
||||
all_net_ids.sort(
|
||||
key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y),
|
||||
reverse=(order == "longest"),
|
||||
)
|
||||
|
||||
greedy_paths: dict[str, list[ComponentResult]] = {}
|
||||
temp_obj_ids: list[int] = []
|
||||
greedy_node_limit = min(self.context.config.node_limit, 2000)
|
||||
for net_id in all_net_ids:
|
||||
start, target = netlist[net_id]
|
||||
width = net_widths.get(net_id, 2.0)
|
||||
h_start = self.cost_evaluator.h_manhattan(start, target)
|
||||
max_cost_limit = max(h_start * 3.0, 2000.0)
|
||||
path = route_astar(
|
||||
start,
|
||||
target,
|
||||
width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
skip_congestion=True,
|
||||
max_cost=max_cost_limit,
|
||||
self_collision_check=True,
|
||||
node_limit=greedy_node_limit,
|
||||
)
|
||||
if not path:
|
||||
continue
|
||||
greedy_paths[net_id] = path
|
||||
for res in path:
|
||||
geoms = res.actual_geometry if res.actual_geometry is not None else res.geometry
|
||||
dilated_geoms = res.dilated_actual_geometry if res.dilated_actual_geometry else res.dilated_geometry
|
||||
for i, poly in enumerate(geoms):
|
||||
dilated = dilated_geoms[i] if dilated_geoms else None
|
||||
obj_id = self.cost_evaluator.collision_engine.add_static_obstacle(poly, dilated_geometry=dilated)
|
||||
temp_obj_ids.append(obj_id)
|
||||
self.context.clear_static_caches()
|
||||
|
||||
for obj_id in temp_obj_ids:
|
||||
self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id)
|
||||
return greedy_paths
|
||||
|
||||
def _has_self_collision(self, path: list[ComponentResult]) -> bool:
|
||||
for i, comp_i in enumerate(path):
|
||||
tb_i = comp_i.total_bounds
|
||||
for j in range(i + 2, len(path)):
|
||||
comp_j = path[j]
|
||||
tb_j = comp_j.total_bounds
|
||||
if tb_i[0] < tb_j[2] and tb_i[2] > tb_j[0] and tb_i[1] < tb_j[3] and tb_i[3] > tb_j[1]:
|
||||
for p_i in comp_i.geometry:
|
||||
for p_j in comp_j.geometry:
|
||||
if p_i.intersects(p_j) and not p_i.touches(p_j):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _path_cost(self, path: list[ComponentResult]) -> float:
|
||||
total = 0.0
|
||||
bend_penalty = self.context.config.bend_penalty
|
||||
sbend_penalty = self.context.config.sbend_penalty
|
||||
for comp in path:
|
||||
total += comp.length
|
||||
if comp.move_type == "Bend90":
|
||||
radius = comp.length * 2.0 / math.pi if comp.length > 0 else 0.0
|
||||
if radius > 0:
|
||||
total += bend_penalty * (10.0 / radius) ** 0.5
|
||||
else:
|
||||
total += bend_penalty
|
||||
elif comp.move_type == "SBend":
|
||||
total += sbend_penalty
|
||||
return total
|
||||
|
||||
def _extract_geometry(self, path: list[ComponentResult]) -> tuple[list[Any], list[Any]]:
|
||||
all_geoms = []
|
||||
all_dilated = []
|
||||
for res in path:
|
||||
all_geoms.extend(res.geometry)
|
||||
if res.dilated_geometry:
|
||||
all_dilated.extend(res.dilated_geometry)
|
||||
else:
|
||||
dilation = self.cost_evaluator.collision_engine.clearance / 2.0
|
||||
all_dilated.extend([p.buffer(dilation) for p in res.geometry])
|
||||
return all_geoms, all_dilated
|
||||
|
||||
def _to_local(self, start: Port, point: Port) -> tuple[int, int]:
|
||||
dx = point.x - start.x
|
||||
dy = point.y - start.y
|
||||
if start.r == 0:
|
||||
return dx, dy
|
||||
if start.r == 90:
|
||||
return dy, -dx
|
||||
if start.r == 180:
|
||||
return -dx, -dy
|
||||
return -dy, dx
|
||||
|
||||
def _build_same_orientation_dogleg(
|
||||
self,
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
radius: float,
|
||||
side_extent: float,
|
||||
) -> list[ComponentResult] | None:
|
||||
local_dx, local_dy = self._to_local(start, target)
|
||||
if abs(local_dy) > 0 or local_dx < 4.0 * radius - 0.01:
|
||||
return None
|
||||
|
||||
side_abs = abs(side_extent)
|
||||
side_length = side_abs - 2.0 * radius
|
||||
if side_length < self.context.config.min_straight_length - 0.01:
|
||||
return None
|
||||
|
||||
forward_length = local_dx - 4.0 * radius
|
||||
if forward_length < -0.01:
|
||||
return None
|
||||
|
||||
first_dir = "CCW" if side_extent > 0 else "CW"
|
||||
second_dir = "CW" if side_extent > 0 else "CCW"
|
||||
dilation = self.cost_evaluator.collision_engine.clearance / 2.0
|
||||
|
||||
path: list[ComponentResult] = []
|
||||
curr = start
|
||||
|
||||
for direction, straight_len in (
|
||||
(first_dir, side_length),
|
||||
(second_dir, forward_length),
|
||||
(second_dir, side_length),
|
||||
(first_dir, None),
|
||||
):
|
||||
bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation)
|
||||
path.append(bend)
|
||||
curr = bend.end_port
|
||||
if straight_len is None:
|
||||
continue
|
||||
if straight_len > 0.01:
|
||||
straight = Straight.generate(curr, straight_len, net_width, dilation=dilation)
|
||||
path.append(straight)
|
||||
curr = straight.end_port
|
||||
|
||||
if curr != target:
|
||||
return None
|
||||
return path
|
||||
|
||||
def _refine_path(
|
||||
self,
|
||||
net_id: str,
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
path: list[ComponentResult],
|
||||
) -> list[ComponentResult]:
|
||||
if not path or start.r != target.r:
|
||||
return path
|
||||
|
||||
bend_count = sum(1 for comp in path if comp.move_type == "Bend90")
|
||||
if bend_count < 5:
|
||||
return path
|
||||
|
||||
side_extents = []
|
||||
local_points = [self._to_local(start, start)]
|
||||
local_points.extend(self._to_local(start, comp.end_port) for comp in path)
|
||||
min_side = min(point[1] for point in local_points)
|
||||
max_side = max(point[1] for point in local_points)
|
||||
if min_side < -0.01:
|
||||
side_extents.append(float(min_side))
|
||||
if max_side > 0.01:
|
||||
side_extents.append(float(max_side))
|
||||
if not side_extents:
|
||||
return path
|
||||
|
||||
best_path = path
|
||||
best_cost = self._path_cost(path)
|
||||
collision_engine = self.cost_evaluator.collision_engine
|
||||
|
||||
for radius in self.context.config.bend_radii:
|
||||
for side_extent in side_extents:
|
||||
candidate = self._build_same_orientation_dogleg(start, target, net_width, radius, side_extent)
|
||||
if candidate is None:
|
||||
continue
|
||||
is_valid, collisions = collision_engine.verify_path(net_id, candidate)
|
||||
if not is_valid or collisions != 0:
|
||||
continue
|
||||
candidate_cost = self._path_cost(candidate)
|
||||
if candidate_cost + 1e-6 < best_cost:
|
||||
best_cost = candidate_cost
|
||||
best_path = candidate
|
||||
|
||||
return best_path
|
||||
|
||||
def route_all(
|
||||
self,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
store_expanded: bool = False,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
|
||||
shuffle_nets: bool = False,
|
||||
sort_nets: Literal["shortest", "longest", "user", None] = None,
|
||||
initial_paths: dict[str, list[ComponentResult]] | None = None,
|
||||
seed: int | None = None,
|
||||
) -> dict[str, RoutingResult]:
|
||||
results: dict[str, RoutingResult] = {}
|
||||
self.cost_evaluator.congestion_penalty = self.base_congestion_penalty
|
||||
self.accumulated_expanded_nodes = []
|
||||
self.metrics.reset_per_route()
|
||||
|
||||
start_time = time.monotonic()
|
||||
num_nets = len(netlist)
|
||||
session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations)
|
||||
all_net_ids = list(netlist.keys())
|
||||
needs_sc: set[str] = set()
|
||||
|
||||
if initial_paths is None:
|
||||
ws_order = sort_nets if sort_nets is not None else self.warm_start
|
||||
if ws_order is not None:
|
||||
initial_paths = self._perform_greedy_pass(netlist, net_widths, ws_order)
|
||||
self.context.clear_static_caches()
|
||||
|
||||
if sort_nets and sort_nets != "user":
|
||||
all_net_ids.sort(
|
||||
key=lambda nid: abs(netlist[nid][1].x - netlist[nid][0].x) + abs(netlist[nid][1].y - netlist[nid][0].y),
|
||||
reverse=(sort_nets == "longest"),
|
||||
)
|
||||
|
||||
for iteration in range(self.max_iterations):
|
||||
any_congestion = False
|
||||
self.accumulated_expanded_nodes = []
|
||||
self.metrics.reset_per_route()
|
||||
|
||||
if shuffle_nets and (iteration > 0 or initial_paths is None):
|
||||
it_seed = (seed + iteration) if seed is not None else None
|
||||
random.Random(it_seed).shuffle(all_net_ids)
|
||||
|
||||
for net_id in all_net_ids:
|
||||
start, target = netlist[net_id]
|
||||
if time.monotonic() - start_time > session_timeout:
|
||||
self.cost_evaluator.collision_engine.dynamic_tree = None
|
||||
self.cost_evaluator.collision_engine._ensure_dynamic_tree()
|
||||
return self.verify_all_nets(results, netlist)
|
||||
|
||||
width = net_widths.get(net_id, 2.0)
|
||||
self.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
path: list[ComponentResult] | None = None
|
||||
|
||||
if iteration == 0 and initial_paths and net_id in initial_paths:
|
||||
path = initial_paths[net_id]
|
||||
else:
|
||||
target_coll_model = self.context.config.bend_collision_type
|
||||
coll_model = target_coll_model
|
||||
skip_cong = False
|
||||
if self.use_tiered_strategy and iteration == 0:
|
||||
skip_cong = True
|
||||
if target_coll_model == "arc":
|
||||
coll_model = "clipped_bbox"
|
||||
|
||||
path = route_astar(
|
||||
start,
|
||||
target,
|
||||
width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
bend_collision_type=coll_model,
|
||||
return_partial=True,
|
||||
store_expanded=store_expanded,
|
||||
skip_congestion=skip_cong,
|
||||
self_collision_check=(net_id in needs_sc),
|
||||
node_limit=self.context.config.node_limit,
|
||||
)
|
||||
|
||||
if store_expanded and self.metrics.last_expanded_nodes:
|
||||
self.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
|
||||
|
||||
if not path:
|
||||
results[net_id] = RoutingResult(net_id, [], False, 0, reached_target=False)
|
||||
any_congestion = True
|
||||
continue
|
||||
|
||||
last_p = path[-1].end_port
|
||||
reached = last_p == target
|
||||
|
||||
if reached and net_id not in needs_sc and self._has_self_collision(path):
|
||||
needs_sc.add(net_id)
|
||||
any_congestion = True
|
||||
|
||||
all_geoms = []
|
||||
all_dilated = []
|
||||
for res in path:
|
||||
all_geoms.extend(res.geometry)
|
||||
if res.dilated_geometry:
|
||||
all_dilated.extend(res.dilated_geometry)
|
||||
else:
|
||||
dilation = self.cost_evaluator.collision_engine.clearance / 2.0
|
||||
all_dilated.extend([p.buffer(dilation) for p in res.geometry])
|
||||
self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
|
||||
|
||||
collision_count = 0
|
||||
if reached:
|
||||
is_valid, collision_count = self.cost_evaluator.collision_engine.verify_path(net_id, path)
|
||||
any_congestion = any_congestion or not is_valid
|
||||
|
||||
results[net_id] = RoutingResult(net_id, path, reached and collision_count == 0, collision_count, reached_target=reached)
|
||||
|
||||
if iteration_callback:
|
||||
iteration_callback(iteration, results)
|
||||
if not any_congestion:
|
||||
break
|
||||
self.cost_evaluator.congestion_penalty *= self.congestion_multiplier
|
||||
|
||||
if self.refine_paths and results:
|
||||
for net_id in all_net_ids:
|
||||
res = results.get(net_id)
|
||||
if not res or not res.path or not res.reached_target or not res.is_valid:
|
||||
continue
|
||||
start, target = netlist[net_id]
|
||||
width = net_widths.get(net_id, 2.0)
|
||||
self.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
refined_path = self._refine_path(net_id, start, target, width, res.path)
|
||||
all_geoms, all_dilated = self._extract_geometry(refined_path)
|
||||
self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
|
||||
results[net_id] = RoutingResult(
|
||||
net_id=net_id,
|
||||
path=refined_path,
|
||||
is_valid=res.is_valid,
|
||||
collisions=res.collisions,
|
||||
reached_target=res.reached_target,
|
||||
)
|
||||
|
||||
self.cost_evaluator.collision_engine.dynamic_tree = None
|
||||
self.cost_evaluator.collision_engine._ensure_dynamic_tree()
|
||||
return self.verify_all_nets(results, netlist)
|
||||
|
||||
def verify_all_nets(
|
||||
self,
|
||||
results: dict[str, RoutingResult],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
) -> dict[str, RoutingResult]:
|
||||
final_results: dict[str, RoutingResult] = {}
|
||||
for net_id, (_, target_p) in netlist.items():
|
||||
res = results.get(net_id)
|
||||
if not res or not res.path:
|
||||
final_results[net_id] = RoutingResult(net_id, [], False, 0)
|
||||
continue
|
||||
last_p = res.path[-1].end_port
|
||||
reached = last_p == target_p
|
||||
is_valid, collisions = self.cost_evaluator.collision_engine.verify_path(net_id, res.path)
|
||||
final_results[net_id] = RoutingResult(
|
||||
net_id=net_id,
|
||||
path=res.path,
|
||||
is_valid=(is_valid and reached),
|
||||
collisions=collisions,
|
||||
reached_target=reached,
|
||||
)
|
||||
return final_results
|
||||
317
inire/router/refiner.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from inire.geometry.component_overlap import components_overlap
|
||||
from inire.geometry.components import Bend90, Straight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router._astar_types import AStarContext
|
||||
|
||||
|
||||
def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool:
|
||||
current = parent_node
|
||||
while current and current.parent:
|
||||
ancestor_component = current.component_result
|
||||
if ancestor_component and components_overlap(component, ancestor_component):
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
|
||||
class PathRefiner:
|
||||
__slots__ = ("context",)
|
||||
|
||||
def __init__(self, context: AStarContext) -> None:
|
||||
self.context = context
|
||||
|
||||
@property
|
||||
def collision_engine(self) -> RoutingWorld:
|
||||
return self.context.cost_evaluator.collision_engine
|
||||
|
||||
def path_cost(
|
||||
self,
|
||||
path: Sequence[ComponentResult],
|
||||
*,
|
||||
start: Port | None = None,
|
||||
) -> float:
|
||||
if not path:
|
||||
return 0.0
|
||||
actual_start = path[0].start_port if start is None else start
|
||||
return self.score_path(actual_start, path)
|
||||
|
||||
def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float:
|
||||
weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights
|
||||
return self.context.cost_evaluator.path_cost(start, path, weights=weights)
|
||||
|
||||
def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]:
|
||||
ports = [start]
|
||||
ports.extend(comp.end_port for comp in path)
|
||||
return ports
|
||||
|
||||
def _to_local(self, start: Port, point: Port) -> tuple[int, int]:
|
||||
dx = point.x - start.x
|
||||
dy = point.y - start.y
|
||||
if start.r == 0:
|
||||
return dx, dy
|
||||
if start.r == 90:
|
||||
return dy, -dx
|
||||
if start.r == 180:
|
||||
return -dx, -dy
|
||||
return -dy, dx
|
||||
|
||||
def _to_local_xy(self, start: Port, x: float, y: float) -> tuple[float, float]:
|
||||
dx = float(x) - start.x
|
||||
dy = float(y) - start.y
|
||||
if start.r == 0:
|
||||
return dx, dy
|
||||
if start.r == 90:
|
||||
return dy, -dx
|
||||
if start.r == 180:
|
||||
return -dx, -dy
|
||||
return -dy, dx
|
||||
|
||||
def _window_query_bounds(self, start: Port, target: Port, path: Sequence[ComponentResult], pad: float) -> tuple[float, float, float, float]:
|
||||
min_x = float(min(start.x, target.x))
|
||||
min_y = float(min(start.y, target.y))
|
||||
max_x = float(max(start.x, target.x))
|
||||
max_y = float(max(start.y, target.y))
|
||||
for comp in path:
|
||||
bounds = comp.total_bounds
|
||||
min_x = min(min_x, bounds[0])
|
||||
min_y = min(min_y, bounds[1])
|
||||
max_x = max(max_x, bounds[2])
|
||||
max_y = max(max_y, bounds[3])
|
||||
return (min_x - pad, min_y - pad, max_x + pad, max_y + pad)
|
||||
|
||||
def _candidate_side_extents(
|
||||
self,
|
||||
start: Port,
|
||||
target: Port,
|
||||
window_path: Sequence[ComponentResult],
|
||||
net_width: float,
|
||||
radius: float,
|
||||
) -> list[float]:
|
||||
local_dx, local_dy = self._to_local(start, target)
|
||||
if local_dx < 4.0 * radius - 0.01:
|
||||
return []
|
||||
|
||||
local_points = [self._to_local(start, start)]
|
||||
local_points.extend(self._to_local(start, comp.end_port) for comp in window_path)
|
||||
min_side = float(min(point[1] for point in local_points))
|
||||
max_side = float(max(point[1] for point in local_points))
|
||||
|
||||
positive_anchors: set[float] = set()
|
||||
negative_anchors: set[float] = set()
|
||||
direct_extents: set[float] = set()
|
||||
|
||||
if max_side > 0.01:
|
||||
positive_anchors.add(max_side)
|
||||
direct_extents.add(max_side)
|
||||
if min_side < -0.01:
|
||||
negative_anchors.add(min_side)
|
||||
direct_extents.add(min_side)
|
||||
if local_dy > 0:
|
||||
positive_anchors.add(float(local_dy))
|
||||
elif local_dy < 0:
|
||||
negative_anchors.add(float(local_dy))
|
||||
|
||||
pad = 2.0 * radius + self.collision_engine.clearance + net_width
|
||||
query_bounds = self._window_query_bounds(start, target, window_path, pad)
|
||||
x_min = min(0.0, float(local_dx)) - 0.01
|
||||
x_max = max(0.0, float(local_dx)) + 0.01
|
||||
|
||||
for bounds in self.collision_engine.iter_static_obstacle_bounds(query_bounds):
|
||||
local_corners = (
|
||||
self._to_local_xy(start, bounds[0], bounds[1]),
|
||||
self._to_local_xy(start, bounds[0], bounds[3]),
|
||||
self._to_local_xy(start, bounds[2], bounds[1]),
|
||||
self._to_local_xy(start, bounds[2], bounds[3]),
|
||||
)
|
||||
obs_min_x = min(pt[0] for pt in local_corners)
|
||||
obs_max_x = max(pt[0] for pt in local_corners)
|
||||
if obs_max_x < x_min or obs_min_x > x_max:
|
||||
continue
|
||||
obs_min_y = min(pt[1] for pt in local_corners)
|
||||
obs_max_y = max(pt[1] for pt in local_corners)
|
||||
positive_anchors.add(obs_max_y)
|
||||
negative_anchors.add(obs_min_y)
|
||||
|
||||
for bounds in self.collision_engine.iter_dynamic_path_bounds(query_bounds):
|
||||
local_corners = (
|
||||
self._to_local_xy(start, bounds[0], bounds[1]),
|
||||
self._to_local_xy(start, bounds[0], bounds[3]),
|
||||
self._to_local_xy(start, bounds[2], bounds[1]),
|
||||
self._to_local_xy(start, bounds[2], bounds[3]),
|
||||
)
|
||||
obs_min_x = min(pt[0] for pt in local_corners)
|
||||
obs_max_x = max(pt[0] for pt in local_corners)
|
||||
if obs_max_x < x_min or obs_min_x > x_max:
|
||||
continue
|
||||
obs_min_y = min(pt[1] for pt in local_corners)
|
||||
obs_max_y = max(pt[1] for pt in local_corners)
|
||||
positive_anchors.add(obs_max_y)
|
||||
negative_anchors.add(obs_min_y)
|
||||
|
||||
for anchor in tuple(positive_anchors):
|
||||
if anchor > max(0.0, float(local_dy)) - 0.01:
|
||||
direct_extents.add(anchor + pad)
|
||||
for anchor in tuple(negative_anchors):
|
||||
if anchor < min(0.0, float(local_dy)) + 0.01:
|
||||
direct_extents.add(anchor - pad)
|
||||
|
||||
return sorted(direct_extents, key=lambda value: (abs(value), value))
|
||||
|
||||
def _build_same_orientation_dogleg(
|
||||
self,
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
radius: float,
|
||||
side_extent: float,
|
||||
) -> list[ComponentResult] | None:
|
||||
local_dx, local_dy = self._to_local(start, target)
|
||||
if local_dx < 4.0 * radius - 0.01 or abs(side_extent) < 0.01:
|
||||
return None
|
||||
|
||||
side_abs = abs(side_extent)
|
||||
first_straight = side_abs - 2.0 * radius
|
||||
second_straight = side_abs - 2.0 * radius - math.copysign(float(local_dy), side_extent)
|
||||
if first_straight < -0.01 or second_straight < -0.01:
|
||||
return None
|
||||
min_straight = self.context.options.search.min_straight_length
|
||||
if 0.01 < first_straight < min_straight - 0.01:
|
||||
return None
|
||||
if 0.01 < second_straight < min_straight - 0.01:
|
||||
return None
|
||||
|
||||
forward_length = local_dx - 4.0 * radius
|
||||
if forward_length < -0.01:
|
||||
return None
|
||||
if 0.01 < forward_length < min_straight - 0.01:
|
||||
return None
|
||||
|
||||
first_dir = "CCW" if side_extent > 0 else "CW"
|
||||
second_dir = "CW" if side_extent > 0 else "CCW"
|
||||
dilation = self.collision_engine.clearance / 2.0
|
||||
|
||||
path: list[ComponentResult] = []
|
||||
curr = start
|
||||
|
||||
for direction, straight_len in (
|
||||
(first_dir, first_straight),
|
||||
(second_dir, forward_length),
|
||||
(second_dir, second_straight),
|
||||
(first_dir, None),
|
||||
):
|
||||
bend = Bend90.generate(curr, radius, net_width, direction, dilation=dilation)
|
||||
path.append(bend)
|
||||
curr = bend.end_port
|
||||
if straight_len is None:
|
||||
continue
|
||||
if straight_len > 0.01:
|
||||
straight = Straight.generate(curr, straight_len, net_width, dilation=dilation)
|
||||
path.append(straight)
|
||||
curr = straight.end_port
|
||||
|
||||
if curr != target:
|
||||
return None
|
||||
return path
|
||||
|
||||
def _iter_refinement_windows(self, start: Port, path: Sequence[ComponentResult]) -> list[tuple[int, int]]:
|
||||
ports = self._path_ports(start, path)
|
||||
windows: list[tuple[int, int]] = []
|
||||
min_radius = min(self.context.options.search.bend_radii, default=0.0)
|
||||
|
||||
for window_size in range(len(path), 0, -1):
|
||||
for start_idx in range(len(path) - window_size + 1):
|
||||
end_idx = start_idx + window_size
|
||||
window = path[start_idx:end_idx]
|
||||
bend_count = sum(1 for comp in window if comp.move_type == "bend90")
|
||||
if bend_count < 4:
|
||||
continue
|
||||
window_start = ports[start_idx]
|
||||
window_end = ports[end_idx]
|
||||
if window_start.r != window_end.r:
|
||||
continue
|
||||
local_dx, _ = self._to_local(window_start, window_end)
|
||||
if local_dx < 4.0 * min_radius - 0.01:
|
||||
continue
|
||||
windows.append((start_idx, end_idx))
|
||||
return windows
|
||||
|
||||
def _try_refine_window(
|
||||
self,
|
||||
net_id: str,
|
||||
start: Port,
|
||||
net_width: float,
|
||||
path: list[ComponentResult],
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
best_cost: float,
|
||||
) -> tuple[list[ComponentResult], float] | None:
|
||||
ports = self._path_ports(start, path)
|
||||
window_start = ports[start_idx]
|
||||
window_end = ports[end_idx]
|
||||
window_path = path[start_idx:end_idx]
|
||||
|
||||
best_path: list[ComponentResult] | None = None
|
||||
best_candidate_cost = best_cost
|
||||
|
||||
for radius in self.context.options.search.bend_radii:
|
||||
side_extents = self._candidate_side_extents(window_start, window_end, window_path, net_width, radius)
|
||||
for side_extent in side_extents:
|
||||
replacement = self._build_same_orientation_dogleg(window_start, window_end, net_width, radius, side_extent)
|
||||
if replacement is None:
|
||||
continue
|
||||
candidate_path = path[:start_idx] + replacement + path[end_idx:]
|
||||
report = self.collision_engine.verify_path_report(net_id, candidate_path)
|
||||
if not report.is_valid:
|
||||
continue
|
||||
candidate_cost = self.path_cost(candidate_path)
|
||||
if candidate_cost + 1e-6 < best_candidate_cost:
|
||||
best_candidate_cost = candidate_cost
|
||||
best_path = candidate_path
|
||||
|
||||
if best_path is None:
|
||||
return None
|
||||
return best_path, best_candidate_cost
|
||||
|
||||
def refine_path(
|
||||
self,
|
||||
net_id: str,
|
||||
start: Port,
|
||||
net_width: float,
|
||||
path: list[ComponentResult],
|
||||
) -> list[ComponentResult]:
|
||||
if not path:
|
||||
return path
|
||||
|
||||
path = list(path)
|
||||
|
||||
bend_count = sum(1 for comp in path if comp.move_type == "bend90")
|
||||
if bend_count < 4:
|
||||
return path
|
||||
|
||||
best_path = path
|
||||
best_cost = self.score_path(start, path)
|
||||
|
||||
for _ in range(3):
|
||||
improved = False
|
||||
for start_idx, end_idx in self._iter_refinement_windows(start, best_path):
|
||||
refined = self._try_refine_window(net_id, start, net_width, best_path, start_idx, end_idx, best_cost)
|
||||
if refined is None:
|
||||
continue
|
||||
best_path, best_cost = refined
|
||||
improved = True
|
||||
break
|
||||
if not improved:
|
||||
break
|
||||
|
||||
return best_path
|
||||
16
inire/router/results.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Semi-private compatibility exports for router result types.
|
||||
|
||||
These deep-module imports remain accessible for advanced use, but they are
|
||||
unstable and may change without notice. Prefer importing public result types
|
||||
from ``inire`` or ``inire.results``.
|
||||
"""
|
||||
|
||||
from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult
|
||||
|
||||
__all__ = [
|
||||
"RouteMetrics",
|
||||
"RoutingOutcome",
|
||||
"RoutingReport",
|
||||
"RoutingResult",
|
||||
"RoutingRunResult",
|
||||
]
|
||||
|
|
@ -2,28 +2,28 @@ from __future__ import annotations
|
|||
|
||||
import numpy
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import rtree
|
||||
from shapely.geometry import Point, LineString
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
class VisibilityManager:
|
||||
"""
|
||||
Manages corners of static obstacles for sparse A* / Visibility Graph jumps.
|
||||
"""
|
||||
__slots__ = ('collision_engine', 'corners', 'corner_index', '_corner_graph', '_static_visibility_cache', '_built_static_version')
|
||||
__slots__ = ("collision_engine", "corners", "corner_index", "_corner_graph", "_point_visibility_cache", "_built_static_version")
|
||||
|
||||
def __init__(self, collision_engine: CollisionEngine) -> None:
|
||||
def __init__(self, collision_engine: RoutingWorld) -> None:
|
||||
self.collision_engine = collision_engine
|
||||
self.corners: list[tuple[float, float]] = []
|
||||
self.corner_index = rtree.index.Index()
|
||||
self._corner_graph: dict[int, list[tuple[float, float, float]]] = {}
|
||||
self._static_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {}
|
||||
self._point_visibility_cache: dict[tuple[int, int], list[tuple[float, float, float]]] = {}
|
||||
self._built_static_version = -1
|
||||
self._build()
|
||||
|
||||
|
|
@ -34,20 +34,20 @@ class VisibilityManager:
|
|||
self.corners = []
|
||||
self.corner_index = rtree.index.Index()
|
||||
self._corner_graph = {}
|
||||
self._static_visibility_cache = {}
|
||||
self._point_visibility_cache = {}
|
||||
self._build()
|
||||
|
||||
def _ensure_current(self) -> None:
|
||||
if self._built_static_version != self.collision_engine._static_version:
|
||||
if self._built_static_version != self.collision_engine.get_static_version():
|
||||
self.clear_cache()
|
||||
|
||||
def _build(self) -> None:
|
||||
"""
|
||||
Extract corners and pre-compute corner-to-corner visibility.
|
||||
"""
|
||||
self._built_static_version = self.collision_engine._static_version
|
||||
self._built_static_version = self.collision_engine.get_static_version()
|
||||
raw_corners = []
|
||||
for obj_id, poly in self.collision_engine.static_dilated.items():
|
||||
for poly in self.collision_engine.iter_static_dilated_geometries():
|
||||
coords = list(poly.exterior.coords)
|
||||
if coords[0] == coords[-1]:
|
||||
coords = coords[:-1]
|
||||
|
|
@ -83,7 +83,8 @@ class VisibilityManager:
|
|||
self._corner_graph[i] = []
|
||||
p1 = Port(self.corners[i][0], self.corners[i][1], 0)
|
||||
for j in range(num_corners):
|
||||
if i == j: continue
|
||||
if i == j:
|
||||
continue
|
||||
cx, cy = self.corners[j]
|
||||
dx, dy = cx - p1.x, cy - p1.y
|
||||
dist = numpy.sqrt(dx**2 + dy**2)
|
||||
|
|
@ -92,53 +93,51 @@ class VisibilityManager:
|
|||
if reach >= dist - 0.01:
|
||||
self._corner_graph[i].append((cx, cy, dist))
|
||||
|
||||
def get_visible_corners(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
|
||||
"""
|
||||
Find all corners visible from the origin.
|
||||
Returns list of (x, y, distance).
|
||||
"""
|
||||
self._ensure_current()
|
||||
if max_dist < 0:
|
||||
return []
|
||||
|
||||
def _corner_idx_at(self, origin: Port) -> int | None:
|
||||
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
||||
|
||||
# 1. Exact corner check
|
||||
# Use spatial index to find if origin is AT a corner
|
||||
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
|
||||
for idx in nearby:
|
||||
cx, cy = self.corners[idx]
|
||||
if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4:
|
||||
# We are at a corner! Return pre-computed graph (filtered by max_dist)
|
||||
if idx in self._corner_graph:
|
||||
return [c for c in self._corner_graph[idx] if c[2] <= max_dist]
|
||||
return idx
|
||||
return None
|
||||
|
||||
# 2. Cache check for arbitrary points
|
||||
# Grid-based caching for arbitrary points is tricky,
|
||||
# but since static obstacles don't change, we can cache exact coordinates.
|
||||
cache_key = (int(ox * 1000), int(oy * 1000))
|
||||
if cache_key in self._static_visibility_cache:
|
||||
return self._static_visibility_cache[cache_key]
|
||||
def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
|
||||
"""
|
||||
Find visible corners from an arbitrary point.
|
||||
This may perform direct ray-cast scans and is not intended for hot search paths.
|
||||
"""
|
||||
self._ensure_current()
|
||||
if max_dist < 0:
|
||||
return []
|
||||
|
||||
corner_idx = self._corner_idx_at(origin)
|
||||
if corner_idx is not None and corner_idx in self._corner_graph:
|
||||
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
|
||||
|
||||
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
||||
cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000)))
|
||||
if cache_key in self._point_visibility_cache:
|
||||
return self._point_visibility_cache[cache_key]
|
||||
|
||||
# 3. Full visibility check
|
||||
bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist)
|
||||
candidates = list(self.corner_index.intersection(bounds))
|
||||
|
||||
|
||||
visible = []
|
||||
for i in candidates:
|
||||
cx, cy = self.corners[i]
|
||||
dx, dy = cx - origin.x, cy - origin.y
|
||||
dist = numpy.sqrt(dx**2 + dy**2)
|
||||
|
||||
|
||||
if dist > max_dist or dist < 1e-3:
|
||||
continue
|
||||
|
||||
|
||||
angle = numpy.degrees(numpy.arctan2(dy, dx))
|
||||
reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05)
|
||||
if reach >= dist - 0.01:
|
||||
visible.append((cx, cy, dist))
|
||||
|
||||
self._static_visibility_cache[cache_key] = visible
|
||||
|
||||
self._point_visibility_cache[cache_key] = visible
|
||||
return visible
|
||||
|
||||
def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
|
||||
|
|
@ -150,10 +149,7 @@ class VisibilityManager:
|
|||
if max_dist < 0:
|
||||
return []
|
||||
|
||||
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
||||
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
|
||||
for idx in nearby:
|
||||
cx, cy = self.corners[idx]
|
||||
if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4 and idx in self._corner_graph:
|
||||
return [corner for corner in self._corner_graph[idx] if corner[2] <= max_dist]
|
||||
corner_idx = self._corner_idx_at(origin)
|
||||
if corner_idx is not None and corner_idx in self._corner_graph:
|
||||
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
|
||||
return []
|
||||
|
|
|
|||
48
inire/seeds.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
BendDirection = Literal["CW", "CCW"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StraightSeed:
|
||||
length: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "length", float(self.length))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Bend90Seed:
|
||||
radius: float
|
||||
direction: BendDirection
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "radius", float(self.radius))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SBendSeed:
|
||||
offset: float
|
||||
radius: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "offset", float(self.offset))
|
||||
object.__setattr__(self, "radius", float(self.radius))
|
||||
|
||||
|
||||
PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PathSeed:
|
||||
segments: tuple[PathSegmentSeed, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
segments = tuple(self.segments)
|
||||
if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments):
|
||||
raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances")
|
||||
object.__setattr__(self, "segments", segments)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import time
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
def benchmark_scaling() -> None:
|
||||
print("Starting Scalability Benchmark...")
|
||||
|
||||
# 1. Memory Verification (20x20mm)
|
||||
# Resolution 1um -> 20000 x 20000 grid
|
||||
bounds = (0, 0, 20000, 20000)
|
||||
print(f"Initializing DangerMap for {bounds} area...")
|
||||
dm = DangerMap(bounds=bounds, resolution=1.0)
|
||||
# nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB
|
||||
mem_gb = dm.grid.nbytes / (1024**3)
|
||||
print(f"DangerMap memory usage: {mem_gb:.2f} GB")
|
||||
assert mem_gb < 2.0
|
||||
|
||||
# 2. Node Expansion Rate (50 nets)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
# Use a smaller area for routing benchmark to keep it fast
|
||||
routing_bounds = (0, 0, 1000, 1000)
|
||||
danger_map = DangerMap(bounds=routing_bounds)
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = AStarContext(evaluator)
|
||||
metrics = AStarMetrics()
|
||||
pf = PathFinder(context, metrics)
|
||||
|
||||
num_nets = 50
|
||||
netlist = {}
|
||||
for i in range(num_nets):
|
||||
# Parallel nets spaced by 10um
|
||||
netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0))
|
||||
|
||||
print(f"Routing {num_nets} nets...")
|
||||
start_time = time.monotonic()
|
||||
results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0))
|
||||
end_time = time.monotonic()
|
||||
|
||||
total_time = end_time - start_time
|
||||
print(f"Total routing time: {total_time:.2f} s")
|
||||
print(f"Time per net: {total_time/num_nets:.4f} s")
|
||||
|
||||
if total_time > 0:
|
||||
nodes_per_sec = metrics.total_nodes_expanded / total_time
|
||||
print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s")
|
||||
|
||||
# Success rate
|
||||
successes = sum(1 for r in results.values() if r.is_valid)
|
||||
print(f"Success rate: {successes/num_nets * 100:.1f}%")
|
||||
|
||||
if __name__ == "__main__":
|
||||
benchmark_scaling()
|
||||
|
|
@ -1,44 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
RoutingResult,
|
||||
SearchOptions,
|
||||
)
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, AStarMetrics
|
||||
from inire.router._astar_types import AStarContext, AStarMetrics
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder, RoutingResult
|
||||
|
||||
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
|
||||
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
|
||||
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
|
||||
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
|
||||
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
|
||||
|
||||
ScenarioOutcome = tuple[float, int, int, int]
|
||||
ScenarioRun = Callable[[], ScenarioOutcome]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioOutcome:
|
||||
duration_s: float
|
||||
total_results: int
|
||||
valid_results: int
|
||||
reached_targets: int
|
||||
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
|
||||
return (
|
||||
duration_s,
|
||||
len(results),
|
||||
sum(1 for result in results.values() if result.is_valid),
|
||||
sum(1 for result in results.values() if result.reached_target),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioDefinition:
|
||||
name: str
|
||||
run: Callable[[], ScenarioOutcome]
|
||||
def _build_evaluator(
|
||||
bounds: tuple[float, float, float, float],
|
||||
*,
|
||||
clearance: float = 2.0,
|
||||
obstacles: list[Polygon] | None = None,
|
||||
bend_penalty: float = 50.0,
|
||||
sbend_penalty: float = 150.0,
|
||||
) -> CostEvaluator:
|
||||
static_obstacles = obstacles or []
|
||||
engine = RoutingWorld(clearance=clearance)
|
||||
for obstacle in static_obstacles:
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute(static_obstacles)
|
||||
return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty)
|
||||
|
||||
|
||||
def _build_router(
|
||||
def _net_specs(
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
widths: dict[str, float],
|
||||
) -> tuple[NetSpec, ...]:
|
||||
return tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
|
||||
|
||||
def _build_options(**overrides: object) -> RoutingOptions:
|
||||
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
|
||||
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
|
||||
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
|
||||
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
|
||||
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
|
||||
return RoutingOptions(
|
||||
search=SearchOptions(**search_overrides),
|
||||
congestion=CongestionOptions(**congestion_overrides),
|
||||
refinement=RefinementOptions(**refinement_overrides),
|
||||
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
|
||||
objective=ObjectiveWeights(**objective_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
nets: tuple[NetSpec, ...],
|
||||
metrics: AStarMetrics | None = None,
|
||||
**request_kwargs: object,
|
||||
) -> PathFinder:
|
||||
return PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets),
|
||||
_build_options(**request_kwargs),
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
def _build_routing_stack(
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
widths: dict[str, float],
|
||||
clearance: float = 2.0,
|
||||
obstacles: list[Polygon] | None = None,
|
||||
evaluator_kwargs: dict[str, float] | None = None,
|
||||
context_kwargs: dict[str, object] | None = None,
|
||||
pathfinder_kwargs: dict[str, object] | None = None,
|
||||
) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]:
|
||||
request_kwargs: dict[str, object] | None = None,
|
||||
) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]:
|
||||
static_obstacles = obstacles or []
|
||||
engine = CollisionEngine(clearance=clearance)
|
||||
engine = RoutingWorld(clearance=clearance)
|
||||
for obstacle in static_obstacles:
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
|
|
@ -46,107 +122,126 @@ def _build_router(
|
|||
danger_map.precompute(static_obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
|
||||
context = AStarContext(evaluator, **(context_kwargs or {}))
|
||||
metrics = AStarMetrics()
|
||||
pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {}))
|
||||
return engine, evaluator, context, metrics, pathfinder
|
||||
|
||||
|
||||
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
|
||||
return ScenarioOutcome(
|
||||
duration_s=duration_s,
|
||||
total_results=len(results),
|
||||
valid_results=sum(1 for result in results.values() if result.is_valid),
|
||||
reached_targets=sum(1 for result in results.values() if result.reached_target),
|
||||
pathfinder = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, widths),
|
||||
metrics=metrics,
|
||||
**(request_kwargs or {}),
|
||||
)
|
||||
return engine, evaluator, metrics, pathfinder
|
||||
|
||||
|
||||
def run_example_01() -> ScenarioOutcome:
|
||||
_, _, _, _, pathfinder = _build_router(bounds=(0, 0, 100, 100), context_kwargs={"bend_radii": [10.0]})
|
||||
netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))}
|
||||
widths = {"net1": 2.0}
|
||||
_, _, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
request_kwargs={"bend_radii": [10.0]},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(netlist, {"net1": 2.0})
|
||||
results = pathfinder.route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
def run_example_02() -> ScenarioOutcome:
|
||||
_, _, _, _, pathfinder = _build_router(
|
||||
bounds=(0, 0, 100, 100),
|
||||
evaluator_kwargs={
|
||||
"greedy_h_weight": 1.5,
|
||||
"bend_penalty": 50.0,
|
||||
"sbend_penalty": 150.0,
|
||||
},
|
||||
context_kwargs={
|
||||
"bend_radii": [10.0],
|
||||
"sbend_radii": [10.0],
|
||||
},
|
||||
pathfinder_kwargs={"base_congestion_penalty": 1000.0},
|
||||
)
|
||||
netlist = {
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
}
|
||||
widths = {net_id: 2.0 for net_id in netlist}
|
||||
_, _, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
evaluator_kwargs={
|
||||
"greedy_h_weight": 1.5,
|
||||
"bend_penalty": 50.0,
|
||||
"sbend_penalty": 150.0,
|
||||
},
|
||||
request_kwargs={
|
||||
"bend_radii": [10.0],
|
||||
"sbend_radii": [10.0],
|
||||
"base_penalty": 1000.0,
|
||||
},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(netlist, widths)
|
||||
results = pathfinder.route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
def run_example_03() -> ScenarioOutcome:
|
||||
engine, _, _, _, pathfinder = _build_router(bounds=(0, -50, 100, 50), context_kwargs={"bend_radii": [10.0]})
|
||||
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
|
||||
widths_a = {"netA": 2.0}
|
||||
engine, evaluator, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist=netlist_a,
|
||||
widths=widths_a,
|
||||
request_kwargs={"bend_radii": [10.0]},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
|
||||
engine.lock_net("netA")
|
||||
results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})
|
||||
results_a = pathfinder.route_all()
|
||||
for polygon in results_a["netA"].locked_geometry:
|
||||
engine.add_static_obstacle(polygon)
|
||||
results_b = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}),
|
||||
bend_radii=[10.0],
|
||||
).route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize({**results_a, **results_b}, t1 - t0)
|
||||
|
||||
|
||||
def run_example_04() -> ScenarioOutcome:
|
||||
_, _, _, _, pathfinder = _build_router(
|
||||
bounds=(0, 0, 100, 100),
|
||||
evaluator_kwargs={
|
||||
"unit_length_cost": 1.0,
|
||||
"bend_penalty": 10.0,
|
||||
"sbend_penalty": 20.0,
|
||||
},
|
||||
context_kwargs={
|
||||
"node_limit": 50000,
|
||||
"bend_radii": [10.0, 30.0],
|
||||
"sbend_offsets": [5.0],
|
||||
"bend_penalty": 10.0,
|
||||
"sbend_penalty": 20.0,
|
||||
},
|
||||
)
|
||||
netlist = {
|
||||
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
|
||||
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
|
||||
}
|
||||
widths = {"sbend_only": 2.0, "multi_radii": 2.0}
|
||||
_, _, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
evaluator_kwargs={
|
||||
"unit_length_cost": 1.0,
|
||||
"bend_penalty": 10.0,
|
||||
"sbend_penalty": 20.0,
|
||||
},
|
||||
request_kwargs={
|
||||
"node_limit": 50000,
|
||||
"bend_radii": [10.0, 30.0],
|
||||
"sbend_offsets": [5.0],
|
||||
},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(netlist, widths)
|
||||
results = pathfinder.route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
def run_example_05() -> ScenarioOutcome:
|
||||
_, _, _, _, pathfinder = _build_router(
|
||||
bounds=(0, 0, 200, 200),
|
||||
evaluator_kwargs={"bend_penalty": 50.0},
|
||||
context_kwargs={"bend_radii": [20.0]},
|
||||
)
|
||||
netlist = {
|
||||
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
|
||||
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
|
||||
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
|
||||
}
|
||||
widths = {net_id: 2.0 for net_id in netlist}
|
||||
_, _, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, 0, 200, 200),
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
evaluator_kwargs={"bend_penalty": 50.0},
|
||||
request_kwargs={"bend_radii": [20.0]},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(netlist, widths)
|
||||
results = pathfinder.route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
|
@ -158,35 +253,42 @@ def run_example_06() -> ScenarioOutcome:
|
|||
box(40, 60, 60, 80),
|
||||
box(40, 10, 60, 30),
|
||||
]
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
for obstacle in obstacles:
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute(obstacles)
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
|
||||
contexts = [
|
||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc"),
|
||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox"),
|
||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0),
|
||||
]
|
||||
netlists = [
|
||||
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
|
||||
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
|
||||
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
|
||||
]
|
||||
widths = [
|
||||
{"arc_model": 2.0},
|
||||
{"bbox_model": 2.0},
|
||||
{"clipped_model": 2.0},
|
||||
scenarios = [
|
||||
(
|
||||
_build_evaluator(bounds, obstacles=obstacles),
|
||||
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
|
||||
{"arc_model": 2.0},
|
||||
{"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False},
|
||||
),
|
||||
(
|
||||
_build_evaluator(bounds, obstacles=obstacles),
|
||||
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
|
||||
{"bbox_model": 2.0},
|
||||
{"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False},
|
||||
),
|
||||
(
|
||||
_build_evaluator(bounds, obstacles=obstacles),
|
||||
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
|
||||
{"clipped_model": 2.0},
|
||||
{
|
||||
"bend_radii": [10.0],
|
||||
"bend_collision_type": "clipped_bbox",
|
||||
"bend_clip_margin": 1.0,
|
||||
"use_tiered_strategy": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
t0 = perf_counter()
|
||||
combined_results: dict[str, RoutingResult] = {}
|
||||
for context, netlist, net_widths in zip(contexts, netlists, widths, strict=True):
|
||||
pathfinder = PathFinder(context, use_tiered_strategy=False)
|
||||
combined_results.update(pathfinder.route_all(netlist, net_widths))
|
||||
for evaluator, netlist, net_widths, request_kwargs in scenarios:
|
||||
pathfinder = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, net_widths),
|
||||
**request_kwargs,
|
||||
)
|
||||
combined_results.update(pathfinder.route_all())
|
||||
t1 = perf_counter()
|
||||
return _summarize(combined_results, t1 - t0)
|
||||
|
||||
|
|
@ -197,29 +299,6 @@ def run_example_07() -> ScenarioOutcome:
|
|||
box(450, 0, 550, 400),
|
||||
box(450, 600, 550, 1000),
|
||||
]
|
||||
_, evaluator, _, metrics, pathfinder = _build_router(
|
||||
bounds=bounds,
|
||||
clearance=6.0,
|
||||
obstacles=obstacles,
|
||||
evaluator_kwargs={
|
||||
"greedy_h_weight": 1.5,
|
||||
"unit_length_cost": 0.1,
|
||||
"bend_penalty": 100.0,
|
||||
"sbend_penalty": 400.0,
|
||||
"congestion_penalty": 100.0,
|
||||
},
|
||||
context_kwargs={
|
||||
"node_limit": 2000000,
|
||||
"bend_radii": [50.0],
|
||||
"sbend_radii": [50.0],
|
||||
},
|
||||
pathfinder_kwargs={
|
||||
"max_iterations": 15,
|
||||
"base_congestion_penalty": 100.0,
|
||||
"congestion_multiplier": 1.4,
|
||||
},
|
||||
)
|
||||
|
||||
num_nets = 10
|
||||
start_x = 50
|
||||
start_y_base = 500 - (num_nets * 10.0) / 2.0
|
||||
|
|
@ -232,49 +311,74 @@ def run_example_07() -> ScenarioOutcome:
|
|||
sy = int(round(start_y_base + index * 10.0))
|
||||
ey = int(round(end_y_base + index * end_y_pitch))
|
||||
netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
|
||||
widths = dict.fromkeys(netlist, 2.0)
|
||||
_, evaluator, metrics, pathfinder = _build_routing_stack(
|
||||
bounds=bounds,
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
clearance=6.0,
|
||||
obstacles=obstacles,
|
||||
evaluator_kwargs={
|
||||
"greedy_h_weight": 1.5,
|
||||
"unit_length_cost": 0.1,
|
||||
"bend_penalty": 100.0,
|
||||
"sbend_penalty": 400.0,
|
||||
},
|
||||
request_kwargs={
|
||||
"node_limit": 2000000,
|
||||
"bend_radii": [50.0],
|
||||
"sbend_radii": [50.0],
|
||||
"bend_clip_margin": 10.0,
|
||||
"max_iterations": 15,
|
||||
"base_penalty": 100.0,
|
||||
"multiplier": 1.4,
|
||||
"net_order": "shortest",
|
||||
"capture_expanded": True,
|
||||
"shuffle_nets": True,
|
||||
"seed": 42,
|
||||
},
|
||||
)
|
||||
|
||||
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
|
||||
_ = current_results
|
||||
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
|
||||
evaluator.greedy_h_weight = new_greedy
|
||||
metrics.reset_per_route()
|
||||
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(
|
||||
netlist,
|
||||
dict.fromkeys(netlist, 2.0),
|
||||
store_expanded=True,
|
||||
iteration_callback=iteration_callback,
|
||||
shuffle_nets=True,
|
||||
seed=42,
|
||||
)
|
||||
results = pathfinder.route_all(iteration_callback=iteration_callback)
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
def run_example_08() -> ScenarioOutcome:
|
||||
bounds = (0, 0, 150, 150)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
metrics = AStarMetrics()
|
||||
netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
|
||||
widths = {"custom_bend": 2.0}
|
||||
|
||||
context_std = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[])
|
||||
context_custom = AStarContext(
|
||||
evaluator,
|
||||
bend_radii=[10.0],
|
||||
bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]),
|
||||
sbend_radii=[],
|
||||
)
|
||||
custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
|
||||
evaluator = _build_evaluator(bounds)
|
||||
|
||||
t0 = perf_counter()
|
||||
results_std = PathFinder(context_std, metrics).route_all(netlist, widths)
|
||||
results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all(
|
||||
{"custom_model": netlist["custom_bend"]},
|
||||
{"custom_model": 2.0},
|
||||
)
|
||||
results_std = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, widths),
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[],
|
||||
max_iterations=1,
|
||||
metrics=AStarMetrics(),
|
||||
).route_all()
|
||||
results_custom = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
|
||||
bend_radii=[10.0],
|
||||
bend_collision_type=custom_model,
|
||||
sbend_radii=[],
|
||||
max_iterations=1,
|
||||
use_tiered_strategy=False,
|
||||
metrics=AStarMetrics(),
|
||||
).route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize({**results_std, **results_custom}, t1 - t0)
|
||||
|
||||
|
|
@ -284,28 +388,30 @@ def run_example_09() -> ScenarioOutcome:
|
|||
box(35, 35, 45, 65),
|
||||
box(55, 35, 65, 65),
|
||||
]
|
||||
_, _, _, _, pathfinder = _build_router(
|
||||
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
|
||||
widths = {"budget_limited_net": 2.0}
|
||||
_, _, _, pathfinder = _build_routing_stack(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist,
|
||||
widths=widths,
|
||||
obstacles=obstacles,
|
||||
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
|
||||
context_kwargs={"node_limit": 3, "bend_radii": [10.0]},
|
||||
pathfinder_kwargs={"warm_start": None},
|
||||
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1},
|
||||
)
|
||||
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0})
|
||||
results = pathfinder.route_all()
|
||||
t1 = perf_counter()
|
||||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
SCENARIOS: tuple[ScenarioDefinition, ...] = (
|
||||
ScenarioDefinition("example_01_simple_route", run_example_01),
|
||||
ScenarioDefinition("example_02_congestion_resolution", run_example_02),
|
||||
ScenarioDefinition("example_03_locked_paths", run_example_03),
|
||||
ScenarioDefinition("example_04_sbends_and_radii", run_example_04),
|
||||
ScenarioDefinition("example_05_orientation_stress", run_example_05),
|
||||
ScenarioDefinition("example_06_bend_collision_models", run_example_06),
|
||||
ScenarioDefinition("example_07_large_scale_routing", run_example_07),
|
||||
ScenarioDefinition("example_08_custom_bend_geometry", run_example_08),
|
||||
ScenarioDefinition("example_09_unroutable_best_effort", run_example_09),
|
||||
SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
|
||||
("example_01_simple_route", run_example_01),
|
||||
("example_02_congestion_resolution", run_example_02),
|
||||
("example_03_locked_paths", run_example_03),
|
||||
("example_04_sbends_and_radii", run_example_04),
|
||||
("example_05_orientation_stress", run_example_05),
|
||||
("example_06_bend_collision_models", run_example_06),
|
||||
("example_07_large_scale_routing", run_example_07),
|
||||
("example_08_custom_bend_geometry", run_example_08),
|
||||
("example_09_unroutable_best_effort", run_example_09),
|
||||
)
|
||||
|
|
|
|||
139
inire/tests/test_api.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import importlib
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import box
|
||||
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
Port,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
SearchOptions,
|
||||
route,
|
||||
)
|
||||
from inire.geometry.components import Straight
|
||||
|
||||
|
||||
def test_root_module_exports_only_stable_surface() -> None:
|
||||
import inire
|
||||
|
||||
assert not hasattr(inire, "RoutingWorld")
|
||||
assert not hasattr(inire, "AStarContext")
|
||||
assert not hasattr(inire, "PathFinder")
|
||||
assert not hasattr(inire, "CostEvaluator")
|
||||
assert not hasattr(inire, "DangerMap")
|
||||
|
||||
|
||||
def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None:
|
||||
router_module = importlib.import_module("inire.router._router")
|
||||
search_module = importlib.import_module("inire.router._search")
|
||||
collision_module = importlib.import_module("inire.geometry.collision")
|
||||
|
||||
assert hasattr(router_module, "PathFinder")
|
||||
assert hasattr(search_module, "route_astar")
|
||||
assert hasattr(collision_module, "RoutingWorld")
|
||||
|
||||
|
||||
def test_route_problem_smoke() -> None:
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
|
||||
)
|
||||
|
||||
run = route(problem)
|
||||
|
||||
assert set(run.results_by_net) == {"net1"}
|
||||
assert run.results_by_net["net1"].is_valid
|
||||
|
||||
|
||||
def test_route_problem_supports_configs_and_debug_data() -> None:
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("net1", Port(10, 10, 0), Port(90, 90, 0), width=2.0),),
|
||||
static_obstacles=(box(40, 0, 60, 70),),
|
||||
)
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(10.0,),
|
||||
node_limit=50000,
|
||||
greedy_h_weight=1.2,
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
bend_penalty=50.0,
|
||||
sbend_penalty=150.0,
|
||||
),
|
||||
congestion=CongestionOptions(warm_start_enabled=False),
|
||||
refinement=RefinementOptions(enabled=True),
|
||||
diagnostics=DiagnosticsOptions(capture_expanded=True),
|
||||
)
|
||||
|
||||
run = route(problem, options=options)
|
||||
|
||||
assert run.results_by_net["net1"].reached_target
|
||||
assert run.expanded_nodes
|
||||
assert run.metrics.nodes_expanded > 0
|
||||
|
||||
|
||||
def test_route_problem_locked_routes_become_static_obstacles() -> None:
|
||||
locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),)
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),),
|
||||
static_obstacles=tuple(polygon for component in locked for polygon in component.physical_geometry),
|
||||
)
|
||||
options = RoutingOptions(
|
||||
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
|
||||
refinement=RefinementOptions(enabled=False),
|
||||
)
|
||||
|
||||
run = route(problem, options=options)
|
||||
result = run.results_by_net["crossing"]
|
||||
|
||||
assert not result.is_valid
|
||||
|
||||
|
||||
def test_locked_routes_enable_incremental_requests_without_sessions() -> None:
|
||||
problem_a = RoutingProblem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
|
||||
)
|
||||
options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,)))
|
||||
results_a = route(problem_a, options=options)
|
||||
assert results_a.results_by_net["netA"].is_valid
|
||||
|
||||
problem_b = RoutingProblem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
|
||||
static_obstacles=results_a.results_by_net["netA"].locked_geometry,
|
||||
)
|
||||
results_b = route(problem_b, options=options)
|
||||
|
||||
assert results_b.results_by_net["netB"].is_valid
|
||||
|
||||
|
||||
def test_route_problem_rejects_untyped_initial_paths() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
|
||||
initial_paths={"net1": (object(),)}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_route_results_metrics_are_snapshots() -> None:
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
|
||||
)
|
||||
options = RoutingOptions()
|
||||
run1 = route(problem, options=options)
|
||||
first_metrics = run1.metrics
|
||||
run2 = route(problem, options=options)
|
||||
|
||||
assert first_metrics == run1.metrics
|
||||
assert run1.metrics is not run2.metrics
|
||||
assert first_metrics.nodes_expanded > 0
|
||||
|
|
@ -1,34 +1,117 @@
|
|||
import math
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
import inire.router.astar as astar_module
|
||||
from inire.geometry.components import SBend, Straight
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
|
||||
from inire.geometry.components import Bend90, Straight
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, route_astar
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import RoutingResult
|
||||
from inire.utils.validation import validate_routing_result
|
||||
|
||||
BOUNDS = (0, -50, 150, 150)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator() -> CostEvaluator:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, -50, 150, 150))
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=BOUNDS)
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
|
||||
|
||||
def _build_options(**search_overrides: object) -> RoutingOptions:
|
||||
return RoutingOptions(search=SearchOptions(**search_overrides))
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
**search_overrides: object,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds),
|
||||
_build_options(**search_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options, **config_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _validate_routing_result(
|
||||
result: RoutingResult,
|
||||
static_obstacles: list[Polygon],
|
||||
clearance: float,
|
||||
expected_start: Port | None = None,
|
||||
expected_end: Port | None = None,
|
||||
) -> dict[str, object]:
|
||||
if not result.path:
|
||||
return {"is_valid": False, "reason": "No path found"}
|
||||
|
||||
connectivity_errors: list[str] = []
|
||||
if expected_start:
|
||||
first_port = result.path[0].start_port
|
||||
dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y)
|
||||
if dist_to_start > 0.005:
|
||||
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
|
||||
if abs(first_port.r - expected_start.r) > 0.1:
|
||||
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
|
||||
|
||||
if expected_end:
|
||||
last_port = result.path[-1].end_port
|
||||
dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y)
|
||||
if dist_to_end > 0.005:
|
||||
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
|
||||
if abs(last_port.r - expected_end.r) > 0.1:
|
||||
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
|
||||
|
||||
engine = RoutingWorld(clearance=clearance)
|
||||
for obstacle in static_obstacles:
|
||||
engine.add_static_obstacle(obstacle)
|
||||
report = engine.verify_path_report("validation", result.path)
|
||||
is_valid = report.is_valid and not connectivity_errors
|
||||
|
||||
reasons = []
|
||||
if report.static_collision_count:
|
||||
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
|
||||
if report.dynamic_collision_count:
|
||||
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
|
||||
if report.self_collision_count:
|
||||
reasons.append(f"Found {report.self_collision_count} self-intersections.")
|
||||
reasons.extend(connectivity_errors)
|
||||
|
||||
return {
|
||||
"is_valid": is_valid,
|
||||
"reason": " ".join(reasons),
|
||||
"obstacle_collisions": report.static_collision_count,
|
||||
"dynamic_collisions": report.dynamic_collision_count,
|
||||
"self_intersections": report.self_collision_count,
|
||||
"total_length": report.total_length,
|
||||
"connectivity_ok": not connectivity_errors,
|
||||
}
|
||||
|
||||
|
||||
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
assert validation["connectivity_ok"]
|
||||
|
|
@ -37,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
|||
|
||||
|
||||
def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator, bend_radii=[10.0])
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,))
|
||||
start = Port(0, 0, 0)
|
||||
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
|
||||
target = Port(20, 20, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
assert validation["connectivity_ok"]
|
||||
|
|
@ -58,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
|
|||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
|
||||
context = AStarContext(basic_evaluator, bend_radii=[10.0], node_limit=1000000)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(60, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
||||
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
# Path should have detoured, so length > 50
|
||||
|
|
@ -73,217 +156,165 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
|
|||
|
||||
|
||||
def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(10.1, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
assert target.x == 10
|
||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
|
||||
|
||||
def test_expand_moves_only_shortens_consecutive_straights(
|
||||
basic_evaluator: CostEvaluator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0)
|
||||
prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
|
||||
current = astar_module.AStarNode(
|
||||
prev_result.end_port,
|
||||
g_cost=prev_result.length,
|
||||
h_cost=0.0,
|
||||
component_result=prev_result,
|
||||
def test_validate_routing_result_checks_expected_start() -> None:
|
||||
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
|
||||
validation = _validate_routing_result(
|
||||
result,
|
||||
[],
|
||||
clearance=2.0,
|
||||
expected_start=Port(0, 0, 0),
|
||||
expected_end=Port(110, 0, 0),
|
||||
)
|
||||
|
||||
emitted: list[tuple[str, tuple]] = []
|
||||
assert not validation["is_valid"]
|
||||
assert "Initial port position mismatch" in validation["reason"]
|
||||
|
||||
def fake_process_move(*args, **kwargs) -> None:
|
||||
emitted.append((args[9], args[10]))
|
||||
|
||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
||||
def test_validate_routing_result_uses_exact_component_geometry() -> None:
|
||||
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0)
|
||||
result = RoutingResult(net_id="test", path=[bend], reached_target=True)
|
||||
obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)])
|
||||
|
||||
astar_module.expand_moves(
|
||||
current,
|
||||
Port(80, 0, 0),
|
||||
validation = _validate_routing_result(
|
||||
result,
|
||||
[obstacle],
|
||||
clearance=2.0,
|
||||
expected_start=Port(0, 0, 0),
|
||||
expected_end=bend.end_port,
|
||||
)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
|
||||
|
||||
def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None:
|
||||
basic_evaluator = CostEvaluator(
|
||||
basic_evaluator.collision_engine,
|
||||
basic_evaluator.danger_map,
|
||||
bend_penalty=120.0,
|
||||
sbend_penalty=240.0,
|
||||
)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,))
|
||||
|
||||
assert context.options.search.bend_radii == (5.0,)
|
||||
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
|
||||
|
||||
|
||||
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
|
||||
|
||||
route_astar(
|
||||
Port(0, 0, 0),
|
||||
Port(30, 10, 0),
|
||||
net_width=2.0,
|
||||
net_id="test",
|
||||
open_set=[],
|
||||
closed_set={},
|
||||
context=context,
|
||||
metrics=astar_module.AStarMetrics(),
|
||||
congestion_cache={},
|
||||
config=SearchRunConfig.from_options(
|
||||
context.options,
|
||||
bend_collision_type="clipped_bbox",
|
||||
return_partial=True,
|
||||
),
|
||||
)
|
||||
|
||||
straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"]
|
||||
assert straight_lengths
|
||||
assert all(length < prev_result.length for length in straight_lengths)
|
||||
assert context.options.search.bend_collision_type == "arc"
|
||||
|
||||
|
||||
def test_expand_moves_does_not_chain_sbends(
|
||||
basic_evaluator: CostEvaluator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0)
|
||||
prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0)
|
||||
current = astar_module.AStarNode(
|
||||
prev_result.end_port,
|
||||
g_cost=prev_result.length,
|
||||
h_cost=0.0,
|
||||
component_result=prev_result,
|
||||
)
|
||||
def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None:
|
||||
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(60, 0, 0)
|
||||
|
||||
emitted: list[str] = []
|
||||
partial_path = _route(context, start, target, return_partial=True)
|
||||
no_partial_path = _route(context, start, target, return_partial=False)
|
||||
|
||||
def fake_process_move(*args, **kwargs) -> None:
|
||||
emitted.append(args[9])
|
||||
|
||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
||||
|
||||
astar_module.expand_moves(
|
||||
current,
|
||||
Port(60, 10, 0),
|
||||
net_width=2.0,
|
||||
net_id="test",
|
||||
open_set=[],
|
||||
closed_set={},
|
||||
context=context,
|
||||
metrics=astar_module.AStarMetrics(),
|
||||
congestion_cache={},
|
||||
)
|
||||
|
||||
assert "SB" not in emitted
|
||||
assert emitted
|
||||
assert partial_path is not None
|
||||
assert partial_path
|
||||
assert partial_path[-1].end_port != target
|
||||
assert no_partial_path is None
|
||||
|
||||
|
||||
def test_expand_moves_adds_sbend_aligned_straight_stop_points(
|
||||
basic_evaluator: CostEvaluator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
context = AStarContext(
|
||||
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
bounds=BOUNDS,
|
||||
bend_radii=(10.0,),
|
||||
sbend_radii=(10.0,),
|
||||
sbend_offsets=(10.0,),
|
||||
max_straight_length=150.0,
|
||||
)
|
||||
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(100, 10, 0)
|
||||
|
||||
emitted: list[tuple[str, tuple]] = []
|
||||
path = _route(context, start, target)
|
||||
|
||||
def fake_process_move(*args, **kwargs) -> None:
|
||||
emitted.append((args[9], args[10]))
|
||||
|
||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
||||
|
||||
astar_module.expand_moves(
|
||||
current,
|
||||
Port(100, 10, 0),
|
||||
net_width=2.0,
|
||||
net_id="test",
|
||||
open_set=[],
|
||||
closed_set={},
|
||||
context=context,
|
||||
metrics=astar_module.AStarMetrics(),
|
||||
congestion_cache={},
|
||||
assert path is not None
|
||||
assert path[-1].end_port == target
|
||||
assert sum(1 for component in path if component.move_type == "sbend") == 1
|
||||
assert not any(
|
||||
first.move_type == second.move_type == "sbend"
|
||||
for first, second in zip(path, path[1:], strict=False)
|
||||
)
|
||||
|
||||
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
||||
sbend_span = astar_module._sbend_forward_span(10.0, 10.0)
|
||||
assert sbend_span is not None
|
||||
assert int(round(100.0 - sbend_span)) in straight_lengths
|
||||
assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths
|
||||
|
||||
|
||||
def test_expand_moves_adds_exact_corner_visibility_stop_points(
|
||||
@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"])
|
||||
def test_route_astar_supports_all_visibility_guidance_modes(
|
||||
basic_evaluator: CostEvaluator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
visibility_guidance: str,
|
||||
) -> None:
|
||||
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bounds=BOUNDS,
|
||||
bend_radii=(10.0,),
|
||||
sbend_radii=(),
|
||||
max_straight_length=150.0,
|
||||
visibility_guidance=visibility_guidance,
|
||||
)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(80, 50, 0)
|
||||
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
assert validation["connectivity_ok"]
|
||||
|
||||
|
||||
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(
|
||||
basic_evaluator,
|
||||
bend_radii=[10.0],
|
||||
max_straight_length=150.0,
|
||||
visibility_guidance="exact_corner",
|
||||
RoutingProblem(bounds=BOUNDS),
|
||||
_build_options(
|
||||
min_straight_length=1.0,
|
||||
max_straight_length=100.0,
|
||||
),
|
||||
max_cache_size=2,
|
||||
)
|
||||
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
||||
start = Port(0, 0, 0)
|
||||
targets = [Port(length, 0, 0) for length in range(10, 70, 10)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
astar_module.VisibilityManager,
|
||||
"get_corner_visibility",
|
||||
lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)],
|
||||
)
|
||||
|
||||
emitted: list[tuple[str, tuple]] = []
|
||||
|
||||
def fake_process_move(*args, **kwargs) -> None:
|
||||
emitted.append((args[9], args[10]))
|
||||
|
||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
||||
|
||||
astar_module.expand_moves(
|
||||
current,
|
||||
Port(120, 20, 0),
|
||||
net_width=2.0,
|
||||
net_id="test",
|
||||
open_set=[],
|
||||
closed_set={},
|
||||
context=context,
|
||||
metrics=astar_module.AStarMetrics(),
|
||||
congestion_cache={},
|
||||
)
|
||||
|
||||
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
||||
assert 40 in straight_lengths
|
||||
assert 75 in straight_lengths
|
||||
|
||||
|
||||
def test_expand_moves_adds_tangent_corner_visibility_stop_points(
|
||||
basic_evaluator: CostEvaluator,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class DummyCornerIndex:
|
||||
def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]:
|
||||
return [0, 1]
|
||||
|
||||
context = AStarContext(
|
||||
basic_evaluator,
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[],
|
||||
max_straight_length=150.0,
|
||||
visibility_guidance="tangent_corner",
|
||||
)
|
||||
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
||||
|
||||
monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None)
|
||||
context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)]
|
||||
context.visibility_manager.corner_index = DummyCornerIndex()
|
||||
monkeypatch.setattr(
|
||||
type(context.cost_evaluator.collision_engine),
|
||||
"ray_cast",
|
||||
lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist,
|
||||
)
|
||||
|
||||
emitted: list[tuple[str, tuple]] = []
|
||||
|
||||
def fake_process_move(*args, **kwargs) -> None:
|
||||
emitted.append((args[9], args[10]))
|
||||
|
||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
||||
|
||||
astar_module.expand_moves(
|
||||
current,
|
||||
Port(120, 20, 0),
|
||||
net_width=2.0,
|
||||
net_id="test",
|
||||
open_set=[],
|
||||
closed_set={},
|
||||
context=context,
|
||||
metrics=astar_module.AStarMetrics(),
|
||||
congestion_cache={},
|
||||
)
|
||||
|
||||
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
||||
assert 40 in straight_lengths
|
||||
assert 70 in straight_lengths
|
||||
for target in targets:
|
||||
path = _route(context, start, target)
|
||||
assert path is not None
|
||||
assert path[-1].end_port == target
|
||||
|
|
|
|||
|
|
@ -1,13 +1,41 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from shapely.geometry import Polygon
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.components import Straight
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.router.pathfinder import PathFinder, RoutingResult
|
||||
from inire import RoutingResult
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
search: SearchOptions | None = None,
|
||||
congestion: CongestionOptions | None = None,
|
||||
) -> PathFinder:
|
||||
nets = tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
return PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets),
|
||||
RoutingOptions(
|
||||
search=SearchOptions() if search is None else search,
|
||||
congestion=CongestionOptions() if congestion is None else congestion,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_clearance_thresholds():
|
||||
"""
|
||||
|
|
@ -16,43 +44,41 @@ def test_clearance_thresholds():
|
|||
"""
|
||||
# Clearance = 2.0, Width = 2.0
|
||||
# Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0
|
||||
ce = CollisionEngine(clearance=2.0)
|
||||
ce = RoutingWorld(clearance=2.0)
|
||||
|
||||
# Net 1: Centerline at y=0
|
||||
p1 = Port(0, 0, 0)
|
||||
res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0)
|
||||
ce.add_path("net1", res1.geometry, dilated_geometry=res1.dilated_geometry)
|
||||
ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry)
|
||||
|
||||
# Net 2: Parallel to Net 1
|
||||
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
|
||||
p2_ok = Port(0, 5, 0)
|
||||
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_ok])
|
||||
assert is_v, f"Gap 3 should be valid, but got {count} collisions"
|
||||
report_ok = ce.verify_path_report("net2", [res2_ok])
|
||||
assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions"
|
||||
|
||||
# 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK.
|
||||
p2_exact = Port(0, 4, 0)
|
||||
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_exact])
|
||||
assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions"
|
||||
report_exact = ce.verify_path_report("net2", [res2_exact])
|
||||
assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions"
|
||||
|
||||
# 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL.
|
||||
p2_fail = Port(0, 3, 0)
|
||||
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_fail])
|
||||
assert not is_v, "Gap 1.999 should be invalid"
|
||||
assert count > 0
|
||||
report_fail = ce.verify_path_report("net2", [res2_fail])
|
||||
assert not report_fail.is_valid, "Gap 1.999 should be invalid"
|
||||
assert report_fail.collision_count > 0
|
||||
|
||||
def test_verify_all_nets_cases():
|
||||
"""
|
||||
Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases.
|
||||
"""
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 100, 100))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map)
|
||||
context = AStarContext(cost_evaluator=evaluator)
|
||||
pf = PathFinder(context, warm_start=None, max_iterations=1)
|
||||
|
||||
# Case 1: Parallel paths exactly at clearance (Should be VALID)
|
||||
netlist_parallel_ok = {
|
||||
|
|
@ -60,8 +86,14 @@ def test_verify_all_nets_cases():
|
|||
"net2": (Port(0, 54, 0), Port(100, 54, 0)),
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
results = pf.route_all(netlist_parallel_ok, net_widths)
|
||||
|
||||
results = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_parallel_ok,
|
||||
net_widths=net_widths,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
|
||||
assert results["net2"].is_valid
|
||||
|
||||
|
|
@ -74,7 +106,13 @@ def test_verify_all_nets_cases():
|
|||
engine.remove_path("net1")
|
||||
engine.remove_path("net2")
|
||||
|
||||
results_p = pf.route_all(netlist_parallel_fail, net_widths)
|
||||
results_p = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_parallel_fail,
|
||||
net_widths=net_widths,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
# verify_all_nets should flag both as invalid because they cross-collide
|
||||
assert not results_p["net3"].is_valid
|
||||
assert not results_p["net4"].is_valid
|
||||
|
|
@ -87,6 +125,12 @@ def test_verify_all_nets_cases():
|
|||
engine.remove_path("net3")
|
||||
engine.remove_path("net4")
|
||||
|
||||
results_c = pf.route_all(netlist_cross, net_widths)
|
||||
results_c = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_cross,
|
||||
net_widths=net_widths,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
assert not results_c["net5"].is_valid
|
||||
assert not results_c["net6"].is_valid
|
||||
|
|
|
|||
|
|
@ -1,75 +1,51 @@
|
|||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import Straight
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
def _install_static_straight(
|
||||
engine: RoutingWorld,
|
||||
start: Port,
|
||||
length: float,
|
||||
*,
|
||||
width: float,
|
||||
dilation: float = 0.0,
|
||||
) -> None:
|
||||
obstacle = Straight.generate(start, length, width=width, dilation=dilation)
|
||||
for polygon in obstacle.physical_geometry:
|
||||
engine.add_static_obstacle(polygon)
|
||||
|
||||
|
||||
def test_collision_detection() -> None:
|
||||
# Clearance = 2um
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0)
|
||||
|
||||
# 10x10 um obstacle at (10,10)
|
||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0)
|
||||
assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port)
|
||||
|
||||
# 1. Direct hit
|
||||
test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)])
|
||||
assert engine.is_collision(test_poly, net_width=2.0)
|
||||
far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0)
|
||||
assert not engine.check_move_static(far_away, start_port=far_away.start_port)
|
||||
|
||||
# 2. Far away
|
||||
test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
|
||||
assert not engine.is_collision(test_poly_far, net_width=2.0)
|
||||
|
||||
# 3. Near hit (within clearance)
|
||||
# Obstacle edge at x=10.
|
||||
# test_poly edge at x=9.
|
||||
# Distance = 1.0 um.
|
||||
# Required distance (Wi+C)/2 = 2.0. Collision!
|
||||
test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)])
|
||||
assert engine.is_collision(test_poly_near, net_width=2.0)
|
||||
near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0)
|
||||
assert engine.check_move_static(near_hit, start_port=near_hit.start_port)
|
||||
|
||||
|
||||
def test_safety_zone() -> None:
|
||||
# Use zero clearance for this test to verify the 2nm port safety zone
|
||||
# against the physical obstacle boundary.
|
||||
engine = CollisionEngine(clearance=0.0)
|
||||
engine = RoutingWorld(clearance=0.0)
|
||||
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0)
|
||||
|
||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
# Port exactly on the boundary
|
||||
start_port = Port(10, 12, 0)
|
||||
|
||||
# Move starting from this port that overlaps the obstacle by 1nm
|
||||
# (Inside the 2nm safety zone)
|
||||
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
|
||||
|
||||
assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port)
|
||||
|
||||
|
||||
def test_configurable_max_net_width() -> None:
|
||||
# Large max_net_width (10.0) -> large pre-dilation (6.0)
|
||||
engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
|
||||
|
||||
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)])
|
||||
# physical check: dilated test_poly by C/2 = 1.0.
|
||||
# Dilated test_poly bounds: (14, 19, 17, 26).
|
||||
# obstacle: (20, 20, 25, 25). No physical collision.
|
||||
assert not engine.is_collision(test_poly, net_width=2.0)
|
||||
test_move = Straight.generate(start_port, 0.002, width=0.001)
|
||||
assert not engine.check_move_static(test_move, start_port=start_port)
|
||||
|
||||
|
||||
def test_ray_cast_width_clearance() -> None:
|
||||
# Clearance = 2.0um, Width = 2.0um.
|
||||
# Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um.
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
|
||||
# Obstacle at x=10 to 20
|
||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0)
|
||||
|
||||
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
|
||||
start_ok = Port(6, 50, 90)
|
||||
|
|
@ -83,23 +59,73 @@ def test_ray_cast_width_clearance() -> None:
|
|||
|
||||
|
||||
def test_check_move_static_clearance() -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0)
|
||||
|
||||
# Straight move of length 10 at x=8 (Width 2.0)
|
||||
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
|
||||
start = Port(8, 0, 90)
|
||||
res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2
|
||||
|
||||
assert engine.check_move_static(res, start_port=start, net_width=2.0)
|
||||
assert engine.check_move_static(res, start_port=start)
|
||||
|
||||
# Move at x=7. Gap = 3.0 == minimum. OK.
|
||||
start_ok = Port(7, 0, 90)
|
||||
res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0)
|
||||
assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0)
|
||||
assert not engine.check_move_static(res_ok, start_port=start_ok)
|
||||
|
||||
# 3. Same exact-boundary case.
|
||||
start_exact = Port(7, 0, 90)
|
||||
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
|
||||
assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0)
|
||||
assert not engine.check_move_static(res_exact, start_port=start_exact)
|
||||
|
||||
|
||||
def test_verify_path_report_preserves_long_net_id() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
net_id = "net_abcdefghijklmnopqrstuvwxyz_0123456789"
|
||||
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
|
||||
geoms = [poly for component in path for poly in component.collision_geometry]
|
||||
dilated = [poly for component in path for poly in component.dilated_collision_geometry]
|
||||
|
||||
engine.add_path(net_id, geoms, dilated_geometry=dilated)
|
||||
report = engine.verify_path_report(net_id, path)
|
||||
|
||||
assert report.dynamic_collision_count == 0
|
||||
|
||||
|
||||
def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
shared_prefix = "net_shared_prefix_abcdefghijklmnopqrstuvwxyz_"
|
||||
net_a = f"{shared_prefix}A"
|
||||
net_b = f"{shared_prefix}B"
|
||||
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
|
||||
path_b = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
|
||||
|
||||
engine.add_path(
|
||||
net_a,
|
||||
[poly for component in path_a for poly in component.collision_geometry],
|
||||
dilated_geometry=[poly for component in path_a for poly in component.dilated_collision_geometry],
|
||||
)
|
||||
engine.add_path(
|
||||
net_b,
|
||||
[poly for component in path_b for poly in component.collision_geometry],
|
||||
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
|
||||
)
|
||||
|
||||
report = engine.verify_path_report(net_a, path_a)
|
||||
|
||||
assert report.dynamic_collision_count == 1
|
||||
|
||||
|
||||
def test_remove_path_clears_dynamic_path() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
|
||||
geoms = [poly for component in path for poly in component.collision_geometry]
|
||||
dilated = [poly for component in path for poly in component.dilated_collision_geometry]
|
||||
|
||||
engine.add_path("netA", geoms, dilated_geometry=dilated)
|
||||
assert {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} == {"netA"}
|
||||
|
||||
engine.remove_path("netA")
|
||||
assert list(engine._dynamic_paths.geometries.values()) == []
|
||||
assert len(engine._static_obstacles.geometries) == 0
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import pytest
|
||||
from dataclasses import FrozenInstanceError
|
||||
from shapely.affinity import rotate as shapely_rotate
|
||||
from shapely.affinity import scale as shapely_scale
|
||||
from shapely.affinity import translate as shapely_translate
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import Bend90, SBend, Straight
|
||||
from inire.geometry.primitives import Port, rotate_port, translate_port
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
def test_straight_generation() -> None:
|
||||
|
|
@ -12,15 +17,16 @@ def test_straight_generation() -> None:
|
|||
|
||||
assert result.end_port.x == 10.0
|
||||
assert result.end_port.y == 0.0
|
||||
assert result.end_port.orientation == 0.0
|
||||
assert len(result.geometry) == 1
|
||||
assert result.end_port.r == 0.0
|
||||
assert len(result.collision_geometry) == 1
|
||||
|
||||
# Bounds of the polygon
|
||||
minx, miny, maxx, maxy = result.geometry[0].bounds
|
||||
minx, miny, maxx, maxy = result.collision_geometry[0].bounds
|
||||
assert minx == 0.0
|
||||
assert maxx == 10.0
|
||||
assert miny == -1.0
|
||||
assert maxy == 1.0
|
||||
assert isinstance(result.collision_geometry, tuple)
|
||||
|
||||
|
||||
def test_bend90_generation() -> None:
|
||||
|
|
@ -32,13 +38,13 @@ def test_bend90_generation() -> None:
|
|||
result_cw = Bend90.generate(start, radius, width, direction="CW")
|
||||
assert result_cw.end_port.x == 10.0
|
||||
assert result_cw.end_port.y == -10.0
|
||||
assert result_cw.end_port.orientation == 270.0
|
||||
assert result_cw.end_port.r == 270.0
|
||||
|
||||
# CCW bend
|
||||
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
|
||||
assert result_ccw.end_port.x == 10.0
|
||||
assert result_ccw.end_port.y == 10.0
|
||||
assert result_ccw.end_port.orientation == 90.0
|
||||
assert result_ccw.end_port.r == 90.0
|
||||
|
||||
|
||||
def test_sbend_generation() -> None:
|
||||
|
|
@ -49,8 +55,8 @@ def test_sbend_generation() -> None:
|
|||
|
||||
result = SBend.generate(start, offset, radius, width)
|
||||
assert result.end_port.y == 5.0
|
||||
assert result.end_port.orientation == 0.0
|
||||
assert len(result.geometry) == 2 # Optimization: returns individual arcs
|
||||
assert result.end_port.r == 0.0
|
||||
assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs
|
||||
|
||||
# Verify failure for large offset
|
||||
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
|
||||
|
|
@ -66,7 +72,7 @@ def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() ->
|
|||
result = SBend.generate(start, offset, radius, width)
|
||||
|
||||
assert result.end_port.y == -5.0
|
||||
second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.geometry[1].bounds
|
||||
second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds
|
||||
assert second_arc_maxy <= width / 2.0 + 1e-6
|
||||
assert second_arc_miny < -width / 2.0
|
||||
|
||||
|
|
@ -80,16 +86,65 @@ def test_bend_collision_models() -> None:
|
|||
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
|
||||
# Arc CCW R=10 from (0,0,0) ends at (10,10,90).
|
||||
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
|
||||
minx, miny, maxx, maxy = res_bbox.geometry[0].bounds
|
||||
minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds
|
||||
assert minx <= 0.0 + 1e-6
|
||||
assert maxx >= 10.0 - 1e-6
|
||||
assert miny <= 0.0 + 1e-6
|
||||
assert maxy >= 10.0 - 1e-6
|
||||
|
||||
# 2. Clipped BBox model
|
||||
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0)
|
||||
# Area should be less than full bbox
|
||||
assert res_clipped.geometry[0].area < res_bbox.geometry[0].area
|
||||
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox")
|
||||
# Conservative 8-point approximation should still be tighter than the full bbox.
|
||||
assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8
|
||||
assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area
|
||||
|
||||
# It should also conservatively contain the true arc.
|
||||
res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc")
|
||||
assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0])
|
||||
|
||||
# 3. Legacy clip-margin mode should still be available when explicitly requested.
|
||||
res_clipped_margin = Bend90.generate(
|
||||
start,
|
||||
radius,
|
||||
width,
|
||||
direction="CCW",
|
||||
collision_type="clipped_bbox",
|
||||
clip_margin=1.0,
|
||||
)
|
||||
assert len(res_clipped_margin.collision_geometry[0].exterior.coords) - 1 == 4
|
||||
assert abs(res_clipped_margin.collision_geometry[0].area - 81.0) < 1e-6
|
||||
assert res_clipped_margin.collision_geometry[0].area > res_clipped.collision_geometry[0].area
|
||||
|
||||
|
||||
def test_custom_bend_collision_polygon_uses_local_transform() -> None:
|
||||
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
|
||||
|
||||
cases = [
|
||||
(Port(0, 0, 0), "CCW", (0.0, 10.0), 0.0, False),
|
||||
(Port(0, 0, 0), "CW", (0.0, -10.0), 0.0, True),
|
||||
(Port(0, 0, 90), "CCW", (-10.0, 0.0), 90.0, False),
|
||||
]
|
||||
|
||||
for start, direction, center_xy, rotation_deg, mirror_y in cases:
|
||||
result = Bend90.generate(start, 10.0, 2.0, direction=direction, collision_type=custom_poly)
|
||||
expected = custom_poly
|
||||
if mirror_y:
|
||||
expected = shapely_scale(expected, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0))
|
||||
if rotation_deg:
|
||||
expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False)
|
||||
expected = shapely_translate(expected, center_xy[0], center_xy[1])
|
||||
|
||||
assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6
|
||||
|
||||
|
||||
def test_custom_bend_collision_polygon_only_overrides_search_geometry() -> None:
|
||||
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
|
||||
result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_poly, dilation=1.0)
|
||||
|
||||
assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area > 1e-6
|
||||
assert result.dilated_collision_geometry is not None
|
||||
assert result.dilated_physical_geometry is not None
|
||||
assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area > 1e-6
|
||||
|
||||
|
||||
def test_sbend_collision_models() -> None:
|
||||
|
|
@ -100,11 +155,11 @@ def test_sbend_collision_models() -> None:
|
|||
|
||||
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
|
||||
# Geometry should be a list of individual bbox polygons for each arc
|
||||
assert len(res_bbox.geometry) == 2
|
||||
assert len(res_bbox.collision_geometry) == 2
|
||||
|
||||
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
|
||||
area_bbox = sum(p.area for p in res_bbox.geometry)
|
||||
area_arc = sum(p.area for p in res_arc.geometry)
|
||||
area_bbox = sum(p.area for p in res_bbox.collision_geometry)
|
||||
area_arc = sum(p.area for p in res_arc.collision_geometry)
|
||||
assert area_bbox > area_arc
|
||||
|
||||
|
||||
|
|
@ -118,14 +173,14 @@ def test_sbend_continuity() -> None:
|
|||
res = SBend.generate(start, offset, radius, width)
|
||||
|
||||
# Target orientation should be same as start
|
||||
assert abs(res.end_port.orientation - 90.0) < 1e-6
|
||||
assert abs(res.end_port.r - 90.0) < 1e-6
|
||||
|
||||
# For a port at 90 deg, +offset is a shift in -x direction
|
||||
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
|
||||
|
||||
# Geometry should be a list of valid polygons
|
||||
assert len(res.geometry) == 2
|
||||
for p in res.geometry:
|
||||
assert len(res.collision_geometry) == 2
|
||||
for p in res.collision_geometry:
|
||||
assert p.is_valid
|
||||
|
||||
|
||||
|
|
@ -142,8 +197,8 @@ def test_arc_sagitta_precision() -> None:
|
|||
|
||||
# Number of segments should be significantly higher for fine
|
||||
# Exterior points = (segments + 1) * 2
|
||||
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
|
||||
pts_fine = len(res_fine.geometry[0].exterior.coords)
|
||||
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
|
||||
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
|
||||
|
||||
assert pts_fine > pts_coarse * 2
|
||||
|
||||
|
|
@ -162,12 +217,19 @@ def test_component_transform_invariance() -> None:
|
|||
angle = 90.0
|
||||
|
||||
# 1. Transform the generated geometry
|
||||
p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle)
|
||||
p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle)
|
||||
|
||||
# 2. Generate at transformed start
|
||||
start_transformed = rotate_port(translate_port(start0, dx, dy), angle)
|
||||
start_transformed = start0.translate(dx, dy).rotated(angle)
|
||||
res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW")
|
||||
|
||||
assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6
|
||||
assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6
|
||||
assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6
|
||||
assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6
|
||||
|
||||
|
||||
def test_component_result_is_immutable_value_type() -> None:
|
||||
result = Straight.generate(Port(0, 0, 0), 10.0, 2.0)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
result.length = 42.0
|
||||
|
|
|
|||
|
|
@ -1,30 +1,90 @@
|
|||
import pytest
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, route_astar
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
BOUNDS = (0, -40, 100, 40)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator() -> CostEvaluator:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
# Wider bounds to allow going around (y from -40 to 40)
|
||||
danger_map = DangerMap(bounds=(0, -40, 100, 40))
|
||||
danger_map = DangerMap(bounds=BOUNDS)
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
nets: tuple[NetSpec, ...] = (),
|
||||
search: SearchOptions | None = None,
|
||||
congestion: CongestionOptions | None = None,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets),
|
||||
RoutingOptions(
|
||||
search=SearchOptions() if search is None else search,
|
||||
congestion=CongestionOptions() if congestion is None else congestion,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
search: SearchOptions | None = None,
|
||||
congestion: CongestionOptions | None = None,
|
||||
) -> PathFinder:
|
||||
nets = tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
return PathFinder(
|
||||
_build_context(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=nets,
|
||||
search=search,
|
||||
congestion=congestion,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port) -> object:
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
|
||||
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator, sbend_offsets=[2.0, 5.0])
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bounds=BOUNDS,
|
||||
search=SearchOptions(sbend_offsets=(2.0, 5.0)),
|
||||
)
|
||||
# Start at (0,0), target at (50, 2) -> 2um lateral offset
|
||||
# This matches one of our discretized SBend offsets.
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 2, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
# Check if any component in the path is an SBend
|
||||
|
|
@ -32,37 +92,7 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
|
|||
for res in path:
|
||||
# Check if the end port orientation is same as start
|
||||
# and it's not a single straight (which would have y=0)
|
||||
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1:
|
||||
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1:
|
||||
found_sbend = True
|
||||
break
|
||||
assert found_sbend
|
||||
|
||||
|
||||
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator, bend_radii=[5.0, 10.0])
|
||||
# Increase base penalty to force detour immediately
|
||||
pf = PathFinder(context, max_iterations=10, base_congestion_penalty=1000.0)
|
||||
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
# Force them into a narrow corridor that only fits ONE.
|
||||
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall
|
||||
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])
|
||||
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obs_top)
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
|
||||
basic_evaluator.danger_map.precompute([obs_top, obs_bottom])
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results["net1"].reached_target
|
||||
assert results["net2"].reached_target
|
||||
assert results["net1"].is_valid
|
||||
assert results["net2"].is_valid
|
||||
assert results["net1"].collisions == 0
|
||||
assert results["net2"].collisions == 0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from shapely.geometry import Polygon
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
|
||||
def test_cost_calculation() -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
# 50x50 um area, 1um resolution
|
||||
danger_map = DangerMap(bounds=(0, 0, 50, 50))
|
||||
danger_map.precompute([])
|
||||
|
|
@ -40,6 +40,18 @@ def test_cost_calculation() -> None:
|
|||
assert h_away >= h_90
|
||||
|
||||
|
||||
def test_greedy_h_weight_is_mutable() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 50, 50))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=10.0)
|
||||
|
||||
assert evaluator.greedy_h_weight == 1.5
|
||||
evaluator.greedy_h_weight = 1.2
|
||||
assert evaluator.greedy_h_weight == 1.2
|
||||
assert abs(evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) - 72.0) < 1e-6
|
||||
|
||||
|
||||
def test_danger_map_kd_tree_and_cache() -> None:
|
||||
# Test that KD-Tree based danger map works and uses cache
|
||||
bounds = (0, 0, 1000, 1000)
|
||||
|
|
@ -61,7 +73,23 @@ def test_danger_map_kd_tree_and_cache() -> None:
|
|||
# We can check if calling it again is fast or just verify it returns same result
|
||||
cost_near_2 = dm.get_cost(100.5, 100.5)
|
||||
assert cost_near_2 == cost_near
|
||||
assert len(dm._cost_cache) == 2
|
||||
|
||||
# 4. Out of bounds
|
||||
assert dm.get_cost(-1, -1) >= 1e12
|
||||
|
||||
|
||||
def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None:
|
||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
||||
dm_a = DangerMap((0, 0, 100, 100))
|
||||
dm_b = DangerMap((0, 0, 100, 100))
|
||||
|
||||
dm_a.precompute([obstacle])
|
||||
dm_b.precompute([])
|
||||
|
||||
dm_a.get_cost(15.0, 15.0)
|
||||
assert len(dm_a._cost_cache) == 1
|
||||
assert len(dm_b._cost_cache) == 0
|
||||
|
||||
dm_a.precompute([])
|
||||
assert len(dm_a._cost_cache) == 0
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import statistics
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome
|
||||
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
|
||||
|
||||
|
||||
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
|
||||
PERFORMANCE_REPEATS = 3
|
||||
REGRESSION_FACTOR = 1.5
|
||||
|
||||
# Baselines are measured from the current code path without plotting.
|
||||
# Baselines are measured from clean 6a28dcf-style runs without plotting.
|
||||
BASELINE_SECONDS = {
|
||||
"example_01_simple_route": 0.0035,
|
||||
"example_02_congestion_resolution": 0.2666,
|
||||
|
|
@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = {
|
|||
|
||||
|
||||
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
|
||||
_, total_results, valid_results, reached_targets = outcome
|
||||
expected = EXPECTED_OUTCOMES[name]
|
||||
assert outcome.total_results == expected["total_results"]
|
||||
assert outcome.valid_results == expected["valid_results"]
|
||||
assert outcome.reached_targets == expected["reached_targets"]
|
||||
assert total_results == expected["total_results"]
|
||||
assert valid_results == expected["valid_results"]
|
||||
assert reached_targets == expected["reached_targets"]
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS])
|
||||
def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None:
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS])
|
||||
def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None:
|
||||
name, run = scenario
|
||||
timings = []
|
||||
for _ in range(PERFORMANCE_REPEATS):
|
||||
outcome = scenario.run()
|
||||
_assert_expected_outcome(scenario.name, outcome)
|
||||
timings.append(outcome.duration_s)
|
||||
outcome = run()
|
||||
_assert_expected_outcome(name, outcome)
|
||||
timings.append(outcome[0])
|
||||
|
||||
median_runtime = statistics.median(timings)
|
||||
assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, (
|
||||
f"{scenario.name} median runtime {median_runtime:.4f}s exceeded "
|
||||
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s "
|
||||
assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, (
|
||||
f"{name} median runtime {median_runtime:.4f}s exceeded "
|
||||
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
|
||||
f"from timings {timings!r}"
|
||||
)
|
||||
|
|
|
|||
184
inire/tests/test_example_regressions.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import pytest
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
Port,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
SearchOptions,
|
||||
route,
|
||||
)
|
||||
from inire.router._stack import build_routing_stack
|
||||
from inire.seeds import Bend90Seed, PathSeed, StraightSeed
|
||||
from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics
|
||||
|
||||
|
||||
EXPECTED_OUTCOMES = {
|
||||
"example_01_simple_route": (1, 1, 1),
|
||||
"example_02_congestion_resolution": (3, 3, 3),
|
||||
"example_03_locked_paths": (2, 2, 2),
|
||||
"example_04_sbends_and_radii": (2, 2, 2),
|
||||
"example_05_orientation_stress": (3, 3, 3),
|
||||
"example_06_bend_collision_models": (3, 3, 3),
|
||||
"example_07_large_scale_routing": (10, 10, 10),
|
||||
"example_08_custom_bend_geometry": (2, 1, 2),
|
||||
"example_09_unroutable_best_effort": (1, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "run"), SCENARIOS, ids=[name for name, _ in SCENARIOS])
|
||||
def test_examples_match_legacy_expected_outcomes(name: str, run) -> None:
|
||||
outcome = run()
|
||||
assert outcome[1:] == EXPECTED_OUTCOMES[name]
|
||||
|
||||
|
||||
def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None:
|
||||
bounds = (-20, -20, 170, 170)
|
||||
obstacles = (
|
||||
Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]),
|
||||
Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]),
|
||||
Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]),
|
||||
)
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=(NetSpec("clipped_model", Port(10, 20, 0), Port(90, 40, 90), width=2.0),),
|
||||
static_obstacles=obstacles,
|
||||
)
|
||||
common_kwargs = {
|
||||
"objective": ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0),
|
||||
"congestion": CongestionOptions(use_tiered_strategy=False),
|
||||
}
|
||||
no_margin = route(
|
||||
problem,
|
||||
options=RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(10.0,),
|
||||
bend_collision_type="clipped_bbox",
|
||||
),
|
||||
**common_kwargs,
|
||||
),
|
||||
).results_by_net["clipped_model"]
|
||||
legacy_margin = route(
|
||||
problem,
|
||||
options=RoutingOptions(
|
||||
search=SearchOptions(
|
||||
bend_radii=(10.0,),
|
||||
bend_collision_type="clipped_bbox",
|
||||
bend_clip_margin=1.0,
|
||||
),
|
||||
**common_kwargs,
|
||||
),
|
||||
).results_by_net["clipped_model"]
|
||||
|
||||
assert no_margin.is_valid
|
||||
assert legacy_margin.is_valid
|
||||
assert legacy_margin.as_seed() != no_margin.as_seed()
|
||||
assert legacy_margin.as_seed() == PathSeed(
|
||||
(
|
||||
StraightSeed(5.0),
|
||||
Bend90Seed(10.0, "CW"),
|
||||
Bend90Seed(10.0, "CCW"),
|
||||
StraightSeed(45.0),
|
||||
Bend90Seed(10.0, "CCW"),
|
||||
StraightSeed(30.0),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None:
|
||||
bounds = (0, 0, 500, 300)
|
||||
obstacles = (
|
||||
box(220, 0, 280, 100),
|
||||
box(220, 200, 280, 300),
|
||||
)
|
||||
netlist = {
|
||||
"net_00": (Port(30, 130, 0), Port(470, 60, 0)),
|
||||
"net_01": (Port(30, 140, 0), Port(470, 120, 0)),
|
||||
"net_02": (Port(30, 150, 0), Port(470, 180, 0)),
|
||||
"net_03": (Port(30, 160, 0), Port(470, 240, 0)),
|
||||
}
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()),
|
||||
static_obstacles=obstacles,
|
||||
clearance=6.0,
|
||||
)
|
||||
options = RoutingOptions(
|
||||
search=SearchOptions(
|
||||
node_limit=200000,
|
||||
bend_radii=(30.0,),
|
||||
sbend_radii=(30.0,),
|
||||
greedy_h_weight=1.5,
|
||||
bend_clip_margin=10.0,
|
||||
),
|
||||
objective=ObjectiveWeights(
|
||||
unit_length_cost=0.1,
|
||||
bend_penalty=100.0,
|
||||
sbend_penalty=400.0,
|
||||
),
|
||||
congestion=CongestionOptions(
|
||||
max_iterations=6,
|
||||
base_penalty=100.0,
|
||||
multiplier=1.4,
|
||||
net_order="shortest",
|
||||
shuffle_nets=True,
|
||||
seed=42,
|
||||
),
|
||||
diagnostics=DiagnosticsOptions(capture_expanded=False),
|
||||
)
|
||||
stack = build_routing_stack(problem, options)
|
||||
evaluator = stack.evaluator
|
||||
finder = stack.finder
|
||||
weights: list[float] = []
|
||||
|
||||
def iteration_callback(iteration: int, current_results: dict[str, object]) -> None:
|
||||
_ = current_results
|
||||
new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4)
|
||||
evaluator.greedy_h_weight = new_greedy
|
||||
weights.append(new_greedy)
|
||||
finder.metrics.reset_per_route()
|
||||
|
||||
results = finder.route_all(iteration_callback=iteration_callback)
|
||||
|
||||
assert weights == [1.46]
|
||||
assert evaluator.greedy_h_weight == 1.46
|
||||
assert all(result.is_valid for result in results.values())
|
||||
assert all(result.reached_target for result in results.values())
|
||||
|
||||
|
||||
def test_example_08_custom_box_restores_legacy_collision_outcome() -> None:
|
||||
bounds = (0, 0, 150, 150)
|
||||
netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
|
||||
widths = {"custom_bend": 2.0}
|
||||
evaluator = _build_evaluator(bounds)
|
||||
|
||||
standard = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, widths),
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[],
|
||||
max_iterations=1,
|
||||
metrics=AStarMetrics(),
|
||||
).route_all()
|
||||
custom = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
|
||||
bend_radii=[10.0],
|
||||
bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]),
|
||||
sbend_radii=[],
|
||||
max_iterations=1,
|
||||
use_tiered_strategy=False,
|
||||
metrics=AStarMetrics(),
|
||||
).route_all()
|
||||
|
||||
assert standard["custom_bend"].is_valid
|
||||
assert standard["custom_bend"].reached_target
|
||||
assert not custom["custom_model"].is_valid
|
||||
assert custom["custom_model"].reached_target
|
||||
assert custom["custom_model"].collisions == 2
|
||||
|
|
@ -1,70 +1,77 @@
|
|||
|
||||
import pytest
|
||||
import numpy
|
||||
from inire import CongestionOptions, RoutingOptions, RoutingProblem
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
def test_failed_net_visibility():
|
||||
def test_failed_net_visibility() -> None:
|
||||
"""
|
||||
Verifies that nets that fail to reach their target (return partial paths)
|
||||
ARE added to the collision engine, making them visible to other nets
|
||||
for negotiated congestion.
|
||||
"""
|
||||
# 1. Setup
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
|
||||
# Create a simple danger map (bounds 0-100)
|
||||
# We don't strictly need obstacles in it for this test.
|
||||
dm = DangerMap(bounds=(0, 0, 100, 100))
|
||||
|
||||
|
||||
evaluator = CostEvaluator(engine, dm)
|
||||
|
||||
|
||||
# 2. Configure Router with low limit to FORCE failure
|
||||
# node_limit=10 is extremely low, likely allowing only a few moves.
|
||||
# Start (0,0) -> Target (100,0) is 100um away.
|
||||
|
||||
|
||||
# Let's add a static obstacle that blocks the direct path.
|
||||
from shapely.geometry import box
|
||||
|
||||
obstacle = box(40, -10, 60, 10) # Wall at x=50
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
|
||||
# With obstacle, direct jump fails. A* must search around.
|
||||
# Limit=10 should be enough to fail to find a path around.
|
||||
context = AStarContext(evaluator, node_limit=10)
|
||||
|
||||
# 3. Configure PathFinder
|
||||
# max_iterations=1 because we only need to check the state after the first attempt.
|
||||
pf = PathFinder(context, max_iterations=1, warm_start=None)
|
||||
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(100, 0, 0))
|
||||
}
|
||||
net_widths = {"net1": 1.0}
|
||||
|
||||
pf = PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
),
|
||||
),
|
||||
RoutingOptions(
|
||||
search=RoutingOptions().search.__class__(node_limit=10),
|
||||
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 4. Route
|
||||
print("\nStarting Route...")
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
results = pf.route_all()
|
||||
|
||||
res = results["net1"]
|
||||
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")
|
||||
|
||||
|
||||
# 5. Verify Failure Condition
|
||||
# We expect reached_target to be False because of node_limit + obstacle
|
||||
assert not res.reached_target, "Test setup failed: Net reached target despite low limit!"
|
||||
assert len(res.path) > 0, "Test setup failed: No partial path returned!"
|
||||
|
||||
|
||||
# 6. Verify Visibility
|
||||
# Check if net1 is in the collision engine
|
||||
found_nets = set()
|
||||
# CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)]
|
||||
for obj_id, (nid, poly) in engine.dynamic_geometries.items():
|
||||
found_nets.add(nid)
|
||||
|
||||
found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()}
|
||||
|
||||
print(f"Nets found in engine: {found_nets}")
|
||||
|
||||
|
||||
# The FIX Expectation: "net1" SHOULD be present
|
||||
assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import pytest
|
|||
from hypothesis import given, settings, strategies as st
|
||||
from shapely.geometry import Point, Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext, route_astar
|
||||
from inire.model import RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
|
|
@ -34,12 +36,35 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance
|
|||
return all(point.distance(obstacle) >= required_gap for obstacle in obstacles)
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
**search_overrides: object,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds),
|
||||
RoutingOptions(search=SearchOptions(**search_overrides)),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=3, deadline=None)
|
||||
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
|
||||
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
|
||||
net_width = 2.0
|
||||
clearance = 2.0
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
for obs in obstacles:
|
||||
engine.add_static_obstacle(obs)
|
||||
|
||||
|
|
@ -47,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
|
|||
danger_map.precompute(obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = AStarContext(evaluator, node_limit=5000) # Lower limit for fuzzing stability
|
||||
context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000)
|
||||
|
||||
# Check if start/target are inside obstacles (safety zone check)
|
||||
# The router should handle this gracefully (either route or return None)
|
||||
try:
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
# This is a crash-smoke test rather than a full correctness proof.
|
||||
# If a full path is returned, it should at least terminate at the requested target.
|
||||
|
|
|
|||
|
|
@ -1,118 +1,231 @@
|
|||
import pytest
|
||||
from shapely.geometry import box
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
SearchOptions,
|
||||
)
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import Bend90, Straight
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
DEFAULT_BOUNDS = (0, 0, 100, 100)
|
||||
|
||||
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"}
|
||||
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
|
||||
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
|
||||
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
|
||||
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
|
||||
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
|
||||
|
||||
def _request_nets(
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
) -> tuple[NetSpec, ...]:
|
||||
return tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator() -> CostEvaluator:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 100, 100))
|
||||
def _build_options(**overrides: object) -> RoutingOptions:
|
||||
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
|
||||
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
|
||||
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
|
||||
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
|
||||
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
|
||||
return RoutingOptions(
|
||||
search=SearchOptions(**search_overrides),
|
||||
congestion=CongestionOptions(**congestion_overrides),
|
||||
refinement=RefinementOptions(**refinement_overrides),
|
||||
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
|
||||
objective=ObjectiveWeights(**objective_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
nets: tuple[NetSpec, ...] = (),
|
||||
**request_overrides: object,
|
||||
) -> AStarContext:
|
||||
problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS}
|
||||
option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS}
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets, **problem_overrides),
|
||||
_build_options(**option_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
|
||||
metrics=None,
|
||||
**request_overrides: object,
|
||||
) -> PathFinder:
|
||||
return PathFinder(
|
||||
_build_context(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_request_nets(netlist, net_widths),
|
||||
**request_overrides,
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list:
|
||||
path = []
|
||||
curr = start
|
||||
dilation = clearance / 2.0
|
||||
for kind, value in steps:
|
||||
if kind == "B":
|
||||
comp = Bend90.generate(curr, 5.0, width, value, dilation=dilation)
|
||||
else:
|
||||
comp = Straight.generate(curr, value, width, dilation=dilation)
|
||||
path.append(comp)
|
||||
curr = comp.end_port
|
||||
return path
|
||||
def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None:
|
||||
netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))}
|
||||
widths = {"net": 2.0}
|
||||
|
||||
def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(-20, -20, 60, 60))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = _build_context(
|
||||
evaluator,
|
||||
bounds=(-20, -20, 60, 60),
|
||||
nets=_request_nets(netlist, widths),
|
||||
bend_radii=[10.0],
|
||||
max_straight_length=50.0,
|
||||
node_limit=50,
|
||||
warm_start_enabled=False,
|
||||
max_iterations=1,
|
||||
enabled=False,
|
||||
)
|
||||
return engine, context, PathFinder(context)
|
||||
|
||||
engine_auto, _context_auto, pf_auto = build_router()
|
||||
assert pf_auto.route_all()["net"].is_valid
|
||||
engine_auto.add_static_obstacle(box(4, 4, 8, 12))
|
||||
auto_result = pf_auto.route_all()["net"]
|
||||
|
||||
engine_manual, context_manual, pf_manual = build_router()
|
||||
assert pf_manual.route_all()["net"].is_valid
|
||||
engine_manual.add_static_obstacle(box(4, 4, 8, 12))
|
||||
context_manual.clear_static_caches()
|
||||
manual_result = pf_manual.route_all()["net"]
|
||||
|
||||
assert auto_result.reached_target == manual_result.reached_target
|
||||
assert auto_result.collisions == manual_result.collisions
|
||||
assert auto_result.outcome == manual_result.outcome
|
||||
assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [
|
||||
(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path
|
||||
]
|
||||
def test_refine_path_handles_same_orientation_lateral_offset() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map)
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
|
||||
pf = _build_pathfinder(
|
||||
evaluator,
|
||||
netlist={"net": (Port(0, 0, 0), Port(60, 15, 0))},
|
||||
net_widths={"net": 2.0},
|
||||
bounds=(-20, -20, 120, 120),
|
||||
bend_radii=[5.0, 10.0],
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
start = Port(0, 0, 0)
|
||||
width = 2.0
|
||||
path = _build_manual_path(
|
||||
start,
|
||||
width,
|
||||
engine.clearance,
|
||||
[
|
||||
("B", "CCW"),
|
||||
("S", 10.0),
|
||||
("B", "CW"),
|
||||
("S", 20.0),
|
||||
("B", "CW"),
|
||||
("S", 10.0),
|
||||
("B", "CCW"),
|
||||
("S", 10.0),
|
||||
("B", "CCW"),
|
||||
("S", 5.0),
|
||||
("B", "CW"),
|
||||
],
|
||||
)
|
||||
target = path[-1].end_port
|
||||
|
||||
refined = pf.refiner.refine_path("net", start, width, path)
|
||||
|
||||
assert target == Port(60, 15, 0)
|
||||
assert sum(1 for comp in path if comp.move_type == "bend90") == 6
|
||||
assert sum(1 for comp in refined if comp.move_type == "bend90") == 4
|
||||
assert refined[-1].end_port == target
|
||||
assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)
|
||||
|
||||
|
||||
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator)
|
||||
pf = PathFinder(context)
|
||||
def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
|
||||
pf = _build_pathfinder(
|
||||
evaluator,
|
||||
netlist={"net": (Port(0, 0, 0), Port(65, 30, 90))},
|
||||
net_widths={"net": 2.0},
|
||||
bounds=(-20, -20, 120, 120),
|
||||
bend_radii=[5.0, 10.0],
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
start = Port(0, 0, 0)
|
||||
width = 2.0
|
||||
path = _build_manual_path(
|
||||
start,
|
||||
width,
|
||||
engine.clearance,
|
||||
[
|
||||
("B", "CCW"),
|
||||
("S", 10.0),
|
||||
("B", "CW"),
|
||||
("S", 20.0),
|
||||
("B", "CW"),
|
||||
("S", 10.0),
|
||||
("B", "CCW"),
|
||||
("S", 10.0),
|
||||
("B", "CCW"),
|
||||
("S", 5.0),
|
||||
("B", "CW"),
|
||||
("B", "CCW"),
|
||||
("S", 10.0),
|
||||
],
|
||||
)
|
||||
target = path[-1].end_port
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
refined = pf.refiner.refine_path("net", start, width, path)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results["net1"].is_valid
|
||||
assert results["net2"].is_valid
|
||||
assert results["net1"].collisions == 0
|
||||
assert results["net2"].collisions == 0
|
||||
|
||||
|
||||
def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
|
||||
context = AStarContext(basic_evaluator)
|
||||
# Force a crossing by setting low iterations and low penalty
|
||||
pf = PathFinder(context, max_iterations=1, base_congestion_penalty=1.0, warm_start=None)
|
||||
|
||||
# Net 1: (0, 25) -> (100, 25) Horizontal
|
||||
# Net 2: (50, 0) -> (50, 50) Vertical
|
||||
netlist = {
|
||||
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
|
||||
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
# Both should be invalid because they cross
|
||||
assert not results["net1"].is_valid
|
||||
assert not results["net2"].is_valid
|
||||
assert results["net1"].collisions > 0
|
||||
assert results["net2"].collisions > 0
|
||||
|
||||
|
||||
def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None:
|
||||
bounds = (0, -50, 100, 50)
|
||||
|
||||
def build_pathfinder(*, refine_paths: bool) -> tuple[CollisionEngine, PathFinder]:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
|
||||
context = AStarContext(evaluator, bend_radii=[10.0])
|
||||
return engine, PathFinder(context, refine_paths=refine_paths)
|
||||
|
||||
base_engine, base_pf = build_pathfinder(refine_paths=False)
|
||||
base_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
|
||||
base_engine.lock_net("netA")
|
||||
base_result = base_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
|
||||
|
||||
refined_engine, refined_pf = build_pathfinder(refine_paths=True)
|
||||
refined_pf.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
|
||||
refined_engine.lock_net("netA")
|
||||
refined_result = refined_pf.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})["netB"]
|
||||
|
||||
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
|
||||
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
|
||||
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert refined_bends < base_bends
|
||||
assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path)
|
||||
|
||||
|
||||
def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None:
|
||||
bounds = (0, 0, 100, 100)
|
||||
netlist = {
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
}
|
||||
net_widths = {net_id: 2.0 for net_id in netlist}
|
||||
|
||||
def build_pathfinder(*, refine_paths: bool) -> PathFinder:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=bounds)
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0)
|
||||
context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[10.0])
|
||||
return PathFinder(context, base_congestion_penalty=1000.0, refine_paths=refine_paths)
|
||||
|
||||
base_results = build_pathfinder(refine_paths=False).route_all(netlist, net_widths)
|
||||
refined_results = build_pathfinder(refine_paths=True).route_all(netlist, net_widths)
|
||||
|
||||
for net_id in ("vertical_up", "vertical_down"):
|
||||
base_result = base_results[net_id]
|
||||
refined_result = refined_results[net_id]
|
||||
base_bends = sum(1 for comp in base_result.path if comp.move_type == "Bend90")
|
||||
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "Bend90")
|
||||
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert refined_bends < base_bends
|
||||
assert target == Port(65, 30, 90)
|
||||
assert sum(1 for comp in path if comp.move_type == "bend90") == 7
|
||||
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
|
||||
assert refined[-1].end_port == target
|
||||
assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,33 @@
|
|||
from inire.geometry.collision import CollisionEngine
|
||||
from inire import RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import Bend90
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarContext
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
search: SearchOptions | None = None,
|
||||
) -> PathFinder:
|
||||
nets = tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
return PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets),
|
||||
RoutingOptions(search=SearchOptions() if search is None else search),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_arc_resolution_sagitta() -> None:
|
||||
|
|
@ -18,34 +41,45 @@ def test_arc_resolution_sagitta() -> None:
|
|||
|
||||
# Check number of points in the polygon exterior
|
||||
# (num_segments + 1) * 2 points usually
|
||||
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
|
||||
pts_fine = len(res_fine.geometry[0].exterior.coords)
|
||||
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
|
||||
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
|
||||
|
||||
assert pts_fine > pts_coarse
|
||||
|
||||
|
||||
def test_locked_paths() -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
def test_locked_routes() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, -50, 100, 50))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
|
||||
pf = PathFinder(context)
|
||||
|
||||
# 1. Route Net A
|
||||
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
|
||||
results_a = pf.route_all(netlist_a, {"netA": 2.0})
|
||||
results_a = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist=netlist_a,
|
||||
net_widths={"netA": 2.0},
|
||||
search=SearchOptions(bend_radii=(5.0, 10.0)),
|
||||
).route_all()
|
||||
assert results_a["netA"].is_valid
|
||||
|
||||
# 2. Lock Net A
|
||||
engine.lock_net("netA")
|
||||
# 2. Treat Net A as locked geometry in the next run.
|
||||
for polygon in results_a["netA"].locked_geometry:
|
||||
engine.add_static_obstacle(polygon)
|
||||
|
||||
# 3. Route Net B through the same space. It should detour or fail.
|
||||
# We'll place Net B's start/target such that it MUST cross Net A's physical path.
|
||||
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
|
||||
|
||||
# Route Net B
|
||||
results_b = pf.route_all(netlist_b, {"netB": 2.0})
|
||||
results_b = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist=netlist_b,
|
||||
net_widths={"netB": 2.0},
|
||||
search=SearchOptions(bend_radii=(5.0, 10.0)),
|
||||
).route_all()
|
||||
|
||||
# Net B should be is_valid (it detoured) or at least not have collisions
|
||||
# with Net A in the dynamic set (because netA is now static).
|
||||
|
|
@ -55,8 +89,8 @@ def test_locked_paths() -> None:
|
|||
assert results_b["netB"].is_valid
|
||||
|
||||
# Verify geometry doesn't intersect locked netA (physical check)
|
||||
poly_a = [p.geometry[0] for p in results_a["netA"].path]
|
||||
poly_b = [p.geometry[0] for p in results_b["netB"].path]
|
||||
poly_a = [p.physical_geometry[0] for p in results_a["netA"].path]
|
||||
poly_b = [p.physical_geometry[0] for p in results_b["netB"].path]
|
||||
|
||||
for pa in poly_a:
|
||||
for pb in poly_b:
|
||||
|
|
|
|||
301
inire/tests/test_route_behavior.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire import (
|
||||
Bend90Seed,
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
PathSeed,
|
||||
Port,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
RoutingResult,
|
||||
SearchOptions,
|
||||
StraightSeed,
|
||||
route,
|
||||
)
|
||||
|
||||
DEFAULT_BOUNDS = (0, 0, 100, 100)
|
||||
|
||||
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"}
|
||||
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
|
||||
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
|
||||
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
|
||||
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
|
||||
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
|
||||
|
||||
|
||||
def _request_nets(
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
) -> tuple[NetSpec, ...]:
|
||||
return tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
|
||||
|
||||
def _build_options(**overrides: object) -> RoutingOptions:
|
||||
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
|
||||
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
|
||||
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
|
||||
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
|
||||
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
|
||||
return RoutingOptions(
|
||||
search=SearchOptions(**search_overrides),
|
||||
congestion=CongestionOptions(**congestion_overrides),
|
||||
refinement=RefinementOptions(**refinement_overrides),
|
||||
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
|
||||
objective=ObjectiveWeights(**objective_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _route_problem(
|
||||
*,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
|
||||
static_obstacles: tuple[Polygon, ...] = (),
|
||||
iteration_callback=None,
|
||||
**overrides: object,
|
||||
):
|
||||
problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS}
|
||||
option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS}
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=_request_nets(netlist, net_widths),
|
||||
static_obstacles=static_obstacles,
|
||||
**problem_overrides,
|
||||
)
|
||||
return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback)
|
||||
|
||||
|
||||
def _bend_count(result: RoutingResult) -> int:
|
||||
return sum(1 for component in result.path if component.move_type == "bend90")
|
||||
|
||||
|
||||
def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed:
|
||||
segments = []
|
||||
for kind, value in steps:
|
||||
if kind == "B":
|
||||
segments.append(Bend90Seed(radius=5.0, direction=value))
|
||||
else:
|
||||
segments.append(StraightSeed(length=value))
|
||||
return PathSeed(tuple(segments))
|
||||
|
||||
|
||||
def test_route_parallel_nets_are_valid() -> None:
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
)
|
||||
|
||||
assert len(run.results_by_net) == 2
|
||||
assert run.results_by_net["net1"].is_valid
|
||||
assert run.results_by_net["net2"].is_valid
|
||||
assert run.results_by_net["net1"].collisions == 0
|
||||
assert run.results_by_net["net2"].collisions == 0
|
||||
|
||||
|
||||
def test_route_reports_crossing_nets_without_congestion_resolution() -> None:
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
|
||||
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
max_iterations=1,
|
||||
base_penalty=1.0,
|
||||
warm_start_enabled=False,
|
||||
)
|
||||
|
||||
assert not run.results_by_net["net1"].is_valid
|
||||
assert not run.results_by_net["net2"].is_valid
|
||||
assert run.results_by_net["net1"].collisions > 0
|
||||
assert run.results_by_net["net2"].collisions > 0
|
||||
|
||||
|
||||
def test_route_callback_respects_requested_net_order() -> None:
|
||||
callback_orders: list[list[str]] = []
|
||||
|
||||
_route_problem(
|
||||
netlist={
|
||||
"short": (Port(0, 0, 0), Port(10, 0, 0)),
|
||||
"long": (Port(0, 0, 0), Port(40, 10, 0)),
|
||||
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
|
||||
},
|
||||
net_widths={"short": 2.0, "long": 2.0, "mid": 2.0},
|
||||
max_iterations=1,
|
||||
warm_start_enabled=False,
|
||||
net_order="longest",
|
||||
enabled=False,
|
||||
iteration_callback=lambda iteration, results: callback_orders.append(list(results)),
|
||||
)
|
||||
|
||||
assert callback_orders == [["long", "mid", "short"]]
|
||||
|
||||
|
||||
def test_route_callback_receives_iteration_results() -> None:
|
||||
callback_results: list[dict[str, RoutingResult]] = []
|
||||
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(10, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(10, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
iteration_callback=lambda iteration, results: callback_results.append(dict(results)),
|
||||
)
|
||||
|
||||
assert len(callback_results) == 1
|
||||
assert set(callback_results[0]) == {"net1", "net2"}
|
||||
assert callback_results[0]["net1"].is_valid
|
||||
assert callback_results[0]["net2"].is_valid
|
||||
assert run.results_by_net["net1"].reached_target
|
||||
assert run.results_by_net["net2"].reached_target
|
||||
|
||||
|
||||
def test_route_uses_complete_initial_paths_without_rerouting() -> None:
|
||||
initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")])
|
||||
run = _route_problem(
|
||||
netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))},
|
||||
net_widths={"net": 2.0},
|
||||
bend_radii=[5.0],
|
||||
max_iterations=1,
|
||||
warm_start_enabled=False,
|
||||
initial_paths={"net": initial_seed},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
result = run.results_by_net["net"]
|
||||
assert result.is_valid
|
||||
assert result.reached_target
|
||||
assert result.as_seed() == initial_seed
|
||||
|
||||
|
||||
def test_route_retries_partial_initial_paths_across_iterations() -> None:
|
||||
iterations: list[int] = []
|
||||
partial_seed = PathSeed((StraightSeed(length=5.0),))
|
||||
run = _route_problem(
|
||||
netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))},
|
||||
net_widths={"net": 2.0},
|
||||
max_iterations=2,
|
||||
warm_start_enabled=False,
|
||||
capture_expanded=True,
|
||||
initial_paths={"net": partial_seed},
|
||||
enabled=False,
|
||||
iteration_callback=lambda iteration, results: iterations.append(iteration),
|
||||
)
|
||||
|
||||
result = run.results_by_net["net"]
|
||||
assert iterations == [0, 1]
|
||||
assert result.is_valid
|
||||
assert result.reached_target
|
||||
assert result.outcome == "completed"
|
||||
assert result.as_seed() != partial_seed
|
||||
assert run.expanded_nodes
|
||||
|
||||
|
||||
def test_route_negotiated_congestion_resolution() -> None:
|
||||
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)])
|
||||
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])
|
||||
run = _route_problem(
|
||||
bounds=(0, -40, 100, 40),
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
static_obstacles=(obs_top, obs_bottom),
|
||||
bend_radii=(5.0, 10.0),
|
||||
max_iterations=10,
|
||||
base_penalty=1000.0,
|
||||
)
|
||||
|
||||
assert run.results_by_net["net1"].reached_target
|
||||
assert run.results_by_net["net2"].reached_target
|
||||
assert run.results_by_net["net1"].is_valid
|
||||
assert run.results_by_net["net2"].is_valid
|
||||
|
||||
|
||||
def test_route_refinement_reduces_locked_detour_bends() -> None:
|
||||
route_a = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))},
|
||||
net_widths={"netA": 2.0},
|
||||
bend_radii=[10.0],
|
||||
enabled=False,
|
||||
)
|
||||
locked_geometry = route_a.results_by_net["netA"].locked_geometry
|
||||
|
||||
base_run = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
|
||||
net_widths={"netB": 2.0},
|
||||
static_obstacles=locked_geometry,
|
||||
bend_radii=[10.0],
|
||||
enabled=False,
|
||||
)
|
||||
refined_run = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
|
||||
net_widths={"netB": 2.0},
|
||||
static_obstacles=locked_geometry,
|
||||
bend_radii=[10.0],
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
base_result = base_run.results_by_net["netB"]
|
||||
refined_result = refined_run.results_by_net["netB"]
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert _bend_count(refined_result) < _bend_count(base_result)
|
||||
|
||||
|
||||
def test_route_refinement_simplifies_triple_crossing_detours() -> None:
|
||||
base_run = _route_problem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist={
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
},
|
||||
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
base_penalty=1000.0,
|
||||
enabled=False,
|
||||
greedy_h_weight=1.5,
|
||||
bend_penalty=250.0,
|
||||
sbend_penalty=500.0,
|
||||
)
|
||||
refined_run = _route_problem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist={
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
},
|
||||
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
base_penalty=1000.0,
|
||||
enabled=True,
|
||||
greedy_h_weight=1.5,
|
||||
bend_penalty=250.0,
|
||||
sbend_penalty=500.0,
|
||||
)
|
||||
|
||||
for net_id in ("vertical_up", "vertical_down"):
|
||||
base_result = base_run.results_by_net[net_id]
|
||||
refined_result = refined_run.results_by_net[net_id]
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert _bend_count(refined_result) < _bend_count(base_result)
|
||||
|
|
@ -1,21 +1,41 @@
|
|||
import unittest
|
||||
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import route_astar, AStarContext
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
|
||||
|
||||
class TestIntegerPorts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ce = CollisionEngine(clearance=2.0)
|
||||
self.ce = RoutingWorld(clearance=2.0)
|
||||
self.cost = CostEvaluator(self.ce)
|
||||
self.bounds = (0, 0, 100, 100)
|
||||
|
||||
def _build_context(self) -> AStarContext:
|
||||
return AStarContext(
|
||||
self.cost,
|
||||
RoutingProblem(bounds=self.bounds),
|
||||
RoutingOptions(),
|
||||
)
|
||||
|
||||
def _route(self, context: AStarContext, start: Port, target: Port):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=1.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
def test_route_reaches_integer_target(self):
|
||||
context = AStarContext(self.cost)
|
||||
context = self._build_context()
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(12, 0, 0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
last_port = path[-1].end_port
|
||||
|
|
@ -24,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase):
|
|||
self.assertEqual(last_port.r, 0)
|
||||
|
||||
def test_port_constructor_rounds_to_integer_lattice(self):
|
||||
context = AStarContext(self.cost)
|
||||
context = self._build_context()
|
||||
start = Port(0.0, 0.0, 0.0)
|
||||
target = Port(12.3, 0.0, 0.0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
self.assertEqual(target.x, 12)
|
||||
|
|
@ -36,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase):
|
|||
self.assertEqual(last_port.x, 12)
|
||||
|
||||
def test_half_step_inputs_use_integerized_targets(self):
|
||||
context = AStarContext(self.cost)
|
||||
context = self._build_context()
|
||||
start = Port(0.0, 0.0, 0.0)
|
||||
target = Port(7.5, 0.0, 0.0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
self.assertEqual(target.x, 8)
|
||||
|
|
|
|||
20
inire/tests/test_visibility.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from shapely.geometry import box
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.visibility import VisibilityManager
|
||||
|
||||
|
||||
def test_point_visibility_cache_respects_max_distance() -> None:
|
||||
engine = RoutingWorld(clearance=0.0)
|
||||
engine.add_static_obstacle(box(10, 20, 20, 30))
|
||||
engine.add_static_obstacle(box(100, 20, 110, 30))
|
||||
visibility = VisibilityManager(engine)
|
||||
origin = Port(0, 0, 0)
|
||||
|
||||
near_corners = visibility.get_point_visibility(origin, max_dist=40.0)
|
||||
far_corners = visibility.get_point_visibility(origin, max_dist=200.0)
|
||||
|
||||
assert len(near_corners) == 3
|
||||
assert len(far_corners) > len(near_corners)
|
||||
assert any(corner[0] >= 100.0 for corner in far_corners)
|
||||
26
inire/tests/test_visualization.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from inire.geometry.components import Bend90
|
||||
from inire.geometry.primitives import Port
|
||||
from inire import RoutingResult
|
||||
from inire.utils.visualization import plot_routing_results
|
||||
|
||||
|
||||
def test_plot_routing_results_respects_show_actual() -> None:
|
||||
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox")
|
||||
result = RoutingResult("net", [bend], reached_target=True)
|
||||
|
||||
fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True)
|
||||
fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False)
|
||||
|
||||
actual_line_points = max(len(line.get_xdata()) for line in ax_actual.lines)
|
||||
proxy_line_points = max(len(line.get_xdata()) for line in ax_proxy.lines)
|
||||
|
||||
assert actual_line_points > proxy_line_points
|
||||
assert ax_actual.get_title().endswith("Actual Geometry)")
|
||||
assert ax_proxy.get_title().endswith("(Proxy Geometry)")
|
||||
|
||||
fig_actual.clf()
|
||||
fig_proxy.clf()
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import numpy
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.pathfinder import RoutingResult
|
||||
|
||||
|
||||
def validate_routing_result(
|
||||
result: RoutingResult,
|
||||
static_obstacles: list[Polygon],
|
||||
clearance: float,
|
||||
expected_start: Port | None = None,
|
||||
expected_end: Port | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Perform a high-precision validation of a routed path.
|
||||
|
||||
Args:
|
||||
result: The routing result to validate.
|
||||
static_obstacles: List of static obstacle geometries.
|
||||
clearance: Required minimum distance.
|
||||
expected_start: Optional expected start port.
|
||||
expected_end: Optional expected end port.
|
||||
|
||||
Returns:
|
||||
A dictionary with validation results.
|
||||
"""
|
||||
_ = expected_start
|
||||
if not result.path:
|
||||
return {"is_valid": False, "reason": "No path found"}
|
||||
|
||||
obstacle_collision_geoms = []
|
||||
self_intersection_geoms = []
|
||||
connectivity_errors = []
|
||||
|
||||
# 1. Connectivity Check
|
||||
total_length = 0.0
|
||||
for comp in result.path:
|
||||
total_length += comp.length
|
||||
|
||||
# Boundary check
|
||||
if expected_end:
|
||||
last_port = result.path[-1].end_port
|
||||
dist_to_end = numpy.sqrt(((last_port[:2] - expected_end[:2])**2).sum())
|
||||
if dist_to_end > 0.005:
|
||||
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
|
||||
if abs(last_port[2] - expected_end[2]) > 0.1:
|
||||
connectivity_errors.append(f"Final port orientation mismatch: {last_port[2]} vs {expected_end[2]}")
|
||||
|
||||
# 2. Geometry Buffering
|
||||
dilation_half = clearance / 2.0
|
||||
dilation_full = clearance
|
||||
|
||||
dilated_for_self = []
|
||||
|
||||
for comp in result.path:
|
||||
for poly in comp.geometry:
|
||||
# Check against obstacles
|
||||
d_full = poly.buffer(dilation_full)
|
||||
for obs in static_obstacles:
|
||||
if d_full.intersects(obs):
|
||||
intersection = d_full.intersection(obs)
|
||||
if intersection.area > 1e-9:
|
||||
obstacle_collision_geoms.append(intersection)
|
||||
|
||||
# Save for self-intersection check
|
||||
dilated_for_self.append(poly.buffer(dilation_half))
|
||||
|
||||
# 3. Self-intersection
|
||||
for i, seg_i in enumerate(dilated_for_self):
|
||||
for j, seg_j in enumerate(dilated_for_self):
|
||||
if j > i + 1 and seg_i.intersects(seg_j): # Non-adjacent
|
||||
overlap = seg_i.intersection(seg_j)
|
||||
if overlap.area > TOLERANCE_LINEAR:
|
||||
self_intersection_geoms.append((i, j, overlap))
|
||||
|
||||
is_valid = (len(obstacle_collision_geoms) == 0 and
|
||||
len(self_intersection_geoms) == 0 and
|
||||
len(connectivity_errors) == 0)
|
||||
|
||||
reasons = []
|
||||
if obstacle_collision_geoms:
|
||||
reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")
|
||||
if self_intersection_geoms:
|
||||
# report which indices
|
||||
idx_str = ", ".join([f"{i}-{j}" for i, j, _ in self_intersection_geoms[:5]])
|
||||
reasons.append(f"Found {len(self_intersection_geoms)} self-intersections (e.g. {idx_str}).")
|
||||
if connectivity_errors:
|
||||
reasons.extend(connectivity_errors)
|
||||
|
||||
return {
|
||||
"is_valid": is_valid,
|
||||
"reason": " ".join(reasons),
|
||||
"obstacle_collisions": obstacle_collision_geoms,
|
||||
"self_intersections": self_intersection_geoms,
|
||||
"total_length": total_length,
|
||||
"connectivity_ok": len(connectivity_errors) == 0,
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ if TYPE_CHECKING:
|
|||
from matplotlib.figure import Figure
|
||||
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.pathfinder import RoutingResult
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.results import RoutingResult
|
||||
|
||||
|
||||
def plot_routing_results(
|
||||
|
|
@ -50,8 +51,7 @@ def plot_routing_results(
|
|||
label_added = False
|
||||
for comp in res.path:
|
||||
# 1. Plot Collision Geometry (Translucent fill)
|
||||
# This is the geometry used during search (e.g. proxy or arc)
|
||||
for poly in comp.geometry:
|
||||
for poly in comp.collision_geometry:
|
||||
if isinstance(poly, MultiPolygon):
|
||||
geoms = list(poly.geoms)
|
||||
else:
|
||||
|
|
@ -66,9 +66,7 @@ def plot_routing_results(
|
|||
x, y = g.xy
|
||||
ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2)
|
||||
|
||||
# 2. Plot "Actual" Geometry (The high-fidelity shape used for fabrication)
|
||||
# Use comp.actual_geometry if it exists (should be the arc)
|
||||
actual_geoms_to_plot = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry
|
||||
actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry
|
||||
|
||||
for poly in actual_geoms_to_plot:
|
||||
if isinstance(poly, MultiPolygon):
|
||||
|
|
@ -86,27 +84,29 @@ def plot_routing_results(
|
|||
|
||||
# 3. Plot subtle port orientation arrow
|
||||
p = comp.end_port
|
||||
rad = numpy.radians(p.orientation)
|
||||
rad = numpy.radians(p.r)
|
||||
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
|
||||
scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4)
|
||||
|
||||
if not res.path and not res.is_valid:
|
||||
# Best-effort display: If the path is empty but failed, it might be unroutable.
|
||||
# We don't have a partial path in RoutingResult currently.
|
||||
# Empty failed paths are typically unroutable.
|
||||
pass
|
||||
|
||||
# 4. Plot main arrows for netlist ports
|
||||
if netlist:
|
||||
for net_id, (start_p, target_p) in netlist.items():
|
||||
for _net_id, (start_p, target_p) in netlist.items():
|
||||
for p in [start_p, target_p]:
|
||||
rad = numpy.radians(p[2])
|
||||
ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black",
|
||||
rad = numpy.radians(p.r)
|
||||
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
|
||||
scale=25, width=0.004, pivot="tail", zorder=6)
|
||||
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
ax.set_aspect("equal")
|
||||
ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)")
|
||||
if show_actual:
|
||||
ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)")
|
||||
else:
|
||||
ax.set_title("Inire Routing Results (Proxy Geometry)")
|
||||
|
||||
# Legend handling for many nets
|
||||
if len(results) < 25:
|
||||
|
|
@ -181,7 +181,7 @@ def plot_expanded_nodes(
|
|||
if not nodes:
|
||||
return fig, ax
|
||||
|
||||
x, y, _ = zip(*nodes)
|
||||
x, y, _ = zip(*nodes, strict=False)
|
||||
ax.scatter(x, y, s=1, c=color, alpha=alpha, zorder=0)
|
||||
return fig, ax
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ def plot_expansion_density(
|
|||
ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes)
|
||||
return fig, ax
|
||||
|
||||
x, y, _ = zip(*nodes)
|
||||
x, y, _ = zip(*nodes, strict=False)
|
||||
|
||||
# Create 2D histogram
|
||||
h, xedges, yedges = numpy.histogram2d(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
||||
|
|
|
|||