Compare commits

...

3 commits

Author SHA1 Message Date
725980e694 update docs and perf metrics 2026-03-31 17:26:00 -07:00
1849075b11 linter fixes 2026-03-30 23:54:30 -07:00
e2c91076f7 example fixes and improvements 2026-03-30 23:40:29 -07:00
53 changed files with 1667 additions and 769 deletions

62
DOCS.md
View file

@ -80,7 +80,9 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
| `bend_radii` | `(50.0, 100.0)` | Available radii for 90-degree bends. |
| `sbend_radii` | `(10.0,)` | Available radii for S-bends. |
| `sbend_offsets` | `None` | Optional explicit lateral offsets for S-bends. |
| `bend_collision_type` | `"arc"` | Bend collision model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. |
| `bend_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. |
@ -131,19 +133,60 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
`RoutingRunResult.metrics` is an immutable per-run snapshot.
| Field | Type | Description |
| :-- | :-- | :-- |
| `nodes_expanded` | `int` | Total nodes expanded during the run. |
| `moves_generated` | `int` | Total candidate moves generated during the run. |
| `moves_added` | `int` | Total candidate moves admitted to the open set during the run. |
| `pruned_closed_set` | `int` | Total moves pruned because the state was already closed at lower cost. |
| `pruned_hard_collision` | `int` | Total moves pruned by hard collision checks. |
| `pruned_cost` | `int` | Total moves pruned by cost ceilings or invalid costs. |
### 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.
### 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.
- `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_builds`: Number of static visibility-graph rebuilds.
- `visibility_corner_pairs_checked`: Number of corner-pair visibility probes considered while building that graph.
- `visibility_corner_queries` / `visibility_corner_hits`: Precomputed-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_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_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification.
## 8. Internal Modules
Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
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)**.
## 9. Tuning Notes
### Speed vs. optimality
@ -161,6 +204,7 @@ Lower-level search and collision modules are semi-private implementation details
- 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

View file

@ -8,7 +8,7 @@
* **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**: Incorporates a "Danger Map" (pre-computed distance transform) to maintain optimal spacing and reduce crosstalk.
* **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
@ -77,7 +77,11 @@ INIRE_RUN_PERFORMANCE=1 python3 -m pytest -q inire/tests/test_example_performanc
## Documentation
Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**.
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
@ -92,7 +96,7 @@ Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router.
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, ensuring that paths find the globally optimal configuration without crossings.
For multi-net problems, the negotiated-congestion loop handles rip-up and reroute logic and seeks a collision-free configuration without crossings.
## Configuration

47
docs/architecture.md Normal file
View file

@ -0,0 +1,47 @@
# 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.
- `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.
- `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration.
- 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`.

25
docs/performance.md Normal file
View file

@ -0,0 +1,25 @@
# Performance Baseline
Generated on 2026-03-31 by `scripts/record_performance_baseline.py`.
The full machine-readable snapshot lives in `docs/performance_baseline.json`.
| 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.0042 | 1 | 1 | 1 | 1 | 1 | 2 | 22 | 11 | 7 | 2 | 2 | 0 | 3 |
| example_02_congestion_resolution | 0.3335 | 3 | 3 | 3 | 1 | 3 | 366 | 1176 | 1413 | 668 | 8 | 4 | 0 | 35 |
| example_03_locked_paths | 0.1810 | 2 | 2 | 2 | 2 | 2 | 191 | 681 | 904 | 307 | 5 | 4 | 0 | 14 |
| example_04_sbends_and_radii | 2.0151 | 2 | 2 | 2 | 1 | 2 | 15 | 18218 | 123 | 65 | 4 | 3 | 0 | 6 |
| example_05_orientation_stress | 0.2438 | 3 | 3 | 3 | 2 | 6 | 286 | 1243 | 1624 | 681 | 12 | 3 | 412 | 12 |
| example_06_bend_collision_models | 4.1636 | 3 | 3 | 3 | 3 | 3 | 240 | 40530 | 1026 | 629 | 6 | 6 | 0 | 9 |
| example_07_large_scale_routing | 1.3759 | 10 | 10 | 10 | 1 | 10 | 78 | 11151 | 372 | 227 | 20 | 11 | 0 | 30 |
| example_08_custom_bend_geometry | 0.2437 | 2 | 2 | 2 | 2 | 2 | 18 | 2308 | 78 | 56 | 4 | 4 | 0 | 6 |
| example_09_unroutable_best_effort | 0.0052 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 1 | 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.
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, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_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_builds, visibility_corner_pairs_checked, visibility_corner_queries, visibility_corner_hits, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, ray_cast_calls, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_exact_pair_checks

View file

@ -0,0 +1,474 @@
{
"generated_on": "2026-03-31",
"generator": "scripts/record_performance_baseline.py",
"scenarios": [
{
"duration_s": 0.0041740520391613245,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 2,
"dynamic_path_objects_removed": 1,
"dynamic_tree_rebuilds": 2,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 1,
"move_cache_abs_misses": 10,
"move_cache_rel_hits": 0,
"move_cache_rel_misses": 10,
"moves_added": 7,
"moves_generated": 11,
"nets_reached_target": 1,
"nets_routed": 1,
"nodes_expanded": 2,
"pruned_closed_set": 0,
"pruned_cost": 4,
"pruned_hard_collision": 0,
"ray_cast_calls": 22,
"ray_cast_candidate_bounds": 12,
"ray_cast_exact_geometry_checks": 0,
"refine_path_calls": 1,
"route_iterations": 1,
"static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1,
"static_tree_rebuilds": 1,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 3,
"verify_static_buffer_ops": 0,
"visibility_builds": 2,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 12,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 1,
"warm_start_paths_used": 1
},
"name": "example_01_simple_route",
"reached_targets": 1,
"total_results": 1,
"valid_results": 1
},
{
"duration_s": 0.3335385399404913,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 32,
"dynamic_path_objects_removed": 17,
"dynamic_tree_rebuilds": 8,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 12,
"move_cache_abs_misses": 1401,
"move_cache_rel_hits": 1293,
"move_cache_rel_misses": 108,
"moves_added": 668,
"moves_generated": 1413,
"nets_reached_target": 3,
"nets_routed": 3,
"nodes_expanded": 366,
"pruned_closed_set": 157,
"pruned_cost": 208,
"pruned_hard_collision": 380,
"ray_cast_calls": 1176,
"ray_cast_candidate_bounds": 925,
"ray_cast_exact_geometry_checks": 136,
"refine_path_calls": 3,
"route_iterations": 1,
"static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1,
"static_tree_rebuilds": 2,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 90,
"verify_path_report_calls": 35,
"verify_static_buffer_ops": 0,
"visibility_builds": 4,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 12,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 3,
"warm_start_paths_used": 3
},
"name": "example_02_congestion_resolution",
"reached_targets": 3,
"total_results": 3,
"valid_results": 3
},
{
"duration_s": 0.1809853739105165,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 17,
"dynamic_path_objects_removed": 10,
"dynamic_tree_rebuilds": 5,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 1,
"move_cache_abs_misses": 903,
"move_cache_rel_hits": 821,
"move_cache_rel_misses": 82,
"moves_added": 307,
"moves_generated": 904,
"nets_reached_target": 2,
"nets_routed": 2,
"nodes_expanded": 191,
"pruned_closed_set": 97,
"pruned_cost": 140,
"pruned_hard_collision": 181,
"ray_cast_calls": 681,
"ray_cast_candidate_bounds": 179,
"ray_cast_exact_geometry_checks": 0,
"refine_path_calls": 2,
"route_iterations": 2,
"static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 1,
"static_tree_rebuilds": 2,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 14,
"verify_static_buffer_ops": 69,
"visibility_builds": 4,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 24,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 2,
"warm_start_paths_used": 2
},
"name": "example_03_locked_paths",
"reached_targets": 2,
"total_results": 2,
"valid_results": 2
},
{
"duration_s": 2.0151148419827223,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 14,
"dynamic_path_objects_removed": 7,
"dynamic_tree_rebuilds": 4,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 1,
"move_cache_abs_misses": 122,
"move_cache_rel_hits": 80,
"move_cache_rel_misses": 42,
"moves_added": 65,
"moves_generated": 123,
"nets_reached_target": 2,
"nets_routed": 2,
"nodes_expanded": 15,
"pruned_closed_set": 2,
"pruned_cost": 25,
"pruned_hard_collision": 16,
"ray_cast_calls": 18218,
"ray_cast_candidate_bounds": 50717,
"ray_cast_exact_geometry_checks": 21265,
"refine_path_calls": 2,
"route_iterations": 1,
"static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1,
"static_tree_rebuilds": 2,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 6,
"verify_static_buffer_ops": 0,
"visibility_builds": 3,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 18148,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 2,
"warm_start_paths_used": 2
},
"name": "example_04_sbends_and_radii",
"reached_targets": 2,
"total_results": 2,
"valid_results": 2
},
{
"duration_s": 0.2437819039914757,
"metrics": {
"congestion_cache_hits": 2,
"congestion_cache_misses": 412,
"congestion_check_calls": 412,
"congestion_exact_pair_checks": 66,
"dynamic_grid_rebuilds": 3,
"dynamic_path_objects_added": 37,
"dynamic_path_objects_removed": 25,
"dynamic_tree_rebuilds": 12,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 253,
"move_cache_abs_misses": 1371,
"move_cache_rel_hits": 1269,
"move_cache_rel_misses": 102,
"moves_added": 681,
"moves_generated": 1624,
"nets_reached_target": 6,
"nets_routed": 6,
"nodes_expanded": 286,
"pruned_closed_set": 139,
"pruned_cost": 505,
"pruned_hard_collision": 14,
"ray_cast_calls": 1243,
"ray_cast_candidate_bounds": 45,
"ray_cast_exact_geometry_checks": 43,
"refine_path_calls": 3,
"route_iterations": 2,
"static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 3,
"static_tree_rebuilds": 1,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 2,
"verify_path_report_calls": 12,
"verify_static_buffer_ops": 0,
"visibility_builds": 3,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 0,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 2,
"warm_start_paths_used": 2
},
"name": "example_05_orientation_stress",
"reached_targets": 3,
"total_results": 3,
"valid_results": 3
},
{
"duration_s": 4.163613382959738,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 36,
"dynamic_path_objects_removed": 18,
"dynamic_tree_rebuilds": 6,
"hard_collision_cache_hits": 18,
"move_cache_abs_hits": 186,
"move_cache_abs_misses": 840,
"move_cache_rel_hits": 702,
"move_cache_rel_misses": 138,
"moves_added": 629,
"moves_generated": 1026,
"nets_reached_target": 3,
"nets_routed": 3,
"nodes_expanded": 240,
"pruned_closed_set": 108,
"pruned_cost": 204,
"pruned_hard_collision": 85,
"ray_cast_calls": 40530,
"ray_cast_candidate_bounds": 121732,
"ray_cast_exact_geometry_checks": 36858,
"refine_path_calls": 3,
"route_iterations": 3,
"static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 3,
"static_safe_cache_hits": 141,
"static_tree_rebuilds": 6,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 9,
"verify_static_buffer_ops": 54,
"visibility_builds": 6,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 39848,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 3,
"warm_start_paths_used": 3
},
"name": "example_06_bend_collision_models",
"reached_targets": 3,
"total_results": 3,
"valid_results": 3
},
{
"duration_s": 1.375933071016334,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 88,
"dynamic_path_objects_removed": 44,
"dynamic_tree_rebuilds": 20,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 6,
"move_cache_abs_misses": 366,
"move_cache_rel_hits": 275,
"move_cache_rel_misses": 91,
"moves_added": 227,
"moves_generated": 372,
"nets_reached_target": 10,
"nets_routed": 10,
"nodes_expanded": 78,
"pruned_closed_set": 20,
"pruned_cost": 64,
"pruned_hard_collision": 61,
"ray_cast_calls": 11151,
"ray_cast_candidate_bounds": 21198,
"ray_cast_exact_geometry_checks": 11651,
"refine_path_calls": 10,
"route_iterations": 1,
"static_net_tree_rebuilds": 10,
"static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 6,
"static_tree_rebuilds": 10,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 30,
"verify_static_buffer_ops": 132,
"visibility_builds": 11,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 10768,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 10,
"warm_start_paths_used": 10
},
"name": "example_07_large_scale_routing",
"reached_targets": 10,
"total_results": 10,
"valid_results": 10
},
{
"duration_s": 0.2436628290452063,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 12,
"dynamic_path_objects_removed": 6,
"dynamic_tree_rebuilds": 4,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 2,
"move_cache_abs_misses": 76,
"move_cache_rel_hits": 32,
"move_cache_rel_misses": 44,
"moves_added": 56,
"moves_generated": 78,
"nets_reached_target": 2,
"nets_routed": 2,
"nodes_expanded": 18,
"pruned_closed_set": 6,
"pruned_cost": 16,
"pruned_hard_collision": 0,
"ray_cast_calls": 2308,
"ray_cast_candidate_bounds": 3802,
"ray_cast_exact_geometry_checks": 1904,
"refine_path_calls": 2,
"route_iterations": 2,
"static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 2,
"static_tree_rebuilds": 2,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 6,
"verify_static_buffer_ops": 0,
"visibility_builds": 4,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 2252,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 2,
"warm_start_paths_used": 2
},
"name": "example_08_custom_bend_geometry",
"reached_targets": 2,
"total_results": 2,
"valid_results": 2
},
{
"duration_s": 0.0052433289820328355,
"metrics": {
"congestion_cache_hits": 0,
"congestion_cache_misses": 0,
"congestion_check_calls": 0,
"congestion_exact_pair_checks": 0,
"dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 1,
"dynamic_path_objects_removed": 0,
"dynamic_tree_rebuilds": 1,
"hard_collision_cache_hits": 0,
"move_cache_abs_hits": 0,
"move_cache_abs_misses": 16,
"move_cache_rel_hits": 2,
"move_cache_rel_misses": 14,
"moves_added": 10,
"moves_generated": 16,
"nets_reached_target": 0,
"nets_routed": 1,
"nodes_expanded": 3,
"pruned_closed_set": 0,
"pruned_cost": 4,
"pruned_hard_collision": 2,
"ray_cast_calls": 13,
"ray_cast_candidate_bounds": 5,
"ray_cast_exact_geometry_checks": 0,
"refine_path_calls": 0,
"route_iterations": 1,
"static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 0,
"static_tree_rebuilds": 0,
"timeout_events": 0,
"verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 1,
"verify_static_buffer_ops": 1,
"visibility_builds": 0,
"visibility_corner_hits": 0,
"visibility_corner_pairs_checked": 0,
"visibility_corner_queries": 0,
"visibility_point_cache_hits": 0,
"visibility_point_cache_misses": 0,
"visibility_point_queries": 0,
"warm_start_paths_built": 0,
"warm_start_paths_used": 0
},
"name": "example_09_unroutable_best_effort",
"reached_targets": 0,
"total_results": 1,
"valid_results": 0
}
]
}

View file

@ -1,42 +0,0 @@
# Cost and Collision Engine Spec
This document describes the methods for ensuring "analytic correctness" while maintaining a computationally efficient cost function.
## 1. Analytic Correctness
The router balances speed and verification using a two-tier approach.
### 1.1. R-Tree Geometry Engine
The router uses an **R-Tree of Polygons** for all geometric queries.
* **Move Validation:** Every "Move" proposed by the search is checked for intersection against the R-Tree.
* **Pre-dilation for Clearance:** All obstacles and paths use a global clearance $C$. At the start of the session, all user-provided obstacles are **pre-dilated by $(W_{max} + C)/2$** for initial broad pruning. However, individual path dilation for intersection tests uses $(W_i + C)/2$ for a specific net's width $W_i$.
* **Safety Zone:** To prevent immediate collision flags for ports placed on or near obstacle boundaries, the router ignores collisions within a radius of **2nm** of start and end ports.
* **Single Layer:** All routing and collision detection occur on a single layer.
### 1.2. Cost Calculation (Soft Constraint)
The "Danger Cost" $g_{proximity}(n)$ is a function of the distance $d$ to the nearest obstacle:
$$g_{proximity}(n) = \begin{cases} \infty & \text{if } d < (W_i + C)/2 \\ \frac{k}{d^2} & \text{if } (W_i + C)/2 \le d < \text{Safety Threshold} \\ 0 & \text{if } d \ge \text{Safety Threshold} \end{cases}$$
To optimize A* search, a **Static Danger Map (Precomputed Grid)** is used for the heuristic.
* **Grid Resolution:** Default **1000nm (1µm)**.
* **Static Nature:** The grid only accounts for fixed obstacles. It is computed once at the start of the session and is **not re-computed** during the Negotiated Congestion loop.
* **Efficiency:** For a 20x20mm layout, this results in a 20k x 20k matrix.
* **Memory:** Using a `uint8` or `float16` representation, this consumes ~400-800MB (Default < 2GB). For extremely high resolution or larger areas, the system supports up to **20GB** allocation.
* **Precision:** Strict intersection checks still use the R-Tree for "analytic correctness."
## 2. Collision Detection Implementation
The system relies on `shapely` for geometry and `rtree` for spatial indexing.
1. **Arc Resolution:** Arcs are represented as polygons approximated by segments with a maximum deviation (sagitta).
2. **Intersection Test:** A "Move" is valid only if its geometry does not intersect any obstacle in the R-Tree.
3. **Self-Intersection:** Paths from the same net must not intersect themselves.
4. **No Crossings:** Strictly 2D; no crossings (vias or bridges) are supported.
## 3. Negotiated Congestion (Path R-Tree)
To handle multiple nets, the router maintains a separate R-Tree containing the dilated geometries ($C/2$) of all currently routed paths.
* **Congestion Cost:** $P \times (\text{Overlaps in Path R-Tree})$.
* **Failure Policy:** If no collision-free path is found after the max iterations, the router returns the **"least-bad" (lowest cost)** path. These paths MUST be explicitly flagged as invalid (e.g., via an `is_valid=False` attribute or a non-zero `collision_count`) so the user can identify and manually fix the failure.
## 4. Handling Global Clearances
Clearances are global. Both obstacles and paths are pre-dilated once. This ensures that any two objects maintain at least $C$ distance if their dilated versions do not intersect.
## 5. Locked Paths
The router supports **Locked Paths**—existing geometries inserted into the static Obstacle R-Tree, ensuring they are never modified or rerouted.

View file

@ -1,37 +0,0 @@
# Geometric Representation and Data Structures
This document defines the core data structures for representing the routing problem.
## 1. Port Definitions (Connectivity)
Routing requests are defined as a mapping of source ports to destination ports.
* **Port Structure:** `(x, y, orientation)`
* `x, y`: Coordinates snapped to **1nm** grid.
* `orientation`: Strictly $\{0, 90, 180, 270\}$ degrees.
* **Netlist:** A dictionary or list of tuples: `{(start_port): (end_port)}`. Each entry represents a single-layer path that must be routed.
* **Heterogeneous Widths:** The router supports different widths $W_i$ per net. Dilation for a specific net $i$ is calculated as $(W_i + C)/2$ to maintain the global clearance $C$.
## 2. Component Library (Move Generator)
The router uses a discrete set of components to expand states in the A* search.
### 2.1. Straight Waveguides
* **A* Expansion:** Generates "Straight" moves of varying lengths (e.g., $1\mu m, 5\mu m, 25\mu m$).
* **Snap-to-Target:** If the destination port $T$ is directly ahead and the current state's orientation matches $T$'s entry orientation, a special move is generated to close the gap exactly.
### 2.2. Fixed 90° Bends (PDK Cells)
* **Parameters:** `radius`, `width`.
* **A* Expansion:** A discrete move that changes orientation by $\pm 90^\circ$ and shifts the coordinate by the radius.
* **Grid Alignment:** If the bend radius $R$ is not a multiple of the search grid (default $1\mu m$), the resulting state is **snapped to the nearest grid point**, and a warning is issued to the user.
### 2.3. Parametric S-Bends (Compact)
* **Parameters:** `radius`, `width`.
* **A* Expansion:** Used ONLY for lateral offsets $O < 2R$.
* **Large Offsets ($O \ge 2R$):** The router does not use a single S-bend move for large offsets. Instead, the A* search naturally finds the optimal path by combining two 90° bends and a straight segment. This ensures maximum flexibility in obstacle avoidance for large shifts.
## 3. Obstacle Representation
Obstacles are provided as raw polygons on a single layer.
* **Pre-processing:** All input polygons are inserted into an **R-Tree**.
* **Buffer/Dilation:** Obstacles are pre-dilated by $(W_{max} + Clearance)/2$ for initial pruning, but final collision tests use the net-specific width $W_i$.
* **No Multi-layer:** The router assumes all obstacles and paths share the same plane.
* **Safety Zone:** Ignore collisions within **2nm** of start and end ports for robustness.

View file

@ -1,13 +0,0 @@
# High-Level Notes: Auto-Routing for Integrated Circuits
We're implementing auto-routing for photonic and RF integrated circuits. The problem space has the following features:
- **Single Layer:** All paths are on a single layer; multi-layer routing is not supported.
- **No Crossings:** Crossings are to be avoided and are not supported by the router's automatic placement. The user must manually handle any required crossings.
- **Large Bend Radii:** Bends use large radii ($R$), and are usually pre-generated (e.g. 90-degree cells).
- **Proximity Sensitivity:** Paths are sensitive to proximity to other paths and obstacles (crosstalk/coupling).
- **Manhattan Preference:** Manhattan pathing is sufficient for most cases; any-angle is rare.
- **S-Bends:** S-bends are necessary for small lateral offsets ($O < 2R$).
- **Hybrid Search:** A* state-lattice search is used for discrete component placement.
- **Dilation:** A $Clearance/2$ dilation is applied to all obstacles and paths for efficient collision avoidance.
- **Negotiated Congestion:** A multi-net "PathFinder" loop iteratively reroutes nets to resolve congestion.

View file

@ -1,64 +0,0 @@
# Implementation Plan
This plan outlines the step-by-step implementation of the `inire` auto-router. For detailed test cases, refer to [Testing Plan](./testing_plan.md).
## Phase 1: Core Geometry & Move Generation
**Goal:** Implement Ports, Polygons, and Component Library with high geometric fidelity.
1. **Project Setup:** Initialize `inire/` structure and `pytest` configuration. Include `hypothesis` for property-based testing.
2. **`geometry.primitives`:**
* `Port` with **1nm** snapping.
* Basic 2D transformations (rotate, translate).
* **Property-Based Tests:** Verify transform invariants (e.g., $90^\circ$ rotation cycles).
3. **`geometry.components`:**
* `Straight`, `Bend90`, `SBend`.
* **Search Grid Snapping:** Implement 1µm snapping for expanded ports.
* **Small S-Bends ($O < 2R$):** Logic for parametric generation.
* **Edge Cases:** Handle $O=2R$ and $L < 1\mu m$.
4. **Tests:**
* Verify geometric correctness (refer to Testing Plan Section 1).
* Unit tests for `Port` snapping and component transformations.
## Phase 2: Collision Engine & Cost
**Goal:** Build the R-Tree wrapper and the analytic cost function.
1. **`geometry.collision`:** Implement `CollisionEngine`.
* **Pre-dilation:** Obstacles/Paths dilated by $Clearance/2$.
* **Safety Zone:** Ignore collisions within **2nm** of start/end ports.
2. **`router.danger_map`:**
* Implement **1µm** pre-computed proximity grid.
* Optimize for design sizes up to **20x20mm** (< 2GB memory).
3. **`router.cost`:** Implement `CostEvaluator`.
* Bend cost: $10 \times (\text{Manhattan distance between ports})$.
* Integrate R-Tree for strict checks and Danger Map for heuristic.
4. **Tests:**
* Verify collision detection with simple overlapping shapes (Testing Plan Section 2.1).
* Verify Danger Map accuracy and memory footprint (Testing Plan Section 2.2).
* **Post-Route Validator:** Implement the independent `validate_path` utility.
## Phase 3: Single-Net A* Search
**Goal:** Route a single net from A to B with 1nm precision.
1. **`router.astar`:** Implement the priority queue loop.
* State representation: `(x_µm, y_µm, theta)`.
* Move expansion loop with 1µm grid.
* **Natural S-Bends:** Ensure search can find $O \ge 2R$ shifts by combining moves.
* **Look-ahead Snapping:** Actively bridge to the 1nm target when in the capture radius (10µm).
2. **Heuristic:** Manhattan distance $h(n)$ + orientation penalty + Danger Map lookup.
3. **Tests:**
* Solve simple maze problems and verify path optimality (Testing Plan Section 3).
* Verify snap-to-target precision at 1nm resolution.
* **Determinism:** Verify same seed = same path.
## Phase 4: Multi-Net PathFinder
**Goal:** Implement the "Negotiated Congestion" loop for multiple nets.
1. **`router.pathfinder`:**
* Sequential routing -> Identify congestion -> Inflate cost -> Reroute.
* **R-Tree Congestion:** Store dilated path geometries.
2. **Explicit Results:** Return `RoutingResult` objects with `is_valid` and `collisions` metadata.
3. **Tests:**
* Full multi-net benchmarks (Testing Plan Section 4).
* Verify rerouting behavior in crowded environments.
## Phase 5: Visualization, Benchmarking & Fuzzing
1. **`utils.visualization`:** Plot paths using `matplotlib`. Highlight collisions in red.
2. **Benchmarks:** Stress test with 50+ nets. Verify performance and node limits (Testing Plan Section 5).
3. **Fuzzing:** Run A* on randomized layouts to ensure stability.
4. **Final Validation:** Ensure all `is_valid=True` results pass the independent `validate_path` check.

View file

@ -1,57 +0,0 @@
# Python Package Structure
This document outlines the directory structure and module organization for the `inire` auto-router package.
## 1. Directory Layout
```
inire/
├── __init__.py # Exposes the main `Router` class and key types
├── geometry/ # Core geometric primitives and operations
│ ├── __init__.py
│ ├── primitives.py # Point, Port, Polygon, Arc classes
│ ├── collision.py # R-Tree wrapper and intersection logic
│ └── components.py # Move generators (Straight, Bend90, SBend)
├── router/ # Search algorithms and pathfinding
│ ├── __init__.py
│ ├── astar.py # Hybrid State-Lattice A* implementation
│ ├── graph.py # Node, Edge, and Graph data structures
│ ├── cost.py # Cost functions (length, bend, proximity)
│ ├── danger_map.py # Pre-computed grid for heuristic proximity costs
│ └── pathfinder.py # Multi-net "Negotiated Congestion" manager
├── utils/ # Utility functions
│ ├── __init__.py
│ └── visualization.py # Plotting tools for debug/heatmaps (matplotlib/klayout)
└── tests/ # Unit and integration tests
├── __init__.py
├── conftest.py # Pytest fixtures (common shapes, PDK cells)
├── test_primitives.py # Tests for Port and coordinate transforms
├── test_components.py # Tests for Straight, Bend90, SBend generation
├── test_collision.py # Tests for R-Tree and dilation logic
├── test_cost.py # Tests for Danger Map and cost evaluation
├── test_astar.py # Tests for single-net routing (mazes, snapping)
└── test_pathfinder.py # Multi-net "Negotiated Congestion" benchmarks
```
## 2. Module Responsibilities
### `inire.geometry`
* **`primitives.py`**: Defines the `Port` named tuple `(x, y, theta)` and helper functions for coordinate transforms.
* **`collision.py`**: Wraps the `rtree` or `shapely` library. Handles the "Analytic Correctness" checks (exact polygon distance).
* **`components.py`**: Logic to generate "Moves" from a start port. E.g., `SBend.generate(start_port, offset, radius)` returns a list of polygons and the end port. Handles $O > 2R$ logic.
### `inire.router`
* **`astar.py`**: The heavy lifter. Maintains the `OpenSet` (priority queue) and `ClosedSet`. Implements the "Snap-to-Target" logic.
* **`cost.py`**: compute $f(n) = g(n) + h(n)$. encapsulates the "Danger Map" and Path R-Tree lookups.
* **`danger_map.py`**: Manages the pre-computed proximity grid used for $O(1)$ heuristic calculations.
* **`pathfinder.py`**: Orchestrates the multi-net loop. Tracks the Path R-Tree for negotiated congestion and triggers reroutes.
### `inire.tests`
* **Structure:** Tests are co-located within the package for ease of access.
* **Fixtures:** `conftest.py` will provide standard PDK cells (e.g., a $10\mu m$ radius bend) to avoid repetition in test cases.
## 3. Dependencies
* `numpy`: Vector math.
* `shapely`: Polygon geometry and intersection.
* `rtree`: Spatial indexing.
* `networkx` (Optional): Not used for core search to ensure performance.

View file

@ -1,67 +0,0 @@
# Architecture Decision Record: Auto-Routing for Photonic & RF ICs
## 1. Problem Context
Photonic and RF routing differ significantly from digital VLSI due to physical constraints:
* **Geometric Rigidity:** 90° bends are pre-rendered PDK cells with fixed bounding boxes.
* **Parametric Flexibility:** S-bends must be generated on-the-fly to handle arbitrary offsets, provided they maintain a constant radius $R$.
* **Signal Integrity:** High sensitivity to proximity (coupling/crosstalk) and a strong preference for single-layer, non-crossing paths.
* **Manual Intervention:** The router is strictly 2D and avoids all other geometries on the same layer. No crossings (e.g. vias or bridges) are supported by the automatic routing engine. The user must manually handle any required crossings by placing components (e.g. crossing cells) and splitting the net list accordingly. This simplifies the router's task to 2D obstacle avoidance and spacing optimization.
---
## 2. Candidate Algorithms & Tradeoffs
### 2.1. Rubber-Band (Topological) Routing
This approach treats paths as elastic bands stretched around obstacles, later "inflating" them to have width and curvature.
| Feature | Analysis |
| :--- | :--- |
| **Strengths** | Excellent at "River Routing" and maintaining minimum clearances. Inherently avoids crossings. |
| **Downsides** | **The Inflation Gap:** A valid thin-line topology may be physically un-routable once the large radius $R$ is applied. It struggles to integrate rigid, pre-rendered 90° blocks into a continuous elastic model. |
| **Future Potential** | High, if a "Post-Processing" engine can reliably snap elastic curves to discrete PDK cells without breaking connectivity. |
### 2.2. Voronoi-Based (Medial Axis) Routing
Uses a Voronoi diagram to find paths that are maximally distant from all obstacles.
| Feature | Analysis |
| :--- | :--- |
| **Strengths** | Theoretically optimal for "Safety" and crosstalk reduction. Guaranteed maximum clearance. |
| **Downsides** | **Manhattan Incompatibility:** Voronoi edges are any-angle and often jagged. Mapping these to a Manhattan-heavy PDK (90° bends) requires a lossy "snapping" phase that often violates the very safety the algorithm intended to provide. |
| **Future Potential** | Useful as a "Channel Finder" to guide a more rigid router, but unsuitable as a standalone geometric solver. |
### 2.3. Integer Linear Programming (ILP)
Formulates routing as a massive optimization problem where a solver picks the best path from a pool of candidates.
| Feature | Analysis |
| :--- | :--- |
| **Strengths** | Can find the mathematically "best" layout (e.g., minimum total length or total bends) across all nets simultaneously. |
| **Downsides** | **Candidate Explosion:** Because S-bends are generated on-the-fly, the number of possible candidate shapes is infinite. To use ILP, one must "discretize" the search space, which may miss the one specific geometry needed for a tight fit. |
| **Future Potential** | Effective for small, high-congestion "Switchbox" areas where all possible geometries can be pre-tabulated. |
---
## 3. Selected Approach: Hybrid State-Lattice A*
### 3.1. Rationale
The **State-Lattice** variant of the A* algorithm is selected as the primary routing engine. Unlike standard A* which moves between grid cells, State-Lattice moves between **states** defined as $(x, y, \theta)$.
1. **Native PDK Integration:** The router treats the pre-rendered 90° bend cell as a discrete "Move" in the search tree. The algorithm only considers placing a bend if the cell's bounding box is clear of obstacles.
2. **Parametric S-Bends:** When the search needs to bridge a lateral gap, it triggers a "Procedural Move." It calculates a fixed-radius S-bend on-the-fly. If the resulting arc is valid and collision-free, it is added as an edge in the search graph.
3. **Predictable Costing:** It allows for a sophisticated cost function $f(n) = g(n) + h(n)$ where:
* $g(n)$ penalizes path length and proximity to obstacles (using a distance-transform field).
* $h(n)$ (the heuristic) guides the search toward the destination while favoring Manhattan alignments.
### 3.2. Implementation Strategy
* **Step 1: Distance Transform.** Pre-calculate a "Danger Map" of the layout. Cells close to obstacles have a high cost; cells far away have low cost. This handles the **Proximity Sensitivity** constraint.
* **Step 2: State Expansion.** From the current point, explore:
* `Straight(length)`
* `PDK_Bend_90(direction)`
* `S_Bend(target_offset, R)`
* **Step 3: Rip-up and Reroute.** To handle the sequential nature of A*, implement a "Negotiated Congestion" scheme (PathFinder algorithm) where nets "pay" more to occupy areas that other nets also want.
---
## 4. Summary of Tradeoffs for Future Review
* **Why not pure Manhattan?** Photonic/RF requirements for large $R$ and S-bends make standard grid-based maze routers obsolete.
* **Why not any-angle?** While any-angle is possible, the PDK's reliance on pre-rendered 90° blocks makes a lattice-based approach more manufacturing-stable.
* **Risk:** The primary risk is **Search Time**. As the library of moves grows (more S-bend options), the branching factor increases. This must be managed with aggressive pruning and spatial indexing (e.g., R-trees).

View file

@ -1,66 +0,0 @@
# Routing Search Specification
This document details the Hybrid State-Lattice A* implementation and the "PathFinder" (Negotiated Congestion) algorithm for multi-net routing.
## 1. Hybrid State-Lattice A* Search
The core router operates on states $S = (x, y, \theta)$, where $\theta \in \{0, 90, 180, 270\}$.
### 1.1. State Representation & Snapping
To ensure search stability and hash-map performance:
* **Intermediate Ports:** Every state expanded by A* is snapped to a search grid.
* **Search Grid:** Default snap is **1000nm (1µm)**.
* **Final Alignment:** The "Snap-to-Target" logic bridges the gap from the coarse search grid to the final **1nm** port coordinates.
* **Max Design Size:** Guidelines for memory/performance assume up to **20mm x 20mm** routing area.
### 1.2. Move Expansion Logic
From state $S_n$, the following moves are evaluated:
1. **Tiered Straight Steps:** Expand by a set of distances $L \in \{1\mu m, 5\mu m, 25\mu m\}$.
2. **Snap-to-Target:** A "last-inch" look-ahead move. If the target $T$ is within a **Capture Radius (Default: 10µm)** and a straight segment or single bend can reach it, a special move is generated to close the gap exactly at 1nm resolution.
3. **90° Bend:** Try clockwise/counter-clockwise turns using fixed PDK cells.
4. **Small-Offset S-Bend:**
* **Only for $O < 2R$:** Parametric S-bend (two tangent arcs).
* **$O \ge 2R$:** Search naturally finds these by combining 90° bends and straight segments.
### 1.3. Cost Function $f(n) = g(n) + h(n)$
The search uses a flexible, component-based cost model.
* **$g(n)$ (Actual Cost):** $\sum \text{ComponentCost}_i + \text{ProximityCost} + \text{CongestionCost}$
* **Straight Segment:** $L \times C_{unit\_length}$.
* **90° Bend:** $10 \times (\text{Manhattan distance between ports})$.
* **S-Bend:** $f(O, R)$.
* **Proximity Cost:** $k/d^2$ penalty (strict checks use R-Tree).
* **Congestion Cost:** $P \times (\text{Overlaps in Path R-Tree})$.
* **$h(n)$ (Heuristic):**
* Manhattan distance $L_1$ to the target.
* Orientation Penalty: High cost if the state's orientation doesn't match the target's entry orientation.
* **Greedy Weighting:** The A* search uses a weighted heuristic (e.g., $1.1 \times h(n)$) to prioritize search speed over strict path optimality.
* **Danger Map Heuristic:** Fast lookups from the **1µm** pre-computed proximity grid.
## 2. Multi-Net "PathFinder" Strategy (Negotiated Congestion)
1. **Iteration:** Identify "Congestion Areas" using Path R-Tree intersections.
2. **Inflation:** Increase penalty multiplier $P$ for congested areas.
3. **Repeat:** Continue until no overlaps exist or the max iteration count is reached (Default: **20 iterations**).
### 2.1. Convergence & Result Policy
* **Least Bad Attempt:** If no 100% collision-free solution is found, return the result with the lowest total cost (including overlaps).
* **Explicit Reporting:** Results MUST include a `RoutingResult` object containing:
* `path_geometry`: The actual polygon sequence.
* `is_valid`: Boolean (True only if no collisions).
* `collisions`: A count or list of detected overlap points/polygons.
* **Visualization:** Overlapping regions are highlighted (e.g., in red) in the heatmaps.
## 3. Search Limits & Scaling
* **Node Limit:** A* search is capped at **50,000 nodes** per net per iteration.
* **Dynamic Timeout:** Session-level timeout based on problem size:
* `Timeout = max(2s, 0.05s * num_nets * num_iterations)`.
* *Example:* A 100-net problem over 20 iterations times out at **100 seconds**.
## 4. Determinism
All search and rip-up operations are strictly deterministic.
* **Seed:** A user-provided `seed` (int) MUST be used to initialize any random number generators (e.g., if used for tie-breaking or initial net ordering).
* **Tie-Breaking:** If two nodes have the same $f(n)$, a consistent tie-breaking rule (e.g., based on node insertion order or state hash) must be used.
## 5. Optimizations
* **A* Pruning:** Head toward the target and prune suboptimal orientations.
* **Safety Zones:** Ignore collisions within **2nm** of start/end ports.
* **Spatial Indexing:** R-Tree queries are limited to the bounding box of the current move.

View file

@ -1,138 +0,0 @@
# Testing Plan (Refined)
This document defines the comprehensive testing strategy for the `inire` auto-router, ensuring analytic correctness and performance.
## 1. Unit Tests: Geometry & Components (`inire/geometry`)
### 1.1. Primitives (`primitives.py`)
* **Port Snapping:** Verify that `Port(x, y, orientation)` snaps `x` and `y` to the nearest **1nm** grid point.
* **Coordinate Transforms:**
* Translate a port by `(dx, dy)`.
* Rotate a port by `90`, `180`, `270` degrees around an origin.
* Verify orientation wrapping (e.g., $270^\circ + 90^\circ \to 0^\circ$).
* **Polygon Creation:**
* Generate a polygon from a list of points.
* Verify bounding box calculation.
* **Property-Based Testing (Hypothesis):**
* Verify that any `Port` after transformation remains snapped to the **1nm** grid.
* Verify that $Rotate(Rotate(Port, 90), -90)$ returns the original `Port` (up to snapping).
### 1.2. Component Generation (`components.py`)
* **Straight Moves:**
* Generate a `Straight(length=10.0, width=2.0)` segment.
* Verify the end port is exactly `length` away in the correct orientation.
* Verify the resulting polygon's dimensions.
* **Edge Case:** $L < 1\mu m$ (below search grid). Verify it still generates a valid 1nm segment.
* **Bend90:**
* Generate a `Bend90(radius=10.0, width=2.0, direction='CW')`.
* Verify the end port's orientation is changed by $-90^\circ$.
* Verify the end port's position is exactly `(R, -R)` relative to the start (for $0^\circ \to 270^\circ$).
* **Grid Snapping:** Verify that if the bend radius results in a non-1µm aligned port, it is snapped to the nearest **1µm** search grid point (with a warning).
* **SBend (Small Offset $O < 2R$):**
* Generate an `SBend(offset=5.0, radius=10.0, width=2.0)`.
* Verify the total length matches the analytical $L = 2\sqrt{O(2R - O/4)}$ (or equivalent arc-based formula).
* Verify the tangent continuity at the junction of the two arcs.
* **Edge Case:** $O = 2R$. Verify it either generates two 90-degree bends or fails gracefully with a clear error.
* Verify it fails/warns if $O > 2R$.
### 1.3. Geometric Fidelity
* **Arc Resolution (Sagitta):**
* Verify that `Bend90` and `SBend` polygons are approximated by segments such that the maximum deviation (sagitta) is within a user-defined tolerance (e.g., 10nm).
* Test with varying radii to ensure segment count scales appropriately.
## 2. Unit Tests: Collision & Cost (`inire/geometry/collision` & `router/cost`)
### 2.1. Collision Engine
* **Pre-dilation Logic:**
* Verify that an obstacle (polygon) is correctly dilated by $(W_{max} + C)/2$.
* **Heterogeneous Widths:** Verify that a path for Net A (width $W_1$) is dilated by $(W_1 + C)/2$, while Net B (width $W_2$) uses $(W_2 + C)/2$.
* **Locked Paths:**
* Insert an existing path geometry into the "Static Obstacle" R-Tree.
* Verify that the router treats it as an unmovable obstacle and avoids it.
* **R-Tree Queries:**
* Test intersection detection between two overlapping polygons.
* Test non-intersection between adjacent but non-overlapping polygons (exactly $C$ distance apart).
* **Safety Zone (2nm):**
* Create a port exactly on the edge of an obstacle.
* Verify that a "Move" starting from this port is NOT flagged for collision if the intersection occurs within **2nm** of the port.
* **Self-Intersection:**
* Verify that a path consisting of multiple segments is flagged if it loops back on itself.
### 2.2. Danger Map & Cost Evaluator
* **Danger Map Generation:**
* Initialize a map for a $100\mu m \times 100\mu m$ area with a single obstacle.
* Verify the cost $g_{proximity}$ matches $k/d^2$ for cells near the obstacle.
* Verify cost is $0$ for cells beyond the **Safety Threshold**.
* **Memory Check:**
* Mock a $20mm \times 20mm$ grid and verify memory allocation stays within limits (e.g., `< 2GB` for standard `uint8` resolution).
* **Cost Calculation:**
* Verify total cost $f(n)$ correctly sums length, bend penalties ($10 \times$ Manhattan), and proximity costs.
### 2.3. Robustness & Limits
* **Design Bounds:**
* Test routing at the extreme edges of the $20mm \times 20mm$ coordinate space.
* Verify that moves extending outside the design bounds are correctly pruned or flagged.
* **Empty/Invalid Inputs:**
* Test with an empty netlist.
* Test with start and end ports at the exact same location.
## 3. Integration Tests: Single-Net A* Search (`inire/router/astar`)
### 3.1. Open Space Scenarios
* **Straight Line:** Route from `(0,0,0)` to `(100,0,0)`. Verify it uses only `Straight` moves.
* **Simple Turn:** Route from `(0,0,0)` to `(20,20,90)`. Verify it uses a `Bend90` and `Straight` segments.
* **Small S-Bend:** Route with an offset of $5\mu m$ and radius $10\mu m$. Verify it uses the `SBend` component.
* **Large S-Bend ($O \ge 2R$):** Route with an offset of $50\mu m$ and radius $10\mu m$. Verify it combines two `Bend90`s and a `Straight` segment.
### 3.2. Obstacle Avoidance (The "Maze" Tests)
* **L-Obstacle:** Place an obstacle blocking the direct path. Verify the router goes around it.
* **Narrow Channel:** Create two obstacles with a gap slightly wider than $W_i + C$. Verify the router passes through.
* **Dead End:** Create a U-shaped obstacle. Verify the search explores alternatives and fails gracefully if no path exists.
### 3.3. Snapping & Precision
* **Snap-to-Target Lookahead:**
* Route to a target at `(100.005, 0, 0)` (not on 1µm grid).
* Verify the search reaches the vicinity via the 1µm grid and the final segment bridges the **5nm** gap exactly.
* **Grid Alignment:**
* Start from a port at `(0.5, 0.5, 0)`. Verify it snaps to the 1µm search grid correctly for the first move expansion.
### 3.4. Failure Modes
* **Unreachable Target:** Create a target completely enclosed by obstacles. Verify the search terminates after exploring all options (or hitting the 50,000 node limit) and returns an invalid result.
* **Start/End Collision:** Place a port deep inside an obstacle (beyond the 2nm safety zone). Verify the router identifies the immediate collision and fails gracefully.
## 4. Integration Tests: Multi-Net PathFinder (`inire/router/pathfinder`)
### 4.1. Congestion Scenarios
* **Parallel Paths:** Route two nets that can both take straight paths. Verify no reroutes occur.
* **The "Cross" Test:** Two nets must cross paths in 2D.
* Since crossings are illegal, verify the second net finds a detour.
* Verify the `Negotiated Congestion` loop increases the cost of the shared region.
* **Bottleneck:** Force 3 nets through a channel that only fits 2.
* Verify the router returns 2 valid paths and 1 "least bad" path (with collisions flagged).
* Verify the `is_valid=False` attribute is set for the failing net.
### 4.2. Determinism & Performance
* **Seed Consistency:** Run the same multi-net problem twice with the same seed; verify identical results (pixel-perfect).
* **Node Limit Enforcement:** Trigger a complex search that exceeds **50,000 nodes**. Verify it terminates and returns the best-so-far or failure.
* **Timeout:** Verify the session-level timeout stops the process for extremely large problems.
## 5. Benchmarking & Regression
* **Standard Benchmark Suite:** A set of 5-10 layouts with varying net counts (1 to 50).
* **Metrics to Track:**
* Total wire length.
* Total number of bends.
* Execution time per net.
* Success rate (percentage of nets routed without collisions).
* **Node Expansion Rate:** Nodes per second.
* **Memory Usage:** Peak RSS during 20x20mm routing.
* **Fuzz Testing:**
* Generate random obstacles and ports within a 1mm x 1mm area.
* Verify that the router never crashes.
* Verify that every result marked `is_valid=True` is confirmed collision-free by a high-precision (slow) check.
## 6. Analytic Correctness Guarantees
* **Post-Route Validation:**
* Implement an independent `validate_path(path, obstacles, clearance)` function using `shapely`'s most precise intersection tests.
* Run this on every test result to ensure the `CollisionEngine` (which uses R-Tree for speed) hasn't missed any edge cases.
* **Orientation Check:**
* Verify that the final port of every path matches the target orientation exactly $\{0, 90, 180, 270\}$.

View file

@ -22,8 +22,8 @@ def main() -> None:
greedy_h_weight=1.5,
),
objective=ObjectiveWeights(
bend_penalty=250.0,
sbend_penalty=500.0,
bend_penalty=50.0,
sbend_penalty=150.0,
),
congestion=CongestionOptions(base_penalty=1000.0),
)

View file

@ -1,4 +1,7 @@
from inire import NetSpec, ObjectiveWeights, Port, RoutingOptions, RoutingProblem, SearchOptions, route
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
@ -6,31 +9,31 @@ def main() -> None:
print("Running Example 03: Locked Paths...")
bounds = (0, -50, 100, 50)
options = RoutingOptions(
search=SearchOptions(bend_radii=(10.0,)),
objective=ObjectiveWeights(
bend_penalty=250.0,
sbend_penalty=500.0,
),
)
print("Routing initial net...")
results_a = route(
RoutingProblem(
stack = build_routing_stack(
problem=RoutingProblem(
bounds=bounds,
nets=(NetSpec("netA", Port(10, 0, 0), Port(90, 0, 0), width=2.0),),
),
options=options,
).results_by_net
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...")
results_b = route(
RoutingProblem(
bounds=bounds,
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
static_obstacles=results_a["netA"].locked_geometry,
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,))),
),
options=options,
).results_by_net
).route_all()
results = {**results_a, **results_b}
fig, ax = plot_routing_results(results, [], bounds)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

View file

@ -1,6 +1,7 @@
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
@ -8,10 +9,12 @@ from inire.utils.visualization import plot_routing_results
def _route_scenario(
bounds: tuple[float, float, float, float],
obstacles: list[Polygon],
bend_collision_type: str,
netlist: dict[str, tuple[Port, Port]],
widths: dict[str, float],
*,
bend_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(
@ -23,6 +26,8 @@ def _route_scenario(
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(
@ -40,29 +45,30 @@ def main() -> None:
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_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
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_clipped]
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_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}
netlist_custom = {"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))}
print("Routing Scenario 1 (Arc)...")
res_arc = _route_scenario(bounds, obstacles, "arc", netlist_arc, {"arc_model": 2.0})
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, "bbox", netlist_bbox, {"bbox_model": 2.0})
print("Routing Scenario 3 (Clipped BBox)...")
res_clipped = _route_scenario(
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,
"clipped_bbox",
netlist_clipped,
{"clipped_model": 2.0},
bend_clip_margin=1.0,
netlist_custom,
{"custom_geometry": 2.0},
bend_physical_geometry=custom_bend,
bend_proxy_geometry=custom_bend,
)
all_results = {**res_arc, **res_bbox, **res_clipped}
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped}
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")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

View file

@ -1,65 +1,58 @@
from shapely.geometry import Polygon
from shapely.geometry import Polygon, box
from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld
from inire import CongestionOptions, NetSpec, RoutingOptions, RoutingProblem, SearchOptions, route
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry
from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext, AStarMetrics
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.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)
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
metrics = AStarMetrics()
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 with standard arc...")
results_std = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=bounds,
nets=(NetSpec("custom_bend", start, target, width=2.0),),
),
RoutingOptions(
search=SearchOptions(bend_radii=(10.0,), sbend_radii=()),
congestion=CongestionOptions(max_iterations=1),
),
),
metrics=metrics,
).route_all()
custom_poly = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
print("Routing with custom collision model...")
results_custom = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=bounds,
nets=(NetSpec("custom_model", start, target, width=2.0),),
),
RoutingOptions(
search=SearchOptions(
bend_radii=(10.0,),
bend_collision_type=custom_poly,
sbend_radii=(),
),
congestion=CongestionOptions(max_iterations=1, use_tiered_strategy=False),
),
),
metrics=AStarMetrics(),
use_tiered_strategy=False,
).route_all()
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(
@ -67,8 +60,8 @@ def main() -> None:
[],
bounds,
netlist={
"custom_bend": (start, target),
"custom_model": (start, target),
"standard_arc": (start, target),
"custom_geometry_and_proxy": (start, target),
},
)
fig.savefig("examples/08_custom_bend_geometry.png")

View file

@ -37,7 +37,12 @@ def main() -> None:
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, _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")

View file

@ -14,13 +14,14 @@ Demonstrates the Negotiated Congestion algorithm handling multiple intersecting
![Fan-Out Routing](07_large_scale_routing.png)
## 2. Custom Bend Geometry Models
## 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).
* **Clipped BBox**: A balanced model that clips the corners of the AABB to better fit the arc (Optimal performance).
* **Custom Manhattan Geometry**: A custom 90-degree bend polygon with the same width as the normal waveguide.
Example 08 also demonstrates a custom polygonal bend geometry. It uses a centered `20x20` box as a custom bend collision model.
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)
@ -34,7 +35,5 @@ Demonstrates the router's ability to handle complex orientation requirements, in
![Orientation Stress Test](05_orientation_stress.png)
## 5. Tiered Fidelity & Lazy Dilation
Our architecture leverages two key optimizations for high-performance routing:
1. **Tiered Fidelity**: Initial routing passes use fast `clipped_bbox` proxies. If collisions are found, the system automatically escalates to high-fidelity `arc` geometry for the affected regions.
2. **Lazy Dilation**: Geometric buffering (dilation) is deferred until a collision check is strictly necessary, avoiding thousands of redundant `buffer()` and `translate()` calls.
## 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

@ -37,6 +37,7 @@ class RoutingWorld:
"clearance",
"safety_zone_radius",
"grid_cell_size",
"metrics",
"_dynamic_paths",
"_static_obstacles",
)
@ -50,6 +51,7 @@ class RoutingWorld:
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)
@ -105,9 +107,16 @@ class RoutingWorld:
return reach < length - 0.001
def _is_in_safety_zone_fast(self, idx: int, start_port: Port | None, end_port: Port | None) -> bool:
bounds = self._static_obstacles.bounds_array[idx]
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:
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
@ -176,15 +185,18 @@ class RoutingWorld:
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 = static_obstacles.tree.query(box(*result.total_dilated_bounds))
hits = tree.query(box(*result.total_dilated_bounds))
if hits.size == 0:
return False
static_bounds = static_obstacles.bounds_array
move_poly_bounds = result.dilated_bounds
for hit_idx in hits:
obstacle_bounds = static_bounds[hit_idx]
obstacle_bounds = bounds_array[hit_idx]
poly_hits_obstacle_aabb = False
for poly_bounds in move_poly_bounds:
if (
@ -255,6 +267,8 @@ class RoutingWorld:
found_real = False
for index in range(len(sub_tree_indices)):
if self.metrics is not None:
self.metrics.total_congestion_exact_pair_checks += 1
test_geometry = geometries_to_test[sub_res_indices[index]]
tree_geometry = tree_geometries[sub_tree_indices[index]]
if not test_geometry.touches(tree_geometry) and test_geometry.intersection(tree_geometry).area > 1e-7:
@ -267,6 +281,8 @@ class RoutingWorld:
return real_hits_count
def check_move_congestion(self, result: ComponentResult, net_id: str) -> int:
if self.metrics is not None:
self.metrics.total_congestion_check_calls += 1
dynamic_paths = self._dynamic_paths
if not dynamic_paths.geometries:
return 0
@ -306,6 +322,8 @@ class RoutingWorld:
return self._check_real_congestion(result, net_id)
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
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
@ -319,7 +337,9 @@ class RoutingWorld:
raw_geometries = static_obstacles.raw_tree.geometries
for component in components:
for polygon in component.physical_geometry:
buffered = polygon.buffer(self.clearance, join_style=2)
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]
@ -345,6 +365,8 @@ class RoutingWorld:
if hit_net_ids[index] == str(net_id):
continue
if self.metrics is not None:
self.metrics.total_verify_dynamic_exact_pair_checks += 1
new_geometry = test_geometries[res_indices[index]]
tree_geometry = tree_geometries[tree_indices[index]]
if not new_geometry.touches(tree_geometry) and new_geometry.intersection(tree_geometry).area > 1e-7:
@ -372,7 +394,12 @@ class RoutingWorld:
max_dist: float = 2000.0,
net_width: float | None = None,
) -> float:
if self.metrics is not None:
self.metrics.total_ray_cast_calls += 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)
@ -391,12 +418,14 @@ class RoutingWorld:
is_rect_array = static_obstacles.is_rect_array
bounds_array = static_obstacles.bounds_array
if tree is None:
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
@ -444,6 +473,8 @@ class RoutingWorld:
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

View file

@ -3,6 +3,7 @@ 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
@ -13,8 +14,8 @@ def components_overlap(
component_b: ComponentResult,
prefer_actual: bool = False,
) -> bool:
polygons_a: tuple[Polygon, ...]
polygons_b: tuple[Polygon, ...]
polygons_a: Sequence[Polygon]
polygons_b: Sequence[Polygon]
if prefer_actual:
polygons_a = component_a.physical_geometry
polygons_b = component_b.physical_geometry

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from typing import TYPE_CHECKING, Literal
import numpy
from shapely.affinity import rotate as shapely_rotate
@ -13,10 +13,14 @@ 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:
@ -26,14 +30,14 @@ def _normalize_length(value: float) -> float:
@dataclass(frozen=True, slots=True)
class ComponentResult:
start_port: Port
collision_geometry: tuple[Polygon, ...]
collision_geometry: Sequence[Polygon]
end_port: Port
length: float
move_type: MoveKind
move_spec: PathSegmentSeed
physical_geometry: tuple[Polygon, ...]
dilated_collision_geometry: tuple[Polygon, ...]
dilated_physical_geometry: tuple[Polygon, ...]
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)
@ -145,7 +149,7 @@ def _clip_bbox_legacy(
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=2) if shrink > 0 else bbox_poly
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:
@ -281,6 +285,7 @@ class Bend90:
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:
@ -310,16 +315,33 @@ class Bend90:
mirror_y=(sign < 0),
)
physical_geometry = arc_polys
if dilation > 0:
dilated_physical_geometry = _get_arc_polygons(
(float(center_xy[0]), float(center_xy[1])),
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,
sagitta,
dilation=dilation,
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]
)
@ -349,6 +371,7 @@ class SBend:
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:
@ -402,12 +425,41 @@ class SBend:
)[0],
]
physical_geometry = actual_geometry
if dilation > 0:
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],
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]
)

View file

@ -48,6 +48,8 @@ class DynamicPathIndex:
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)
@ -59,6 +61,8 @@ class DynamicPathIndex:
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():
for cell in iter_grid_cells(polygon.bounds, cell_size):
@ -66,6 +70,8 @@ class DynamicPathIndex:
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self.invalidate_queries()
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_added += len(geometry)
for index, polygon in enumerate(geometry):
obj_id = self.id_counter
self.id_counter += 1
@ -83,6 +89,8 @@ class DynamicPathIndex:
return
self.invalidate_queries()
if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_removed += len(obj_ids)
for obj_id in obj_ids:
self.index.delete(obj_id, self.dilated[obj_id].bounds)
del self.geometries[obj_id]

View file

@ -1,17 +1,18 @@
from __future__ import annotations
import math
from collections.abc import Iterator, Mapping
from typing import TypeVar
from typing import TYPE_CHECKING
import numpy
GeometryT = TypeVar("GeometryT")
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from shapely.geometry.base import BaseGeometry
def build_index_payload(
geometries: Mapping[int, GeometryT],
) -> tuple[list[int], list[GeometryT], numpy.ndarray]:
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)
@ -42,7 +43,7 @@ def iter_grid_cells(
yield (gx, gy)
def is_axis_aligned_rect(geometry, *, tolerance: float = 1e-4) -> bool:
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,10 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Self
from typing import TYPE_CHECKING, Self
import numpy
from numpy.typing import NDArray
if TYPE_CHECKING:
from numpy.typing import NDArray
def _normalize_angle(angle_deg: int | float) -> int:
@ -58,6 +60,6 @@ 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) -> NDArray[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

@ -59,7 +59,7 @@ class StaticObstacleIndex:
if dilated_geometry is not None:
dilated = dilated_geometry
else:
dilated = polygon.buffer(self.engine.clearance / 2.0, join_style=2)
dilated = polygon.buffer(self.engine.clearance / 2.0, join_style="mitre")
self.geometries[obj_id] = polygon
self.dilated[obj_id] = dilated
@ -93,6 +93,8 @@ class StaticObstacleIndex:
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])
@ -102,6 +104,8 @@ class StaticObstacleIndex:
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 = []
@ -109,7 +113,7 @@ class StaticObstacleIndex:
for obj_id in sorted(self.geometries.keys()):
polygon = self.geometries[obj_id]
dilated = polygon.buffer(total_dilation, join_style=2)
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))
@ -122,5 +126,7 @@ class StaticObstacleIndex:
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,15 +1,14 @@
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from inire.geometry.components import BendCollisionModel
from shapely.geometry import Polygon
from inire.seeds import PathSeed
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.components import BendCollisionModel
from inire.geometry.components import BendCollisionModel, BendPhysicalGeometry
from inire.geometry.primitives import Port
@ -43,6 +42,8 @@ class SearchOptions:
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"
@ -51,6 +52,36 @@ class SearchOptions:
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)

View file

@ -38,6 +38,43 @@ class RouteMetrics:
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
move_cache_abs_hits: int
move_cache_abs_misses: int
move_cache_rel_hits: int
move_cache_rel_misses: int
static_safe_cache_hits: int
hard_collision_cache_hits: int
congestion_cache_hits: int
congestion_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_builds: int
visibility_corner_pairs_checked: int
visibility_corner_queries: int
visibility_corner_hits: int
visibility_point_queries: int
visibility_point_cache_hits: int
visibility_point_cache_misses: int
ray_cast_calls: int
ray_cast_candidate_bounds: int
ray_cast_exact_geometry_checks: int
congestion_check_calls: int
congestion_exact_pair_checks: int
verify_path_report_calls: int
verify_static_buffer_ops: int
verify_dynamic_exact_pair_checks: int
@dataclass(frozen=True, slots=True)
@ -70,7 +107,7 @@ class RoutingResult:
@property
def locked_geometry(self) -> tuple[Polygon, ...]:
polygons = []
polygons: list[Polygon] = []
for component in self.path:
polygons.extend(component.physical_geometry)
return tuple(polygons)

View file

@ -33,6 +33,8 @@ def process_move(
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 = (
@ -41,11 +43,14 @@ def process_move(
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 = (
@ -54,11 +59,14 @@ def process_move(
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)
@ -69,6 +77,7 @@ def process_move(
net_width,
params[1],
collision_type=coll_type,
physical_geometry_type=config.bend_physical_geometry,
clip_margin=config.bend_clip_margin,
dilation=self_dilation,
)
@ -79,6 +88,7 @@ def process_move(
params[1],
net_width,
collision_type=coll_type,
physical_geometry_type=config.bend_physical_geometry,
clip_margin=config.bend_clip_margin,
dilation=self_dilation,
)
@ -88,7 +98,6 @@ def process_move(
res = res_rel.translate(cp.x, cp.y)
context.move_cache_abs[abs_key] = res
move_radius = params[0] if move_class == "bend90" else (params[1] if move_class == "sbend" else None)
add_node(
parent,
res,
@ -134,11 +143,14 @@ def add_node(
end_p = result.end_port
if cache_key in context.hard_collision_set:
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":
@ -155,8 +167,10 @@ def add_node(
total_overlaps = 0
if not config.skip_congestion:
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 = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
congestion_cache[cache_key] = total_overlaps

View file

@ -1,13 +1,16 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import MoveKind
from inire.geometry.primitives import Port
from ._astar_admission import process_move
from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
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]:
@ -96,7 +99,7 @@ def _visible_straight_candidates(
return []
collision_engine = context.cost_evaluator.collision_engine
candidates: set[int] = set()
tangent_candidates: set[int] = set()
for _, dist, length, dx, dy in sorted(scored)[:4]:
angle = math.degrees(math.atan2(dy, dx))
corner_reach = collision_engine.ray_cast(current, angle, max_dist=dist + 0.05, net_width=net_width)
@ -104,9 +107,9 @@ def _visible_straight_candidates(
continue
qlen = int(round(length))
if qlen > 0:
candidates.add(qlen)
tangent_candidates.add(qlen)
return sorted(candidates, reverse=True)
return sorted(tangent_candidates, reverse=True)
def _previous_move_metadata(node: AStarNode) -> tuple[MoveKind | None, float | None]:

View file

@ -3,19 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from inire.geometry.components import BendCollisionModel
from inire.model import RoutingOptions, RoutingProblem
from inire.model import resolve_bend_geometry
from inire.results import RouteMetrics
from inire.router.visibility import VisibilityManager
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
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
@dataclass(frozen=True, slots=True)
class SearchRunConfig:
bend_collision_type: BendCollisionModel
bend_physical_geometry: BendPhysicalGeometry
bend_clip_margin: float | None
node_limit: int
return_partial: bool = False
@ -38,8 +40,13 @@ class SearchRunConfig:
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=search.bend_collision_type if bend_collision_type is None else bend_collision_type,
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,
return_partial=return_partial,
@ -55,7 +62,7 @@ class AStarNode:
def __init__(
self,
port,
port: Port,
g_cost: float,
h_cost: float,
parent: AStarNode | None = None,
@ -80,6 +87,43 @@ class AStarMetrics:
"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_move_cache_abs_hits",
"total_move_cache_abs_misses",
"total_move_cache_rel_hits",
"total_move_cache_rel_misses",
"total_static_safe_cache_hits",
"total_hard_collision_cache_hits",
"total_congestion_cache_hits",
"total_congestion_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_builds",
"total_visibility_corner_pairs_checked",
"total_visibility_corner_queries",
"total_visibility_corner_hits",
"total_visibility_point_queries",
"total_visibility_point_cache_hits",
"total_visibility_point_cache_misses",
"total_ray_cast_calls",
"total_ray_cast_candidate_bounds",
"total_ray_cast_exact_geometry_checks",
"total_congestion_check_calls",
"total_congestion_exact_pair_checks",
"total_verify_path_report_calls",
"total_verify_static_buffer_ops",
"total_verify_dynamic_exact_pair_checks",
"last_expanded_nodes",
"nodes_expanded",
"moves_generated",
@ -96,6 +140,43 @@ class AStarMetrics:
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_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_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_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_builds = 0
self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0
self.total_ray_cast_calls = 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_exact_pair_checks = 0
self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_exact_pair_checks = 0
self.last_expanded_nodes: list[tuple[int, int, int]] = []
self.nodes_expanded = 0
self.moves_generated = 0
@ -111,6 +192,43 @@ class AStarMetrics:
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_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_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_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_builds = 0
self.total_visibility_corner_pairs_checked = 0
self.total_visibility_corner_queries = 0
self.total_visibility_corner_hits = 0
self.total_visibility_point_queries = 0
self.total_visibility_point_cache_hits = 0
self.total_visibility_point_cache_misses = 0
self.total_ray_cast_calls = 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_exact_pair_checks = 0
self.total_verify_path_report_calls = 0
self.total_verify_static_buffer_ops = 0
self.total_verify_dynamic_exact_pair_checks = 0
def reset_per_route(self) -> None:
self.nodes_expanded = 0
@ -129,12 +247,50 @@ class AStarMetrics:
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,
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,
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,
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_builds=self.total_visibility_builds,
visibility_corner_pairs_checked=self.total_visibility_corner_pairs_checked,
visibility_corner_queries=self.total_visibility_corner_queries,
visibility_corner_hits=self.total_visibility_corner_hits,
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,
ray_cast_calls=self.total_ray_cast_calls,
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_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_exact_pair_checks=self.total_verify_dynamic_exact_pair_checks,
)
class AStarContext:
__slots__ = (
"cost_evaluator",
"metrics",
"congestion_penalty",
"min_bend_radius",
"problem",
@ -153,9 +309,11 @@ class AStarContext:
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

View file

@ -5,7 +5,7 @@ import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from inire.model import NetOrder, NetSpec
from inire.model import NetOrder, NetSpec, resolve_bend_geometry
from inire.results import RoutingOutcome, RoutingReport, RoutingResult
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
from inire.router._search import route_astar
@ -15,6 +15,8 @@ from inire.router.refiner import PathRefiner
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from shapely.geometry import Polygon
from inire.geometry.components import ComponentResult
@ -43,13 +45,15 @@ class PathFinder:
metrics: AStarMetrics | None = None,
) -> None:
self.context = context
self.metrics = metrics if metrics is not None else AStarMetrics()
self.metrics = self.context.metrics if metrics is None else metrics
self.context.metrics = self.metrics
self.context.cost_evaluator.collision_engine.metrics = self.metrics
self.refiner = PathRefiner(self.context)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
all_geoms = []
all_dilated = []
all_geoms: list[Polygon] = []
all_dilated: list[Polygon] = []
for result in path:
all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry)
@ -104,6 +108,7 @@ class PathFinder:
)
if not path:
continue
self.metrics.total_warm_start_paths_built += 1
greedy_paths[net_id] = tuple(path)
for result in path:
for polygon in result.physical_geometry:
@ -168,12 +173,14 @@ class PathFinder:
congestion = self.context.options.congestion
diagnostics = self.context.options.diagnostics
net = state.net_specs[net_id]
self.metrics.total_nets_routed += 1
self.context.cost_evaluator.collision_engine.remove_path(net_id)
if iteration == 0 and state.initial_paths and net_id in state.initial_paths:
self.metrics.total_warm_start_paths_used += 1
path: Sequence[ComponentResult] | None = state.initial_paths[net_id]
else:
coll_model = search.bend_collision_type
coll_model, _ = resolve_bend_geometry(search)
skip_congestion = False
if congestion.use_tiered_strategy and iteration == 0:
skip_congestion = True
@ -206,6 +213,8 @@ class PathFinder:
return RoutingResult(net_id=net_id, path=(), reached_target=False)
reached_target = path[-1].end_port == net.target
if reached_target:
self.metrics.total_nets_reached_target += 1
report = None
self._install_path(net_id, path)
if reached_target:
@ -215,7 +224,7 @@ class PathFinder:
return RoutingResult(
net_id=net_id,
path=path,
path=tuple(path),
reached_target=reached_target,
report=RoutingReport() if report is None else report,
)
@ -228,6 +237,7 @@ class PathFinder:
) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {}
congestion = self.context.options.congestion
self.metrics.total_route_iterations += 1
self.metrics.reset_per_route()
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
@ -236,6 +246,7 @@ class PathFinder:
for net_id in state.ordered_net_ids:
if time.monotonic() - state.start_time > state.timeout_s:
self.metrics.total_timeout_events += 1
return None
result = self._route_net_once(state, iteration, net_id)
@ -270,13 +281,14 @@ class PathFinder:
if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}:
continue
net = state.net_specs[net_id]
self.metrics.total_refine_path_calls += 1
self.context.cost_evaluator.collision_engine.remove_path(net_id)
refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
self._install_path(net_id, refined_path)
report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
state.results[net_id] = RoutingResult(
net_id=net_id,
path=refined_path,
path=tuple(refined_path),
reached_target=result.reached_target,
report=report,
)

View file

@ -4,13 +4,13 @@ import heapq
from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR
from inire.geometry.primitives import Port
from ._astar_moves import expand_moves as _expand_moves
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
def _reconstruct_path(end_node: _AStarNode) -> list[ComponentResult]:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from inire.model import SearchOptions
from inire.model import SearchOptions, resolve_bend_geometry
from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed
if TYPE_CHECKING:
@ -23,7 +23,7 @@ def materialize_path_seed(
path: list[ComponentResult] = []
current = start
dilation = clearance / 2.0
bend_collision_type = search.bend_collision_type
bend_collision_type, bend_physical_geometry = resolve_bend_geometry(search)
bend_clip_margin = search.bend_clip_margin
for segment in seed.segments:
@ -36,6 +36,7 @@ def materialize_path_seed(
net_width,
segment.direction,
collision_type=bend_collision_type,
physical_geometry_type=bend_physical_geometry,
clip_margin=bend_clip_margin,
dilation=dilation,
)
@ -46,6 +47,7 @@ def materialize_path_seed(
segment.radius,
net_width,
collision_type=bend_collision_type,
physical_geometry_type=bend_physical_geometry,
clip_margin=bend_clip_margin,
dilation=dilation,
)

View file

@ -1,17 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from inire.model import RoutingOptions, RoutingProblem
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: object
danger_map: object
evaluator: object
context: object
finder: object
world: RoutingWorld
danger_map: DangerMap
evaluator: CostEvaluator
context: AStarContext
finder: PathFinder
def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack:

View file

@ -8,6 +8,7 @@ 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
@ -71,7 +72,7 @@ class CostEvaluator:
def set_target(self, target: Port) -> None:
self._target_x = target.x
self._target_y = target.y
self._target_r = target.r
self._target_r = int(target.r)
rad = np.radians(target.r)
self._target_cos = np.cos(rad)
self._target_sin = np.sin(rad)
@ -176,7 +177,7 @@ class CostEvaluator:
def path_cost(
self,
start_port: Port,
path: list[ComponentResult],
path: Sequence[ComponentResult],
*,
weights: ObjectiveWeights | None = None,
) -> float:

View file

@ -51,14 +51,14 @@ class DangerMap:
for poly in obstacles:
# Sample exterior
exterior = poly.exterior
dist = 0
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
dist = 0.0
while dist < interior.length:
pt = interior.interpolate(dist)
all_points.append((pt.x, pt.y))

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from inire.geometry.component_overlap import components_overlap
from inire.geometry.components import Bend90, Straight
@ -55,7 +55,7 @@ class PathRefiner:
ports.extend(comp.end_port for comp in path)
return ports
def _to_local(self, start: Port, point: Port) -> tuple[int, int]:
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:
@ -197,8 +197,8 @@ class PathRefiner:
if 0.01 < forward_length < min_straight - 0.01:
return None
first_dir = "CCW" if side_extent > 0 else "CW"
second_dir = "CW" if side_extent > 0 else "CCW"
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] = []
@ -288,10 +288,10 @@ class PathRefiner:
net_id: str,
start: Port,
net_width: float,
path: list[ComponentResult],
path: Sequence[ComponentResult],
) -> list[ComponentResult]:
if not path:
return path
return list(path)
path = list(path)

View file

@ -23,7 +23,7 @@ class VisibilityManager:
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], list[tuple[float, float, float]]] = {}
self._point_visibility_cache: dict[tuple[int, int, int], list[tuple[float, float, float]]] = {}
self._built_static_version = -1
self._build()
@ -45,6 +45,8 @@ class VisibilityManager:
"""
Extract corners and pre-compute corner-to-corner visibility.
"""
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_builds += 1
self._built_static_version = self.collision_engine.get_static_version()
raw_corners = []
for poly in self.collision_engine.iter_static_dilated_geometries():
@ -85,6 +87,8 @@ class VisibilityManager:
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)
@ -107,6 +111,8 @@ class VisibilityManager:
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_current()
if max_dist < 0:
return []
@ -118,7 +124,11 @@ class VisibilityManager:
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))
@ -145,11 +155,15 @@ class VisibilityManager:
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 += 1
self._ensure_current()
if max_dist < 0:
return []
corner_idx = self._corner_idx_at(origin)
if corner_idx is not None and corner_idx in self._corner_graph:
if self.collision_engine.metrics is not None:
self.collision_engine.metrics.total_visibility_corner_hits += 1
return [corner for corner in self._corner_graph[corner_idx] if corner[2] <= max_dist]
return []

View file

@ -1,7 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from time import perf_counter
from typing import Callable
from collections.abc import Callable
from shapely.geometry import Polygon, box
@ -18,6 +19,7 @@ from inire import (
)
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
@ -31,6 +33,25 @@ _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
ScenarioOutcome = tuple[float, int, int, int]
ScenarioRun = Callable[[], ScenarioOutcome]
ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"]
@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:
@ -42,6 +63,32 @@ def _summarize(results: dict[str, RoutingResult], duration_s: float) -> Scenario
)
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 _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],
*,
@ -93,13 +140,15 @@ def _build_pathfinder(
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=metrics,
metrics=resolved_metrics,
)
@ -133,7 +182,7 @@ def _build_routing_stack(
return engine, evaluator, metrics, pathfinder
def run_example_01() -> ScenarioOutcome:
def snapshot_example_01() -> ScenarioSnapshot:
netlist = {"net1": (Port(10, 50, 0), Port(90, 50, 0))}
widths = {"net1": 2.0}
_, _, _, pathfinder = _build_routing_stack(
@ -145,16 +194,20 @@ def run_example_01() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
return _make_snapshot("example_01_simple_route", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_02() -> ScenarioOutcome:
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 = {net_id: 2.0 for net_id in netlist}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 100, 100),
netlist=netlist,
@ -173,10 +226,14 @@ def run_example_02() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
return _make_snapshot("example_02_congestion_resolution", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_03() -> ScenarioOutcome:
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(
@ -187,19 +244,26 @@ def run_example_03() -> ScenarioOutcome:
)
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)
results_b = _build_pathfinder(
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],
).route_all()
)
results_b = pathfinder_b.route_all()
t1 = perf_counter()
return _summarize({**results_a, **results_b}, t1 - t0)
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_04() -> ScenarioOutcome:
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)),
@ -223,16 +287,20 @@ def run_example_04() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
return _make_snapshot("example_04_sbends_and_radii", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_05() -> ScenarioOutcome:
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 = {net_id: 2.0 for net_id in netlist}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 200, 200),
netlist=netlist,
@ -243,16 +311,21 @@ def run_example_05() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
return _make_snapshot("example_05_orientation_stress", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_06() -> ScenarioOutcome:
def run_example_05() -> ScenarioOutcome:
return snapshot_example_05().as_outcome()
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),
@ -268,12 +341,12 @@ def run_example_06() -> ScenarioOutcome:
),
(
_build_evaluator(bounds, obstacles=obstacles),
{"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))},
{"clipped_model": 2.0},
{"custom_geometry": (Port(10, 20, 0), Port(90, 40, 90))},
{"custom_geometry": 2.0},
{
"bend_radii": [10.0],
"bend_collision_type": "clipped_bbox",
"bend_clip_margin": 1.0,
"bend_physical_geometry": custom_physical,
"bend_proxy_geometry": custom_physical,
"use_tiered_strategy": False,
},
),
@ -281,6 +354,7 @@ def run_example_06() -> ScenarioOutcome:
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,
@ -289,11 +363,21 @@ def run_example_06() -> ScenarioOutcome:
**request_kwargs,
)
combined_results.update(pathfinder.route_all())
route_metrics.append(pathfinder.metrics.snapshot())
t1 = perf_counter()
return _summarize(combined_results, t1 - t0)
return _make_snapshot(
"example_06_bend_collision_models",
combined_results,
t1 - t0,
_sum_metrics(tuple(route_metrics)),
)
def run_example_07() -> ScenarioOutcome:
def run_example_06() -> ScenarioOutcome:
return snapshot_example_06().as_outcome()
def snapshot_example_07() -> ScenarioSnapshot:
bounds = (0, 0, 1000, 1000)
obstacles = [
box(450, 0, 550, 400),
@ -348,42 +432,60 @@ def run_example_07() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all(iteration_callback=iteration_callback)
t1 = perf_counter()
return _summarize(results, t1 - t0)
return _make_snapshot("example_07_large_scale_routing", results, t1 - t0, pathfinder.metrics.snapshot())
def run_example_08() -> ScenarioOutcome:
def run_example_07() -> ScenarioOutcome:
return snapshot_example_07().as_outcome()
def snapshot_example_08() -> ScenarioSnapshot:
bounds = (0, 0, 150, 150)
netlist = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
widths = {"custom_bend": 2.0}
custom_model = Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)])
evaluator = _build_evaluator(bounds)
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()
results_std = _build_pathfinder(
evaluator,
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(),
).route_all()
results_custom = _build_pathfinder(
evaluator,
)
results_std = pathfinder_std.route_all()
pathfinder_custom = _build_pathfinder(
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}),
bend_radii=[10.0],
bend_collision_type=custom_model,
bend_physical_geometry=custom_physical,
bend_proxy_geometry=custom_proxy,
sbend_radii=[],
max_iterations=1,
use_tiered_strategy=False,
metrics=AStarMetrics(),
).route_all()
)
results_custom = pathfinder_custom.route_all()
t1 = perf_counter()
return _summarize({**results_std, **results_custom}, t1 - t0)
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_09() -> ScenarioOutcome:
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),
@ -401,7 +503,11 @@ def run_example_09() -> ScenarioOutcome:
t0 = perf_counter()
results = pathfinder.route_all()
t1 = perf_counter()
return _summarize(results, t1 - t0)
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], ...] = (
@ -415,3 +521,19 @@ SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
("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),
)
def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]:
return tuple(run() for _, run in SCENARIO_SNAPSHOTS)

View file

@ -76,6 +76,13 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
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.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_builds >= 0
assert run.metrics.verify_path_report_calls >= 0
def test_route_problem_locked_routes_become_static_obstacles() -> None:

View file

@ -1,6 +1,3 @@
import pytest
import numpy
from shapely.geometry import Polygon
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
@ -10,7 +7,6 @@ from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire import RoutingResult
def _build_pathfinder(
@ -45,19 +41,19 @@ def test_clearance_thresholds():
# 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)
@ -105,7 +101,7 @@ def test_verify_all_nets_cases():
# Reset engine
engine.remove_path("net1")
engine.remove_path("net2")
results_p = _build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),
@ -124,7 +120,7 @@ def test_verify_all_nets_cases():
}
engine.remove_path("net3")
engine.remove_path("net4")
results_c = _build_pathfinder(
evaluator,
bounds=(0, 0, 100, 100),

View file

@ -43,15 +43,15 @@ 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)
@ -61,19 +61,19 @@ def test_ray_cast_width_clearance() -> None:
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)

View file

@ -137,13 +137,29 @@ def test_custom_bend_collision_polygon_uses_local_transform() -> None:
assert result.collision_geometry[0].symmetric_difference(expected).area < 1e-6
def test_custom_bend_collision_polygon_only_overrides_search_geometry() -> None:
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, dilation=1.0)
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.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

View file

@ -17,7 +17,7 @@ def test_cost_calculation() -> None:
p2 = Port(10, 10, 0)
h = evaluator.h_manhattan(p1, p2)
# Manhattan distance = 20.
# Manhattan distance = 20.
# Jog alignment penalty = 2*bp = 20.
# Side check penalty = 2*bp = 20.
# Total = 1.1 * (20 + 40) = 66.0
@ -56,25 +56,25 @@ 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

View file

@ -2,12 +2,15 @@ from __future__ import annotations
import os
import statistics
from collections.abc import Callable
from typing import TYPE_CHECKING
import pytest
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
if TYPE_CHECKING:
from collections.abc import Callable
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
PERFORMANCE_REPEATS = 3
@ -22,7 +25,7 @@ BASELINE_SECONDS = {
"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": 4.2111,
"example_08_custom_bend_geometry": 0.9848,
"example_09_unroutable_best_effort": 0.0056,
}
@ -34,7 +37,7 @@ EXPECTED_OUTCOMES = {
"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": 1, "reached_targets": 2},
"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},
}

View file

@ -25,7 +25,7 @@ EXPECTED_OUTCOMES = {
"example_05_orientation_stress": (3, 3, 3),
"example_06_bend_collision_models": (3, 3, 3),
"example_07_large_scale_routing": (10, 10, 10),
"example_08_custom_bend_geometry": (2, 1, 2),
"example_08_custom_bend_geometry": (2, 2, 2),
"example_09_unroutable_best_effort": (1, 0, 0),
}
@ -150,35 +150,107 @@ def test_example_07_reduced_bottleneck_uses_adaptive_greedy_callback() -> None:
assert all(result.reached_target for result in results.values())
def test_example_08_custom_box_restores_legacy_collision_outcome() -> None:
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 = {"custom_bend": (Port(20, 20, 0), Port(100, 100, 90))}
widths = {"custom_bend": 2.0}
evaluator = _build_evaluator(bounds)
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(
evaluator,
_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(
evaluator,
_build_evaluator(bounds),
bounds=bounds,
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
nets=_net_specs({"custom_geometry_and_proxy": netlist["standard_arc"]}, {"custom_geometry_and_proxy": 2.0}),
bend_radii=[10.0],
bend_collision_type=Polygon([(-10, -10), (10, -10), (10, 10), (-10, 10)]),
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["custom_bend"].is_valid
assert standard["custom_bend"].reached_target
assert not custom["custom_model"].is_valid
assert custom["custom_model"].reached_target
assert custom["custom_model"].collisions == 2
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

@ -0,0 +1,45 @@
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.move_cache_abs_misses >= 0
assert snapshot.metrics.ray_cast_calls >= 0
assert snapshot.metrics.dynamic_tree_rebuilds >= 0
assert snapshot.metrics.visibility_builds >= 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()

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
import matplotlib.pyplot as plt
import numpy
from shapely.geometry import MultiPolygon, Polygon
@ -129,7 +129,7 @@ def plot_danger_map(
if ax is None:
fig, ax = plt.subplots(figsize=(10, 10))
else:
fig = ax.get_figure()
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)
@ -155,12 +155,12 @@ def plot_danger_map(
# 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
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')
plt.colorbar(im, ax=ax, label="Danger Cost")
ax.set_title("Danger Map (Proximity Costs)")
return fig, ax
@ -176,7 +176,7 @@ def plot_expanded_nodes(
if ax is None:
fig, ax = plt.subplots(figsize=(10, 10))
else:
fig = ax.get_figure()
fig = cast("Figure", ax.get_figure())
if not nodes:
return fig, ax
@ -206,7 +206,7 @@ def plot_expansion_density(
if ax is None:
fig, ax = plt.subplots(figsize=(12, 12))
else:
fig = ax.get_figure()
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)
@ -224,14 +224,14 @@ def plot_expansion_density(
# 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
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')
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])

View file

@ -74,6 +74,18 @@ lint.ignore = [
"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]
addopts = "-rsXx"
testpaths = ["inire"]

View file

@ -0,0 +1,129 @@
#!/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 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 _build_payload(selected_scenarios: tuple[str, ...] | None = None) -> dict[str, object]:
allowed = None if selected_scenarios is None else set(selected_scenarios)
snapshots = []
for name, run in SCENARIO_SNAPSHOTS:
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`.",
"",
"| 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.",
"",
"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.",
)
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)
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()