Compare commits

..

No commits in common. "7e0d96f9876575d94c0497f6c21a984f8eb565e1" and "34615f3aac9846e9ebe3771fdc3913af76507514" have entirely different histories.

86 changed files with 23 additions and 26605 deletions

4
.gitignore vendored
View file

@ -8,7 +8,3 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
.hypothesis
*.png

View file

@ -1 +0,0 @@
3.13

363
DOCS.md
View file

@ -1,363 +0,0 @@
# Inire Configuration & API Documentation
This document describes the current public API for `inire`.
## 1. Primary API
### `RoutingProblem`
`RoutingProblem` describes the physical routing problem:
- `bounds`
- `nets`
- `static_obstacles`
- `initial_paths`
- `clearance`
- `safety_zone_radius`
### `RoutingOptions`
`RoutingOptions` groups all expert controls for the routing engine:
- `search`
- `objective`
- `congestion`
- `refinement`
- `diagnostics`
Route a problem with:
```python
run = route(problem, options=options)
```
If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults.
The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
Stable example:
```python
from inire import route, RoutingOptions, RoutingProblem
```
Unstable example:
```python
from inire.router._router import PathFinder
```
### Incremental routing with locked geometry
For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem:
```python
run_a = route(problem_a)
problem_b = RoutingProblem(
bounds=problem_a.bounds,
nets=(...),
static_obstacles=run_a.results_by_net["netA"].locked_geometry,
)
run_b = route(problem_b)
```
`RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle.
### Initial paths with `PathSeed`
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.
## 2. Search Options
`RoutingOptions.search` is a `SearchOptions` object.
| Field | Default | Description |
| :-- | :-- | :-- |
| `node_limit` | `1_000_000` | Maximum number of states to explore per net. |
| `max_straight_length` | `2000.0` | Maximum length of a single straight segment. |
| `min_straight_length` | `5.0` | Minimum length of a single straight segment. |
| `greedy_h_weight` | `1.5` | Heuristic weight. `1.0` is optimal but slower. |
| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. |
| `sbend_radii` | `(10.0,)` | Available radii for S-bends. |
| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. |
| `bend_collision_type` | `"arc"` | Bend collision/proxy model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or, for backward compatibility, a custom polygon. A legacy custom polygon here is treated as both the physical bend and its proxy unless overridden by the split fields below. |
| `bend_proxy_geometry` | `None` | Optional explicit bend proxy geometry. Use this when you want a custom search/collision envelope that differs from the routed bend shape. Supplying only a custom polygon proxy warns and keeps the physical bend as the standard arc. |
| `bend_physical_geometry` | `None` | Optional explicit bend physical geometry. Use `"arc"` or a custom polygon. If you set a custom physical polygon and do not set a proxy, the proxy defaults to the same 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. |
## 3. Objective Weights
`RoutingOptions.objective` and `RoutingOptions.refinement.objective` use `ObjectiveWeights`.
| Field | Default | Description |
| :-- | :-- | :-- |
| `unit_length_cost` | `1.0` | Cost per unit length. |
| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. |
| `sbend_penalty` | `500.0` | Flat S-bend penalty. |
| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. |
## 4. Congestion Options
`RoutingOptions.congestion` is a `CongestionOptions` object.
| Field | Default | Description |
| :-- | :-- | :-- |
| `max_iterations` | `10` | Maximum rip-up and reroute iterations. |
| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. |
| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. |
| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. |
| `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. |
| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. |
| `shuffle_nets` | `False` | Shuffle routing order between iterations. |
| `seed` | `None` | RNG seed for shuffled routing order. |
## 5. Refinement Options
`RoutingOptions.refinement` is a `RefinementOptions` object.
| Field | Default | Description |
| :-- | :-- | :-- |
| `enabled` | `True` | Enable post-route refinement. |
| `objective` | `None` | Optional override objective for refinement. `None` reuses the search objective. |
## 6. Diagnostics Options
`RoutingOptions.diagnostics` is a `DiagnosticsOptions` object.
| Field | Default | Description |
| :-- | :-- | :-- |
| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. |
| `capture_conflict_trace` | `False` | Capture authoritative post-reverify conflict trace entries for debugging negotiated-congestion failures. |
| `capture_frontier_trace` | `False` | Run an analysis-only reroute for reached-but-colliding nets and capture prune causes near their final conflict hotspots. |
| `capture_iteration_trace` | `False` | Capture per-iteration and per-net route-attempt attribution for negotiated-congestion diagnosis. |
| `capture_pre_pair_frontier_trace` | `False` | Capture the final unresolved pre-pair-local subset iteration plus hotspot-adjacent frontier prunes for the routed nets in that basin. |
## 7. Conflict Trace
`RoutingRunResult.conflict_trace` is an immutable tuple of post-reverify conflict snapshots. It is empty unless `RoutingOptions.diagnostics.capture_conflict_trace=True`.
Trace types:
- `ConflictTraceEntry`
- `stage`: `"iteration"`, `"restored_best"`, or `"final"`
- `iteration`: Iteration index for `"iteration"` entries, otherwise `None`
- `completed_net_ids`: Nets with collision-free reached-target paths at that stage
- `conflict_edges`: Undirected dynamic-conflict net pairs seen after full reverify
- `nets`: Per-net trace payloads in routing-order order
- `NetConflictTrace`
- `net_id`
- `outcome`
- `reached_target`
- `report`
- `conflicting_net_ids`: Dynamic conflicting nets for that stage
- `component_conflicts`: Dynamic component-pair overlaps for that stage
- `ComponentConflictTrace`
- `other_net_id`
- `self_component_index`
- `other_component_index`
The conflict trace only records dynamic net-vs-net component overlaps. Static-obstacle and self-collision details remain count-only in `RoutingReport`.
Use `scripts/record_conflict_trace.py` to capture JSON and Markdown trace artifacts for the built-in trace scenarios. The default target is `example_07_large_scale_routing_no_warm_start`.
## 8. Frontier Trace
`RoutingRunResult.frontier_trace` is an immutable tuple of per-net post-run frontier analyses. It is empty unless `RoutingOptions.diagnostics.capture_frontier_trace=True`.
Trace types:
- `NetFrontierTrace`
- `net_id`
- `hotspot_bounds`: Buffered bounds around the net's final dynamic component-overlap hotspots
- `pruned_closed_set`
- `pruned_hard_collision`
- `pruned_self_collision`
- `pruned_cost`
- `samples`: First traced prune events near those hotspots
- `FrontierPruneSample`
- `reason`: `"closed_set"`, `"hard_collision"`, `"self_collision"`, or `"cost"`
- `move_type`
- `hotspot_index`
- `parent_state`
- `end_state`
The frontier trace is observational only. It reruns only the final reached-but-colliding nets in analysis mode, with scratch metrics, after the routed result is already fixed.
Use `scripts/record_frontier_trace.py` to capture JSON and Markdown frontier-prune artifacts for the built-in trace scenarios. The default target is `example_07_large_scale_routing_no_warm_start`.
Separately from the observational trace tooling, the router may run a bounded post-loop pair-local scratch reroute before refinement when the restored best snapshot ends with final two-net reached-target dynamic conflicts. That repair phase is part of normal routing behavior and is reported through the `pair_local_search_*` counters below.
## 9. Pre-Pair Frontier Trace
`RoutingRunResult.pre_pair_frontier_trace` is either a single immutable trace entry or `None`. It is populated only when `RoutingOptions.diagnostics.capture_pre_pair_frontier_trace=True`.
Trace types:
- `PrePairFrontierTraceEntry`
- `iteration`: The final unresolved subset-reroute iteration immediately before pair-local handoff
- `routed_net_ids`: Nets rerouted in that iteration, in routing order
- `conflict_edges`: Dynamic conflict edges reported for that unresolved basin
- `nets`: Per-net attempt attribution plus hotspot-adjacent frontier rerun data
- `PrePairNetTrace`
- `net_id`
- `nodes_expanded`
- `congestion_check_calls`
- `pruned_closed_set`
- `pruned_cost`
- `pruned_hard_collision`
- `guidance_seed_present`
- `frontier`: A `NetFrontierTrace` captured against the restored best unresolved state
Use `scripts/record_pre_pair_frontier_trace.py` to capture JSON and Markdown artifacts. Its default comparison target is the solved seed-42 no-warm canary versus the heavier seed-43 no-warm canary.
## 10. Iteration Trace
`RoutingRunResult.iteration_trace` is an immutable tuple of negotiated-congestion iteration summaries. It is empty unless `RoutingOptions.diagnostics.capture_iteration_trace=True`.
Trace types:
- `IterationTraceEntry`
- `iteration`
- `congestion_penalty`: Penalty in effect for that iteration
- `routed_net_ids`: Nets rerouted during that iteration, in routing order
- `completed_nets`
- `conflict_edges`
- `total_dynamic_collisions`
- `nodes_expanded`
- `congestion_check_calls`
- `congestion_candidate_ids`
- `congestion_exact_pair_checks`
- `net_attempts`: Per-net attribution for that iteration
- `IterationNetAttemptTrace`
- `net_id`
- `reached_target`
- `nodes_expanded`
- `congestion_check_calls`
- `pruned_closed_set`
- `pruned_cost`
- `pruned_hard_collision`
- `guidance_seed_present`
Use `scripts/record_iteration_trace.py` to capture JSON and Markdown iteration-attribution artifacts. Its default comparison target is the solved seed-42 no-warm canary versus the pathological seed-43 no-warm canary.
## 11. RouteMetrics
`RoutingRunResult.metrics` is an immutable per-run snapshot.
### Search Counters
- `nodes_expanded`: Total nodes expanded during the run.
- `moves_generated`: Total candidate moves generated during the run.
- `moves_added`: Total candidate moves admitted to the open set.
- `pruned_closed_set`: Total moves pruned because the state was already closed at lower cost.
- `pruned_hard_collision`: Total moves pruned by hard collision checks.
- `pruned_cost`: Total moves pruned by cost ceilings or invalid costs.
- `route_iterations`: Number of negotiated-congestion iterations entered.
- `nets_routed`: Number of net-routing attempts executed across all iterations.
- `nets_reached_target`: Number of those attempts that reached the requested target port.
- `warm_start_paths_built`: Number of warm-start seed paths built by the greedy bootstrap pass.
- `warm_start_paths_used`: Number of routing attempts satisfied directly from an initial or warm-start path.
- `refine_path_calls`: Number of completed paths passed through the post-route refiner.
- `timeout_events`: Number of timeout exits encountered during the run.
- `iteration_reverify_calls`: Number of end-of-iteration full reverify passes against the final installed dynamic geometry.
- `iteration_reverified_nets`: Number of reached-target nets reverified at iteration boundaries.
- `iteration_conflicting_nets`: Total unique nets found in end-of-iteration dynamic conflicts.
- `iteration_conflict_edges`: Total undirected dynamic-conflict edges observed at iteration boundaries.
- `nets_carried_forward`: Number of nets retained unchanged between iterations.
### Cache Counters
- `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity.
- `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity.
- `guidance_match_moves`: Number of moves that matched the reroute guidance seed and received the guidance bonus.
- `guidance_match_moves_straight`, `guidance_match_moves_bend90`, `guidance_match_moves_sbend`: Guidance-match counts split by move type.
- `guidance_bonus_applied`: Total reroute-guidance bonus subtracted from move costs across the run.
- `guidance_bonus_applied_straight`, `guidance_bonus_applied_bend90`, `guidance_bonus_applied_sbend`: Guidance bonus totals split by move type.
- `static_safe_cache_hits`: Reuse count for the static-safe admission cache.
- `hard_collision_cache_hits`: Reuse count for the hard-collision cache.
- `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity.
### Index And Collision Counters
- `dynamic_path_objects_added` / `dynamic_path_objects_removed`: Dynamic-path geometry objects inserted into or removed from the live routing index.
- `dynamic_tree_rebuilds`: Number of dynamic STRtree rebuilds.
- `dynamic_grid_rebuilds`: Number of dynamic congestion-grid rebuilds.
- `static_tree_rebuilds`: Number of static dilated-obstacle STRtree rebuilds.
- `static_raw_tree_rebuilds`: Number of raw static-obstacle STRtree rebuilds used for verification.
- `static_net_tree_rebuilds`: Number of net-width-specific static STRtree rebuilds.
- `visibility_corner_index_builds`: Number of lazy corner-index rebuilds.
- `visibility_builds`: Number of exact corner-visibility graph rebuilds.
- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building the exact graph.
- `visibility_corner_queries_exact` / `visibility_corner_hits_exact`: Exact-corner visibility query activity.
- `visibility_point_queries`, `visibility_point_cache_hits`, `visibility_point_cache_misses`: Arbitrary-point visibility query and cache activity.
- `ray_cast_calls`: Number of ray-cast queries issued against static obstacles.
- `ray_cast_candidate_bounds`: Total broad-phase candidate bounds considered by ray casts.
- `ray_cast_exact_geometry_checks`: Total exact non-rectangular geometry checks performed by ray casts.
- `congestion_check_calls`: Number of congestion broad-phase checks requested by search.
- `congestion_presence_cache_hits` / `congestion_presence_cache_misses`: Reuse of cached per-span booleans indicating whether a move polygon could overlap any other routed net at all.
- `congestion_presence_skips`: Number of moves that bypassed full congestion evaluation because the presence precheck found no other routed nets in any covered dynamic-grid span.
- `congestion_candidate_precheck_hits` / `congestion_candidate_precheck_misses`: Reuse of cached conservative per-span booleans indicating whether any candidate nets survive the net-envelope and grid-net broad phases.
- `congestion_candidate_precheck_skips`: Number of moves that bypassed full congestion evaluation because the candidate-net precheck found no surviving candidate nets after those broad phases.
- `congestion_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during routing.
- `congestion_net_envelope_cache_hits` / `congestion_net_envelope_cache_misses`: Reuse of cached dynamic net-envelope candidate sets keyed by the queried grid-cell span.
- `congestion_grid_net_cache_hits` / `congestion_grid_net_cache_misses`: Reuse of cached per-span candidate net ids gathered from dynamic grid occupancy.
- `congestion_grid_span_cache_hits` / `congestion_grid_span_cache_misses`: Reuse of cached dynamic-path candidate unions keyed by the queried grid-cell span.
- `congestion_lazy_resolutions`: Number of popped nodes whose pending congestion was resolved lazily.
- `congestion_lazy_requeues`: Number of lazily resolved nodes requeued after a positive congestion penalty was applied.
- `congestion_candidate_ids`: Total dynamic-path object ids returned by the congestion broad phase before exact confirmation.
- `congestion_exact_pair_checks`: Number of exact geometry-pair checks performed while confirming congestion hits.
### Verification Counters
- `verify_path_report_calls`: Number of full path-verification passes.
- `verify_static_buffer_ops`: Number of static-verification `buffer()` operations.
- `verify_dynamic_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during final verification.
- `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification.
### Local Search Counters
- `pair_local_search_pairs_considered`: Number of final reached-target conflict pairs considered by the bounded post-loop pair-local-search phase.
- `pair_local_search_attempts`: Number of pair-local-search reroute attempts executed across all considered pairs.
- `pair_local_search_accepts`: Number of pair-local-search attempts accepted into the whole routed result set.
- `pair_local_search_nodes_expanded`: Total A* node expansions spent inside pair-local-search attempts.
- `late_phase_capped_nets`: Number of late all-reached heavy-net reroutes run under the bounded node-limit cap before pair-local handoff.
- `late_phase_capped_fallbacks`: Number of those capped late-phase reroutes that fell back to the incumbent reached-target path instead of replacing it.
## 10. 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=...)`.
The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**.
Use `scripts/diff_performance_baseline.py` to compare a fresh local run against that baseline. Use `scripts/record_conflict_trace.py` for opt-in conflict-hotspot traces, `scripts/record_frontier_trace.py` for hotspot-adjacent prune traces, `scripts/record_pre_pair_frontier_trace.py` for the final unresolved pre-pair basin, `scripts/record_iteration_trace.py` for per-iteration negotiated-congestion attribution, and `scripts/characterize_pair_local_search.py` to sweep example_07-style no-warm runs for pair-local repair behavior. The counter baseline is currently observational and is not enforced as a CI gate.
## 11. 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.
- Use `search.bend_physical_geometry` and `search.bend_proxy_geometry` together when you need a real custom bend shape plus a different conservative proxy.
### 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`.

107
README.md
View file

@ -1,107 +0,0 @@
# inire: Auto-Routing for Photonic and RF Integrated Circuits
`inire` is a high-performance auto-router designed specifically for the physical constraints of photonic and RF integrated circuits. It uses a Hybrid State-Lattice A* search combined with negotiated congestion to route multiple nets while maintaining strict geometric fidelity and clearance.
## Key Features
* **Hybrid State-Lattice Search**: Routes using discrete 90° bends and parametric S-bends, ensuring manufacturing-stable paths.
* **Negotiated Congestion**: Iteratively resolves multi-net bottlenecks by inflating costs in high-traffic regions.
* **Analytic Correctness**: Every move is verified against an R-Tree spatial index of obstacles and other paths.
* **1nm Precision**: All coordinates and ports are snapped to a 1nm manufacturing grid.
* **Safety & Proximity**: Uses a sampled obstacle-boundary proximity model to bias routes away from nearby geometry.
* **Locked Routes**: Supports treating prior routed nets as fixed obstacles in later runs.
## Installation
`inire` requires Python 3.11+. You can install the dependencies using `uv` (recommended) or `pip`:
```bash
# Using uv
uv sync
# Using pip
pip install numpy scipy shapely rtree matplotlib
```
## Quick Start
```python
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
problem = RoutingProblem(
bounds=(0, 0, 1000, 1000),
nets=(
NetSpec("net1", Port(0, 0, 0), Port(100, 50, 0), width=2.0),
),
)
options = RoutingOptions(
search=SearchOptions(
bend_radii=(50.0, 100.0),
greedy_h_weight=1.2,
),
objective=ObjectiveWeights(
bend_penalty=10.0,
),
)
run = route(problem, options=options)
if run.results_by_net["net1"].is_valid:
print("Successfully routed net1!")
```
For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`.
## Usage Examples
For detailed visual demonstrations and architectural deep-dives, see the **[Examples README](examples/README.md)**.
Check the `examples/` directory for ready-to-run scripts. To run an example:
```bash
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
Current documentation lives in:
* **[DOCS.md](DOCS.md)** for the public API and option reference.
* **[docs/architecture.md](docs/architecture.md)** for the current implementation structure.
* **[docs/performance.md](docs/performance.md)** for the committed performance-counter baseline.
## API Stability
The stable API lives at the package root and is centered on `route(problem, options=...)`.
Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
## Architecture
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:
1. **Straights**: Variable-length segments.
2. **90° Bends**: Fixed-radius PDK cells.
3. **Parametric S-Bends**: Procedural arcs for bridging small lateral offsets ($O < 2R$).
For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic and seeks a collision-free configuration without crossings.
## Configuration
`inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference.
## License
This project is licensed under the GNU Affero General Public License v3. See `LICENSE.md` for details.

View file

@ -1,50 +0,0 @@
# Architecture Overview
`inire` is a single-package Python router with a small stable API at the package root and a larger semi-private implementation under `inire.geometry` and `inire.router`.
## Stable Surface
- The supported entrypoint is `route(problem, options=...)`.
- Stable public types live at the package root and include `RoutingProblem`, `RoutingOptions`, `NetSpec`, `Port`, `RoutingResult`, and `RoutingRunResult`.
- Deep imports such as `inire.router._router.PathFinder` and `inire.geometry.collision.RoutingWorld` are intentionally accessible for advanced workflows, but they are unstable.
## Current Module Layout
- `inire/model.py`: Immutable request and option dataclasses.
- `inire/results.py`: Immutable routing results plus the per-run `RouteMetrics` snapshot.
- `inire/seeds.py`: Serializable path-seed primitives.
- `inire/geometry/primitives.py`: Integer Manhattan ports and small transform helpers.
- `inire/geometry/components.py`: `Straight`, `Bend90`, and `SBend` geometry generation.
- `inire/geometry/collision.py`: Routing-world collision, congestion, ray-cast, and path-verification logic.
- `inire/geometry/static_obstacle_index.py` and `inire/geometry/dynamic_path_index.py`: Spatial-index management for static obstacles and routed paths, including dynamic per-object indices, per-net grid occupancy, congestion grid membership, and per-net dynamic envelopes.
- `inire/router/_search.py`, `_astar_moves.py`, `_astar_admission.py`, `_astar_types.py`: The state-lattice A* search loop and move admission pipeline.
- `inire/router/_router.py`: The negotiated-congestion driver and refinement orchestration.
- `inire/router/refiner.py`: Post-route path simplification for completed paths.
- `inire/router/cost.py` and `inire/router/danger_map.py`: Search scoring and obstacle-proximity biasing.
- `inire/utils/visualization.py`: Plotting and diagnostics helpers.
## Routing Stack
`route(problem, options=...)` builds a routing stack composed of:
1. `RoutingWorld` for collision state.
2. `DangerMap` for static-obstacle proximity costs.
3. `CostEvaluator` for move scoring and heuristic support.
4. `AStarContext` for caches and search configuration.
5. `PathFinder` for negotiated congestion, rip-up/reroute, and refinement.
The search state is a snapped Manhattan `(x, y, r)` port. From each state the router expands straight segments, 90-degree bends, and compact S-bends, then validates candidates against static geometry, dynamic congestion, and optional self-collision checks.
## Notes On Current Behavior
- Static obstacles and routed paths are treated as single-layer geometry; automatic crossings are not supported.
- The danger-map implementation uses sampled obstacle-boundary points and a KD-tree, not a dense distance-transform grid.
- The visibility subsystem keeps a lazy static corner index for default `tangent_corner` guidance and only builds the exact corner-to-corner graph on demand for `exact_corner` queries.
- `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration.
- Negotiated congestion now re-verifies every reached-target path at the end of each iteration against the final installed dynamic geometry, and it stops early if the conflict graph stalls for consecutive iterations.
- After best-snapshot restoration, the router runs a bounded pair-local scratch reroute on final two-net reached-target conflict pairs. That repair phase clones static obstacles from the live collision world, treats all outside-pair geometry as fixed blockers, tries both pair orders, and only keeps the result if whole-set reverify improves.
- Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning.
## Performance Visibility
`RoutingRunResult.metrics` includes both A* counters and index/cache/verification counters. The committed example-corpus baseline for those counters is tracked in `docs/performance.md` and `docs/performance_baseline.json`.

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
# Conflict Trace
Generated at 2026-04-02T14:24:39-07:00 by `scripts/record_conflict_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
| Stage | Iteration | Conflicting Nets | Conflict Edges | Completed Nets |
| :-- | --: | --: | --: | --: |
| iteration | 0 | 9 | 16 | 1 |
| iteration | 1 | 8 | 12 | 2 |
| iteration | 2 | 6 | 5 | 4 |
| iteration | 3 | 4 | 2 | 6 |
| iteration | 4 | 4 | 2 | 6 |
| iteration | 5 | 4 | 2 | 6 |
| restored_best | | 4 | 2 | 6 |
| final | | 0 | 0 | 10 |
Top nets by traced dynamic-collision stages:
- `net_06`: 7
- `net_07`: 7
- `net_01`: 6
- `net_00`: 5
- `net_02`: 5
- `net_03`: 4
- `net_08`: 2
- `net_09`: 2
- `net_05`: 1
Top net pairs by frequency:
- `net_06` <-> `net_07`: 7
- `net_00` <-> `net_01`: 5
- `net_01` <-> `net_02`: 4
- `net_00` <-> `net_02`: 3
- `net_00` <-> `net_03`: 3
- `net_02` <-> `net_03`: 3
- `net_01` <-> `net_03`: 2
- `net_06` <-> `net_08`: 2
- `net_06` <-> `net_09`: 2
- `net_07` <-> `net_08`: 2
Top component pairs by frequency:
- `net_06[2]` <-> `net_07[2]`: 6
- `net_06[3]` <-> `net_07[2]`: 6
- `net_06[1]` <-> `net_07[1]`: 6
- `net_06[2]` <-> `net_07[1]`: 5
- `net_00[2]` <-> `net_01[3]`: 4
- `net_01[2]` <-> `net_02[2]`: 3
- `net_01[2]` <-> `net_02[3]`: 3
- `net_00[2]` <-> `net_01[2]`: 3
- `net_07[3]` <-> `net_08[2]`: 2
- `net_02[1]` <-> `net_03[1]`: 2

View file

@ -1,120 +0,0 @@
{
"generated_at": "2026-04-02T14:24:39-07:00",
"generator": "scripts/record_frontier_trace.py",
"scenarios": [
{
"frontier_trace": [],
"metrics": {
"congestion_cache_hits": 31,
"congestion_cache_misses": 4625,
"congestion_candidate_ids": 9924,
"congestion_candidate_nets": 9979,
"congestion_candidate_precheck_hits": 2562,
"congestion_candidate_precheck_misses": 2165,
"congestion_candidate_precheck_skips": 71,
"congestion_check_calls": 4625,
"congestion_exact_pair_checks": 8122,
"congestion_grid_net_cache_hits": 2457,
"congestion_grid_net_cache_misses": 3942,
"congestion_grid_span_cache_hits": 2283,
"congestion_grid_span_cache_misses": 1948,
"congestion_lazy_requeues": 0,
"congestion_lazy_resolutions": 0,
"congestion_net_envelope_cache_hits": 2673,
"congestion_net_envelope_cache_misses": 4139,
"congestion_presence_cache_hits": 2858,
"congestion_presence_cache_misses": 2556,
"congestion_presence_skips": 687,
"danger_map_cache_hits": 16878,
"danger_map_cache_misses": 7425,
"danger_map_lookup_calls": 24303,
"danger_map_query_calls": 7425,
"danger_map_total_ns": 212814061,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 471,
"dynamic_path_objects_removed": 423,
"dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 11000.0,
"guidance_bonus_applied_bend90": 3500.0,
"guidance_bonus_applied_sbend": 625.0,
"guidance_bonus_applied_straight": 6875.0,
"guidance_match_moves": 176,
"guidance_match_moves_bend90": 56,
"guidance_match_moves_sbend": 10,
"guidance_match_moves_straight": 110,
"hard_collision_cache_hits": 0,
"iteration_conflict_edges": 39,
"iteration_conflicting_nets": 36,
"iteration_reverified_nets": 60,
"iteration_reverify_calls": 6,
"move_cache_abs_hits": 2559,
"move_cache_abs_misses": 6494,
"move_cache_rel_hits": 5872,
"move_cache_rel_misses": 622,
"moves_added": 8081,
"moves_generated": 9053,
"nets_carried_forward": 0,
"nets_reached_target": 60,
"nets_routed": 60,
"nodes_expanded": 1764,
"pair_local_search_accepts": 2,
"pair_local_search_attempts": 2,
"pair_local_search_nodes_expanded": 68,
"pair_local_search_pairs_considered": 2,
"path_cost_calls": 0,
"pruned_closed_set": 439,
"pruned_cost": 533,
"pruned_hard_collision": 0,
"ray_cast_calls": 5477,
"ray_cast_calls_expand_forward": 1704,
"ray_cast_calls_expand_snap": 46,
"ray_cast_calls_other": 0,
"ray_cast_calls_straight_static": 3721,
"ray_cast_calls_visibility_build": 0,
"ray_cast_calls_visibility_query": 0,
"ray_cast_calls_visibility_tangent": 6,
"ray_cast_candidate_bounds": 305,
"ray_cast_exact_geometry_checks": 0,
"refine_path_calls": 10,
"refinement_candidate_side_extents": 0,
"refinement_candidates_accepted": 0,
"refinement_candidates_built": 0,
"refinement_candidates_verified": 0,
"refinement_dynamic_bounds_checked": 0,
"refinement_static_bounds_checked": 0,
"refinement_windows_considered": 0,
"route_iterations": 6,
"score_component_calls": 8634,
"score_component_total_ns": 241025335,
"static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 2482,
"static_tree_rebuilds": 1,
"timeout_events": 0,
"verify_dynamic_candidate_nets": 2106,
"verify_dynamic_exact_pair_checks": 558,
"verify_path_report_calls": 190,
"verify_static_buffer_ops": 895,
"visibility_builds": 0,
"visibility_corner_hits_exact": 0,
"visibility_corner_index_builds": 1,
"visibility_corner_pairs_checked": 0,
"visibility_corner_queries_exact": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"visibility_tangent_candidate_corner_checks": 6,
"visibility_tangent_candidate_ray_tests": 6,
"visibility_tangent_candidate_scans": 1704,
"warm_start_paths_built": 0,
"warm_start_paths_used": 0
},
"name": "example_07_large_scale_routing_no_warm_start",
"summary": {
"reached_targets": 10,
"total_results": 10,
"valid_results": 10
}
}
]
}

View file

@ -1,23 +0,0 @@
# Frontier Trace
Generated at 2026-04-02T14:24:39-07:00 by `scripts/record_frontier_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
| Net | Hotspots | Closed-Set | Hard Collision | Self Collision | Cost | Samples |
| :-- | --: | --: | --: | --: | --: | --: |
Prune totals by reason:
- None
Top traced hotspots by sample count:
- None
Per-net sampled reason/move breakdown:
- None

File diff suppressed because it is too large Load diff

View file

@ -1,81 +0,0 @@
# Iteration Trace
Generated at 2026-04-02T18:51:01-07:00 by `scripts/record_iteration_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
| Iteration | Penalty | Routed Nets | Completed | Conflict Edges | Dynamic Collisions | Nodes | Congestion Checks | Candidate Ids | Exact Pairs |
| --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| 0 | 100.0 | 10 | 1 | 16 | 50 | 571 | 0 | 0 | 0 |
| 1 | 140.0 | 10 | 2 | 12 | 54 | 253 | 974 | 2378 | 1998 |
| 2 | 196.0 | 10 | 4 | 5 | 22 | 253 | 993 | 1928 | 1571 |
| 3 | 274.4 | 10 | 6 | 2 | 10 | 100 | 437 | 852 | 698 |
| 4 | 384.2 | 4 | 6 | 2 | 10 | 81 | 332 | 627 | 513 |
Top nets by iteration-attributed nodes expanded:
- `net_09`: 242
- `net_00`: 201
- `net_02`: 157
- `net_06`: 155
- `net_01`: 147
- `net_08`: 144
- `net_03`: 141
- `net_07`: 45
- `net_04`: 13
- `net_05`: 13
Top nets by iteration-attributed congestion checks:
- `net_06`: 569
- `net_02`: 514
- `net_01`: 468
- `net_03`: 425
- `net_00`: 203
- `net_08`: 170
- `net_07`: 143
- `net_09`: 124
- `net_04`: 60
- `net_05`: 60
## example_07_large_scale_routing_no_warm_start_seed43
Results: 10 valid / 10 reached / 10 total.
| Iteration | Penalty | Routed Nets | Completed | Conflict Edges | Dynamic Collisions | Nodes | Congestion Checks | Candidate Ids | Exact Pairs |
| --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| 0 | 100.0 | 10 | 1 | 16 | 50 | 571 | 0 | 0 | 0 |
| 1 | 140.0 | 10 | 1 | 13 | 53 | 269 | 961 | 2562 | 2032 |
| 2 | 196.0 | 10 | 4 | 3 | 15 | 140 | 643 | 1610 | 1224 |
| 3 | 274.4 | 6 | 4 | 3 | 15 | 54 | 250 | 557 | 428 |
| 4 | 384.2 | 6 | 6 | 2 | 10 | 136 | 505 | 1025 | 829 |
| 5 | 537.8 | 4 | 6 | 2 | 10 | 33 | 171 | 419 | 287 |
Top nets by iteration-attributed nodes expanded:
- `net_09`: 250
- `net_03`: 199
- `net_00`: 177
- `net_08`: 166
- `net_07`: 140
- `net_06`: 105
- `net_02`: 79
- `net_01`: 65
- `net_05`: 12
- `net_04`: 10
Top nets by iteration-attributed congestion checks:
- `net_03`: 639
- `net_07`: 454
- `net_06`: 382
- `net_02`: 290
- `net_08`: 283
- `net_09`: 178
- `net_01`: 135
- `net_00`: 82
- `net_05`: 47
- `net_04`: 40

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,30 +0,0 @@
# Pair-Local Search Characterization
Generated at 2026-04-02T15:53:29-07:00 by `scripts/characterize_pair_local_search.py`.
Grid: `num_nets=[6, 8, 10]`, `seed=[41, 42, 43]`, repeats=2.
| Nets | Seed | Repeat | Duration (s) | Valid | Reached | Pair Pairs | Pair Accepts | Pair Nodes | Nodes | Checks |
| :-- | :-- | :-- | --: | --: | --: | --: | --: | --: | --: | --: |
| 6 | 41 | 0 | 0.5462 | 1 | 6 | 0 | 0 | 0 | 500 | 674 |
| 6 | 41 | 1 | 0.5256 | 1 | 6 | 0 | 0 | 0 | 500 | 674 |
| 6 | 42 | 0 | 0.5241 | 1 | 6 | 0 | 0 | 0 | 503 | 683 |
| 6 | 42 | 1 | 0.5477 | 1 | 6 | 0 | 0 | 0 | 503 | 683 |
| 6 | 43 | 0 | 0.5199 | 1 | 6 | 0 | 0 | 0 | 493 | 654 |
| 6 | 43 | 1 | 0.5160 | 1 | 6 | 0 | 0 | 0 | 493 | 654 |
| 8 | 41 | 0 | 1.8818 | 8 | 8 | 2 | 2 | 38 | 1558 | 4313 |
| 8 | 41 | 1 | 1.8618 | 8 | 8 | 2 | 2 | 38 | 1558 | 4313 |
| 8 | 42 | 0 | 1.4850 | 8 | 8 | 1 | 1 | 19 | 1440 | 3799 |
| 8 | 42 | 1 | 1.4636 | 8 | 8 | 1 | 1 | 19 | 1440 | 3799 |
| 8 | 43 | 0 | 1.0652 | 8 | 8 | 0 | 0 | 0 | 939 | 1844 |
| 8 | 43 | 1 | 1.0502 | 8 | 8 | 0 | 0 | 0 | 939 | 1844 |
| 10 | 41 | 0 | 2.8617 | 8 | 10 | 2 | 2 | 41 | 2223 | 6208 |
| 10 | 41 | 1 | 2.8282 | 8 | 10 | 2 | 2 | 41 | 2223 | 6208 |
| 10 | 42 | 0 | 2.0356 | 10 | 10 | 2 | 2 | 68 | 1764 | 4625 |
| 10 | 42 | 1 | 2.0052 | 10 | 10 | 2 | 2 | 68 | 1764 | 4625 |
| 10 | 43 | 0 | 50.1863 | 10 | 10 | 2 | 2 | 38 | 61259 | 165223 |
| 10 | 43 | 1 | 50.4019 | 10 | 10 | 2 | 2 | 38 | 61259 | 165223 |
## Recommendation
No smaller stable pair-local smoke scenario satisfied the rule `valid_results == total_results`, `pair_local_search_accepts >= 1`, and `duration_s <= 1.0` across all repeats.

View file

@ -1,27 +0,0 @@
# Performance Baseline
Generated on 2026-04-02 by `scripts/record_performance_baseline.py`.
The full machine-readable snapshot lives in `docs/performance_baseline.json`.
Use `scripts/diff_performance_baseline.py` to compare a fresh run against that snapshot.
| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |
| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| example_01_simple_route | 0.0037 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 5 |
| example_02_congestion_resolution | 0.3361 | 3 | 3 | 3 | 1 | 3 | 366 | 1164 | 1413 | 668 | 0 | 0 | 0 | 41 |
| example_03_locked_paths | 0.1877 | 2 | 2 | 2 | 2 | 2 | 191 | 657 | 904 | 307 | 0 | 0 | 0 | 18 |
| example_04_sbends_and_radii | 0.0269 | 2 | 2 | 2 | 1 | 2 | 15 | 70 | 123 | 65 | 0 | 0 | 0 | 10 |
| example_05_orientation_stress | 0.2311 | 3 | 3 | 3 | 2 | 5 | 297 | 1274 | 1680 | 689 | 0 | 0 | 146 | 20 |
| example_06_bend_collision_models | 0.1988 | 3 | 3 | 3 | 3 | 3 | 240 | 682 | 1026 | 629 | 0 | 0 | 0 | 15 |
| example_07_large_scale_routing | 0.2088 | 10 | 10 | 10 | 1 | 10 | 78 | 383 | 372 | 227 | 0 | 0 | 0 | 50 |
| example_08_custom_bend_geometry | 0.0177 | 2 | 2 | 2 | 2 | 2 | 18 | 56 | 78 | 56 | 0 | 0 | 0 | 10 |
| example_09_unroutable_best_effort | 0.0057 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 0 | 0 | 0 | 1 |
## Full Counter Set
Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.
These counters are currently observational only and are not enforced as CI regression gates.
Tracked metric keys:
nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, iteration_reverify_calls, iteration_reverified_nets, iteration_conflicting_nets, iteration_conflict_edges, nets_carried_forward, score_component_calls, score_component_total_ns, path_cost_calls, danger_map_lookup_calls, danger_map_cache_hits, danger_map_cache_misses, danger_map_query_calls, danger_map_total_ns, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, guidance_match_moves, guidance_match_moves_straight, guidance_match_moves_bend90, guidance_match_moves_sbend, guidance_bonus_applied, guidance_bonus_applied_straight, guidance_bonus_applied_bend90, guidance_bonus_applied_sbend, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_cache_misses, congestion_presence_cache_hits, congestion_presence_cache_misses, congestion_presence_skips, congestion_candidate_precheck_hits, congestion_candidate_precheck_misses, congestion_candidate_precheck_skips, congestion_grid_net_cache_hits, congestion_grid_net_cache_misses, congestion_grid_span_cache_hits, congestion_grid_span_cache_misses, congestion_candidate_nets, congestion_net_envelope_cache_hits, congestion_net_envelope_cache_misses, dynamic_path_objects_added, dynamic_path_objects_removed, dynamic_tree_rebuilds, dynamic_grid_rebuilds, static_tree_rebuilds, static_raw_tree_rebuilds, static_net_tree_rebuilds, visibility_corner_index_builds, visibility_builds, visibility_corner_pairs_checked, visibility_corner_queries_exact, visibility_corner_hits_exact, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, visibility_tangent_candidate_scans, visibility_tangent_candidate_corner_checks, visibility_tangent_candidate_ray_tests, ray_cast_calls, ray_cast_calls_straight_static, ray_cast_calls_expand_snap, ray_cast_calls_expand_forward, ray_cast_calls_visibility_build, ray_cast_calls_visibility_query, ray_cast_calls_visibility_tangent, ray_cast_calls_other, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_lazy_resolutions, congestion_lazy_requeues, congestion_candidate_ids, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_candidate_nets, verify_dynamic_exact_pair_checks, refinement_windows_considered, refinement_static_bounds_checked, refinement_dynamic_bounds_checked, refinement_candidate_side_extents, refinement_candidates_built, refinement_candidates_verified, refinement_candidates_accepted, pair_local_search_pairs_considered, pair_local_search_attempts, pair_local_search_accepts, pair_local_search_nodes_expanded, late_phase_capped_nets, late_phase_capped_fallbacks

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,48 +0,0 @@
# Pre-Pair Frontier Trace
Generated at 2026-04-02T18:51:01-07:00 by `scripts/record_pre_pair_frontier_trace.py`.
## example_07_large_scale_routing_no_warm_start
Results: 10 valid / 10 reached / 10 total.
Captured iteration: `4`
Conflict edges: `(('net_01', 'net_02'), ('net_06', 'net_07'))`
| Net | Nodes | Checks | Closed-Set | Cost | Hard Collision | Guidance Seed | Frontier Samples |
| :-- | --: | --: | --: | --: | --: | :--: | --: |
| net_07 | 7 | 30 | 1 | 0 | 0 | yes | 0 |
| net_06 | 46 | 179 | 7 | 15 | 0 | yes | 5 |
| net_00 | 10 | 43 | 1 | 0 | 0 | yes | 0 |
| net_01 | 18 | 80 | 3 | 0 | 0 | yes | 0 |
Frontier prune totals by reason:
- `closed_set`: 5
- `hard_collision`: 0
- `self_collision`: 0
- `cost`: 0
## example_07_large_scale_routing_no_warm_start_seed43
Results: 10 valid / 10 reached / 10 total.
Captured iteration: `5`
Conflict edges: `(('net_02', 'net_03'), ('net_06', 'net_07'))`
| Net | Nodes | Checks | Closed-Set | Cost | Hard Collision | Guidance Seed | Frontier Samples |
| :-- | --: | --: | --: | --: | --: | :--: | --: |
| net_07 | 16 | 85 | 3 | 0 | 0 | yes | 2 |
| net_02 | 17 | 86 | 4 | 0 | 0 | yes | 3 |
| net_06 | 0 | 0 | 0 | 0 | 0 | yes | 8 |
| net_03 | 0 | 0 | 0 | 0 | 0 | yes | 12 |
Frontier prune totals by reason:
- `closed_set`: 25
- `hard_collision`: 0
- `self_collision`: 0
- `cost`: 0

View file

@ -1,32 +0,0 @@
from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 01: Simple Route...")
bounds = (0, 0, 100, 100)
netlist = {
"net1": (Port(10, 50, 0), Port(90, 50, 0)),
}
problem = RoutingProblem(
bounds=bounds,
nets=(NetSpec("net1", *netlist["net1"], width=2.0),),
)
options = RoutingOptions(search=SearchOptions(bend_radii=(10.0,)))
run = route(problem, options=options)
result = run.results_by_net["net1"]
if result.is_valid:
print("Success! Route found.")
print(f"Path collisions: {result.collisions}")
else:
print("Failed to find route.")
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
fig.savefig("examples/01_simple_route.png")
print("Saved plot to examples/01_simple_route.png")
if __name__ == "__main__":
main()

View file

@ -1,44 +0,0 @@
from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 02: Congestion Resolution (Triple Crossing)...")
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)),
}
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=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(base_penalty=1000.0),
)
run = route(problem, options=options)
all_valid = all(result.is_valid for result in run.results_by_net.values())
if all_valid:
print("Success! Congestion resolved for all nets.")
else:
print("Failed to resolve congestion for some nets.")
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
fig.savefig("examples/02_congestion_resolution.png")
print("Saved plot to examples/02_congestion_resolution.png")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View file

@ -1,45 +0,0 @@
from inire import NetSpec, Port, RoutingOptions, RoutingProblem, SearchOptions
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router._stack import build_routing_stack
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 03: Locked Paths...")
bounds = (0, -50, 100, 50)
print("Routing initial net...")
stack = build_routing_stack(
problem=RoutingProblem(
bounds=bounds,
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
),
options=RoutingOptions(search=SearchOptions(bend_radii=(10.0,))),
)
engine = stack.world
evaluator = stack.evaluator
results_a = stack.finder.route_all()
print("Routing detour net around locked path...")
for polygon in results_a["netA"].locked_geometry:
engine.add_static_obstacle(polygon)
results_b = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=bounds,
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
),
RoutingOptions(search=SearchOptions(bend_radii=(10.0,))),
),
).route_all()
results = {**results_a, **results_b}
fig, ax = plot_routing_results(results, [], bounds)
fig.savefig("examples/03_locked_paths.png")
print("Saved plot to examples/03_locked_paths.png")
if __name__ == "__main__":
main()

View file

@ -1,41 +0,0 @@
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 04: S-Bends and Multiple Radii...")
bounds = (0, 0, 100, 100)
netlist = {
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
}
problem = RoutingProblem(
bounds=bounds,
nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()),
)
options = RoutingOptions(
search=SearchOptions(
node_limit=50000,
bend_radii=(10.0, 30.0),
sbend_offsets=(5.0,),
),
objective=ObjectiveWeights(
unit_length_cost=1.0,
bend_penalty=10.0,
sbend_penalty=20.0,
),
)
run = route(problem, options=options)
for net_id, result in run.results_by_net.items():
status = "Success" if result.is_valid else "Failed"
print(f"{net_id}: {status}, collisions={result.collisions}")
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
fig.savefig("examples/04_sbends_and_radii.png")
print("Saved plot to examples/04_sbends_and_radii.png")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View file

@ -1,35 +0,0 @@
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 05: Orientation Stress Test...")
bounds = (0, 0, 200, 200)
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
problem = RoutingProblem(
bounds=bounds,
nets=tuple(NetSpec(net_id, start, target, width=2.0) for net_id, (start, target) in netlist.items()),
)
options = RoutingOptions(
search=SearchOptions(bend_radii=(20.0,)),
objective=ObjectiveWeights(bend_penalty=50.0),
)
print("Routing complex orientation nets...")
run = route(problem, options=options)
for net_id, result in run.results_by_net.items():
status = "Success" if result.is_valid else "Failed"
print(f" {net_id}: {status}")
fig, _ax = plot_routing_results(run.results_by_net, [], bounds, netlist=netlist)
fig.savefig("examples/05_orientation_stress.png")
print("Saved plot to examples/05_orientation_stress.png")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

View file

@ -1,79 +0,0 @@
from shapely.geometry import Polygon
from inire import CongestionOptions, NetSpec, ObjectiveWeights, RoutingOptions, RoutingProblem, RoutingResult, SearchOptions, route
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry
from inire.geometry.primitives import Port
from inire.utils.visualization import plot_routing_results
def _route_scenario(
bounds: tuple[float, float, float, float],
obstacles: list[Polygon],
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
*,
bend_collision_type: BendCollisionModel = "arc",
bend_proxy_geometry: BendCollisionModel | None = None,
bend_physical_geometry: BendPhysicalGeometry | None = None,
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_proxy_geometry=bend_proxy_geometry,
bend_physical_geometry=bend_physical_geometry,
bend_clip_margin=bend_clip_margin,
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(use_tiered_strategy=False),
)
return route(problem, options=options).results_by_net
def main() -> None:
print("Running Example 06: Bend Collision Models...")
bounds = (-20, -20, 170, 170)
obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)])
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
obs_custom = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
custom_bend = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
obstacles = [obs_arc, obs_bbox, obs_custom]
netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}
netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}
netlist_custom = {"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))}
print("Routing Scenario 1 (Arc)...")
res_arc = _route_scenario(bounds, obstacles, netlist_arc, {"arc_model": 2.0}, bend_collision_type="arc")
print("Routing Scenario 2 (BBox)...")
res_bbox = _route_scenario(bounds, obstacles, netlist_bbox, {"bbox_model": 2.0}, bend_collision_type="bbox")
print("Routing Scenario 3 (Custom Manhattan Geometry With Matching Proxy)...")
res_custom = _route_scenario(
bounds,
obstacles,
netlist_custom,
{"custom_geometry": 2.0},
bend_physical_geometry=custom_bend,
bend_proxy_geometry=custom_bend,
)
all_results = {**res_arc, **res_bbox, **res_custom}
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_custom}
fig, _ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
fig.savefig("examples/06_bend_collision_models.png")
print("Saved plot to examples/06_bend_collision_models.png")
if __name__ == "__main__":
main()

View file

@ -1,120 +0,0 @@
import time
from shapely.geometry import box
from inire import (
NetSpec,
Port,
RoutingProblem,
RoutingResult,
)
from inire.router._stack import build_routing_stack
from inire.utils.visualization import plot_expanded_nodes, plot_routing_results
def main() -> None:
print("Running Example 07: Fan-Out (10 Nets, 50um Radius)...")
bounds = (0, 0, 1000, 1000)
obstacles = [
box(450, 0, 550, 400),
box(450, 600, 550, 1000),
]
num_nets = 10
start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950
end_y_base = 100
end_y_pitch = 800.0 / (num_nets - 1)
netlist: dict[str, tuple[Port, Port]] = {}
for index in range(num_nets):
start_y = int(round(start_y_base + index * 10.0))
end_y = int(round(end_y_base + index * end_y_pitch))
netlist[f"net_{index:02d}"] = (Port(start_x, start_y, 0), Port(end_x, end_y, 0))
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
options = RoutingOptions(
search=SearchOptions(
node_limit=2_000_000,
bend_radii=(50.0,),
sbend_radii=(50.0,),
greedy_h_weight=1.5,
bend_clip_margin=10.0,
),
objective=ObjectiveWeights(
unit_length_cost=0.1,
bend_penalty=100.0,
sbend_penalty=400.0,
),
congestion=CongestionOptions(
max_iterations=15,
base_penalty=100.0,
multiplier=1.4,
net_order="shortest",
shuffle_nets=True,
seed=42,
),
diagnostics=DiagnosticsOptions(capture_expanded=True),
)
stack = build_routing_stack(problem, options)
evaluator = stack.evaluator
finder = stack.finder
metrics = finder.metrics
iteration_stats: list[dict[str, int]] = []
def iteration_callback(iteration: int, current_results: dict[str, RoutingResult]) -> None:
successes = sum(1 for result in current_results.values() if result.is_valid)
total_collisions = sum(result.collisions for result in current_results.values())
total_nodes = metrics.nodes_expanded
print(f" Iteration {iteration} finished. Successes: {successes}/{len(netlist)}, Collisions: {total_collisions}")
new_greedy = max(1.1, 1.5 - ((iteration + 1) / 10.0) * 0.4)
evaluator.greedy_h_weight = new_greedy
print(f" Adaptive Greedy Weight for Next Iteration: {new_greedy:.3f}")
iteration_stats.append(
{
"Iteration": iteration,
"Success": successes,
"Congestion": total_collisions,
"Nodes": total_nodes,
}
)
metrics.reset_per_route()
print(f"Routing {len(netlist)} nets through 200um bottleneck...")
start_time = time.perf_counter()
results = finder.route_all(iteration_callback=iteration_callback)
end_time = time.perf_counter()
print(f"Routing took {end_time - start_time:.4f}s")
print("\n--- Iteration Summary ---")
print(f"{'Iter':<5} | {'Success':<8} | {'Congest':<8} | {'Nodes':<10}")
print("-" * 43)
for stats in iteration_stats:
print(f"{stats['Iteration']:<5} | {stats['Success']:<8} | {stats['Congestion']:<8} | {stats['Nodes']:<10}")
success_count = sum(1 for result in results.values() if result.is_valid)
print(f"\nFinal: Routed {success_count}/{len(netlist)} nets successfully.")
for net_id, result in results.items():
if not result.is_valid:
print(f" FAILED: {net_id}, collisions={result.collisions}")
else:
print(f" {net_id}: SUCCESS")
fig, ax = plot_routing_results(results, list(obstacles), bounds, netlist=netlist)
plot_expanded_nodes(list(finder.accumulated_expanded_nodes), ax=ax)
fig.savefig("examples/07_large_scale_routing.png")
print("Saved plot to examples/07_large_scale_routing.png")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View file

@ -1,72 +0,0 @@
from shapely.geometry import Polygon, box
from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry
from inire.geometry.primitives import Port
from inire.utils.visualization import plot_routing_results
def _run_session(
bounds: tuple[float, float, float, float],
net_id: str,
start: Port,
target: Port,
*,
bend_collision_type: BendCollisionModel = "arc",
bend_proxy_geometry: BendCollisionModel | None = None,
bend_physical_geometry: BendPhysicalGeometry | None = None,
) -> dict[str, object]:
problem = RoutingProblem(
bounds=bounds,
nets=(NetSpec(net_id, start, target, width=2.0),),
)
options = RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
bend_collision_type=bend_collision_type,
bend_proxy_geometry=bend_proxy_geometry,
bend_physical_geometry=bend_physical_geometry,
sbend_radii=(),
),
congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False),
)
return route(problem, options=options).results_by_net
def main() -> None:
print("Running Example 08: Custom Bend Geometry...")
bounds = (0, 0, 150, 150)
start = Port(20, 20, 0)
target = Port(100, 100, 90)
custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
custom_proxy = box(0, -11, 11, 0)
print("Routing standard arc in its own session...")
results_std = _run_session(bounds, "standard_arc", start, target)
print("Routing custom geometry with a separate custom proxy in its own session...")
results_custom = _run_session(
bounds,
"custom_geometry_and_proxy",
start,
target,
bend_physical_geometry=custom_physical,
bend_proxy_geometry=custom_proxy,
)
all_results = {**results_std, **results_custom}
fig, _ax = plot_routing_results(
all_results,
[],
bounds,
netlist={
"standard_arc": (start, target),
"custom_geometry_and_proxy": (start, target),
},
)
fig.savefig("examples/08_custom_bend_geometry.png")
print("Saved plot to examples/08_custom_bend_geometry.png")
if __name__ == "__main__":
main()

View file

@ -1,51 +0,0 @@
from shapely.geometry import box
from inire import CongestionOptions, NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 09: Best-Effort Under Tight Search Budget...")
bounds = (0, 0, 100, 100)
obstacles = [
box(35, 35, 45, 65),
box(55, 35, 65, 65),
]
problem = RoutingProblem(
bounds=bounds,
nets=(NetSpec("budget_limited_net", Port(10, 50, 0), Port(85, 60, 180), width=2.0),),
static_obstacles=tuple(obstacles),
)
options = RoutingOptions(
search=SearchOptions(
node_limit=3,
bend_radii=(10.0,),
),
objective=ObjectiveWeights(
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
)
print("Routing with a deliberately tiny node budget (should return a partial path)...")
run = route(problem, options=options)
result = run.results_by_net["budget_limited_net"]
if not result.reached_target:
print(f"Target not reached as expected. Partial path length: {len(result.path)} segments.")
else:
print("The route unexpectedly reached the target. Increase difficulty or reduce the node budget further.")
fig, _ax = plot_routing_results(
run.results_by_net,
list(obstacles),
bounds,
netlist={"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))},
)
fig.savefig("examples/09_unroutable_best_effort.png")
print("Saved plot to examples/09_unroutable_best_effort.png")
if __name__ == "__main__":
main()

View file

@ -1,39 +0,0 @@
# Inire Routing Examples
This directory contains examples demonstrating the features and architectural capabilities of the `inire` router.
## Architectural Visualization
In all plots generated by `inire`, we distinguish between the search-time geometry and the final "actual" geometry:
* **Dashed Lines & Translucent Fill**: The **Collision Proxy** used during the A* search (e.g., `clipped_bbox` or `bbox`). This represents the conservative envelope the router used to guarantee clearance.
* **Solid Lines**: The **Actual Geometry** (high-fidelity arcs). This is the exact shape that will be used for PDK generation and fabrication.
---
## 1. Fan-Out (Negotiated Congestion)
Demonstrates the Negotiated Congestion algorithm handling multiple intersecting nets. The router iteratively increases penalties for overlaps until a collision-free solution is found. This example shows a bundle of nets fanning out through a narrow bottleneck.
![Fan-Out Routing](07_large_scale_routing.png)
## 2. Bend Geometry Models
`inire` supports multiple collision models for bends, allowing a trade-off between search speed and geometric accuracy:
* **Arc**: High-fidelity geometry (Highest accuracy).
* **BBox**: Simple axis-aligned bounding box (Fastest search).
* **Custom Manhattan Geometry**: A custom 90-degree bend polygon with the same width as the normal waveguide.
Example 06 uses the Manhattan polygon as both the true routed bend geometry and the collision proxy.
Example 08 compares the standard arc against a run that uses a custom physical bend plus a separate custom proxy polygon, with each net routed in its own session.
![Custom Bend Geometry](08_custom_bend_geometry.png)
## 3. Unroutable Nets & Best-Effort Display
When a net is physically blocked or exceeds the node limit, the router returns the "best-effort" partial path—the path that reached the point closest to the target according to the heuristic. This is critical for debugging design constraints.
![Best Effort Display](09_unroutable_best_effort.png)
## 4. Orientation Stress Test
Demonstrates the router's ability to handle complex orientation requirements, including U-turns, 90-degree flips, and loops.
![Orientation Stress Test](05_orientation_stress.png)
## 5. Tiered Fidelity
The current implementation can use a cheaper bend proxy on the first negotiated-congestion pass before later passes fall back to the configured bend model. This is controlled by `RoutingOptions.congestion.use_tiered_strategy` together with the bend collision settings described in `DOCS.md`.

View file

@ -1,84 +1,6 @@
""" """
inire Wave-router inire Wave-router
""" """
from collections.abc import Callable
from .geometry.primitives import Port as Port # noqa: PLC0414
from .model import (
CongestionOptions as CongestionOptions,
DiagnosticsOptions as DiagnosticsOptions,
NetSpec as NetSpec,
ObjectiveWeights as ObjectiveWeights,
RefinementOptions as RefinementOptions,
RoutingOptions as RoutingOptions,
RoutingProblem as RoutingProblem,
SearchOptions as SearchOptions,
) # noqa: PLC0414
from .results import ( # noqa: PLC0414
ComponentConflictTrace as ComponentConflictTrace,
ConflictTraceEntry as ConflictTraceEntry,
FrontierPruneSample as FrontierPruneSample,
IterationNetAttemptTrace as IterationNetAttemptTrace,
IterationTraceEntry as IterationTraceEntry,
NetConflictTrace as NetConflictTrace,
NetFrontierTrace as NetFrontierTrace,
PrePairFrontierTraceEntry as PrePairFrontierTraceEntry,
PrePairNetTrace as PrePairNetTrace,
RoutingResult as RoutingResult,
RoutingRunResult as RoutingRunResult,
)
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),
conflict_trace=tuple(finder.conflict_trace),
frontier_trace=tuple(finder.frontier_trace),
pre_pair_frontier_trace=finder.pre_pair_frontier_trace,
iteration_trace=tuple(finder.iteration_trace),
)
__all__ = [
"Bend90Seed",
"CongestionOptions",
"ComponentConflictTrace",
"ConflictTraceEntry",
"DiagnosticsOptions",
"NetSpec",
"NetConflictTrace",
"NetFrontierTrace",
"ObjectiveWeights",
"PathSeed",
"Port",
"FrontierPruneSample",
"IterationNetAttemptTrace",
"IterationTraceEntry",
"PrePairFrontierTraceEntry",
"PrePairNetTrace",
"RefinementOptions",
"RoutingOptions",
"RoutingProblem",
"RoutingResult",
"RoutingRunResult",
"SBendSeed",
"SearchOptions",
"StraightSeed",
"route",
]

View file

@ -1,6 +0,0 @@
"""
Centralized constants for the inire routing engine.
"""
TOLERANCE_LINEAR = 1e-6
TOLERANCE_ANGULAR = 1e-3

View file

@ -1,864 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy
from shapely.geometry import LineString, box
from inire.geometry.component_overlap import components_overlap
from inire.geometry.dynamic_path_index import DynamicPathIndex
from inire.geometry.index_helpers import grid_cell_span
from inire.results import RoutingReport
from inire.geometry.static_obstacle_index import StaticObstacleIndex
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry
from shapely.strtree import STRtree
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
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))
def _bounds_overlap(
left: tuple[float, float, float, float],
right: tuple[float, float, float, float],
) -> bool:
return (
left[0] < right[2]
and left[2] > right[0]
and left[1] < right[3]
and left[3] > right[1]
)
def _has_non_touching_overlap(left: BaseGeometry, right: BaseGeometry) -> bool:
return left.intersects(right) and not left.touches(right)
def _span_to_bounds(
gx_min: int,
gy_min: int,
gx_max: int,
gy_max: int,
cell_size: float,
) -> tuple[float, float, float, float]:
return (
gx_min * cell_size,
gy_min * cell_size,
(gx_max + 1) * cell_size,
(gy_max + 1) * cell_size,
)
@dataclass(frozen=True, slots=True)
class PathVerificationDetail:
report: RoutingReport
conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[tuple[int, str, int], ...] = ()
@dataclass(frozen=True, slots=True)
class DynamicCongestionDetail:
soft_overlap_count: int = 0
hits_frozen_net: bool = False
class RoutingWorld:
"""
Internal spatial state for collision detection, congestion, and verification.
"""
__slots__ = (
"clearance",
"safety_zone_radius",
"grid_cell_size",
"metrics",
"_dynamic_paths",
"_static_obstacles",
)
def __init__(
self,
clearance: float,
safety_zone_radius: float = 0.0021,
) -> None:
self.clearance = clearance
self.safety_zone_radius = safety_zone_radius
self.grid_cell_size = 50.0
self.metrics = None
self._static_obstacles = StaticObstacleIndex(self)
self._dynamic_paths = DynamicPathIndex(self)
def get_static_version(self) -> int:
return self._static_obstacles.version
def iter_static_dilated_geometries(self) -> Iterable[Polygon]:
return self._static_obstacles.dilated.values()
def iter_static_obstacle_bounds(
self,
query_bounds: tuple[float, float, float, float],
) -> Iterable[tuple[float, float, float, float]]:
for obj_id in self._static_obstacles.index.intersection(query_bounds):
yield self._static_obstacles.geometries[obj_id].bounds
def iter_dynamic_path_bounds(
self,
query_bounds: tuple[float, float, float, float],
) -> Iterable[tuple[float, float, float, float]]:
for obj_id in self._dynamic_paths.index.intersection(query_bounds):
yield self._dynamic_paths.geometries[obj_id][1].bounds
def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry)
def remove_static_obstacle(self, obj_id: int) -> None:
self._static_obstacles.remove_obstacle(obj_id)
def _ensure_static_tree(self) -> None:
self._static_obstacles.ensure_tree()
def _ensure_net_static_tree(self, net_width: float) -> STRtree:
return self._static_obstacles.ensure_net_tree(net_width)
def _ensure_static_raw_tree(self) -> None:
self._static_obstacles.ensure_raw_tree()
def _ensure_dynamic_tree(self) -> None:
self._dynamic_paths.ensure_tree()
def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid()
def add_path(
self,
net_id: str,
geometry: Sequence[Polygon],
dilated_geometry: Sequence[Polygon],
component_indexes: Sequence[int] | None = None,
) -> None:
self._dynamic_paths.add_path(
net_id,
geometry,
dilated_geometry=dilated_geometry,
component_indexes=component_indexes,
)
def remove_path(self, net_id: str) -> None:
self._dynamic_paths.remove_path(net_id)
def has_dynamic_paths(self) -> bool:
return bool(self._dynamic_paths.geometries)
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
reach = self.ray_cast(
start_port,
start_port.r,
max_dist=length + 0.01,
net_width=net_width,
caller="straight_static",
)
return reach < length - 0.001
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
bounds_array = self._static_obstacles.bounds_array
if bounds_array is None:
return False
bounds = bounds_array[idx]
safety_zone = self.safety_zone_radius
if (
start_port
and bounds[0] - safety_zone <= start_port.x <= bounds[2] + safety_zone
and bounds[1] - safety_zone <= start_port.y <= bounds[3] + safety_zone
):
return True
return bool(
end_port
and bounds[0] - safety_zone <= end_port.x <= bounds[2] + safety_zone
and bounds[1] - safety_zone <= end_port.y <= bounds[3] + safety_zone
)
def _is_in_safety_zone(
self,
geometry: Polygon,
obj_id: int,
start_port: Port | None,
end_port: Port | None,
) -> bool:
raw_obstacle = self._static_obstacles.geometries[obj_id]
safety_zone = self.safety_zone_radius
obstacle_bounds = raw_obstacle.bounds
near_start = start_port and (
obstacle_bounds[0] - safety_zone <= start_port.x <= obstacle_bounds[2] + safety_zone
and obstacle_bounds[1] - safety_zone <= start_port.y <= obstacle_bounds[3] + safety_zone
)
near_end = end_port and (
obstacle_bounds[0] - safety_zone <= end_port.x <= obstacle_bounds[2] + safety_zone
and obstacle_bounds[1] - safety_zone <= end_port.y <= obstacle_bounds[3] + safety_zone
)
if not near_start and not near_end:
return False
if not geometry.intersects(raw_obstacle):
return False
intersection = geometry.intersection(raw_obstacle)
if intersection.is_empty:
return False
ix_bounds = intersection.bounds
if (
start_port
and near_start
and abs(ix_bounds[0] - start_port.x) < safety_zone
and abs(ix_bounds[1] - start_port.y) < safety_zone
and abs(ix_bounds[2] - start_port.x) < safety_zone
and abs(ix_bounds[3] - start_port.y) < safety_zone
):
return True
return bool(
end_port
and near_end
and abs(ix_bounds[0] - end_port.x) < safety_zone
and abs(ix_bounds[1] - end_port.y) < safety_zone
and abs(ix_bounds[2] - end_port.x) < safety_zone
and abs(ix_bounds[3] - end_port.y) < safety_zone
)
def check_move_static(
self,
result: ComponentResult,
start_port: Port | None = None,
end_port: Port | None = None,
) -> bool:
# TODO: If static buffering becomes net-width-specific, add dedicated
# width-aware geometry/index handling instead of reviving dead args here.
static_obstacles = self._static_obstacles
if not static_obstacles.dilated:
return False
self._ensure_static_tree()
tree = static_obstacles.tree
bounds_array = static_obstacles.bounds_array
if tree is None or bounds_array is None:
return False
hits = tree.query(box(*result.total_dilated_bounds))
if hits.size == 0:
return False
move_poly_bounds = result.dilated_bounds
for hit_idx in hits:
obstacle_bounds = bounds_array[hit_idx]
poly_hits_obstacle_aabb = False
for poly_bounds in move_poly_bounds:
if (
poly_bounds[0] < obstacle_bounds[2]
and poly_bounds[2] > obstacle_bounds[0]
and poly_bounds[1] < obstacle_bounds[3]
and poly_bounds[3] > obstacle_bounds[1]
):
poly_hits_obstacle_aabb = True
break
if not poly_hits_obstacle_aabb:
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if self._is_in_safety_zone_fast(hit_idx, start_port, end_port):
collision_found = False
for polygon in result.collision_geometry:
if not self._is_in_safety_zone(polygon, obj_id, start_port, end_port):
collision_found = True
break
if collision_found:
return True
continue
static_obstacle = static_obstacles.dilated[obj_id]
for polygon in result.dilated_collision_geometry:
if polygon.intersects(static_obstacle) and not polygon.touches(static_obstacle):
return True
return False
def _check_real_congestion(
self,
result: ComponentResult,
candidates_by_net: dict[str, dict[int, tuple[int, ...]]],
frozen_net_ids: frozenset[str] = frozenset(),
) -> DynamicCongestionDetail:
if not candidates_by_net:
return DynamicCongestionDetail()
dynamic_paths = self._dynamic_paths
geometries_to_test = result.dilated_collision_geometry
real_hits_count = 0
for other_net_id, other_obj_ids in candidates_by_net.items():
found_real = False
for obj_id, test_geometry_indexes in other_obj_ids.items():
tree_geometry = dynamic_paths.dilated[obj_id]
for test_geometry_index in test_geometry_indexes:
test_geometry = geometries_to_test[test_geometry_index]
if self.metrics is not None:
self.metrics.total_congestion_exact_pair_checks += 1
if _has_non_touching_overlap(test_geometry, tree_geometry):
found_real = True
break
if found_real:
break
if found_real:
if other_net_id in frozen_net_ids:
return DynamicCongestionDetail(
soft_overlap_count=real_hits_count,
hits_frozen_net=True,
)
real_hits_count += 1
return DynamicCongestionDetail(soft_overlap_count=real_hits_count)
def _collect_congestion_candidates(
self,
result: ComponentResult,
net_id: str,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None,
) -> dict[str, dict[int, tuple[int, ...]]]:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return {}
self._ensure_dynamic_grid()
if not dynamic_paths.grid:
return {}
candidates_by_net: dict[str, dict[int, set[int]]] = {}
for test_geometry_index, test_bounds in enumerate(result.dilated_bounds):
if not self._has_possible_congestion_in_grid(test_bounds, net_id):
continue
envelope_net_ids = self._get_net_envelope_candidates(
test_bounds,
net_id,
net_envelope_cache,
)
if not envelope_net_ids:
continue
grid_net_ids = self._get_grid_span_net_candidates(
test_bounds,
net_id,
grid_net_cache,
)
if not grid_net_ids:
continue
candidate_net_ids = tuple(sorted(set(envelope_net_ids) & set(grid_net_ids)))
if not candidate_net_ids:
continue
grid_candidates = self._get_grid_span_candidates(
test_bounds,
net_id,
broad_phase_cache,
)
for other_net_id in candidate_net_ids:
if self.metrics is not None:
self.metrics.total_congestion_candidate_nets += 1
obj_ids = grid_candidates.get(other_net_id)
if not obj_ids:
continue
for obj_id in obj_ids:
if not _bounds_overlap(test_bounds, dynamic_paths.dilated_bounds[obj_id]):
continue
if self.metrics is not None:
self.metrics.total_congestion_candidate_ids += 1
candidate_indexes = candidates_by_net.setdefault(other_net_id, {}).setdefault(obj_id, set())
candidate_indexes.add(test_geometry_index)
return {
other_net_id: {
obj_id: tuple(sorted(test_geometry_indexes))
for obj_id, test_geometry_indexes in sorted(obj_ids.items())
}
for other_net_id, obj_ids in candidates_by_net.items()
}
def _has_possible_congestion_in_grid(
self,
bounds: tuple[float, float, float, float],
net_id: str,
) -> bool:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
dynamic_paths = self._dynamic_paths
if gx_min == gx_max and gy_min == gy_max:
net_counts = dynamic_paths.grid_net_counts.get((gx_min, gy_min))
return bool(net_counts and (len(net_counts) > 1 or net_id not in net_counts))
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
net_counts = dynamic_paths.grid_net_counts.get((gx, gy))
if net_counts and (len(net_counts) > 1 or net_id not in net_counts):
return True
return False
def has_possible_move_congestion(
self,
result: ComponentResult,
net_id: str,
presence_cache: dict[tuple[str, int, int, int, int], bool] | None = None,
) -> bool:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return False
self._ensure_dynamic_grid()
if not dynamic_paths.grid:
return False
for bounds in result.dilated_bounds:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if presence_cache is not None and cache_key in presence_cache:
if self.metrics is not None:
self.metrics.total_congestion_presence_cache_hits += 1
has_possible = presence_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_presence_cache_misses += 1
has_possible = self._has_possible_congestion_in_grid(bounds, net_id)
if presence_cache is not None:
presence_cache[cache_key] = has_possible
if has_possible:
return True
return False
def has_candidate_move_congestion(
self,
result: ComponentResult,
net_id: str,
candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] | None = None,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
) -> bool:
dynamic_paths = self._dynamic_paths
if not dynamic_paths.dilated:
return False
for bounds in result.dilated_bounds:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if candidate_precheck_cache is not None and cache_key in candidate_precheck_cache:
if self.metrics is not None:
self.metrics.total_congestion_candidate_precheck_hits += 1
has_candidates = candidate_precheck_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_candidate_precheck_misses += 1
span_bounds = _span_to_bounds(gx_min, gy_min, gx_max, gy_max, self.grid_cell_size)
envelope_net_ids = self._get_net_envelope_candidates(
span_bounds,
net_id,
net_envelope_cache,
)
if not envelope_net_ids:
has_candidates = False
else:
grid_net_ids = self._get_grid_span_net_candidates(
span_bounds,
net_id,
grid_net_cache,
)
if not grid_net_ids:
has_candidates = False
else:
has_candidates = bool(set(envelope_net_ids) & set(grid_net_ids))
if candidate_precheck_cache is not None:
candidate_precheck_cache[cache_key] = has_candidates
if has_candidates:
return True
return False
def _get_grid_span_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None,
) -> dict[str, tuple[int, ...]]:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if broad_phase_cache is not None and cache_key in broad_phase_cache:
if self.metrics is not None:
self.metrics.total_congestion_grid_span_cache_hits += 1
return broad_phase_cache[cache_key]
if self.metrics is not None:
self.metrics.total_congestion_grid_span_cache_misses += 1
dynamic_paths = self._dynamic_paths
candidates_by_net: dict[str, set[int]] = {}
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
for other_net_id, obj_ids in dynamic_paths.grid_net_obj_ids.get((gx, gy), {}).items():
if other_net_id == net_id:
continue
candidates_by_net.setdefault(other_net_id, set()).update(obj_ids)
frozen = {
other_net_id: tuple(sorted(obj_ids))
for other_net_id, obj_ids in sorted(candidates_by_net.items())
}
if broad_phase_cache is not None:
broad_phase_cache[cache_key] = frozen
return frozen
def _get_grid_span_net_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None,
) -> tuple[str, ...]:
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if grid_net_cache is not None and cache_key in grid_net_cache:
if self.metrics is not None:
self.metrics.total_congestion_grid_net_cache_hits += 1
return grid_net_cache[cache_key]
if self.metrics is not None:
self.metrics.total_congestion_grid_net_cache_misses += 1
dynamic_paths = self._dynamic_paths
candidate_net_ids: set[str] = set()
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
for other_net_id in dynamic_paths.grid_net_obj_ids.get((gx, gy), {}):
if other_net_id != net_id:
candidate_net_ids.add(other_net_id)
frozen = tuple(sorted(candidate_net_ids))
if grid_net_cache is not None:
grid_net_cache[cache_key] = frozen
return frozen
def _get_net_envelope_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None,
) -> tuple[str, ...]:
dynamic_paths = self._dynamic_paths
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
cache_key = (net_id, gx_min, gy_min, gx_max, gy_max)
if net_envelope_cache is not None and cache_key in net_envelope_cache:
if self.metrics is not None:
self.metrics.total_congestion_net_envelope_cache_hits += 1
cached_net_ids = net_envelope_cache[cache_key]
else:
if self.metrics is not None:
self.metrics.total_congestion_net_envelope_cache_misses += 1
span_bounds = _span_to_bounds(gx_min, gy_min, gx_max, gy_max, self.grid_cell_size)
cached_net_ids = tuple(
sorted(
dynamic_paths.net_envelope_obj_to_net[obj_id]
for obj_id in dynamic_paths.net_envelope_index.intersection(span_bounds)
if dynamic_paths.net_envelope_obj_to_net[obj_id] != net_id
)
)
if net_envelope_cache is not None:
net_envelope_cache[cache_key] = cached_net_ids
return tuple(
other_net_id
for other_net_id in cached_net_ids
if _bounds_overlap(bounds, dynamic_paths.net_envelopes[other_net_id])
)
def _get_verify_net_envelope_candidates(
self,
bounds: tuple[float, float, float, float],
net_id: str,
) -> tuple[str, ...]:
dynamic_paths = self._dynamic_paths
candidate_net_ids: list[str] = []
for obj_id in dynamic_paths.net_envelope_index.intersection(bounds):
other_net_id = dynamic_paths.net_envelope_obj_to_net[obj_id]
if other_net_id == net_id:
continue
if not _bounds_overlap(bounds, dynamic_paths.net_envelopes[other_net_id]):
continue
candidate_net_ids.append(other_net_id)
if self.metrics is not None:
self.metrics.total_verify_dynamic_candidate_nets += len(candidate_net_ids)
return tuple(candidate_net_ids)
def _get_verify_grid_span_obj_ids(
self,
bounds: tuple[float, float, float, float],
other_net_id: str,
) -> tuple[int, ...]:
dynamic_paths = self._dynamic_paths
gx_min, gy_min, gx_max, gy_max = grid_cell_span(bounds, self.grid_cell_size)
obj_ids: set[int] = set()
for gx in range(gx_min, gx_max + 1):
for gy in range(gy_min, gy_max + 1):
obj_ids.update(dynamic_paths.grid_net_obj_ids.get((gx, gy), {}).get(other_net_id, ()))
return tuple(sorted(obj_ids))
def check_move_congestion(
self,
result: ComponentResult,
net_id: str,
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None,
) -> int:
return self.check_move_congestion_detail(
result,
net_id,
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=broad_phase_cache,
).soft_overlap_count
def check_move_congestion_detail(
self,
result: ComponentResult,
net_id: str,
*,
frozen_net_ids: frozenset[str] = frozenset(),
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] | None = None,
broad_phase_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] | None = None,
) -> DynamicCongestionDetail:
if self.metrics is not None:
self.metrics.total_congestion_check_calls += 1
dynamic_paths = self._dynamic_paths
if not dynamic_paths.geometries:
return DynamicCongestionDetail()
candidates_by_net = self._collect_congestion_candidates(
result,
net_id,
net_envelope_cache,
grid_net_cache,
broad_phase_cache,
)
if not candidates_by_net:
return DynamicCongestionDetail()
return self._check_real_congestion(
result,
candidates_by_net,
frozen_net_ids=frozen_net_ids,
)
def verify_path_details(
self,
net_id: str,
components: Sequence[ComponentResult],
*,
capture_component_conflicts: bool = False,
) -> PathVerificationDetail:
if self.metrics is not None:
self.metrics.total_verify_path_report_calls += 1
static_collision_count = 0
dynamic_collision_count = 0
self_collision_count = 0
total_length = sum(component.length for component in components)
conflicting_net_ids: set[str] = set()
component_conflicts: set[tuple[int, str, int]] = set()
static_obstacles = self._static_obstacles
dynamic_paths = self._dynamic_paths
self._ensure_static_raw_tree()
if static_obstacles.raw_tree is not None:
raw_geometries = static_obstacles.raw_tree.geometries
for component in components:
for polygon in component.physical_geometry:
if self.metrics is not None:
self.metrics.total_verify_static_buffer_ops += 1
buffered = polygon.buffer(self.clearance, join_style="mitre")
hits = static_obstacles.raw_tree.query(buffered, predicate="intersects")
for hit_idx in hits:
obstacle = raw_geometries[hit_idx]
if buffered.touches(obstacle):
continue
obj_id = static_obstacles.raw_obj_ids[hit_idx]
if not self._is_in_safety_zone(polygon, obj_id, None, None):
static_collision_count += 1
if dynamic_paths.dilated:
for component_index, component in enumerate(components):
test_geometries = component.dilated_physical_geometry
component_hits = []
for new_geometry in test_geometries:
for hit_net_id in self._get_verify_net_envelope_candidates(new_geometry.bounds, str(net_id)):
for obj_id in self._get_verify_grid_span_obj_ids(new_geometry.bounds, hit_net_id):
if not _bounds_overlap(new_geometry.bounds, dynamic_paths.dilated_bounds[obj_id]):
continue
if self.metrics is not None:
self.metrics.total_verify_dynamic_exact_pair_checks += 1
tree_geometry = dynamic_paths.dilated[obj_id]
if _has_non_touching_overlap(new_geometry, tree_geometry):
component_hits.append(hit_net_id)
if capture_component_conflicts:
component_conflicts.add(
(
component_index,
hit_net_id,
dynamic_paths.component_indexes[obj_id],
)
)
break
if component_hits:
unique_hits = tuple(sorted(set(component_hits)))
dynamic_collision_count += len(unique_hits)
conflicting_net_ids.update(unique_hits)
for index, component in enumerate(components):
for other_index in range(index + 2, len(components)):
if components_overlap(component, components[other_index], prefer_actual=True):
self_collision_count += 1
return PathVerificationDetail(
report=RoutingReport(
static_collision_count=static_collision_count,
dynamic_collision_count=dynamic_collision_count,
self_collision_count=self_collision_count,
total_length=total_length,
),
conflicting_net_ids=tuple(sorted(conflicting_net_ids)),
component_conflicts=tuple(sorted(component_conflicts)),
)
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
return self.verify_path_details(net_id, components).report
def ray_cast(
self,
origin: Port,
angle_deg: float,
max_dist: float = 2000.0,
net_width: float | None = None,
caller: str = "other",
) -> float:
if self.metrics is not None:
self.metrics.total_ray_cast_calls += 1
if caller == "straight_static":
self.metrics.total_ray_cast_calls_straight_static += 1
elif caller == "expand_snap":
self.metrics.total_ray_cast_calls_expand_snap += 1
elif caller == "expand_forward":
self.metrics.total_ray_cast_calls_expand_forward += 1
elif caller == "visibility_build":
self.metrics.total_ray_cast_calls_visibility_build += 1
elif caller == "visibility_query":
self.metrics.total_ray_cast_calls_visibility_query += 1
elif caller == "visibility_tangent":
self.metrics.total_ray_cast_calls_visibility_tangent += 1
else:
self.metrics.total_ray_cast_calls_other += 1
static_obstacles = self._static_obstacles
tree: STRtree | None
is_rect_array: numpy.ndarray | None
bounds_array: numpy.ndarray | None
radians = numpy.radians(angle_deg)
cos_v, sin_v = numpy.cos(radians), numpy.sin(radians)
dx, dy = max_dist * cos_v, max_dist * sin_v
min_x, max_x = sorted([origin.x, origin.x + dx])
min_y, max_y = sorted([origin.y, origin.y + dy])
if net_width is not None:
tree = self._ensure_net_static_tree(net_width)
key = (round(net_width, 4), round(self.clearance, 4))
is_rect_array = static_obstacles.net_specific_is_rect[key]
bounds_array = static_obstacles.net_specific_bounds[key]
else:
self._ensure_static_tree()
tree = static_obstacles.tree
is_rect_array = static_obstacles.is_rect_array
bounds_array = static_obstacles.bounds_array
if tree is None or is_rect_array is None or bounds_array is None:
return max_dist
candidates = tree.query(box(min_x, min_y, max_x, max_y))
if candidates.size == 0:
return max_dist
if self.metrics is not None:
self.metrics.total_ray_cast_candidate_bounds += int(candidates.size)
min_dist = max_dist
inv_dx = 1.0 / dx if abs(dx) > 1e-12 else 1e30
inv_dy = 1.0 / dy if abs(dy) > 1e-12 else 1e30
tree_geometries = tree.geometries
ray_line = None
candidates_bounds = bounds_array[candidates]
dist_sq = (candidates_bounds[:, 0] - origin.x) ** 2 + (candidates_bounds[:, 1] - origin.y) ** 2
sorted_indices = numpy.argsort(dist_sq)
for idx in sorted_indices:
candidate_id = candidates[idx]
bounds = bounds_array[candidate_id]
if abs(dx) < 1e-12:
if origin.x < bounds[0] or origin.x > bounds[2]:
tx_min, tx_max = 1e30, -1e30
else:
tx_min, tx_max = -1e30, 1e30
else:
t1, t2 = (bounds[0] - origin.x) * inv_dx, (bounds[2] - origin.x) * inv_dx
tx_min, tx_max = min(t1, t2), max(t1, t2)
if abs(dy) < 1e-12:
if origin.y < bounds[1] or origin.y > bounds[3]:
ty_min, ty_max = 1e30, -1e30
else:
ty_min, ty_max = -1e30, 1e30
else:
t1, t2 = (bounds[1] - origin.y) * inv_dy, (bounds[3] - origin.y) * inv_dy
ty_min, ty_max = min(t1, t2), max(t1, t2)
t_min, t_max = max(tx_min, ty_min), min(tx_max, ty_max)
if t_max < 0 or t_min > t_max or t_min > 1.0:
continue
if t_min * max_dist >= min_dist:
continue
if is_rect_array[candidate_id]:
min_dist = max(0.0, t_min * max_dist)
continue
if ray_line is None:
ray_line = LineString([(origin.x, origin.y), (origin.x + dx, origin.y + dy)])
obstacle = tree_geometries[candidate_id]
if self.metrics is not None:
self.metrics.total_ray_cast_exact_geometry_checks += 1
if not obstacle.intersects(ray_line):
continue
intersection = ray_line.intersection(obstacle)
if intersection.is_empty:
continue
distance = _intersection_distance(origin, intersection)
min_dist = min(min_dist, distance)
return min_dist

View file

@ -1,52 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
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: Sequence[Polygon]
polygons_b: Sequence[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

View file

@ -1,480 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
import numpy
from shapely.affinity import rotate as shapely_rotate
from shapely.affinity import scale as shapely_scale
from shapely.affinity import translate as shapely_translate
from shapely.geometry import Polygon, box
from inire.constants import TOLERANCE_ANGULAR
from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed
from .primitives import Port, rotation_matrix2
if TYPE_CHECKING:
from collections.abc import Sequence
MoveKind = Literal["straight", "bend90", "sbend"]
BendCollisionModelName = Literal["arc", "bbox", "clipped_bbox"]
BendCollisionModel = BendCollisionModelName | Polygon
BendPhysicalGeometry = Literal["arc"] | Polygon
def _normalize_length(value: float) -> float:
return float(value)
@dataclass(frozen=True, slots=True)
class ComponentResult:
start_port: Port
collision_geometry: Sequence[Polygon]
end_port: Port
length: float
move_type: MoveKind
move_spec: PathSegmentSeed
physical_geometry: Sequence[Polygon]
dilated_collision_geometry: Sequence[Polygon]
dilated_physical_geometry: Sequence[Polygon]
_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
_total_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
_dilated_bounds: tuple[tuple[float, float, float, float], ...] = field(init=False, repr=False)
_total_dilated_bounds: tuple[float, float, float, float] = field(init=False, repr=False)
def __post_init__(self) -> None:
collision_geometry = tuple(self.collision_geometry)
physical_geometry = tuple(self.physical_geometry)
dilated_collision_geometry = tuple(self.dilated_collision_geometry)
dilated_physical_geometry = tuple(self.dilated_physical_geometry)
object.__setattr__(self, "collision_geometry", collision_geometry)
object.__setattr__(self, "physical_geometry", physical_geometry)
object.__setattr__(self, "dilated_collision_geometry", dilated_collision_geometry)
object.__setattr__(self, "dilated_physical_geometry", dilated_physical_geometry)
object.__setattr__(self, "length", float(self.length))
bounds = tuple(poly.bounds for poly in collision_geometry)
object.__setattr__(self, "_bounds", bounds)
object.__setattr__(self, "_total_bounds", _combine_bounds(list(bounds)))
dilated_bounds = tuple(poly.bounds for poly in dilated_collision_geometry)
object.__setattr__(self, "_dilated_bounds", dilated_bounds)
object.__setattr__(self, "_total_dilated_bounds", _combine_bounds(list(dilated_bounds)))
@property
def bounds(self) -> tuple[tuple[float, float, float, float], ...]:
return self._bounds
@property
def total_bounds(self) -> tuple[float, float, float, float]:
return self._total_bounds
@property
def dilated_bounds(self) -> tuple[tuple[float, float, float, float], ...]:
return self._dilated_bounds
@property
def total_dilated_bounds(self) -> tuple[float, float, float, float]:
return self._total_dilated_bounds
def translate(self, dx: int | float, dy: int | float) -> ComponentResult:
return ComponentResult(
start_port=self.start_port.translate(dx, dy),
collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.collision_geometry],
end_port=self.end_port.translate(dx, dy),
length=self.length,
move_type=self.move_type,
move_spec=self.move_spec,
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry],
dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry],
)
def _combine_bounds(bounds_list: list[tuple[float, float, float, float]]) -> tuple[float, float, float, float]:
arr = numpy.asarray(bounds_list, dtype=numpy.float64)
return (
float(arr[:, 0].min()),
float(arr[:, 1].min()),
float(arr[:, 2].max()),
float(arr[:, 3].max()),
)
def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int:
if radius <= 0:
return 1
ratio = max(0.0, min(1.0, 1.0 - sagitta / radius))
theta_max = 2.0 * numpy.arccos(ratio)
if theta_max < TOLERANCE_ANGULAR:
return 16
num = int(numpy.ceil(numpy.radians(abs(angle_deg)) / theta_max))
return max(8, num)
def _get_arc_polygons(
cxy: tuple[float, float],
radius: float,
width: float,
ts: tuple[float, float],
sagitta: float = 0.01,
dilation: float = 0.0,
) -> list[Polygon]:
t_start, t_end = numpy.radians(ts[0]), numpy.radians(ts[1])
num_segments = _get_num_segments(radius, abs(ts[1] - ts[0]), sagitta)
angles = numpy.linspace(t_start, t_end, num_segments + 1)
cx, cy = cxy
inner_radius = radius - width / 2.0 - dilation
outer_radius = radius + width / 2.0 + dilation
cos_a = numpy.cos(angles)
sin_a = numpy.sin(angles)
inner_points = numpy.column_stack((cx + inner_radius * cos_a, cy + inner_radius * sin_a))
outer_points = numpy.column_stack((cx + outer_radius * cos_a[::-1], cy + outer_radius * sin_a[::-1]))
return [Polygon(numpy.concatenate((inner_points, outer_points), axis=0))]
def _clip_bbox_legacy(
cxy: tuple[float, float],
radius: float,
width: float,
ts: tuple[float, float],
clip_margin: float,
) -> Polygon:
arc_poly = _get_arc_polygons(cxy, radius, width, ts)[0]
minx, miny, maxx, maxy = arc_poly.bounds
bbox_poly = box(minx, miny, maxx, maxy)
shrink = min(clip_margin, max(radius, width))
return bbox_poly.buffer(-shrink, join_style="mitre") if shrink > 0 else bbox_poly
def _clip_bbox_polygonal(cxy: tuple[float, float], radius: float, width: float, ts: tuple[float, float]) -> Polygon:
"""Return a conservative 8-point polygonal proxy for the arc.
The polygon uses 4 points along the outer edge and 4 along the inner edge.
The outer edge is a circumscribed polyline and the inner edge is an
inscribed polyline, so the result conservatively contains the true arc.
"""
cx, cy = cxy
sample_count = 4
angle_span = abs(float(ts[1]) - float(ts[0]))
if angle_span < TOLERANCE_ANGULAR:
return box(*_get_arc_polygons(cxy, radius, width, ts)[0].bounds)
segment_half_angle = numpy.radians(angle_span / (2.0 * (sample_count - 1)))
cos_half = max(float(numpy.cos(segment_half_angle)), 1e-9)
inner_radius = max(0.0, radius - width / 2.0)
outer_radius = radius + width / 2.0
tolerance = max(1e-3, radius * 1e-4)
conservative_inner_radius = max(0.0, inner_radius * cos_half - tolerance)
conservative_outer_radius = outer_radius / cos_half + tolerance
angles = numpy.radians(numpy.linspace(ts[0], ts[1], sample_count))
cos_a = numpy.cos(angles)
sin_a = numpy.sin(angles)
outer_points = numpy.column_stack((cx + conservative_outer_radius * cos_a, cy + conservative_outer_radius * sin_a))
inner_points = numpy.column_stack((cx + conservative_inner_radius * cos_a[::-1], cy + conservative_inner_radius * sin_a[::-1]))
return Polygon(numpy.concatenate((outer_points, inner_points), axis=0))
def _clip_bbox(
cxy: tuple[float, float],
radius: float,
width: float,
ts: tuple[float, float],
clip_margin: float | None,
) -> Polygon:
if clip_margin is not None:
return _clip_bbox_legacy(cxy, radius, width, ts, clip_margin)
return _clip_bbox_polygonal(cxy, radius, width, ts)
def _transform_custom_collision_polygon(
collision_poly: Polygon,
cxy: tuple[float, float],
rotation_deg: float,
mirror_y: bool,
) -> Polygon:
poly = collision_poly
if mirror_y:
poly = shapely_scale(poly, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0))
if rotation_deg % 360:
poly = shapely_rotate(poly, rotation_deg, origin=(0.0, 0.0), use_radians=False)
return shapely_translate(poly, cxy[0], cxy[1])
def _apply_collision_model(
arc_poly: Polygon,
collision_type: BendCollisionModel,
radius: float,
width: float,
cxy: tuple[float, float],
ts: tuple[float, float],
clip_margin: float | None = None,
rotation_deg: float = 0.0,
mirror_y: bool = False,
) -> list[Polygon]:
if isinstance(collision_type, Polygon):
return [_transform_custom_collision_polygon(collision_type, cxy, rotation_deg, mirror_y)]
if collision_type == "arc":
return [arc_poly]
if collision_type == "clipped_bbox":
clipped = _clip_bbox(cxy, radius, width, ts, clip_margin)
return [clipped if not clipped.is_empty else box(*arc_poly.bounds)]
return [box(*arc_poly.bounds)]
class Straight:
@staticmethod
def generate(
start_port: Port,
length: float,
width: float,
dilation: float = 0.0,
) -> ComponentResult:
rot2 = rotation_matrix2(start_port.r)
length_f = _normalize_length(length)
disp = rot2 @ numpy.array((length_f, 0.0))
end_port = Port(start_port.x + disp[0], start_port.y + disp[1], start_port.r)
half_w = width / 2.0
pts = numpy.array(((0.0, half_w), (length_f, half_w), (length_f, -half_w), (0.0, -half_w)))
poly_points = (pts @ rot2.T) + numpy.array((start_port.x, start_port.y))
geometry = [Polygon(poly_points)]
if dilation > 0:
half_w_d = half_w + dilation
pts_d = numpy.array(
(
(-dilation, half_w_d),
(length_f + dilation, half_w_d),
(length_f + dilation, -half_w_d),
(-dilation, -half_w_d),
)
)
poly_points_d = (pts_d @ rot2.T) + numpy.array((start_port.x, start_port.y))
dilated_geometry = [Polygon(poly_points_d)]
else:
dilated_geometry = geometry
return ComponentResult(
start_port=start_port,
collision_geometry=geometry,
end_port=end_port,
length=abs(length_f),
move_type="straight",
move_spec=StraightSeed(length=length_f),
physical_geometry=geometry,
dilated_collision_geometry=dilated_geometry,
dilated_physical_geometry=dilated_geometry,
)
class Bend90:
@staticmethod
def generate(
start_port: Port,
radius: float,
width: float,
direction: Literal["CW", "CCW"],
sagitta: float = 0.01,
collision_type: BendCollisionModel = "arc",
physical_geometry_type: BendPhysicalGeometry = "arc",
clip_margin: float | None = None,
dilation: float = 0.0,
) -> ComponentResult:
rot2 = rotation_matrix2(start_port.r)
sign = 1 if direction == "CCW" else -1
center_local = numpy.array((0.0, sign * radius))
end_local = numpy.array((radius, sign * radius))
center_xy = (rot2 @ center_local) + numpy.array((start_port.x, start_port.y))
end_xy = (rot2 @ end_local) + numpy.array((start_port.x, start_port.y))
end_port = Port(end_xy[0], end_xy[1], start_port.r + sign * 90)
start_theta = start_port.r - sign * 90
end_theta = start_port.r
ts = (float(start_theta), float(end_theta))
arc_polys = _get_arc_polygons((float(center_xy[0]), float(center_xy[1])), radius, width, ts, sagitta)
collision_polys = _apply_collision_model(
arc_polys[0],
collision_type,
radius,
width,
(float(center_xy[0]), float(center_xy[1])),
ts,
clip_margin=clip_margin,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
)
if isinstance(physical_geometry_type, Polygon):
physical_geometry = _apply_collision_model(
arc_polys[0],
physical_geometry_type,
radius,
width,
(float(center_xy[0]), float(center_xy[1])),
ts,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
)
uses_physical_custom_geometry = True
else:
physical_geometry = arc_polys
uses_physical_custom_geometry = False
if dilation > 0:
if uses_physical_custom_geometry:
dilated_physical_geometry = [poly.buffer(dilation) for poly in physical_geometry]
else:
dilated_physical_geometry = _get_arc_polygons(
(float(center_xy[0]), float(center_xy[1])),
radius,
width,
ts,
sagitta,
dilation=dilation,
)
dilated_collision_geometry = (
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in collision_polys]
)
else:
dilated_physical_geometry = physical_geometry
dilated_collision_geometry = collision_polys
return ComponentResult(
start_port=start_port,
collision_geometry=collision_polys,
end_port=end_port,
length=abs(radius) * numpy.pi / 2.0,
move_type="bend90",
move_spec=Bend90Seed(radius=radius, direction=direction),
physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry,
)
class SBend:
@staticmethod
def generate(
start_port: Port,
offset: float,
radius: float,
width: float,
sagitta: float = 0.01,
collision_type: BendCollisionModel = "arc",
physical_geometry_type: BendPhysicalGeometry = "arc",
clip_margin: float | None = None,
dilation: float = 0.0,
) -> ComponentResult:
if abs(offset) >= 2 * radius:
raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}")
sign = 1 if offset >= 0 else -1
theta = numpy.arccos(1.0 - abs(offset) / (2.0 * radius))
dx = 2.0 * radius * numpy.sin(theta)
theta_deg = float(numpy.degrees(theta))
rot2 = rotation_matrix2(start_port.r)
end_local = numpy.array((dx, offset))
end_xy = (rot2 @ end_local) + numpy.array((start_port.x, start_port.y))
end_port = Port(end_xy[0], end_xy[1], start_port.r)
c1_local = numpy.array((0.0, sign * radius))
c2_local = numpy.array((dx, offset - sign * radius))
c1_xy = (rot2 @ c1_local) + numpy.array((start_port.x, start_port.y))
c2_xy = (rot2 @ c2_local) + numpy.array((start_port.x, start_port.y))
ts1 = (float(start_port.r - sign * 90), float(start_port.r - sign * 90 + sign * theta_deg))
second_base = start_port.r + (90 if sign > 0 else 270)
ts2 = (float(second_base + sign * theta_deg), float(second_base))
arc1 = _get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta)[0]
arc2 = _get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta)[0]
actual_geometry = [arc1, arc2]
geometry = [
_apply_collision_model(
arc1,
collision_type,
radius,
width,
(float(c1_xy[0]), float(c1_xy[1])),
ts1,
clip_margin=clip_margin,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
)[0],
_apply_collision_model(
arc2,
collision_type,
radius,
width,
(float(c2_xy[0]), float(c2_xy[1])),
ts2,
clip_margin=clip_margin,
rotation_deg=float(start_port.r),
mirror_y=(sign > 0),
)[0],
]
if isinstance(physical_geometry_type, Polygon):
physical_geometry = [
_apply_collision_model(
arc1,
physical_geometry_type,
radius,
width,
(float(c1_xy[0]), float(c1_xy[1])),
ts1,
rotation_deg=float(start_port.r),
mirror_y=(sign < 0),
)[0],
_apply_collision_model(
arc2,
physical_geometry_type,
radius,
width,
(float(c2_xy[0]), float(c2_xy[1])),
ts2,
rotation_deg=float(start_port.r),
mirror_y=(sign > 0),
)[0],
]
uses_physical_custom_geometry = True
else:
physical_geometry = actual_geometry
uses_physical_custom_geometry = False
if dilation > 0:
if uses_physical_custom_geometry:
dilated_physical_geometry = [poly.buffer(dilation) for poly in physical_geometry]
else:
dilated_physical_geometry = [
_get_arc_polygons((float(c1_xy[0]), float(c1_xy[1])), radius, width, ts1, sagitta, dilation=dilation)[0],
_get_arc_polygons((float(c2_xy[0]), float(c2_xy[1])), radius, width, ts2, sagitta, dilation=dilation)[0],
]
dilated_collision_geometry = (
dilated_physical_geometry if collision_type == "arc" else [poly.buffer(dilation) for poly in geometry]
)
else:
dilated_physical_geometry = physical_geometry
dilated_collision_geometry = geometry
return ComponentResult(
start_port=start_port,
collision_geometry=geometry,
end_port=end_port,
length=2.0 * radius * theta,
move_type="sbend",
move_spec=SBendSeed(offset=offset, radius=radius),
physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry,
)

View file

@ -1,233 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy
import rtree
from shapely.strtree import STRtree
from inire.geometry.index_helpers import build_index_payload, iter_grid_cells
if TYPE_CHECKING:
from collections.abc import Sequence
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld
class DynamicPathIndex:
__slots__ = (
"engine",
"index",
"geometries",
"component_indexes",
"dilated",
"dilated_bounds",
"net_envelope_index",
"net_envelopes",
"net_envelope_obj_ids",
"net_envelope_obj_to_net",
"tree",
"obj_ids",
"grid",
"grid_net_obj_ids",
"grid_net_counts",
"obj_cells",
"net_to_obj_ids",
"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.component_indexes: dict[int, int] = {}
self.dilated: dict[int, Polygon] = {}
self.dilated_bounds: dict[int, tuple[float, float, float, float]] = {}
self.net_envelope_index = rtree.index.Index()
self.net_envelopes: dict[str, tuple[float, float, float, float]] = {}
self.net_envelope_obj_ids: dict[str, int] = {}
self.net_envelope_obj_to_net: dict[int, str] = {}
self.tree: STRtree | None = None
self.obj_ids: numpy.ndarray = numpy.array([], dtype=numpy.int32)
self.grid: dict[tuple[int, int], list[int]] = {}
self.grid_net_obj_ids: dict[tuple[int, int], dict[str, set[int]]] = {}
self.grid_net_counts: dict[tuple[int, int], dict[str, int]] = {}
self.obj_cells: dict[int, tuple[tuple[int, int], ...]] = {}
self.net_to_obj_ids: dict[str, set[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 _combine_net_bounds(self, obj_ids: set[int]) -> tuple[float, float, float, float]:
first_obj_id = next(iter(obj_ids))
minx, miny, maxx, maxy = self.dilated_bounds[first_obj_id]
for obj_id in obj_ids:
bounds = self.dilated_bounds[obj_id]
minx = min(minx, bounds[0])
miny = min(miny, bounds[1])
maxx = max(maxx, bounds[2])
maxy = max(maxy, bounds[3])
return (minx, miny, maxx, maxy)
def _set_net_envelope(self, net_id: str, bounds: tuple[float, float, float, float]) -> None:
old_bounds = self.net_envelopes.get(net_id)
if old_bounds is not None:
obj_id = self.net_envelope_obj_ids[net_id]
self.net_envelope_index.delete(obj_id, old_bounds)
else:
obj_id = len(self.net_envelope_obj_ids)
while obj_id in self.net_envelope_obj_to_net:
obj_id += 1
self.net_envelope_obj_ids[net_id] = obj_id
self.net_envelope_obj_to_net[obj_id] = net_id
self.net_envelopes[net_id] = bounds
self.net_envelope_index.insert(self.net_envelope_obj_ids[net_id], bounds)
def _clear_net_envelope(self, net_id: str) -> None:
old_bounds = self.net_envelopes.pop(net_id, None)
obj_id = self.net_envelope_obj_ids.pop(net_id, None)
if old_bounds is None or obj_id is None:
return
self.net_envelope_index.delete(obj_id, old_bounds)
self.net_envelope_obj_to_net.pop(obj_id, None)
def _refresh_net_envelope(self, net_id: str) -> None:
obj_ids = self.net_to_obj_ids.get(net_id)
if not obj_ids:
self._clear_net_envelope(net_id)
return
self._set_net_envelope(net_id, self._combine_net_bounds(obj_ids))
def invalidate_queries(self) -> None:
self.tree = None
self.grid = {}
self.grid_net_obj_ids = {}
self.grid_net_counts = {}
self.obj_cells = {}
def ensure_tree(self) -> None:
if self.tree is None and self.dilated:
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_tree_rebuilds += 1
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
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_grid_rebuilds += 1
cell_size = self.engine.grid_cell_size
for obj_id, polygon in self.dilated.items():
self._register_grid_membership(obj_id, self.geometries[obj_id][0], polygon.bounds, cell_size=cell_size)
def _register_grid_membership(
self,
obj_id: int,
net_id: str,
bounds: tuple[float, float, float, float],
*,
cell_size: float,
) -> None:
cells = tuple(iter_grid_cells(bounds, cell_size))
self.obj_cells[obj_id] = cells
for cell in cells:
self.grid.setdefault(cell, []).append(obj_id)
net_obj_ids = self.grid_net_obj_ids.setdefault(cell, {})
net_obj_ids.setdefault(net_id, set()).add(obj_id)
net_counts = self.grid_net_counts.setdefault(cell, {})
net_counts[net_id] = net_counts.get(net_id, 0) + 1
def _unregister_grid_membership(self, obj_id: int, net_id: str) -> None:
cells = self.obj_cells.pop(obj_id, ())
for cell in cells:
obj_ids = self.grid.get(cell)
if obj_ids is not None:
try:
obj_ids.remove(obj_id)
except ValueError:
pass
if not obj_ids:
self.grid.pop(cell, None)
net_obj_ids = self.grid_net_obj_ids.get(cell)
if net_obj_ids is not None:
member_ids = net_obj_ids.get(net_id)
if member_ids is not None:
member_ids.discard(obj_id)
if not member_ids:
net_obj_ids.pop(net_id, None)
if not net_obj_ids:
self.grid_net_obj_ids.pop(cell, None)
net_counts = self.grid_net_counts.get(cell)
if net_counts is not None:
remaining = net_counts.get(net_id, 0) - 1
if remaining > 0:
net_counts[net_id] = remaining
else:
net_counts.pop(net_id, None)
if not net_counts:
self.grid_net_counts.pop(cell, None)
def add_path(
self,
net_id: str,
geometry: Sequence[Polygon],
dilated_geometry: Sequence[Polygon],
component_indexes: Sequence[int] | None = None,
) -> None:
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_added += len(geometry)
cell_size = self.engine.grid_cell_size
for index, polygon in enumerate(geometry):
obj_id = self.id_counter
self.id_counter += 1
dilated = dilated_geometry[index]
dilated_bounds = dilated.bounds
self.geometries[obj_id] = (net_id, polygon)
self.component_indexes[obj_id] = index if component_indexes is None else component_indexes[index]
self.dilated[obj_id] = dilated
self.dilated_bounds[obj_id] = dilated_bounds
self.index.insert(obj_id, dilated_bounds)
self.net_to_obj_ids.setdefault(net_id, set()).add(obj_id)
self._register_grid_membership(obj_id, net_id, dilated_bounds, cell_size=cell_size)
self._refresh_net_envelope(net_id)
self.tree = None
def remove_path(self, net_id: str) -> None:
to_remove = list(self.net_to_obj_ids.get(net_id, ()))
self.remove_obj_ids(to_remove)
def remove_obj_ids(self, obj_ids: list[int]) -> None:
if not obj_ids:
return
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids)
affected_nets: set[str] = set()
for obj_id in obj_ids:
net_id, _ = self.geometries[obj_id]
affected_nets.add(net_id)
self._unregister_grid_membership(obj_id, net_id)
self.index.delete(obj_id, self.dilated_bounds[obj_id])
del self.geometries[obj_id]
del self.component_indexes[obj_id]
del self.dilated[obj_id]
del self.dilated_bounds[obj_id]
obj_id_set = self.net_to_obj_ids.get(net_id)
if obj_id_set is not None:
obj_id_set.discard(obj_id)
if not obj_id_set:
self.net_to_obj_ids.pop(net_id, None)
for net_id in affected_nets:
self._refresh_net_envelope(net_id)
self.tree = None

View file

@ -1,49 +0,0 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
import numpy
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from shapely.geometry.base import BaseGeometry
def build_index_payload(
geometries: Mapping[int, BaseGeometry],
) -> tuple[list[int], list[BaseGeometry], 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: BaseGeometry, *, tolerance: float = 1e-4) -> bool:
bounds = geometry.bounds
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
return abs(geometry.area - area) < tolerance

View file

@ -1,65 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Self
import numpy
if TYPE_CHECKING:
from numpy.typing import NDArray
def _normalize_angle(angle_deg: int | float) -> int:
angle = int(round(angle_deg)) % 360
if angle % 90 != 0:
raise ValueError(f"Port angle must be Manhattan (multiple of 90), got {angle_deg!r}")
return angle
@dataclass(frozen=True, slots=True)
class Port:
"""
Port represented as a normalized integer (x, y, r) triple.
"""
x: int | float
y: int | float
r: int | float
def __post_init__(self) -> None:
object.__setattr__(self, "x", int(round(self.x)))
object.__setattr__(self, "y", int(round(self.y)))
object.__setattr__(self, "r", _normalize_angle(self.r))
def as_tuple(self) -> tuple[int, int, int]:
return (int(self.x), int(self.y), int(self.r))
def translate(
self,
dx: int | float = 0,
dy: int | float = 0,
rotation: int | float = 0,
) -> Self:
return type(self)(self.x + dx, self.y + dy, self.r + rotation)
def rotated(
self,
angle: int | float,
origin: tuple[int | float, int | float] = (0, 0),
) -> Self:
angle_i = _normalize_angle(angle)
rot = rotation_matrix2(angle_i)
origin_xy = numpy.array((int(round(origin[0])), int(round(origin[1]))), dtype=numpy.int32)
rel = numpy.array((self.x, self.y), dtype=numpy.int32) - origin_xy
rotated = origin_xy + rot @ rel
return type(self)(int(rotated[0]), int(rotated[1]), self.r + angle_i)
ROT2_0 = numpy.array(((1, 0), (0, 1)), dtype=numpy.int32)
ROT2_90 = numpy.array(((0, -1), (1, 0)), dtype=numpy.int32)
ROT2_180 = numpy.array(((-1, 0), (0, -1)), dtype=numpy.int32)
ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32)
def rotation_matrix2(rotation_deg: int | float) -> NDArray[numpy.int32]:
quadrant = (_normalize_angle(rotation_deg) // 90) % 4
return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant]

View file

@ -1,132 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy
import rtree
from shapely.strtree import STRtree
from inire.geometry.index_helpers import build_index_payload, is_axis_aligned_rect
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld
class StaticObstacleIndex:
__slots__ = (
"engine",
"index",
"geometries",
"dilated",
"is_rect",
"tree",
"obj_ids",
"bounds_array",
"is_rect_array",
"raw_tree",
"raw_obj_ids",
"net_specific_trees",
"net_specific_is_rect",
"net_specific_bounds",
"id_counter",
"version",
)
def __init__(self, engine: RoutingWorld) -> None:
self.engine = engine
self.index = rtree.index.Index()
self.geometries: dict[int, Polygon] = {}
self.dilated: dict[int, Polygon] = {}
self.is_rect: dict[int, bool] = {}
self.tree: STRtree | None = None
self.obj_ids: list[int] = []
self.bounds_array: numpy.ndarray | None = None
self.is_rect_array: numpy.ndarray | None = None
self.raw_tree: STRtree | None = None
self.raw_obj_ids: list[int] = []
self.net_specific_trees: dict[tuple[float, float], STRtree] = {}
self.net_specific_is_rect: dict[tuple[float, float], numpy.ndarray] = {}
self.net_specific_bounds: dict[tuple[float, float], numpy.ndarray] = {}
self.id_counter = 0
self.version = 0
def add_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
obj_id = self.id_counter
self.id_counter += 1
if dilated_geometry is not None:
dilated = dilated_geometry
else:
dilated = polygon.buffer(self.engine.clearance / 2.0, join_style="mitre")
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:
if self.engine.metrics is not None:
self.engine.metrics.total_static_tree_rebuilds += 1
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]
if self.engine.metrics is not None:
self.engine.metrics.total_static_net_tree_rebuilds += 1
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="mitre")
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:
if self.engine.metrics is not None:
self.engine.metrics.total_static_raw_tree_rebuilds += 1
self.raw_obj_ids, geometries, _bounds_array = build_index_payload(self.geometries)
self.raw_tree = STRtree(geometries)

View file

@ -1,142 +0,0 @@
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from shapely.geometry import Polygon
from inire.seeds import PathSeed
if TYPE_CHECKING:
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry
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_proxy_geometry: BendCollisionModel | None = None
bend_physical_geometry: BendPhysicalGeometry | None = None
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))
if self.bend_physical_geometry is None and isinstance(self.bend_proxy_geometry, Polygon):
warnings.warn(
"Custom bend proxy provided without bend_physical_geometry; routed bends will keep standard arc geometry.",
stacklevel=2,
)
def resolve_bend_geometry(
search: SearchOptions,
*,
bend_collision_override: BendCollisionModel | None = None,
) -> tuple[BendCollisionModel, BendPhysicalGeometry]:
bend_physical_geometry = search.bend_physical_geometry
if bend_physical_geometry is None and isinstance(search.bend_collision_type, Polygon) and search.bend_proxy_geometry is None:
bend_physical_geometry = search.bend_collision_type
if bend_physical_geometry is None:
bend_physical_geometry = "arc"
if bend_collision_override is not None:
bend_proxy_geometry = bend_collision_override
elif search.bend_proxy_geometry is not None:
bend_proxy_geometry = search.bend_proxy_geometry
elif isinstance(search.bend_collision_type, Polygon):
bend_proxy_geometry = search.bend_collision_type
elif bend_physical_geometry != "arc" and search.bend_collision_type == "arc":
bend_proxy_geometry = bend_physical_geometry
else:
bend_proxy_geometry = search.bend_collision_type
return bend_proxy_geometry, bend_physical_geometry
@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
capture_conflict_trace: bool = False
capture_frontier_trace: bool = False
capture_iteration_trace: bool = False
capture_pre_pair_frontier_trace: 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,
)

View file

@ -1,284 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from inire.seeds import PathSeed
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.components import ComponentResult
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
ConflictTraceStage = Literal["iteration", "restored_best", "final"]
FrontierTraceReason = Literal["closed_set", "hard_collision", "self_collision", "cost"]
@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 ComponentConflictTrace:
other_net_id: str
self_component_index: int
other_component_index: int
@dataclass(frozen=True, slots=True)
class NetConflictTrace:
net_id: str
outcome: RoutingOutcome
reached_target: bool
report: RoutingReport
conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[ComponentConflictTrace, ...] = ()
@dataclass(frozen=True, slots=True)
class ConflictTraceEntry:
stage: ConflictTraceStage
iteration: int | None
completed_net_ids: tuple[str, ...]
conflict_edges: tuple[tuple[str, str], ...]
nets: tuple[NetConflictTrace, ...]
@dataclass(frozen=True, slots=True)
class FrontierPruneSample:
reason: FrontierTraceReason
move_type: str
hotspot_index: int
parent_state: tuple[int, int, int]
end_state: tuple[int, int, int]
@dataclass(frozen=True, slots=True)
class NetFrontierTrace:
net_id: str
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
pruned_closed_set: int
pruned_hard_collision: int
pruned_self_collision: int
pruned_cost: int
samples: tuple[FrontierPruneSample, ...] = ()
@dataclass(frozen=True, slots=True)
class PrePairNetTrace:
net_id: str
nodes_expanded: int
congestion_check_calls: int
pruned_closed_set: int
pruned_cost: int
pruned_hard_collision: int
guidance_seed_present: bool
frontier: NetFrontierTrace
@dataclass(frozen=True, slots=True)
class PrePairFrontierTraceEntry:
iteration: int
routed_net_ids: tuple[str, ...]
conflict_edges: tuple[tuple[str, str], ...]
nets: tuple[PrePairNetTrace, ...]
@dataclass(frozen=True, slots=True)
class IterationNetAttemptTrace:
net_id: str
reached_target: bool
nodes_expanded: int
congestion_check_calls: int
pruned_closed_set: int
pruned_cost: int
pruned_hard_collision: int
guidance_seed_present: bool
@dataclass(frozen=True, slots=True)
class IterationTraceEntry:
iteration: int
congestion_penalty: float
routed_net_ids: tuple[str, ...]
completed_nets: int
conflict_edges: int
total_dynamic_collisions: int
nodes_expanded: int
congestion_check_calls: int
congestion_candidate_ids: int
congestion_exact_pair_checks: int
net_attempts: tuple[IterationNetAttemptTrace, ...] = ()
@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
route_iterations: int
nets_routed: int
nets_reached_target: int
warm_start_paths_built: int
warm_start_paths_used: int
refine_path_calls: int
timeout_events: int
iteration_reverify_calls: int
iteration_reverified_nets: int
iteration_conflicting_nets: int
iteration_conflict_edges: int
nets_carried_forward: int
score_component_calls: int
score_component_total_ns: int
path_cost_calls: int
danger_map_lookup_calls: int
danger_map_cache_hits: int
danger_map_cache_misses: int
danger_map_query_calls: int
danger_map_total_ns: int
move_cache_abs_hits: int
move_cache_abs_misses: int
move_cache_rel_hits: int
move_cache_rel_misses: int
guidance_match_moves: int
guidance_match_moves_straight: int
guidance_match_moves_bend90: int
guidance_match_moves_sbend: int
guidance_bonus_applied: float
guidance_bonus_applied_straight: float
guidance_bonus_applied_bend90: float
guidance_bonus_applied_sbend: float
static_safe_cache_hits: int
hard_collision_cache_hits: int
congestion_cache_hits: int
congestion_cache_misses: int
congestion_presence_cache_hits: int
congestion_presence_cache_misses: int
congestion_presence_skips: int
congestion_candidate_precheck_hits: int
congestion_candidate_precheck_misses: int
congestion_candidate_precheck_skips: int
congestion_grid_net_cache_hits: int
congestion_grid_net_cache_misses: int
congestion_grid_span_cache_hits: int
congestion_grid_span_cache_misses: int
congestion_candidate_nets: int
congestion_net_envelope_cache_hits: int
congestion_net_envelope_cache_misses: int
dynamic_path_objects_added: int
dynamic_path_objects_removed: int
dynamic_tree_rebuilds: int
dynamic_grid_rebuilds: int
static_tree_rebuilds: int
static_raw_tree_rebuilds: int
static_net_tree_rebuilds: int
visibility_corner_index_builds: int
visibility_builds: int
visibility_corner_pairs_checked: int
visibility_corner_queries_exact: int
visibility_corner_hits_exact: int
visibility_point_queries: int
visibility_point_cache_hits: int
visibility_point_cache_misses: int
visibility_tangent_candidate_scans: int
visibility_tangent_candidate_corner_checks: int
visibility_tangent_candidate_ray_tests: int
ray_cast_calls: int
ray_cast_calls_straight_static: int
ray_cast_calls_expand_snap: int
ray_cast_calls_expand_forward: int
ray_cast_calls_visibility_build: int
ray_cast_calls_visibility_query: int
ray_cast_calls_visibility_tangent: int
ray_cast_calls_other: int
ray_cast_candidate_bounds: int
ray_cast_exact_geometry_checks: int
congestion_check_calls: int
congestion_lazy_resolutions: int
congestion_lazy_requeues: int
congestion_candidate_ids: int
congestion_exact_pair_checks: int
verify_path_report_calls: int
verify_static_buffer_ops: int
verify_dynamic_candidate_nets: int
verify_dynamic_exact_pair_checks: int
refinement_windows_considered: int
refinement_static_bounds_checked: int
refinement_dynamic_bounds_checked: int
refinement_candidate_side_extents: int
refinement_candidates_built: int
refinement_candidates_verified: int
refinement_candidates_accepted: int
pair_local_search_pairs_considered: int
pair_local_search_attempts: int
pair_local_search_accepts: int
pair_local_search_nodes_expanded: int
late_phase_capped_nets: int
late_phase_capped_fallbacks: 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: list[Polygon] = []
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], ...] = ()
conflict_trace: tuple[ConflictTraceEntry, ...] = ()
frontier_trace: tuple[NetFrontierTrace, ...] = ()
pre_pair_frontier_trace: PrePairFrontierTraceEntry | None = None
iteration_trace: tuple[IterationTraceEntry, ...] = ()

View file

@ -1,297 +0,0 @@
from __future__ import annotations
import heapq
from typing import TYPE_CHECKING
from shapely.geometry import Polygon
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import Bend90, SBend, Straight, MoveKind
from inire.geometry.primitives import Port
from inire.router.refiner import component_hits_ancestor_chain
from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
def process_move(
parent: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: dict[tuple[int, int, int], float],
context: AStarContext,
metrics: AStarMetrics,
congestion_cache: dict[tuple, int],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, 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
physical_type = config.bend_physical_geometry
physical_key = id(physical_type) if isinstance(physical_type, Polygon) else physical_type
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
abs_key = (
cp.as_tuple(),
move_class,
params,
net_width,
coll_key,
physical_key,
self_dilation,
)
if abs_key in context.move_cache_abs:
context.metrics.total_move_cache_abs_hits += 1
res = context.move_cache_abs[abs_key]
else:
context.metrics.total_move_cache_abs_misses += 1
context.check_cache_eviction()
base_port = Port(0, 0, cp.r)
rel_key = (
cp.r,
move_class,
params,
net_width,
coll_key,
physical_key,
self_dilation,
)
if rel_key in context.move_cache_rel:
context.metrics.total_move_cache_rel_hits += 1
res_rel = context.move_cache_rel[rel_key]
else:
context.metrics.total_move_cache_rel_misses += 1
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,
physical_geometry_type=config.bend_physical_geometry,
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,
physical_geometry_type=config.bend_physical_geometry,
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
add_node(
parent,
res,
target,
net_width,
net_id,
open_set,
closed_set,
context,
metrics,
congestion_cache,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_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],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]],
config: SearchRunConfig,
move_type: MoveKind,
cache_key: tuple,
) -> None:
frontier_trace = config.frontier_trace
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:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
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:
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
context.metrics.total_hard_collision_cache_hits += 1
metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1
return
is_static_safe = cache_key in context.static_safe_cache
if is_static_safe:
context.metrics.total_static_safe_cache_hits += 1
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)
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1
return
context.static_safe_cache.add(cache_key)
if config.self_collision_check and component_hits_ancestor_chain(result, parent):
if frontier_trace is not None:
frontier_trace.record("self_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
return
move_cost = context.cost_evaluator.score_component(
result,
start_port=parent_p,
)
next_seed_index = None
if (
config.guidance_seed is not None
and parent.seed_index is not None
and parent.seed_index < len(config.guidance_seed)
and result.move_spec == config.guidance_seed[parent.seed_index]
):
context.metrics.total_guidance_match_moves += 1
if result.move_type == "straight":
context.metrics.total_guidance_match_moves_straight += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_straight += applied_bonus
elif result.move_type == "bend90":
context.metrics.total_guidance_match_moves_bend90 += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_bend90 += applied_bonus
else:
context.metrics.total_guidance_match_moves_sbend += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_sbend += applied_bonus
context.metrics.total_guidance_bonus_applied += applied_bonus
move_cost = max(0.001, move_cost - applied_bonus)
next_seed_index = parent.seed_index + 1
if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1
metrics.total_pruned_cost += 1
return
if move_cost > 1e12:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1
metrics.total_pruned_cost += 1
return
if state in closed_set and closed_set[state] <= parent.g_cost + move_cost + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1
return
total_overlaps = 0
if not config.skip_congestion and context.cost_evaluator.collision_engine.has_dynamic_paths():
ce = context.cost_evaluator.collision_engine
if ce.has_possible_move_congestion(result, net_id, congestion_presence_cache):
if ce.has_candidate_move_congestion(
result,
net_id,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
):
if cache_key in congestion_cache:
context.metrics.total_congestion_cache_hits += 1
total_overlaps = congestion_cache[cache_key]
else:
context.metrics.total_congestion_cache_misses += 1
total_overlaps = ce.check_move_congestion(
result,
net_id,
net_envelope_cache=congestion_net_envelope_cache,
grid_net_cache=congestion_grid_net_cache,
broad_phase_cache=congestion_grid_span_cache,
)
congestion_cache[cache_key] = total_overlaps
else:
context.metrics.total_congestion_candidate_precheck_skips += 1
else:
context.metrics.total_congestion_presence_skips += 1
move_cost += total_overlaps * context.congestion_penalty
g_cost = parent.g_cost + move_cost
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
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,
seed_index=next_seed_index,
),
)
metrics.moves_added += 1
metrics.total_moves_added += 1

View file

@ -1,362 +0,0 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR
from ._astar_admission import process_move
if TYPE_CHECKING:
from inire.geometry.components import MoveKind
from inire.geometry.primitives import Port
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 _distance_to_bounds_in_heading(
current: Port,
bounds: tuple[float, float, float, float],
) -> float:
min_x, min_y, max_x, max_y = bounds
if current.r == 0:
return max(0.0, max_x - current.x)
if current.r == 90:
return max(0.0, max_y - current.y)
if current.r == 180:
return max(0.0, current.x - min_x)
return max(0.0, current.y - min_y)
def _should_cap_straights_to_bounds(context: AStarContext) -> bool:
return (
not context.options.congestion.warm_start_enabled
and len(context.problem.nets) >= 8
)
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_corner_index_current()
context.metrics.total_visibility_tangent_candidate_scans += 1
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
candidate_ids = visibility_manager.get_tangent_corner_candidates(
current,
min_forward=search_options.min_straight_length,
max_forward=reach,
radii=search_options.bend_radii,
)
if not candidate_ids:
return []
scored: list[tuple[float, float, float, float, float]] = []
for idx in candidate_ids:
context.metrics.total_visibility_tangent_candidate_corner_checks += 1
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
tangent_candidates: set[int] = set()
for _, dist, length, dx, dy in sorted(scored)[:4]:
context.metrics.total_visibility_tangent_candidate_ray_tests += 1
angle = math.degrees(math.atan2(dy, dx))
corner_reach = collision_engine.ray_cast(
current,
angle,
max_dist=dist + 0.05,
net_width=net_width,
caller="visibility_tangent",
)
if corner_reach < dist - 0.01:
continue
qlen = int(round(length))
if qlen > 0:
tangent_candidates.add(qlen)
return sorted(tangent_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],
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool],
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool],
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]],
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, 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,
caller="expand_snap",
)
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,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_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,
caller="expand_forward",
)
if _should_cap_straights_to_bounds(context):
max_reach = min(max_reach, _distance_to_bounds_in_heading(cp, context.problem.bounds))
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,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_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,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_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,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config,
"sbend",
(offset, radius),
)

View file

@ -1,674 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from inire.model import resolve_bend_geometry
from inire.results import RouteMetrics
from inire.router.visibility import VisibilityManager
if TYPE_CHECKING:
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry, ComponentResult
from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem
from inire.router.cost import CostEvaluator
from inire.seeds import PathSegmentSeed
@dataclass(slots=True)
class FrontierTraceCollector:
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
sample_limit: int = 64
pruned_closed_set: int = 0
pruned_hard_collision: int = 0
pruned_self_collision: int = 0
pruned_cost: int = 0
samples: list[tuple[str, str, int, tuple[int, int, int], tuple[int, int, int]]] = field(default_factory=list)
def _matching_hotspot_index(self, bounds: tuple[float, float, float, float]) -> int | None:
for idx, hotspot_bounds in enumerate(self.hotspot_bounds):
if (
bounds[0] < hotspot_bounds[2]
and bounds[2] > hotspot_bounds[0]
and bounds[1] < hotspot_bounds[3]
and bounds[3] > hotspot_bounds[1]
):
return idx
return None
def record(
self,
reason: str,
move_type: str,
parent_state: tuple[int, int, int],
end_state: tuple[int, int, int],
bounds: tuple[float, float, float, float],
) -> None:
hotspot_index = self._matching_hotspot_index(bounds)
if hotspot_index is None:
return
if reason == "closed_set":
self.pruned_closed_set += 1
elif reason == "hard_collision":
self.pruned_hard_collision += 1
elif reason == "self_collision":
self.pruned_self_collision += 1
else:
self.pruned_cost += 1
if len(self.samples) < self.sample_limit:
self.samples.append((reason, move_type, hotspot_index, parent_state, end_state))
@dataclass(frozen=True, slots=True)
class SearchRunConfig:
bend_collision_type: BendCollisionModel
bend_physical_geometry: BendPhysicalGeometry
bend_clip_margin: float | None
node_limit: int
guidance_seed: tuple[PathSegmentSeed, ...] | None = None
guidance_bonus: float = 0.0
frontier_trace: FrontierTraceCollector | None = None
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,
guidance_seed: tuple[PathSegmentSeed, ...] | None = None,
guidance_bonus: float = 0.0,
frontier_trace: FrontierTraceCollector | 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
bend_collision_type, bend_physical_geometry = resolve_bend_geometry(
search,
bend_collision_override=bend_collision_type,
)
return cls(
bend_collision_type=bend_collision_type,
bend_physical_geometry=bend_physical_geometry,
bend_clip_margin=search.bend_clip_margin,
node_limit=search.node_limit if node_limit is None else node_limit,
guidance_seed=guidance_seed,
guidance_bonus=float(guidance_bonus),
frontier_trace=frontier_trace,
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",
"base_move_cost",
"cache_key",
"seed_index",
"congestion_resolved",
)
def __init__(
self,
port: Port,
g_cost: float,
h_cost: float,
parent: AStarNode | None = None,
component_result: ComponentResult | None = None,
*,
base_move_cost: float = 0.0,
cache_key: tuple | None = None,
seed_index: int | None = None,
congestion_resolved: bool = True,
) -> 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
self.base_move_cost = base_move_cost
self.cache_key = cache_key
self.seed_index = seed_index
self.congestion_resolved = congestion_resolved
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",
"total_route_iterations",
"total_nets_routed",
"total_nets_reached_target",
"total_warm_start_paths_built",
"total_warm_start_paths_used",
"total_refine_path_calls",
"total_timeout_events",
"total_iteration_reverify_calls",
"total_iteration_reverified_nets",
"total_iteration_conflicting_nets",
"total_iteration_conflict_edges",
"total_nets_carried_forward",
"total_score_component_calls",
"total_score_component_total_ns",
"total_path_cost_calls",
"total_danger_map_lookup_calls",
"total_danger_map_cache_hits",
"total_danger_map_cache_misses",
"total_danger_map_query_calls",
"total_danger_map_total_ns",
"total_move_cache_abs_hits",
"total_move_cache_abs_misses",
"total_move_cache_rel_hits",
"total_move_cache_rel_misses",
"total_guidance_match_moves",
"total_guidance_match_moves_straight",
"total_guidance_match_moves_bend90",
"total_guidance_match_moves_sbend",
"total_guidance_bonus_applied",
"total_guidance_bonus_applied_straight",
"total_guidance_bonus_applied_bend90",
"total_guidance_bonus_applied_sbend",
"total_static_safe_cache_hits",
"total_hard_collision_cache_hits",
"total_congestion_cache_hits",
"total_congestion_cache_misses",
"total_congestion_presence_cache_hits",
"total_congestion_presence_cache_misses",
"total_congestion_presence_skips",
"total_congestion_candidate_precheck_hits",
"total_congestion_candidate_precheck_misses",
"total_congestion_candidate_precheck_skips",
"total_congestion_grid_net_cache_hits",
"total_congestion_grid_net_cache_misses",
"total_congestion_grid_span_cache_hits",
"total_congestion_grid_span_cache_misses",
"total_congestion_candidate_nets",
"total_congestion_net_envelope_cache_hits",
"total_congestion_net_envelope_cache_misses",
"total_dynamic_path_objects_added",
"total_dynamic_path_objects_removed",
"total_dynamic_tree_rebuilds",
"total_dynamic_grid_rebuilds",
"total_static_tree_rebuilds",
"total_static_raw_tree_rebuilds",
"total_static_net_tree_rebuilds",
"total_visibility_corner_index_builds",
"total_visibility_builds",
"total_visibility_corner_pairs_checked",
"total_visibility_corner_queries_exact",
"total_visibility_corner_hits_exact",
"total_visibility_point_queries",
"total_visibility_point_cache_hits",
"total_visibility_point_cache_misses",
"total_visibility_tangent_candidate_scans",
"total_visibility_tangent_candidate_corner_checks",
"total_visibility_tangent_candidate_ray_tests",
"total_ray_cast_calls",
"total_ray_cast_calls_straight_static",
"total_ray_cast_calls_expand_snap",
"total_ray_cast_calls_expand_forward",
"total_ray_cast_calls_visibility_build",
"total_ray_cast_calls_visibility_query",
"total_ray_cast_calls_visibility_tangent",
"total_ray_cast_calls_other",
"total_ray_cast_candidate_bounds",
"total_ray_cast_exact_geometry_checks",
"total_congestion_check_calls",
"total_congestion_lazy_resolutions",
"total_congestion_lazy_requeues",
"total_congestion_candidate_ids",
"total_congestion_exact_pair_checks",
"total_verify_path_report_calls",
"total_verify_static_buffer_ops",
"total_verify_dynamic_candidate_nets",
"total_verify_dynamic_exact_pair_checks",
"total_refinement_windows_considered",
"total_refinement_static_bounds_checked",
"total_refinement_dynamic_bounds_checked",
"total_refinement_candidate_side_extents",
"total_refinement_candidates_built",
"total_refinement_candidates_verified",
"total_refinement_candidates_accepted",
"total_pair_local_search_pairs_considered",
"total_pair_local_search_attempts",
"total_pair_local_search_accepts",
"total_pair_local_search_nodes_expanded",
"total_late_phase_capped_nets",
"total_late_phase_capped_fallbacks",
"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.total_route_iterations = 0
self.total_nets_routed = 0
self.total_nets_reached_target = 0
self.total_warm_start_paths_built = 0
self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 0
self.total_timeout_events = 0
self.total_iteration_reverify_calls = 0
self.total_iteration_reverified_nets = 0
self.total_iteration_conflicting_nets = 0
self.total_iteration_conflict_edges = 0
self.total_nets_carried_forward = 0
self.total_score_component_calls = 0
self.total_score_component_total_ns = 0
self.total_path_cost_calls = 0
self.total_danger_map_lookup_calls = 0
self.total_danger_map_cache_hits = 0
self.total_danger_map_cache_misses = 0
self.total_danger_map_query_calls = 0
self.total_danger_map_total_ns = 0
self.total_move_cache_abs_hits = 0
self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0
self.total_congestion_cache_misses = 0
self.total_congestion_presence_cache_hits = 0
self.total_congestion_presence_cache_misses = 0
self.total_congestion_presence_skips = 0
self.total_congestion_candidate_precheck_hits = 0
self.total_congestion_candidate_precheck_misses = 0
self.total_congestion_candidate_precheck_skips = 0
self.total_congestion_grid_net_cache_hits = 0
self.total_congestion_grid_net_cache_misses = 0
self.total_congestion_grid_span_cache_hits = 0
self.total_congestion_grid_span_cache_misses = 0
self.total_congestion_candidate_nets = 0
self.total_congestion_net_envelope_cache_hits = 0
self.total_congestion_net_envelope_cache_misses = 0
self.total_dynamic_path_objects_added = 0
self.total_dynamic_path_objects_removed = 0
self.total_dynamic_tree_rebuilds = 0
self.total_dynamic_grid_rebuilds = 0
self.total_static_tree_rebuilds = 0
self.total_static_raw_tree_rebuilds = 0
self.total_static_net_tree_rebuilds = 0
self.total_visibility_corner_index_builds = 0
self.total_visibility_builds = 0
self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries_exact = 0
self.total_visibility_corner_hits_exact = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0
self.total_visibility_tangent_candidate_scans = 0
self.total_visibility_tangent_candidate_corner_checks = 0
self.total_visibility_tangent_candidate_ray_tests = 0
self.total_ray_cast_calls = 0
self.total_ray_cast_calls_straight_static = 0
self.total_ray_cast_calls_expand_snap = 0
self.total_ray_cast_calls_expand_forward = 0
self.total_ray_cast_calls_visibility_build = 0
self.total_ray_cast_calls_visibility_query = 0
self.total_ray_cast_calls_visibility_tangent = 0
self.total_ray_cast_calls_other = 0
self.total_ray_cast_candidate_bounds = 0
self.total_ray_cast_exact_geometry_checks = 0
self.total_congestion_check_calls = 0
self.total_congestion_lazy_resolutions = 0
self.total_congestion_lazy_requeues = 0
self.total_congestion_candidate_ids = 0
self.total_congestion_exact_pair_checks = 0
self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_candidate_nets = 0
self.total_verify_dynamic_exact_pair_checks = 0
self.total_refinement_windows_considered = 0
self.total_refinement_static_bounds_checked = 0
self.total_refinement_dynamic_bounds_checked = 0
self.total_refinement_candidate_side_extents = 0
self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
self.total_late_phase_capped_nets = 0
self.total_late_phase_capped_fallbacks = 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
self.total_route_iterations = 0
self.total_nets_routed = 0
self.total_nets_reached_target = 0
self.total_warm_start_paths_built = 0
self.total_warm_start_paths_used = 0
self.total_refine_path_calls = 0
self.total_timeout_events = 0
self.total_iteration_reverify_calls = 0
self.total_iteration_reverified_nets = 0
self.total_iteration_conflicting_nets = 0
self.total_iteration_conflict_edges = 0
self.total_nets_carried_forward = 0
self.total_score_component_calls = 0
self.total_score_component_total_ns = 0
self.total_path_cost_calls = 0
self.total_danger_map_lookup_calls = 0
self.total_danger_map_cache_hits = 0
self.total_danger_map_cache_misses = 0
self.total_danger_map_query_calls = 0
self.total_danger_map_total_ns = 0
self.total_move_cache_abs_hits = 0
self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0
self.total_congestion_cache_misses = 0
self.total_congestion_presence_cache_hits = 0
self.total_congestion_presence_cache_misses = 0
self.total_congestion_presence_skips = 0
self.total_congestion_candidate_precheck_hits = 0
self.total_congestion_candidate_precheck_misses = 0
self.total_congestion_candidate_precheck_skips = 0
self.total_congestion_grid_net_cache_hits = 0
self.total_congestion_grid_net_cache_misses = 0
self.total_congestion_grid_span_cache_hits = 0
self.total_congestion_grid_span_cache_misses = 0
self.total_congestion_candidate_nets = 0
self.total_congestion_net_envelope_cache_hits = 0
self.total_congestion_net_envelope_cache_misses = 0
self.total_dynamic_path_objects_added = 0
self.total_dynamic_path_objects_removed = 0
self.total_dynamic_tree_rebuilds = 0
self.total_dynamic_grid_rebuilds = 0
self.total_static_tree_rebuilds = 0
self.total_static_raw_tree_rebuilds = 0
self.total_static_net_tree_rebuilds = 0
self.total_visibility_corner_index_builds = 0
self.total_visibility_builds = 0
self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries_exact = 0
self.total_visibility_corner_hits_exact = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0
self.total_visibility_tangent_candidate_scans = 0
self.total_visibility_tangent_candidate_corner_checks = 0
self.total_visibility_tangent_candidate_ray_tests = 0
self.total_ray_cast_calls = 0
self.total_ray_cast_calls_straight_static = 0
self.total_ray_cast_calls_expand_snap = 0
self.total_ray_cast_calls_expand_forward = 0
self.total_ray_cast_calls_visibility_build = 0
self.total_ray_cast_calls_visibility_query = 0
self.total_ray_cast_calls_visibility_tangent = 0
self.total_ray_cast_calls_other = 0
self.total_ray_cast_candidate_bounds = 0
self.total_ray_cast_exact_geometry_checks = 0
self.total_congestion_check_calls = 0
self.total_congestion_lazy_resolutions = 0
self.total_congestion_lazy_requeues = 0
self.total_congestion_candidate_ids = 0
self.total_congestion_exact_pair_checks = 0
self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_candidate_nets = 0
self.total_verify_dynamic_exact_pair_checks = 0
self.total_refinement_windows_considered = 0
self.total_refinement_static_bounds_checked = 0
self.total_refinement_dynamic_bounds_checked = 0
self.total_refinement_candidate_side_extents = 0
self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
self.total_late_phase_capped_nets = 0
self.total_late_phase_capped_fallbacks = 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,
route_iterations=self.total_route_iterations,
nets_routed=self.total_nets_routed,
nets_reached_target=self.total_nets_reached_target,
warm_start_paths_built=self.total_warm_start_paths_built,
warm_start_paths_used=self.total_warm_start_paths_used,
refine_path_calls=self.total_refine_path_calls,
timeout_events=self.total_timeout_events,
iteration_reverify_calls=self.total_iteration_reverify_calls,
iteration_reverified_nets=self.total_iteration_reverified_nets,
iteration_conflicting_nets=self.total_iteration_conflicting_nets,
iteration_conflict_edges=self.total_iteration_conflict_edges,
nets_carried_forward=self.total_nets_carried_forward,
score_component_calls=self.total_score_component_calls,
score_component_total_ns=self.total_score_component_total_ns,
path_cost_calls=self.total_path_cost_calls,
danger_map_lookup_calls=self.total_danger_map_lookup_calls,
danger_map_cache_hits=self.total_danger_map_cache_hits,
danger_map_cache_misses=self.total_danger_map_cache_misses,
danger_map_query_calls=self.total_danger_map_query_calls,
danger_map_total_ns=self.total_danger_map_total_ns,
move_cache_abs_hits=self.total_move_cache_abs_hits,
move_cache_abs_misses=self.total_move_cache_abs_misses,
move_cache_rel_hits=self.total_move_cache_rel_hits,
move_cache_rel_misses=self.total_move_cache_rel_misses,
guidance_match_moves=self.total_guidance_match_moves,
guidance_match_moves_straight=self.total_guidance_match_moves_straight,
guidance_match_moves_bend90=self.total_guidance_match_moves_bend90,
guidance_match_moves_sbend=self.total_guidance_match_moves_sbend,
guidance_bonus_applied=self.total_guidance_bonus_applied,
guidance_bonus_applied_straight=self.total_guidance_bonus_applied_straight,
guidance_bonus_applied_bend90=self.total_guidance_bonus_applied_bend90,
guidance_bonus_applied_sbend=self.total_guidance_bonus_applied_sbend,
static_safe_cache_hits=self.total_static_safe_cache_hits,
hard_collision_cache_hits=self.total_hard_collision_cache_hits,
congestion_cache_hits=self.total_congestion_cache_hits,
congestion_cache_misses=self.total_congestion_cache_misses,
congestion_presence_cache_hits=self.total_congestion_presence_cache_hits,
congestion_presence_cache_misses=self.total_congestion_presence_cache_misses,
congestion_presence_skips=self.total_congestion_presence_skips,
congestion_candidate_precheck_hits=self.total_congestion_candidate_precheck_hits,
congestion_candidate_precheck_misses=self.total_congestion_candidate_precheck_misses,
congestion_candidate_precheck_skips=self.total_congestion_candidate_precheck_skips,
congestion_grid_net_cache_hits=self.total_congestion_grid_net_cache_hits,
congestion_grid_net_cache_misses=self.total_congestion_grid_net_cache_misses,
congestion_grid_span_cache_hits=self.total_congestion_grid_span_cache_hits,
congestion_grid_span_cache_misses=self.total_congestion_grid_span_cache_misses,
congestion_candidate_nets=self.total_congestion_candidate_nets,
congestion_net_envelope_cache_hits=self.total_congestion_net_envelope_cache_hits,
congestion_net_envelope_cache_misses=self.total_congestion_net_envelope_cache_misses,
dynamic_path_objects_added=self.total_dynamic_path_objects_added,
dynamic_path_objects_removed=self.total_dynamic_path_objects_removed,
dynamic_tree_rebuilds=self.total_dynamic_tree_rebuilds,
dynamic_grid_rebuilds=self.total_dynamic_grid_rebuilds,
static_tree_rebuilds=self.total_static_tree_rebuilds,
static_raw_tree_rebuilds=self.total_static_raw_tree_rebuilds,
static_net_tree_rebuilds=self.total_static_net_tree_rebuilds,
visibility_corner_index_builds=self.total_visibility_corner_index_builds,
visibility_builds=self.total_visibility_builds,
visibility_corner_pairs_checked=self.total_visibility_corner_pairs_checked,
visibility_corner_queries_exact=self.total_visibility_corner_queries_exact,
visibility_corner_hits_exact=self.total_visibility_corner_hits_exact,
visibility_point_queries=self.total_visibility_point_queries,
visibility_point_cache_hits=self.total_visibility_point_cache_hits,
visibility_point_cache_misses=self.total_visibility_point_cache_misses,
visibility_tangent_candidate_scans=self.total_visibility_tangent_candidate_scans,
visibility_tangent_candidate_corner_checks=self.total_visibility_tangent_candidate_corner_checks,
visibility_tangent_candidate_ray_tests=self.total_visibility_tangent_candidate_ray_tests,
ray_cast_calls=self.total_ray_cast_calls,
ray_cast_calls_straight_static=self.total_ray_cast_calls_straight_static,
ray_cast_calls_expand_snap=self.total_ray_cast_calls_expand_snap,
ray_cast_calls_expand_forward=self.total_ray_cast_calls_expand_forward,
ray_cast_calls_visibility_build=self.total_ray_cast_calls_visibility_build,
ray_cast_calls_visibility_query=self.total_ray_cast_calls_visibility_query,
ray_cast_calls_visibility_tangent=self.total_ray_cast_calls_visibility_tangent,
ray_cast_calls_other=self.total_ray_cast_calls_other,
ray_cast_candidate_bounds=self.total_ray_cast_candidate_bounds,
ray_cast_exact_geometry_checks=self.total_ray_cast_exact_geometry_checks,
congestion_check_calls=self.total_congestion_check_calls,
congestion_lazy_resolutions=self.total_congestion_lazy_resolutions,
congestion_lazy_requeues=self.total_congestion_lazy_requeues,
congestion_candidate_ids=self.total_congestion_candidate_ids,
congestion_exact_pair_checks=self.total_congestion_exact_pair_checks,
verify_path_report_calls=self.total_verify_path_report_calls,
verify_static_buffer_ops=self.total_verify_static_buffer_ops,
verify_dynamic_candidate_nets=self.total_verify_dynamic_candidate_nets,
verify_dynamic_exact_pair_checks=self.total_verify_dynamic_exact_pair_checks,
refinement_windows_considered=self.total_refinement_windows_considered,
refinement_static_bounds_checked=self.total_refinement_static_bounds_checked,
refinement_dynamic_bounds_checked=self.total_refinement_dynamic_bounds_checked,
refinement_candidate_side_extents=self.total_refinement_candidate_side_extents,
refinement_candidates_built=self.total_refinement_candidates_built,
refinement_candidates_verified=self.total_refinement_candidates_verified,
refinement_candidates_accepted=self.total_refinement_candidates_accepted,
pair_local_search_pairs_considered=self.total_pair_local_search_pairs_considered,
pair_local_search_attempts=self.total_pair_local_search_attempts,
pair_local_search_accepts=self.total_pair_local_search_accepts,
pair_local_search_nodes_expanded=self.total_pair_local_search_nodes_expanded,
late_phase_capped_nets=self.total_late_phase_capped_nets,
late_phase_capped_fallbacks=self.total_late_phase_capped_fallbacks,
)
class AStarContext:
__slots__ = (
"cost_evaluator",
"metrics",
"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,
metrics: AStarMetrics | None = None,
max_cache_size: int = 1000000,
) -> None:
self.cost_evaluator = cost_evaluator
self.metrics = metrics if metrics is not None else AStarMetrics()
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)

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
from __future__ import annotations
import heapq
from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR
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
from inire.geometry.primitives import Port
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] = {}
congestion_presence_cache: dict[tuple[str, int, int, int, int], bool] = {}
congestion_candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] = {}
congestion_net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
congestion_grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
congestion_grid_span_cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] = {}
start_node = _AStarNode(
start,
0.0,
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
seed_index=0 if config.guidance_seed else None,
)
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,
congestion_presence_cache,
congestion_candidate_precheck_cache,
congestion_net_envelope_cache,
congestion_grid_net_cache,
congestion_grid_span_cache,
config=config,
)
return _reconstruct_path(best_node) if config.return_partial else None

View file

@ -1,58 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from inire.model import SearchOptions, resolve_bend_geometry
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, bend_physical_geometry = resolve_bend_geometry(search)
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,
physical_geometry_type=bend_physical_geometry,
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,
physical_geometry_type=bend_physical_geometry,
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)

View file

@ -1,59 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from inire.geometry.collision import RoutingWorld
from inire.model import RoutingOptions, RoutingProblem
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
@dataclass(frozen=True, slots=True)
class RoutingStack:
world: RoutingWorld
danger_map: DangerMap
evaluator: CostEvaluator
context: AStarContext
finder: PathFinder
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,
)

View file

@ -1,207 +0,0 @@
from __future__ import annotations
from time import perf_counter_ns
from typing import TYPE_CHECKING
import numpy as np
from inire.constants import TOLERANCE_LINEAR
from inire.model import ObjectiveWeights
if TYPE_CHECKING:
from collections.abc import Sequence
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult, MoveKind
from inire.geometry.primitives import Port
from inire.router.danger_map import DangerMap
class CostEvaluator:
__slots__ = (
"collision_engine",
"danger_map",
"_search_weights",
"_greedy_h_weight",
"_target_x",
"_target_y",
"_target_r",
"_target_cos",
"_target_sin",
)
def __init__(
self,
collision_engine: RoutingWorld,
danger_map: DangerMap | None = None,
unit_length_cost: float = 1.0,
greedy_h_weight: float = 1.5,
bend_penalty: float = 250.0,
sbend_penalty: float | None = None,
danger_weight: float = 1.0,
) -> None:
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
self.collision_engine = collision_engine
self.danger_map = danger_map
self._search_weights = ObjectiveWeights(
unit_length_cost=unit_length_cost,
bend_penalty=bend_penalty,
sbend_penalty=actual_sbend_penalty,
danger_weight=danger_weight,
)
self._greedy_h_weight = float(greedy_h_weight)
self._target_x = 0.0
self._target_y = 0.0
self._target_r = 0
self._target_cos = 1.0
self._target_sin = 0.0
@property
def default_weights(self) -> ObjectiveWeights:
return self._search_weights
@property
def greedy_h_weight(self) -> float:
return self._greedy_h_weight
@greedy_h_weight.setter
def greedy_h_weight(self, value: float) -> None:
self._greedy_h_weight = float(value)
def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights:
return self._search_weights if weights is None else weights
def set_target(self, target: Port) -> None:
self._target_x = target.x
self._target_y = target.y
self._target_r = int(target.r)
rad = np.radians(target.r)
self._target_cos = np.cos(rad)
self._target_sin = np.sin(rad)
def h_manhattan(
self,
current: Port,
target: Port,
*,
min_bend_radius: float = 50.0,
) -> float:
tx, ty = target.x, target.y
if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r:
self.set_target(target)
dx = abs(current.x - tx)
dy = abs(current.y - ty)
dist = dx + dy
bp = self._search_weights.bend_penalty
penalty = 0.0
curr_r = current.r
diff = abs(curr_r - self._target_r) % 360
if diff > 0:
penalty += 2 * bp if diff == 180 else bp
v_dx = tx - current.x
v_dy = ty - current.y
side_proj = v_dx * self._target_cos + v_dy * self._target_sin
perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos)
if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0):
penalty += 2 * bp
if curr_r == 0:
c_cos, c_sin = 1.0, 0.0
elif curr_r == 90:
c_cos, c_sin = 0.0, 1.0
elif curr_r == 180:
c_cos, c_sin = -1.0, 0.0
else:
c_cos, c_sin = 0.0, -1.0
move_proj = v_dx * c_cos + v_dy * c_sin
if move_proj < 0:
penalty += 2 * bp
if diff == 0 and perp_dist > 0:
penalty += 2 * bp
return self._greedy_h_weight * (dist + penalty)
def score_component(
self,
component: ComponentResult,
*,
start_port: Port | None = None,
weights: ObjectiveWeights | None = None,
) -> float:
metrics = self.collision_engine.metrics
if metrics is not None:
metrics.total_score_component_calls += 1
start_ns = perf_counter_ns()
active_weights = self._resolve_weights(weights)
danger_map = self.danger_map
end_port = component.end_port
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
if metrics is not None:
metrics.total_score_component_total_ns += perf_counter_ns() - start_ns
return 1e15
move_radius = None
if component.move_type == "bend90":
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
total_cost = component.length * active_weights.unit_length_cost + self.component_penalty(
component.move_type,
move_radius=move_radius,
weights=active_weights,
)
# Skip danger sampling entirely when there are no static obstacles in the KD-tree.
if danger_map is not None and active_weights.danger_weight and danger_map.tree is not None:
cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0
cost_e = danger_map.get_cost(end_port.x, end_port.y)
if start_port:
mid_x = (start_port.x + end_port.x) / 2.0
mid_y = (start_port.y + end_port.y) / 2.0
cost_m = danger_map.get_cost(mid_x, mid_y)
total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
else:
total_cost += component.length * active_weights.danger_weight * cost_e
if metrics is not None:
metrics.total_score_component_total_ns += perf_counter_ns() - start_ns
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: Sequence[ComponentResult],
*,
weights: ObjectiveWeights | None = None,
) -> float:
metrics = self.collision_engine.metrics
if metrics is not None:
metrics.total_path_cost_calls += 1
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

View file

@ -1,125 +0,0 @@
from __future__ import annotations
from collections import OrderedDict
from time import perf_counter_ns
from typing import TYPE_CHECKING
import numpy
from scipy.spatial import cKDTree
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.router._astar_types import AStarMetrics
_COST_CACHE_SIZE = 100000
class DangerMap:
"""
A proximity cost evaluator using a KD-Tree of obstacle boundary points.
Scales with obstacle perimeter rather than design area.
"""
__slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'tree', '_cost_cache', 'metrics')
def __init__(
self,
bounds: tuple[float, float, float, float],
resolution: float = 5.0,
safety_threshold: float = 10.0,
k: float = 1.0,
) -> None:
"""
Initialize the Danger Map.
Args:
bounds: (minx, miny, maxx, maxy) in um.
resolution: Sampling resolution for obstacle boundaries (um).
safety_threshold: Proximity limit (um).
k: Penalty multiplier.
"""
self.minx, self.miny, self.maxx, self.maxy = bounds
self.resolution = resolution
self.safety_threshold = safety_threshold
self.k = k
self.tree: cKDTree | None = None
self._cost_cache: OrderedDict[tuple[int, int], float] = OrderedDict()
self.metrics: AStarMetrics | None = None
def precompute(self, obstacles: list[Polygon]) -> None:
"""
Pre-compute the proximity tree by sampling obstacle boundaries.
"""
all_points = []
for poly in obstacles:
# Sample exterior
exterior = poly.exterior
dist = 0.0
while dist < exterior.length:
pt = exterior.interpolate(dist)
all_points.append((pt.x, pt.y))
dist += self.resolution
# Sample interiors (holes)
for interior in poly.interiors:
dist = 0.0
while dist < interior.length:
pt = interior.interpolate(dist)
all_points.append((pt.x, pt.y))
dist += self.resolution
if all_points:
self.tree = cKDTree(numpy.array(all_points))
else:
self.tree = None
self._cost_cache.clear()
def is_within_bounds(self, x: float, y: float) -> bool:
"""
Check if a coordinate is within the design bounds.
"""
return self.minx <= x <= self.maxx and self.miny <= y <= self.maxy
def get_cost(self, x: float, y: float) -> float:
"""
Get the proximity cost at a specific coordinate using the KD-Tree.
Coordinates are quantized to 1nm to improve cache performance.
"""
metrics = self.metrics
if metrics is not None:
metrics.total_danger_map_lookup_calls += 1
start_ns = perf_counter_ns()
qx_milli = int(round(x * 1000))
qy_milli = int(round(y * 1000))
key = (qx_milli, qy_milli)
if key in self._cost_cache:
if metrics is not None:
metrics.total_danger_map_cache_hits += 1
metrics.total_danger_map_total_ns += perf_counter_ns() - start_ns
self._cost_cache.move_to_end(key)
return self._cost_cache[key]
if metrics is not None:
metrics.total_danger_map_cache_misses += 1
cost = self._compute_cost_quantized(qx_milli, qy_milli)
self._cost_cache[key] = cost
if len(self._cost_cache) > _COST_CACHE_SIZE:
self._cost_cache.popitem(last=False)
if metrics is not None:
metrics.total_danger_map_total_ns += perf_counter_ns() - start_ns
return cost
def _compute_cost_quantized(self, qx_milli: int, qy_milli: int) -> float:
qx = qx_milli / 1000.0
qy = qy_milli / 1000.0
if not self.is_within_bounds(qx, qy):
return 1e15
if self.tree is None:
return 0.0
if self.metrics is not None:
self.metrics.total_danger_map_query_calls += 1
dist, _ = self.tree.query([qx, qy], distance_upper_bound=self.safety_threshold)
if dist >= self.safety_threshold:
return 0.0
safe_dist = max(dist, 0.1)
return float(self.k / (safe_dist ** 2))

View file

@ -1,324 +0,0 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Literal
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[float, float]:
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):
self.context.metrics.total_refinement_static_bounds_checked += 1
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):
self.context.metrics.total_refinement_dynamic_bounds_checked += 1
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)
self.context.metrics.total_refinement_candidate_side_extents += len(direct_extents)
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: Literal["CW", "CCW"] = "CCW" if side_extent > 0 else "CW"
second_dir: Literal["CW", "CCW"] = "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
self.context.metrics.total_refinement_windows_considered += 1
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
self.context.metrics.total_refinement_candidates_built += 1
candidate_path = path[:start_idx] + replacement + path[end_idx:]
self.context.metrics.total_refinement_candidates_verified += 1
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:
self.context.metrics.total_refinement_candidates_accepted += 1
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: Sequence[ComponentResult],
) -> list[ComponentResult]:
if not path:
return list(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

View file

@ -1,16 +0,0 @@
"""Semi-private compatibility exports for router result types.
These deep-module imports remain accessible for advanced use, but they are
unstable and may change without notice. Prefer importing public result types
from ``inire`` or ``inire.results``.
"""
from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult
__all__ = [
"RouteMetrics",
"RoutingOutcome",
"RoutingReport",
"RoutingResult",
"RoutingRunResult",
]

View file

@ -1,246 +0,0 @@
from __future__ import annotations
import numpy
from typing import TYPE_CHECKING
import rtree
if TYPE_CHECKING:
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.primitives import Port
class VisibilityManager:
"""
Manages corners of static obstacles for sparse A* / Visibility Graph jumps.
"""
__slots__ = (
"collision_engine",
"corners",
"corner_index",
"_corner_graph",
"_point_visibility_cache",
"_corner_index_version",
"_corner_graph_version",
)
def __init__(self, collision_engine: RoutingWorld) -> None:
self.collision_engine = collision_engine
self.corners: list[tuple[float, float]] = []
self.corner_index = rtree.index.Index()
self._corner_graph: dict[int, list[tuple[float, float, float]]] = {}
self._point_visibility_cache: dict[tuple[int, int, int], list[tuple[float, float, float]]] = {}
self._corner_index_version = -1
self._corner_graph_version = -1
def clear_cache(self) -> None:
"""
Reset all static visibility data.
"""
self.corners = []
self.corner_index = rtree.index.Index()
self._corner_graph = {}
self._point_visibility_cache = {}
self._corner_index_version = -1
self._corner_graph_version = -1
def ensure_corner_index_current(self) -> None:
if self._corner_index_version != self.collision_engine.get_static_version():
self._build_corner_index()
def ensure_corner_graph_current(self) -> None:
self.ensure_corner_index_current()
static_version = self.collision_engine.get_static_version()
if self._corner_graph_version != static_version:
self._build_corner_graph()
def _ensure_current(self) -> None:
self.ensure_corner_graph_current()
def _build_corner_index(self) -> None:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_index_builds += 1
self.corners = []
self.corner_index = rtree.index.Index()
self._corner_graph = {}
self._point_visibility_cache = {}
self._corner_graph_version = -1
self._corner_index_version = self.collision_engine.get_static_version()
raw_corners = []
for poly in self.collision_engine.iter_static_dilated_geometries():
coords = list(poly.exterior.coords)
if coords[0] == coords[-1]:
coords = coords[:-1]
raw_corners.extend(coords)
for ring in poly.interiors:
coords = list(ring.coords)
if coords[0] == coords[-1]:
coords = coords[:-1]
raw_corners.extend(coords)
if not raw_corners:
return
seen = set()
for x, y in raw_corners:
sx, sy = round(x, 3), round(y, 3)
if (sx, sy) not in seen:
seen.add((sx, sy))
self.corners.append((sx, sy))
for i, (x, y) in enumerate(self.corners):
self.corner_index.insert(i, (x, y, x, y))
def _build_corner_graph(self) -> None:
"""
Pre-compute corner-to-corner visibility from the current corner index.
"""
self.ensure_corner_index_current()
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_builds += 1
self._corner_graph = {}
self._corner_graph_version = self.collision_engine.get_static_version()
if not self.corners:
return
# Pre-compute visibility graph between corners
num_corners = len(self.corners)
if num_corners > 200:
# Limit pre-computation if too many corners
return
for i in range(num_corners):
self._corner_graph[i] = []
p1 = Port(self.corners[i][0], self.corners[i][1], 0)
for j in range(num_corners):
if i == j:
continue
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_pairs_checked += 1
cx, cy = self.corners[j]
dx, dy = cx - p1.x, cy - p1.y
dist = numpy.sqrt(dx**2 + dy**2)
angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(p1, angle, max_dist=dist + 0.05, caller="visibility_build")
if reach >= dist - 0.01:
self._corner_graph[i].append((cx, cy, dist))
def _corner_idx_at(self, origin: Port) -> int | None:
self.ensure_corner_index_current()
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_tangent_corner_candidates(
self,
origin: Port,
*,
min_forward: float,
max_forward: float,
radii: tuple[float, ...],
tolerance: float = 2.0,
) -> list[int]:
self.ensure_corner_index_current()
if max_forward <= min_forward or not radii or not self.corners:
return []
candidate_ids: set[int] = set()
x0 = float(origin.x)
y0 = float(origin.y)
def _add_hits(bounds: tuple[float, float, float, float]) -> None:
min_x, min_y, max_x, max_y = bounds
if min_x > max_x or min_y > max_y:
return
candidate_ids.update(self.corner_index.intersection(bounds))
for radius in radii:
if origin.r == 0:
x_bounds = (x0 + min_forward, x0 + max_forward)
_add_hits((x_bounds[0], y0 + radius - tolerance, x_bounds[1], y0 + radius + tolerance))
_add_hits((x_bounds[0], y0 - radius - tolerance, x_bounds[1], y0 - radius + tolerance))
elif origin.r == 180:
x_bounds = (x0 - max_forward, x0 - min_forward)
_add_hits((x_bounds[0], y0 + radius - tolerance, x_bounds[1], y0 + radius + tolerance))
_add_hits((x_bounds[0], y0 - radius - tolerance, x_bounds[1], y0 - radius + tolerance))
elif origin.r == 90:
y_bounds = (y0 + min_forward, y0 + max_forward)
_add_hits((x0 + radius - tolerance, y_bounds[0], x0 + radius + tolerance, y_bounds[1]))
_add_hits((x0 - radius - tolerance, y_bounds[0], x0 - radius + tolerance, y_bounds[1]))
else:
y_bounds = (y0 - max_forward, y0 - min_forward)
_add_hits((x0 + radius - tolerance, y_bounds[0], x0 + radius + tolerance, y_bounds[1]))
_add_hits((x0 - radius - tolerance, y_bounds[0], x0 - radius + tolerance, y_bounds[1]))
return sorted(candidate_ids)
def get_point_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
"""
Find visible corners from an arbitrary point.
This may perform direct ray-cast scans and is not intended for hot search paths.
"""
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_point_queries += 1
self.ensure_corner_index_current()
if max_dist < 0:
return []
corner_idx = self._corner_idx_at(origin)
if corner_idx is not None:
self.ensure_corner_graph_current()
if corner_idx is not None and corner_idx in self._corner_graph:
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
ox, oy = round(origin.x, 3), round(origin.y, 3)
cache_key = (int(ox * 1000), int(oy * 1000), int(round(max_dist * 1000)))
if cache_key in self._point_visibility_cache:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_point_cache_hits += 1
return self._point_visibility_cache[cache_key]
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_point_cache_misses += 1
bounds = (origin.x - max_dist, origin.y - max_dist, origin.x + max_dist, origin.y + max_dist)
candidates = list(self.corner_index.intersection(bounds))
visible = []
for i in candidates:
cx, cy = self.corners[i]
dx, dy = cx - origin.x, cy - origin.y
dist = numpy.sqrt(dx**2 + dy**2)
if dist > max_dist or dist < 1e-3:
continue
angle = numpy.degrees(numpy.arctan2(dy, dx))
reach = self.collision_engine.ray_cast(origin, angle, max_dist=dist + 0.05, caller="visibility_query")
if reach >= dist - 0.01:
visible.append((cx, cy, dist))
self._point_visibility_cache[cache_key] = visible
return visible
def get_corner_visibility(self, origin: Port, max_dist: float = 1000.0) -> list[tuple[float, float, float]]:
"""
Return precomputed visibility only when the origin is already at a known corner.
This avoids the expensive arbitrary-point visibility scan in hot search paths.
"""
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_queries_exact += 1
self.ensure_corner_graph_current()
if max_dist < 0:
return []
corner_idx = self._corner_idx_at(origin)
if corner_idx is not None and corner_idx in self._corner_graph:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_hits_exact += 1
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
return []

View file

@ -1,48 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
BendDirection = Literal["CW", "CCW"]
@dataclass(frozen=True, slots=True)
class StraightSeed:
length: float
def __post_init__(self) -> None:
object.__setattr__(self, "length", float(self.length))
@dataclass(frozen=True, slots=True)
class Bend90Seed:
radius: float
direction: BendDirection
def __post_init__(self) -> None:
object.__setattr__(self, "radius", float(self.radius))
@dataclass(frozen=True, slots=True)
class SBendSeed:
offset: float
radius: float
def __post_init__(self) -> None:
object.__setattr__(self, "offset", float(self.offset))
object.__setattr__(self, "radius", float(self.radius))
PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed
@dataclass(frozen=True, slots=True)
class PathSeed:
segments: tuple[PathSegmentSeed, ...]
def __post_init__(self) -> None:
segments = tuple(self.segments)
if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments):
raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances")
object.__setattr__(self, "segments", segments)

View file

@ -1,688 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from time import perf_counter
from collections.abc import Callable
from shapely.geometry import Polygon, box
from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
RoutingResult,
RoutingRunResult,
SearchOptions,
)
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.results import RouteMetrics
from inire.router._astar_types import AStarContext, AStarMetrics
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
_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]
ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"]
TraceScenarioRun = Callable[[], RoutingRunResult]
@dataclass(frozen=True, slots=True)
class ScenarioSnapshot:
name: str
duration_s: float
total_results: int
valid_results: int
reached_targets: int
metrics: RouteMetrics
def as_outcome(self) -> ScenarioOutcome:
return (
self.duration_s,
self.total_results,
self.valid_results,
self.reached_targets,
)
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
return (
duration_s,
len(results),
sum(1 for result in results.values() if result.is_valid),
sum(1 for result in results.values() if result.reached_target),
)
def _make_snapshot(
name: str,
results: dict[str, RoutingResult],
duration_s: float,
metrics: RouteMetrics,
) -> ScenarioSnapshot:
return ScenarioSnapshot(
name=name,
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),
metrics=metrics,
)
def _make_run_result(
results: dict[str, RoutingResult],
pathfinder: PathFinder,
) -> RoutingRunResult:
return RoutingRunResult(
results_by_net=results,
metrics=pathfinder.metrics.snapshot(),
expanded_nodes=tuple(pathfinder.accumulated_expanded_nodes),
conflict_trace=tuple(pathfinder.conflict_trace),
frontier_trace=tuple(pathfinder.frontier_trace),
pre_pair_frontier_trace=pathfinder.pre_pair_frontier_trace,
iteration_trace=tuple(pathfinder.iteration_trace),
)
def _sum_metrics(metrics_list: tuple[RouteMetrics, ...]) -> RouteMetrics:
metric_names = RouteMetrics.__dataclass_fields__
return RouteMetrics(
**{
name: sum(getattr(metrics, name) for metrics in metrics_list)
for name in metric_names
}
)
def _build_evaluator(
bounds: tuple[float, float, float, float],
*,
clearance: float = 2.0,
obstacles: list[Polygon] | None = None,
bend_penalty: float = 50.0,
sbend_penalty: float = 150.0,
) -> CostEvaluator:
static_obstacles = obstacles or []
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(static_obstacles)
return CostEvaluator(engine, danger_map, bend_penalty=bend_penalty, sbend_penalty=sbend_penalty)
def _net_specs(
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
) -> tuple[NetSpec, ...]:
return tuple(
NetSpec(net_id=net_id, start=start, target=target, width=widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
def _build_options(**overrides: object) -> RoutingOptions:
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
return RoutingOptions(
search=SearchOptions(**search_overrides),
congestion=CongestionOptions(**congestion_overrides),
refinement=RefinementOptions(**refinement_overrides),
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
objective=ObjectiveWeights(**objective_overrides),
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...],
metrics: AStarMetrics | None = None,
**request_kwargs: object,
) -> PathFinder:
resolved_metrics = AStarMetrics() if metrics is None else metrics
return PathFinder(
AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
_build_options(**request_kwargs),
metrics=resolved_metrics,
),
metrics=resolved_metrics,
)
def _build_routing_stack(
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
clearance: float = 2.0,
obstacles: list[Polygon] | None = None,
evaluator_kwargs: dict[str, float] | None = None,
request_kwargs: dict[str, object] | None = None,
) -> tuple[RoutingWorld, CostEvaluator, AStarMetrics, object]:
static_obstacles = obstacles or []
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute(static_obstacles)
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
metrics = AStarMetrics()
pathfinder = _build_pathfinder(
evaluator,
bounds=bounds,
nets=_net_specs(netlist, widths),
metrics=metrics,
**(request_kwargs or {}),
)
return engine, evaluator, metrics, pathfinder
def snapshot_example_01() -> ScenarioSnapshot:
netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))}
widths = {"net1": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
request_kwargs={"bend_radii": [10.0]},
)
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _make_snapshot("example_01_simple_route", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_01() -> ScenarioOutcome:
return snapshot_example_01().as_outcome()
def snapshot_example_02() -> ScenarioSnapshot:
netlist = {
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"bend_penalty": 50.0,
"sbend_penalty": 150.0,
},
request_kwargs={
"bend_radii": [10.0],
"sbend_radii": [10.0],
"base_penalty": 1000.0,
},
)
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _make_snapshot("example_02_congestion_resolution", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_02() -> ScenarioOutcome:
return snapshot_example_02().as_outcome()
def snapshot_example_03() -> ScenarioSnapshot:
netlist_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
widths_a = {"netA": 2.0}
engine, evaluator, _, pathfinder = _build_routing_stack(
bounds=(0, -50, 100, 50),
netlist=netlist_a,
widths=widths_a,
request_kwargs={"bend_radii": [10.0]},
)
t0 = perf_counter()
results_a = pathfinder.route_all()
metrics_a = pathfinder.metrics.snapshot()
for polygon in results_a["netA"].locked_geometry:
engine.add_static_obstacle(polygon)
pathfinder_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],
)
results_b = pathfinder_b.route_all()
t1 = perf_counter()
combined_metrics = _sum_metrics((metrics_a, pathfinder_b.metrics.snapshot()))
return _make_snapshot("example_03_locked_paths", {**results_a, **results_b}, t1 - t0, combined_metrics)
def run_example_03() -> ScenarioOutcome:
return snapshot_example_03().as_outcome()
def snapshot_example_04() -> ScenarioSnapshot:
netlist = {
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
}
widths = {"sbend_only": 2.0, "multi_radii": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
evaluator_kwargs={
"unit_length_cost": 1.0,
"bend_penalty": 10.0,
"sbend_penalty": 20.0,
},
request_kwargs={
"node_limit": 50000,
"bend_radii": [10.0, 30.0],
"sbend_offsets": [5.0],
},
)
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _make_snapshot("example_04_sbends_and_radii", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_04() -> ScenarioOutcome:
return snapshot_example_04().as_outcome()
def snapshot_example_05() -> ScenarioSnapshot:
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 200, 200),
netlist=netlist,
widths=widths,
evaluator_kwargs={"bend_penalty": 50.0},
request_kwargs={"bend_radii": [20.0]},
)
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _make_snapshot("example_05_orientation_stress", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_05() -> ScenarioOutcome:
return snapshot_example_05().as_outcome()
def trace_example_05() -> RoutingRunResult:
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, 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], "capture_conflict_trace": True, "capture_frontier_trace": True},
)
results = pathfinder.route_all()
return _make_run_result(results, pathfinder)
def snapshot_example_06() -> ScenarioSnapshot:
bounds = (-20, -20, 170, 170)
obstacles = [
box(40, 110, 60, 130),
box(40, 60, 60, 80),
box(40, 10, 60, 30),
]
custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
scenarios = [
(
_build_evaluator(bounds, obstacles=obstacles),
{"arc_model": (Port(10, 120, 0), Port(90, 140, 90))},
{"arc_model": 2.0},
{"bend_radii": [10.0], "bend_collision_type": "arc", "use_tiered_strategy": False},
),
(
_build_evaluator(bounds, obstacles=obstacles),
{"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))},
{"bbox_model": 2.0},
{"bend_radii": [10.0], "bend_collision_type": "bbox", "use_tiered_strategy": False},
),
(
_build_evaluator(bounds, obstacles=obstacles),
{"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))},
{"custom_geometry": 2.0},
{
"bend_radii": [10.0],
"bend_physical_geometry": custom_physical,
"bend_proxy_geometry": custom_physical,
"use_tiered_strategy": False,
},
),
]
t0 = perf_counter()
combined_results: dict[str, RoutingResult] = {}
route_metrics: list[RouteMetrics] = []
for evaluator, netlist, net_widths, request_kwargs in scenarios:
pathfinder = _build_pathfinder(
evaluator,
bounds=bounds,
nets=_net_specs(netlist, net_widths),
**request_kwargs,
)
combined_results.update(pathfinder.route_all())
route_metrics.append(pathfinder.metrics.snapshot())
t1 = perf_counter()
return _make_snapshot(
"example_06_bend_collision_models",
combined_results,
t1 - t0,
_sum_metrics(tuple(route_metrics)),
)
def run_example_06() -> ScenarioOutcome:
return snapshot_example_06().as_outcome()
def snapshot_example_07() -> ScenarioSnapshot:
return _snapshot_example_07_variant(
"example_07_large_scale_routing",
warm_start_enabled=True,
)
def snapshot_example_07_no_warm_start() -> ScenarioSnapshot:
return _snapshot_example_07_variant(
"example_07_large_scale_routing_no_warm_start",
warm_start_enabled=False,
)
def snapshot_example_07_no_warm_start_seed43() -> ScenarioSnapshot:
return _snapshot_example_07_variant(
"example_07_large_scale_routing_no_warm_start_seed43",
warm_start_enabled=False,
seed=43,
)
def trace_example_07() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=True)
def trace_example_07_no_warm_start() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=False)
def trace_example_07_no_warm_start_seed43() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=False, seed=43)
def _build_example_07_variant_stack(
*,
num_nets: int,
seed: int,
warm_start_enabled: bool,
capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
capture_iteration_trace: bool = False,
capture_pre_pair_frontier_trace: bool = False,
) -> tuple[CostEvaluator, AStarMetrics, PathFinder]:
bounds = (0, 0, 1000, 1000)
obstacles = [
box(450, 0, 550, 400),
box(450, 600, 550, 1000),
]
start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950
end_y_base = 100
end_y_pitch = 800.0 / (num_nets - 1)
netlist = {}
for index in range(num_nets):
sy = int(round(start_y_base + index * 10.0))
ey = int(round(end_y_base + index * end_y_pitch))
netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
widths = dict.fromkeys(netlist, 2.0)
_, evaluator, metrics, pathfinder = _build_routing_stack(
bounds=bounds,
netlist=netlist,
widths=widths,
clearance=6.0,
obstacles=obstacles,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"unit_length_cost": 0.1,
"bend_penalty": 100.0,
"sbend_penalty": 400.0,
},
request_kwargs={
"node_limit": 2000000,
"bend_radii": [50.0],
"sbend_radii": [50.0],
"bend_clip_margin": 10.0,
"max_iterations": 15,
"base_penalty": 100.0,
"multiplier": 1.4,
"net_order": "shortest",
"capture_expanded": True,
"capture_conflict_trace": capture_conflict_trace,
"capture_frontier_trace": capture_frontier_trace,
"capture_iteration_trace": capture_iteration_trace,
"capture_pre_pair_frontier_trace": capture_pre_pair_frontier_trace,
"shuffle_nets": True,
"seed": seed,
"warm_start_enabled": warm_start_enabled,
},
)
return evaluator, metrics, pathfinder
def _run_example_07_variant(
*,
num_nets: int,
seed: int,
warm_start_enabled: bool,
capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
capture_iteration_trace: bool = False,
capture_pre_pair_frontier_trace: bool = False,
) -> RoutingRunResult:
evaluator, metrics, pathfinder = _build_example_07_variant_stack(
num_nets=num_nets,
seed=seed,
warm_start_enabled=warm_start_enabled,
capture_conflict_trace=capture_conflict_trace,
capture_frontier_trace=capture_frontier_trace,
capture_iteration_trace=capture_iteration_trace,
capture_pre_pair_frontier_trace=capture_pre_pair_frontier_trace,
)
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
_ = current_results
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
evaluator.greedy_h_weight = new_greedy
metrics.reset_per_route()
results = pathfinder.route_all(iteration_callback=iteration_callback)
return _make_run_result(results, pathfinder)
def _snapshot_example_07_variant(
name: str,
*,
warm_start_enabled: bool,
seed: int = 42,
) -> ScenarioSnapshot:
t0 = perf_counter()
run = _run_example_07_variant(
num_nets=10,
seed=seed,
warm_start_enabled=warm_start_enabled,
)
t1 = perf_counter()
return _make_snapshot(name, run.results_by_net, t1 - t0, run.metrics)
def _trace_example_07_variant(
*,
warm_start_enabled: bool,
seed: int = 42,
) -> RoutingRunResult:
return _run_example_07_variant(
num_nets=10,
seed=seed,
warm_start_enabled=warm_start_enabled,
capture_conflict_trace=True,
capture_frontier_trace=True,
capture_iteration_trace=True,
capture_pre_pair_frontier_trace=True,
)
def run_example_07() -> ScenarioOutcome:
return snapshot_example_07().as_outcome()
def snapshot_example_08() -> ScenarioSnapshot:
bounds = (0, 0, 150, 150)
netlist = {"standard_arc": (Port(20, 20, 0), Port(100, 100, 90))}
widths = {"standard_arc": 2.0}
custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
custom_proxy = box(0, -11, 11, 0)
t0 = perf_counter()
pathfinder_std = _build_pathfinder(
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs(netlist, widths),
bend_radii=[10.0],
sbend_radii=[],
max_iterations=1,
use_tiered_strategy=False,
metrics=AStarMetrics(),
)
results_std = pathfinder_std.route_all()
pathfinder_custom = _build_pathfinder(
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}),
bend_radii=[10.0],
bend_physical_geometry=custom_physical,
bend_proxy_geometry=custom_proxy,
sbend_radii=[],
max_iterations=1,
use_tiered_strategy=False,
metrics=AStarMetrics(),
)
results_custom = pathfinder_custom.route_all()
t1 = perf_counter()
combined_metrics = _sum_metrics((pathfinder_std.metrics.snapshot(), pathfinder_custom.metrics.snapshot()))
return _make_snapshot(
"example_08_custom_bend_geometry",
{**results_std, **results_custom},
t1 - t0,
combined_metrics,
)
def run_example_08() -> ScenarioOutcome:
return snapshot_example_08().as_outcome()
def snapshot_example_09() -> ScenarioSnapshot:
obstacles = [
box(35, 35, 45, 65),
box(55, 35, 65, 65),
]
netlist = {"budget_limited_net": (Port(10, 50, 0), Port(85, 60, 180))}
widths = {"budget_limited_net": 2.0}
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
widths=widths,
obstacles=obstacles,
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False, "max_iterations": 1},
)
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _make_snapshot("example_09_unroutable_best_effort", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_09() -> ScenarioOutcome:
return snapshot_example_09().as_outcome()
SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
("example_01_simple_route", run_example_01),
("example_02_congestion_resolution", run_example_02),
("example_03_locked_paths", run_example_03),
("example_04_sbends_and_radii", run_example_04),
("example_05_orientation_stress", run_example_05),
("example_06_bend_collision_models", run_example_06),
("example_07_large_scale_routing", run_example_07),
("example_08_custom_bend_geometry", run_example_08),
("example_09_unroutable_best_effort", run_example_09),
)
SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("example_01_simple_route", snapshot_example_01),
("example_02_congestion_resolution", snapshot_example_02),
("example_03_locked_paths", snapshot_example_03),
("example_04_sbends_and_radii", snapshot_example_04),
("example_05_orientation_stress", snapshot_example_05),
("example_06_bend_collision_models", snapshot_example_06),
("example_07_large_scale_routing", snapshot_example_07),
("example_08_custom_bend_geometry", snapshot_example_08),
("example_09_unroutable_best_effort", snapshot_example_09),
)
PERFORMANCE_SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("example_07_large_scale_routing_no_warm_start", snapshot_example_07_no_warm_start),
("example_07_large_scale_routing_no_warm_start_seed43", snapshot_example_07_no_warm_start_seed43),
)
TRACE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_05_orientation_stress", trace_example_05),
("example_07_large_scale_routing", trace_example_07),
)
TRACE_PERFORMANCE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_07_large_scale_routing_no_warm_start", trace_example_07_no_warm_start),
("example_07_large_scale_routing_no_warm_start_seed43", trace_example_07_no_warm_start_seed43),
)
def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]:
return tuple(run() for _, run in SCENARIO_SNAPSHOTS)

View file

@ -1,805 +0,0 @@
import importlib
import pytest
from shapely.geometry import box
import inire.router._router as router_module
from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
Port,
RefinementOptions,
RoutingOptions,
RoutingProblem,
SearchOptions,
route,
)
from inire.geometry.components import Straight
from inire.geometry.collision import RoutingWorld
from inire.results import RoutingReport, RoutingResult
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder, _IterationReview
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
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
assert run.conflict_trace == ()
assert run.frontier_trace == ()
assert run.pre_pair_frontier_trace is None
assert run.iteration_trace == ()
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
assert run.metrics.route_iterations >= 1
assert run.metrics.iteration_reverify_calls >= 1
assert run.metrics.iteration_reverified_nets >= 0
assert run.metrics.iteration_conflicting_nets >= 0
assert run.metrics.iteration_conflict_edges >= 0
assert run.metrics.nets_carried_forward >= 0
assert run.metrics.nets_routed >= 1
assert run.metrics.move_cache_abs_misses >= 0
assert run.metrics.ray_cast_calls >= 0
assert run.metrics.dynamic_tree_rebuilds >= 0
assert run.metrics.visibility_corner_index_builds >= 0
assert run.metrics.visibility_builds >= 0
assert run.metrics.guidance_match_moves >= 0
assert run.metrics.guidance_match_moves_straight >= 0
assert run.metrics.guidance_match_moves_bend90 >= 0
assert run.metrics.guidance_match_moves_sbend >= 0
assert run.metrics.guidance_bonus_applied >= 0.0
assert run.metrics.guidance_bonus_applied_straight >= 0.0
assert run.metrics.guidance_bonus_applied_bend90 >= 0.0
assert run.metrics.guidance_bonus_applied_sbend >= 0.0
assert run.metrics.congestion_grid_span_cache_hits >= 0
assert run.metrics.congestion_grid_span_cache_misses >= 0
assert run.metrics.congestion_presence_cache_hits >= 0
assert run.metrics.congestion_presence_cache_misses >= 0
assert run.metrics.congestion_presence_skips >= 0
assert run.metrics.congestion_candidate_precheck_hits >= 0
assert run.metrics.congestion_candidate_precheck_misses >= 0
assert run.metrics.congestion_candidate_precheck_skips >= 0
assert run.metrics.congestion_candidate_nets >= 0
assert run.metrics.congestion_net_envelope_cache_hits >= 0
assert run.metrics.congestion_net_envelope_cache_misses >= 0
assert run.metrics.congestion_grid_net_cache_hits >= 0
assert run.metrics.congestion_grid_net_cache_misses >= 0
assert run.metrics.congestion_lazy_resolutions >= 0
assert run.metrics.congestion_lazy_requeues >= 0
assert run.metrics.congestion_candidate_ids >= 0
assert run.metrics.verify_dynamic_candidate_nets >= 0
assert run.metrics.verify_path_report_calls >= 0
assert run.metrics.pair_local_search_pairs_considered >= 0
assert run.metrics.pair_local_search_attempts >= 0
assert run.metrics.pair_local_search_accepts >= 0
assert run.metrics.pair_local_search_nodes_expanded >= 0
assert run.metrics.late_phase_capped_nets >= 0
assert run.metrics.late_phase_capped_fallbacks >= 0
def test_iteration_callback_observes_reverified_conflicts() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
snapshots: list[dict[str, str]] = []
def callback(iteration: int, current_results: dict[str, object]) -> None:
_ = iteration
snapshots.append({net_id: result.outcome for net_id, result in current_results.items()})
results = pathfinder.route_all(iteration_callback=callback)
assert snapshots == [{"horizontal": "colliding", "vertical": "colliding"}]
assert results["horizontal"].outcome == "colliding"
assert results["vertical"].outcome == "colliding"
def test_capture_conflict_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert [entry.stage for entry in run_with_trace.conflict_trace] == ["iteration", "restored_best", "final"]
def test_capture_iteration_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_iteration_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert len(run_with_trace.iteration_trace) == 1
def test_capture_iteration_trace_records_iteration_and_attempt_deltas() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
run = route(
problem,
options=RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_iteration_trace=True),
),
)
entry = run.iteration_trace[0]
assert entry.iteration == 0
assert entry.congestion_penalty == 100.0
assert entry.routed_net_ids == ("horizontal", "vertical")
assert entry.completed_nets == 0
assert entry.conflict_edges == 1
assert entry.total_dynamic_collisions >= 2
assert entry.nodes_expanded >= 0
assert entry.congestion_check_calls >= 0
assert entry.congestion_candidate_ids >= 0
assert entry.congestion_exact_pair_checks >= 0
assert len(entry.net_attempts) == 2
assert [attempt.net_id for attempt in entry.net_attempts] == ["horizontal", "vertical"]
assert all(attempt.nodes_expanded >= 0 for attempt in entry.net_attempts)
assert all(attempt.congestion_check_calls >= 0 for attempt in entry.net_attempts)
assert all(not attempt.guidance_seed_present for attempt in entry.net_attempts)
assert sum(attempt.nodes_expanded for attempt in entry.net_attempts) == entry.nodes_expanded
assert sum(attempt.congestion_check_calls for attempt in entry.net_attempts) == entry.congestion_check_calls
def test_capture_conflict_trace_records_component_pairs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
)
run = route(problem, options=options)
final_entry = run.conflict_trace[-1]
trace_by_net = {net.net_id: net for net in final_entry.nets}
assert final_entry.stage == "final"
assert final_entry.conflict_edges == (("horizontal", "vertical"),)
assert trace_by_net["horizontal"].component_conflicts[0].other_net_id == "vertical"
assert trace_by_net["horizontal"].component_conflicts[0].self_component_index == 0
assert trace_by_net["horizontal"].component_conflicts[0].other_component_index == 0
assert trace_by_net["vertical"].component_conflicts[0].other_net_id == "horizontal"
assert trace_by_net["vertical"].component_conflicts[0].self_component_index == 0
assert trace_by_net["vertical"].component_conflicts[0].other_component_index == 0
def test_capture_frontier_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_frontier_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert {trace.net_id for trace in run_with_trace.frontier_trace} == {"horizontal", "vertical"}
def test_capture_pre_pair_frontier_trace_preserves_route_outputs() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
base_options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run_without_trace = route(problem, options=base_options)
run_with_trace = route(
problem,
options=RoutingOptions(
congestion=base_options.congestion,
refinement=base_options.refinement,
diagnostics=DiagnosticsOptions(capture_pre_pair_frontier_trace=True),
),
)
assert {net_id: result.outcome for net_id, result in run_without_trace.results_by_net.items()} == {
net_id: result.outcome for net_id, result in run_with_trace.results_by_net.items()
}
assert run_with_trace.pre_pair_frontier_trace is None
def test_capture_frontier_trace_records_prune_reasons() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
run = route(
problem,
options=RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_frontier_trace=True),
),
)
trace_by_net = {entry.net_id: entry for entry in run.frontier_trace}
assert trace_by_net["horizontal"].hotspot_bounds
assert (
trace_by_net["horizontal"].pruned_closed_set
+ trace_by_net["horizontal"].pruned_hard_collision
+ trace_by_net["horizontal"].pruned_self_collision
+ trace_by_net["horizontal"].pruned_cost
) > 0
assert trace_by_net["horizontal"].samples
def test_reverify_iterations_stop_early_on_stalled_conflict_graph() -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("horizontal", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("vertical", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=10, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
run = route(problem, options=options)
assert run.metrics.route_iterations < 10
def test_reverify_iterations_limit_late_reroutes_to_conflicting_nets(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("netB", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
NetSpec("netC", Port(10, 20, 0), Port(90, 20, 0), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=10, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
colliding_a = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(dynamic_collision_count=1, total_length=80.0),
)
colliding_b = RoutingResult(
net_id="netB",
path=(Straight.generate(Port(50, 10, 90), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(dynamic_collision_count=1, total_length=80.0),
)
completed_c = RoutingResult(
net_id="netC",
path=(Straight.generate(Port(10, 20, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(total_length=80.0),
)
iterations_seen: list[int] = []
reroute_sets: list[set[str]] = []
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = iteration_callback
iterations_seen.append(iteration)
reroute_sets.append(set(reroute_net_ids))
state.results = {"netA": colliding_a, "netB": colliding_b, "netC": completed_c}
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids={"netC"},
total_dynamic_collisions=2,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
monkeypatch.setattr(PathFinder, "_verify_results", lambda self, state: dict(state.results))
monkeypatch.setattr(PathFinder, "_run_pair_local_search", lambda self, state: None)
results = pathfinder.route_all()
assert iterations_seen == [0, 1]
assert reroute_sets == [{"netA", "netB", "netC"}, {"netA", "netB"}]
assert results["netA"].outcome == "colliding"
assert results["netB"].outcome == "colliding"
assert results["netC"].reached_target
def test_run_iteration_orders_subset_reroutes_by_recent_work(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("netB", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
NetSpec("netC", Port(10, 20, 0), Port(90, 20, 0), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False, shuffle_nets=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
state = pathfinder._prepare_state()
state.recent_attempt_work = {"netA": 200, "netB": 20}
route_order: list[str] = []
def fake_route_net_once(self, state, iteration, net_id, *, node_limit_override=None, incumbent_fallback=None):
_ = self
_ = state
_ = iteration
route_order.append(net_id)
assert node_limit_override is None
assert incumbent_fallback is None
return RoutingResult(net_id=net_id, path=(), reached_target=False), False
def fake_reverify(self, state):
_ = self
_ = state
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids=set(),
total_dynamic_collisions=2,
)
monkeypatch.setattr(PathFinder, "_route_net_once", fake_route_net_once)
monkeypatch.setattr(PathFinder, "_reverify_iteration_results", fake_reverify)
review = pathfinder._run_iteration(state, 1, {"netA", "netB"}, None)
assert review is not None
assert route_order == ["netB", "netA"]
def test_run_iteration_caps_two_heaviest_late_phase_nets(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("netB", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
NetSpec("netC", Port(10, 20, 0), Port(90, 20, 0), width=2.0),
NetSpec("netD", Port(10, 80, 0), Port(90, 80, 0), width=2.0),
NetSpec("netE", Port(10, 65, 0), Port(90, 65, 0), width=2.0),
),
)
options = RoutingOptions(
objective=ObjectiveWeights(bend_penalty=100.0),
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False, shuffle_nets=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
state = pathfinder._prepare_state()
state.results = {
net_id: RoutingResult(net_id=net_id, path=(Straight.generate(spec.start, 80.0, 2.0, dilation=1.0),), reached_target=True)
for net_id, spec in state.net_specs.items()
}
state.best_conflict_edges = 2
state.recent_attempt_work = {"netA": 20, "netB": 40, "netC": 400, "netD": 220}
incumbents = dict(state.results)
caps_by_net: dict[str, tuple[int | None, RoutingResult | None]] = {}
def fake_route_net_once(self, state, iteration, net_id, *, node_limit_override=None, incumbent_fallback=None):
_ = self
_ = state
_ = iteration
caps_by_net[net_id] = (node_limit_override, incumbent_fallback)
return RoutingResult(net_id=net_id, path=(), reached_target=False), False
def fake_reverify(self, state):
_ = self
_ = state
return _IterationReview(
conflicting_nets={"netA", "netB", "netC", "netD"},
conflict_edges={("netA", "netB"), ("netC", "netD")},
completed_net_ids=set(),
total_dynamic_collisions=2,
)
monkeypatch.setattr(PathFinder, "_route_net_once", fake_route_net_once)
monkeypatch.setattr(PathFinder, "_reverify_iteration_results", fake_reverify)
review = pathfinder._run_iteration(state, 1, {"netA", "netB", "netC", "netD"}, None)
assert review is not None
assert caps_by_net["netA"] == (None, None)
assert caps_by_net["netB"] == (None, None)
assert caps_by_net["netC"][0] == 1
assert caps_by_net["netD"][0] == 1
assert caps_by_net["netC"][1] is incumbents["netC"]
assert caps_by_net["netD"][1] is incumbents["netD"]
def test_route_net_once_skips_search_for_capped_incumbent_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
objective=ObjectiveWeights(bend_penalty=100.0),
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
state = pathfinder._prepare_state()
incumbent = RoutingResult(
net_id="netA",
path=(Straight.generate(problem.nets[0].start, 80.0, 2.0, dilation=1.0),),
reached_target=True,
)
state.results["netA"] = incumbent
installed: list[tuple[str, tuple[object, ...]]] = []
def fail_route_astar(*args, **kwargs):
raise AssertionError("route_astar should not run for capped incumbent fallback")
def record_install(self, net_id, path):
_ = self
installed.append((net_id, tuple(path)))
monkeypatch.setattr(router_module, "route_astar", fail_route_astar)
monkeypatch.setattr(PathFinder, "_install_path", record_install)
result, guidance_seed_present = pathfinder._route_net_once(
state,
1,
"netA",
node_limit_override=1,
incumbent_fallback=incumbent,
)
assert result is incumbent
assert guidance_seed_present is True
assert installed == [("netA", incumbent.path)]
assert pathfinder.metrics.total_late_phase_capped_nets == 1
assert pathfinder.metrics.total_late_phase_capped_fallbacks == 1
def test_route_all_restores_best_iteration_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(
NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),
NetSpec("netB", Port(50, 10, 90), Port(50, 90, 90), width=2.0),
),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
missing_result = RoutingResult(net_id="netA", path=(), reached_target=False)
unroutable_b = RoutingResult(net_id="netB", path=(), reached_target=False)
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = reroute_net_ids
_ = iteration_callback
if iteration == 0:
state.results = {"netA": best_result, "netB": unroutable_b}
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids={"netA"},
total_dynamic_collisions=1,
)
state.results = {"netA": missing_result, "netB": unroutable_b}
return _IterationReview(
conflicting_nets={"netA", "netB"},
conflict_edges={("netA", "netB")},
completed_net_ids=set(),
total_dynamic_collisions=2,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
monkeypatch.setattr(PathFinder, "_verify_results", lambda self, state: dict(state.results))
results = pathfinder.route_all()
assert results["netA"].outcome == "completed"
assert results["netB"].outcome == "unroutable"
def test_route_all_restores_best_iteration_snapshot_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
worse_result = RoutingResult(net_id="netA", path=(), reached_target=False)
def fake_run_iterations(self, state, iteration_callback):
_ = iteration_callback
_ = self
state.results = {"netA": best_result}
pathfinder._update_best_iteration(
state,
_IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids={"netA"},
total_dynamic_collisions=0,
),
)
state.results = {"netA": worse_result}
return True
monkeypatch.setattr(PathFinder, "_run_iterations", fake_run_iterations)
monkeypatch.setattr(PathFinder, "_verify_results", lambda self, state: dict(state.results))
results = pathfinder.route_all()
assert results["netA"].outcome == "completed"
def test_capture_conflict_trace_records_restored_best_stage(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
worse_result = RoutingResult(net_id="netA", path=(), reached_target=False)
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = reroute_net_ids
_ = iteration_callback
if iteration == 0:
state.results = {"netA": best_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids={"netA"},
total_dynamic_collisions=0,
)
state.results = {"netA": worse_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids=set(),
total_dynamic_collisions=0,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
pathfinder.route_all()
assert [entry.stage for entry in pathfinder.conflict_trace] == [
"restored_best",
"final",
]
restored_entry = pathfinder.conflict_trace[0]
assert restored_entry.nets[0].outcome == "completed"
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

View file

@ -1,669 +0,0 @@
import math
import pytest
from shapely.geometry import Polygon
from inire import CongestionOptions, NetSpec, RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
from inire.geometry.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext, AStarNode, SearchRunConfig
from inire.router._astar_admission import add_node
from inire.router._astar_moves import (
_distance_to_bounds_in_heading,
_should_cap_straights_to_bounds,
)
from inire.router._router import PathFinder, _RoutingState
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.seeds import StraightSeed
BOUNDS = (0, -50, 150, 150)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([])
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def _build_options(**search_overrides: object) -> RoutingOptions:
return RoutingOptions(search=SearchOptions(**search_overrides))
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
**search_overrides: object,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds),
_build_options(**search_overrides),
)
def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object):
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options, **config_overrides),
)
def _validate_routing_result(
result: RoutingResult,
static_obstacles: list[Polygon],
clearance: float,
expected_start: Port | None = None,
expected_end: Port | None = None,
) -> dict[str, object]:
if not result.path:
return {"is_valid": False, "reason": "No path found"}
connectivity_errors: list[str] = []
if expected_start:
first_port = result.path[0].start_port
dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y)
if dist_to_start > 0.005:
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
if abs(first_port.r - expected_start.r) > 0.1:
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
if expected_end:
last_port = result.path[-1].end_port
dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y)
if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
if abs(last_port.r - expected_end.r) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
report = engine.verify_path_report("validation", result.path)
is_valid = report.is_valid and not connectivity_errors
reasons = []
if report.static_collision_count:
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
if report.dynamic_collision_count:
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
if report.self_collision_count:
reasons.append(f"Found {report.self_collision_count} self-intersections.")
reasons.extend(connectivity_errors)
return {
"is_valid": is_valid,
"reason": " ".join(reasons),
"obstacle_collisions": report.static_collision_count,
"dynamic_collisions": report.dynamic_collision_count,
"self_intersections": report.self_collision_count,
"total_length": report.total_length,
"connectivity_ok": not connectivity_errors,
}
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0)
target = Port(50, 0, 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, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
# Path should be exactly 50um (or slightly more if it did weird things, but here it's straight)
assert abs(validation["total_length"] - 50.0) < 1e-6
def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,))
start = Port(0, 0, 0)
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
target = Port(20, 20, 0)
path = _route(context, start, target)
assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
# Add an obstacle in the middle of a straight path
# Obstacle from x=20 to 40, y=-20 to 20
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000)
start = Port(0, 0, 0)
target = Port(60, 0, 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')}"
# Path should have detoured, so length > 50
assert validation["total_length"] > 50.0
def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0)
target = Port(10.1, 0, 0)
path = _route(context, start, target)
assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True)
assert target.x == 10
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
def test_validate_routing_result_checks_expected_start() -> None:
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = _validate_routing_result(
result,
[],
clearance=2.0,
expected_start=Port(0, 0, 0),
expected_end=Port(110, 0, 0),
)
assert not validation["is_valid"]
assert "Initial port position mismatch" in validation["reason"]
def test_validate_routing_result_uses_exact_component_geometry() -> None:
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0)
result = RoutingResult(net_id="test", path=[bend], reached_target=True)
obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)])
validation = _validate_routing_result(
result,
[obstacle],
clearance=2.0,
expected_start=Port(0, 0, 0),
expected_end=bend.end_port,
)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None:
basic_evaluator = CostEvaluator(
basic_evaluator.collision_engine,
basic_evaluator.danger_map,
bend_penalty=120.0,
sbend_penalty=240.0,
)
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,))
assert context.options.search.bend_radii == (5.0,)
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
def test_distance_to_bounds_in_heading_is_directional() -> None:
bounds = (0, 0, 100, 200)
assert _distance_to_bounds_in_heading(Port(20, 30, 0), bounds) == pytest.approx(80.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 90), bounds) == pytest.approx(170.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 180), bounds) == pytest.approx(20.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 270), bounds) == pytest.approx(30.0)
def test_should_cap_straights_to_bounds_only_for_large_no_warm_runs(basic_evaluator: CostEvaluator) -> None:
large_context = AStarContext(
basic_evaluator,
RoutingProblem(
bounds=(0, 0, 1000, 1000),
nets=tuple(
NetSpec(f"net{i}", Port(0, i * 10, 0), Port(10, i * 10, 0), width=2.0)
for i in range(8)
),
),
RoutingOptions(
congestion=CongestionOptions(warm_start_enabled=False),
),
)
small_context = _build_context(basic_evaluator, bounds=BOUNDS)
assert _should_cap_straights_to_bounds(large_context)
assert not _should_cap_straights_to_bounds(small_context)
def test_pair_local_context_clones_live_static_obstacles() -> None:
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
engine = RoutingWorld(clearance=2.0)
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([obstacle])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
finder = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=BOUNDS,
nets=(
NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
),
),
RoutingOptions(),
)
)
state = _RoutingState(
net_specs={
"pair_a": NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
"pair_b": NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
},
ordered_net_ids=["pair_a", "pair_b"],
results={},
needs_self_collision_check=set(),
start_time=0.0,
timeout_s=1.0,
initial_paths=None,
accumulated_expanded_nodes=[],
best_results={},
best_completed_nets=-1,
best_conflict_edges=10**9,
best_dynamic_collisions=10**9,
last_conflict_signature=(),
last_conflict_edge_count=0,
repeated_conflict_count=0,
pair_local_plateau_count=0,
recent_attempt_work={},
pre_pair_candidate=None,
)
local_context = finder._build_pair_local_context(state, {}, ("pair_a", "pair_b"))
assert finder.context.problem.static_obstacles == ()
assert len(local_context.problem.static_obstacles) == 1
assert len(local_context.cost_evaluator.collision_engine._static_obstacles.geometries) == 1
assert next(iter(local_context.problem.static_obstacles)).equals(obstacle)
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
route_astar(
Port(0, 0, 0),
Port(30, 10, 0),
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(
context.options,
bend_collision_type="clipped_bbox",
return_partial=True,
),
)
assert context.options.search.bend_collision_type == "arc"
def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None:
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2)
start = Port(0, 0, 0)
target = Port(60, 0, 0)
partial_path = _route(context, start, target, return_partial=True)
no_partial_path = _route(context, start, target, return_partial=False)
assert partial_path is not None
assert partial_path
assert partial_path[-1].end_port != target
assert no_partial_path is None
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
context = _build_context(
basic_evaluator,
bounds=BOUNDS,
bend_radii=(10.0,),
sbend_radii=(10.0,),
sbend_offsets=(10.0,),
max_straight_length=150.0,
)
start = Port(0, 0, 0)
target = Port(100, 10, 0)
path = _route(context, start, target)
assert path is not None
assert path[-1].end_port == target
assert sum(1 for component in path if component.move_type == "sbend") == 1
assert not any(
first.move_type == second.move_type == "sbend"
for first, second in zip(path, path[1:], strict=False)
)
@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"])
def test_route_astar_supports_all_visibility_guidance_modes(
basic_evaluator: CostEvaluator,
visibility_guidance: str,
) -> None:
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
context = _build_context(
basic_evaluator,
bounds=BOUNDS,
bend_radii=(10.0,),
sbend_radii=(),
max_straight_length=150.0,
visibility_guidance=visibility_guidance,
)
start = Port(0, 0, 0)
target = Port(80, 50, 0)
path = _route(context, start, target)
assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
def test_tangent_corner_mode_avoids_exact_visibility_graph_builds(basic_evaluator: CostEvaluator) -> 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="tangent_corner",
)
path = _route(context, Port(0, 0, 0), Port(80, 50, 0))
assert path is not None
assert context.metrics.total_visibility_builds == 0
assert context.metrics.total_visibility_corner_pairs_checked == 0
assert context.metrics.total_ray_cast_calls_visibility_build == 0
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
context = AStarContext(
basic_evaluator,
RoutingProblem(bounds=BOUNDS),
_build_options(
min_straight_length=1.0,
max_straight_length=100.0,
),
max_cache_size=2,
)
start = Port(0, 0, 0)
targets = [Port(length, 0, 0) for length in range(10, 70, 10)]
for target in targets:
path = _route(context, start, target)
assert path is not None
assert path[-1].end_port == target
def test_self_collision_prunes_before_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
parent_result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
parent = AStarNode(parent_result.end_port, g_cost=10.0, h_cost=0.0, parent=root, component_result=parent_result)
open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
parent,
parent_result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options, self_collision_check=True),
move_type="straight",
cache_key=("overlap",),
)
assert not open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_closed_set_dominance_prunes_before_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
closed_set = {result.end_port.as_tuple(): context.cost_evaluator.score_component(result, start_port=root.port)}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("dominated",),
)
assert not open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_no_dynamic_paths_skips_congestion_check(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("no-dynamic",),
)
assert open_set
assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 0
def test_guidance_seed_matching_move_reduces_cost_and_advances_seed_index(
basic_evaluator: CostEvaluator,
) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0, seed_index=0)
result = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
open_set: list[AStarNode] = []
unguided_open_set: list[AStarNode] = []
closed_set: dict[tuple[int, int, int], float] = {}
add_node(
root,
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set=closed_set,
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(
context.options,
guidance_seed=(StraightSeed(length=10.0),),
guidance_bonus=5.0,
),
move_type="straight",
cache_key=("guided",),
)
add_node(
AStarNode(Port(0, 0, 0), 0.0, 0.0),
result,
target=Port(20, 0, 0),
net_width=2.0,
net_id="netA",
open_set=unguided_open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="straight",
cache_key=("unguided",),
)
assert open_set
assert unguided_open_set
guided_node = open_set[0]
unguided_node = unguided_open_set[0]
assert guided_node.seed_index == 1
assert guided_node.g_cost < unguided_node.g_cost
assert context.metrics.total_guidance_match_moves == 1
assert context.metrics.total_guidance_match_moves_straight == 1
assert context.metrics.total_guidance_match_moves_bend90 == 0
assert context.metrics.total_guidance_match_moves_sbend == 0
assert context.metrics.total_guidance_bonus_applied == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_straight == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_bend90 == pytest.approx(0.0)
assert context.metrics.total_guidance_bonus_applied_sbend == pytest.approx(0.0)
def test_guidance_seed_bend90_keeps_full_bonus(
basic_evaluator: CostEvaluator,
) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS)
root = AStarNode(Port(0, 0, 0), 0.0, 0.0, seed_index=0)
result = Bend90.generate(Port(0, 0, 0), 10.0, width=2.0, direction="CCW", dilation=1.0)
open_set: list[AStarNode] = []
unguided_open_set: list[AStarNode] = []
add_node(
root,
result,
target=Port(10, 10, 90),
net_width=2.0,
net_id="netA",
open_set=open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(
context.options,
guidance_seed=(result.move_spec,),
guidance_bonus=5.0,
),
move_type="bend90",
cache_key=("guided-bend90",),
)
add_node(
AStarNode(Port(0, 0, 0), 0.0, 0.0),
result,
target=Port(10, 10, 90),
net_width=2.0,
net_id="netA",
open_set=unguided_open_set,
closed_set={},
context=context,
metrics=context.metrics,
congestion_cache={},
congestion_presence_cache={},
congestion_candidate_precheck_cache={},
congestion_net_envelope_cache={},
congestion_grid_net_cache={},
congestion_grid_span_cache={},
config=SearchRunConfig.from_options(context.options),
move_type="bend90",
cache_key=("unguided-bend90",),
)
assert open_set
assert unguided_open_set
guided_node = open_set[0]
unguided_node = unguided_open_set[0]
assert guided_node.seed_index == 1
assert unguided_node.g_cost - guided_node.g_cost == pytest.approx(5.0)
assert context.metrics.total_guidance_match_moves == 1
assert context.metrics.total_guidance_match_moves_straight == 0
assert context.metrics.total_guidance_match_moves_bend90 == 1
assert context.metrics.total_guidance_match_moves_sbend == 0
assert context.metrics.total_guidance_bonus_applied == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_straight == pytest.approx(0.0)
assert context.metrics.total_guidance_bonus_applied_bend90 == pytest.approx(5.0)
assert context.metrics.total_guidance_bonus_applied_sbend == pytest.approx(0.0)

View file

@ -1,132 +0,0 @@
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.components import Straight
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
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():
"""
Check that clearance is correctly calculated:
two paths slightly beyond, exactly at, and slightly violating.
"""
# Clearance = 2.0, Width = 2.0
# Required Centerline-to-Centerline = (2+2)/2 + 2.0 = 4.0
ce = RoutingWorld(clearance=2.0)
# Net 1: Centerline at y=0
p1 = Port(0, 0, 0)
res1 = Straight.generate(p1, 50.0, width=2.0, dilation=1.0)
ce.add_path("net1", res1.collision_geometry, dilated_geometry=res1.dilated_collision_geometry)
# Net 2: Parallel to Net 1
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
p2_ok = Port(0, 5, 0)
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
report_ok = ce.verify_path_report("net2", [res2_ok])
assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions"
# 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK.
p2_exact = Port(0, 4, 0)
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
report_exact = ce.verify_path_report("net2", [res2_exact])
assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions"
# 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL.
p2_fail = Port(0, 3, 0)
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
report_fail = ce.verify_path_report("net2", [res2_fail])
assert not report_fail.is_valid, "Gap 1.999 should be invalid"
assert report_fail.collision_count > 0
def test_verify_all_nets_cases():
"""
Validate that verify_all_nets catches some common cases and doesn't flag reasonable non-failing cases.
"""
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
evaluator = CostEvaluator(collision_engine=engine, danger_map=danger_map)
# Case 1: Parallel paths exactly at clearance (Should be VALID)
netlist_parallel_ok = {
"net1": (Port(0, 50, 0), Port(100, 50, 0)),
"net2": (Port(0, 54, 0), Port(100, 54, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = _build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_parallel_ok,
net_widths=net_widths,
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
).route_all()
assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
assert results["net2"].is_valid
# Case 2: Parallel paths slightly within clearance (Should be INVALID)
netlist_parallel_fail = {
"net3": (Port(0, 20, 0), Port(100, 20, 0)),
"net4": (Port(0, 23, 0), Port(100, 23, 0)),
}
# Reset engine
engine.remove_path("net1")
engine.remove_path("net2")
results_p = _build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_parallel_fail,
net_widths=net_widths,
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
).route_all()
# verify_all_nets should flag both as invalid because they cross-collide
assert not results_p["net3"].is_valid
assert not results_p["net4"].is_valid
# Case 3: Crossing paths (Should be INVALID)
netlist_cross = {
"net5": (Port(0, 75, 0), Port(100, 75, 0)),
"net6": (Port(50, 0, 90), Port(50, 100, 90)),
}
engine.remove_path("net3")
engine.remove_path("net4")
results_c = _build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
netlist=netlist_cross,
net_widths=net_widths,
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
).route_all()
assert not results_c["net5"].is_valid
assert not results_c["net6"].is_valid

View file

@ -1,488 +0,0 @@
from shapely.geometry import box
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult
from inire.geometry.components import Straight
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
from inire.seeds import StraightSeed
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:
engine = RoutingWorld(clearance=2.0)
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0)
direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0)
assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port)
far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0)
assert not engine.check_move_static(far_away, start_port=far_away.start_port)
near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0)
assert engine.check_move_static(near_hit, start_port=near_hit.start_port)
def test_safety_zone() -> None:
engine = RoutingWorld(clearance=0.0)
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0)
start_port = Port(10, 12, 0)
test_move = Straight.generate(start_port, 0.002, width=0.001)
assert not engine.check_move_static(test_move, start_port=start_port)
def test_ray_cast_width_clearance() -> None:
# Clearance = 2.0um, Width = 2.0um.
# Centerline to obstacle edge must be >= W/2 + C = 1.0 + 2.0 = 3.0um.
engine = RoutingWorld(clearance=2.0)
# Obstacle at x=10 to 20
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0)
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
start_ok = Port(6, 50, 90)
reach_ok = engine.ray_cast(start_ok, 90, max_dist=10.0, net_width=2.0)
assert reach_ok >= 10.0
# 2. Parallel move at x=8. Gap = 10 - 8 = 2.0. COLLISION.
start_fail = Port(8, 50, 90)
reach_fail = engine.ray_cast(start_fail, 90, max_dist=10.0, net_width=2.0)
assert reach_fail < 10.0
def test_check_move_static_clearance() -> None:
engine = RoutingWorld(clearance=2.0)
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0)
# Straight move of length 10 at x=8 (Width 2.0)
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
start = Port(8, 0, 90)
res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2
assert engine.check_move_static(res, start_port=start)
# Move at x=7. Gap = 3.0 == minimum. OK.
start_ok = Port(7, 0, 90)
res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0)
assert not engine.check_move_static(res_ok, start_port=start_ok)
# 3. Same exact-boundary case.
start_exact = Port(7, 0, 90)
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
assert not engine.check_move_static(res_exact, start_port=start_exact)
def test_verify_path_report_preserves_long_net_id() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
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
assert engine.metrics.total_verify_dynamic_candidate_nets == 0
def test_verify_path_report_distinguishes_long_net_ids_with_shared_prefix() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
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
assert engine.metrics.total_verify_dynamic_candidate_nets == 1
def test_verify_path_report_uses_net_envelopes_before_dynamic_object_scan() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
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)]
path_far = [Straight.generate(Port(100, 100, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
"netB",
[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],
)
engine.add_path(
"netFar",
[poly for component in path_far for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_far for poly in component.dilated_collision_geometry],
)
report = engine.verify_path_report("netA", path_a)
assert report.dynamic_collision_count == 1
assert engine.metrics.total_verify_dynamic_candidate_nets == 1
assert engine.metrics.total_verify_dynamic_exact_pair_checks == 1
def test_verify_path_details_returns_conflicting_net_ids() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
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(
"netB",
[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],
)
detail = engine.verify_path_details("netA", path_a)
assert detail.report.dynamic_collision_count == 1
assert detail.conflicting_net_ids == ("netB",)
def test_verify_path_details_reports_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [
Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0),
Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0),
]
engine.add_path(
"netB",
[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],
component_indexes=[0] * len(path_b[0].collision_geometry) + [1] * len(path_b[1].collision_geometry),
)
detail = engine.verify_path_details("netA", path_a, capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 1),)
def test_verify_path_details_deduplicates_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
query_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
end_port=Port(22, 0, 0),
length=22.0,
move_type="straight",
move_spec=StraightSeed(22.0),
physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
)
blocker_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(5, 0, 17, 10)],
end_port=Port(17, 0, 0),
length=12.0,
move_type="straight",
move_spec=StraightSeed(12.0),
physical_geometry=[box(5, 0, 17, 10)],
dilated_collision_geometry=[box(5, 0, 17, 10)],
dilated_physical_geometry=[box(5, 0, 17, 10)],
)
engine.add_path(
"netB",
blocker_component.collision_geometry,
dilated_geometry=blocker_component.dilated_collision_geometry,
component_indexes=[0],
)
detail = engine.verify_path_details("netA", [query_component], capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 0),)
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
def test_dynamic_grid_updates_incrementally_on_add_and_remove() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 4, 0), 20.0, width=2.0, dilation=1.0)]
engine.add_path(
"netA",
[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(
"netB",
[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],
)
dynamic_paths = engine._dynamic_paths
assert dynamic_paths.net_to_obj_ids["netA"]
assert dynamic_paths.net_to_obj_ids["netB"]
assert dynamic_paths.grid
assert engine.metrics.total_dynamic_grid_rebuilds == 0
engine.remove_path("netA")
assert "netA" not in dynamic_paths.net_to_obj_ids
assert "netB" in dynamic_paths.net_to_obj_ids
assert engine.metrics.total_dynamic_grid_rebuilds == 0
assert "netA" not in dynamic_paths.grid_net_obj_ids.get((0, -1), {})
assert "netB" in dynamic_paths.grid_net_obj_ids.get((0, 0), {})
def test_dynamic_net_envelopes_update_incrementally_on_add_and_remove() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [Straight.generate(Port(0, 40, 0), 10.0, width=2.0, dilation=1.0)]
engine.add_path(
"netA",
[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(
"netB",
[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],
)
dynamic_paths = engine._dynamic_paths
assert set(dynamic_paths.net_envelopes) == {"netA", "netB"}
assert dynamic_paths.net_envelopes["netA"] == (-1.0, -2.0, 21.0, 2.0)
assert dynamic_paths.net_envelopes["netB"] == (-1.0, 38.0, 11.0, 42.0)
assert engine.metrics.total_dynamic_tree_rebuilds == 0
net_b_envelope_obj_id = dynamic_paths.net_envelope_obj_ids["netB"]
assert list(dynamic_paths.net_envelope_index.intersection((-5.0, 35.0, 15.0, 45.0))) == [net_b_envelope_obj_id]
engine.remove_path("netA")
assert "netA" not in dynamic_paths.net_envelopes
assert "netA" not in dynamic_paths.net_envelope_obj_ids
assert "netB" in dynamic_paths.net_envelopes
assert engine.metrics.total_dynamic_tree_rebuilds == 0
def test_congestion_query_uses_per_polygon_bounds() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
blocker = Straight.generate(Port(40, 4, 90), 2.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
end_port=Port(100, 0, 0),
length=100.0,
move_type="straight",
move_spec=StraightSeed(100.0),
physical_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
dilated_collision_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
dilated_physical_geometry=[box(0, 0, 10, 10), box(90, 0, 100, 10)],
)
assert engine.check_move_congestion(move, "netA") == 0
assert engine.metrics.total_congestion_candidate_nets == 0
assert engine.metrics.total_congestion_candidate_ids == 0
assert engine.metrics.total_congestion_exact_pair_checks == 0
def test_congestion_touching_geometries_do_not_count_as_overlap() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
existing = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
touching = Straight.generate(Port(12, 0, 0), 10.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in existing.collision_geometry],
dilated_geometry=[poly for poly in existing.dilated_collision_geometry],
)
assert engine.check_move_congestion(touching, "netA") == 0
def test_congestion_exact_checks_only_touch_relevant_move_polygons() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
blocker = Straight.generate(Port(0, 0, 0), 10.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
end_port=Port(100, 0, 0),
length=100.0,
move_type="straight",
move_spec=StraightSeed(100.0),
physical_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
dilated_collision_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
dilated_physical_geometry=[box(0, -2, 10, 2), box(90, -2, 100, 2)],
)
assert engine.check_move_congestion(move, "netA") == 1
assert engine.metrics.total_congestion_candidate_nets == 1
assert engine.metrics.total_congestion_candidate_ids == 1
assert engine.metrics.total_congestion_exact_pair_checks == 1
def test_congestion_grid_span_cache_reuses_broad_phase_candidates() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
cache: dict[tuple[str, int, int, int, int], dict[str, tuple[int, ...]]] = {}
blocker = Straight.generate(Port(15, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
move_a = Straight.generate(Port(5, 0, 0), 20.0, width=2.0, dilation=1.0)
move_b = Straight.generate(Port(7, 0, 0), 20.0, width=2.0, dilation=1.0)
assert engine.check_move_congestion(
move_a,
"netA",
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=cache,
) == 1
assert engine.check_move_congestion(
move_b,
"netA",
net_envelope_cache=net_envelope_cache,
grid_net_cache=grid_net_cache,
broad_phase_cache=cache,
) == 1
assert engine.metrics.total_congestion_candidate_nets == 2
assert engine.metrics.total_congestion_net_envelope_cache_misses == 1
assert engine.metrics.total_congestion_net_envelope_cache_hits == 1
assert engine.metrics.total_congestion_grid_net_cache_misses == 1
assert engine.metrics.total_congestion_grid_net_cache_hits == 1
assert engine.metrics.total_congestion_grid_span_cache_misses == 1
assert engine.metrics.total_congestion_grid_span_cache_hits == 1
def test_has_possible_move_congestion_uses_presence_cache_and_skips_empty_spans() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
presence_cache: dict[tuple[str, int, int, int, int], bool] = {}
blocker = Straight.generate(Port(10, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
empty_move = Straight.generate(Port(200, 0, 0), 20.0, width=2.0, dilation=1.0)
assert not engine.has_possible_move_congestion(empty_move, "netA", presence_cache)
assert not engine.has_possible_move_congestion(empty_move, "netA", presence_cache)
assert engine.metrics.total_congestion_presence_cache_misses == 1
assert engine.metrics.total_congestion_presence_cache_hits == 1
occupied_move = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
assert engine.has_possible_move_congestion(occupied_move, "netA")
def test_has_candidate_move_congestion_uses_candidate_precheck_cache() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
candidate_precheck_cache: dict[tuple[str, int, int, int, int], bool] = {}
net_envelope_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
grid_net_cache: dict[tuple[str, int, int, int, int], tuple[str, ...]] = {}
blocker = Straight.generate(Port(10, 0, 0), 20.0, width=2.0, dilation=1.0)
engine.add_path(
"netB",
[poly for poly in blocker.collision_geometry],
dilated_geometry=[poly for poly in blocker.dilated_collision_geometry],
)
empty_move = Straight.generate(Port(200, 0, 0), 20.0, width=2.0, dilation=1.0)
occupied_move = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
assert not engine.has_candidate_move_congestion(
empty_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert not engine.has_candidate_move_congestion(
empty_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert engine.has_candidate_move_congestion(
occupied_move,
"netA",
candidate_precheck_cache,
net_envelope_cache,
grid_net_cache,
)
assert engine.metrics.total_congestion_candidate_precheck_misses >= 2
assert engine.metrics.total_congestion_candidate_precheck_hits >= 1

View file

@ -1,251 +0,0 @@
import pytest
from dataclasses import FrozenInstanceError
from shapely.affinity import rotate as shapely_rotate
from shapely.affinity import scale as shapely_scale
from shapely.affinity import translate as shapely_translate
from shapely.geometry import Polygon
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port
def test_straight_generation() -> None:
start = Port(0, 0, 0)
length = 10.0
width = 2.0
result = Straight.generate(start, length, width)
assert result.end_port.x == 10.0
assert result.end_port.y == 0.0
assert result.end_port.r == 0.0
assert len(result.collision_geometry) == 1
# Bounds of the polygon
minx, miny, maxx, maxy = result.collision_geometry[0].bounds
assert minx == 0.0
assert maxx == 10.0
assert miny == -1.0
assert maxy == 1.0
assert isinstance(result.collision_geometry, tuple)
def test_bend90_generation() -> None:
start = Port(0, 0, 0)
radius = 10.0
width = 2.0
# CW bend
result_cw = Bend90.generate(start, radius, width, direction="CW")
assert result_cw.end_port.x == 10.0
assert result_cw.end_port.y == -10.0
assert result_cw.end_port.r == 270.0
# CCW bend
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
assert result_ccw.end_port.x == 10.0
assert result_ccw.end_port.y == 10.0
assert result_ccw.end_port.r == 90.0
def test_sbend_generation() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == 5.0
assert result.end_port.r == 0.0
assert len(result.collision_geometry) == 2 # Optimization: returns individual arcs
# Verify failure for large offset
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
SBend.generate(start, 25.0, 10.0, 2.0)
def test_sbend_generation_negative_offset_keeps_second_arc_below_centerline() -> None:
start = Port(0, 0, 0)
offset = -5.0
radius = 10.0
width = 2.0
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == -5.0
second_arc_minx, second_arc_miny, second_arc_maxx, second_arc_maxy = result.collision_geometry[1].bounds
assert second_arc_maxy <= width / 2.0 + 1e-6
assert second_arc_miny < -width / 2.0
def test_bend_collision_models() -> None:
start = Port(0, 0, 0)
radius = 10.0
width = 2.0
# 1. BBox model
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
# Arc CCW R=10 from (0,0,0) ends at (10,10,90).
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
minx, miny, maxx, maxy = res_bbox.collision_geometry[0].bounds
assert minx <= 0.0 + 1e-6
assert maxx >= 10.0 - 1e-6
assert miny <= 0.0 + 1e-6
assert maxy >= 10.0 - 1e-6
# 2. Clipped BBox model
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox")
# Conservative 8-point approximation should still be tighter than the full bbox.
assert len(res_clipped.collision_geometry[0].exterior.coords) - 1 == 8
assert res_clipped.collision_geometry[0].area < res_bbox.collision_geometry[0].area
# It should also conservatively contain the true arc.
res_arc = Bend90.generate(start, radius, width, direction="CCW", collision_type="arc")
assert res_clipped.collision_geometry[0].covers(res_arc.collision_geometry[0])
# 3. Legacy clip-margin mode should still be available when explicitly requested.
res_clipped_margin = Bend90.generate(
start,
radius,
width,
direction="CCW",
collision_type="clipped_bbox",
clip_margin=1.0,
)
assert len(res_clipped_margin.collision_geometry[0].exterior.coords) - 1 == 4
assert abs(res_clipped_margin.collision_geometry[0].area - 81.0) < 1e-6
assert res_clipped_margin.collision_geometry[0].area > res_clipped.collision_geometry[0].area
def test_custom_bend_collision_polygon_uses_local_transform() -> None:
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
cases = [
(Port(0, 0, 0), "CCW", (0.0, 10.0), 0.0, False),
(Port(0, 0, 0), "CW", (0.0, -10.0), 0.0, True),
(Port(0, 0, 90), "CCW", (-10.0, 0.0), 90.0, False),
]
for start, direction, center_xy, rotation_deg, mirror_y in cases:
result = Bend90.generate(start, 10.0, 2.0, direction=direction, collision_type=custom_poly)
expected = custom_poly
if mirror_y:
expected = shapely_scale(expected, xfact=1.0, yfact=-1.0, origin=(0.0, 0.0))
if rotation_deg:
expected = shapely_rotate(expected, rotation_deg, origin=(0.0, 0.0), use_radians=False)
expected = shapely_translate(expected, center_xy[0], center_xy[1])
assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6
def test_custom_bend_collision_polygon_is_true_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,
physical_geometry_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_custom_bend_collision_polygon_can_differ_from_physical_geometry() -> None:
custom_proxy = Polygon([(0, -11), (11, -11), (11, 0), (0, 0)])
result = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type=custom_proxy, dilation=1.0)
assert result.collision_geometry[0].symmetric_difference(result.physical_geometry[0]).area > 1e-6
assert result.dilated_collision_geometry[0].symmetric_difference(result.dilated_physical_geometry[0]).area > 1e-6
def test_sbend_collision_models() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
# Geometry should be a list of individual bbox polygons for each arc
assert len(res_bbox.collision_geometry) == 2
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
area_bbox = sum(p.area for p in res_bbox.collision_geometry)
area_arc = sum(p.area for p in res_arc.collision_geometry)
assert area_bbox > area_arc
def test_sbend_continuity() -> None:
# Verify SBend endpoints and continuity math
start = Port(10, 20, 90) # Starting facing up
offset = 4.0
radius = 20.0
width = 1.0
res = SBend.generate(start, offset, radius, width)
# Target orientation should be same as start
assert abs(res.end_port.r - 90.0) < 1e-6
# For a port at 90 deg, +offset is a shift in -x direction
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
# Geometry should be a list of valid polygons
assert len(res.collision_geometry) == 2
for p in res.collision_geometry:
assert p.is_valid
def test_arc_sagitta_precision() -> None:
# Verify that requested sagitta actually controls segment count
start = Port(0, 0, 0)
radius = 100.0 # Large radius to make sagitta significant
width = 2.0
# Coarse: 1um sagitta
res_coarse = Bend90.generate(start, radius, width, direction="CCW", sagitta=1.0)
# Fine: 0.01um (10nm) sagitta
res_fine = Bend90.generate(start, radius, width, direction="CCW", sagitta=0.01)
# Number of segments should be significantly higher for fine
# Exterior points = (segments + 1) * 2
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
assert pts_fine > pts_coarse * 2
def test_component_transform_invariance() -> None:
# Verify that generating at (0,0) then transforming
# is same as generating at the transformed port.
start0 = Port(0, 0, 0)
radius = 10.0
width = 2.0
res0 = Bend90.generate(start0, radius, width, direction="CCW")
# Transform: Translate (10, 10) then Rotate 90
dx, dy = 10.0, 5.0
angle = 90.0
# 1. Transform the generated geometry
p_end_transformed = res0.end_port.translate(dx, dy).rotated(angle)
# 2. Generate at transformed start
start_transformed = start0.translate(dx, dy).rotated(angle)
res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW")
assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6
assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6
assert abs(res_transformed.end_port.r - p_end_transformed.r) < 1e-6
def test_component_result_is_immutable_value_type() -> None:
result = Straight.generate(Port(0, 0, 0), 10.0, 2.0)
with pytest.raises(FrozenInstanceError):
result.length = 42.0

View file

@ -1,98 +0,0 @@
import pytest
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.model import NetSpec
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._router import PathFinder
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
BOUNDS = (0, -40, 100, 40)
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
engine = RoutingWorld(clearance=2.0)
# Wider bounds to allow going around (y from -40 to 40)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([])
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...] = (),
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
RoutingOptions(
search=SearchOptions() if search is None else search,
congestion=CongestionOptions() if congestion is None else congestion,
),
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
) -> PathFinder:
nets = tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
return PathFinder(
_build_context(
evaluator,
bounds=bounds,
nets=nets,
search=search,
congestion=congestion,
),
)
def _route(context: AStarContext, start: Port, target: Port) -> object:
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
context = _build_context(
basic_evaluator,
bounds=BOUNDS,
search=SearchOptions(sbend_offsets=(2.0, 5.0)),
)
# Start at (0,0), target at (50, 2) -> 2um lateral offset
# This matches one of our discretized SBend offsets.
start = Port(0, 0, 0)
target = Port(50, 2, 0)
path = _route(context, start, target)
assert path is not None
# Check if any component in the path is an SBend
found_sbend = False
for res in path:
# Check if the end port orientation is same as start
# and it's not a single straight (which would have y=0)
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.r - start.r) < 0.1:
found_sbend = True
break
assert found_sbend

View file

@ -1,95 +0,0 @@
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
def test_cost_calculation() -> None:
engine = RoutingWorld(clearance=2.0)
# 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50))
danger_map.precompute([])
# Use small penalties for testing
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1, bend_penalty=10.0)
p1 = Port(0, 0, 0)
p2 = Port(10, 10, 0)
h = evaluator.h_manhattan(p1, p2)
# Manhattan distance = 20.
# Jog alignment penalty = 2*bp = 20.
# Side check penalty = 2*bp = 20.
# Total = 1.1 * (20 + 40) = 66.0
assert abs(h - 66.0) < 1e-6
# Orientation difference
p3 = Port(10, 10, 90)
h_90 = evaluator.h_manhattan(p1, p3)
# diff = 90. penalty += 1*bp = 10.
# Side check: 2*bp = 20. (Total penalty = 30)
# Total = 1.1 * (20 + 30) = 55.0
assert abs(h_90 - 55.0) < 1e-6
# Traveling away
p4 = Port(10, 10, 180)
h_away = evaluator.h_manhattan(p1, p4)
# diff = 180. penalty += 2*bp = 20.
# Side check: 2*bp = 20.
# Total = 1.1 * (20 + 40) = 66.0
assert h_away >= h_90
def test_greedy_h_weight_is_mutable() -> None:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 50, 50))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=10.0)
assert evaluator.greedy_h_weight == 1.5
evaluator.greedy_h_weight = 1.2
assert evaluator.greedy_h_weight == 1.2
assert abs(evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) - 72.0) < 1e-6
def test_danger_map_kd_tree_and_cache() -> None:
# Test that KD-Tree based danger map works and uses cache
bounds = (0, 0, 1000, 1000)
dm = DangerMap(bounds, resolution=1.0, safety_threshold=10.0)
# Square obstacle at (100, 100) to (110, 110)
obstacle = Polygon([(100, 100), (110, 100), (110, 110), (100, 110)])
dm.precompute([obstacle])
# 1. High cost near boundary
cost_near = dm.get_cost(100.5, 100.5)
assert cost_near > 1.0
# 2. Zero cost far away
cost_far = dm.get_cost(500, 500)
assert cost_far == 0.0
# 3. Check cache usage (internal detail check)
# We can check if calling it again is fast or just verify it returns same result
cost_near_2 = dm.get_cost(100.5, 100.5)
assert cost_near_2 == cost_near
assert len(dm._cost_cache) == 2
# 4. Out of bounds
assert dm.get_cost(-1, -1) >= 1e12
def test_danger_map_cache_is_instance_local_and_cleared_on_precompute() -> None:
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
dm_a = DangerMap((0, 0, 100, 100))
dm_b = DangerMap((0, 0, 100, 100))
dm_a.precompute([obstacle])
dm_b.precompute([])
dm_a.get_cost(15.0, 15.0)
assert len(dm_a._cost_cache) == 1
assert len(dm_b._cost_cache) == 0
dm_a.precompute([])
assert len(dm_a._cost_cache) == 0

View file

@ -1,123 +0,0 @@
from __future__ import annotations
import os
import statistics
from typing import TYPE_CHECKING
import pytest
from inire.tests.example_scenarios import (
SCENARIOS,
ScenarioOutcome,
snapshot_example_07_no_warm_start,
snapshot_example_07_no_warm_start_seed43,
trace_example_07_no_warm_start_seed43,
)
if TYPE_CHECKING:
from collections.abc import Callable
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
PERFORMANCE_REPEATS = 3
REGRESSION_FACTOR = 1.5
NO_WARM_START_REGRESSION_SECONDS = 15.0
NO_WARM_START_SEED43_REGRESSION_SECONDS = 20.0
# Baselines are measured from clean 6a28dcf-style runs without plotting.
BASELINE_SECONDS = {
"example_01_simple_route": 0.0035,
"example_02_congestion_resolution": 0.2666,
"example_03_locked_paths": 0.2304,
"example_04_sbends_and_radii": 1.8734,
"example_05_orientation_stress": 0.5630,
"example_06_bend_collision_models": 5.2382,
"example_07_large_scale_routing": 1.2081,
"example_08_custom_bend_geometry": 0.9848,
"example_09_unroutable_best_effort": 0.0056,
}
EXPECTED_OUTCOMES = {
"example_01_simple_route": {"total_results": 1, "valid_results": 1, "reached_targets": 1},
"example_02_congestion_resolution": {"total_results": 3, "valid_results": 3, "reached_targets": 3},
"example_03_locked_paths": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_04_sbends_and_radii": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_05_orientation_stress": {"total_results": 3, "valid_results": 3, "reached_targets": 3},
"example_06_bend_collision_models": {"total_results": 3, "valid_results": 3, "reached_targets": 3},
"example_07_large_scale_routing": {"total_results": 10, "valid_results": 10, "reached_targets": 10},
"example_08_custom_bend_geometry": {"total_results": 2, "valid_results": 2, "reached_targets": 2},
"example_09_unroutable_best_effort": {"total_results": 1, "valid_results": 0, "reached_targets": 0},
}
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
_, total_results, valid_results, reached_targets = outcome
expected = EXPECTED_OUTCOMES[name]
assert total_results == expected["total_results"]
assert valid_results == expected["valid_results"]
assert reached_targets == expected["reached_targets"]
@pytest.mark.performance
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS])
def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None:
name, run = scenario
timings = []
for _ in range(PERFORMANCE_REPEATS):
outcome = run()
_assert_expected_outcome(name, outcome)
timings.append(outcome[0])
median_runtime = statistics.median(timings)
assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, (
f"{name} median runtime {median_runtime:.4f}s exceeded "
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
f"from timings {timings!r}"
)
@pytest.mark.performance
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
def test_example_07_no_warm_start_runtime_regression() -> None:
snapshot = snapshot_example_07_no_warm_start()
assert snapshot.total_results == 10
assert snapshot.valid_results == 10
assert snapshot.reached_targets == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.duration_s <= NO_WARM_START_REGRESSION_SECONDS, (
"example_07_large_scale_routing_no_warm_start runtime "
f"{snapshot.duration_s:.4f}s exceeded guardrail "
f"{NO_WARM_START_REGRESSION_SECONDS:.1f}s"
)
@pytest.mark.performance
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
def test_example_07_no_warm_start_seed43_runtime_regression() -> None:
snapshot = snapshot_example_07_no_warm_start_seed43()
run = trace_example_07_no_warm_start_seed43()
assert snapshot.total_results == 10
assert snapshot.valid_results == 10
assert snapshot.reached_targets == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 1
assert snapshot.metrics.pair_local_search_accepts >= 1
assert snapshot.duration_s <= NO_WARM_START_SEED43_REGRESSION_SECONDS, (
"example_07_large_scale_routing_no_warm_start_seed43 runtime "
f"{snapshot.duration_s:.4f}s exceeded guardrail "
f"{NO_WARM_START_SEED43_REGRESSION_SECONDS:.1f}s"
)
assert run.iteration_trace
assert len(run.results_by_net) == 10
assert sum(result.is_valid for result in run.results_by_net.values()) == 10
assert sum(result.reached_target for result in run.results_by_net.values()) == 10
assert run.metrics.warm_start_paths_built == 0
assert run.metrics.warm_start_paths_used == 0
assert run.metrics.pair_local_search_pairs_considered >= 1
assert run.metrics.pair_local_search_accepts >= 1

View file

@ -1,303 +0,0 @@
import pytest
from shapely.geometry import Polygon, box
from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
Port,
RoutingOptions,
RoutingProblem,
SearchOptions,
route,
)
from inire.router._stack import build_routing_stack
from inire.seeds import Bend90Seed, PathSeed, StraightSeed
from inire.tests.example_scenarios import (
SCENARIOS,
_build_evaluator,
_build_pathfinder,
_net_specs,
AStarMetrics,
snapshot_example_05,
snapshot_example_07_no_warm_start,
trace_example_07_no_warm_start,
)
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, 2, 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_05_avoids_dynamic_tree_rebuilds() -> None:
snapshot = snapshot_example_05()
assert snapshot.valid_results == 3
assert snapshot.metrics.dynamic_tree_rebuilds == 0
def test_example_07_no_warm_start_canary_improves_validity() -> None:
snapshot = snapshot_example_07_no_warm_start()
assert snapshot.total_results == 10
assert snapshot.reached_targets == 10
assert snapshot.valid_results == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 1
assert snapshot.metrics.pair_local_search_accepts >= 1
assert snapshot.metrics.pair_local_search_nodes_expanded <= 128
assert snapshot.metrics.nodes_expanded <= 2500
assert snapshot.metrics.congestion_check_calls <= 6000
def test_example_07_no_warm_start_trace_finishes_without_conflict_edges() -> None:
run = trace_example_07_no_warm_start()
assert len(run.results_by_net) == 10
assert sum(result.is_valid for result in run.results_by_net.values()) == 10
assert sum(result.reached_target for result in run.results_by_net.values()) == 10
assert run.metrics.pair_local_search_pairs_considered >= 1
assert run.metrics.pair_local_search_accepts >= 1
assert run.pre_pair_frontier_trace is not None
final_entry = run.conflict_trace[-1]
assert final_entry.stage == "final"
assert len(final_entry.completed_net_ids) == 10
assert final_entry.conflict_edges == ()
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_06_custom_geometry_can_be_true_physical_geometry() -> 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)]),
)
custom_poly = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
result = route(
RoutingProblem(
bounds=bounds,
nets=(NetSpec("custom_geometry", Port(10, 20, 0), Port(90, 40, 90), width=2.0),),
static_obstacles=obstacles,
),
options=RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
bend_physical_geometry=custom_poly,
bend_proxy_geometry=custom_poly,
),
objective=ObjectiveWeights(bend_penalty=50.0, sbend_penalty=150.0),
congestion=CongestionOptions(use_tiered_strategy=False),
),
).results_by_net["custom_geometry"]
assert result.is_valid
bends = [component for component in result.path if component.move_type == "bend90"]
assert bends
assert all(
component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area < 1e-6
for component in bends
)
def test_custom_proxy_without_physical_geometry_warns_and_keeps_arc_geometry() -> None:
custom_proxy = Polygon([(0, -11), (11, -11), (11, 0), (0, 0)])
with pytest.warns(UserWarning, match="Custom bend proxy provided without bend_physical_geometry"):
search = SearchOptions(
bend_radii=(10.0,),
sbend_radii=(),
bend_proxy_geometry=custom_proxy,
)
problem = RoutingProblem(
bounds=(0, 0, 150, 150),
nets=(NetSpec("proxy_only", Port(20, 20, 0), Port(100, 100, 90), width=2.0),),
)
result = route(
problem,
options=RoutingOptions(
search=search,
congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False),
),
).results_by_net["proxy_only"]
bends = [component for component in result.path if component.move_type == "bend90"]
assert bends
assert all(
component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area > 1e-6
for component in bends
)
def test_example_08_custom_geometry_runs_in_separate_sessions() -> None:
bounds = (0, 0, 150, 150)
netlist = {"standard_arc": (Port(20, 20, 0), Port(100, 100, 90))}
widths = {"standard_arc": 2.0}
custom_physical = Polygon([(0, -11), (11, -11), (11, 0), (9, 0), (9, -9), (0, -9)])
custom_proxy = box(0, -11, 11, 0)
standard = _build_pathfinder(
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs(netlist, widths),
bend_radii=[10.0],
sbend_radii=[],
max_iterations=1,
use_tiered_strategy=False,
metrics=AStarMetrics(),
).route_all()
custom = _build_pathfinder(
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}),
bend_radii=[10.0],
bend_physical_geometry=custom_physical,
bend_proxy_geometry=custom_proxy,
sbend_radii=[],
max_iterations=1,
use_tiered_strategy=False,
metrics=AStarMetrics(),
).route_all()
assert standard["standard_arc"].is_valid
assert standard["standard_arc"].reached_target
assert custom["custom_geometry_and_proxy"].is_valid
assert custom["custom_geometry_and_proxy"].reached_target
custom_bends = [component for component in custom["custom_geometry_and_proxy"].path if component.move_type == "bend90"]
assert custom_bends
assert all(
component.collision_geometry[0].symmetric_difference(component.physical_geometry[0]).area > 1e-6
for component in custom_bends
)

View file

@ -1,77 +0,0 @@
from inire import CongestionOptions, RoutingOptions, RoutingProblem
from inire.geometry.primitives import Port
from inire.geometry.collision import RoutingWorld
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
def test_failed_net_visibility() -> None:
"""
Verifies that nets that fail to reach their target (return partial paths)
ARE added to the collision engine, making them visible to other nets
for negotiated congestion.
"""
# 1. Setup
engine = RoutingWorld(clearance=2.0)
# Create a simple danger map (bounds 0-100)
# We don't strictly need obstacles in it for this test.
dm = DangerMap(bounds=(0, 0, 100, 100))
evaluator = CostEvaluator(engine, dm)
# 2. Configure Router with low limit to FORCE failure
# node_limit=10 is extremely low, likely allowing only a few moves.
# Start (0,0) -> Target (100,0) is 100um away.
# Let's add a static obstacle that blocks the direct path.
from shapely.geometry import box
obstacle = box(40, -10, 60, 10) # Wall at x=50
engine.add_static_obstacle(obstacle)
# With obstacle, direct jump fails. A* must search around.
# Limit=10 should be enough to fail to find a path around.
netlist = {
"net1": (Port(0, 0, 0), Port(100, 0, 0))
}
net_widths = {"net1": 1.0}
pf = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=(0, 0, 100, 100),
nets=tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
),
),
RoutingOptions(
search=RoutingOptions().search.__class__(node_limit=10),
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
),
),
)
# 4. Route
print("\nStarting Route...")
results = pf.route_all()
res = results["net1"]
print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}")
# 5. Verify Failure Condition
# We expect reached_target to be False because of node_limit + obstacle
assert not res.reached_target, "Test setup failed: Net reached target despite low limit!"
assert len(res.path) > 0, "Test setup failed: No partial path returned!"
# 6. Verify Visibility
# Check if net1 is in the collision engine
found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()}
print(f"Nets found in engine: {found_nets}")
# The FIX Expectation: "net1" SHOULD be present
assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!"

View file

@ -1,93 +0,0 @@
from typing import Any
import pytest
from hypothesis import given, settings, strategies as st
from shapely.geometry import Point, Polygon
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem, SearchOptions
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
@st.composite
def random_obstacle(draw: Any) -> Polygon:
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
w = draw(st.floats(min_value=1, max_value=5))
h = draw(st.floats(min_value=1, max_value=5))
return Polygon([(x, y), (x + w, y), (x + w, y + h), (x, y + h)])
@st.composite
def random_port(draw: Any) -> Port:
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation)
def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance: float, net_width: float) -> bool:
point = Point(float(port.x), float(port.y))
required_gap = (net_width / 2.0) + clearance
return all(point.distance(obstacle) >= required_gap for obstacle in obstacles)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
**search_overrides: object,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds),
RoutingOptions(search=SearchOptions(**search_overrides)),
)
def _route(context: AStarContext, start: Port, target: Port):
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
@settings(max_examples=3, deadline=None)
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
net_width = 2.0
clearance = 2.0
engine = RoutingWorld(clearance=2.0)
for obs in obstacles:
engine.add_static_obstacle(obs)
danger_map = DangerMap(bounds=(0, 0, 30, 30))
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map)
context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000)
# Check if start/target are inside obstacles (safety zone check)
# The router should handle this gracefully (either route or return None)
try:
path = _route(context, start, target)
# This is a crash-smoke test rather than a full correctness proof.
# If a full path is returned, it should at least terminate at the requested target.
endpoints_are_clear = (
_port_has_required_clearance(start, obstacles, clearance, net_width)
and _port_has_required_clearance(target, obstacles, clearance, net_width)
)
if path and endpoints_are_clear:
assert path[-1].end_port == target
except Exception as e:
# Unexpected exceptions are failures
pytest.fail(f"Router crashed with {type(e).__name__}: {e}")

View file

@ -1,231 +0,0 @@
from shapely.geometry import box
from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
SearchOptions,
)
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90, Straight
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
DEFAULT_BOUNDS = (0, 0, 100, 100)
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"}
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
def _request_nets(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
) -> tuple[NetSpec, ...]:
return tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
def _build_options(**overrides: object) -> RoutingOptions:
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
return RoutingOptions(
search=SearchOptions(**search_overrides),
congestion=CongestionOptions(**congestion_overrides),
refinement=RefinementOptions(**refinement_overrides),
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
objective=ObjectiveWeights(**objective_overrides),
)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...] = (),
**request_overrides: object,
) -> AStarContext:
problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS}
option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS}
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets, **problem_overrides),
_build_options(**option_overrides),
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
metrics=None,
**request_overrides: object,
) -> PathFinder:
return PathFinder(
_build_context(
evaluator,
bounds=bounds,
nets=_request_nets(netlist, net_widths),
**request_overrides,
),
metrics=metrics,
)
def _build_manual_path(start: Port, width: float, clearance: float, steps: list[tuple[str, float | str]]) -> list:
path = []
curr = start
dilation = clearance / 2.0
for kind, value in steps:
if kind == "B":
comp = Bend90.generate(curr, 5.0, width, value, dilation=dilation)
else:
comp = Straight.generate(curr, value, width, dilation=dilation)
path.append(comp)
curr = comp.end_port
return path
def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None:
netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))}
widths = {"net": 2.0}
def build_router() -> tuple[RoutingWorld, AStarContext, PathFinder]:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 60, 60))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
context = _build_context(
evaluator,
bounds=(-20, -20, 60, 60),
nets=_request_nets(netlist, widths),
bend_radii=[10.0],
max_straight_length=50.0,
node_limit=50,
warm_start_enabled=False,
max_iterations=1,
enabled=False,
)
return engine, context, PathFinder(context)
engine_auto, _context_auto, pf_auto = build_router()
assert pf_auto.route_all()["net"].is_valid
engine_auto.add_static_obstacle(box(4, 4, 8, 12))
auto_result = pf_auto.route_all()["net"]
engine_manual, context_manual, pf_manual = build_router()
assert pf_manual.route_all()["net"].is_valid
engine_manual.add_static_obstacle(box(4, 4, 8, 12))
context_manual.clear_static_caches()
manual_result = pf_manual.route_all()["net"]
assert auto_result.reached_target == manual_result.reached_target
assert auto_result.collisions == manual_result.collisions
assert auto_result.outcome == manual_result.outcome
assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [
(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path
]
def test_refine_path_handles_same_orientation_lateral_offset() -> None:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
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_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
pf = _build_pathfinder(
evaluator,
netlist={"net": (Port(0, 0, 0), Port(65, 30, 90))},
net_widths={"net": 2.0},
bounds=(-20, -20, 120, 120),
bend_radii=[5.0, 10.0],
enabled=True,
)
start = Port(0, 0, 0)
width = 2.0
path = _build_manual_path(
start,
width,
engine.clearance,
[
("B", "CCW"),
("S", 10.0),
("B", "CW"),
("S", 20.0),
("B", "CW"),
("S", 10.0),
("B", "CCW"),
("S", 10.0),
("B", "CCW"),
("S", 5.0),
("B", "CW"),
("B", "CCW"),
("S", 10.0),
],
)
target = path[-1].end_port
refined = pf.refiner.refine_path("net", start, width, path)
assert target == Port(65, 30, 90)
assert sum(1 for comp in path if comp.move_type == "bend90") == 7
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
assert refined[-1].end_port == target
assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)

View file

@ -1,357 +0,0 @@
import json
import subprocess
import sys
from pathlib import Path
from inire.tests.example_scenarios import snapshot_example_01
def test_snapshot_example_01_exposes_metrics() -> None:
snapshot = snapshot_example_01()
assert snapshot.name == "example_01_simple_route"
assert snapshot.total_results == 1
assert snapshot.valid_results == 1
assert snapshot.reached_targets == 1
assert snapshot.metrics.route_iterations >= 1
assert snapshot.metrics.nets_routed >= 1
assert snapshot.metrics.nodes_expanded > 0
assert snapshot.metrics.score_component_calls >= 0
assert snapshot.metrics.danger_map_lookup_calls >= 0
assert snapshot.metrics.move_cache_abs_misses >= 0
assert snapshot.metrics.guidance_match_moves >= 0
assert snapshot.metrics.guidance_match_moves_straight >= 0
assert snapshot.metrics.guidance_match_moves_bend90 >= 0
assert snapshot.metrics.guidance_match_moves_sbend >= 0
assert snapshot.metrics.guidance_bonus_applied >= 0.0
assert snapshot.metrics.guidance_bonus_applied_straight >= 0.0
assert snapshot.metrics.guidance_bonus_applied_bend90 >= 0.0
assert snapshot.metrics.guidance_bonus_applied_sbend >= 0.0
assert snapshot.metrics.ray_cast_calls >= 0
assert snapshot.metrics.ray_cast_calls_expand_forward >= 0
assert snapshot.metrics.dynamic_tree_rebuilds >= 0
assert snapshot.metrics.visibility_corner_index_builds >= 0
assert snapshot.metrics.visibility_builds >= 0
assert snapshot.metrics.congestion_grid_span_cache_hits >= 0
assert snapshot.metrics.congestion_grid_span_cache_misses >= 0
assert snapshot.metrics.congestion_candidate_nets >= 0
assert snapshot.metrics.congestion_net_envelope_cache_hits >= 0
assert snapshot.metrics.congestion_net_envelope_cache_misses >= 0
assert snapshot.metrics.congestion_grid_net_cache_hits >= 0
assert snapshot.metrics.congestion_grid_net_cache_misses >= 0
assert snapshot.metrics.congestion_lazy_resolutions >= 0
assert snapshot.metrics.congestion_lazy_requeues >= 0
assert snapshot.metrics.congestion_candidate_ids >= 0
assert snapshot.metrics.verify_dynamic_candidate_nets >= 0
assert snapshot.metrics.refinement_candidates_verified >= 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 0
assert snapshot.metrics.pair_local_search_attempts >= 0
assert snapshot.metrics.pair_local_search_accepts >= 0
assert snapshot.metrics.pair_local_search_nodes_expanded >= 0
assert snapshot.metrics.late_phase_capped_nets >= 0
assert snapshot.metrics.late_phase_capped_fallbacks >= 0
def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_performance_baseline.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--scenario",
"example_01_simple_route",
],
check=True,
)
payload = json.loads((tmp_path / "performance_baseline.json").read_text())
assert payload["generated_on"]
assert payload["generator"] == "scripts/record_performance_baseline.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_01_simple_route"]
assert (tmp_path / "performance.md").exists()
def test_diff_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
output_path = tmp_path / "diff.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_01_simple_route",
"--output",
str(output_path),
],
check=True,
)
report = output_path.read_text()
assert "Performance Baseline Diff" in report
assert "example_01_simple_route" in report
def test_diff_performance_baseline_script_can_append_measurement_log(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
log_path = tmp_path / "optimization.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_01_simple_route",
"--metric",
"duration_s",
"--metric",
"valid_results",
"--metric",
"nodes_expanded",
"--metric",
"visibility_corner_index_builds",
"--label",
"Step 0 - Baseline",
"--notes",
"Tooling smoke test.",
"--log",
str(log_path),
],
check=True,
)
report = log_path.read_text()
assert "Step 0 - Baseline" in report
assert "Tooling smoke test." in report
assert "| example_01_simple_route | duration_s |" in report
assert "| example_01_simple_route | valid_results |" in report
assert "| example_01_simple_route | visibility_corner_index_builds |" in report
def test_diff_performance_baseline_script_renders_current_metrics_for_added_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
record_script = repo_root / "scripts" / "record_performance_baseline.py"
diff_script = repo_root / "scripts" / "diff_performance_baseline.py"
baseline_dir = tmp_path / "baseline"
baseline_dir.mkdir()
output_path = tmp_path / "diff_added.md"
subprocess.run(
[
sys.executable,
str(record_script),
"--output-dir",
str(baseline_dir),
"--scenario",
"example_01_simple_route",
],
check=True,
)
subprocess.run(
[
sys.executable,
str(diff_script),
"--baseline",
str(baseline_dir / "performance_baseline.json"),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
"--metric",
"duration_s",
"--metric",
"nodes_expanded",
"--output",
str(output_path),
],
check=True,
)
report = output_path.read_text()
assert "| example_07_large_scale_routing_no_warm_start | duration_s | - |" in report
assert "| example_07_large_scale_routing_no_warm_start | nodes_expanded | - |" in report
def test_record_conflict_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_conflict_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--scenario",
"example_05_orientation_stress",
],
check=True,
)
payload = json.loads((tmp_path / "conflict_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_conflict_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"]
assert (tmp_path / "conflict_trace.md").exists()
def test_record_conflict_trace_script_supports_performance_only_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_conflict_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
],
check=True,
)
payload = json.loads((tmp_path / "conflict_trace.json").read_text())
assert [entry["name"] for entry in payload["scenarios"]] == ["example_07_large_scale_routing_no_warm_start"]
def test_record_frontier_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_frontier_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--scenario",
"example_05_orientation_stress",
],
check=True,
)
payload = json.loads((tmp_path / "frontier_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_frontier_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"]
assert (tmp_path / "frontier_trace.md").exists()
def test_record_iteration_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_iteration_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
"--output-dir",
str(tmp_path),
],
check=True,
)
payload = json.loads((tmp_path / "iteration_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_iteration_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_07_large_scale_routing_no_warm_start"]
assert (tmp_path / "iteration_trace.md").exists()
def test_record_pre_pair_frontier_trace_script_writes_selected_scenario(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "record_pre_pair_frontier_trace.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--include-performance-only",
"--scenario",
"example_07_large_scale_routing_no_warm_start",
"--output-dir",
str(tmp_path),
],
check=True,
)
payload = json.loads((tmp_path / "pre_pair_frontier_trace.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/record_pre_pair_frontier_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_07_large_scale_routing_no_warm_start"]
assert payload["scenarios"][0]["pre_pair_frontier_trace"] is not None
assert (tmp_path / "pre_pair_frontier_trace.md").exists()
def test_characterize_pair_local_search_script_writes_outputs(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "characterize_pair_local_search.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--num-nets",
"6",
"--seeds",
"41",
"--repeats",
"1",
],
check=True,
)
payload = json.loads((tmp_path / "pair_local_characterization.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/characterize_pair_local_search.py"
assert payload["grid"]["num_nets"] == [6]
assert payload["grid"]["seeds"] == [41]
assert payload["grid"]["repeats"] == 1
assert len(payload["cases"]) == 1
assert (tmp_path / "pair_local_characterization.md").exists()

View file

@ -1,59 +0,0 @@
from dataclasses import FrozenInstanceError
from typing import Any
from hypothesis import given, strategies as st
import pytest
from inire.geometry.primitives import Port
@st.composite
def port_strategy(draw: Any) -> Port:
x = draw(st.floats(min_value=-1e6, max_value=1e6))
y = draw(st.floats(min_value=-1e6, max_value=1e6))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation)
def test_port_snapping() -> None:
p = Port(0.123456, 0.654321, 90)
assert p.x == 0
assert p.y == 1
@given(p=port_strategy())
def test_port_transform_invariants(p: Port) -> None:
# Rotating 90 degrees 4 times should return to same orientation
p_rot = p
for _ in range(4):
p_rot = p_rot.rotated(90)
assert abs(p_rot.x - p.x) < 1e-6
assert abs(p_rot.y - p.y) < 1e-6
assert (p_rot.r % 360) == (p.r % 360)
@given(
p=port_strategy(),
dx=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:
p_trans = p.translate(dx, dy)
assert isinstance(p_trans.x, int)
assert isinstance(p_trans.y, int)
def test_orientation_normalization() -> None:
p = Port(0, 0, 360)
assert p.r == 0
p2 = Port(0, 0, -90)
assert p2.r == 270
def test_port_is_immutable_value_type() -> None:
p = Port(1, 2, 90)
with pytest.raises(FrozenInstanceError):
p.x = 3

View file

@ -1,97 +0,0 @@
from inire import RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90
from inire.geometry.primitives import Port
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
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:
start = Port(0, 0, 0)
# R=10, 90 deg bend.
# High tolerance (0.5um) -> few segments
res_coarse = Bend90.generate(start, radius=10.0, width=2.0, direction="CCW", sagitta=0.5)
# Low tolerance (1nm) -> many segments
res_fine = Bend90.generate(start, radius=10.0, width=2.0, direction="CCW", sagitta=0.001)
# Check number of points in the polygon exterior
# (num_segments + 1) * 2 points usually
pts_coarse = len(res_coarse.collision_geometry[0].exterior.coords)
pts_fine = len(res_fine.collision_geometry[0].exterior.coords)
assert pts_fine > pts_coarse
def test_locked_routes() -> None:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 100, 50))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
# 1. Route Net A
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
results_a = _build_pathfinder(
evaluator,
bounds=(0, -50, 100, 50),
netlist=netlist_a,
net_widths={"netA": 2.0},
search=SearchOptions(bend_radii=(5.0, 10.0)),
).route_all()
assert results_a["netA"].is_valid
# 2. Treat Net A as locked geometry in the next run.
for polygon in results_a["netA"].locked_geometry:
engine.add_static_obstacle(polygon)
# 3. Route Net B through the same space. It should detour or fail.
# We'll place Net B's start/target such that it MUST cross Net A's physical path.
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
# Route Net B
results_b = _build_pathfinder(
evaluator,
bounds=(0, -50, 100, 50),
netlist=netlist_b,
net_widths={"netB": 2.0},
search=SearchOptions(bend_radii=(5.0, 10.0)),
).route_all()
# 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).
# Since netA is static, netB will see it as a HARD collision if it tries to cross.
# Our A* will find a detour around the static obstacle.
assert results_b["netB"].is_valid
# Verify geometry doesn't intersect locked netA (physical check)
poly_a = [p.physical_geometry[0] for p in results_a["netA"].path]
poly_b = [p.physical_geometry[0] for p in results_b["netB"].path]
for pa in poly_a:
for pb in poly_b:
assert not pa.intersects(pb)

View file

@ -1,301 +0,0 @@
from __future__ import annotations
from shapely.geometry import Polygon
from inire import (
Bend90Seed,
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
PathSeed,
Port,
RefinementOptions,
RoutingOptions,
RoutingProblem,
RoutingResult,
SearchOptions,
StraightSeed,
route,
)
DEFAULT_BOUNDS = (0, 0, 100, 100)
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"}
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
def _request_nets(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
) -> tuple[NetSpec, ...]:
return tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
def _build_options(**overrides: object) -> RoutingOptions:
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
return RoutingOptions(
search=SearchOptions(**search_overrides),
congestion=CongestionOptions(**congestion_overrides),
refinement=RefinementOptions(**refinement_overrides),
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
objective=ObjectiveWeights(**objective_overrides),
)
def _route_problem(
*,
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
static_obstacles: tuple[Polygon, ...] = (),
iteration_callback=None,
**overrides: object,
):
problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS}
option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS}
problem = RoutingProblem(
bounds=bounds,
nets=_request_nets(netlist, net_widths),
static_obstacles=static_obstacles,
**problem_overrides,
)
return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback)
def _bend_count(result: RoutingResult) -> int:
return sum(1 for component in result.path if component.move_type == "bend90")
def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed:
segments = []
for kind, value in steps:
if kind == "B":
segments.append(Bend90Seed(radius=5.0, direction=value))
else:
segments.append(StraightSeed(length=value))
return PathSeed(tuple(segments))
def test_route_parallel_nets_are_valid() -> None:
run = _route_problem(
netlist={
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
},
net_widths={"net1": 2.0, "net2": 2.0},
)
assert len(run.results_by_net) == 2
assert run.results_by_net["net1"].is_valid
assert run.results_by_net["net2"].is_valid
assert run.results_by_net["net1"].collisions == 0
assert run.results_by_net["net2"].collisions == 0
def test_route_reports_crossing_nets_without_congestion_resolution() -> None:
run = _route_problem(
netlist={
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
},
net_widths={"net1": 2.0, "net2": 2.0},
max_iterations=1,
base_penalty=1.0,
warm_start_enabled=False,
)
assert not run.results_by_net["net1"].is_valid
assert not run.results_by_net["net2"].is_valid
assert run.results_by_net["net1"].collisions > 0
assert run.results_by_net["net2"].collisions > 0
def test_route_callback_respects_requested_net_order() -> None:
callback_orders: list[list[str]] = []
_route_problem(
netlist={
"short": (Port(0, 0, 0), Port(10, 0, 0)),
"long": (Port(0, 0, 0), Port(40, 10, 0)),
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
},
net_widths={"short": 2.0, "long": 2.0, "mid": 2.0},
max_iterations=1,
warm_start_enabled=False,
net_order="longest",
enabled=False,
iteration_callback=lambda iteration, results: callback_orders.append(list(results)),
)
assert callback_orders == [["long", "mid", "short"]]
def test_route_callback_receives_iteration_results() -> None:
callback_results: list[dict[str, RoutingResult]] = []
run = _route_problem(
netlist={
"net1": (Port(0, 0, 0), Port(10, 0, 0)),
"net2": (Port(0, 10, 0), Port(10, 10, 0)),
},
net_widths={"net1": 2.0, "net2": 2.0},
iteration_callback=lambda iteration, results: callback_results.append(dict(results)),
)
assert len(callback_results) == 1
assert set(callback_results[0]) == {"net1", "net2"}
assert callback_results[0]["net1"].is_valid
assert callback_results[0]["net2"].is_valid
assert run.results_by_net["net1"].reached_target
assert run.results_by_net["net2"].reached_target
def test_route_uses_complete_initial_paths_without_rerouting() -> None:
initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")])
run = _route_problem(
netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))},
net_widths={"net": 2.0},
bend_radii=[5.0],
max_iterations=1,
warm_start_enabled=False,
initial_paths={"net": initial_seed},
enabled=False,
)
result = run.results_by_net["net"]
assert result.is_valid
assert result.reached_target
assert result.as_seed() == initial_seed
def test_route_retries_partial_initial_paths_across_iterations() -> None:
iterations: list[int] = []
partial_seed = PathSeed((StraightSeed(length=5.0),))
run = _route_problem(
netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))},
net_widths={"net": 2.0},
max_iterations=2,
warm_start_enabled=False,
capture_expanded=True,
initial_paths={"net": partial_seed},
enabled=False,
iteration_callback=lambda iteration, results: iterations.append(iteration),
)
result = run.results_by_net["net"]
assert iterations == [0, 1]
assert result.is_valid
assert result.reached_target
assert result.outcome == "completed"
assert result.as_seed() != partial_seed
assert run.expanded_nodes
def test_route_negotiated_congestion_resolution() -> None:
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)])
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])
run = _route_problem(
bounds=(0, -40, 100, 40),
netlist={
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
},
net_widths={"net1": 2.0, "net2": 2.0},
static_obstacles=(obs_top, obs_bottom),
bend_radii=(5.0, 10.0),
max_iterations=10,
base_penalty=1000.0,
)
assert run.results_by_net["net1"].reached_target
assert run.results_by_net["net2"].reached_target
assert run.results_by_net["net1"].is_valid
assert run.results_by_net["net2"].is_valid
def test_route_refinement_reduces_locked_detour_bends() -> None:
route_a = _route_problem(
bounds=(0, -50, 100, 50),
netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))},
net_widths={"netA": 2.0},
bend_radii=[10.0],
enabled=False,
)
locked_geometry = route_a.results_by_net["netA"].locked_geometry
base_run = _route_problem(
bounds=(0, -50, 100, 50),
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
net_widths={"netB": 2.0},
static_obstacles=locked_geometry,
bend_radii=[10.0],
enabled=False,
)
refined_run = _route_problem(
bounds=(0, -50, 100, 50),
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
net_widths={"netB": 2.0},
static_obstacles=locked_geometry,
bend_radii=[10.0],
enabled=True,
)
base_result = base_run.results_by_net["netB"]
refined_result = refined_run.results_by_net["netB"]
assert base_result.is_valid
assert refined_result.is_valid
assert _bend_count(refined_result) < _bend_count(base_result)
def test_route_refinement_simplifies_triple_crossing_detours() -> None:
base_run = _route_problem(
bounds=(0, 0, 100, 100),
netlist={
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
},
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
bend_radii=[10.0],
sbend_radii=[10.0],
base_penalty=1000.0,
enabled=False,
greedy_h_weight=1.5,
bend_penalty=250.0,
sbend_penalty=500.0,
)
refined_run = _route_problem(
bounds=(0, 0, 100, 100),
netlist={
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
},
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
bend_radii=[10.0],
sbend_radii=[10.0],
base_penalty=1000.0,
enabled=True,
greedy_h_weight=1.5,
bend_penalty=250.0,
sbend_penalty=500.0,
)
for net_id in ("vertical_up", "vertical_down"):
base_result = base_run.results_by_net[net_id]
refined_result = refined_run.results_by_net[net_id]
assert base_result.is_valid
assert refined_result.is_valid
assert _bend_count(refined_result) < _bend_count(base_result)

View file

@ -1,71 +0,0 @@
import unittest
from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._search import route_astar
from inire.router.cost import CostEvaluator
from inire.geometry.collision import RoutingWorld
class TestIntegerPorts(unittest.TestCase):
def setUp(self):
self.ce = RoutingWorld(clearance=2.0)
self.cost = CostEvaluator(self.ce)
self.bounds = (0, 0, 100, 100)
def _build_context(self) -> AStarContext:
return AStarContext(
self.cost,
RoutingProblem(bounds=self.bounds),
RoutingOptions(),
)
def _route(self, context: AStarContext, start: Port, target: Port):
return route_astar(
start,
target,
net_width=1.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
def test_route_reaches_integer_target(self):
context = self._build_context()
start = Port(0, 0, 0)
target = Port(12, 0, 0)
path = self._route(context, start, target)
self.assertIsNotNone(path)
last_port = path[-1].end_port
self.assertEqual(last_port.x, 12)
self.assertEqual(last_port.y, 0)
self.assertEqual(last_port.r, 0)
def test_port_constructor_rounds_to_integer_lattice(self):
context = self._build_context()
start = Port(0.0, 0.0, 0.0)
target = Port(12.3, 0.0, 0.0)
path = self._route(context, start, target)
self.assertIsNotNone(path)
self.assertEqual(target.x, 12)
last_port = path[-1].end_port
self.assertEqual(last_port.x, 12)
def test_half_step_inputs_use_integerized_targets(self):
context = self._build_context()
start = Port(0.0, 0.0, 0.0)
target = Port(7.5, 0.0, 0.0)
path = self._route(context, start, target)
self.assertIsNotNone(path)
self.assertEqual(target.x, 8)
last_port = path[-1].end_port
self.assertEqual(last_port.x, 8)
if __name__ == '__main__':
unittest.main()

View file

@ -1,129 +0,0 @@
from shapely.geometry import box
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics
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)
def test_visibility_manager_is_lazy_until_queried() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
assert visibility.corners == []
assert engine.metrics.total_visibility_corner_index_builds == 0
assert engine.metrics.total_visibility_builds == 0
visibility.ensure_corner_index_current()
assert visibility.corners
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 0
def test_exact_corner_visibility_builds_graph_once_per_static_version() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
origin = Port(10, 20, 0)
first = visibility.get_corner_visibility(origin, max_dist=100.0)
second = visibility.get_corner_visibility(origin, max_dist=100.0)
assert second == first
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
def test_clear_cache_invalidates_without_rebuilding_and_static_change_rebuilds_lazily() -> None:
engine = RoutingWorld(clearance=0.0)
engine.metrics = AStarMetrics()
engine.add_static_obstacle(box(10, 20, 20, 30))
visibility = VisibilityManager(engine)
visibility.get_corner_visibility(Port(10, 20, 0), max_dist=100.0)
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
visibility.clear_cache()
assert visibility.corners == []
assert engine.metrics.total_visibility_corner_index_builds == 1
assert engine.metrics.total_visibility_builds == 1
engine.add_static_obstacle(box(40, 20, 50, 30))
visible = visibility.get_corner_visibility(Port(10, 20, 0), max_dist=100.0)
assert visible == []
assert engine.metrics.total_visibility_corner_index_builds == 2
assert engine.metrics.total_visibility_builds == 2
def test_tangent_corner_candidate_query_matches_bruteforce_filter() -> None:
engine = RoutingWorld(clearance=0.0)
engine.add_static_obstacle(box(10, 20, 20, 30))
engine.add_static_obstacle(box(-35, -15, -25, -5))
engine.add_static_obstacle(box(35, -40, 45, -30))
visibility = VisibilityManager(engine)
radii = (10.0, 20.0)
min_forward = 5.0
max_forward = 60.0
tolerance = 2.0
for origin in (
Port(0, 0, 0),
Port(0, 0, 90),
Port(0, 0, 180),
Port(0, 0, 270),
):
candidate_ids = set(
visibility.get_tangent_corner_candidates(
origin,
min_forward=min_forward,
max_forward=max_forward,
radii=radii,
tolerance=tolerance,
)
)
expected_ids: set[int] = set()
if origin.r == 0:
cos_v, sin_v = 1.0, 0.0
elif origin.r == 90:
cos_v, sin_v = 0.0, 1.0
elif origin.r == 180:
cos_v, sin_v = -1.0, 0.0
else:
cos_v, sin_v = 0.0, -1.0
for idx, (cx, cy) in enumerate(visibility.corners):
dx = cx - origin.x
dy = cy - origin.y
local_x = dx * cos_v + dy * sin_v
local_y = -dx * sin_v + dy * cos_v
if local_x <= min_forward or local_x > max_forward + 0.01:
continue
nearest_radius = min(radii, key=lambda radius: abs(abs(local_y) - radius))
if abs(abs(local_y) - nearest_radius) <= tolerance:
expected_ids.add(idx)
assert candidate_ids == expected_ids

View file

@ -1,26 +0,0 @@
import matplotlib
matplotlib.use("Agg")
from inire.geometry.components import Bend90
from inire.geometry.primitives import Port
from inire import RoutingResult
from inire.utils.visualization import plot_routing_results
def test_plot_routing_results_respects_show_actual() -> None:
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox")
result = RoutingResult("net", [bend], reached_target=True)
fig_actual, ax_actual = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=True)
fig_proxy, ax_proxy = plot_routing_results({"net": result}, [], (-5.0, -5.0, 20.0, 20.0), show_actual=False)
actual_line_points = max(len(line.get_xdata()) for line in ax_actual.lines)
proxy_line_points = max(len(line.get_xdata()) for line in ax_proxy.lines)
assert actual_line_points > proxy_line_points
assert ax_actual.get_title().endswith("Actual Geometry)")
assert ax_proxy.get_title().endswith("(Proxy Geometry)")
fig_actual.clf()
fig_proxy.clf()

View file

@ -1,239 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
import matplotlib.pyplot as plt
import numpy
from shapely.geometry import MultiPolygon, Polygon
if TYPE_CHECKING:
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from inire.geometry.primitives import Port
from inire.router.danger_map import DangerMap
from inire.results import RoutingResult
def plot_routing_results(
results: dict[str, RoutingResult],
static_obstacles: list[Polygon],
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]] | None = None,
show_actual: bool = True,
) -> tuple[Figure, Axes]:
"""
Plot obstacles and routed paths using matplotlib.
Args:
results: Dictionary of net_id to RoutingResult.
static_obstacles: List of static obstacle polygons.
bounds: Plot limits (minx, miny, maxx, maxy).
netlist: Optional original netlist for port visualization.
show_actual: If True, overlay high-fidelity geometry if available.
Returns:
The matplotlib Figure and Axes objects.
"""
fig, ax = plt.subplots(figsize=(12, 12))
# Plot static obstacles (gray)
for poly in static_obstacles:
x, y = poly.exterior.xy
ax.fill(x, y, alpha=0.3, fc="gray", ec="black", zorder=1)
# Plot paths
colors = plt.get_cmap("tab20")
for i, (net_id, res) in enumerate(results.items()):
color: str | tuple[float, ...] = colors(i % 20)
if not res.is_valid:
color = "red"
label_added = False
for comp in res.path:
# 1. Plot Collision Geometry (Translucent fill)
for poly in comp.collision_geometry:
if isinstance(poly, MultiPolygon):
geoms = list(poly.geoms)
else:
geoms = [poly]
for g in geoms:
if hasattr(g, "exterior"):
x, y = g.exterior.xy
ax.fill(x, y, alpha=0.15, fc=color, ec=color, linestyle='--', lw=0.5, zorder=2)
else:
# Fallback for LineString or other geometries
x, y = g.xy
ax.plot(x, y, color=color, alpha=0.15, linestyle='--', lw=0.5, zorder=2)
actual_geoms_to_plot = comp.physical_geometry if show_actual else comp.collision_geometry
for poly in actual_geoms_to_plot:
if isinstance(poly, MultiPolygon):
geoms = list(poly.geoms)
else:
geoms = [poly]
for g in geoms:
if hasattr(g, "exterior"):
x, y = g.exterior.xy
ax.plot(x, y, color=color, lw=1.5, alpha=0.9, zorder=3, label=net_id if not label_added else "")
else:
x, y = g.xy
ax.plot(x, y, color=color, lw=1.5, alpha=0.9, zorder=3, label=net_id if not label_added else "")
label_added = True
# 3. Plot subtle port orientation arrow
p = comp.end_port
rad = numpy.radians(p.r)
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
scale=40, width=0.002, alpha=0.2, pivot="tail", zorder=4)
if not res.path and not res.is_valid:
# Empty failed paths are typically unroutable.
pass
# 4. Plot main arrows for netlist ports
if netlist:
for _net_id, (start_p, target_p) in netlist.items():
for p in [start_p, target_p]:
rad = numpy.radians(p.r)
ax.quiver(p.x, p.y, numpy.cos(rad), numpy.sin(rad), color="black",
scale=25, width=0.004, pivot="tail", zorder=6)
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
ax.set_aspect("equal")
if show_actual:
ax.set_title("Inire Routing Results (Dashed: Collision Proxy, Solid: Actual Geometry)")
else:
ax.set_title("Inire Routing Results (Proxy Geometry)")
# Legend handling for many nets
if len(results) < 25:
handles, labels = ax.get_legend_handles_labels()
if labels:
ax.legend(loc='upper left', bbox_to_anchor=(1, 1), fontsize='small', ncol=2)
fig.tight_layout()
plt.grid(True, which='both', linestyle=':', alpha=0.5)
return fig, ax
def plot_danger_map(
danger_map: DangerMap,
ax: Axes | None = None,
resolution: float | None = None
) -> tuple[Figure, Axes]:
"""
Plot the pre-computed danger map as a heatmap.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(10, 10))
else:
fig = cast("Figure", ax.get_figure())
# Generate a temporary grid for visualization
res = resolution if resolution is not None else max(1.0, (danger_map.maxx - danger_map.minx) / 200.0)
x_coords = numpy.arange(danger_map.minx + res/2, danger_map.maxx, res)
y_coords = numpy.arange(danger_map.miny + res/2, danger_map.maxy, res)
xv, yv = numpy.meshgrid(x_coords, y_coords, indexing='ij')
if danger_map.tree is not None:
points = numpy.stack([xv.ravel(), yv.ravel()], axis=1)
dists, _ = danger_map.tree.query(points, distance_upper_bound=danger_map.safety_threshold)
# Apply cost function
safe_dists = numpy.maximum(dists, 0.1)
grid_flat = numpy.where(
dists < danger_map.safety_threshold,
danger_map.k / (safe_dists**2),
0.0
)
grid = grid_flat.reshape(xv.shape)
else:
grid = numpy.zeros(xv.shape)
# Need to transpose because grid is [x, y] and imshow expects [row, col] (y, x)
im = ax.imshow(
grid.T,
origin="lower",
extent=(danger_map.minx, danger_map.maxx, danger_map.miny, danger_map.maxy),
cmap="YlOrRd",
alpha=0.6,
)
plt.colorbar(im, ax=ax, label="Danger Cost")
ax.set_title("Danger Map (Proximity Costs)")
return fig, ax
def plot_expanded_nodes(
nodes: list[tuple[float, float, float]],
ax: Axes | None = None,
color: str = 'gray',
alpha: float = 0.3,
) -> tuple[Figure, Axes]:
"""
Plot A* expanded nodes for debugging.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(10, 10))
else:
fig = cast("Figure", ax.get_figure())
if not nodes:
return fig, ax
x, y, _ = zip(*nodes, strict=False)
ax.scatter(x, y, s=1, c=color, alpha=alpha, zorder=0)
return fig, ax
def plot_expansion_density(
nodes: list[tuple[float, float, float]],
bounds: tuple[float, float, float, float],
ax: Axes | None = None,
bins: int | tuple[int, int] = 50,
) -> tuple[Figure, Axes]:
"""
Plot a density heatmap (2D histogram) of expanded nodes.
Args:
nodes: List of (x, y, orientation) tuples.
bounds: (minx, miny, maxx, maxy) for the plot range.
ax: Optional existing axes to plot on.
bins: Number of bins for the histogram (int or (nx, ny)).
Returns:
Figure and Axes objects.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(12, 12))
else:
fig = cast("Figure", ax.get_figure())
if not nodes:
ax.text(0.5, 0.5, "No Expansion Data", ha='center', va='center', transform=ax.transAxes)
return fig, ax
x, y, _ = zip(*nodes, strict=False)
# Create 2D histogram
h, xedges, yedges = numpy.histogram2d(
x, y,
bins=bins,
range=[[bounds[0], bounds[2]], [bounds[1], bounds[3]]]
)
# Plot as image
im = ax.imshow(
h.T,
origin="lower",
extent=(bounds[0], bounds[2], bounds[1], bounds[3]),
cmap="plasma",
interpolation="nearest",
alpha=0.7,
)
plt.colorbar(im, ax=ax, label="Expansion Count")
ax.set_title("Search Expansion Density")
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
return fig, ax

View file

@ -1,6 +1,6 @@
[project] [project]
name = "inire" name = "inire"
description = "Wave-router: Auto-routing for photonic and RF integrated circuits" description = "Wave-router"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { file = "LICENSE.md" } license = { file = "LICENSE.md" }
@ -9,6 +9,22 @@ authors = [
] ]
homepage = "https://mpxd.net/code/jan/inire" homepage = "https://mpxd.net/code/jan/inire"
repository = "https://mpxd.net/code/jan/inire" repository = "https://mpxd.net/code/jan/inire"
keywords = [
"layout",
"CAD",
"EDA",
"mask",
"pattern",
"lithography",
"oas",
"gds",
"dxf",
"svg",
"OASIS",
"gdsii",
"gds2",
"stream",
]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
@ -20,17 +36,10 @@ classifiers = [
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
] ]
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = []
"numpy",
"scipy",
"shapely",
"rtree",
"matplotlib",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"hypothesis>=6.151.9",
"matplotlib>=3.10.8", "matplotlib>=3.10.8",
"pytest>=9.0.2", "pytest>=9.0.2",
"ruff>=0.15.5", "ruff>=0.15.5",
@ -74,21 +83,7 @@ lint.ignore = [
"TRY003", # Long exception message "TRY003", # Long exception message
] ]
[tool.ruff.lint.per-file-ignores]
"inire/tests/*.py" = ["ANN", "ARG005", "PT009"]
[tool.mypy]
python_version = "3.11"
warn_unused_configs = true
exclude = ["^examples/", "^inire/tests/"]
[[tool.mypy.overrides]]
module = ["scipy.*"]
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-rsXx" addopts = "-rsXx"
testpaths = ["inire"] testpaths = ["inire"]
markers = [
"performance: opt-in runtime regression checks against example-like routing scenarios",
]

View file

@ -1,177 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from time import perf_counter
from inire.tests.example_scenarios import _run_example_07_variant
def _parse_csv_ints(raw: str) -> tuple[int, ...]:
return tuple(int(part) for part in raw.split(",") if part.strip())
def _run_case(num_nets: int, seed: int) -> dict[str, object]:
t0 = perf_counter()
run = _run_example_07_variant(
num_nets=num_nets,
seed=seed,
warm_start_enabled=False,
)
duration_s = perf_counter() - t0
return {
"duration_s": duration_s,
"summary": {
"total_results": len(run.results_by_net),
"valid_results": sum(1 for result in run.results_by_net.values() if result.is_valid),
"reached_targets": sum(1 for result in run.results_by_net.values() if result.reached_target),
},
"metrics": asdict(run.metrics),
}
def _is_smoke_candidate(entry: dict[str, object]) -> bool:
summary = entry["summary"]
metrics = entry["metrics"]
return (
summary["valid_results"] == summary["total_results"]
and metrics["pair_local_search_accepts"] >= 1
and entry["duration_s"] <= 1.0
)
def _select_smoke_case(cases: list[dict[str, object]]) -> dict[str, object] | None:
grouped: dict[tuple[int, int], list[dict[str, object]]] = {}
for case in cases:
key = (case["num_nets"], case["seed"])
grouped.setdefault(key, []).append(case)
candidates = []
for (num_nets, seed), repeats in grouped.items():
if repeats and all(_is_smoke_candidate(repeat) for repeat in repeats):
candidates.append({"num_nets": num_nets, "seed": seed})
if not candidates:
return None
candidates.sort(key=lambda item: (item["num_nets"], item["seed"]))
return candidates[0]
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Pair-Local Search Characterization",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
f"Grid: `num_nets={payload['grid']['num_nets']}`, `seed={payload['grid']['seeds']}`, repeats={payload['grid']['repeats']}.",
"",
"| Nets | Seed | Repeat | Duration (s) | Valid | Reached | Pair Pairs | Pair Accepts | Pair Nodes | Nodes | Checks |",
"| :-- | :-- | :-- | --: | --: | --: | --: | --: | --: | --: | --: |",
]
for case in payload["cases"]:
summary = case["summary"]
metrics = case["metrics"]
lines.append(
"| "
f"{case['num_nets']} | "
f"{case['seed']} | "
f"{case['repeat']} | "
f"{case['duration_s']:.4f} | "
f"{summary['valid_results']} | "
f"{summary['reached_targets']} | "
f"{metrics['pair_local_search_pairs_considered']} | "
f"{metrics['pair_local_search_accepts']} | "
f"{metrics['pair_local_search_nodes_expanded']} | "
f"{metrics['nodes_expanded']} | "
f"{metrics['congestion_check_calls']} |"
)
lines.extend(["", "## Recommendation", ""])
recommended = payload["recommended_smoke_scenario"]
if recommended is None:
lines.append(
"No smaller stable pair-local smoke scenario satisfied the rule "
"`valid_results == total_results`, `pair_local_search_accepts >= 1`, and `duration_s <= 1.0` across all repeats."
)
else:
lines.append(
f"Recommended smoke scenario: `num_nets={recommended['num_nets']}`, `seed={recommended['seed']}`."
)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Characterize pair-local search across example_07-style no-warm runs.")
parser.add_argument(
"--num-nets",
default="6,8,10",
help="Comma-separated num_nets values to sweep. Default: 6,8,10.",
)
parser.add_argument(
"--seeds",
default="41,42,43",
help="Comma-separated seed values to sweep. Default: 41,42,43.",
)
parser.add_argument(
"--repeats",
type=int,
default=2,
help="Number of repeated runs per (num_nets, seed). Default: 2.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write pair_local_characterization.json and .md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
num_nets_values = _parse_csv_ints(args.num_nets)
seed_values = _parse_csv_ints(args.seeds)
cases: list[dict[str, object]] = []
for num_nets in num_nets_values:
for seed in seed_values:
for repeat in range(args.repeats):
case = _run_case(num_nets, seed)
case["num_nets"] = num_nets
case["seed"] = seed
case["repeat"] = repeat
cases.append(case)
payload = {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/characterize_pair_local_search.py",
"grid": {
"num_nets": list(num_nets_values),
"seeds": list(seed_values),
"repeats": args.repeats,
},
"cases": cases,
"recommended_smoke_scenario": _select_smoke_case(cases),
}
json_path = output_dir / "pair_local_characterization.json"
markdown_path = output_dir / "pair_local_characterization.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,237 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import PERFORMANCE_SCENARIO_SNAPSHOTS, SCENARIO_SNAPSHOTS
from inire.results import RouteMetrics
SUMMARY_KEYS = (
"duration_s",
"valid_results",
"reached_targets",
"route_iterations",
"nets_routed",
"nodes_expanded",
"ray_cast_calls",
"moves_generated",
"moves_added",
"congestion_check_calls",
"verify_path_report_calls",
)
def _snapshot_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if not include_performance_only:
return SCENARIO_SNAPSHOTS
return SCENARIO_SNAPSHOTS + PERFORMANCE_SCENARIO_SNAPSHOTS
def _available_metric_names() -> tuple[str, ...]:
return (
"duration_s",
"total_results",
"valid_results",
"reached_targets",
*RouteMetrics.__dataclass_fields__.keys(),
)
def _current_snapshots(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, dict[str, object]]:
allowed = None if selected_scenarios is None else set(selected_scenarios)
snapshots: dict[str, dict[str, object]] = {}
for name, run in _snapshot_registry(include_performance_only):
if allowed is not None and name not in allowed:
continue
snapshots[name] = asdict(run())
return snapshots
def _load_baseline(path: Path, selected_scenarios: tuple[str, ...] | None) -> dict[str, dict[str, object]]:
payload = json.loads(path.read_text())
allowed = None if selected_scenarios is None else set(selected_scenarios)
return {
entry["name"]: entry
for entry in payload["scenarios"]
if allowed is None or entry["name"] in allowed
}
def _metric_value(snapshot: dict[str, object], key: str) -> float | None:
if key in {"duration_s", "total_results", "valid_results", "reached_targets"}:
return float(snapshot[key])
if key not in snapshot["metrics"]:
return None
return float(snapshot["metrics"][key])
def _validate_metrics(metric_names: tuple[str, ...]) -> None:
valid_names = set(_available_metric_names())
unknown = [name for name in metric_names if name not in valid_names]
if unknown:
raise SystemExit(
f"Unknown metric name(s): {', '.join(sorted(unknown))}. "
f"Valid names are: {', '.join(_available_metric_names())}"
)
def _render_report(
baseline: dict[str, dict[str, object]],
current: dict[str, dict[str, object]],
metric_names: tuple[str, ...],
) -> str:
scenario_names = sorted(set(baseline) | set(current))
lines = [
"# Performance Baseline Diff",
"",
"| Scenario | Metric | Baseline | Current | Delta |",
"| :-- | :-- | --: | --: | --: |",
]
for scenario in scenario_names:
base_snapshot = baseline.get(scenario)
curr_snapshot = current.get(scenario)
if base_snapshot is None:
if curr_snapshot is None:
lines.append(f"| {scenario} | added | - | - | - |")
continue
for key in metric_names:
curr_value = _metric_value(curr_snapshot, key)
if curr_value is None:
lines.append(f"| {scenario} | {key} | - | - | - |")
continue
lines.append(f"| {scenario} | {key} | - | {curr_value:.4f} | - |")
continue
if curr_snapshot is None:
lines.append(f"| {scenario} | missing | - | - | - |")
continue
for key in metric_names:
base_value = _metric_value(base_snapshot, key)
curr_value = _metric_value(curr_snapshot, key)
if base_value is None:
lines.append(
f"| {scenario} | {key} | - | {curr_value:.4f} | - |"
)
continue
if curr_value is None:
lines.append(
f"| {scenario} | {key} | {base_value:.4f} | - | - |"
)
continue
lines.append(
f"| {scenario} | {key} | {base_value:.4f} | {curr_value:.4f} | {curr_value - base_value:+.4f} |"
)
return "\n".join(lines) + "\n"
def _render_log_entry(
*,
baseline_path: Path,
label: str,
notes: tuple[str, ...],
report: str,
) -> str:
lines = [
f"## {label}",
"",
f"Measured on {datetime.now().astimezone().isoformat(timespec='seconds')}.",
f"Baseline: `{baseline_path}`.",
"",
]
if notes:
lines.extend(["Findings:", ""])
lines.extend(f"- {note}" for note in notes)
lines.append("")
lines.append(report.rstrip())
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Diff the committed performance baseline against a fresh run.")
parser.add_argument(
"--baseline",
type=Path,
default=Path("docs/performance_baseline.json"),
help="Baseline JSON to compare against.",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Optional file to write the report to. Defaults to stdout.",
)
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--metric",
action="append",
dest="metrics",
default=[],
help="Optional metric to include. May be passed more than once. Defaults to the summary metric set.",
)
parser.add_argument(
"--label",
default="Measurement",
help="Section label to use when appending to a log file.",
)
parser.add_argument(
"--notes",
action="append",
dest="notes",
default=[],
help="Optional short finding to append under the measurement section. May be passed more than once.",
)
parser.add_argument(
"--log",
type=Path,
default=None,
help="Optional Markdown log file to append the rendered report to.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only snapshot scenarios that are excluded from the default baseline corpus.",
)
args = parser.parse_args()
selected = tuple(args.scenarios) if args.scenarios else None
metrics = tuple(args.metrics) if args.metrics else SUMMARY_KEYS
_validate_metrics(metrics)
baseline = _load_baseline(args.baseline, selected)
current = _current_snapshots(selected, include_performance_only=args.include_performance_only)
report = _render_report(baseline, current, metrics)
if args.output is not None:
args.output.write_text(report)
print(f"Wrote {args.output}")
elif args.log is None:
print(report, end="")
if args.log is not None:
entry = _render_log_entry(
baseline_path=args.baseline,
label=args.label,
notes=tuple(args.notes),
report=report,
)
with args.log.open("a", encoding="utf-8") as handle:
handle.write(entry)
print(f"Appended {args.log}")
if __name__ == "__main__":
main()

View file

@ -1,228 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.results import RoutingRunResult
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
return (("example_07_large_scale_routing_no_warm_start", dict(TRACE_PERFORMANCE_SCENARIO_RUNS)["example_07_large_scale_routing_no_warm_start"]),)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _result_summary(run: RoutingRunResult) -> dict[str, object]:
return {
"total_results": len(run.results_by_net),
"valid_results": sum(1 for result in run.results_by_net.values() if result.is_valid),
"reached_targets": sum(1 for result in run.results_by_net.values() if result.reached_target),
"results_by_net": {
net_id: {
"outcome": result.outcome,
"reached_target": result.reached_target,
"report": asdict(result.report),
}
for net_id, result in run.results_by_net.items()
},
}
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": _result_summary(result),
"metrics": asdict(result.metrics),
"conflict_trace": [asdict(entry) for entry in result.conflict_trace],
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_conflict_trace.py",
"scenarios": scenarios,
}
def _count_stage_nets(entry: dict[str, object]) -> int:
return sum(
1
for net in entry["nets"]
if net["report"]["dynamic_collision_count"] > 0
)
def _canonical_component_pair(
net_id: str,
self_component_index: int,
other_net_id: str,
other_component_index: int,
) -> tuple[tuple[str, int], tuple[str, int]]:
left = (net_id, self_component_index)
right = (other_net_id, other_component_index)
if left <= right:
return (left, right)
return (right, left)
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Conflict Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
"| Stage | Iteration | Conflicting Nets | Conflict Edges | Completed Nets |",
"| :-- | --: | --: | --: | --: |",
]
)
net_stage_counts: Counter[str] = Counter()
edge_counts: Counter[tuple[str, str]] = Counter()
component_pair_counts: Counter[tuple[tuple[str, int], tuple[str, int]]] = Counter()
trace_entries = scenario["conflict_trace"]
for entry in trace_entries:
lines.append(
"| "
f"{entry['stage']} | "
f"{'' if entry['iteration'] is None else entry['iteration']} | "
f"{_count_stage_nets(entry)} | "
f"{len(entry['conflict_edges'])} | "
f"{len(entry['completed_net_ids'])} |"
)
seen_component_pairs: set[tuple[tuple[str, int], tuple[str, int]]] = set()
for edge in entry["conflict_edges"]:
edge_counts[tuple(edge)] += 1
for net in entry["nets"]:
if net["report"]["dynamic_collision_count"] > 0:
net_stage_counts[net["net_id"]] += 1
for component_conflict in net["component_conflicts"]:
pair = _canonical_component_pair(
net["net_id"],
component_conflict["self_component_index"],
component_conflict["other_net_id"],
component_conflict["other_component_index"],
)
seen_component_pairs.add(pair)
for pair in seen_component_pairs:
component_pair_counts[pair] += 1
lines.extend(["", "Top nets by traced dynamic-collision stages:", ""])
if net_stage_counts:
for net_id, count in net_stage_counts.most_common(10):
lines.append(f"- `{net_id}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top net pairs by frequency:", ""])
if edge_counts:
for (left, right), count in edge_counts.most_common(10):
lines.append(f"- `{left}` <-> `{right}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top component pairs by frequency:", ""])
if component_pair_counts:
for pair, count in component_pair_counts.most_common(10):
(left_net, left_index), (right_net, right_index) = pair
lines.append(f"- `{left_net}[{left_index}]` <-> `{right_net}[{right_index}]`: {count}")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record conflict-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write conflict_trace.json and conflict_trace.md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "conflict_trace.json"
markdown_path = output_dir / "conflict_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,205 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
default_registry = dict(TRACE_PERFORMANCE_SCENARIO_RUNS)
return (("example_07_large_scale_routing_no_warm_start", default_registry["example_07_large_scale_routing_no_warm_start"]),)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": {
"total_results": len(result.results_by_net),
"valid_results": sum(1 for entry in result.results_by_net.values() if entry.is_valid),
"reached_targets": sum(1 for entry in result.results_by_net.values() if entry.reached_target),
},
"metrics": asdict(result.metrics),
"frontier_trace": [asdict(entry) for entry in result.frontier_trace],
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_frontier_trace.py",
"scenarios": scenarios,
}
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Frontier Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
"| Net | Hotspots | Closed-Set | Hard Collision | Self Collision | Cost | Samples |",
"| :-- | --: | --: | --: | --: | --: | --: |",
]
)
reason_counts: Counter[str] = Counter()
hotspot_counts: Counter[tuple[str, int]] = Counter()
for net_trace in scenario["frontier_trace"]:
sample_count = len(net_trace["samples"])
lines.append(
"| "
f"{net_trace['net_id']} | "
f"{len(net_trace['hotspot_bounds'])} | "
f"{net_trace['pruned_closed_set']} | "
f"{net_trace['pruned_hard_collision']} | "
f"{net_trace['pruned_self_collision']} | "
f"{net_trace['pruned_cost']} | "
f"{sample_count} |"
)
reason_counts["closed_set"] += net_trace["pruned_closed_set"]
reason_counts["hard_collision"] += net_trace["pruned_hard_collision"]
reason_counts["self_collision"] += net_trace["pruned_self_collision"]
reason_counts["cost"] += net_trace["pruned_cost"]
for sample in net_trace["samples"]:
hotspot_counts[(net_trace["net_id"], sample["hotspot_index"])] += 1
lines.extend(["", "Prune totals by reason:", ""])
if reason_counts:
for reason, count in reason_counts.most_common():
lines.append(f"- `{reason}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top traced hotspots by sample count:", ""])
if hotspot_counts:
for (net_id, hotspot_index), count in hotspot_counts.most_common(10):
lines.append(f"- `{net_id}` hotspot `{hotspot_index}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Per-net sampled reason/move breakdown:", ""])
if scenario["frontier_trace"]:
for net_trace in scenario["frontier_trace"]:
reason_move_counts: Counter[tuple[str, str]] = Counter()
hotspot_sample_counts: Counter[int] = Counter()
for sample in net_trace["samples"]:
reason_move_counts[(sample["reason"], sample["move_type"])] += 1
hotspot_sample_counts[sample["hotspot_index"]] += 1
lines.append(f"- `{net_trace['net_id']}`")
if reason_move_counts:
top_pairs = ", ".join(
f"`{reason}` x `{move}` = {count}"
for (reason, move), count in reason_move_counts.most_common(3)
)
lines.append(f" sampled reasons: {top_pairs}")
else:
lines.append(" sampled reasons: none")
if hotspot_sample_counts:
top_hotspots = ", ".join(
f"`{hotspot}` = {count}" for hotspot, count in hotspot_sample_counts.most_common(3)
)
lines.append(f" hotspot samples: {top_hotspots}")
else:
lines.append(" hotspot samples: none")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record frontier-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write frontier_trace.json and frontier_trace.md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "frontier_trace.json"
markdown_path = output_dir / "frontier_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,186 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
perf_registry = dict(TRACE_PERFORMANCE_SCENARIO_RUNS)
return (
(
"example_07_large_scale_routing_no_warm_start",
perf_registry["example_07_large_scale_routing_no_warm_start"],
),
(
"example_07_large_scale_routing_no_warm_start_seed43",
perf_registry["example_07_large_scale_routing_no_warm_start_seed43"],
),
)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown iteration-trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": {
"total_results": len(result.results_by_net),
"valid_results": sum(1 for entry in result.results_by_net.values() if entry.is_valid),
"reached_targets": sum(1 for entry in result.results_by_net.values() if entry.reached_target),
},
"metrics": asdict(result.metrics),
"iteration_trace": [asdict(entry) for entry in result.iteration_trace],
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_iteration_trace.py",
"scenarios": scenarios,
}
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Iteration Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
"| Iteration | Penalty | Routed Nets | Completed | Conflict Edges | Dynamic Collisions | Nodes | Congestion Checks | Candidate Ids | Exact Pairs |",
"| --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |",
]
)
net_node_counts: Counter[str] = Counter()
net_check_counts: Counter[str] = Counter()
for entry in scenario["iteration_trace"]:
lines.append(
"| "
f"{entry['iteration']} | "
f"{entry['congestion_penalty']:.1f} | "
f"{len(entry['routed_net_ids'])} | "
f"{entry['completed_nets']} | "
f"{entry['conflict_edges']} | "
f"{entry['total_dynamic_collisions']} | "
f"{entry['nodes_expanded']} | "
f"{entry['congestion_check_calls']} | "
f"{entry['congestion_candidate_ids']} | "
f"{entry['congestion_exact_pair_checks']} |"
)
for attempt in entry["net_attempts"]:
net_node_counts[attempt["net_id"]] += attempt["nodes_expanded"]
net_check_counts[attempt["net_id"]] += attempt["congestion_check_calls"]
lines.extend(["", "Top nets by iteration-attributed nodes expanded:", ""])
if net_node_counts:
for net_id, count in net_node_counts.most_common(10):
lines.append(f"- `{net_id}`: {count}")
else:
lines.append("- None")
lines.extend(["", "Top nets by iteration-attributed congestion checks:", ""])
if net_check_counts:
for net_id, count in net_check_counts.most_common(10):
lines.append(f"- `{net_id}`: {count}")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record iteration-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write iteration_trace.json and iteration_trace.md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "iteration_trace.json"
markdown_path = output_dir / "iteration_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,146 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from datetime import date
from pathlib import Path
from inire.tests.example_scenarios import PERFORMANCE_SCENARIO_SNAPSHOTS, SCENARIO_SNAPSHOTS
SUMMARY_METRICS = (
"route_iterations",
"nets_routed",
"nodes_expanded",
"ray_cast_calls",
"moves_generated",
"moves_added",
"dynamic_tree_rebuilds",
"visibility_builds",
"congestion_check_calls",
"verify_path_report_calls",
)
def _snapshot_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if not include_performance_only:
return SCENARIO_SNAPSHOTS
return SCENARIO_SNAPSHOTS + PERFORMANCE_SCENARIO_SNAPSHOTS
def _build_payload(
selected_scenarios: tuple[str, ...] | None = None,
*,
include_performance_only: bool = False,
) -> dict[str, object]:
allowed = None if selected_scenarios is None else set(selected_scenarios)
snapshots = []
for name, run in _snapshot_registry(include_performance_only):
if allowed is not None and name not in allowed:
continue
snapshots.append(run())
return {
"generated_on": date.today().isoformat(),
"generator": "scripts/record_performance_baseline.py",
"scenarios": [asdict(snapshot) for snapshot in snapshots],
}
def _render_markdown(payload: dict[str, object]) -> str:
rows = payload["scenarios"]
lines = [
"# Performance Baseline",
"",
f"Generated on {payload['generated_on']} by `{payload['generator']}`.",
"",
"The full machine-readable snapshot lives in `docs/performance_baseline.json`.",
"Use `scripts/diff_performance_baseline.py` to compare a fresh run against that snapshot.",
"",
"| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |",
"| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |",
]
for row in rows:
metrics = row["metrics"]
lines.append(
"| "
f"{row['name']} | "
f"{row['duration_s']:.4f} | "
f"{row['total_results']} | "
f"{row['valid_results']} | "
f"{row['reached_targets']} | "
f"{metrics['route_iterations']} | "
f"{metrics['nets_routed']} | "
f"{metrics['nodes_expanded']} | "
f"{metrics['ray_cast_calls']} | "
f"{metrics['moves_generated']} | "
f"{metrics['moves_added']} | "
f"{metrics['dynamic_tree_rebuilds']} | "
f"{metrics['visibility_builds']} | "
f"{metrics['congestion_check_calls']} | "
f"{metrics['verify_path_report_calls']} |"
)
lines.extend(
[
"",
"## Full Counter Set",
"",
"Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.",
"These counters are currently observational only and are not enforced as CI regression gates.",
"",
"Tracked metric keys:",
"",
", ".join(rows[0]["metrics"].keys()) if rows else "",
]
)
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Record the example-scenario performance baseline.")
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write performance_baseline.json and performance.md into. Defaults to <repo>/docs.",
)
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only snapshot scenarios that are excluded from the default baseline corpus.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
docs_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
docs_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = docs_dir / "performance_baseline.json"
markdown_path = docs_dir / "performance.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload))
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

View file

@ -1,191 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from collections import Counter
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from inire.tests.example_scenarios import TRACE_PERFORMANCE_SCENARIO_RUNS, TRACE_SCENARIO_RUNS
def _trace_registry(include_performance_only: bool) -> tuple[tuple[str, object], ...]:
if include_performance_only:
return TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS
return TRACE_SCENARIO_RUNS
def _selected_runs(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> tuple[tuple[str, object], ...]:
if selected_scenarios is None:
perf_registry = dict(TRACE_PERFORMANCE_SCENARIO_RUNS)
return (
(
"example_07_large_scale_routing_no_warm_start",
perf_registry["example_07_large_scale_routing_no_warm_start"],
),
(
"example_07_large_scale_routing_no_warm_start_seed43",
perf_registry["example_07_large_scale_routing_no_warm_start_seed43"],
),
)
registry = dict(TRACE_SCENARIO_RUNS + TRACE_PERFORMANCE_SCENARIO_RUNS)
allowed_standard = dict(_trace_registry(include_performance_only))
runs = []
for name in selected_scenarios:
if name in allowed_standard:
runs.append((name, allowed_standard[name]))
continue
if name in registry:
runs.append((name, registry[name]))
continue
valid = ", ".join(sorted(registry))
raise SystemExit(f"Unknown pre-pair frontier-trace scenario: {name}. Valid scenarios: {valid}")
return tuple(runs)
def _build_payload(
selected_scenarios: tuple[str, ...] | None,
*,
include_performance_only: bool,
) -> dict[str, object]:
scenarios = []
for name, run in _selected_runs(selected_scenarios, include_performance_only=include_performance_only):
result = run()
scenarios.append(
{
"name": name,
"summary": {
"total_results": len(result.results_by_net),
"valid_results": sum(1 for entry in result.results_by_net.values() if entry.is_valid),
"reached_targets": sum(1 for entry in result.results_by_net.values() if entry.reached_target),
},
"metrics": asdict(result.metrics),
"pre_pair_frontier_trace": None
if result.pre_pair_frontier_trace is None
else asdict(result.pre_pair_frontier_trace),
}
)
return {
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"generator": "scripts/record_pre_pair_frontier_trace.py",
"scenarios": scenarios,
}
def _render_markdown(payload: dict[str, object]) -> str:
lines = [
"# Pre-Pair Frontier Trace",
"",
f"Generated at {payload['generated_at']} by `{payload['generator']}`.",
"",
]
for scenario in payload["scenarios"]:
lines.extend(
[
f"## {scenario['name']}",
"",
f"Results: {scenario['summary']['valid_results']} valid / "
f"{scenario['summary']['reached_targets']} reached / "
f"{scenario['summary']['total_results']} total.",
"",
]
)
trace = scenario["pre_pair_frontier_trace"]
if trace is None:
lines.extend(["No pre-pair frontier trace captured.", ""])
continue
lines.extend(
[
f"Captured iteration: `{trace['iteration']}`",
"",
f"Conflict edges: `{trace['conflict_edges']}`",
"",
"| Net | Nodes | Checks | Closed-Set | Cost | Hard Collision | Guidance Seed | Frontier Samples |",
"| :-- | --: | --: | --: | --: | --: | :--: | --: |",
]
)
reason_counts: Counter[str] = Counter()
for net_trace in trace["nets"]:
frontier = net_trace["frontier"]
lines.append(
"| "
f"{net_trace['net_id']} | "
f"{net_trace['nodes_expanded']} | "
f"{net_trace['congestion_check_calls']} | "
f"{net_trace['pruned_closed_set']} | "
f"{net_trace['pruned_cost']} | "
f"{net_trace['pruned_hard_collision']} | "
f"{'yes' if net_trace['guidance_seed_present'] else 'no'} | "
f"{len(frontier['samples'])} |"
)
reason_counts["closed_set"] += frontier["pruned_closed_set"]
reason_counts["hard_collision"] += frontier["pruned_hard_collision"]
reason_counts["self_collision"] += frontier["pruned_self_collision"]
reason_counts["cost"] += frontier["pruned_cost"]
lines.extend(["", "Frontier prune totals by reason:", ""])
if reason_counts:
for reason, count in reason_counts.most_common():
lines.append(f"- `{reason}`: {count}")
else:
lines.append("- None")
lines.append("")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Record pre-pair frontier-trace artifacts for selected trace scenarios.")
parser.add_argument(
"--scenario",
action="append",
dest="scenarios",
default=[],
help="Optional trace scenario name to include. May be passed more than once.",
)
parser.add_argument(
"--include-performance-only",
action="store_true",
help="Include performance-only trace scenarios when selecting from the standard registry.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory to write pre_pair_frontier_trace.json and .md into. Defaults to <repo>/docs.",
)
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[1]
output_dir = repo_root / "docs" if args.output_dir is None else args.output_dir.resolve()
output_dir.mkdir(exist_ok=True)
selected = tuple(args.scenarios) if args.scenarios else None
payload = _build_payload(selected, include_performance_only=args.include_performance_only)
json_path = output_dir / "pre_pair_frontier_trace.json"
markdown_path = output_dir / "pre_pair_frontier_trace.md"
json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
markdown_path.write_text(_render_markdown(payload) + "\n")
if json_path.is_relative_to(repo_root):
print(f"Wrote {json_path.relative_to(repo_root)}")
else:
print(f"Wrote {json_path}")
if markdown_path.is_relative_to(repo_root):
print(f"Wrote {markdown_path.relative_to(repo_root)}")
else:
print(f"Wrote {markdown_path}")
if __name__ == "__main__":
main()

337
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1 version = 1
requires-python = ">=3.11" requires-python = ">=3.13"
[[package]] [[package]]
name = "colorama" name = "colorama"
@ -19,28 +19,6 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 },
{ url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 },
{ url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 },
{ url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 },
{ url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 },
{ url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 },
{ url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 },
{ url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 },
{ url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 },
{ url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 },
{ url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 },
{ url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 },
{ url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 },
{ url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 },
{ url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 },
{ url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 },
{ url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 },
{ url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 },
{ url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 },
{ url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 },
{ url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 },
{ url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 },
{ url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 }, { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 },
{ url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 }, { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 },
{ url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 }, { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 },
@ -85,11 +63,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 }, { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 },
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 }, { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 },
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 }, { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 },
{ url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 },
{ url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 },
{ url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 },
{ url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 },
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 },
] ]
[[package]] [[package]]
@ -107,22 +80,6 @@ version = "4.61.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756 } sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213 },
{ url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689 },
{ url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809 },
{ url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039 },
{ url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714 },
{ url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648 },
{ url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681 },
{ url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951 },
{ url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593 },
{ url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231 },
{ url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103 },
{ url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295 },
{ url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109 },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598 },
{ url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060 },
{ url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078 },
{ url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454 }, { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454 },
{ url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191 }, { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191 },
{ url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410 }, { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410 },
@ -150,18 +107,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996 }, { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996 },
] ]
[[package]]
name = "hypothesis"
version = "6.151.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307 },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -173,35 +118,20 @@ wheels = [
[[package]] [[package]]
name = "inire" name = "inire"
source = { editable = "." } version = "0.1.0"
dependencies = [ source = { virtual = "." }
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "hypothesis" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "hypothesis", specifier = ">=6.151.9" },
{ name = "matplotlib", specifier = ">=3.10.8" }, { name = "matplotlib", specifier = ">=3.10.8" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "ruff", specifier = ">=0.15.5" }, { name = "ruff", specifier = ">=0.15.5" },
@ -213,32 +143,6 @@ version = "1.4.9"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 } sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 },
{ url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 },
{ url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 },
{ url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 },
{ url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 },
{ url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 },
{ url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 },
{ url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 },
{ url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 },
{ url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 },
{ url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 },
{ url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 },
{ url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 },
{ url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 },
{ url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 },
{ url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 },
{ url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756 },
{ url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404 },
{ url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410 },
{ url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631 },
{ url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963 },
{ url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295 },
{ url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987 },
{ url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817 },
{ url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895 },
{ url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992 },
{ url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 }, { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 },
{ url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 }, { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 },
{ url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 }, { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 },
@ -290,11 +194,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 }, { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 },
{ url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 }, { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 },
{ url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 }, { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 },
{ url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 },
{ url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 },
{ url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 },
{ url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 },
{ url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 },
] ]
[[package]] [[package]]
@ -314,20 +213,6 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 } sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215 },
{ url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625 },
{ url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614 },
{ url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997 },
{ url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825 },
{ url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090 },
{ url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377 },
{ url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453 },
{ url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321 },
{ url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944 },
{ url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099 },
{ url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040 },
{ url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717 },
{ url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751 },
{ url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076 }, { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076 },
{ url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794 }, { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794 },
{ url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474 }, { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474 },
@ -356,9 +241,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011 }, { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011 },
{ url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801 }, { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801 },
{ url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560 }, { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560 },
{ url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198 },
{ url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817 },
{ url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867 },
] ]
[[package]] [[package]]
@ -367,28 +249,6 @@ version = "2.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651 } sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478 },
{ url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467 },
{ url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172 },
{ url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145 },
{ url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084 },
{ url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477 },
{ url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429 },
{ url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109 },
{ url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915 },
{ url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972 },
{ url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763 },
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963 },
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571 },
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469 },
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820 },
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067 },
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782 },
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128 },
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324 },
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282 },
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210 },
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171 },
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696 }, { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696 },
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322 }, { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322 },
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157 }, { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157 },
@ -431,13 +291,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937 }, { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937 },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844 }, { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844 },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379 }, { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379 },
{ url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179 },
{ url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755 },
{ url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500 },
{ url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252 },
{ url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142 },
{ url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979 },
{ url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577 },
] ]
[[package]] [[package]]
@ -455,28 +308,6 @@ version = "12.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264 } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084 },
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866 },
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148 },
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007 },
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418 },
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590 },
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655 },
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286 },
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663 },
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448 },
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651 },
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803 },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601 },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995 },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012 },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638 },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540 },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613 },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745 },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823 },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367 },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811 },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689 }, { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689 },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535 }, { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535 },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364 }, { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364 },
@ -527,13 +358,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736 }, { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736 },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894 }, { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894 },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446 }, { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446 },
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606 },
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321 },
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579 },
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094 },
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850 },
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343 },
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880 },
] ]
[[package]] [[package]]
@ -591,22 +415,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
] ]
[[package]]
name = "rtree"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484 },
{ url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325 },
{ url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789 },
{ url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644 },
{ url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478 },
{ url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140 },
{ url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358 },
{ url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253 },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.5" version = "0.15.5"
@ -632,136 +440,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572 }, { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572 },
] ]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199 },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001 },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719 },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595 },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429 },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952 },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063 },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449 },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708 },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135 },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977 },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601 },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667 },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159 },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771 },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910 },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 },
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 },
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 },
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 },
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 },
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 },
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 },
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 },
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 },
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 },
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 },
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 },
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 },
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 },
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 },
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 },
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 },
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 },
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 },
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 },
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 },
]
[[package]]
name = "shapely"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038 },
{ url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039 },
{ url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519 },
{ url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842 },
{ url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316 },
{ url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586 },
{ url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961 },
{ url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856 },
{ url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550 },
{ url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556 },
{ url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308 },
{ url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844 },
{ url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842 },
{ url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714 },
{ url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745 },
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861 },
{ url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644 },
{ url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887 },
{ url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931 },
{ url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855 },
{ url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960 },
{ url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851 },
{ url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890 },
{ url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151 },
{ url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130 },
{ url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802 },
{ url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460 },
{ url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223 },
{ url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760 },
{ url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078 },
{ url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178 },
{ url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756 },
{ url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290 },
{ url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463 },
{ url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145 },
{ url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806 },
{ url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803 },
{ url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301 },
{ url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247 },
{ url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019 },
{ url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137 },
{ url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884 },
{ url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320 },
{ url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931 },
{ url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406 },
{ url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511 },
{ url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607 },
{ url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682 },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@ -770,12 +448,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
] ]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 },
]