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
|
# 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 |
|
`RoutingProblem` describes the physical routing problem:
|
||||||
| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ |
|
|
||||||
| `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"`. |
|
|
||||||
|
|
||||||
## 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 |
|
`RoutingOptions` groups all expert controls for the routing engine:
|
||||||
| :--------------------- | :---- | :---------------------------------------------------- |
|
|
||||||
| `nodes_expanded` | `int` | Number of nodes expanded in the last `route_astar` call. |
|
|
||||||
| `total_nodes_expanded` | `int` | Cumulative nodes expanded across all calls. |
|
|
||||||
| `max_depth_reached` | `int` | Deepest point in the search tree reached. |
|
|
||||||
|
|
||||||
---
|
- `search`
|
||||||
|
- `objective`
|
||||||
|
- `congestion`
|
||||||
|
- `refinement`
|
||||||
|
- `diagnostics`
|
||||||
|
|
||||||
## 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 |
|
If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults.
|
||||||
| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- |
|
|
||||||
| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. |
|
|
||||||
| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. |
|
|
||||||
| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. |
|
|
||||||
|
|
||||||
---
|
The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
|
||||||
|
|
||||||
## 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 |
|
Unstable example:
|
||||||
| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- |
|
|
||||||
| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. |
|
|
||||||
| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.|
|
|
||||||
|
|
||||||
---
|
```python
|
||||||
|
from inire.router._router import PathFinder
|
||||||
|
```
|
||||||
|
|
||||||
## 4. CollisionEngine Parameters
|
### Incremental routing with locked geometry
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem:
|
||||||
| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ |
|
|
||||||
| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). |
|
|
||||||
| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. |
|
|
||||||
|
|
||||||
---
|
```python
|
||||||
|
run_a = route(problem_a)
|
||||||
|
problem_b = RoutingProblem(
|
||||||
|
bounds=problem_a.bounds,
|
||||||
|
nets=(...),
|
||||||
|
static_obstacles=run_a.results_by_net["netA"].locked_geometry,
|
||||||
|
)
|
||||||
|
run_b = route(problem_b)
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Physical Units & Precision
|
`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle.
|
||||||
- **Coordinates**: Micrometers (µm).
|
|
||||||
- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves.
|
|
||||||
- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**.
|
|
||||||
- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost.
|
|
||||||
|
|
||||||
---
|
### Initial paths with `PathSeed`
|
||||||
|
|
||||||
## 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
|
## 2. Search Options
|
||||||
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.
|
|
||||||
|
|
||||||
### Avoiding "Zig-Zags"
|
`RoutingOptions.search` is a `SearchOptions` object.
|
||||||
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`.
|
|
||||||
|
|
||||||
### Visibility Guidance
|
| Field | Default | Description |
|
||||||
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.
|
| `node_limit` | `1_000_000` | Maximum number of states to explore per net. |
|
||||||
- **`"exact_corner"`**: Only uses precomputed corner-to-corner visibility when the current search state already lands on an obstacle corner.
|
| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. |
|
||||||
- **`"off"`**: Disables visibility-derived straight candidates entirely.
|
| `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
|
## 3. Objective Weights
|
||||||
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.
|
|
||||||
|
|
||||||
### S-Bend Usage
|
`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`.
|
||||||
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.
|
| Field | Default | Description |
|
||||||
- **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.
|
| `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: 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
|
## Key Features
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths.
|
* **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.
|
* **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.
|
* **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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -26,42 +26,32 @@ pip install numpy scipy shapely rtree matplotlib
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from inire.geometry.primitives import Port
|
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
|
||||||
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
|
|
||||||
|
|
||||||
# 1. Setup Environment
|
problem = RoutingProblem(
|
||||||
engine = CollisionEngine(clearance=2.0)
|
bounds=(0, 0, 1000, 1000),
|
||||||
danger_map = DangerMap(bounds=(0, 0, 1000, 1000))
|
nets=(
|
||||||
danger_map.precompute([]) # Add polygons here for obstacles
|
NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0),
|
||||||
|
),
|
||||||
# 2. Configure Router
|
|
||||||
evaluator = CostEvaluator(
|
|
||||||
collision_engine=engine,
|
|
||||||
danger_map=danger_map,
|
|
||||||
greedy_h_weight=1.2
|
|
||||||
)
|
)
|
||||||
context = AStarContext(
|
options = RoutingOptions(
|
||||||
cost_evaluator=evaluator,
|
search=SearchOptions(
|
||||||
bend_penalty=10.0
|
bend_radii=(50.0, 100.0),
|
||||||
|
greedy_h_weight=1.2,
|
||||||
|
),
|
||||||
|
objective=ObjectiveWeights(
|
||||||
|
bend_penalty=10.0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
pf = PathFinder(context)
|
|
||||||
|
|
||||||
# 3. Define Netlist
|
run = route(problem, options=options)
|
||||||
netlist = {
|
|
||||||
"net1": (Port(0, 0, 0), Port(100, 50, 0)),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Route
|
if run.results_by_net["net1"].is_valid:
|
||||||
results = pf.route_all(netlist, {"net1": 2.0})
|
|
||||||
|
|
||||||
if results["net1"].is_valid:
|
|
||||||
print("Successfully routed net1!")
|
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
|
## Usage Examples
|
||||||
|
|
||||||
For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**.
|
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
|
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
|
## Documentation
|
||||||
|
|
||||||
Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**.
|
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
|
## Architecture
|
||||||
|
|
||||||
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:
|
`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.
|
2. **90° Bends**: Fixed-radius PDK cells.
|
||||||
3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$).
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,29 @@
|
||||||
from inire.geometry.collision import CollisionEngine
|
from inire import NetSpec, Port, RoutingOptions, RoutingProblem, 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
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 01: Simple Route...")
|
print("Running Example 01: Simple Route...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
# We define a 100um x 100um routing area
|
|
||||||
bounds = (0, 0, 100, 100)
|
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 = {
|
netlist = {
|
||||||
"net1": (Port(10, 50, 0), Port(90, 50, 0)),
|
"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
|
run = route(problem, options=options)
|
||||||
results = pf.route_all(netlist, net_widths)
|
result = run.results_by_net["net1"]
|
||||||
|
if result.is_valid:
|
||||||
# 5. Check Results
|
|
||||||
res = results["net1"]
|
|
||||||
if res.is_valid:
|
|
||||||
print("Success! Route found.")
|
print("Success! Route found.")
|
||||||
print(f"Path collisions: {res.collisions}")
|
print(f"Path collisions: {result.collisions}")
|
||||||
else:
|
else:
|
||||||
print("Failed to find route.")
|
print("Failed to find route.")
|
||||||
|
|
||||||
# 6. Visualize
|
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
|
||||||
# plot_routing_results takes a dict of RoutingResult objects
|
|
||||||
fig, ax = plot_routing_results(results, [], bounds)
|
|
||||||
fig.savefig("examples/01_simple_route.png")
|
fig.savefig("examples/01_simple_route.png")
|
||||||
print("Saved plot to 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 import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, 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
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 02: Congestion Resolution (Triple Crossing)...")
|
print("Running Example 02: Congestion Resolution (Triple Crossing)...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 100, 100)
|
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 = {
|
netlist = {
|
||||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
"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
|
run = route(problem, options=options)
|
||||||
# PathFinder uses Negotiated Congestion to resolve overlaps iteratively
|
all_valid = all(result.is_valid for result in run.results_by_net.values())
|
||||||
results = pf.route_all(netlist, net_widths)
|
|
||||||
|
|
||||||
# 4. Check Results
|
|
||||||
all_valid = all(res.is_valid for res in results.values())
|
|
||||||
if all_valid:
|
if all_valid:
|
||||||
print("Success! Congestion resolved for all nets.")
|
print("Success! Congestion resolved for all nets.")
|
||||||
else:
|
else:
|
||||||
print("Failed to resolve congestion for some nets.")
|
print("Failed to resolve congestion for some nets.")
|
||||||
|
|
||||||
# 5. Visualize
|
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
|
||||||
fig, ax = plot_routing_results(results, [], bounds, netlist=netlist)
|
|
||||||
fig.savefig("examples/02_congestion_resolution.png")
|
fig.savefig("examples/02_congestion_resolution.png")
|
||||||
print("Saved plot to 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 import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, 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
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 03: Locked Paths...")
|
print("Running Example 03: Locked Paths...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, -50, 100, 50)
|
bounds = (0, -50, 100, 50)
|
||||||
engine = CollisionEngine(clearance=2.0)
|
options = RoutingOptions(
|
||||||
danger_map = DangerMap(bounds=bounds)
|
search=SearchOptions(bend_radii=(10.0,)),
|
||||||
danger_map.precompute([])
|
objective=ObjectiveWeights(
|
||||||
|
bend_penalty=250.0,
|
||||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
|
sbend_penalty=500.0,
|
||||||
context = AStarContext(evaluator, bend_radii=[10.0])
|
),
|
||||||
metrics = AStarMetrics()
|
)
|
||||||
pf = PathFinder(context, metrics)
|
|
||||||
|
|
||||||
# 2. Route Net A and 'Lock' it
|
|
||||||
# Net A is a straight path blocking the direct route for Net B
|
|
||||||
print("Routing initial net...")
|
print("Routing initial net...")
|
||||||
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
|
results_a = route(
|
||||||
results_a = pf.route_all(netlist_a, {"netA": 2.0})
|
RoutingProblem(
|
||||||
|
bounds=bounds,
|
||||||
|
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
|
||||||
|
),
|
||||||
|
options=options,
|
||||||
|
).results_by_net
|
||||||
|
|
||||||
# Locking prevents Net A from being removed or rerouted during NC iterations
|
|
||||||
engine.lock_net("netA")
|
|
||||||
print("Initial net locked as static obstacle.")
|
|
||||||
|
|
||||||
# 3. Route Net B (forced to detour)
|
|
||||||
print("Routing detour net around locked path...")
|
print("Routing detour net around locked path...")
|
||||||
netlist_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))}
|
results_b = route(
|
||||||
results_b = pf.route_all(netlist_b, {"netB": 2.0})
|
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}
|
results = {**results_a, **results_b}
|
||||||
fig, ax = plot_routing_results(results, [], bounds)
|
fig, ax = plot_routing_results(results, [], bounds)
|
||||||
fig.savefig("examples/03_locked_paths.png")
|
fig.savefig("examples/03_locked_paths.png")
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,38 @@
|
||||||
from inire.geometry.collision import CollisionEngine
|
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, 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
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 04: S-Bends and Multiple Radii...")
|
print("Running Example 04: S-Bends and Multiple Radii...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 100, 100)
|
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 = {
|
netlist = {
|
||||||
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
|
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
|
||||||
"multi_radii": (Port(10, 10, 0), Port(90, 90, 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
|
run = route(problem, options=options)
|
||||||
results = pf.route_all(netlist, net_widths)
|
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
|
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
|
||||||
for nid, res in results.items():
|
|
||||||
status = "Success" if res.is_valid else "Failed"
|
|
||||||
print(f"{nid}: {status}, collisions={res.collisions}")
|
|
||||||
|
|
||||||
# 6. Visualize
|
|
||||||
fig, ax = plot_routing_results(results, [], bounds, netlist=netlist)
|
|
||||||
fig.savefig("examples/04_sbends_and_radii.png")
|
fig.savefig("examples/04_sbends_and_radii.png")
|
||||||
print("Saved plot to 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 import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, 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
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 05: Orientation Stress Test...")
|
print("Running Example 05: Orientation Stress Test...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 200, 200)
|
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 = {
|
netlist = {
|
||||||
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
|
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
|
||||||
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
|
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
|
||||||
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
|
"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...")
|
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
|
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
|
||||||
for nid, res in results.items():
|
|
||||||
status = "Success" if res.is_valid else "Failed"
|
|
||||||
print(f" {nid}: {status}")
|
|
||||||
|
|
||||||
# 5. Visualize
|
|
||||||
fig, ax = plot_routing_results(results, [], bounds, netlist=netlist)
|
|
||||||
fig.savefig("examples/05_orientation_stress.png")
|
fig.savefig("examples/05_orientation_stress.png")
|
||||||
print("Saved plot to 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 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.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 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:
|
def main() -> None:
|
||||||
print("Running Example 06: Bend Collision Models...")
|
print("Running Example 06: Bend Collision Models...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
# Give room for 10um bends near the edges
|
|
||||||
bounds = (-20, -20, 170, 170)
|
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_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)])
|
||||||
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
|
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
|
||||||
obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
|
obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
|
||||||
|
|
||||||
obstacles = [obs_arc, obs_bbox, obs_clipped]
|
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))}
|
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))}
|
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))}
|
netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}
|
||||||
|
|
||||||
# 2. Route each scenario
|
|
||||||
print("Routing Scenario 1 (Arc)...")
|
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)...")
|
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)...")
|
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_results = {**res_arc, **res_bbox, **res_clipped}
|
||||||
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_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")
|
fig.savefig("examples/06_bend_collision_models.png")
|
||||||
print("Saved plot to 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
|
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 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:
|
def main() -> None:
|
||||||
print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...")
|
print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 1000, 1000)
|
bounds = (0, 0, 1000, 1000)
|
||||||
engine = CollisionEngine(clearance=6.0)
|
|
||||||
|
|
||||||
# Bottleneck at x=500, 200um gap
|
|
||||||
obstacles = [
|
obstacles = [
|
||||||
box(450, 0, 550, 400),
|
box(450, 0, 550, 400),
|
||||||
box(450, 600, 550, 1000),
|
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
|
num_nets = 10
|
||||||
start_x = 50
|
start_x = 50
|
||||||
start_y_base = 500 - (num_nets * 10.0) / 2.0
|
start_y_base = 500 - (num_nets * 10.0) / 2.0
|
||||||
|
|
||||||
end_x = 950
|
end_x = 950
|
||||||
end_y_base = 100
|
end_y_base = 100
|
||||||
end_y_pitch = 800.0 / (num_nets - 1)
|
end_y_pitch = 800.0 / (num_nets - 1)
|
||||||
|
|
||||||
for i in range(num_nets):
|
netlist: dict[str, tuple[Port, Port]] = {}
|
||||||
sy = int(round(start_y_base + i * 10.0))
|
for index in range(num_nets):
|
||||||
ey = int(round(end_y_base + i * end_y_pitch))
|
start_y = int(round(start_y_base + index * 10.0))
|
||||||
netlist[f"net_{i:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 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
|
options = RoutingOptions(
|
||||||
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
|
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):
|
def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None:
|
||||||
successes = sum(1 for r in current_results.values() if r.is_valid)
|
successes = sum(1 for result in current_results.values() if result.is_valid)
|
||||||
total_collisions = sum(r.collisions for r in current_results.values())
|
total_collisions = sum(result.collisions for result in current_results.values())
|
||||||
total_nodes = metrics.nodes_expanded
|
total_nodes = metrics.nodes_expanded
|
||||||
|
print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
|
||||||
print(f" Iteration {idx} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
|
new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4)
|
||||||
|
|
||||||
# Adaptive Greediness: Decay from 1.5 to 1.1 over 10 iterations
|
|
||||||
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
|
|
||||||
evaluator.greedy_h_weight = new_greedy
|
evaluator.greedy_h_weight = new_greedy
|
||||||
print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}")
|
print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}")
|
||||||
|
iteration_stats.append(
|
||||||
iteration_stats.append({
|
{
|
||||||
'Iteration': idx,
|
"Iteration": iteration,
|
||||||
'Success': successes,
|
"Success": successes,
|
||||||
'Congestion': total_collisions,
|
"Congestion": total_collisions,
|
||||||
'Nodes': total_nodes
|
"Nodes": total_nodes,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
metrics.reset_per_route()
|
metrics.reset_per_route()
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
|
||||||
results = pf.route_all(netlist, net_widths, store_expanded=True, iteration_callback=iteration_callback, shuffle_nets=True, seed=42)
|
start_time = time.perf_counter()
|
||||||
t1 = time.perf_counter()
|
results = finder.route_all(iteration_callback=iteration_callback)
|
||||||
|
end_time = time.perf_counter()
|
||||||
|
|
||||||
print(f"Routing took {t1-t0:.4f}s")
|
print(f"Routing took {end_time - start_time:.4f}s")
|
||||||
|
|
||||||
# 4. Check Results
|
|
||||||
print("\n--- Iteration Summary ---")
|
print("\n--- Iteration Summary ---")
|
||||||
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}")
|
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}")
|
||||||
print("-" * 40)
|
print("-" * 43)
|
||||||
for s in iteration_stats:
|
for stats in iteration_stats:
|
||||||
print(f"{s['Iteration']:<5} | {s['Success']:<8} | {s['Congestion']:<8} | {s['Nodes']:<10}")
|
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.")
|
print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.")
|
||||||
|
for net_id, result in results.items():
|
||||||
for nid, res in results.items():
|
if not result.is_valid:
|
||||||
if not res.is_valid:
|
print(f" FAILED: {net_id}, collisions={result.collisions}")
|
||||||
print(f" FAILED: {nid}, collisions={res.collisions}")
|
|
||||||
else:
|
else:
|
||||||
print(f" {nid}: SUCCESS")
|
print(f" {net_id}: SUCCESS")
|
||||||
|
|
||||||
# 5. Visualize
|
|
||||||
fig, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
|
|
||||||
plot_danger_map(danger_map, ax=ax)
|
|
||||||
|
|
||||||
|
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")
|
fig.savefig("examples/07_large_scale_routing.png")
|
||||||
print("Saved plot to examples/07_large_scale_routing.png")
|
print("Saved plot to examples/07_large_scale_routing.png")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -1,54 +1,76 @@
|
||||||
from shapely.geometry import Polygon
|
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.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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
from inire.router.danger_map import DangerMap
|
||||||
from inire.router.pathfinder import PathFinder
|
|
||||||
from inire.utils.visualization import plot_routing_results
|
from inire.utils.visualization import plot_routing_results
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Running Example 08: Custom Bend Geometry...")
|
print("Running Example 08: Custom Bend Geometry...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 150, 150)
|
bounds = (0, 0, 150, 150)
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
danger_map = DangerMap(bounds=bounds)
|
danger_map = DangerMap(bounds=bounds)
|
||||||
danger_map.precompute([])
|
danger_map.precompute([])
|
||||||
|
|
||||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||||
context = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[])
|
|
||||||
metrics = AStarMetrics()
|
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...")
|
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)])
|
custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
|
||||||
|
|
||||||
print("Routing with custom collision model...")
|
print("Routing with custom collision model...")
|
||||||
# Override bend_collision_type with a literal Polygon
|
results_custom = PathFinder(
|
||||||
context_custom = AStarContext(evaluator, bend_radii=[10.0], bend_collision_type=custom_poly, sbend_radii=[])
|
AStarContext(
|
||||||
metrics_custom = AStarMetrics()
|
evaluator,
|
||||||
results_custom = PathFinder(context_custom, metrics_custom, use_tiered_strategy=False).route_all(
|
RoutingProblem(
|
||||||
{"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}
|
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}
|
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")
|
fig.savefig("examples/08_custom_bend_geometry.png")
|
||||||
print("Saved plot to 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 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:
|
def main() -> None:
|
||||||
print("Running Example 09: Best-Effort Under Tight Search Budget...")
|
print("Running Example 09: Best-Effort Under Tight Search Budget...")
|
||||||
|
|
||||||
# 1. Setup Environment
|
|
||||||
bounds = (0, 0, 100, 100)
|
bounds = (0, 0, 100, 100)
|
||||||
engine = CollisionEngine(clearance=2.0)
|
|
||||||
|
|
||||||
# A small obstacle cluster keeps the partial route visually interesting.
|
|
||||||
obstacles = [
|
obstacles = [
|
||||||
box(35, 35, 45, 65),
|
box(35, 35, 45, 65),
|
||||||
box(55, 35, 65, 65),
|
box(55, 35, 65, 65),
|
||||||
]
|
]
|
||||||
for obs in obstacles:
|
problem = RoutingProblem(
|
||||||
engine.add_static_obstacle(obs)
|
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)...")
|
print("Routing with a deliberately tiny node budget (should return a partial path)...")
|
||||||
results = pf.route_all(netlist, net_widths)
|
run = route(problem, options=options)
|
||||||
|
result = run.results_by_net["budget_limited_net"]
|
||||||
# 4. Check Results
|
if not result.reached_target:
|
||||||
res = results["budget_limited_net"]
|
print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.")
|
||||||
if not res.reached_target:
|
|
||||||
print(f"Target not reached as expected. Partial path length: {len(res.path)} segments.")
|
|
||||||
else:
|
else:
|
||||||
print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.")
|
print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.")
|
||||||
|
|
||||||
# 5. Visualize
|
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, ax = plot_routing_results(results, obstacles, bounds, netlist=netlist)
|
|
||||||
fig.savefig("examples/09_unroutable_best_effort.png")
|
fig.savefig("examples/09_unroutable_best_effort.png")
|
||||||
print("Saved plot to examples/09_unroutable_best_effort.png")
|
print("Saved plot to examples/09_unroutable_best_effort.png")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting
|
||||||
* **BBox**: Simple axis-aligned bounding box (Fastest search).
|
* **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).
|
* **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
|
## 3. Unroutable Nets & Best-Effort Display
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,59 @@
|
||||||
"""
|
"""
|
||||||
inire Wave-router
|
inire Wave-router
|
||||||
"""
|
"""
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from .geometry.primitives import Port as Port # noqa: PLC0414
|
from .geometry.primitives import Port as Port # noqa: PLC0414
|
||||||
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414
|
from .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'
|
__author__ = 'Jan Petykiewicz'
|
||||||
__version__ = '0.1'
|
__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.
|
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_LINEAR = 1e-6
|
||||||
TOLERANCE_ANGULAR = 1e-3
|
TOLERANCE_ANGULAR = 1e-3
|
||||||
TOLERANCE_GRID = 1e-6
|
|
||||||
|
|
|
||||||
|
|
@ -1,654 +1,457 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING
|
||||||
import rtree
|
|
||||||
import numpy
|
import numpy
|
||||||
import shapely
|
from shapely.geometry import LineString, box
|
||||||
from shapely.prepared import prep
|
|
||||||
from shapely.strtree import STRtree
|
from inire.geometry.component_overlap import components_overlap
|
||||||
from shapely.geometry import box, LineString
|
from inire.geometry.dynamic_path_index import DynamicPathIndex
|
||||||
|
from inire.geometry.index_helpers import grid_cell_span
|
||||||
|
from inire.results import RoutingReport
|
||||||
|
from inire.geometry.static_obstacle_index import StaticObstacleIndex
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from shapely.prepared import PreparedGeometry
|
from shapely.geometry.base import BaseGeometry
|
||||||
from inire.geometry.primitives import Port
|
from shapely.strtree import STRtree
|
||||||
|
|
||||||
from inire.geometry.components import ComponentResult
|
from inire.geometry.components import ComponentResult
|
||||||
|
from inire.geometry.primitives import Port
|
||||||
|
|
||||||
|
|
||||||
class CollisionEngine:
|
def _intersection_distance(origin: Port, geometry: BaseGeometry) -> float:
|
||||||
|
if hasattr(geometry, "geoms"):
|
||||||
|
return min(_intersection_distance(origin, sub_geometry) for sub_geometry in geometry.geoms)
|
||||||
|
return float(numpy.sqrt((geometry.coords[0][0] - origin.x) ** 2 + (geometry.coords[0][1] - origin.y) ** 2))
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingWorld:
|
||||||
"""
|
"""
|
||||||
Manages spatial queries for collision detection with unified dilation logic.
|
Internal spatial state for collision detection, congestion, and verification.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'clearance', 'max_net_width', 'safety_zone_radius',
|
"clearance",
|
||||||
'static_index', 'static_geometries', 'static_dilated', 'static_prepared',
|
"safety_zone_radius",
|
||||||
'static_is_rect', 'static_tree', 'static_obj_ids', 'static_safe_cache',
|
"grid_cell_size",
|
||||||
'static_grid', 'grid_cell_size', '_static_id_counter', '_net_specific_trees',
|
"_dynamic_paths",
|
||||||
'_net_specific_is_rect', '_net_specific_bounds',
|
"_static_obstacles",
|
||||||
'dynamic_index', 'dynamic_geometries', 'dynamic_dilated', 'dynamic_prepared',
|
|
||||||
'dynamic_tree', 'dynamic_obj_ids', 'dynamic_grid', '_dynamic_id_counter',
|
|
||||||
'metrics', '_dynamic_tree_dirty', '_dynamic_net_ids_array', '_inv_grid_cell_size',
|
|
||||||
'_static_bounds_array', '_static_is_rect_array', '_locked_nets',
|
|
||||||
'_static_raw_tree', '_static_raw_obj_ids', '_dynamic_bounds_array', '_static_version'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
clearance: float,
|
clearance: float,
|
||||||
max_net_width: float = 2.0,
|
safety_zone_radius: float = 0.0021,
|
||||||
safety_zone_radius: float = 0.0021,
|
) -> None:
|
||||||
) -> None:
|
|
||||||
self.clearance = clearance
|
self.clearance = clearance
|
||||||
self.max_net_width = max_net_width
|
|
||||||
self.safety_zone_radius = safety_zone_radius
|
self.safety_zone_radius = safety_zone_radius
|
||||||
|
|
||||||
# Static obstacles
|
|
||||||
self.static_index = rtree.index.Index()
|
|
||||||
self.static_geometries: dict[int, Polygon] = {}
|
|
||||||
self.static_dilated: dict[int, Polygon] = {}
|
|
||||||
self.static_prepared: dict[int, PreparedGeometry] = {}
|
|
||||||
self.static_is_rect: dict[int, bool] = {}
|
|
||||||
self.static_tree: STRtree | None = None
|
|
||||||
self.static_obj_ids: list[int] = []
|
|
||||||
self._static_bounds_array: numpy.ndarray | None = None
|
|
||||||
self._static_is_rect_array: numpy.ndarray | None = None
|
|
||||||
self._static_raw_tree: STRtree | None = None
|
|
||||||
self._static_raw_obj_ids: list[int] = []
|
|
||||||
self._net_specific_trees: dict[tuple[float, float], STRtree] = {}
|
|
||||||
self._net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {}
|
|
||||||
self._net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {}
|
|
||||||
self._static_version = 0
|
|
||||||
|
|
||||||
self.static_safe_cache: set[tuple] = set()
|
|
||||||
self.static_grid: dict[tuple[int, int], list[int]] = {}
|
|
||||||
self.grid_cell_size = 50.0
|
self.grid_cell_size = 50.0
|
||||||
self._inv_grid_cell_size = 1.0 / self.grid_cell_size
|
self._static_obstacles = StaticObstacleIndex(self)
|
||||||
self._static_id_counter = 0
|
self._dynamic_paths = DynamicPathIndex(self)
|
||||||
|
|
||||||
# Dynamic paths
|
def get_static_version(self) -> int:
|
||||||
self.dynamic_index = rtree.index.Index()
|
return self._static_obstacles.version
|
||||||
self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {}
|
|
||||||
self.dynamic_dilated: dict[int, Polygon] = {}
|
|
||||||
self.dynamic_prepared: dict[int, PreparedGeometry] = {}
|
|
||||||
self.dynamic_tree: STRtree | None = None
|
|
||||||
self.dynamic_obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
|
|
||||||
self.dynamic_grid: dict[tuple[int, int], list[int]] = {}
|
|
||||||
|
|
||||||
self._dynamic_id_counter = 0
|
def iter_static_dilated_geometries(self) -> Iterable[Polygon]:
|
||||||
self._dynamic_tree_dirty = True
|
return self._static_obstacles.dilated.values()
|
||||||
self._dynamic_net_ids_array = numpy.array([], dtype='<U32')
|
|
||||||
self._dynamic_bounds_array = numpy.array([], dtype=numpy.float64).reshape(0, 4)
|
|
||||||
self._locked_nets: set[str] = set()
|
|
||||||
|
|
||||||
self.metrics = {
|
def iter_static_obstacle_bounds(
|
||||||
'static_cache_hits': 0,
|
self,
|
||||||
'static_grid_skips': 0,
|
query_bounds: tuple[float, float, float, float],
|
||||||
'static_tree_queries': 0,
|
) -> Iterable[tuple[float, float, float, float]]:
|
||||||
'static_straight_fast': 0,
|
for obj_id in self._static_obstacles.index.intersection(query_bounds):
|
||||||
'congestion_grid_skips': 0,
|
yield self._static_obstacles.geometries[obj_id].bounds
|
||||||
'congestion_tree_queries': 0,
|
|
||||||
'safety_zone_checks': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
def reset_metrics(self) -> None:
|
def iter_dynamic_path_bounds(
|
||||||
for k in self.metrics:
|
self,
|
||||||
self.metrics[k] = 0
|
query_bounds: tuple[float, float, float, float],
|
||||||
|
) -> Iterable[tuple[float, float, float, float]]:
|
||||||
def get_metrics_summary(self) -> str:
|
for obj_id in self._dynamic_paths.index.intersection(query_bounds):
|
||||||
m = self.metrics
|
yield self._dynamic_paths.geometries[obj_id][1].bounds
|
||||||
return (f"Collision Performance: \n"
|
|
||||||
f" Static: {m['static_tree_queries']} checks\n"
|
|
||||||
f" Congestion: {m['congestion_tree_queries']} checks\n"
|
|
||||||
f" Safety Zone: {m['safety_zone_checks']} full intersections performed")
|
|
||||||
|
|
||||||
def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
|
def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
|
||||||
obj_id = self._static_id_counter
|
return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry)
|
||||||
self._static_id_counter += 1
|
|
||||||
|
|
||||||
# Preserve existing dilation if provided, else use default C/2
|
|
||||||
if dilated_geometry is not None:
|
|
||||||
dilated = dilated_geometry
|
|
||||||
else:
|
|
||||||
dilated = polygon.buffer(self.clearance / 2.0, join_style=2)
|
|
||||||
|
|
||||||
self.static_geometries[obj_id] = polygon
|
|
||||||
self.static_dilated[obj_id] = dilated
|
|
||||||
self.static_prepared[obj_id] = prep(dilated)
|
|
||||||
self.static_index.insert(obj_id, dilated.bounds)
|
|
||||||
self._invalidate_static_caches()
|
|
||||||
b = dilated.bounds
|
|
||||||
area = (b[2] - b[0]) * (b[3] - b[1])
|
|
||||||
self.static_is_rect[obj_id] = (abs(dilated.area - area) < 1e-4)
|
|
||||||
return obj_id
|
|
||||||
|
|
||||||
def remove_static_obstacle(self, obj_id: int) -> None:
|
def remove_static_obstacle(self, obj_id: int) -> None:
|
||||||
"""
|
self._static_obstacles.remove_obstacle(obj_id)
|
||||||
Remove a static obstacle by ID.
|
|
||||||
"""
|
|
||||||
if obj_id not in self.static_geometries:
|
|
||||||
return
|
|
||||||
|
|
||||||
bounds = self.static_dilated[obj_id].bounds
|
|
||||||
self.static_index.delete(obj_id, bounds)
|
|
||||||
|
|
||||||
del self.static_geometries[obj_id]
|
|
||||||
del self.static_dilated[obj_id]
|
|
||||||
del self.static_prepared[obj_id]
|
|
||||||
del self.static_is_rect[obj_id]
|
|
||||||
self._invalidate_static_caches()
|
|
||||||
|
|
||||||
def _invalidate_static_caches(self) -> None:
|
|
||||||
self.static_tree = None
|
|
||||||
self._static_bounds_array = None
|
|
||||||
self._static_is_rect_array = None
|
|
||||||
self.static_obj_ids = []
|
|
||||||
self._static_raw_tree = None
|
|
||||||
self._static_raw_obj_ids = []
|
|
||||||
self.static_grid = {}
|
|
||||||
self._net_specific_trees.clear()
|
|
||||||
self._net_specific_is_rect.clear()
|
|
||||||
self._net_specific_bounds.clear()
|
|
||||||
self.static_safe_cache.clear()
|
|
||||||
self._static_version += 1
|
|
||||||
|
|
||||||
def _ensure_static_tree(self) -> None:
|
def _ensure_static_tree(self) -> None:
|
||||||
if self.static_tree is None and self.static_dilated:
|
self._static_obstacles.ensure_tree()
|
||||||
self.static_obj_ids = sorted(self.static_dilated.keys())
|
|
||||||
geoms = [self.static_dilated[i] for i in self.static_obj_ids]
|
|
||||||
self.static_tree = STRtree(geoms)
|
|
||||||
self._static_bounds_array = numpy.array([g.bounds for g in geoms])
|
|
||||||
self._static_is_rect_array = numpy.array([self.static_is_rect[i] for i in self.static_obj_ids])
|
|
||||||
|
|
||||||
def _ensure_net_static_tree(self, net_width: float) -> STRtree:
|
def _ensure_net_static_tree(self, net_width: float) -> STRtree:
|
||||||
"""
|
return self._static_obstacles.ensure_net_tree(net_width)
|
||||||
Lazily generate a tree where obstacles are dilated by (net_width/2 + clearance).
|
|
||||||
"""
|
|
||||||
key = (round(net_width, 4), round(self.clearance, 4))
|
|
||||||
if key in self._net_specific_trees:
|
|
||||||
return self._net_specific_trees[key]
|
|
||||||
|
|
||||||
# Physical separation must be >= clearance.
|
|
||||||
# Centerline to raw obstacle edge must be >= net_width/2 + clearance.
|
|
||||||
total_dilation = net_width / 2.0 + self.clearance
|
|
||||||
geoms = []
|
|
||||||
is_rect_list = []
|
|
||||||
bounds_list = []
|
|
||||||
|
|
||||||
for obj_id in sorted(self.static_geometries.keys()):
|
|
||||||
poly = self.static_geometries[obj_id]
|
|
||||||
dilated = poly.buffer(total_dilation, join_style=2)
|
|
||||||
geoms.append(dilated)
|
|
||||||
|
|
||||||
b = dilated.bounds
|
|
||||||
bounds_list.append(b)
|
|
||||||
area = (b[2] - b[0]) * (b[3] - b[1])
|
|
||||||
is_rect_list.append(abs(dilated.area - area) < 1e-4)
|
|
||||||
|
|
||||||
tree = STRtree(geoms)
|
|
||||||
self._net_specific_trees[key] = tree
|
|
||||||
self._net_specific_is_rect[key] = numpy.array(is_rect_list, dtype=bool)
|
|
||||||
self._net_specific_bounds[key] = numpy.array(bounds_list)
|
|
||||||
return tree
|
|
||||||
|
|
||||||
def _ensure_static_raw_tree(self) -> None:
|
def _ensure_static_raw_tree(self) -> None:
|
||||||
if self._static_raw_tree is None and self.static_geometries:
|
self._static_obstacles.ensure_raw_tree()
|
||||||
self._static_raw_obj_ids = sorted(self.static_geometries.keys())
|
|
||||||
geoms = [self.static_geometries[i] for i in self._static_raw_obj_ids]
|
|
||||||
self._static_raw_tree = STRtree(geoms)
|
|
||||||
|
|
||||||
def _ensure_dynamic_tree(self) -> None:
|
def _ensure_dynamic_tree(self) -> None:
|
||||||
if self.dynamic_tree is None and self.dynamic_dilated:
|
self._dynamic_paths.ensure_tree()
|
||||||
ids = sorted(self.dynamic_dilated.keys())
|
|
||||||
geoms = [self.dynamic_dilated[i] for i in ids]
|
|
||||||
self.dynamic_tree = STRtree(geoms)
|
|
||||||
self.dynamic_obj_ids = numpy.array(ids, dtype=numpy.int32)
|
|
||||||
self._dynamic_bounds_array = numpy.array([g.bounds for g in geoms])
|
|
||||||
nids = [self.dynamic_geometries[obj_id][0] for obj_id in self.dynamic_obj_ids]
|
|
||||||
self._dynamic_net_ids_array = numpy.array(nids, dtype='<U32')
|
|
||||||
self._dynamic_tree_dirty = False
|
|
||||||
|
|
||||||
def _ensure_dynamic_grid(self) -> None:
|
def _ensure_dynamic_grid(self) -> None:
|
||||||
if not self.dynamic_grid and self.dynamic_dilated:
|
self._dynamic_paths.ensure_grid()
|
||||||
cs = self.grid_cell_size
|
|
||||||
for obj_id, poly in self.dynamic_dilated.items():
|
|
||||||
b = poly.bounds
|
|
||||||
for gx in range(int(b[0] / cs), int(b[2] / cs) + 1):
|
|
||||||
for gy in range(int(b[1] / cs), int(b[3] / cs) + 1):
|
|
||||||
cell = (gx, gy)
|
|
||||||
if cell not in self.dynamic_grid: self.dynamic_grid[cell] = []
|
|
||||||
self.dynamic_grid[cell].append(obj_id)
|
|
||||||
|
|
||||||
def add_path(self, net_id: str, geometry: list[Polygon], dilated_geometry: list[Polygon] | None = None) -> None:
|
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
|
||||||
self.dynamic_tree = None
|
self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry)
|
||||||
self.dynamic_grid = {}
|
|
||||||
self._dynamic_tree_dirty = True
|
|
||||||
dilation = self.clearance / 2.0
|
|
||||||
for i, poly in enumerate(geometry):
|
|
||||||
obj_id = self._dynamic_id_counter
|
|
||||||
self._dynamic_id_counter += 1
|
|
||||||
dilated = dilated_geometry[i] if dilated_geometry else poly.buffer(dilation)
|
|
||||||
self.dynamic_geometries[obj_id] = (net_id, poly)
|
|
||||||
self.dynamic_dilated[obj_id] = dilated
|
|
||||||
self.dynamic_index.insert(obj_id, dilated.bounds)
|
|
||||||
|
|
||||||
def remove_path(self, net_id: str) -> None:
|
def remove_path(self, net_id: str) -> None:
|
||||||
if net_id in self._locked_nets: return
|
self._dynamic_paths.remove_path(net_id)
|
||||||
to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id]
|
|
||||||
if not to_remove: return
|
|
||||||
self.dynamic_tree = None
|
|
||||||
self.dynamic_grid = {}
|
|
||||||
self._dynamic_tree_dirty = True
|
|
||||||
for obj_id in to_remove:
|
|
||||||
self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds)
|
|
||||||
del self.dynamic_geometries[obj_id]
|
|
||||||
del self.dynamic_dilated[obj_id]
|
|
||||||
|
|
||||||
def lock_net(self, net_id: str) -> None:
|
|
||||||
""" Convert a routed net into static obstacles. """
|
|
||||||
self._locked_nets.add(net_id)
|
|
||||||
|
|
||||||
# Move all segments of this net to static obstacles
|
|
||||||
to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id]
|
|
||||||
for obj_id in to_move:
|
|
||||||
poly = self.dynamic_geometries[obj_id][1]
|
|
||||||
dilated = self.dynamic_dilated[obj_id]
|
|
||||||
# Preserve dilation for perfect consistency
|
|
||||||
self.add_static_obstacle(poly, dilated_geometry=dilated)
|
|
||||||
|
|
||||||
# Remove from dynamic index (without triggering the locked-net guard)
|
|
||||||
self.dynamic_tree = None
|
|
||||||
self.dynamic_grid = {}
|
|
||||||
self._dynamic_tree_dirty = True
|
|
||||||
for obj_id in to_move:
|
|
||||||
self.dynamic_index.delete(obj_id, self.dynamic_dilated[obj_id].bounds)
|
|
||||||
del self.dynamic_geometries[obj_id]
|
|
||||||
del self.dynamic_dilated[obj_id]
|
|
||||||
|
|
||||||
def unlock_net(self, net_id: str) -> None:
|
|
||||||
self._locked_nets.discard(net_id)
|
|
||||||
|
|
||||||
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
|
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
|
||||||
self.metrics['static_straight_fast'] += 1
|
reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width)
|
||||||
reach = self.ray_cast(start_port, start_port.orientation, max_dist=length + 0.01, net_width=net_width)
|
|
||||||
return reach < length - 0.001
|
return reach < length - 0.001
|
||||||
|
|
||||||
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
|
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
|
||||||
""" Fast port-based check to see if a collision might be in a safety zone. """
|
bounds = self._static_obstacles.bounds_array[idx]
|
||||||
sz = self.safety_zone_radius
|
safety_zone = self.safety_zone_radius
|
||||||
b = self._static_bounds_array[idx]
|
if start_port and bounds[0] - safety_zone <= start_port.x <= bounds[2] + safety_zone and bounds[1] - safety_zone <= start_port.y <= bounds[3] + safety_zone:
|
||||||
if start_port:
|
return True
|
||||||
if (b[0]-sz <= start_port.x <= b[2]+sz and
|
return bool(
|
||||||
b[1]-sz <= start_port.y <= b[3]+sz): return True
|
end_port
|
||||||
if end_port:
|
and bounds[0] - safety_zone <= end_port.x <= bounds[2] + safety_zone
|
||||||
if (b[0]-sz <= end_port.x <= b[2]+sz and
|
and bounds[1] - safety_zone <= end_port.y <= bounds[3] + safety_zone
|
||||||
b[1]-sz <= end_port.y <= b[3]+sz): return True
|
)
|
||||||
return False
|
|
||||||
|
def _is_in_safety_zone(
|
||||||
|
self,
|
||||||
|
geometry: Polygon,
|
||||||
|
obj_id: int,
|
||||||
|
start_port: Port | None,
|
||||||
|
end_port: Port | None,
|
||||||
|
) -> bool:
|
||||||
|
raw_obstacle = self._static_obstacles.geometries[obj_id]
|
||||||
|
safety_zone = self.safety_zone_radius
|
||||||
|
|
||||||
|
obstacle_bounds = raw_obstacle.bounds
|
||||||
|
near_start = start_port and (
|
||||||
|
obstacle_bounds[0] - safety_zone <= start_port.x <= obstacle_bounds[2] + safety_zone
|
||||||
|
and obstacle_bounds[1] - safety_zone <= start_port.y <= obstacle_bounds[3] + safety_zone
|
||||||
|
)
|
||||||
|
near_end = end_port and (
|
||||||
|
obstacle_bounds[0] - safety_zone <= end_port.x <= obstacle_bounds[2] + safety_zone
|
||||||
|
and obstacle_bounds[1] - safety_zone <= end_port.y <= obstacle_bounds[3] + safety_zone
|
||||||
|
)
|
||||||
|
|
||||||
|
if not near_start and not near_end:
|
||||||
|
return False
|
||||||
|
if not geometry.intersects(raw_obstacle):
|
||||||
|
return False
|
||||||
|
|
||||||
|
intersection = geometry.intersection(raw_obstacle)
|
||||||
|
if intersection.is_empty:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ix_bounds = intersection.bounds
|
||||||
|
if (
|
||||||
|
start_port
|
||||||
|
and near_start
|
||||||
|
and abs(ix_bounds[0] - start_port.x) < safety_zone
|
||||||
|
and abs(ix_bounds[1] - start_port.y) < safety_zone
|
||||||
|
and abs(ix_bounds[2] - start_port.x) < safety_zone
|
||||||
|
and abs(ix_bounds[3] - start_port.y) < safety_zone
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return bool(
|
||||||
|
end_port
|
||||||
|
and near_end
|
||||||
|
and abs(ix_bounds[0] - end_port.x) < safety_zone
|
||||||
|
and abs(ix_bounds[1] - end_port.y) < safety_zone
|
||||||
|
and abs(ix_bounds[2] - end_port.x) < safety_zone
|
||||||
|
and abs(ix_bounds[3] - end_port.y) < safety_zone
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_move_static(
|
||||||
|
self,
|
||||||
|
result: ComponentResult,
|
||||||
|
start_port: Port | None = None,
|
||||||
|
end_port: Port | None = None,
|
||||||
|
) -> bool:
|
||||||
|
# TODO: If static buffering becomes net-width-specific, add dedicated
|
||||||
|
# width-aware geometry/index handling instead of reviving dead args here.
|
||||||
|
static_obstacles = self._static_obstacles
|
||||||
|
if not static_obstacles.dilated:
|
||||||
|
return False
|
||||||
|
|
||||||
def check_move_static(self, result: ComponentResult, start_port: Port | None = None, end_port: Port | None = None, net_width: float | None = None) -> bool:
|
|
||||||
if not self.static_dilated: return False
|
|
||||||
self.metrics['static_tree_queries'] += 1
|
|
||||||
self._ensure_static_tree()
|
self._ensure_static_tree()
|
||||||
|
|
||||||
# 1. Fast total bounds check (Use dilated bounds to ensure clearance is caught)
|
hits = static_obstacles.tree.query(box(*result.total_dilated_bounds))
|
||||||
tb = result.total_dilated_bounds if result.total_dilated_bounds else result.total_bounds
|
if hits.size == 0:
|
||||||
hits = self.static_tree.query(box(*tb))
|
return False
|
||||||
if hits.size == 0: return False
|
|
||||||
|
|
||||||
# 2. Per-hit check
|
static_bounds = static_obstacles.bounds_array
|
||||||
s_bounds = self._static_bounds_array
|
move_poly_bounds = result.dilated_bounds
|
||||||
move_poly_bounds = result.dilated_bounds if result.dilated_bounds else result.bounds
|
|
||||||
for hit_idx in hits:
|
for hit_idx in hits:
|
||||||
obs_b = s_bounds[hit_idx]
|
obstacle_bounds = static_bounds[hit_idx]
|
||||||
|
poly_hits_obstacle_aabb = False
|
||||||
# Check if any polygon in the move actually hits THIS obstacle's AABB
|
for poly_bounds in move_poly_bounds:
|
||||||
poly_hits_obs_aabb = False
|
if (
|
||||||
for pb in move_poly_bounds:
|
poly_bounds[0] < obstacle_bounds[2]
|
||||||
if (pb[0] < obs_b[2] and pb[2] > obs_b[0] and
|
and poly_bounds[2] > obstacle_bounds[0]
|
||||||
pb[1] < obs_b[3] and pb[3] > obs_b[1]):
|
and poly_bounds[1] < obstacle_bounds[3]
|
||||||
poly_hits_obs_aabb = True
|
and poly_bounds[3] > obstacle_bounds[1]
|
||||||
|
):
|
||||||
|
poly_hits_obstacle_aabb = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not poly_hits_obs_aabb: continue
|
if not poly_hits_obstacle_aabb:
|
||||||
|
continue
|
||||||
|
|
||||||
# Safety zone check (Fast port-based)
|
obj_id = static_obstacles.obj_ids[hit_idx]
|
||||||
if self._is_in_safety_zone_fast(hit_idx, start_port, end_port):
|
if self._is_in_safety_zone_fast(hit_idx, start_port, end_port):
|
||||||
# If near port, we must use the high-precision check
|
collision_found = False
|
||||||
obj_id = self.static_obj_ids[hit_idx]
|
for polygon in result.collision_geometry:
|
||||||
collision_found = False
|
if not self._is_in_safety_zone(polygon, obj_id, start_port, end_port):
|
||||||
for p_move in result.geometry:
|
collision_found = True
|
||||||
if not self._is_in_safety_zone(p_move, obj_id, start_port, end_port):
|
break
|
||||||
collision_found = True; break
|
if collision_found:
|
||||||
if not collision_found: continue
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Not in safety zone and AABBs overlap - check real intersection
|
|
||||||
obj_id = self.static_obj_ids[hit_idx]
|
|
||||||
# Use dilated geometry (Wi/2 + C/2) against static_dilated (C/2) to get Wi/2 + C.
|
|
||||||
# Touching means gap is exactly C. Intersection without touches means gap < C.
|
|
||||||
test_geoms = result.dilated_geometry if result.dilated_geometry else result.geometry
|
|
||||||
static_obs_dilated = self.static_dilated[obj_id]
|
|
||||||
|
|
||||||
for i, p_test in enumerate(test_geoms):
|
|
||||||
if p_test.intersects(static_obs_dilated) and not p_test.touches(static_obs_dilated):
|
|
||||||
return True
|
return True
|
||||||
|
continue
|
||||||
|
|
||||||
|
static_obstacle = static_obstacles.dilated[obj_id]
|
||||||
|
for polygon in result.dilated_collision_geometry:
|
||||||
|
if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
|
||||||
|
dynamic_paths = self._dynamic_paths
|
||||||
|
self._ensure_dynamic_tree()
|
||||||
|
if dynamic_paths.tree is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_bounds = result.total_dilated_bounds
|
||||||
|
dynamic_bounds = dynamic_paths.bounds_array
|
||||||
|
possible_total = (
|
||||||
|
(total_bounds[0] < dynamic_bounds[:, 2])
|
||||||
|
& (total_bounds[2] > dynamic_bounds[:, 0])
|
||||||
|
& (total_bounds[1] < dynamic_bounds[:, 3])
|
||||||
|
& (total_bounds[3] > dynamic_bounds[:, 1])
|
||||||
|
)
|
||||||
|
|
||||||
|
valid_hits_mask = dynamic_paths.net_ids_array != net_id
|
||||||
|
if not numpy.any(possible_total & valid_hits_mask):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
geometries_to_test = result.dilated_collision_geometry
|
||||||
|
res_indices, tree_indices = dynamic_paths.tree.query(geometries_to_test, predicate="intersects")
|
||||||
|
if tree_indices.size == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
|
||||||
|
unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
|
||||||
|
if unique_other_nets.size == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tree_geometries = dynamic_paths.tree.geometries
|
||||||
|
real_hits_count = 0
|
||||||
|
for other_net_id in unique_other_nets:
|
||||||
|
other_mask = hit_net_ids == other_net_id
|
||||||
|
sub_tree_indices = tree_indices[other_mask]
|
||||||
|
sub_res_indices = res_indices[other_mask]
|
||||||
|
|
||||||
|
found_real = False
|
||||||
|
for index in range(len(sub_tree_indices)):
|
||||||
|
test_geometry = geometries_to_test[sub_res_indices[index]]
|
||||||
|
tree_geometry = tree_geometries[sub_tree_indices[index]]
|
||||||
|
if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7:
|
||||||
|
found_real = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if found_real:
|
||||||
|
real_hits_count += 1
|
||||||
|
|
||||||
|
return real_hits_count
|
||||||
|
|
||||||
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
|
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
|
||||||
if not self.dynamic_geometries: return 0
|
dynamic_paths = self._dynamic_paths
|
||||||
tb = result.total_dilated_bounds
|
if not dynamic_paths.geometries:
|
||||||
if tb is None: return 0
|
return 0
|
||||||
|
|
||||||
|
total_bounds = result.total_dilated_bounds
|
||||||
self._ensure_dynamic_grid()
|
self._ensure_dynamic_grid()
|
||||||
dynamic_grid = self.dynamic_grid
|
dynamic_grid = dynamic_paths.grid
|
||||||
if not dynamic_grid: return 0
|
if not dynamic_grid:
|
||||||
|
return 0
|
||||||
|
|
||||||
cs_inv = self._inv_grid_cell_size
|
gx_min, gy_min, gx_max, gy_max = grid_cell_span(total_bounds, self.grid_cell_size)
|
||||||
gx_min = int(tb[0] * cs_inv)
|
|
||||||
gy_min = int(tb[1] * cs_inv)
|
|
||||||
gx_max = int(tb[2] * cs_inv)
|
|
||||||
gy_max = int(tb[3] * cs_inv)
|
|
||||||
|
|
||||||
dynamic_geometries = self.dynamic_geometries
|
|
||||||
|
|
||||||
# Fast path for single cell
|
|
||||||
if gx_min == gx_max and gy_min == gy_max:
|
if gx_min == gx_max and gy_min == gy_max:
|
||||||
cell = (gx_min, gy_min)
|
cell = (gx_min, gy_min)
|
||||||
if cell in dynamic_grid:
|
if cell in dynamic_grid:
|
||||||
for obj_id in dynamic_grid[cell]:
|
for obj_id in dynamic_grid[cell]:
|
||||||
if dynamic_geometries[obj_id][0] != net_id:
|
if dynamic_paths.geometries[obj_id][0] != net_id:
|
||||||
return self._check_real_congestion(result, net_id)
|
return self._check_real_congestion(result, net_id)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# General case
|
|
||||||
any_possible = False
|
any_possible = False
|
||||||
for gx in range(gx_min, gx_max + 1):
|
for gx in range(gx_min, gx_max + 1):
|
||||||
for gy in range(gy_min, gy_max + 1):
|
for gy in range(gy_min, gy_max + 1):
|
||||||
cell = (gx, gy)
|
cell = (gx, gy)
|
||||||
if cell in dynamic_grid:
|
if cell in dynamic_grid:
|
||||||
for obj_id in dynamic_grid[cell]:
|
for obj_id in dynamic_grid[cell]:
|
||||||
if dynamic_geometries[obj_id][0] != net_id:
|
if dynamic_paths.geometries[obj_id][0] != net_id:
|
||||||
any_possible = True
|
any_possible = True
|
||||||
break
|
break
|
||||||
if any_possible: break
|
if any_possible:
|
||||||
if any_possible: break
|
break
|
||||||
|
if any_possible:
|
||||||
|
break
|
||||||
|
|
||||||
if not any_possible: return 0
|
if not any_possible:
|
||||||
|
return 0
|
||||||
return self._check_real_congestion(result, net_id)
|
return self._check_real_congestion(result, net_id)
|
||||||
|
|
||||||
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
|
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
|
||||||
self.metrics['congestion_tree_queries'] += 1
|
static_collision_count = 0
|
||||||
self._ensure_dynamic_tree()
|
dynamic_collision_count = 0
|
||||||
if self.dynamic_tree is None: return 0
|
self_collision_count = 0
|
||||||
|
total_length = sum(component.length for component in components)
|
||||||
|
|
||||||
# 1. Fast total bounds check (LAZY SAFE)
|
static_obstacles = self._static_obstacles
|
||||||
tb = result.total_dilated_bounds
|
dynamic_paths = self._dynamic_paths
|
||||||
d_bounds = self._dynamic_bounds_array
|
|
||||||
possible_total = (tb[0] < d_bounds[:, 2]) & (tb[2] > d_bounds[:, 0]) & \
|
|
||||||
(tb[1] < d_bounds[:, 3]) & (tb[3] > d_bounds[:, 1])
|
|
||||||
|
|
||||||
valid_hits_mask = (self._dynamic_net_ids_array != net_id)
|
|
||||||
if not numpy.any(possible_total & valid_hits_mask):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 2. Per-polygon check using query
|
|
||||||
geoms_to_test = result.dilated_geometry if result.dilated_geometry else result.geometry
|
|
||||||
res_indices, tree_indices = self.dynamic_tree.query(geoms_to_test, predicate='intersects')
|
|
||||||
|
|
||||||
if tree_indices.size == 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices)
|
|
||||||
|
|
||||||
# Group by other net_id to minimize 'touches' calls
|
|
||||||
unique_other_nets = numpy.unique(hit_net_ids[hit_net_ids != net_id])
|
|
||||||
if unique_other_nets.size == 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
tree_geoms = self.dynamic_tree.geometries
|
|
||||||
real_hits_count = 0
|
|
||||||
|
|
||||||
for other_nid in unique_other_nets:
|
|
||||||
other_mask = (hit_net_ids == other_nid)
|
|
||||||
sub_tree_indices = tree_indices[other_mask]
|
|
||||||
sub_res_indices = res_indices[other_mask]
|
|
||||||
|
|
||||||
# Check if ANY hit for THIS other net is a real collision
|
|
||||||
found_real = False
|
|
||||||
for j in range(len(sub_tree_indices)):
|
|
||||||
p_test = geoms_to_test[sub_res_indices[j]]
|
|
||||||
p_tree = tree_geoms[sub_tree_indices[j]]
|
|
||||||
if not p_test.touches(p_tree):
|
|
||||||
# Add small area tolerance for numerical precision
|
|
||||||
if p_test.intersection(p_tree).area > 1e-7:
|
|
||||||
found_real = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if found_real:
|
|
||||||
real_hits_count += 1
|
|
||||||
|
|
||||||
return real_hits_count
|
|
||||||
|
|
||||||
def _is_in_safety_zone(self, geometry: Polygon, obj_id: int, start_port: Port | None, end_port: Port | None) -> bool:
|
|
||||||
"""
|
|
||||||
Only returns True if the collision is ACTUALLY inside a safety zone.
|
|
||||||
"""
|
|
||||||
raw_obstacle = self.static_geometries[obj_id]
|
|
||||||
sz = self.safety_zone_radius
|
|
||||||
|
|
||||||
# Fast path: check if ports are even near the obstacle
|
|
||||||
obs_b = raw_obstacle.bounds
|
|
||||||
near_start = start_port and (obs_b[0]-sz <= start_port.x <= obs_b[2]+sz and
|
|
||||||
obs_b[1]-sz <= start_port.y <= obs_b[3]+sz)
|
|
||||||
near_end = end_port and (obs_b[0]-sz <= end_port.x <= obs_b[2]+sz and
|
|
||||||
obs_b[1]-sz <= end_port.y <= obs_b[3]+sz)
|
|
||||||
|
|
||||||
if not near_start and not near_end:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not geometry.intersects(raw_obstacle):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.metrics['safety_zone_checks'] += 1
|
|
||||||
intersection = geometry.intersection(raw_obstacle)
|
|
||||||
if intersection.is_empty: return False
|
|
||||||
|
|
||||||
ix_bounds = intersection.bounds
|
|
||||||
if start_port and near_start:
|
|
||||||
if (abs(ix_bounds[0] - start_port.x) < sz and abs(ix_bounds[1] - start_port.y) < sz and
|
|
||||||
abs(ix_bounds[2] - start_port.x) < sz and abs(ix_bounds[3] - start_port.y) < sz): return True
|
|
||||||
if end_port and near_end:
|
|
||||||
if (abs(ix_bounds[0] - end_port.x) < sz and abs(ix_bounds[1] - end_port.y) < sz and
|
|
||||||
abs(ix_bounds[2] - end_port.x) < sz and abs(ix_bounds[3] - end_port.y) < sz): return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_collision(self, geometry: Polygon, net_id: str, buffer_mode: Literal['static', 'congestion'] = 'static', start_port: Port | None = None, end_port: Port | None = None, dilated_geometry: Polygon | None = None, bounds: tuple[float, float, float, float] | None = None, net_width: float | None = None) -> bool | int:
|
|
||||||
if buffer_mode == 'static':
|
|
||||||
self._ensure_static_tree()
|
|
||||||
if self.static_tree is None: return False
|
|
||||||
|
|
||||||
# Separation needed: Centerline-to-WallEdge >= Wi/2 + C.
|
|
||||||
# static_tree has obstacles buffered by C/2.
|
|
||||||
# geometry is physical waveguide (Wi/2 from centerline).
|
|
||||||
# So we buffer geometry by C/2 to get Wi/2 + C/2.
|
|
||||||
# Intersection means separation < (Wi/2 + C/2) + C/2 = Wi/2 + C.
|
|
||||||
if dilated_geometry is not None:
|
|
||||||
test_geom = dilated_geometry
|
|
||||||
else:
|
|
||||||
dist = self.clearance / 2.0
|
|
||||||
test_geom = geometry.buffer(dist + 1e-7, join_style=2) if dist > 0 else geometry
|
|
||||||
|
|
||||||
hits = self.static_tree.query(test_geom, predicate='intersects')
|
|
||||||
tree_geoms = self.static_tree.geometries
|
|
||||||
for hit_idx in hits:
|
|
||||||
if test_geom.touches(tree_geoms[hit_idx]): continue
|
|
||||||
obj_id = self.static_obj_ids[hit_idx]
|
|
||||||
if self._is_in_safety_zone(geometry, obj_id, start_port, end_port): continue
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
self._ensure_dynamic_tree()
|
|
||||||
if self.dynamic_tree is None: return 0
|
|
||||||
test_poly = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0)
|
|
||||||
hits = self.dynamic_tree.query(test_poly, predicate='intersects')
|
|
||||||
tree_geoms = self.dynamic_tree.geometries
|
|
||||||
hit_net_ids = []
|
|
||||||
for hit_idx in hits:
|
|
||||||
if test_poly.touches(tree_geoms[hit_idx]): continue
|
|
||||||
obj_id = self.dynamic_obj_ids[hit_idx]
|
|
||||||
other_id = self.dynamic_geometries[obj_id][0]
|
|
||||||
if other_id != net_id:
|
|
||||||
hit_net_ids.append(other_id)
|
|
||||||
return len(numpy.unique(hit_net_ids)) if hit_net_ids else 0
|
|
||||||
|
|
||||||
def is_collision(self, geometry: Polygon, net_id: str = 'default', net_width: float | None = None, start_port: Port | None = None, end_port: Port | None = None) -> bool:
|
|
||||||
""" Unified entry point for static collision checks. """
|
|
||||||
result = self.check_collision(geometry, net_id, buffer_mode='static', start_port=start_port, end_port=end_port, net_width=net_width)
|
|
||||||
return bool(result)
|
|
||||||
|
|
||||||
def verify_path(self, net_id: str, components: list[ComponentResult]) -> tuple[bool, int]:
|
|
||||||
"""
|
|
||||||
Non-approximated, full-polygon intersection check of a path against all
|
|
||||||
static obstacles and other nets.
|
|
||||||
"""
|
|
||||||
collision_count = 0
|
|
||||||
|
|
||||||
# 1. Check against static obstacles
|
|
||||||
self._ensure_static_raw_tree()
|
self._ensure_static_raw_tree()
|
||||||
if self._static_raw_tree is not None:
|
if static_obstacles.raw_tree is not None:
|
||||||
raw_geoms = self._static_raw_tree.geometries
|
raw_geometries = static_obstacles.raw_tree.geometries
|
||||||
for comp in components:
|
for component in components:
|
||||||
# Use ACTUAL geometry, not dilated/proxy
|
for polygon in component.physical_geometry:
|
||||||
actual_geoms = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry
|
buffered = polygon.buffer(self.clearance, join_style=2)
|
||||||
for p_actual in actual_geoms:
|
hits = static_obstacles.raw_tree.query(buffered, predicate="intersects")
|
||||||
# Physical separation must be >= clearance.
|
for hit_idx in hits:
|
||||||
p_verify = p_actual.buffer(self.clearance, join_style=2)
|
obstacle = raw_geometries[hit_idx]
|
||||||
hits = self._static_raw_tree.query(p_verify, predicate='intersects')
|
if buffered.touches(obstacle):
|
||||||
for hit_idx in hits:
|
continue
|
||||||
p_obs = raw_geoms[hit_idx]
|
|
||||||
# If they ONLY touch, gap is exactly clearance. Valid.
|
|
||||||
if p_verify.touches(p_obs): continue
|
|
||||||
|
|
||||||
obj_id = self._static_raw_obj_ids[hit_idx]
|
obj_id = static_obstacles.raw_obj_ids[hit_idx]
|
||||||
if not self._is_in_safety_zone(p_actual, obj_id, None, None):
|
if not self._is_in_safety_zone(polygon, obj_id, None, None):
|
||||||
collision_count += 1
|
static_collision_count += 1
|
||||||
|
|
||||||
# 2. Check against other nets
|
|
||||||
self._ensure_dynamic_tree()
|
self._ensure_dynamic_tree()
|
||||||
if self.dynamic_tree is not None:
|
if dynamic_paths.tree is not None:
|
||||||
tree_geoms = self.dynamic_tree.geometries
|
tree_geometries = dynamic_paths.tree.geometries
|
||||||
for comp in components:
|
for component in components:
|
||||||
# Robust fallback chain to ensure crossings are caught even with zero clearance
|
test_geometries = component.dilated_physical_geometry
|
||||||
d_geoms = comp.dilated_actual_geometry or comp.dilated_geometry or comp.actual_geometry or comp.geometry
|
res_indices, tree_indices = dynamic_paths.tree.query(test_geometries, predicate="intersects")
|
||||||
if not d_geoms: continue
|
if tree_indices.size == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
# Ensure d_geoms is a list/array for STRtree.query
|
hit_net_ids = numpy.take(dynamic_paths.net_ids_array, tree_indices)
|
||||||
if not isinstance(d_geoms, (list, tuple, numpy.ndarray)):
|
component_hits = []
|
||||||
d_geoms = [d_geoms]
|
for index in range(len(tree_indices)):
|
||||||
|
if hit_net_ids[index] == str(net_id):
|
||||||
|
continue
|
||||||
|
|
||||||
res_indices, tree_indices = self.dynamic_tree.query(d_geoms, predicate='intersects')
|
new_geometry = test_geometries[res_indices[index]]
|
||||||
if tree_indices.size > 0:
|
tree_geometry = tree_geometries[tree_indices[index]]
|
||||||
hit_net_ids = numpy.take(self._dynamic_net_ids_array, tree_indices)
|
if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7:
|
||||||
net_id_str = str(net_id)
|
component_hits.append(hit_net_ids[index])
|
||||||
|
|
||||||
comp_hits = []
|
if component_hits:
|
||||||
for i in range(len(tree_indices)):
|
dynamic_collision_count += len(numpy.unique(component_hits))
|
||||||
if hit_net_ids[i] == net_id_str: continue
|
|
||||||
|
|
||||||
p_new = d_geoms[res_indices[i]]
|
for index, component in enumerate(components):
|
||||||
p_tree = tree_geoms[tree_indices[i]]
|
for other_index in range(index + 2, len(components)):
|
||||||
if not p_new.touches(p_tree):
|
if components_overlap(component, components[other_index], prefer_actual=True):
|
||||||
# Numerical tolerance for area overlap
|
self_collision_count += 1
|
||||||
if p_new.intersection(p_tree).area > 1e-7:
|
|
||||||
comp_hits.append(hit_net_ids[i])
|
|
||||||
|
|
||||||
if comp_hits:
|
return RoutingReport(
|
||||||
collision_count += len(numpy.unique(comp_hits))
|
static_collision_count=static_collision_count,
|
||||||
|
dynamic_collision_count=dynamic_collision_count,
|
||||||
|
self_collision_count=self_collision_count,
|
||||||
|
total_length=total_length,
|
||||||
|
)
|
||||||
|
|
||||||
return (collision_count == 0), collision_count
|
def ray_cast(
|
||||||
|
self,
|
||||||
|
origin: Port,
|
||||||
|
angle_deg: float,
|
||||||
|
max_dist: float = 2000.0,
|
||||||
|
net_width: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
static_obstacles = self._static_obstacles
|
||||||
|
|
||||||
def ray_cast(self, origin: Port, angle_deg: float, max_dist: float = 2000.0, net_width: float | None = None) -> float:
|
radians = numpy.radians(angle_deg)
|
||||||
rad = numpy.radians(angle_deg)
|
cos_v, sin_v = numpy.cos(radians), numpy.sin(radians)
|
||||||
cos_v, sin_v = numpy.cos(rad), numpy.sin(rad)
|
|
||||||
dx, dy = max_dist * cos_v, max_dist * sin_v
|
dx, dy = max_dist * cos_v, max_dist * sin_v
|
||||||
min_x, max_x = sorted([origin.x, origin.x + dx])
|
min_x, max_x = sorted([origin.x, origin.x + dx])
|
||||||
min_y, max_y = sorted([origin.y, origin.y + dy])
|
min_y, max_y = sorted([origin.y, origin.y + dy])
|
||||||
|
|
||||||
key = None
|
|
||||||
if net_width is not None:
|
if net_width is not None:
|
||||||
tree = self._ensure_net_static_tree(net_width)
|
tree = self._ensure_net_static_tree(net_width)
|
||||||
key = (round(net_width, 4), round(self.clearance, 4))
|
key = (round(net_width, 4), round(self.clearance, 4))
|
||||||
is_rect_arr = self._net_specific_is_rect[key]
|
is_rect_array = static_obstacles.net_specific_is_rect[key]
|
||||||
bounds_arr = self._net_specific_bounds[key]
|
bounds_array = static_obstacles.net_specific_bounds[key]
|
||||||
else:
|
else:
|
||||||
self._ensure_static_tree()
|
self._ensure_static_tree()
|
||||||
tree = self.static_tree
|
tree = static_obstacles.tree
|
||||||
is_rect_arr = self._static_is_rect_array
|
is_rect_array = static_obstacles.is_rect_array
|
||||||
bounds_arr = self._static_bounds_array
|
bounds_array = static_obstacles.bounds_array
|
||||||
|
|
||||||
|
if tree is None:
|
||||||
|
return max_dist
|
||||||
|
|
||||||
if tree is None: return max_dist
|
|
||||||
candidates = tree.query(box(min_x, min_y, max_x, max_y))
|
candidates = tree.query(box(min_x, min_y, max_x, max_y))
|
||||||
if candidates.size == 0: return max_dist
|
if candidates.size == 0:
|
||||||
|
return max_dist
|
||||||
|
|
||||||
min_dist = max_dist
|
min_dist = max_dist
|
||||||
inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30
|
inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30
|
||||||
inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30
|
inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30
|
||||||
|
tree_geometries = tree.geometries
|
||||||
tree_geoms = tree.geometries
|
|
||||||
ray_line = None
|
ray_line = None
|
||||||
|
|
||||||
# Fast AABB-based pre-sort
|
candidates_bounds = bounds_array[candidates]
|
||||||
candidates_bounds = bounds_arr[candidates]
|
dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2
|
||||||
# Distance to AABB min corner as heuristic
|
|
||||||
dist_sq = (candidates_bounds[:, 0] - origin.x)**2 + (candidates_bounds[:, 1] - origin.y)**2
|
|
||||||
sorted_indices = numpy.argsort(dist_sq)
|
sorted_indices = numpy.argsort(dist_sq)
|
||||||
|
|
||||||
for idx in sorted_indices:
|
for idx in sorted_indices:
|
||||||
c = candidates[idx]
|
candidate_id = candidates[idx]
|
||||||
b = bounds_arr[c]
|
bounds = bounds_array[candidate_id]
|
||||||
|
|
||||||
# Fast axis-aligned ray-AABB intersection
|
if abs(dx) < 1e-12:
|
||||||
# (Standard Slab method)
|
if origin.x < bounds[0] or origin.x > bounds[2]:
|
||||||
if abs(dx) < 1e-12: # Vertical ray
|
tx_min, tx_max = 1e30, -1e30
|
||||||
if origin.x < b[0] or origin.x > b[2]: tx_min, tx_max = 1e30, -1e30
|
else:
|
||||||
else: tx_min, tx_max = -1e30, 1e30
|
tx_min, tx_max = -1e30, 1e30
|
||||||
else:
|
else:
|
||||||
t1, t2 = (b[0] - origin.x) * inv_dx, (b[2] - origin.x) * inv_dx
|
t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx
|
||||||
tx_min, tx_max = min(t1, t2), max(t1, t2)
|
tx_min, tx_max = min(t1, t2), max(t1, t2)
|
||||||
|
|
||||||
if abs(dy) < 1e-12: # Horizontal ray
|
if abs(dy) < 1e-12:
|
||||||
if origin.y < b[1] or origin.y > b[3]: ty_min, ty_max = 1e30, -1e30
|
if origin.y < bounds[1] or origin.y > bounds[3]:
|
||||||
else: ty_min, ty_max = -1e30, 1e30
|
ty_min, ty_max = 1e30, -1e30
|
||||||
|
else:
|
||||||
|
ty_min, ty_max = -1e30, 1e30
|
||||||
else:
|
else:
|
||||||
t1, t2 = (b[1] - origin.y) * inv_dy, (b[3] - origin.y) * inv_dy
|
t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy
|
||||||
ty_min, ty_max = min(t1, t2), max(t1, t2)
|
ty_min, ty_max = min(t1, t2), max(t1, t2)
|
||||||
|
|
||||||
t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max)
|
t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max)
|
||||||
|
if t_max < 0 or t_min > t_max or t_min > 1.0:
|
||||||
|
continue
|
||||||
|
if t_min * max_dist >= min_dist:
|
||||||
|
continue
|
||||||
|
|
||||||
# Intersection conditions
|
if is_rect_array[candidate_id]:
|
||||||
if t_max < 0 or t_min > t_max or t_min > 1.0: continue
|
|
||||||
|
|
||||||
# If hit is further than current min_dist, skip
|
|
||||||
if t_min * max_dist >= min_dist: continue
|
|
||||||
|
|
||||||
# HIGH PRECISION CHECK
|
|
||||||
if is_rect_arr[c]:
|
|
||||||
# Rectangles are perfectly described by their AABB
|
|
||||||
min_dist = max(0.0, t_min * max_dist)
|
min_dist = max(0.0, t_min * max_dist)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Fallback to full geometry check for non-rectangles (arcs, etc.)
|
|
||||||
if ray_line is None:
|
if ray_line is None:
|
||||||
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
|
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
|
||||||
|
|
||||||
obs_dilated = tree_geoms[c]
|
obstacle = tree_geometries[candidate_id]
|
||||||
if obs_dilated.intersects(ray_line):
|
if not obstacle.intersects(ray_line):
|
||||||
intersection = ray_line.intersection(obs_dilated)
|
continue
|
||||||
if intersection.is_empty: continue
|
|
||||||
|
|
||||||
def get_dist(geom):
|
intersection = ray_line.intersection(obstacle)
|
||||||
if hasattr(geom, 'geoms'): return min(get_dist(g) for g in geom.geoms)
|
if intersection.is_empty:
|
||||||
return numpy.sqrt((geom.coords[0][0] - origin.x)**2 + (geom.coords[0][1] - origin.y)**2)
|
continue
|
||||||
|
|
||||||
d = get_dist(intersection)
|
distance = _intersection_distance(origin, intersection)
|
||||||
if d < min_dist: min_dist = d
|
min_dist = min(min_dist, distance)
|
||||||
|
|
||||||
return min_dist
|
return min_dist
|
||||||
|
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import numpy
|
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.affinity import translate as shapely_translate
|
||||||
from shapely.geometry import Polygon, box
|
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
|
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:
|
def _normalize_length(value: float) -> float:
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
class ComponentResult:
|
class ComponentResult:
|
||||||
__slots__ = (
|
start_port: Port
|
||||||
"geometry",
|
collision_geometry: tuple[Polygon, ...]
|
||||||
"dilated_geometry",
|
end_port: Port
|
||||||
"proxy_geometry",
|
length: float
|
||||||
"actual_geometry",
|
move_type: MoveKind
|
||||||
"dilated_actual_geometry",
|
move_spec: PathSegmentSeed
|
||||||
"end_port",
|
physical_geometry: tuple[Polygon, ...]
|
||||||
"length",
|
dilated_collision_geometry: tuple[Polygon, ...]
|
||||||
"move_type",
|
dilated_physical_geometry: tuple[Polygon, ...]
|
||||||
"_bounds",
|
_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
|
||||||
"_total_bounds",
|
_total_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
|
||||||
"_dilated_bounds",
|
_dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
|
||||||
"_total_dilated_bounds",
|
_total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __post_init__(self) -> None:
|
||||||
self,
|
collision_geometry = tuple(self.collision_geometry)
|
||||||
geometry: list[Polygon],
|
physical_geometry = tuple(self.physical_geometry)
|
||||||
end_port: Port,
|
dilated_collision_geometry = tuple(self.dilated_collision_geometry)
|
||||||
length: float,
|
dilated_physical_geometry = tuple(self.dilated_physical_geometry)
|
||||||
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
|
|
||||||
|
|
||||||
self._bounds = [poly.bounds for poly in self.geometry]
|
object.__setattr__(self, "collision_geometry", collision_geometry)
|
||||||
self._total_bounds = _combine_bounds(self._bounds)
|
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:
|
bounds = tuple(poly.bounds for poly in collision_geometry)
|
||||||
self._dilated_bounds = None
|
object.__setattr__(self, "_bounds", bounds)
|
||||||
self._total_dilated_bounds = None
|
object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds)))
|
||||||
else:
|
|
||||||
self._dilated_bounds = [poly.bounds for poly in self.dilated_geometry]
|
dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry)
|
||||||
self._total_dilated_bounds = _combine_bounds(self._dilated_bounds)
|
object.__setattr__(self, "_dilated_bounds", dilated_bounds)
|
||||||
|
object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds)))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bounds(self) -> list[tuple[float, float, float, float]]:
|
def bounds(self) -> tuple[tuple[float, float, float, float], ...]:
|
||||||
return self._bounds
|
return self._bounds
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -69,23 +68,24 @@ class ComponentResult:
|
||||||
return self._total_bounds
|
return self._total_bounds
|
||||||
|
|
||||||
@property
|
@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
|
return self._dilated_bounds
|
||||||
|
|
||||||
@property
|
@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
|
return self._total_dilated_bounds
|
||||||
|
|
||||||
def translate(self, dx: int | float, dy: int | float) -> ComponentResult:
|
def translate(self, dx: int | float, dy: int | float) -> ComponentResult:
|
||||||
return ComponentResult(
|
return ComponentResult(
|
||||||
geometry=[shapely_translate(poly, dx, dy) for poly in self.geometry],
|
start_port=self.start_port.translate(dx, dy),
|
||||||
end_port=self.end_port + [dx, dy, 0],
|
collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry],
|
||||||
|
end_port=self.end_port.translate(dx, dy),
|
||||||
length=self.length,
|
length=self.length,
|
||||||
move_type=self.move_type,
|
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],
|
move_spec=self.move_spec,
|
||||||
proxy_geometry=None if self.proxy_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.proxy_geometry],
|
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
|
||||||
actual_geometry=None if self.actual_geometry is None else [shapely_translate(poly, dx, dy) for poly in self.actual_geometry],
|
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_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],
|
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))]
|
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]
|
arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0]
|
||||||
minx, miny, maxx, maxy = arc_poly.bounds
|
minx, miny, maxx, maxy = arc_poly.bounds
|
||||||
bbox_poly = box(minx, miny, maxx, maxy)
|
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
|
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(
|
def _apply_collision_model(
|
||||||
arc_poly: Polygon,
|
arc_poly: Polygon,
|
||||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
|
collision_type: BendCollisionModel,
|
||||||
radius: float,
|
radius: float,
|
||||||
width: float,
|
width: float,
|
||||||
cxy: tuple[float, float],
|
cxy: tuple[float, float],
|
||||||
clip_margin: float,
|
|
||||||
ts: tuple[float, float],
|
ts: tuple[float, float],
|
||||||
|
clip_margin: float | None = None,
|
||||||
|
rotation_deg: float = 0.0,
|
||||||
|
mirror_y: bool = False,
|
||||||
) -> list[Polygon]:
|
) -> list[Polygon]:
|
||||||
if isinstance(collision_type, 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":
|
if collision_type == "arc":
|
||||||
return [arc_poly]
|
return [arc_poly]
|
||||||
if collision_type == "clipped_bbox":
|
if collision_type == "clipped_bbox":
|
||||||
|
|
@ -179,21 +244,31 @@ class Straight:
|
||||||
poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y))
|
poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y))
|
||||||
geometry = [Polygon(poly_points)]
|
geometry = [Polygon(poly_points)]
|
||||||
|
|
||||||
dilated_geometry = None
|
|
||||||
if dilation > 0:
|
if dilation > 0:
|
||||||
half_w_d = half_w + dilation
|
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))
|
poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y))
|
||||||
dilated_geometry = [Polygon(poly_points_d)]
|
dilated_geometry = [Polygon(poly_points_d)]
|
||||||
|
else:
|
||||||
|
dilated_geometry = geometry
|
||||||
|
|
||||||
return ComponentResult(
|
return ComponentResult(
|
||||||
geometry=geometry,
|
start_port=start_port,
|
||||||
|
collision_geometry=geometry,
|
||||||
end_port=end_port,
|
end_port=end_port,
|
||||||
length=abs(length_f),
|
length=abs(length_f),
|
||||||
move_type="Straight",
|
move_type="straight",
|
||||||
dilated_geometry=dilated_geometry,
|
move_spec=StraightSeed(length=length_f),
|
||||||
actual_geometry=geometry,
|
physical_geometry=geometry,
|
||||||
dilated_actual_geometry=dilated_geometry,
|
dilated_collision_geometry=dilated_geometry,
|
||||||
|
dilated_physical_geometry=dilated_geometry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -205,8 +280,8 @@ class Bend90:
|
||||||
width: float,
|
width: float,
|
||||||
direction: Literal["CW", "CCW"],
|
direction: Literal["CW", "CCW"],
|
||||||
sagitta: float = 0.01,
|
sagitta: float = 0.01,
|
||||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
|
collision_type: BendCollisionModel = "arc",
|
||||||
clip_margin: float = 10.0,
|
clip_margin: float | None = None,
|
||||||
dilation: float = 0.0,
|
dilation: float = 0.0,
|
||||||
) -> ComponentResult:
|
) -> ComponentResult:
|
||||||
rot2 = rotation_matrix2(start_port.r)
|
rot2 = rotation_matrix2(start_port.r)
|
||||||
|
|
@ -229,37 +304,39 @@ class Bend90:
|
||||||
radius,
|
radius,
|
||||||
width,
|
width,
|
||||||
(float(center_xy[0]), float(center_xy[1])),
|
(float(center_xy[0]), float(center_xy[1])),
|
||||||
clip_margin,
|
|
||||||
ts,
|
ts,
|
||||||
|
clip_margin=clip_margin,
|
||||||
|
rotation_deg=float(start_port.r),
|
||||||
|
mirror_y=(sign < 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
proxy_geometry = None
|
physical_geometry = arc_polys
|
||||||
if collision_type == "arc":
|
if dilation > 0:
|
||||||
proxy_geometry = _apply_collision_model(
|
dilated_physical_geometry = _get_arc_polygons(
|
||||||
arc_polys[0],
|
(float(center_xy[0]), float(center_xy[1])),
|
||||||
"clipped_bbox",
|
|
||||||
radius,
|
radius,
|
||||||
width,
|
width,
|
||||||
(float(center_xy[0]), float(center_xy[1])),
|
|
||||||
clip_margin,
|
|
||||||
ts,
|
ts,
|
||||||
|
sagitta,
|
||||||
|
dilation=dilation,
|
||||||
)
|
)
|
||||||
|
dilated_collision_geometry = (
|
||||||
dilated_actual_geometry = None
|
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
|
||||||
dilated_geometry = None
|
)
|
||||||
if dilation > 0:
|
else:
|
||||||
dilated_actual_geometry = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta, dilation=dilation)
|
dilated_physical_geometry = physical_geometry
|
||||||
dilated_geometry = dilated_actual_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
|
dilated_collision_geometry = collision_polys
|
||||||
|
|
||||||
return ComponentResult(
|
return ComponentResult(
|
||||||
geometry=collision_polys,
|
start_port=start_port,
|
||||||
|
collision_geometry=collision_polys,
|
||||||
end_port=end_port,
|
end_port=end_port,
|
||||||
length=abs(radius) * numpy.pi / 2.0,
|
length=abs(radius) * numpy.pi / 2.0,
|
||||||
move_type="Bend90",
|
move_type="bend90",
|
||||||
dilated_geometry=dilated_geometry,
|
move_spec=Bend90Seed(radius=radius, direction=direction),
|
||||||
proxy_geometry=proxy_geometry,
|
physical_geometry=physical_geometry,
|
||||||
actual_geometry=arc_polys,
|
dilated_collision_geometry=dilated_collision_geometry,
|
||||||
dilated_actual_geometry=dilated_actual_geometry,
|
dilated_physical_geometry=dilated_physical_geometry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -271,8 +348,8 @@ class SBend:
|
||||||
radius: float,
|
radius: float,
|
||||||
width: float,
|
width: float,
|
||||||
sagitta: float = 0.01,
|
sagitta: float = 0.01,
|
||||||
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
|
collision_type: BendCollisionModel = "arc",
|
||||||
clip_margin: float = 10.0,
|
clip_margin: float | None = None,
|
||||||
dilation: float = 0.0,
|
dilation: float = 0.0,
|
||||||
) -> ComponentResult:
|
) -> ComponentResult:
|
||||||
if abs(offset) >= 2 * radius:
|
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]
|
arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0]
|
||||||
actual_geometry = [arc1, arc2]
|
actual_geometry = [arc1, arc2]
|
||||||
geometry = [
|
geometry = [
|
||||||
_apply_collision_model(arc1, collision_type, radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0],
|
_apply_collision_model(
|
||||||
_apply_collision_model(arc2, collision_type, radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0],
|
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
|
physical_geometry = actual_geometry
|
||||||
if collision_type == "arc":
|
|
||||||
proxy_geometry = [
|
|
||||||
_apply_collision_model(arc1, "clipped_bbox", radius, width, (float(c1_xy[0]), float(c1_xy[1])), clip_margin, ts1)[0],
|
|
||||||
_apply_collision_model(arc2, "clipped_bbox", radius, width, (float(c2_xy[0]), float(c2_xy[1])), clip_margin, ts2)[0],
|
|
||||||
]
|
|
||||||
|
|
||||||
dilated_actual_geometry = None
|
|
||||||
dilated_geometry = None
|
|
||||||
if dilation > 0:
|
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(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],
|
_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(
|
return ComponentResult(
|
||||||
geometry=geometry,
|
start_port=start_port,
|
||||||
|
collision_geometry=geometry,
|
||||||
end_port=end_port,
|
end_port=end_port,
|
||||||
length=2.0 * radius * theta,
|
length=2.0 * radius * theta,
|
||||||
move_type="SBend",
|
move_type="sbend",
|
||||||
dilated_geometry=dilated_geometry,
|
move_spec=SBendSeed(offset=offset, radius=radius),
|
||||||
proxy_geometry=proxy_geometry,
|
physical_geometry=physical_geometry,
|
||||||
actual_geometry=actual_geometry,
|
dilated_collision_geometry=dilated_collision_geometry,
|
||||||
dilated_actual_geometry=dilated_actual_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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterator
|
from dataclasses import dataclass
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
|
||||||
def _normalize_angle(angle_deg: int | float) -> int:
|
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}")
|
raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}")
|
||||||
return angle
|
return angle
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
def _as_int32_triplet(value: ArrayLike) -> NDArray[numpy.int32]:
|
|
||||||
arr = numpy.asarray(value, dtype=numpy.int32)
|
|
||||||
if arr.shape != (3,):
|
|
||||||
raise ValueError(f"Port array must have shape (3,), got {arr.shape}")
|
|
||||||
arr = arr.copy()
|
|
||||||
arr[2] = _normalize_angle(int(arr[2]))
|
|
||||||
return arr
|
|
||||||
|
|
||||||
|
|
||||||
class Port:
|
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:
|
def __post_init__(self) -> None:
|
||||||
self._xyr = numpy.array(
|
object.__setattr__(self, "x", int(round(self.x)))
|
||||||
(int(round(x)), int(round(y)), _normalize_angle(r)),
|
object.__setattr__(self, "y", int(round(self.y)))
|
||||||
dtype=numpy.int32,
|
object.__setattr__(self, "r", _normalize_angle(self.r))
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_array(cls, xyr: ArrayLike) -> Self:
|
|
||||||
obj = cls.__new__(cls)
|
|
||||||
obj._xyr = _as_int32_triplet(xyr)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
@property
|
|
||||||
def x(self) -> int:
|
|
||||||
return int(self._xyr[0])
|
|
||||||
|
|
||||||
@x.setter
|
|
||||||
def x(self, val: int | float) -> None:
|
|
||||||
self._xyr[0] = int(round(val))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def y(self) -> int:
|
|
||||||
return int(self._xyr[1])
|
|
||||||
|
|
||||||
@y.setter
|
|
||||||
def y(self, val: int | float) -> None:
|
|
||||||
self._xyr[1] = int(round(val))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def r(self) -> int:
|
|
||||||
return int(self._xyr[2])
|
|
||||||
|
|
||||||
@r.setter
|
|
||||||
def r(self, val: int | float) -> None:
|
|
||||||
self._xyr[2] = _normalize_angle(val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def orientation(self) -> int:
|
|
||||||
return self.r
|
|
||||||
|
|
||||||
@orientation.setter
|
|
||||||
def orientation(self, val: int | float) -> None:
|
|
||||||
self.r = val
|
|
||||||
|
|
||||||
@property
|
|
||||||
def xyr(self) -> NDArray[numpy.int32]:
|
|
||||||
return self._xyr
|
|
||||||
|
|
||||||
@xyr.setter
|
|
||||||
def xyr(self, val: ArrayLike) -> None:
|
|
||||||
self._xyr = _as_int32_triplet(val)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Port(x={self.x}, y={self.y}, r={self.r})"
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[int]:
|
|
||||||
return iter((self.x, self.y, self.r))
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return 3
|
|
||||||
|
|
||||||
def __getitem__(self, item: int | slice) -> int | NDArray[numpy.int32]:
|
|
||||||
return self._xyr[item]
|
|
||||||
|
|
||||||
def __array__(self, dtype: numpy.dtype | None = None) -> NDArray[numpy.int32]:
|
|
||||||
return numpy.asarray(self._xyr, dtype=dtype)
|
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
|
||||||
if not isinstance(other, Port):
|
|
||||||
return False
|
|
||||||
return bool(numpy.array_equal(self._xyr, other._xyr))
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
|
||||||
return hash(self.as_tuple())
|
|
||||||
|
|
||||||
def copy(self) -> Self:
|
|
||||||
return type(self).from_array(self._xyr.copy())
|
|
||||||
|
|
||||||
def as_tuple(self) -> tuple[int, int, int]:
|
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:
|
def translate(
|
||||||
dxy_arr = numpy.asarray(dxy, dtype=numpy.int32)
|
self,
|
||||||
if dxy_arr.shape == (2,):
|
dx: int | float = 0,
|
||||||
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r)
|
dy: int | float = 0,
|
||||||
if dxy_arr.shape == (3,):
|
rotation: int | float = 0,
|
||||||
return type(self)(self.x + int(dxy_arr[0]), self.y + int(dxy_arr[1]), self.r + int(dxy_arr[2]))
|
) -> Self:
|
||||||
raise ValueError(f"Translation must have shape (2,) or (3,), got {dxy_arr.shape}")
|
return type(self)(self.x + dx, self.y + dy, self.r + rotation)
|
||||||
|
|
||||||
def __add__(self, other: ArrayLike) -> Self:
|
def rotated(
|
||||||
return self.translate(other)
|
self,
|
||||||
|
angle: int | float,
|
||||||
def __sub__(self, other: ArrayLike | Self) -> NDArray[numpy.int32]:
|
origin: tuple[int | float, int | float] = (0, 0),
|
||||||
if isinstance(other, Port):
|
) -> Self:
|
||||||
return self._xyr - other._xyr
|
angle_i = _normalize_angle(angle)
|
||||||
return self._xyr - numpy.asarray(other, dtype=numpy.int32)
|
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)
|
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]:
|
def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]:
|
||||||
quadrant = (_normalize_angle(rotation_deg) // 90) % 4
|
quadrant = (_normalize_angle(rotation_deg) // 90) % 4
|
||||||
return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant]
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from inire.constants import TOLERANCE_LINEAR
|
from inire.constants import TOLERANCE_LINEAR
|
||||||
from inire.router.config import CostConfig
|
from inire.model import ObjectiveWeights
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from shapely.geometry import Polygon
|
from inire.geometry.collision import RoutingWorld
|
||||||
|
from inire.geometry.components import ComponentResult, MoveKind
|
||||||
from inire.geometry.collision import CollisionEngine
|
|
||||||
from inire.geometry.primitives import Port
|
from inire.geometry.primitives import Port
|
||||||
from inire.router.danger_map import DangerMap
|
from inire.router.danger_map import DangerMap
|
||||||
|
|
||||||
|
|
@ -19,63 +18,55 @@ class CostEvaluator:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"collision_engine",
|
"collision_engine",
|
||||||
"danger_map",
|
"danger_map",
|
||||||
"config",
|
"_search_weights",
|
||||||
"unit_length_cost",
|
"_greedy_h_weight",
|
||||||
"greedy_h_weight",
|
|
||||||
"congestion_penalty",
|
|
||||||
"_target_x",
|
"_target_x",
|
||||||
"_target_y",
|
"_target_y",
|
||||||
"_target_r",
|
"_target_r",
|
||||||
"_target_cos",
|
"_target_cos",
|
||||||
"_target_sin",
|
"_target_sin",
|
||||||
"_min_radius",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
collision_engine: CollisionEngine,
|
collision_engine: RoutingWorld,
|
||||||
danger_map: DangerMap | None = None,
|
danger_map: DangerMap | None = None,
|
||||||
unit_length_cost: float = 1.0,
|
unit_length_cost: float = 1.0,
|
||||||
greedy_h_weight: float = 1.5,
|
greedy_h_weight: float = 1.5,
|
||||||
congestion_penalty: float = 10000.0,
|
|
||||||
bend_penalty: float = 250.0,
|
bend_penalty: float = 250.0,
|
||||||
sbend_penalty: float | None = None,
|
sbend_penalty: float | None = None,
|
||||||
min_bend_radius: float = 50.0,
|
danger_weight: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
|
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
|
||||||
self.collision_engine = collision_engine
|
self.collision_engine = collision_engine
|
||||||
self.danger_map = danger_map
|
self.danger_map = danger_map
|
||||||
self.config = CostConfig(
|
self._search_weights = ObjectiveWeights(
|
||||||
unit_length_cost=unit_length_cost,
|
unit_length_cost=unit_length_cost,
|
||||||
greedy_h_weight=greedy_h_weight,
|
|
||||||
congestion_penalty=congestion_penalty,
|
|
||||||
bend_penalty=bend_penalty,
|
bend_penalty=bend_penalty,
|
||||||
sbend_penalty=actual_sbend_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 = float(greedy_h_weight)
|
||||||
self.greedy_h_weight = self.config.greedy_h_weight
|
|
||||||
self.congestion_penalty = self.config.congestion_penalty
|
|
||||||
self._refresh_cached_config()
|
|
||||||
self._target_x = 0.0
|
self._target_x = 0.0
|
||||||
self._target_y = 0.0
|
self._target_y = 0.0
|
||||||
self._target_r = 0
|
self._target_r = 0
|
||||||
self._target_cos = 1.0
|
self._target_cos = 1.0
|
||||||
self._target_sin = 0.0
|
self._target_sin = 0.0
|
||||||
|
|
||||||
def _refresh_cached_config(self) -> None:
|
@property
|
||||||
if hasattr(self.config, "min_bend_radius"):
|
def default_weights(self) -> ObjectiveWeights:
|
||||||
self._min_radius = self.config.min_bend_radius
|
return self._search_weights
|
||||||
elif hasattr(self.config, "bend_radii") and self.config.bend_radii:
|
|
||||||
self._min_radius = min(self.config.bend_radii)
|
@property
|
||||||
else:
|
def greedy_h_weight(self) -> float:
|
||||||
self._min_radius = 50.0
|
return self._greedy_h_weight
|
||||||
if hasattr(self.config, "unit_length_cost"):
|
|
||||||
self.unit_length_cost = self.config.unit_length_cost
|
@greedy_h_weight.setter
|
||||||
if hasattr(self.config, "greedy_h_weight"):
|
def greedy_h_weight(self, value: float) -> None:
|
||||||
self.greedy_h_weight = self.config.greedy_h_weight
|
self._greedy_h_weight = float(value)
|
||||||
if hasattr(self.config, "congestion_penalty"):
|
|
||||||
self.congestion_penalty = self.config.congestion_penalty
|
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:
|
def set_target(self, target: Port) -> None:
|
||||||
self._target_x = target.x
|
self._target_x = target.x
|
||||||
|
|
@ -85,12 +76,13 @@ class CostEvaluator:
|
||||||
self._target_cos = np.cos(rad)
|
self._target_cos = np.cos(rad)
|
||||||
self._target_sin = np.sin(rad)
|
self._target_sin = np.sin(rad)
|
||||||
|
|
||||||
def g_proximity(self, x: float, y: float) -> float:
|
def h_manhattan(
|
||||||
if self.danger_map is None:
|
self,
|
||||||
return 0.0
|
current: Port,
|
||||||
return self.danger_map.get_cost(x, y)
|
target: Port,
|
||||||
|
*,
|
||||||
def h_manhattan(self, current: Port, target: Port) -> float:
|
min_bend_radius: float = 50.0,
|
||||||
|
) -> float:
|
||||||
tx, ty = target.x, target.y
|
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:
|
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)
|
self.set_target(target)
|
||||||
|
|
@ -98,7 +90,7 @@ class CostEvaluator:
|
||||||
dx = abs(current.x - tx)
|
dx = abs(current.x - tx)
|
||||||
dy = abs(current.y - ty)
|
dy = abs(current.y - ty)
|
||||||
dist = dx + dy
|
dist = dx + dy
|
||||||
bp = self.config.bend_penalty
|
bp = self._search_weights.bend_penalty
|
||||||
penalty = 0.0
|
penalty = 0.0
|
||||||
|
|
||||||
curr_r = current.r
|
curr_r = current.r
|
||||||
|
|
@ -110,7 +102,7 @@ class CostEvaluator:
|
||||||
v_dy = ty - current.y
|
v_dy = ty - current.y
|
||||||
side_proj = v_dx * self._target_cos + v_dy * self._target_sin
|
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)
|
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
|
penalty += 2 * bp
|
||||||
|
|
||||||
if curr_r == 0:
|
if curr_r == 0:
|
||||||
|
|
@ -128,55 +120,74 @@ class CostEvaluator:
|
||||||
if diff == 0 and perp_dist > 0:
|
if diff == 0 and perp_dist > 0:
|
||||||
penalty += 2 * bp
|
penalty += 2 * bp
|
||||||
|
|
||||||
return self.greedy_h_weight * (dist + penalty)
|
return self._greedy_h_weight * (dist + penalty)
|
||||||
|
|
||||||
def evaluate_move(
|
def score_component(
|
||||||
self,
|
self,
|
||||||
geometry: list[Polygon] | None,
|
component: ComponentResult,
|
||||||
end_port: Port,
|
*,
|
||||||
net_width: float,
|
|
||||||
net_id: str,
|
|
||||||
start_port: Port | None = None,
|
start_port: Port | None = None,
|
||||||
length: float = 0.0,
|
weights: ObjectiveWeights | None = None,
|
||||||
dilated_geometry: list[Polygon] | None = None,
|
|
||||||
skip_static: bool = False,
|
|
||||||
skip_congestion: bool = False,
|
|
||||||
penalty: float = 0.0,
|
|
||||||
) -> float:
|
) -> float:
|
||||||
_ = net_width
|
active_weights = self._resolve_weights(weights)
|
||||||
danger_map = self.danger_map
|
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):
|
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
|
||||||
return 1e15
|
return 1e15
|
||||||
|
|
||||||
total_cost = length * self.unit_length_cost + penalty
|
move_radius = None
|
||||||
if not skip_static or not skip_congestion:
|
if component.move_type == "bend90":
|
||||||
if geometry is None:
|
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
|
||||||
return 1e15
|
total_cost = component.length * active_weights.unit_length_cost + self.component_penalty(
|
||||||
collision_engine = self.collision_engine
|
component.move_type,
|
||||||
for i, poly in enumerate(geometry):
|
move_radius=move_radius,
|
||||||
dil_poly = dilated_geometry[i] if dilated_geometry else None
|
weights=active_weights,
|
||||||
if not skip_static and collision_engine.check_collision(
|
)
|
||||||
poly,
|
|
||||||
net_id,
|
|
||||||
buffer_mode="static",
|
|
||||||
start_port=start_port,
|
|
||||||
end_port=end_port,
|
|
||||||
dilated_geometry=dil_poly,
|
|
||||||
):
|
|
||||||
return 1e15
|
|
||||||
if not skip_congestion:
|
|
||||||
overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly)
|
|
||||||
if isinstance(overlaps, int) and overlaps > 0:
|
|
||||||
total_cost += overlaps * self.congestion_penalty
|
|
||||||
|
|
||||||
if danger_map is not None:
|
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_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)
|
cost_e = danger_map.get_cost(end_port.x, end_port.y)
|
||||||
if start_port:
|
if start_port:
|
||||||
mid_x = (start_port.x + end_port.x) / 2.0
|
mid_x = (start_port.x + end_port.x) / 2.0
|
||||||
mid_y = (start_port.y + end_port.y) / 2.0
|
mid_y = (start_port.y + end_port.y) / 2.0
|
||||||
cost_m = danger_map.get_cost(mid_x, mid_y)
|
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:
|
else:
|
||||||
total_cost += length * cost_e
|
total_cost += component.length * active_weights.danger_weight * cost_e
|
||||||
return total_cost
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
import shapely
|
|
||||||
from scipy.spatial import cKDTree
|
from scipy.spatial import cKDTree
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
|
|
||||||
|
_COST_CACHE_SIZE = 100000
|
||||||
|
|
||||||
|
|
||||||
class DangerMap:
|
class DangerMap:
|
||||||
"""
|
"""
|
||||||
A proximity cost evaluator using a KD-Tree of obstacle boundary points.
|
A proximity cost evaluator using a KD-Tree of obstacle boundary points.
|
||||||
Scales with obstacle perimeter rather than design area.
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -38,6 +41,7 @@ class DangerMap:
|
||||||
self.safety_threshold = safety_threshold
|
self.safety_threshold = safety_threshold
|
||||||
self.k = k
|
self.k = k
|
||||||
self.tree: cKDTree | None = None
|
self.tree: cKDTree | None = None
|
||||||
|
self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict()
|
||||||
|
|
||||||
def precompute(self, obstacles: list[Polygon]) -> None:
|
def precompute(self, obstacles: list[Polygon]) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -65,8 +69,7 @@ class DangerMap:
|
||||||
else:
|
else:
|
||||||
self.tree = None
|
self.tree = None
|
||||||
|
|
||||||
# Clear cache when tree changes
|
self._cost_cache.clear()
|
||||||
self._get_cost_quantized.cache_clear()
|
|
||||||
|
|
||||||
def is_within_bounds(self, x: float, y: float) -> bool:
|
def is_within_bounds(self, x: float, y: float) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -81,10 +84,18 @@ class DangerMap:
|
||||||
"""
|
"""
|
||||||
qx_milli = int(round(x * 1000))
|
qx_milli = int(round(x * 1000))
|
||||||
qy_milli = int(round(y * 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)
|
cost = self._compute_cost_quantized(qx_milli, qy_milli)
|
||||||
def _get_cost_quantized(self, qx_milli: int, qy_milli: int) -> float:
|
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
|
qx = qx_milli / 1000.0
|
||||||
qy = qy_milli / 1000.0
|
qy = qy_milli / 1000.0
|
||||||
if not self.is_within_bounds(qx, qy):
|
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
|
import numpy
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import rtree
|
import rtree
|
||||||
from shapely.geometry import Point, LineString
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
||||||
|
|
||||||
|
|
||||||
from inire.geometry.primitives import Port
|
from inire.geometry.primitives import Port
|
||||||
|
|
||||||
|
|
||||||
class VisibilityManager:
|
class VisibilityManager:
|
||||||
"""
|
"""
|
||||||
Manages corners of static obstacles for sparse A* / Visibility Graph jumps.
|
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.collision_engine = collision_engine
|
||||||
self.corners: list[tuple[float, float]] = []
|
self.corners: list[tuple[float, float]] = []
|
||||||
self.corner_index = rtree.index.Index()
|
self.corner_index = rtree.index.Index()
|
||||||
self._corner_graph: dict[int, list[tuple[float, float, float]]] = {}
|
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._built_static_version = -1
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
|
|
@ -34,20 +34,20 @@ class VisibilityManager:
|
||||||
self.corners = []
|
self.corners = []
|
||||||
self.corner_index = rtree.index.Index()
|
self.corner_index = rtree.index.Index()
|
||||||
self._corner_graph = {}
|
self._corner_graph = {}
|
||||||
self._static_visibility_cache = {}
|
self._point_visibility_cache = {}
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
def _ensure_current(self) -> None:
|
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()
|
self.clear_cache()
|
||||||
|
|
||||||
def _build(self) -> None:
|
def _build(self) -> None:
|
||||||
"""
|
"""
|
||||||
Extract corners and pre-compute corner-to-corner visibility.
|
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 = []
|
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)
|
coords = list(poly.exterior.coords)
|
||||||
if coords[0] == coords[-1]:
|
if coords[0] == coords[-1]:
|
||||||
coords = coords[:-1]
|
coords = coords[:-1]
|
||||||
|
|
@ -83,7 +83,8 @@ class VisibilityManager:
|
||||||
self._corner_graph[i] = []
|
self._corner_graph[i] = []
|
||||||
p1 = Port(self.corners[i][0], self.corners[i][1], 0)
|
p1 = Port(self.corners[i][0], self.corners[i][1], 0)
|
||||||
for j in range(num_corners):
|
for j in range(num_corners):
|
||||||
if i == j: continue
|
if i == j:
|
||||||
|
continue
|
||||||
cx, cy = self.corners[j]
|
cx, cy = self.corners[j]
|
||||||
dx, dy = cx - p1.x, cy - p1.y
|
dx, dy = cx - p1.x, cy - p1.y
|
||||||
dist = numpy.sqrt(dx**2 + dy**2)
|
dist = numpy.sqrt(dx**2 + dy**2)
|
||||||
|
|
@ -92,35 +93,33 @@ class VisibilityManager:
|
||||||
if reach >= dist - 0.01:
|
if reach >= dist - 0.01:
|
||||||
self._corner_graph[i].append((cx, cy, dist))
|
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]]:
|
def _corner_idx_at(self, origin: Port) -> int | None:
|
||||||
|
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
||||||
|
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
|
||||||
|
for idx in nearby:
|
||||||
|
cx, cy = self.corners[idx]
|
||||||
|
if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4:
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
|
||||||
"""
|
"""
|
||||||
Find all corners visible from the origin.
|
Find visible corners from an arbitrary point.
|
||||||
Returns list of (x, y, distance).
|
This may perform direct ray-cast scans and is not intended for hot search paths.
|
||||||
"""
|
"""
|
||||||
self._ensure_current()
|
self._ensure_current()
|
||||||
if max_dist < 0:
|
if max_dist < 0:
|
||||||
return []
|
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)
|
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
||||||
|
cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000)))
|
||||||
|
if cache_key in self._point_visibility_cache:
|
||||||
|
return self._point_visibility_cache[cache_key]
|
||||||
|
|
||||||
# 1. Exact corner check
|
|
||||||
# Use spatial index to find if origin is AT a corner
|
|
||||||
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
|
|
||||||
for idx in nearby:
|
|
||||||
cx, cy = self.corners[idx]
|
|
||||||
if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4:
|
|
||||||
# We are at a corner! Return pre-computed graph (filtered by max_dist)
|
|
||||||
if idx in self._corner_graph:
|
|
||||||
return [c for c in self._corner_graph[idx] if c[2] <= max_dist]
|
|
||||||
|
|
||||||
# 2. Cache check for arbitrary points
|
|
||||||
# Grid-based caching for arbitrary points is tricky,
|
|
||||||
# but since static obstacles don't change, we can cache exact coordinates.
|
|
||||||
cache_key = (int(ox * 1000), int(oy * 1000))
|
|
||||||
if cache_key in self._static_visibility_cache:
|
|
||||||
return self._static_visibility_cache[cache_key]
|
|
||||||
|
|
||||||
# 3. Full visibility check
|
|
||||||
bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist)
|
bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist)
|
||||||
candidates = list(self.corner_index.intersection(bounds))
|
candidates = list(self.corner_index.intersection(bounds))
|
||||||
|
|
||||||
|
|
@ -138,7 +137,7 @@ class VisibilityManager:
|
||||||
if reach >= dist - 0.01:
|
if reach >= dist - 0.01:
|
||||||
visible.append((cx, cy, dist))
|
visible.append((cx, cy, dist))
|
||||||
|
|
||||||
self._static_visibility_cache[cache_key] = visible
|
self._point_visibility_cache[cache_key] = visible
|
||||||
return visible
|
return visible
|
||||||
|
|
||||||
def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
|
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:
|
if max_dist < 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
ox, oy = round(origin.x, 3), round(origin.y, 3)
|
corner_idx = self._corner_idx_at(origin)
|
||||||
nearby = list(self.corner_index.intersection((ox - 0.001, oy - 0.001, ox + 0.001, oy + 0.001)))
|
if corner_idx is not None and corner_idx in self._corner_graph:
|
||||||
for idx in nearby:
|
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
|
||||||
cx, cy = self.corners[idx]
|
|
||||||
if abs(cx - ox) < 1e-4 and abs(cy - oy) < 1e-4 and idx in self._corner_graph:
|
|
||||||
return [corner for corner in self._corner_graph[idx] if corner[2] <= max_dist]
|
|
||||||
return []
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from shapely.geometry import Polygon, box
|
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.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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
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)
|
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
|
||||||
class ScenarioOutcome:
|
return (
|
||||||
duration_s: float
|
duration_s,
|
||||||
total_results: int
|
len(results),
|
||||||
valid_results: int
|
sum(1 for result in results.values() if result.is_valid),
|
||||||
reached_targets: int
|
sum(1 for result in results.values() if result.reached_target),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
def _build_evaluator(
|
||||||
class ScenarioDefinition:
|
bounds: tuple[float, float, float, float],
|
||||||
name: str
|
*,
|
||||||
run: Callable[[], ScenarioOutcome]
|
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],
|
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,
|
clearance: float = 2.0,
|
||||||
obstacles: list[Polygon] | None = None,
|
obstacles: list[Polygon] | None = None,
|
||||||
evaluator_kwargs: dict[str, float] | None = None,
|
evaluator_kwargs: dict[str, float] | None = None,
|
||||||
context_kwargs: dict[str, object] | None = None,
|
request_kwargs: dict[str, object] | None = None,
|
||||||
pathfinder_kwargs: dict[str, object] | None = None,
|
) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]:
|
||||||
) -> tuple[CollisionEngine, CostEvaluator, AStarContext, AStarMetrics, PathFinder]:
|
|
||||||
static_obstacles = obstacles or []
|
static_obstacles = obstacles or []
|
||||||
engine = CollisionEngine(clearance=clearance)
|
engine = RoutingWorld(clearance=clearance)
|
||||||
for obstacle in static_obstacles:
|
for obstacle in static_obstacles:
|
||||||
engine.add_static_obstacle(obstacle)
|
engine.add_static_obstacle(obstacle)
|
||||||
|
|
||||||
|
|
@ -46,107 +122,126 @@ def _build_router(
|
||||||
danger_map.precompute(static_obstacles)
|
danger_map.precompute(static_obstacles)
|
||||||
|
|
||||||
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
|
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
|
||||||
context = AStarContext(evaluator, **(context_kwargs or {}))
|
|
||||||
metrics = AStarMetrics()
|
metrics = AStarMetrics()
|
||||||
pathfinder = PathFinder(context, metrics, **(pathfinder_kwargs or {}))
|
pathfinder = _build_pathfinder(
|
||||||
return engine, evaluator, context, metrics, pathfinder
|
evaluator,
|
||||||
|
bounds=bounds,
|
||||||
|
nets=_net_specs(netlist, widths),
|
||||||
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
|
metrics=metrics,
|
||||||
return ScenarioOutcome(
|
**(request_kwargs or {}),
|
||||||
duration_s=duration_s,
|
|
||||||
total_results=len(results),
|
|
||||||
valid_results=sum(1 for result in results.values() if result.is_valid),
|
|
||||||
reached_targets=sum(1 for result in results.values() if result.reached_target),
|
|
||||||
)
|
)
|
||||||
|
return engine, evaluator, metrics, pathfinder
|
||||||
|
|
||||||
|
|
||||||
def run_example_01() -> ScenarioOutcome:
|
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))}
|
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()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(netlist, {"net1": 2.0})
|
results = pathfinder.route_all()
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
def run_example_02() -> ScenarioOutcome:
|
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 = {
|
netlist = {
|
||||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||||
}
|
}
|
||||||
widths = {net_id: 2.0 for net_id in netlist}
|
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()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(netlist, widths)
|
results = pathfinder.route_all()
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
def run_example_03() -> ScenarioOutcome:
|
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()
|
t0 = perf_counter()
|
||||||
results_a = pathfinder.route_all({"netA": (Port(10, 0, 0), Port(90, 0, 0))}, {"netA": 2.0})
|
results_a = pathfinder.route_all()
|
||||||
engine.lock_net("netA")
|
for polygon in results_a["netA"].locked_geometry:
|
||||||
results_b = pathfinder.route_all({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0})
|
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()
|
t1 = perf_counter()
|
||||||
return _summarize({**results_a, **results_b}, t1 - t0)
|
return _summarize({**results_a, **results_b}, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
def run_example_04() -> ScenarioOutcome:
|
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 = {
|
netlist = {
|
||||||
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
|
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
|
||||||
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
|
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
|
||||||
}
|
}
|
||||||
widths = {"sbend_only": 2.0, "multi_radii": 2.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()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(netlist, widths)
|
results = pathfinder.route_all()
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
def run_example_05() -> ScenarioOutcome:
|
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 = {
|
netlist = {
|
||||||
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
|
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
|
||||||
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
|
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
|
||||||
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
|
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
|
||||||
}
|
}
|
||||||
widths = {net_id: 2.0 for net_id in netlist}
|
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()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(netlist, widths)
|
results = pathfinder.route_all()
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
@ -158,35 +253,42 @@ def run_example_06() -> ScenarioOutcome:
|
||||||
box(40, 60, 60, 80),
|
box(40, 60, 60, 80),
|
||||||
box(40, 10, 60, 30),
|
box(40, 10, 60, 30),
|
||||||
]
|
]
|
||||||
engine = CollisionEngine(clearance=2.0)
|
scenarios = [
|
||||||
for obstacle in obstacles:
|
(
|
||||||
engine.add_static_obstacle(obstacle)
|
_build_evaluator(bounds, obstacles=obstacles),
|
||||||
|
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
|
||||||
danger_map = DangerMap(bounds=bounds)
|
{"arc_model": 2.0},
|
||||||
danger_map.precompute(obstacles)
|
{"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False},
|
||||||
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
),
|
||||||
|
(
|
||||||
contexts = [
|
_build_evaluator(bounds, obstacles=obstacles),
|
||||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="arc"),
|
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
|
||||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="bbox"),
|
{"bbox_model": 2.0},
|
||||||
AStarContext(evaluator, bend_radii=[10.0], bend_collision_type="clipped_bbox", bend_clip_margin=1.0),
|
{"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False},
|
||||||
]
|
),
|
||||||
netlists = [
|
(
|
||||||
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
|
_build_evaluator(bounds, obstacles=obstacles),
|
||||||
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
|
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
|
||||||
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
|
{"clipped_model": 2.0},
|
||||||
]
|
{
|
||||||
widths = [
|
"bend_radii": [10.0],
|
||||||
{"arc_model": 2.0},
|
"bend_collision_type": "clipped_bbox",
|
||||||
{"bbox_model": 2.0},
|
"bend_clip_margin": 1.0,
|
||||||
{"clipped_model": 2.0},
|
"use_tiered_strategy": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
t0 = perf_counter()
|
t0 = perf_counter()
|
||||||
combined_results: dict[str, RoutingResult] = {}
|
combined_results: dict[str, RoutingResult] = {}
|
||||||
for context, netlist, net_widths in zip(contexts, netlists, widths, strict=True):
|
for evaluator, netlist, net_widths, request_kwargs in scenarios:
|
||||||
pathfinder = PathFinder(context, use_tiered_strategy=False)
|
pathfinder = _build_pathfinder(
|
||||||
combined_results.update(pathfinder.route_all(netlist, net_widths))
|
evaluator,
|
||||||
|
bounds=bounds,
|
||||||
|
nets=_net_specs(netlist, net_widths),
|
||||||
|
**request_kwargs,
|
||||||
|
)
|
||||||
|
combined_results.update(pathfinder.route_all())
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(combined_results, t1 - t0)
|
return _summarize(combined_results, t1 - t0)
|
||||||
|
|
||||||
|
|
@ -197,29 +299,6 @@ def run_example_07() -> ScenarioOutcome:
|
||||||
box(450, 0, 550, 400),
|
box(450, 0, 550, 400),
|
||||||
box(450, 600, 550, 1000),
|
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
|
num_nets = 10
|
||||||
start_x = 50
|
start_x = 50
|
||||||
start_y_base = 500 - (num_nets * 10.0) / 2.0
|
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))
|
sy = int(round(start_y_base + index * 10.0))
|
||||||
ey = int(round(end_y_base + index * end_y_pitch))
|
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))
|
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:
|
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)
|
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
|
||||||
evaluator.greedy_h_weight = new_greedy
|
evaluator.greedy_h_weight = new_greedy
|
||||||
metrics.reset_per_route()
|
metrics.reset_per_route()
|
||||||
|
|
||||||
t0 = perf_counter()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(
|
results = pathfinder.route_all(iteration_callback=iteration_callback)
|
||||||
netlist,
|
|
||||||
dict.fromkeys(netlist, 2.0),
|
|
||||||
store_expanded=True,
|
|
||||||
iteration_callback=iteration_callback,
|
|
||||||
shuffle_nets=True,
|
|
||||||
seed=42,
|
|
||||||
)
|
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
def run_example_08() -> ScenarioOutcome:
|
def run_example_08() -> ScenarioOutcome:
|
||||||
bounds = (0, 0, 150, 150)
|
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))}
|
netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
|
||||||
widths = {"custom_bend": 2.0}
|
widths = {"custom_bend": 2.0}
|
||||||
|
custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
|
||||||
context_std = AStarContext(evaluator, bend_radii=[10.0], sbend_radii=[])
|
evaluator = _build_evaluator(bounds)
|
||||||
context_custom = AStarContext(
|
|
||||||
evaluator,
|
|
||||||
bend_radii=[10.0],
|
|
||||||
bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]),
|
|
||||||
sbend_radii=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
t0 = perf_counter()
|
t0 = perf_counter()
|
||||||
results_std = PathFinder(context_std, metrics).route_all(netlist, widths)
|
results_std = _build_pathfinder(
|
||||||
results_custom = PathFinder(context_custom, AStarMetrics(), use_tiered_strategy=False).route_all(
|
evaluator,
|
||||||
{"custom_model": netlist["custom_bend"]},
|
bounds=bounds,
|
||||||
{"custom_model": 2.0},
|
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()
|
t1 = perf_counter()
|
||||||
return _summarize({**results_std, **results_custom}, t1 - t0)
|
return _summarize({**results_std, **results_custom}, t1 - t0)
|
||||||
|
|
||||||
|
|
@ -284,28 +388,30 @@ def run_example_09() -> ScenarioOutcome:
|
||||||
box(35, 35, 45, 65),
|
box(35, 35, 45, 65),
|
||||||
box(55, 35, 65, 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),
|
bounds=(0, 0, 100, 100),
|
||||||
|
netlist=netlist,
|
||||||
|
widths=widths,
|
||||||
obstacles=obstacles,
|
obstacles=obstacles,
|
||||||
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
|
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
|
||||||
context_kwargs={"node_limit": 3, "bend_radii": [10.0]},
|
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1},
|
||||||
pathfinder_kwargs={"warm_start": None},
|
|
||||||
)
|
)
|
||||||
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
|
|
||||||
t0 = perf_counter()
|
t0 = perf_counter()
|
||||||
results = pathfinder.route_all(netlist, {"budget_limited_net": 2.0})
|
results = pathfinder.route_all()
|
||||||
t1 = perf_counter()
|
t1 = perf_counter()
|
||||||
return _summarize(results, t1 - t0)
|
return _summarize(results, t1 - t0)
|
||||||
|
|
||||||
|
|
||||||
SCENARIOS: tuple[ScenarioDefinition, ...] = (
|
SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
|
||||||
ScenarioDefinition("example_01_simple_route", run_example_01),
|
("example_01_simple_route", run_example_01),
|
||||||
ScenarioDefinition("example_02_congestion_resolution", run_example_02),
|
("example_02_congestion_resolution", run_example_02),
|
||||||
ScenarioDefinition("example_03_locked_paths", run_example_03),
|
("example_03_locked_paths", run_example_03),
|
||||||
ScenarioDefinition("example_04_sbends_and_radii", run_example_04),
|
("example_04_sbends_and_radii", run_example_04),
|
||||||
ScenarioDefinition("example_05_orientation_stress", run_example_05),
|
("example_05_orientation_stress", run_example_05),
|
||||||
ScenarioDefinition("example_06_bend_collision_models", run_example_06),
|
("example_06_bend_collision_models", run_example_06),
|
||||||
ScenarioDefinition("example_07_large_scale_routing", run_example_07),
|
("example_07_large_scale_routing", run_example_07),
|
||||||
ScenarioDefinition("example_08_custom_bend_geometry", run_example_08),
|
("example_08_custom_bend_geometry", run_example_08),
|
||||||
ScenarioDefinition("example_09_unroutable_best_effort", run_example_09),
|
("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
|
import pytest
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
import inire.router.astar as astar_module
|
from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
|
||||||
from inire.geometry.components import SBend, Straight
|
from inire.geometry.components import Bend90, Straight
|
||||||
from inire.geometry.collision import CollisionEngine
|
from inire.geometry.collision import RoutingWorld
|
||||||
from inire.geometry.primitives import Port
|
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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
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
|
@pytest.fixture
|
||||||
def basic_evaluator() -> CostEvaluator:
|
def basic_evaluator() -> CostEvaluator:
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
danger_map = DangerMap(bounds=(0, -50, 150, 150))
|
danger_map = DangerMap(bounds=BOUNDS)
|
||||||
danger_map.precompute([])
|
danger_map.precompute([])
|
||||||
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
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:
|
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
||||||
context = AStarContext(basic_evaluator)
|
context = _build_context(basic_evaluator, bounds=BOUNDS)
|
||||||
start = Port(0, 0, 0)
|
start = Port(0, 0, 0)
|
||||||
target = Port(50, 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
|
assert path is not None
|
||||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
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["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||||
assert validation["connectivity_ok"]
|
assert validation["connectivity_ok"]
|
||||||
|
|
@ -37,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_astar_bend(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)
|
start = Port(0, 0, 0)
|
||||||
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
|
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
|
||||||
target = Port(20, 20, 0)
|
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
|
assert path is not None
|
||||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
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["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||||
assert validation["connectivity_ok"]
|
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.collision_engine.add_static_obstacle(obstacle)
|
||||||
basic_evaluator.danger_map.precompute([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)
|
start = Port(0, 0, 0)
|
||||||
target = Port(60, 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
|
assert path is not None
|
||||||
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||||
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
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["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||||
# Path should have detoured, so length > 50
|
# 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:
|
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)
|
start = Port(0, 0, 0)
|
||||||
target = Port(10.1, 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
|
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
|
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')}"
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||||
|
|
||||||
|
|
||||||
def test_expand_moves_only_shortens_consecutive_straights(
|
def test_validate_routing_result_checks_expected_start() -> None:
|
||||||
basic_evaluator: CostEvaluator,
|
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||||
) -> None:
|
|
||||||
context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0)
|
validation = _validate_routing_result(
|
||||||
prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
|
result,
|
||||||
current = astar_module.AStarNode(
|
[],
|
||||||
prev_result.end_port,
|
clearance=2.0,
|
||||||
g_cost=prev_result.length,
|
expected_start=Port(0, 0, 0),
|
||||||
h_cost=0.0,
|
expected_end=Port(110, 0, 0),
|
||||||
component_result=prev_result,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
validation = _validate_routing_result(
|
||||||
current,
|
result,
|
||||||
Port(80, 0, 0),
|
[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_width=2.0,
|
||||||
net_id="test",
|
|
||||||
open_set=[],
|
|
||||||
closed_set={},
|
|
||||||
context=context,
|
context=context,
|
||||||
metrics=astar_module.AStarMetrics(),
|
config=SearchRunConfig.from_options(
|
||||||
congestion_cache={},
|
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 context.options.search.bend_collision_type == "arc"
|
||||||
assert straight_lengths
|
|
||||||
assert all(length < prev_result.length for length in straight_lengths)
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_moves_does_not_chain_sbends(
|
def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None:
|
||||||
basic_evaluator: CostEvaluator,
|
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||||
) -> None:
|
basic_evaluator.danger_map.precompute([obstacle])
|
||||||
context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0)
|
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2)
|
||||||
prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0)
|
start = Port(0, 0, 0)
|
||||||
current = astar_module.AStarNode(
|
target = Port(60, 0, 0)
|
||||||
prev_result.end_port,
|
|
||||||
g_cost=prev_result.length,
|
|
||||||
h_cost=0.0,
|
|
||||||
component_result=prev_result,
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
assert partial_path is not None
|
||||||
emitted.append(args[9])
|
assert partial_path
|
||||||
|
assert partial_path[-1].end_port != target
|
||||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
assert no_partial_path is None
|
||||||
|
|
||||||
astar_module.expand_moves(
|
|
||||||
current,
|
|
||||||
Port(60, 10, 0),
|
|
||||||
net_width=2.0,
|
|
||||||
net_id="test",
|
|
||||||
open_set=[],
|
|
||||||
closed_set={},
|
|
||||||
context=context,
|
|
||||||
metrics=astar_module.AStarMetrics(),
|
|
||||||
congestion_cache={},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "SB" not in emitted
|
|
||||||
assert emitted
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_moves_adds_sbend_aligned_straight_stop_points(
|
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
|
||||||
basic_evaluator: CostEvaluator,
|
context = _build_context(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
context = AStarContext(
|
|
||||||
basic_evaluator,
|
basic_evaluator,
|
||||||
bend_radii=[10.0],
|
bounds=BOUNDS,
|
||||||
sbend_radii=[10.0],
|
bend_radii=(10.0,),
|
||||||
|
sbend_radii=(10.0,),
|
||||||
|
sbend_offsets=(10.0,),
|
||||||
max_straight_length=150.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:
|
assert path is not None
|
||||||
emitted.append((args[9], args[10]))
|
assert path[-1].end_port == target
|
||||||
|
assert sum(1 for component in path if component.move_type == "sbend") == 1
|
||||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
assert not any(
|
||||||
|
first.move_type == second.move_type == "sbend"
|
||||||
astar_module.expand_moves(
|
for first, second in zip(path, path[1:], strict=False)
|
||||||
current,
|
|
||||||
Port(100, 10, 0),
|
|
||||||
net_width=2.0,
|
|
||||||
net_id="test",
|
|
||||||
open_set=[],
|
|
||||||
closed_set={},
|
|
||||||
context=context,
|
|
||||||
metrics=astar_module.AStarMetrics(),
|
|
||||||
congestion_cache={},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
|
||||||
sbend_span = astar_module._sbend_forward_span(10.0, 10.0)
|
|
||||||
assert sbend_span is not None
|
|
||||||
assert int(round(100.0 - sbend_span)) in straight_lengths
|
|
||||||
assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"])
|
||||||
def test_expand_moves_adds_exact_corner_visibility_stop_points(
|
def test_route_astar_supports_all_visibility_guidance_modes(
|
||||||
basic_evaluator: CostEvaluator,
|
basic_evaluator: CostEvaluator,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
visibility_guidance: str,
|
||||||
) -> None:
|
) -> 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(
|
context = AStarContext(
|
||||||
basic_evaluator,
|
basic_evaluator,
|
||||||
bend_radii=[10.0],
|
RoutingProblem(bounds=BOUNDS),
|
||||||
max_straight_length=150.0,
|
_build_options(
|
||||||
visibility_guidance="exact_corner",
|
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(
|
for target in targets:
|
||||||
astar_module.VisibilityManager,
|
path = _route(context, start, target)
|
||||||
"get_corner_visibility",
|
assert path is not None
|
||||||
lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)],
|
assert path[-1].end_port == target
|
||||||
)
|
|
||||||
|
|
||||||
emitted: list[tuple[str, tuple]] = []
|
|
||||||
|
|
||||||
def fake_process_move(*args, **kwargs) -> None:
|
|
||||||
emitted.append((args[9], args[10]))
|
|
||||||
|
|
||||||
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
||||||
|
|
||||||
astar_module.expand_moves(
|
|
||||||
current,
|
|
||||||
Port(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
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,41 @@
|
||||||
import pytest
|
import pytest
|
||||||
import numpy
|
import numpy
|
||||||
from shapely.geometry import Polygon
|
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.primitives import Port
|
||||||
from inire.geometry.components import Straight
|
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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
from inire.router.danger_map import DangerMap
|
||||||
from inire.router.astar import AStarContext
|
from inire import RoutingResult
|
||||||
from inire.router.pathfinder import PathFinder, 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():
|
def test_clearance_thresholds():
|
||||||
"""
|
"""
|
||||||
|
|
@ -16,43 +44,41 @@ def test_clearance_thresholds():
|
||||||
"""
|
"""
|
||||||
# Clearance = 2.0, Width = 2.0
|
# Clearance = 2.0, Width = 2.0
|
||||||
# Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.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
|
# Net 1: Centerline at y=0
|
||||||
p1 = Port(0, 0, 0)
|
p1 = Port(0, 0, 0)
|
||||||
res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.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
|
# Net 2: Parallel to Net 1
|
||||||
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
|
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
|
||||||
p2_ok = Port(0, 5, 0)
|
p2_ok = Port(0, 5, 0)
|
||||||
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
|
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
|
||||||
is_v, count = ce.verify_path("net2", [res2_ok])
|
report_ok = ce.verify_path_report("net2", [res2_ok])
|
||||||
assert is_v, f"Gap 3 should be valid, but got {count} collisions"
|
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.
|
# 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK.
|
||||||
p2_exact = Port(0, 4, 0)
|
p2_exact = Port(0, 4, 0)
|
||||||
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
|
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
|
||||||
is_v, count = ce.verify_path("net2", [res2_exact])
|
report_exact = ce.verify_path_report("net2", [res2_exact])
|
||||||
assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions"
|
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.
|
# 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL.
|
||||||
p2_fail = Port(0, 3, 0)
|
p2_fail = Port(0, 3, 0)
|
||||||
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
|
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
|
||||||
is_v, count = ce.verify_path("net2", [res2_fail])
|
report_fail = ce.verify_path_report("net2", [res2_fail])
|
||||||
assert not is_v, "Gap 1.999 should be invalid"
|
assert not report_fail.is_valid, "Gap 1.999 should be invalid"
|
||||||
assert count > 0
|
assert report_fail.collision_count > 0
|
||||||
|
|
||||||
def test_verify_all_nets_cases():
|
def test_verify_all_nets_cases():
|
||||||
"""
|
"""
|
||||||
Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing 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 = DangerMap(bounds=(0, 0, 100, 100))
|
||||||
danger_map.precompute([])
|
danger_map.precompute([])
|
||||||
evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map)
|
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)
|
# Case 1: Parallel paths exactly at clearance (Should be VALID)
|
||||||
netlist_parallel_ok = {
|
netlist_parallel_ok = {
|
||||||
|
|
@ -61,7 +87,13 @@ def test_verify_all_nets_cases():
|
||||||
}
|
}
|
||||||
net_widths = {"net1": 2.0, "net2": 2.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["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
|
||||||
assert results["net2"].is_valid
|
assert results["net2"].is_valid
|
||||||
|
|
||||||
|
|
@ -74,7 +106,13 @@ def test_verify_all_nets_cases():
|
||||||
engine.remove_path("net1")
|
engine.remove_path("net1")
|
||||||
engine.remove_path("net2")
|
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
|
# verify_all_nets should flag both as invalid because they cross-collide
|
||||||
assert not results_p["net3"].is_valid
|
assert not results_p["net3"].is_valid
|
||||||
assert not results_p["net4"].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("net3")
|
||||||
engine.remove_path("net4")
|
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["net5"].is_valid
|
||||||
assert not results_c["net6"].is_valid
|
assert not results_c["net6"].is_valid
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,51 @@
|
||||||
from shapely.geometry import Polygon
|
from inire.geometry.collision import RoutingWorld
|
||||||
|
|
||||||
from inire.geometry.collision import CollisionEngine
|
|
||||||
from inire.geometry.primitives import Port
|
|
||||||
from inire.geometry.components import Straight
|
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:
|
def test_collision_detection() -> None:
|
||||||
# Clearance = 2um
|
engine = RoutingWorld(clearance=2.0)
|
||||||
engine = CollisionEngine(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)
|
direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0)
|
||||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port)
|
||||||
engine.add_static_obstacle(obstacle)
|
|
||||||
|
|
||||||
# 1. Direct hit
|
far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0)
|
||||||
test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)])
|
assert not engine.check_move_static(far_away, start_port=far_away.start_port)
|
||||||
assert engine.is_collision(test_poly, net_width=2.0)
|
|
||||||
|
|
||||||
# 2. Far away
|
near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0)
|
||||||
test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
|
assert engine.check_move_static(near_hit, start_port=near_hit.start_port)
|
||||||
assert not engine.is_collision(test_poly_far, net_width=2.0)
|
|
||||||
|
|
||||||
# 3. Near hit (within clearance)
|
|
||||||
# Obstacle edge at x=10.
|
|
||||||
# test_poly edge at x=9.
|
|
||||||
# Distance = 1.0 um.
|
|
||||||
# Required distance (Wi+C)/2 = 2.0. Collision!
|
|
||||||
test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)])
|
|
||||||
assert engine.is_collision(test_poly_near, net_width=2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_safety_zone() -> None:
|
def test_safety_zone() -> None:
|
||||||
# Use zero clearance for this test to verify the 2nm port safety zone
|
engine = RoutingWorld(clearance=0.0)
|
||||||
# against the physical obstacle boundary.
|
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0)
|
||||||
engine = CollisionEngine(clearance=0.0)
|
|
||||||
|
|
||||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
|
||||||
engine.add_static_obstacle(obstacle)
|
|
||||||
|
|
||||||
# Port exactly on the boundary
|
|
||||||
start_port = Port(10, 12, 0)
|
start_port = Port(10, 12, 0)
|
||||||
|
test_move = Straight.generate(start_port, 0.002, width=0.001)
|
||||||
# Move starting from this port that overlaps the obstacle by 1nm
|
assert not engine.check_move_static(test_move, start_port=start_port)
|
||||||
# (Inside the 2nm safety zone)
|
|
||||||
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
|
|
||||||
|
|
||||||
assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port)
|
|
||||||
|
|
||||||
|
|
||||||
def test_configurable_max_net_width() -> None:
|
|
||||||
# Large max_net_width (10.0) -> large pre-dilation (6.0)
|
|
||||||
engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
|
|
||||||
|
|
||||||
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
|
|
||||||
engine.add_static_obstacle(obstacle)
|
|
||||||
|
|
||||||
test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)])
|
|
||||||
# physical check: dilated test_poly by C/2 = 1.0.
|
|
||||||
# Dilated test_poly bounds: (14, 19, 17, 26).
|
|
||||||
# obstacle: (20, 20, 25, 25). No physical collision.
|
|
||||||
assert not engine.is_collision(test_poly, net_width=2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ray_cast_width_clearance() -> None:
|
def test_ray_cast_width_clearance() -> None:
|
||||||
# Clearance = 2.0um, Width = 2.0um.
|
# Clearance = 2.0um, Width = 2.0um.
|
||||||
# Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.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 at x=10 to 20
|
||||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0)
|
||||||
engine.add_static_obstacle(obstacle)
|
|
||||||
|
|
||||||
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
|
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
|
||||||
start_ok = Port(6, 50, 90)
|
start_ok = Port(6, 50, 90)
|
||||||
|
|
@ -83,23 +59,73 @@ def test_ray_cast_width_clearance() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_check_move_static_clearance() -> None:
|
def test_check_move_static_clearance() -> None:
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0)
|
||||||
engine.add_static_obstacle(obstacle)
|
|
||||||
|
|
||||||
# Straight move of length 10 at x=8 (Width 2.0)
|
# Straight move of length 10 at x=8 (Width 2.0)
|
||||||
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
|
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
|
||||||
start = Port(8, 0, 90)
|
start = Port(8, 0, 90)
|
||||||
res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2
|
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.
|
# Move at x=7. Gap = 3.0 == minimum. OK.
|
||||||
start_ok = Port(7, 0, 90)
|
start_ok = Port(7, 0, 90)
|
||||||
res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0)
|
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.
|
# 3. Same exact-boundary case.
|
||||||
start_exact = Port(7, 0, 90)
|
start_exact = Port(7, 0, 90)
|
||||||
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
|
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
|
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.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:
|
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.x == 10.0
|
||||||
assert result.end_port.y == 0.0
|
assert result.end_port.y == 0.0
|
||||||
assert result.end_port.orientation == 0.0
|
assert result.end_port.r == 0.0
|
||||||
assert len(result.geometry) == 1
|
assert len(result.collision_geometry) == 1
|
||||||
|
|
||||||
# Bounds of the polygon
|
# 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 minx == 0.0
|
||||||
assert maxx == 10.0
|
assert maxx == 10.0
|
||||||
assert miny == -1.0
|
assert miny == -1.0
|
||||||
assert maxy == 1.0
|
assert maxy == 1.0
|
||||||
|
assert isinstance(result.collision_geometry, tuple)
|
||||||
|
|
||||||
|
|
||||||
def test_bend90_generation() -> None:
|
def test_bend90_generation() -> None:
|
||||||
|
|
@ -32,13 +38,13 @@ def test_bend90_generation() -> None:
|
||||||
result_cw = Bend90.generate(start, radius, width, direction="CW")
|
result_cw = Bend90.generate(start, radius, width, direction="CW")
|
||||||
assert result_cw.end_port.x == 10.0
|
assert result_cw.end_port.x == 10.0
|
||||||
assert result_cw.end_port.y == -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
|
# CCW bend
|
||||||
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
|
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
|
||||||
assert result_ccw.end_port.x == 10.0
|
assert result_ccw.end_port.x == 10.0
|
||||||
assert result_ccw.end_port.y == 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:
|
def test_sbend_generation() -> None:
|
||||||
|
|
@ -49,8 +55,8 @@ def test_sbend_generation() -> None:
|
||||||
|
|
||||||
result = SBend.generate(start, offset, radius, width)
|
result = SBend.generate(start, offset, radius, width)
|
||||||
assert result.end_port.y == 5.0
|
assert result.end_port.y == 5.0
|
||||||
assert result.end_port.orientation == 0.0
|
assert result.end_port.r == 0.0
|
||||||
assert len(result.geometry) == 2 # Optimization: returns individual arcs
|
assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs
|
||||||
|
|
||||||
# Verify failure for large offset
|
# Verify failure for large offset
|
||||||
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
|
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)
|
result = SBend.generate(start, offset, radius, width)
|
||||||
|
|
||||||
assert result.end_port.y == -5.0
|
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_maxy <= width / 2.0 + 1e-6
|
||||||
assert second_arc_miny < -width / 2.0
|
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")
|
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).
|
# 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)
|
# 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 minx <= 0.0 + 1e-6
|
||||||
assert maxx >= 10.0 - 1e-6
|
assert maxx >= 10.0 - 1e-6
|
||||||
assert miny <= 0.0 + 1e-6
|
assert miny <= 0.0 + 1e-6
|
||||||
assert maxy >= 10.0 - 1e-6
|
assert maxy >= 10.0 - 1e-6
|
||||||
|
|
||||||
# 2. Clipped BBox model
|
# 2. Clipped BBox model
|
||||||
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0)
|
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox")
|
||||||
# Area should be less than full bbox
|
# Conservative 8-point approximation should still be tighter than the full bbox.
|
||||||
assert res_clipped.geometry[0].area < res_bbox.geometry[0].area
|
assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8
|
||||||
|
assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area
|
||||||
|
|
||||||
|
# It should also conservatively contain the true arc.
|
||||||
|
res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc")
|
||||||
|
assert res_clipped.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:
|
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")
|
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
|
||||||
# Geometry should be a list of individual bbox polygons for each arc
|
# 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")
|
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
|
||||||
area_bbox = sum(p.area for p in res_bbox.geometry)
|
area_bbox = sum(p.area for p in res_bbox.collision_geometry)
|
||||||
area_arc = sum(p.area for p in res_arc.geometry)
|
area_arc = sum(p.area for p in res_arc.collision_geometry)
|
||||||
assert area_bbox > area_arc
|
assert area_bbox > area_arc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,14 +173,14 @@ def test_sbend_continuity() -> None:
|
||||||
res = SBend.generate(start, offset, radius, width)
|
res = SBend.generate(start, offset, radius, width)
|
||||||
|
|
||||||
# Target orientation should be same as start
|
# 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
|
# For a port at 90 deg, +offset is a shift in -x direction
|
||||||
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
|
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
|
||||||
|
|
||||||
# Geometry should be a list of valid polygons
|
# Geometry should be a list of valid polygons
|
||||||
assert len(res.geometry) == 2
|
assert len(res.collision_geometry) == 2
|
||||||
for p in res.geometry:
|
for p in res.collision_geometry:
|
||||||
assert p.is_valid
|
assert p.is_valid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,8 +197,8 @@ def test_arc_sagitta_precision() -> None:
|
||||||
|
|
||||||
# Number of segments should be significantly higher for fine
|
# Number of segments should be significantly higher for fine
|
||||||
# Exterior points = (segments + 1) * 2
|
# Exterior points = (segments + 1) * 2
|
||||||
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
|
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
|
||||||
pts_fine = len(res_fine.geometry[0].exterior.coords)
|
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
|
||||||
|
|
||||||
assert pts_fine > pts_coarse * 2
|
assert pts_fine > pts_coarse * 2
|
||||||
|
|
||||||
|
|
@ -162,12 +217,19 @@ def test_component_transform_invariance() -> None:
|
||||||
angle = 90.0
|
angle = 90.0
|
||||||
|
|
||||||
# 1. Transform the generated geometry
|
# 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
|
# 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")
|
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.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.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
|
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.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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
from inire.router.danger_map import DangerMap
|
||||||
from inire.router.pathfinder import PathFinder
|
|
||||||
|
BOUNDS = (0, -40, 100, 40)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def basic_evaluator() -> CostEvaluator:
|
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)
|
# 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([])
|
danger_map.precompute([])
|
||||||
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
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:
|
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
|
# Start at (0,0), target at (50, 2) -> 2um lateral offset
|
||||||
# This matches one of our discretized SBend offsets.
|
# This matches one of our discretized SBend offsets.
|
||||||
start = Port(0, 0, 0)
|
start = Port(0, 0, 0)
|
||||||
target = Port(50, 2, 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
|
assert path is not None
|
||||||
# Check if any component in the path is an SBend
|
# 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:
|
for res in path:
|
||||||
# Check if the end port orientation is same as start
|
# Check if the end port orientation is same as start
|
||||||
# and it's not a single straight (which would have y=0)
|
# 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
|
found_sbend = True
|
||||||
break
|
break
|
||||||
assert found_sbend
|
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 shapely.geometry import Polygon
|
||||||
from inire.geometry.collision import CollisionEngine
|
from inire.geometry.collision import RoutingWorld
|
||||||
from inire.geometry.primitives import Port
|
from inire.geometry.primitives import Port
|
||||||
from inire.router.cost import CostEvaluator
|
from inire.router.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
from inire.router.danger_map import DangerMap
|
||||||
|
|
||||||
|
|
||||||
def test_cost_calculation() -> None:
|
def test_cost_calculation() -> None:
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
# 50x50 um area, 1um resolution
|
# 50x50 um area, 1um resolution
|
||||||
danger_map = DangerMap(bounds=(0, 0, 50, 50))
|
danger_map = DangerMap(bounds=(0, 0, 50, 50))
|
||||||
danger_map.precompute([])
|
danger_map.precompute([])
|
||||||
|
|
@ -40,6 +40,18 @@ def test_cost_calculation() -> None:
|
||||||
assert h_away >= h_90
|
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:
|
def test_danger_map_kd_tree_and_cache() -> None:
|
||||||
# Test that KD-Tree based danger map works and uses cache
|
# Test that KD-Tree based danger map works and uses cache
|
||||||
bounds = (0, 0, 1000, 1000)
|
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
|
# 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)
|
cost_near_2 = dm.get_cost(100.5, 100.5)
|
||||||
assert cost_near_2 == cost_near
|
assert cost_near_2 == cost_near
|
||||||
|
assert len(dm._cost_cache) == 2
|
||||||
|
|
||||||
# 4. Out of bounds
|
# 4. Out of bounds
|
||||||
assert dm.get_cost(-1, -1) >= 1e12
|
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 os
|
||||||
import statistics
|
import statistics
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import pytest
|
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"
|
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
|
||||||
PERFORMANCE_REPEATS = 3
|
PERFORMANCE_REPEATS = 3
|
||||||
REGRESSION_FACTOR = 1.5
|
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 = {
|
BASELINE_SECONDS = {
|
||||||
"example_01_simple_route": 0.0035,
|
"example_01_simple_route": 0.0035,
|
||||||
"example_02_congestion_resolution": 0.2666,
|
"example_02_congestion_resolution": 0.2666,
|
||||||
|
|
@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = {
|
||||||
|
|
||||||
|
|
||||||
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
|
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
|
||||||
|
_, total_results, valid_results, reached_targets = outcome
|
||||||
expected = EXPECTED_OUTCOMES[name]
|
expected = EXPECTED_OUTCOMES[name]
|
||||||
assert outcome.total_results == expected["total_results"]
|
assert total_results == expected["total_results"]
|
||||||
assert outcome.valid_results == expected["valid_results"]
|
assert valid_results == expected["valid_results"]
|
||||||
assert outcome.reached_targets == expected["reached_targets"]
|
assert reached_targets == expected["reached_targets"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.performance
|
@pytest.mark.performance
|
||||||
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
|
@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])
|
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS])
|
||||||
def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None:
|
def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None:
|
||||||
|
name, run = scenario
|
||||||
timings = []
|
timings = []
|
||||||
for _ in range(PERFORMANCE_REPEATS):
|
for _ in range(PERFORMANCE_REPEATS):
|
||||||
outcome = scenario.run()
|
outcome = run()
|
||||||
_assert_expected_outcome(scenario.name, outcome)
|
_assert_expected_outcome(name, outcome)
|
||||||
timings.append(outcome.duration_s)
|
timings.append(outcome[0])
|
||||||
|
|
||||||
median_runtime = statistics.median(timings)
|
median_runtime = statistics.median(timings)
|
||||||
assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, (
|
assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, (
|
||||||
f"{scenario.name} median runtime {median_runtime:.4f}s exceeded "
|
f"{name} median runtime {median_runtime:.4f}s exceeded "
|
||||||
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s "
|
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
|
||||||
f"from timings {timings!r}"
|
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,21 +1,20 @@
|
||||||
|
from inire import CongestionOptions, RoutingOptions, RoutingProblem
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from inire.geometry.primitives import Port
|
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.cost import CostEvaluator
|
||||||
from inire.router.astar import AStarContext
|
|
||||||
from inire.router.pathfinder import PathFinder
|
|
||||||
from inire.router.danger_map import DangerMap
|
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)
|
Verifies that nets that fail to reach their target (return partial paths)
|
||||||
ARE added to the collision engine, making them visible to other nets
|
ARE added to the collision engine, making them visible to other nets
|
||||||
for negotiated congestion.
|
for negotiated congestion.
|
||||||
"""
|
"""
|
||||||
# 1. Setup
|
# 1. Setup
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
|
|
||||||
# Create a simple danger map (bounds 0-100)
|
# Create a simple danger map (bounds 0-100)
|
||||||
# We don't strictly need obstacles in it for this test.
|
# We don't strictly need obstacles in it for this test.
|
||||||
|
|
@ -29,25 +28,36 @@ def test_failed_net_visibility():
|
||||||
|
|
||||||
# Let's add a static obstacle that blocks the direct path.
|
# Let's add a static obstacle that blocks the direct path.
|
||||||
from shapely.geometry import box
|
from shapely.geometry import box
|
||||||
|
|
||||||
obstacle = box(40, -10, 60, 10) # Wall at x=50
|
obstacle = box(40, -10, 60, 10) # Wall at x=50
|
||||||
engine.add_static_obstacle(obstacle)
|
engine.add_static_obstacle(obstacle)
|
||||||
|
|
||||||
# With obstacle, direct jump fails. A* must search around.
|
# With obstacle, direct jump fails. A* must search around.
|
||||||
# Limit=10 should be enough to fail to find a path 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 = {
|
netlist = {
|
||||||
"net1": (Port(0, 0, 0), Port(100, 0, 0))
|
"net1": (Port(0, 0, 0), Port(100, 0, 0))
|
||||||
}
|
}
|
||||||
net_widths = {"net1": 1.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
|
# 4. Route
|
||||||
print("\nStarting Route...")
|
print("\nStarting Route...")
|
||||||
results = pf.route_all(netlist, net_widths)
|
results = pf.route_all()
|
||||||
|
|
||||||
res = results["net1"]
|
res = results["net1"]
|
||||||
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")
|
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")
|
||||||
|
|
@ -59,10 +69,7 @@ def test_failed_net_visibility():
|
||||||
|
|
||||||
# 6. Verify Visibility
|
# 6. Verify Visibility
|
||||||
# Check if net1 is in the collision engine
|
# Check if net1 is in the collision engine
|
||||||
found_nets = set()
|
found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()}
|
||||||
# CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)]
|
|
||||||
for obj_id, (nid, poly) in engine.dynamic_geometries.items():
|
|
||||||
found_nets.add(nid)
|
|
||||||
|
|
||||||
print(f"Nets found in engine: {found_nets}")
|
print(f"Nets found in engine: {found_nets}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import pytest
|
||||||
from hypothesis import given, settings, strategies as st
|
from hypothesis import given, settings, strategies as st
|
||||||
from shapely.geometry import Point, Polygon
|
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.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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
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)
|
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)
|
@settings(max_examples=3, deadline=None)
|
||||||
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
|
@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:
|
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
|
||||||
net_width = 2.0
|
net_width = 2.0
|
||||||
clearance = 2.0
|
clearance = 2.0
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
for obs in obstacles:
|
for obs in obstacles:
|
||||||
engine.add_static_obstacle(obs)
|
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)
|
danger_map.precompute(obstacles)
|
||||||
|
|
||||||
evaluator = CostEvaluator(engine, danger_map)
|
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)
|
# Check if start/target are inside obstacles (safety zone check)
|
||||||
# The router should handle this gracefully (either route or return None)
|
# The router should handle this gracefully (either route or return None)
|
||||||
try:
|
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.
|
# 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.
|
# 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.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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
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 _build_options(**overrides: object) -> RoutingOptions:
|
||||||
def basic_evaluator() -> CostEvaluator:
|
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
|
||||||
engine = CollisionEngine(clearance=2.0)
|
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
|
||||||
danger_map = DangerMap(bounds=(0, 0, 100, 100))
|
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([])
|
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:
|
def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
|
||||||
context = AStarContext(basic_evaluator)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
pf = PathFinder(context)
|
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 = {
|
start = Port(0, 0, 0)
|
||||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
width = 2.0
|
||||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
path = _build_manual_path(
|
||||||
}
|
start,
|
||||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
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 target == Port(65, 30, 90)
|
||||||
assert results["net1"].is_valid
|
assert sum(1 for comp in path if comp.move_type == "bend90") == 7
|
||||||
assert results["net2"].is_valid
|
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
|
||||||
assert results["net1"].collisions == 0
|
assert refined[-1].end_port == target
|
||||||
assert results["net2"].collisions == 0
|
assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from hypothesis import given, strategies as st
|
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
|
@st.composite
|
||||||
|
|
@ -24,11 +26,11 @@ def test_port_transform_invariants(p: Port) -> None:
|
||||||
# Rotating 90 degrees 4 times should return to same orientation
|
# Rotating 90 degrees 4 times should return to same orientation
|
||||||
p_rot = p
|
p_rot = p
|
||||||
for _ in range(4):
|
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.x - p.x) < 1e-6
|
||||||
assert abs(p_rot.y - p.y) < 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(
|
@given(
|
||||||
|
|
@ -37,14 +39,21 @@ def test_port_transform_invariants(p: Port) -> None:
|
||||||
dy=st.floats(min_value=-1000, max_value=1000),
|
dy=st.floats(min_value=-1000, max_value=1000),
|
||||||
)
|
)
|
||||||
def test_translate_snapping(p: Port, dx: float, dy: float) -> None:
|
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.x, int)
|
||||||
assert isinstance(p_trans.y, int)
|
assert isinstance(p_trans.y, int)
|
||||||
|
|
||||||
|
|
||||||
def test_orientation_normalization() -> None:
|
def test_orientation_normalization() -> None:
|
||||||
p = Port(0, 0, 360)
|
p = Port(0, 0, 360)
|
||||||
assert p.orientation == 0
|
assert p.r == 0
|
||||||
|
|
||||||
p2 = Port(0, 0, -90)
|
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.components import Bend90
|
||||||
from inire.geometry.primitives import Port
|
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.cost import CostEvaluator
|
||||||
from inire.router.danger_map import DangerMap
|
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:
|
def test_arc_resolution_sagitta() -> None:
|
||||||
|
|
@ -18,34 +41,45 @@ def test_arc_resolution_sagitta() -> None:
|
||||||
|
|
||||||
# Check number of points in the polygon exterior
|
# Check number of points in the polygon exterior
|
||||||
# (num_segments + 1) * 2 points usually
|
# (num_segments + 1) * 2 points usually
|
||||||
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
|
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
|
||||||
pts_fine = len(res_fine.geometry[0].exterior.coords)
|
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
|
||||||
|
|
||||||
assert pts_fine > pts_coarse
|
assert pts_fine > pts_coarse
|
||||||
|
|
||||||
|
|
||||||
def test_locked_paths() -> None:
|
def test_locked_routes() -> None:
|
||||||
engine = CollisionEngine(clearance=2.0)
|
engine = RoutingWorld(clearance=2.0)
|
||||||
danger_map = DangerMap(bounds=(0, -50, 100, 50))
|
danger_map = DangerMap(bounds=(0, -50, 100, 50))
|
||||||
danger_map.precompute([])
|
danger_map.precompute([])
|
||||||
evaluator = CostEvaluator(engine, danger_map)
|
evaluator = CostEvaluator(engine, danger_map)
|
||||||
context = AStarContext(evaluator, bend_radii=[5.0, 10.0])
|
|
||||||
pf = PathFinder(context)
|
|
||||||
|
|
||||||
# 1. Route Net A
|
# 1. Route Net A
|
||||||
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
|
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
|
assert results_a["netA"].is_valid
|
||||||
|
|
||||||
# 2. Lock Net A
|
# 2. Treat Net A as locked geometry in the next run.
|
||||||
engine.lock_net("netA")
|
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.
|
# 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.
|
# 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))}
|
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
|
||||||
|
|
||||||
# Route Net B
|
# 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
|
# 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).
|
# 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
|
assert results_b["netB"].is_valid
|
||||||
|
|
||||||
# Verify geometry doesn't intersect locked netA (physical check)
|
# Verify geometry doesn't intersect locked netA (physical check)
|
||||||
poly_a = [p.geometry[0] for p in results_a["netA"].path]
|
poly_a = [p.physical_geometry[0] for p in results_a["netA"].path]
|
||||||
poly_b = [p.geometry[0] for p in results_b["netB"].path]
|
poly_b = [p.physical_geometry[0] for p in results_b["netB"].path]
|
||||||
|
|
||||||
for pa in poly_a:
|
for pa in poly_a:
|
||||||
for pb in poly_b:
|
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
|
import unittest
|
||||||
|
|
||||||
from inire.geometry.primitives import Port
|
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.router.cost import CostEvaluator
|
||||||
from inire.geometry.collision import CollisionEngine
|
from inire.geometry.collision import RoutingWorld
|
||||||
|
|
||||||
|
|
||||||
class TestIntegerPorts(unittest.TestCase):
|
class TestIntegerPorts(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.ce = CollisionEngine(clearance=2.0)
|
self.ce = RoutingWorld(clearance=2.0)
|
||||||
self.cost = CostEvaluator(self.ce)
|
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):
|
def test_route_reaches_integer_target(self):
|
||||||
context = AStarContext(self.cost)
|
context = self._build_context()
|
||||||
start = Port(0, 0, 0)
|
start = Port(0, 0, 0)
|
||||||
target = Port(12, 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)
|
self.assertIsNotNone(path)
|
||||||
last_port = path[-1].end_port
|
last_port = path[-1].end_port
|
||||||
|
|
@ -24,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase):
|
||||||
self.assertEqual(last_port.r, 0)
|
self.assertEqual(last_port.r, 0)
|
||||||
|
|
||||||
def test_port_constructor_rounds_to_integer_lattice(self):
|
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)
|
start = Port(0.0, 0.0, 0.0)
|
||||||
target = Port(12.3, 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.assertIsNotNone(path)
|
||||||
self.assertEqual(target.x, 12)
|
self.assertEqual(target.x, 12)
|
||||||
|
|
@ -36,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase):
|
||||||
self.assertEqual(last_port.x, 12)
|
self.assertEqual(last_port.x, 12)
|
||||||
|
|
||||||
def test_half_step_inputs_use_integerized_targets(self):
|
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)
|
start = Port(0.0, 0.0, 0.0)
|
||||||
target = Port(7.5, 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.assertIsNotNone(path)
|
||||||
self.assertEqual(target.x, 8)
|
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 matplotlib.figure import Figure
|
||||||
|
|
||||||
from inire.geometry.primitives import Port
|
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(
|
def plot_routing_results(
|
||||||
|
|
@ -50,8 +51,7 @@ def plot_routing_results(
|
||||||
label_added = False
|
label_added = False
|
||||||
for comp in res.path:
|
for comp in res.path:
|
||||||
# 1. Plot Collision Geometry (Translucent fill)
|
# 1. Plot Collision Geometry (Translucent fill)
|
||||||
# This is the geometry used during search (e.g. proxy or arc)
|
for poly in comp.collision_geometry:
|
||||||
for poly in comp.geometry:
|
|
||||||
if isinstance(poly, MultiPolygon):
|
if isinstance(poly, MultiPolygon):
|
||||||
geoms = list(poly.geoms)
|
geoms = list(poly.geoms)
|
||||||
else:
|
else:
|
||||||
|
|
@ -66,9 +66,7 @@ def plot_routing_results(
|
||||||
x, y = g.xy
|
x, y = g.xy
|
||||||
ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2)
|
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)
|
actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry
|
||||||
# Use comp.actual_geometry if it exists (should be the arc)
|
|
||||||
actual_geoms_to_plot = comp.actual_geometry if comp.actual_geometry is not None else comp.geometry
|
|
||||||
|
|
||||||
for poly in actual_geoms_to_plot:
|
for poly in actual_geoms_to_plot:
|
||||||
if isinstance(poly, MultiPolygon):
|
if isinstance(poly, MultiPolygon):
|
||||||
|
|
@ -86,27 +84,29 @@ def plot_routing_results(
|
||||||
|
|
||||||
# 3. Plot subtle port orientation arrow
|
# 3. Plot subtle port orientation arrow
|
||||||
p = comp.end_port
|
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",
|
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)
|
scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4)
|
||||||
|
|
||||||
if not res.path and not res.is_valid:
|
if not res.path and not res.is_valid:
|
||||||
# Best-effort display: If the path is empty but failed, it might be unroutable.
|
# Empty failed paths are typically unroutable.
|
||||||
# We don't have a partial path in RoutingResult currently.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 4. Plot main arrows for netlist ports
|
# 4. Plot main arrows for netlist ports
|
||||||
if netlist:
|
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]:
|
for p in [start_p, target_p]:
|
||||||
rad = numpy.radians(p[2])
|
rad = numpy.radians(p.r)
|
||||||
ax.quiver(*p[:2], numpy.cos(rad), numpy.sin(rad), color="black",
|
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
|
||||||
scale=25, width=0.004, pivot="tail", zorder=6)
|
scale=25, width=0.004, pivot="tail", zorder=6)
|
||||||
|
|
||||||
ax.set_xlim(bounds[0], bounds[2])
|
ax.set_xlim(bounds[0], bounds[2])
|
||||||
ax.set_ylim(bounds[1], bounds[3])
|
ax.set_ylim(bounds[1], bounds[3])
|
||||||
ax.set_aspect("equal")
|
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
|
# Legend handling for many nets
|
||||||
if len(results) < 25:
|
if len(results) < 25:
|
||||||
|
|
@ -181,7 +181,7 @@ def plot_expanded_nodes(
|
||||||
if not nodes:
|
if not nodes:
|
||||||
return fig, ax
|
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)
|
ax.scatter(x, y, s=1, c=color, alpha=alpha, zorder=0)
|
||||||
return fig, ax
|
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)
|
ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes)
|
||||||
return fig, ax
|
return fig, ax
|
||||||
|
|
||||||
x, y, _ = zip(*nodes)
|
x, y, _ = zip(*nodes, strict=False)
|
||||||
|
|
||||||
# Create 2D histogram
|
# Create 2D histogram
|
||||||
h, xedges, yedges = numpy.histogram2d(
|
h, xedges, yedges = numpy.histogram2d(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ lint.ignore = [
|
||||||
"C408", # dict(x=y) instead of {'x': y}
|
"C408", # dict(x=y) instead of {'x': y}
|
||||||
"PLR09", # Too many xxx
|
"PLR09", # Too many xxx
|
||||||
"PLR2004", # magic number
|
"PLR2004", # magic number
|
||||||
#"PLC0414", # import x as x
|
"PLC0414", # import x as x
|
||||||
"TRY003", # Long exception message
|
"TRY003", # Long exception message
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||