lots more refactoring

This commit is contained in:
Jan Petykiewicz 2026-03-30 19:51:37 -07:00
commit bc218a416b
43 changed files with 1433 additions and 1694 deletions

37
DOCS.md
View file

@ -11,9 +11,8 @@ This document describes the current public API for `inire`.
- `bounds` - `bounds`
- `nets` - `nets`
- `static_obstacles` - `static_obstacles`
- `locked_routes` - `initial_paths`
- `clearance` - `clearance`
- `max_net_width`
- `safety_zone_radius` - `safety_zone_radius`
### `RoutingOptions` ### `RoutingOptions`
@ -34,21 +33,39 @@ run = route(problem, options=options)
If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults. If you omit `options`, `route(problem)` uses `RoutingOptions()` defaults.
### Incremental routing with `LockedRoute` The package root is the stable API surface. Deep imports under `inire.router.*` and `inire.geometry.*` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
For incremental workflows, route one problem, convert a result into a `LockedRoute`, and feed it into the next problem: Stable example:
```python
from inire import route, RoutingOptions, RoutingProblem
```
Unstable example:
```python
from inire.router._router import PathFinder
```
### Incremental routing with locked geometry
For incremental workflows, route one problem, reuse the result's locked geometry, and feed it into the next problem:
```python ```python
run_a = route(problem_a) run_a = route(problem_a)
problem_b = RoutingProblem( problem_b = RoutingProblem(
bounds=problem_a.bounds, bounds=problem_a.bounds,
nets=(...), nets=(...),
locked_routes={"netA": run_a.results_by_net["netA"].as_locked_route()}, static_obstacles=run_a.results_by_net["netA"].locked_geometry,
) )
run_b = route(problem_b) run_b = route(problem_b)
``` ```
`LockedRoute` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle. `RoutingResult.locked_geometry` stores canonical physical geometry only. The next run applies its own clearance rules when treating it as a static obstacle.
### Initial paths with `PathSeed`
Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are materialized with the current width, clearance, and bend collision settings for the run, and partial seeds are retried by normal routing in later iterations.
## 2. Search Options ## 2. Search Options
@ -65,7 +82,6 @@ run_b = route(problem_b)
| `sbend_offsets` | `None` | Optional explicit lateral offsets 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 model: `"arc"`, `"bbox"`, `"clipped_bbox"`, or a custom polygon. |
| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. | | `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. |
| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. |
## 3. Objective Weights ## 3. Objective Weights
@ -77,7 +93,6 @@ run_b = route(problem_b)
| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. | | `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. |
| `sbend_penalty` | `500.0` | Flat S-bend penalty. | | `sbend_penalty` | `500.0` | Flat S-bend penalty. |
| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. | | `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. |
| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. |
## 4. Congestion Options ## 4. Congestion Options
@ -89,9 +104,9 @@ run_b = route(problem_b)
| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. | | `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. |
| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. | | `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. |
| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. | | `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. |
| `warm_start` | `"shortest"` | Optional greedy warm-start ordering. | | `net_order` | `"user"` | Net ordering strategy for warm-start seeding and routed iterations. |
| `warm_start_enabled` | `True` | Run the greedy warm-start seeding pass before negotiated congestion iterations. |
| `shuffle_nets` | `False` | Shuffle routing order between iterations. | | `shuffle_nets` | `False` | Shuffle routing order between iterations. |
| `sort_nets` | `None` | Optional deterministic routing order. |
| `seed` | `None` | RNG seed for shuffled routing order. | | `seed` | `None` | RNG seed for shuffled routing order. |
## 5. Refinement Options ## 5. Refinement Options
@ -126,7 +141,7 @@ run_b = route(problem_b)
## 8. Internal Modules ## 8. Internal Modules
Lower-level search and collision modules are internal implementation details. The supported entrypoint is `route(problem, options=...)`. Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
## 9. Tuning Notes ## 9. Tuning Notes

View file

@ -50,7 +50,7 @@ if run.results_by_net["net1"].is_valid:
print("Successfully routed net1!") print("Successfully routed net1!")
``` ```
For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `locked_routes` using `RoutingResult.as_locked_route()`. For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `static_obstacles` using `RoutingResult.locked_geometry`.
## Usage Examples ## Usage Examples
@ -65,6 +65,12 @@ python3 examples/01_simple_route.py
Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**.
## API Stability
The stable API lives at the package root and is centered on `route(problem, options=...)`.
Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
## Architecture ## Architecture
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:
@ -76,7 +82,7 @@ For multi-net problems, the negotiated-congestion loop handles rip-up and rerout
## Configuration ## Configuration
`inire` is highly tunable. The public API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Search internals remain available only for internal tests and development work; they are not a supported integration surface. See `DOCS.md` for a full parameter reference. `inire` is highly tunable. The stable API is `RoutingProblem` plus `RoutingOptions`, routed via `route(problem, options=...)`. Deep modules remain accessible for advanced workflows, but they are unstable and may change without notice. See `DOCS.md` for a full parameter reference.
## License ## License

View file

@ -27,7 +27,7 @@ def main() -> None:
RoutingProblem( RoutingProblem(
bounds=bounds, bounds=bounds,
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
locked_routes={"netA": results_a["netA"].as_locked_route()}, static_obstacles=results_a["netA"].locked_geometry,
), ),
options=options, options=options,
).results_by_net ).results_by_net

View file

@ -26,7 +26,7 @@ def main() -> None:
bend_penalty=50.0, bend_penalty=50.0,
sbend_penalty=150.0, sbend_penalty=150.0,
), ),
congestion=CongestionOptions(warm_start=None), congestion=CongestionOptions(warm_start_enabled=False),
) )
print("Routing with a deliberately tiny node budget (should return a partial path)...") print("Routing with a deliberately tiny node budget (should return a partial path)...")

View file

@ -1,43 +1,59 @@
""" """
inire Wave-router inire Wave-router
""" """
from .api import ( from collections.abc import Callable
from .geometry.primitives import Port as Port # noqa: PLC0414
from .model import (
CongestionOptions as CongestionOptions, CongestionOptions as CongestionOptions,
DiagnosticsOptions as DiagnosticsOptions, DiagnosticsOptions as DiagnosticsOptions,
LockedRoute as LockedRoute,
NetSpec as NetSpec, NetSpec as NetSpec,
ObjectiveWeights as ObjectiveWeights, ObjectiveWeights as ObjectiveWeights,
RefinementOptions as RefinementOptions, RefinementOptions as RefinementOptions,
RoutingOptions as RoutingOptions, RoutingOptions as RoutingOptions,
RoutingProblem as RoutingProblem, RoutingProblem as RoutingProblem,
RoutingRunResult as RoutingRunResult,
SearchOptions as SearchOptions, SearchOptions as SearchOptions,
route as route,
) # noqa: PLC0414 ) # noqa: PLC0414
from .geometry.primitives import Port as Port # noqa: PLC0414 from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414 from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414
from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
__version__ = '0.1' __version__ = '0.1'
def route(
problem: RoutingProblem,
*,
options: RoutingOptions | None = None,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
) -> RoutingRunResult:
from .router._stack import build_routing_stack
resolved_options = RoutingOptions() if options is None else options
stack = build_routing_stack(problem, resolved_options)
finder = stack.finder
results = finder.route_all(iteration_callback=iteration_callback)
return RoutingRunResult(
results_by_net=results,
metrics=finder.metrics.snapshot(),
expanded_nodes=tuple(finder.accumulated_expanded_nodes),
)
__all__ = [ __all__ = [
"Bend90", "Bend90Seed",
"CongestionOptions", "CongestionOptions",
"DiagnosticsOptions", "DiagnosticsOptions",
"LockedRoute",
"NetSpec", "NetSpec",
"ObjectiveWeights", "ObjectiveWeights",
"PathSeed",
"Port", "Port",
"RefinementOptions", "RefinementOptions",
"RoutingOptions", "RoutingOptions",
"RoutingProblem", "RoutingProblem",
"RoutingReport",
"RoutingResult", "RoutingResult",
"RoutingRunResult", "RoutingRunResult",
"RouteMetrics", "SBendSeed",
"SBend",
"SearchOptions", "SearchOptions",
"Straight", "StraightSeed",
"route", "route",
] ]

View file

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

View file

@ -2,11 +2,5 @@
Centralized constants for the inire routing engine. Centralized constants for the inire routing engine.
""" """
# Search Grid Snap (5.0 µm default)
# TODO: Make this configurable in SearchOptions and define tolerances relative to the grid.
DEFAULT_SEARCH_GRID_SNAP_UM = 5.0
# Tolerances
TOLERANCE_LINEAR = 1e-6 TOLERANCE_LINEAR = 1e-6
TOLERANCE_ANGULAR = 1e-3 TOLERANCE_ANGULAR = 1e-3
TOLERANCE_GRID = 1e-6

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING
import numpy import numpy
from shapely.geometry import LineString, box from shapely.geometry import LineString, box
@ -8,8 +8,8 @@ from shapely.geometry import LineString, box
from inire.geometry.component_overlap import components_overlap from inire.geometry.component_overlap import components_overlap
from inire.geometry.dynamic_path_index import DynamicPathIndex from inire.geometry.dynamic_path_index import DynamicPathIndex
from inire.geometry.index_helpers import grid_cell_span from inire.geometry.index_helpers import grid_cell_span
from inire.results import RoutingReport
from inire.geometry.static_obstacle_index import StaticObstacleIndex from inire.geometry.static_obstacle_index import StaticObstacleIndex
from inire.router.results import RoutingReport
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@ -35,9 +35,7 @@ class RoutingWorld:
__slots__ = ( __slots__ = (
"clearance", "clearance",
"max_net_width",
"safety_zone_radius", "safety_zone_radius",
"metrics",
"grid_cell_size", "grid_cell_size",
"_dynamic_paths", "_dynamic_paths",
"_static_obstacles", "_static_obstacles",
@ -46,27 +44,15 @@ class RoutingWorld:
def __init__( def __init__(
self, self,
clearance: float, clearance: float,
max_net_width: float = 2.0,
safety_zone_radius: float = 0.0021, safety_zone_radius: float = 0.0021,
) -> None: ) -> None:
self.clearance = clearance self.clearance = clearance
self.max_net_width = max_net_width
self.safety_zone_radius = safety_zone_radius self.safety_zone_radius = safety_zone_radius
self.grid_cell_size = 50.0 self.grid_cell_size = 50.0
self._static_obstacles = StaticObstacleIndex(self) self._static_obstacles = StaticObstacleIndex(self)
self._dynamic_paths = DynamicPathIndex(self) self._dynamic_paths = DynamicPathIndex(self)
self.metrics = {
"static_cache_hits": 0,
"static_grid_skips": 0,
"static_tree_queries": 0,
"static_straight_fast": 0,
"congestion_grid_skips": 0,
"congestion_tree_queries": 0,
"safety_zone_checks": 0,
}
def get_static_version(self) -> int: def get_static_version(self) -> int:
return self._static_obstacles.version return self._static_obstacles.version
@ -87,31 +73,12 @@ class RoutingWorld:
for obj_id in self._dynamic_paths.index.intersection(query_bounds): for obj_id in self._dynamic_paths.index.intersection(query_bounds):
yield self._dynamic_paths.geometries[obj_id][1].bounds yield self._dynamic_paths.geometries[obj_id][1].bounds
def iter_dynamic_paths(self) -> Iterable[tuple[str, Polygon]]:
return self._dynamic_paths.geometries.values()
def reset_metrics(self) -> None:
for key in self.metrics:
self.metrics[key] = 0
def get_metrics_summary(self) -> str:
metrics = self.metrics
return (
"Collision Performance: \n"
f" Static: {metrics['static_tree_queries']} checks\n"
f" Congestion: {metrics['congestion_tree_queries']} checks\n"
f" Safety Zone: {metrics['safety_zone_checks']} full intersections performed"
)
def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int: def add_static_obstacle(self, polygon: Polygon, dilated_geometry: Polygon | None = None) -> int:
return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry) return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry)
def remove_static_obstacle(self, obj_id: int) -> None: def remove_static_obstacle(self, obj_id: int) -> None:
self._static_obstacles.remove_obstacle(obj_id) self._static_obstacles.remove_obstacle(obj_id)
def _invalidate_static_caches(self) -> None:
self._static_obstacles.invalidate_caches()
def _ensure_static_tree(self) -> None: def _ensure_static_tree(self) -> None:
self._static_obstacles.ensure_tree() self._static_obstacles.ensure_tree()
@ -127,10 +94,6 @@ class RoutingWorld:
def _ensure_dynamic_grid(self) -> None: def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid() self._dynamic_paths.ensure_grid()
def rebuild_dynamic_tree(self) -> None:
self._dynamic_paths.tree = None
self._ensure_dynamic_tree()
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None:
self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry)
@ -138,7 +101,6 @@ class RoutingWorld:
self._dynamic_paths.remove_path(net_id) self._dynamic_paths.remove_path(net_id)
def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool: def check_move_straight_static(self, start_port: Port, length: float, net_width: float) -> bool:
self.metrics["static_straight_fast"] += 1
reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width) reach = self.ray_cast(start_port, start_port.r, max_dist=length + 0.01, net_width=net_width)
return reach < length - 0.001 return reach < length - 0.001
@ -178,7 +140,6 @@ class RoutingWorld:
if not geometry.intersects(raw_obstacle): if not geometry.intersects(raw_obstacle):
return False return False
self.metrics["safety_zone_checks"] += 1
intersection = geometry.intersection(raw_obstacle) intersection = geometry.intersection(raw_obstacle)
if intersection.is_empty: if intersection.is_empty:
return False return False
@ -207,15 +168,13 @@ class RoutingWorld:
result: ComponentResult, result: ComponentResult,
start_port: Port | None = None, start_port: Port | None = None,
end_port: Port | None = None, end_port: Port | None = None,
net_width: float | None = None,
) -> bool: ) -> bool:
del net_width # TODO: If static buffering becomes net-width-specific, add dedicated
# width-aware geometry/index handling instead of reviving dead args here.
static_obstacles = self._static_obstacles static_obstacles = self._static_obstacles
if not static_obstacles.dilated: if not static_obstacles.dilated:
return False return False
self.metrics["static_tree_queries"] += 1
self._ensure_static_tree() self._ensure_static_tree()
hits = static_obstacles.tree.query(box(*result.total_dilated_bounds)) hits = static_obstacles.tree.query(box(*result.total_dilated_bounds))
@ -260,7 +219,6 @@ class RoutingWorld:
def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int: def _check_real_congestion(self, result: ComponentResult, net_id: str) -> int:
dynamic_paths = self._dynamic_paths dynamic_paths = self._dynamic_paths
self.metrics["congestion_tree_queries"] += 1
self._ensure_dynamic_tree() self._ensure_dynamic_tree()
if dynamic_paths.tree is None: if dynamic_paths.tree is None:
return 0 return 0
@ -347,101 +305,6 @@ class RoutingWorld:
return 0 return 0
return self._check_real_congestion(result, net_id) return self._check_real_congestion(result, net_id)
def _check_static_collision(
self,
geometry: Polygon,
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
) -> bool:
static_obstacles = self._static_obstacles
self._ensure_static_tree()
if static_obstacles.tree is None:
return False
if dilated_geometry is not None:
test_geometry = dilated_geometry
else:
distance = self.clearance / 2.0
test_geometry = geometry.buffer(distance + 1e-7, join_style=2) if distance > 0 else geometry
hits = static_obstacles.tree.query(test_geometry, predicate="intersects")
tree_geometries = static_obstacles.tree.geometries
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = static_obstacles.obj_ids[hit_idx]
if self._is_in_safety_zone(geometry, obj_id, start_port, end_port):
continue
return True
return False
def _check_dynamic_collision(
self,
geometry: Polygon,
net_id: str,
dilated_geometry: Polygon | None = None,
) -> int:
dynamic_paths = self._dynamic_paths
self._ensure_dynamic_tree()
if dynamic_paths.tree is None:
return 0
test_geometry = dilated_geometry if dilated_geometry else geometry.buffer(self.clearance / 2.0)
hits = dynamic_paths.tree.query(test_geometry, predicate="intersects")
tree_geometries = dynamic_paths.tree.geometries
hit_net_ids: list[str] = []
for hit_idx in hits:
if test_geometry.touches(tree_geometries[hit_idx]):
continue
obj_id = dynamic_paths.obj_ids[hit_idx]
other_net_id = dynamic_paths.geometries[obj_id][0]
if other_net_id != net_id:
hit_net_ids.append(other_net_id)
if not hit_net_ids:
return 0
return len(numpy.unique(hit_net_ids))
def check_collision(
self,
geometry: Polygon,
net_id: str,
buffer_mode: Literal["static", "congestion"] = "static",
start_port: Port | None = None,
end_port: Port | None = None,
dilated_geometry: Polygon | None = None,
bounds: tuple[float, float, float, float] | None = None,
net_width: float | None = None,
) -> bool | int:
del bounds, net_width
if buffer_mode == "static":
return self._check_static_collision(
geometry,
start_port=start_port,
end_port=end_port,
dilated_geometry=dilated_geometry,
)
return self._check_dynamic_collision(geometry, net_id, dilated_geometry=dilated_geometry)
def is_collision(
self,
geometry: Polygon,
net_id: str = "default",
net_width: float | None = None,
start_port: Port | None = None,
end_port: Port | None = None,
) -> bool:
result = self.check_collision(
geometry,
net_id,
buffer_mode="static",
start_port=start_port,
end_port=end_port,
net_width=net_width,
)
return bool(result)
def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport: def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:
static_collision_count = 0 static_collision_count = 0
dynamic_collision_count = 0 dynamic_collision_count = 0
@ -502,10 +365,6 @@ class RoutingWorld:
total_length=total_length, total_length=total_length,
) )
def verify_path(self, net_id: str, components: Sequence[ComponentResult]) -> tuple[bool, int]:
report = self.verify_path_report(net_id, components)
return report.is_valid, report.collision_count
def ray_cast( def ray_cast(
self, self,
origin: Port, origin: Port,

View file

@ -8,31 +8,34 @@ if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
def component_polygons(component: ComponentResult, prefer_actual: bool = False) -> list[Polygon]:
if prefer_actual:
return list(component.physical_geometry)
return list(component.collision_geometry)
def component_bounds(component: ComponentResult, prefer_actual: bool = False) -> tuple[float, float, float, float]:
if not prefer_actual:
return component.total_bounds
polygons = component_polygons(component, prefer_actual=True)
min_x = min(polygon.bounds[0] for polygon in polygons)
min_y = min(polygon.bounds[1] for polygon in polygons)
max_x = max(polygon.bounds[2] for polygon in polygons)
max_y = max(polygon.bounds[3] for polygon in polygons)
return (min_x, min_y, max_x, max_y)
def components_overlap( def components_overlap(
component_a: ComponentResult, component_a: ComponentResult,
component_b: ComponentResult, component_b: ComponentResult,
prefer_actual: bool = False, prefer_actual: bool = False,
) -> bool: ) -> bool:
bounds_a = component_bounds(component_a, prefer_actual=prefer_actual) polygons_a: tuple[Polygon, ...]
bounds_b = component_bounds(component_b, prefer_actual=prefer_actual) polygons_b: tuple[Polygon, ...]
if prefer_actual:
polygons_a = component_a.physical_geometry
polygons_b = component_b.physical_geometry
bounds_a = (
min(polygon.bounds[0] for polygon in polygons_a),
min(polygon.bounds[1] for polygon in polygons_a),
max(polygon.bounds[2] for polygon in polygons_a),
max(polygon.bounds[3] for polygon in polygons_a),
)
bounds_b = (
min(polygon.bounds[0] for polygon in polygons_b),
min(polygon.bounds[1] for polygon in polygons_b),
max(polygon.bounds[2] for polygon in polygons_b),
max(polygon.bounds[3] for polygon in polygons_b),
)
else:
polygons_a = component_a.collision_geometry
polygons_b = component_b.collision_geometry
bounds_a = component_a.total_bounds
bounds_b = component_b.total_bounds
if not ( if not (
bounds_a[0] < bounds_b[2] bounds_a[0] < bounds_b[2]
and bounds_a[2] > bounds_b[0] and bounds_a[2] > bounds_b[0]
@ -41,18 +44,8 @@ def components_overlap(
): ):
return False return False
polygons_a = component_polygons(component_a, prefer_actual=prefer_actual)
polygons_b = component_polygons(component_b, prefer_actual=prefer_actual)
for polygon_a in polygons_a: for polygon_a in polygons_a:
for polygon_b in polygons_b: for polygon_b in polygons_b:
if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b): if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b):
return True return True
return False return False
def has_self_overlap(path: list[ComponentResult], prefer_actual: bool = False) -> bool:
for i, component in enumerate(path):
for j in range(i + 2, len(path)):
if components_overlap(component, path[j], prefer_actual=prefer_actual):
return True
return False

View file

@ -10,6 +10,7 @@ from shapely.affinity import translate as shapely_translate
from shapely.geometry import Polygon, box from shapely.geometry import Polygon, box
from inire.constants import TOLERANCE_ANGULAR from inire.constants import TOLERANCE_ANGULAR
from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed
from .primitives import Port, rotation_matrix2 from .primitives import Port, rotation_matrix2
@ -29,6 +30,7 @@ class ComponentResult:
end_port: Port end_port: Port
length: float length: float
move_type: MoveKind move_type: MoveKind
move_spec: PathSegmentSeed
physical_geometry: tuple[Polygon, ...] physical_geometry: tuple[Polygon, ...]
dilated_collision_geometry: tuple[Polygon, ...] dilated_collision_geometry: tuple[Polygon, ...]
dilated_physical_geometry: tuple[Polygon, ...] dilated_physical_geometry: tuple[Polygon, ...]
@ -80,6 +82,7 @@ class ComponentResult:
end_port=self.end_port.translate(dx, dy), end_port=self.end_port.translate(dx, dy),
length=self.length, length=self.length,
move_type=self.move_type, move_type=self.move_type,
move_spec=self.move_spec,
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry], physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry], dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry],
dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry], dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry],
@ -235,6 +238,7 @@ class Straight:
end_port=end_port, end_port=end_port,
length=abs(length_f), length=abs(length_f),
move_type="straight", move_type="straight",
move_spec=StraightSeed(length=length_f),
physical_geometry=geometry, physical_geometry=geometry,
dilated_collision_geometry=dilated_geometry, dilated_collision_geometry=dilated_geometry,
dilated_physical_geometry=dilated_geometry, dilated_physical_geometry=dilated_geometry,
@ -305,6 +309,7 @@ class Bend90:
end_port=end_port, end_port=end_port,
length=abs(radius) * numpy.pi / 2.0, length=abs(radius) * numpy.pi / 2.0,
move_type="bend90", move_type="bend90",
move_spec=Bend90Seed(radius=radius, direction=direction),
physical_geometry=physical_geometry, physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry, dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry, dilated_physical_geometry=dilated_physical_geometry,
@ -394,6 +399,7 @@ class SBend:
end_port=end_port, end_port=end_port,
length=2.0 * radius * theta, length=2.0 * radius * theta,
move_type="sbend", move_type="sbend",
move_spec=SBendSeed(offset=offset, radius=radius),
physical_geometry=physical_geometry, physical_geometry=physical_geometry,
dilated_collision_geometry=dilated_collision_geometry, dilated_collision_geometry=dilated_collision_geometry,
dilated_physical_geometry=dilated_physical_geometry, dilated_physical_geometry=dilated_physical_geometry,

View file

@ -87,8 +87,3 @@ class DynamicPathIndex:
self.index.delete(obj_id, self.dilated[obj_id].bounds) self.index.delete(obj_id, self.dilated[obj_id].bounds)
del self.geometries[obj_id] del self.geometries[obj_id]
del self.dilated[obj_id] del self.dilated[obj_id]
def clear_paths(self) -> None:
if not self.geometries:
return
self.remove_obj_ids(list(self.geometries))

View file

@ -61,11 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32)
def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]: def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]:
quadrant = (_normalize_angle(rotation_deg) // 90) % 4 quadrant = (_normalize_angle(rotation_deg) // 90) % 4
return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant] return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant]
def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]:
rot2 = rotation_matrix2(rotation_deg)
rot3 = numpy.zeros((3, 3), dtype=numpy.int32)
rot3[:2, :2] = rot2
rot3[2, 2] = 1
return rot3

View file

@ -1,18 +1,22 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from inire.geometry.components import BendCollisionModel from inire.geometry.components import BendCollisionModel
from inire.router.results import RouteMetrics, RoutingResult from inire.seeds import PathSeed
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.geometry.components import ComponentResult from inire.geometry.components import BendCollisionModel
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
NetOrder = Literal["user", "shortest", "longest"]
VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class NetSpec: class NetSpec:
net_id: str net_id: str
@ -21,37 +25,12 @@ class NetSpec:
width: float = 2.0 width: float = 2.0
@dataclass(frozen=True, slots=True)
class LockedRoute:
geometry: tuple[Polygon, ...]
def __post_init__(self) -> None:
object.__setattr__(self, "geometry", tuple(self.geometry))
@classmethod
def from_path(cls, path: tuple[ComponentResult, ...] | list[ComponentResult]) -> LockedRoute:
polygons = []
for component in path:
polygons.extend(component.physical_geometry)
return cls(geometry=tuple(polygons))
def _coerce_locked_route(route: LockedRoute | tuple | list) -> LockedRoute:
if isinstance(route, LockedRoute):
return route
route_items = tuple(route)
if route_items and hasattr(route_items[0], "physical_geometry"):
return LockedRoute.from_path(route_items) # type: ignore[arg-type]
return LockedRoute(geometry=route_items)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ObjectiveWeights: class ObjectiveWeights:
unit_length_cost: float = 1.0 unit_length_cost: float = 1.0
bend_penalty: float = 250.0 bend_penalty: float = 250.0
sbend_penalty: float = 500.0 sbend_penalty: float = 500.0
danger_weight: float = 1.0 danger_weight: float = 1.0
congestion_penalty: float = 0.0
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -64,23 +43,13 @@ class SearchOptions:
bend_radii: tuple[float, ...] = (50.0, 100.0) bend_radii: tuple[float, ...] = (50.0, 100.0)
sbend_radii: tuple[float, ...] = (10.0,) sbend_radii: tuple[float, ...] = (10.0,)
bend_collision_type: BendCollisionModel = "arc" bend_collision_type: BendCollisionModel = "arc"
visibility_guidance: str = "tangent_corner" visibility_guidance: VisibilityGuidance = "tangent_corner"
initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "bend_radii", tuple(self.bend_radii)) object.__setattr__(self, "bend_radii", tuple(self.bend_radii))
object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii)) object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii))
if self.sbend_offsets is not None: if self.sbend_offsets is not None:
object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets)) object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets))
if self.initial_paths is not None:
object.__setattr__(
self,
"initial_paths",
{
net_id: tuple(path)
for net_id, path in self.initial_paths.items()
},
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -89,9 +58,9 @@ class CongestionOptions:
base_penalty: float = 100.0 base_penalty: float = 100.0
multiplier: float = 1.5 multiplier: float = 1.5
use_tiered_strategy: bool = True use_tiered_strategy: bool = True
warm_start: str | None = "shortest" net_order: NetOrder = "user"
warm_start_enabled: bool = True
shuffle_nets: bool = False shuffle_nets: bool = False
sort_nets: str | None = None
seed: int | None = None seed: int | None = None
@ -120,26 +89,18 @@ class RoutingProblem:
bounds: tuple[float, float, float, float] bounds: tuple[float, float, float, float]
nets: tuple[NetSpec, ...] = () nets: tuple[NetSpec, ...] = ()
static_obstacles: tuple[Polygon, ...] = () static_obstacles: tuple[Polygon, ...] = ()
locked_routes: dict[str, LockedRoute] = field(default_factory=dict) initial_paths: dict[str, PathSeed] = field(default_factory=dict)
clearance: float = 2.0 clearance: float = 2.0
max_net_width: float = 2.0
safety_zone_radius: float = 0.0021 safety_zone_radius: float = 0.0021
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "nets", tuple(self.nets)) object.__setattr__(self, "nets", tuple(self.nets))
object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles)) object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles))
initial_paths = dict(self.initial_paths)
if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()):
raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances")
object.__setattr__( object.__setattr__(
self, self,
"locked_routes", "initial_paths",
{ initial_paths,
net_id: _coerce_locked_route(route)
for net_id, route in self.locked_routes.items()
},
) )
@dataclass(frozen=True, slots=True)
class RoutingRunResult:
results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = ()

86
inire/results.py Normal file
View file

@ -0,0 +1,86 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal
from inire.seeds import PathSeed
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.components import ComponentResult
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
@dataclass(frozen=True, slots=True)
class RoutingReport:
static_collision_count: int = 0
dynamic_collision_count: int = 0
self_collision_count: int = 0
total_length: float = 0.0
@property
def collision_count(self) -> int:
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
@property
def is_valid(self) -> bool:
return self.collision_count == 0
@dataclass(frozen=True, slots=True)
class RouteMetrics:
nodes_expanded: int
moves_generated: int
moves_added: int
pruned_closed_set: int
pruned_hard_collision: int
pruned_cost: int
@dataclass(frozen=True, slots=True)
class RoutingResult:
net_id: str
path: tuple[ComponentResult, ...]
reached_target: bool = False
report: RoutingReport = field(default_factory=RoutingReport)
def __post_init__(self) -> None:
object.__setattr__(self, "path", tuple(self.path))
@property
def collisions(self) -> int:
return self.report.collision_count
@property
def outcome(self) -> RoutingOutcome:
if not self.path:
return "unroutable"
if not self.reached_target:
return "partial"
if self.report.collision_count > 0:
return "colliding"
return "completed"
@property
def is_valid(self) -> bool:
return self.outcome == "completed"
@property
def locked_geometry(self) -> tuple[Polygon, ...]:
polygons = []
for component in self.path:
polygons.extend(component.physical_geometry)
return tuple(polygons)
def as_seed(self) -> PathSeed:
return PathSeed(tuple(component.move_spec for component in self.path))
@dataclass(frozen=True, slots=True)
class RoutingRunResult:
results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = ()

View file

@ -1,16 +1,16 @@
from __future__ import annotations from __future__ import annotations
import heapq import heapq
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.constants import TOLERANCE_LINEAR from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import Bend90, SBend, Straight, BendCollisionModel, MoveKind from inire.geometry.components import Bend90, SBend, Straight, MoveKind
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router.refiner import component_hits_ancestor_chain from inire.router.refiner import component_hits_ancestor_chain
from ._astar_types import AStarContext, AStarMetrics, AStarNode from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -26,15 +26,12 @@ def process_move(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
config: SearchRunConfig,
move_class: MoveKind, move_class: MoveKind,
params: tuple, params: tuple,
skip_congestion: bool,
bend_collision_type: BendCollisionModel,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None: ) -> None:
cp = parent.port cp = parent.port
coll_type = bend_collision_type coll_type = config.bend_collision_type
coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type coll_key = id(coll_type) if isinstance(coll_type, Polygon) else coll_type
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0 self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
@ -101,12 +98,9 @@ def process_move(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
config,
move_class, move_class,
abs_key, abs_key,
move_radius=move_radius,
skip_congestion=skip_congestion,
max_cost=max_cost,
self_collision_check=self_collision_check,
) )
@ -121,12 +115,9 @@ def add_node(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
config: SearchRunConfig,
move_type: MoveKind, move_type: MoveKind,
cache_key: tuple, cache_key: tuple,
move_radius: float | None = None,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> None: ) -> None:
metrics.moves_generated += 1 metrics.moves_generated += 1
metrics.total_moves_generated += 1 metrics.total_moves_generated += 1
@ -151,7 +142,7 @@ def add_node(
if move_type == "straight": if move_type == "straight":
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width) collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
else: else:
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width) collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
if collision_found: if collision_found:
context.hard_collision_set.add(cache_key) context.hard_collision_set.add(cache_key)
metrics.pruned_hard_collision += 1 metrics.pruned_hard_collision += 1
@ -160,36 +151,23 @@ def add_node(
context.static_safe_cache.add(cache_key) context.static_safe_cache.add(cache_key)
total_overlaps = 0 total_overlaps = 0
if not skip_congestion: if not config.skip_congestion:
if cache_key in congestion_cache: if cache_key in congestion_cache:
total_overlaps = congestion_cache[cache_key] total_overlaps = congestion_cache[cache_key]
else: else:
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id) total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
congestion_cache[cache_key] = total_overlaps congestion_cache[cache_key] = total_overlaps
if self_collision_check and component_hits_ancestor_chain(result, parent): if config.self_collision_check and component_hits_ancestor_chain(result, parent):
return return
penalty = context.cost_evaluator.component_penalty( move_cost = context.cost_evaluator.score_component(
move_type, result,
move_radius=move_radius,
)
move_cost = context.cost_evaluator.evaluate_move(
result.collision_geometry,
result.end_port,
net_width,
net_id,
start_port=parent_p, start_port=parent_p,
length=result.length,
dilated_geometry=result.dilated_collision_geometry,
penalty=penalty,
skip_static=True,
skip_congestion=True,
) )
move_cost += total_overlaps * context.cost_evaluator.congestion_penalty move_cost += total_overlaps * context.congestion_penalty
if max_cost is not None and parent.g_cost + move_cost > max_cost: if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost:
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
return return
@ -204,7 +182,11 @@ def add_node(
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
h_cost = context.cost_evaluator.h_manhattan(result.end_port, target) h_cost = context.cost_evaluator.h_manhattan(
result.end_port,
target,
min_bend_radius=context.min_bend_radius,
)
heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result)) heapq.heappush(open_set, AStarNode(result.end_port, g_cost, h_cost, parent, result))
metrics.moves_added += 1 metrics.moves_added += 1
metrics.total_moves_added += 1 metrics.total_moves_added += 1

View file

@ -3,11 +3,11 @@ from __future__ import annotations
import math import math
from inire.constants import TOLERANCE_LINEAR from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import BendCollisionModel, MoveKind from inire.geometry.components import MoveKind
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from ._astar_admission import process_move from ._astar_admission import process_move
from ._astar_types import AStarContext, AStarMetrics, AStarNode from ._astar_types import AStarContext, AStarMetrics, AStarNode, SearchRunConfig
def _quantized_lengths(values: list[float], max_reach: float) -> list[int]: def _quantized_lengths(values: list[float], max_reach: float) -> list[int]:
@ -129,13 +129,9 @@ def expand_moves(
context: AStarContext, context: AStarContext,
metrics: AStarMetrics, metrics: AStarMetrics,
congestion_cache: dict[tuple, int], congestion_cache: dict[tuple, int],
bend_collision_type: BendCollisionModel | None = None, config: SearchRunConfig,
max_cost: float | None = None,
skip_congestion: bool = False,
self_collision_check: bool = False,
) -> None: ) -> None:
search_options = context.options.search search_options = context.options.search
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type
cp = current.port cp = current.port
prev_move_type, prev_straight_length = _previous_move_metadata(current) prev_move_type, prev_straight_length = _previous_move_metadata(current)
dx_t = target.x - cp.x dx_t = target.x - cp.x
@ -171,12 +167,9 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
config,
"straight", "straight",
(int(round(proj_t)),), (int(round(proj_t)),),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
) )
max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width) max_reach = context.cost_evaluator.collision_engine.ray_cast(cp, cp.r, search_options.max_straight_length, net_width=net_width)
@ -225,12 +218,9 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
config,
"straight", "straight",
(length,), (length,),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
) )
angle_to_target = 0.0 angle_to_target = 0.0
@ -256,12 +246,9 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
config,
"bend90", "bend90",
(radius, direction), (radius, direction),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
) )
max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0 max_sbend_r = max(search_options.sbend_radii) if search_options.sbend_radii else 0.0
@ -293,10 +280,7 @@ def expand_moves(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
config,
"sbend", "sbend",
(offset, radius), (offset, radius),
skip_congestion,
bend_collision_type=effective_bend_collision_type,
max_cost=max_cost,
self_collision_check=self_collision_check,
) )

View file

@ -1,16 +1,53 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.geometry.components import BendCollisionModel
from inire.model import RoutingOptions, RoutingProblem from inire.model import RoutingOptions, RoutingProblem
from inire.results import RouteMetrics
from inire.router.visibility import VisibilityManager from inire.router.visibility import VisibilityManager
from inire.router.results import RouteMetrics
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
@dataclass(frozen=True, slots=True)
class SearchRunConfig:
bend_collision_type: BendCollisionModel
node_limit: int
return_partial: bool = False
store_expanded: bool = False
skip_congestion: bool = False
max_cost: float | None = None
self_collision_check: bool = False
@classmethod
def from_options(
cls,
options: RoutingOptions,
*,
bend_collision_type: BendCollisionModel | None = None,
node_limit: int | None = None,
return_partial: bool = False,
store_expanded: bool = False,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
) -> SearchRunConfig:
search = options.search
return cls(
bend_collision_type=search.bend_collision_type if bend_collision_type is None else bend_collision_type,
node_limit=search.node_limit if node_limit is None else node_limit,
return_partial=return_partial,
store_expanded=store_expanded,
skip_congestion=skip_congestion,
max_cost=max_cost,
self_collision_check=self_collision_check,
)
class AStarNode: class AStarNode:
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result") __slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
@ -96,6 +133,8 @@ class AStarMetrics:
class AStarContext: class AStarContext:
__slots__ = ( __slots__ = (
"cost_evaluator", "cost_evaluator",
"congestion_penalty",
"min_bend_radius",
"problem", "problem",
"options", "options",
"max_cache_size", "max_cache_size",
@ -115,10 +154,11 @@ class AStarContext:
max_cache_size: int = 1000000, max_cache_size: int = 1000000,
) -> None: ) -> None:
self.cost_evaluator = cost_evaluator self.cost_evaluator = cost_evaluator
self.congestion_penalty = 0.0
self.max_cache_size = max_cache_size self.max_cache_size = max_cache_size
self.problem = problem self.problem = problem
self.options = options self.options = options
self.cost_evaluator.set_min_bend_radius(min(self.options.search.bend_radii, default=50.0)) self.min_bend_radius = min(self.options.search.bend_radii, default=50.0)
self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine) self.visibility_manager = VisibilityManager(self.cost_evaluator.collision_engine)
self.move_cache_rel: dict[tuple, ComponentResult] = {} self.move_cache_rel: dict[tuple, ComponentResult] = {}
self.move_cache_abs: dict[tuple, ComponentResult] = {} self.move_cache_abs: dict[tuple, ComponentResult] = {}

View file

@ -5,19 +5,17 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.model import NetSpec, RoutingOptions, RoutingProblem from inire.model import NetOrder, NetSpec
from inire.router._astar_types import AStarContext, AStarMetrics from inire.results import RoutingOutcome, RoutingReport, RoutingResult
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.outcomes import RoutingOutcome, routing_outcome_needs_retry from inire.router._seed_materialization import materialize_path_seed
from inire.router.refiner import PathRefiner from inire.router.refiner import PathRefiner
from inire.router.results import RoutingReport, RoutingResult
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
@dataclass(slots=True) @dataclass(slots=True)
@ -31,10 +29,6 @@ class _RoutingState:
initial_paths: dict[str, tuple[ComponentResult, ...]] | None initial_paths: dict[str, tuple[ComponentResult, ...]] | None
accumulated_expanded_nodes: list[tuple[int, int, int]] accumulated_expanded_nodes: list[tuple[int, int, int]]
__all__ = ["PathFinder"]
class PathFinder: class PathFinder:
__slots__ = ( __slots__ = (
"context", "context",
@ -53,83 +47,18 @@ class PathFinder:
self.refiner = PathRefiner(self.context) self.refiner = PathRefiner(self.context)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
@property def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
def problem(self) -> RoutingProblem:
return self.context.problem
@property
def options(self) -> RoutingOptions:
return self.context.options
@property
def cost_evaluator(self) -> CostEvaluator:
return self.context.cost_evaluator
def _path_cost(self, path: Sequence[ComponentResult]) -> float:
return self.refiner.path_cost(path)
def _refine_path(
self,
net_id: str,
start: Port,
target: Port,
net_width: float,
path: Sequence[ComponentResult],
) -> list[ComponentResult]:
return self.refiner.refine_path(net_id, start, target, net_width, path)
def _extract_path_geometry(self, path: Sequence[ComponentResult]) -> tuple[list, list]:
all_geoms = [] all_geoms = []
all_dilated = [] all_dilated = []
for result in path: for result in path:
all_geoms.extend(result.collision_geometry) all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry) all_dilated.extend(result.dilated_collision_geometry)
return all_geoms, all_dilated self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
all_geoms, all_dilated = self._extract_path_geometry(path)
self.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
def _stage_path_as_static(self, path: Sequence[ComponentResult]) -> list[int]:
obj_ids: list[int] = []
for result in path:
for polygon in result.physical_geometry:
obj_ids.append(self.cost_evaluator.collision_engine.add_static_obstacle(polygon))
return obj_ids
def _remove_static_obstacles(self, obj_ids: list[int]) -> None:
for obj_id in obj_ids:
self.cost_evaluator.collision_engine.remove_static_obstacle(obj_id)
def _remove_path(self, net_id: str) -> None:
self.cost_evaluator.collision_engine.remove_path(net_id)
def _verify_path_report(self, net_id: str, path: Sequence[ComponentResult]) -> RoutingReport:
return self.cost_evaluator.collision_engine.verify_path_report(net_id, path)
def _finalize_dynamic_tree(self) -> None:
self.cost_evaluator.collision_engine.rebuild_dynamic_tree()
def _build_routing_result(
self,
*,
net_id: str,
path: Sequence[ComponentResult],
reached_target: bool | None = None,
report: RoutingReport | None = None,
) -> RoutingResult:
resolved_reached_target = bool(path) if reached_target is None else reached_target
return RoutingResult(
net_id=net_id,
path=path,
reached_target=resolved_reached_target,
report=report if report is not None else RoutingReport(),
)
def _routing_order( def _routing_order(
self, self,
net_specs: dict[str, NetSpec], net_specs: dict[str, NetSpec],
order: str, order: NetOrder,
) -> list[str]: ) -> list[str]:
ordered_net_ids = list(net_specs.keys()) ordered_net_ids = list(net_specs.keys())
if order == "user": if order == "user":
@ -144,15 +73,26 @@ class PathFinder:
def _build_greedy_warm_start_paths( def _build_greedy_warm_start_paths(
self, self,
net_specs: dict[str, NetSpec], net_specs: dict[str, NetSpec],
order: str, order: NetOrder,
) -> dict[str, tuple[ComponentResult, ...]]: ) -> dict[str, tuple[ComponentResult, ...]]:
greedy_paths: dict[str, tuple[ComponentResult, ...]] = {} greedy_paths: dict[str, tuple[ComponentResult, ...]] = {}
temp_obj_ids: list[int] = [] temp_obj_ids: list[int] = []
greedy_node_limit = min(self.options.search.node_limit, 2000) greedy_node_limit = min(self.context.options.search.node_limit, 2000)
for net_id in self._routing_order(net_specs, order): for net_id in self._routing_order(net_specs, order):
net = net_specs[net_id] net = net_specs[net_id]
h_start = self.cost_evaluator.h_manhattan(net.start, net.target) h_start = self.context.cost_evaluator.h_manhattan(
net.start,
net.target,
min_bend_radius=self.context.min_bend_radius,
)
max_cost_limit = max(h_start * 3.0, 2000.0) max_cost_limit = max(h_start * 3.0, 2000.0)
run_config = SearchRunConfig.from_options(
self.context.options,
skip_congestion=True,
max_cost=max_cost_limit,
self_collision_check=True,
node_limit=greedy_node_limit,
)
path = route_astar( path = route_astar(
net.start, net.start,
net.target, net.target,
@ -160,24 +100,24 @@ class PathFinder:
context=self.context, context=self.context,
metrics=self.metrics, metrics=self.metrics,
net_id=net_id, net_id=net_id,
skip_congestion=True, config=run_config,
max_cost=max_cost_limit,
self_collision_check=True,
node_limit=greedy_node_limit,
) )
if not path: if not path:
continue continue
greedy_paths[net_id] = tuple(path) greedy_paths[net_id] = tuple(path)
temp_obj_ids.extend(self._stage_path_as_static(path)) for result in path:
for polygon in result.physical_geometry:
temp_obj_ids.append(self.context.cost_evaluator.collision_engine.add_static_obstacle(polygon))
self.context.clear_static_caches() self.context.clear_static_caches()
self._remove_static_obstacles(temp_obj_ids) for obj_id in temp_obj_ids:
self.context.cost_evaluator.collision_engine.remove_static_obstacle(obj_id)
return greedy_paths return greedy_paths
def _prepare_state(self) -> _RoutingState: def _prepare_state(self) -> _RoutingState:
problem = self.problem problem = self.context.problem
congestion = self.options.congestion congestion = self.context.options.congestion
initial_paths = self.options.search.initial_paths initial_paths = self._materialize_problem_initial_paths()
net_specs = {net.net_id: net for net in problem.nets} net_specs = {net.net_id: net for net in problem.nets}
num_nets = len(net_specs) num_nets = len(net_specs)
state = _RoutingState( state = _RoutingState(
@ -190,27 +130,45 @@ class PathFinder:
initial_paths=initial_paths, initial_paths=initial_paths,
accumulated_expanded_nodes=[], accumulated_expanded_nodes=[],
) )
if state.initial_paths is None: if state.initial_paths is None and congestion.warm_start_enabled:
warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order)
if warm_start_order is not None: self.context.clear_static_caches()
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order)
self.context.clear_static_caches()
if congestion.sort_nets and congestion.sort_nets != "user": if congestion.net_order != "user":
state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets) state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order)
return state return state
def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None:
if not self.context.problem.initial_paths:
return None
search = self.context.options.search
net_specs = {net.net_id: net for net in self.context.problem.nets}
initial_paths: dict[str, tuple[ComponentResult, ...]] = {}
for net_id, seed in self.context.problem.initial_paths.items():
if net_id not in net_specs:
raise ValueError(f"Initial path provided for unknown net: {net_id}")
net = net_specs[net_id]
initial_paths[net_id] = materialize_path_seed(
seed,
start=net.start,
net_width=net.width,
search=search,
clearance=self.context.cost_evaluator.collision_engine.clearance,
)
return initial_paths
def _route_net_once( def _route_net_once(
self, self,
state: _RoutingState, state: _RoutingState,
iteration: int, iteration: int,
net_id: str, net_id: str,
) -> RoutingResult: ) -> RoutingResult:
search = self.options.search search = self.context.options.search
congestion = self.options.congestion congestion = self.context.options.congestion
diagnostics = self.options.diagnostics diagnostics = self.context.options.diagnostics
net = state.net_specs[net_id] net = state.net_specs[net_id]
self._remove_path(net_id) self.context.cost_evaluator.collision_engine.remove_path(net_id)
if iteration == 0 and state.initial_paths and net_id in state.initial_paths: if iteration == 0 and state.initial_paths and net_id in state.initial_paths:
path: Sequence[ComponentResult] | None = state.initial_paths[net_id] path: Sequence[ComponentResult] | None = state.initial_paths[net_id]
@ -222,13 +180,8 @@ class PathFinder:
if coll_model == "arc": if coll_model == "arc":
coll_model = "clipped_bbox" coll_model = "clipped_bbox"
path = route_astar( run_config = SearchRunConfig.from_options(
net.start, self.context.options,
net.target,
net.width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
bend_collision_type=coll_model, bend_collision_type=coll_model,
return_partial=True, return_partial=True,
store_expanded=diagnostics.capture_expanded, store_expanded=diagnostics.capture_expanded,
@ -236,26 +189,35 @@ class PathFinder:
self_collision_check=(net_id in state.needs_self_collision_check), self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=search.node_limit, node_limit=search.node_limit,
) )
path = route_astar(
net.start,
net.target,
net.width,
context=self.context,
metrics=self.metrics,
net_id=net_id,
config=run_config,
)
if diagnostics.capture_expanded and self.metrics.last_expanded_nodes: if diagnostics.capture_expanded and self.metrics.last_expanded_nodes:
state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes) state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
if not path: if not path:
return self._build_routing_result(net_id=net_id, path=[], reached_target=False) return RoutingResult(net_id=net_id, path=(), reached_target=False)
reached_target = path[-1].end_port == net.target reached_target = path[-1].end_port == net.target
report = None report = None
self._install_path(net_id, path) self._install_path(net_id, path)
if reached_target: if reached_target:
report = self._verify_path_report(net_id, path) report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, path)
if report.self_collision_count > 0: if report.self_collision_count > 0:
state.needs_self_collision_check.add(net_id) state.needs_self_collision_check.add(net_id)
return self._build_routing_result( return RoutingResult(
net_id=net_id, net_id=net_id,
path=path, path=path,
reached_target=reached_target, reached_target=reached_target,
report=report, report=RoutingReport() if report is None else report,
) )
def _run_iteration( def _run_iteration(
@ -265,7 +227,7 @@ class PathFinder:
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> dict[str, RoutingOutcome] | None: ) -> dict[str, RoutingOutcome] | None:
outcomes: dict[str, RoutingOutcome] = {} outcomes: dict[str, RoutingOutcome] = {}
congestion = self.options.congestion congestion = self.context.options.congestion
self.metrics.reset_per_route() self.metrics.reset_per_route()
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
@ -274,7 +236,6 @@ class PathFinder:
for net_id in state.ordered_net_ids: for net_id in state.ordered_net_ids:
if time.monotonic() - state.start_time > state.timeout_s: if time.monotonic() - state.start_time > state.timeout_s:
self._finalize_dynamic_tree()
return None return None
result = self._route_net_once(state, iteration, net_id) result = self._route_net_once(state, iteration, net_id)
@ -290,30 +251,30 @@ class PathFinder:
state: _RoutingState, state: _RoutingState,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
) -> bool: ) -> bool:
congestion = self.options.congestion congestion = self.context.options.congestion
for iteration in range(congestion.max_iterations): for iteration in range(congestion.max_iterations):
outcomes = self._run_iteration(state, iteration, iteration_callback) outcomes = self._run_iteration(state, iteration, iteration_callback)
if outcomes is None: if outcomes is None:
return True return True
if not any(routing_outcome_needs_retry(outcome) for outcome in outcomes.values()): if not any(outcome in {"colliding", "partial", "unroutable"} for outcome in outcomes.values()):
return False return False
self.cost_evaluator.congestion_penalty *= congestion.multiplier self.context.congestion_penalty *= congestion.multiplier
return False return False
def _refine_results(self, state: _RoutingState) -> None: def _refine_results(self, state: _RoutingState) -> None:
if not self.options.refinement.enabled or not state.results: if not self.context.options.refinement.enabled or not state.results:
return return
for net_id in state.ordered_net_ids: for net_id in state.ordered_net_ids:
result = state.results.get(net_id) result = state.results.get(net_id)
if not result or not result.path or routing_outcome_needs_retry(result.outcome): if not result or not result.path or result.outcome in {"colliding", "partial", "unroutable"}:
continue continue
net = state.net_specs[net_id] net = state.net_specs[net_id]
self._remove_path(net_id) self.context.cost_evaluator.collision_engine.remove_path(net_id)
refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path) refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
self._install_path(net_id, refined_path) self._install_path(net_id, refined_path)
report = self._verify_path_report(net_id, refined_path) report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
state.results[net_id] = self._build_routing_result( state.results[net_id] = RoutingResult(
net_id=net_id, net_id=net_id,
path=refined_path, path=refined_path,
reached_target=result.reached_target, reached_target=result.reached_target,
@ -322,17 +283,13 @@ class PathFinder:
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {} final_results: dict[str, RoutingResult] = {}
for net in self.problem.nets: for net in self.context.problem.nets:
result = state.results.get(net.net_id) result = state.results.get(net.net_id)
if not result or not result.path: if not result or not result.path:
final_results[net.net_id] = self._build_routing_result( final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
net_id=net.net_id,
path=[],
reached_target=False,
)
continue continue
report = self._verify_path_report(net.net_id, result.path) report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path)
final_results[net.net_id] = self._build_routing_result( final_results[net.net_id] = RoutingResult(
net_id=net.net_id, net_id=net.net_id,
path=result.path, path=result.path,
reached_target=result.reached_target, reached_target=result.reached_target,
@ -345,7 +302,7 @@ class PathFinder:
*, *,
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None, iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
) -> dict[str, RoutingResult]: ) -> dict[str, RoutingResult]:
self.cost_evaluator.congestion_penalty = self.options.congestion.base_penalty self.context.congestion_penalty = self.context.options.congestion.base_penalty
self.accumulated_expanded_nodes = [] self.accumulated_expanded_nodes = []
self.metrics.reset_totals() self.metrics.reset_totals()
self.metrics.reset_per_route() self.metrics.reset_per_route()
@ -358,5 +315,4 @@ class PathFinder:
return self._verify_results(state) return self._verify_results(state)
self._refine_results(state) self._refine_results(state)
self._finalize_dynamic_tree()
return self._verify_results(state) return self._verify_results(state)

View file

@ -4,12 +4,10 @@ import heapq
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.constants import TOLERANCE_LINEAR from inire.constants import TOLERANCE_LINEAR
from inire.geometry.components import BendCollisionModel
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from ._astar_moves import expand_moves as _expand_moves from ._astar_moves import expand_moves as _expand_moves
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig
from .results import RouteMetrics
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -29,21 +27,14 @@ def route_astar(
target: Port, target: Port,
net_width: float, net_width: float,
context: AStarContext, context: AStarContext,
*,
metrics: AStarMetrics | None = None, metrics: AStarMetrics | None = None,
net_id: str = "default", net_id: str = "default",
bend_collision_type: BendCollisionModel | None = None, config: SearchRunConfig,
return_partial: bool = False,
store_expanded: bool = False,
skip_congestion: bool = False,
max_cost: float | None = None,
self_collision_check: bool = False,
node_limit: int | None = None,
) -> list[ComponentResult] | None: ) -> list[ComponentResult] | None:
if metrics is None: if metrics is None:
metrics = AStarMetrics() metrics = AStarMetrics()
metrics.reset_per_route() metrics.reset_per_route()
search_options = context.options.search
effective_bend_collision_type = bend_collision_type if bend_collision_type is not None else search_options.bend_collision_type
context.ensure_static_caches_current() context.ensure_static_caches_current()
context.cost_evaluator.set_target(target) context.cost_evaluator.set_target(target)
@ -51,18 +42,21 @@ def route_astar(
closed_set: dict[tuple[int, int, int], float] = {} closed_set: dict[tuple[int, int, int], float] = {}
congestion_cache: dict[tuple, int] = {} congestion_cache: dict[tuple, int] = {}
start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target)) start_node = _AStarNode(
start,
0.0,
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
)
heapq.heappush(open_set, start_node) heapq.heappush(open_set, start_node)
best_node = start_node best_node = start_node
effective_node_limit = node_limit if node_limit is not None else search_options.node_limit
nodes_expanded = 0 nodes_expanded = 0
while open_set: while open_set:
if nodes_expanded >= effective_node_limit: if nodes_expanded >= config.node_limit:
return _reconstruct_path(best_node) if return_partial else None return _reconstruct_path(best_node) if config.return_partial else None
current = heapq.heappop(open_set) current = heapq.heappop(open_set)
if max_cost is not None and current.fh_cost[0] > max_cost: if config.max_cost is not None and current.fh_cost[0] > config.max_cost:
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
continue continue
@ -75,7 +69,7 @@ def route_astar(
continue continue
closed_set[state] = current.g_cost closed_set[state] = current.g_cost
if store_expanded: if config.store_expanded:
metrics.last_expanded_nodes.append(state) metrics.last_expanded_nodes.append(state)
nodes_expanded += 1 nodes_expanded += 1
@ -95,18 +89,7 @@ def route_astar(
context, context,
metrics, metrics,
congestion_cache, congestion_cache,
bend_collision_type=effective_bend_collision_type, config=config,
max_cost=max_cost,
skip_congestion=skip_congestion,
self_collision_check=self_collision_check,
) )
return _reconstruct_path(best_node) if return_partial else None return _reconstruct_path(best_node) if config.return_partial else None
__all__ = [
"AStarContext",
"AStarMetrics",
"RouteMetrics",
"route_astar",
]

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from inire.model import SearchOptions
from inire.seeds import Bend90Seed, PathSeed, SBendSeed, StraightSeed
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port
def materialize_path_seed(
seed: PathSeed,
*,
start: Port,
net_width: float,
search: SearchOptions,
clearance: float,
) -> tuple[ComponentResult, ...]:
from inire.geometry.components import Bend90, SBend, Straight
path: list[ComponentResult] = []
current = start
dilation = clearance / 2.0
bend_collision_type = search.bend_collision_type
for segment in seed.segments:
if isinstance(segment, StraightSeed):
component = Straight.generate(current, segment.length, net_width, dilation=dilation)
elif isinstance(segment, Bend90Seed):
component = Bend90.generate(
current,
segment.radius,
net_width,
segment.direction,
collision_type=bend_collision_type,
dilation=dilation,
)
elif isinstance(segment, SBendSeed):
component = SBend.generate(
current,
segment.offset,
segment.radius,
net_width,
collision_type=bend_collision_type,
dilation=dilation,
)
else:
raise TypeError(f"Unsupported seed segment: {type(segment)!r}")
path.append(component)
current = component.end_port
return tuple(path)

52
inire/router/_stack.py Normal file
View file

@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass
from inire.model import RoutingOptions, RoutingProblem
@dataclass(frozen=True, slots=True)
class RoutingStack:
world: object
danger_map: object
evaluator: object
context: object
finder: object
def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack:
from inire.geometry.collision import RoutingWorld
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
world = RoutingWorld(
clearance=problem.clearance,
safety_zone_radius=problem.safety_zone_radius,
)
for obstacle in problem.static_obstacles:
world.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=problem.bounds)
danger_map.precompute(list(problem.static_obstacles))
objective = options.objective
evaluator = CostEvaluator(
world,
danger_map,
unit_length_cost=objective.unit_length_cost,
greedy_h_weight=options.search.greedy_h_weight,
bend_penalty=objective.bend_penalty,
sbend_penalty=objective.sbend_penalty,
danger_weight=objective.danger_weight,
)
context = AStarContext(evaluator, problem, options)
finder = PathFinder(context)
return RoutingStack(
world=world,
danger_map=danger_map,
evaluator=evaluator,
context=context,
finder=finder,
)

View file

@ -5,13 +5,9 @@ from typing import TYPE_CHECKING
import numpy as np import numpy as np
from inire.constants import TOLERANCE_LINEAR from inire.constants import TOLERANCE_LINEAR
from inire.model import ObjectiveWeights, RoutingOptions from inire.model import ObjectiveWeights
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult, MoveKind from inire.geometry.components import ComponentResult, MoveKind
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
@ -22,18 +18,13 @@ class CostEvaluator:
__slots__ = ( __slots__ = (
"collision_engine", "collision_engine",
"danger_map", "danger_map",
"_unit_length_cost", "_search_weights",
"_greedy_h_weight", "_greedy_h_weight",
"_bend_penalty",
"_sbend_penalty",
"_danger_weight",
"_congestion_penalty",
"_target_x", "_target_x",
"_target_y", "_target_y",
"_target_r", "_target_r",
"_target_cos", "_target_cos",
"_target_sin", "_target_sin",
"_min_radius",
) )
def __init__( def __init__(
@ -49,91 +40,25 @@ class CostEvaluator:
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
self.collision_engine = collision_engine self.collision_engine = collision_engine
self.danger_map = danger_map self.danger_map = danger_map
self._unit_length_cost = float(unit_length_cost) self._search_weights = ObjectiveWeights(
unit_length_cost=unit_length_cost,
bend_penalty=bend_penalty,
sbend_penalty=actual_sbend_penalty,
danger_weight=danger_weight,
)
self._greedy_h_weight = float(greedy_h_weight) self._greedy_h_weight = float(greedy_h_weight)
self._bend_penalty = float(bend_penalty)
self._sbend_penalty = float(actual_sbend_penalty)
self._danger_weight = float(danger_weight)
self._congestion_penalty = 0.0
self._target_x = 0.0 self._target_x = 0.0
self._target_y = 0.0 self._target_y = 0.0
self._target_r = 0 self._target_r = 0
self._target_cos = 1.0 self._target_cos = 1.0
self._target_sin = 0.0 self._target_sin = 0.0
self._min_radius = 50.0
@property @property
def unit_length_cost(self) -> float: def default_weights(self) -> ObjectiveWeights:
return self._unit_length_cost return self._search_weights
@unit_length_cost.setter def _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights:
def unit_length_cost(self, value: float) -> None: return self._search_weights if weights is None else weights
self._unit_length_cost = float(value)
@property
def greedy_h_weight(self) -> float:
return self._greedy_h_weight
@greedy_h_weight.setter
def greedy_h_weight(self, value: float) -> None:
self._greedy_h_weight = float(value)
@property
def congestion_penalty(self) -> float:
return self._congestion_penalty
@congestion_penalty.setter
def congestion_penalty(self, value: float) -> None:
self._congestion_penalty = float(value)
@property
def bend_penalty(self) -> float:
return self._bend_penalty
@bend_penalty.setter
def bend_penalty(self, value: float) -> None:
self._bend_penalty = float(value)
@property
def sbend_penalty(self) -> float:
return self._sbend_penalty
@sbend_penalty.setter
def sbend_penalty(self, value: float) -> None:
self._sbend_penalty = float(value)
@property
def danger_weight(self) -> float:
return self._danger_weight
@danger_weight.setter
def danger_weight(self, value: float) -> None:
self._danger_weight = float(value)
def set_min_bend_radius(self, radius: float) -> None:
self._min_radius = float(radius) if radius > 0 else 50.0
def objective_weights(self, *, congestion_penalty: float | None = None) -> ObjectiveWeights:
return ObjectiveWeights(
unit_length_cost=self._unit_length_cost,
bend_penalty=self._bend_penalty,
sbend_penalty=self._sbend_penalty,
danger_weight=self._danger_weight,
congestion_penalty=self._congestion_penalty if congestion_penalty is None else float(congestion_penalty),
)
def resolve_refiner_weights(self, options: RoutingOptions) -> ObjectiveWeights:
refinement_objective = options.refinement.objective
if refinement_objective is None:
return ObjectiveWeights(
unit_length_cost=self._unit_length_cost,
bend_penalty=self._bend_penalty,
sbend_penalty=self._sbend_penalty,
danger_weight=self._danger_weight,
congestion_penalty=0.0,
)
return refinement_objective
def set_target(self, target: Port) -> None: def set_target(self, target: Port) -> None:
self._target_x = target.x self._target_x = target.x
@ -143,12 +68,13 @@ class CostEvaluator:
self._target_cos = np.cos(rad) self._target_cos = np.cos(rad)
self._target_sin = np.sin(rad) self._target_sin = np.sin(rad)
def g_proximity(self, x: float, y: float) -> float: def h_manhattan(
if self.danger_map is None: self,
return 0.0 current: Port,
return self._danger_weight * self.danger_map.get_cost(x, y) target: Port,
*,
def h_manhattan(self, current: Port, target: Port) -> float: min_bend_radius: float = 50.0,
) -> float:
tx, ty = target.x, target.y tx, ty = target.x, target.y
if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r: if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r:
self.set_target(target) self.set_target(target)
@ -156,7 +82,7 @@ class CostEvaluator:
dx = abs(current.x - tx) dx = abs(current.x - tx)
dy = abs(current.y - ty) dy = abs(current.y - ty)
dist = dx + dy dist = dx + dy
bp = self._bend_penalty bp = self._search_weights.bend_penalty
penalty = 0.0 penalty = 0.0
curr_r = current.r curr_r = current.r
@ -168,7 +94,7 @@ class CostEvaluator:
v_dy = ty - current.y v_dy = ty - current.y
side_proj = v_dx * self._target_cos + v_dy * self._target_sin side_proj = v_dx * self._target_cos + v_dy * self._target_sin
perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos) perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos)
if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0): if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0):
penalty += 2 * bp penalty += 2 * bp
if curr_r == 0: if curr_r == 0:
@ -188,46 +114,27 @@ class CostEvaluator:
return self._greedy_h_weight * (dist + penalty) return self._greedy_h_weight * (dist + penalty)
def evaluate_move( def score_component(
self, self,
geometry: Sequence[Polygon] | None, component: ComponentResult,
end_port: Port, *,
net_width: float,
net_id: str,
start_port: Port | None = None, start_port: Port | None = None,
length: float = 0.0,
dilated_geometry: Sequence[Polygon] | None = None,
skip_static: bool = False,
skip_congestion: bool = False,
penalty: float = 0.0,
weights: ObjectiveWeights | None = None, weights: ObjectiveWeights | None = None,
) -> float: ) -> float:
active_weights = self.objective_weights() if weights is None else weights active_weights = self._resolve_weights(weights)
_ = net_width
danger_map = self.danger_map danger_map = self.danger_map
end_port = component.end_port
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y): if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
return 1e15 return 1e15
total_cost = length * active_weights.unit_length_cost + penalty move_radius = None
if not skip_static or not skip_congestion: if component.move_type == "bend90":
if geometry is None: move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
return 1e15 total_cost = component.length * active_weights.unit_length_cost + self.component_penalty(
collision_engine = self.collision_engine component.move_type,
for i, poly in enumerate(geometry): move_radius=move_radius,
dil_poly = dilated_geometry[i] if dilated_geometry else None weights=active_weights,
if not skip_static and collision_engine.check_collision( )
poly,
net_id,
buffer_mode="static",
start_port=start_port,
end_port=end_port,
dilated_geometry=dil_poly,
):
return 1e15
if not skip_congestion:
overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly)
if isinstance(overlaps, int) and overlaps > 0:
total_cost += overlaps * active_weights.congestion_penalty
if danger_map is not None and active_weights.danger_weight: if danger_map is not None and active_weights.danger_weight:
cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0 cost_s = danger_map.get_cost(start_port.x, start_port.y) if start_port else 0.0
@ -236,9 +143,9 @@ class CostEvaluator:
mid_x = (start_port.x + end_port.x) / 2.0 mid_x = (start_port.x + end_port.x) / 2.0
mid_y = (start_port.y + end_port.y) / 2.0 mid_y = (start_port.y + end_port.y) / 2.0
cost_m = danger_map.get_cost(mid_x, mid_y) cost_m = danger_map.get_cost(mid_x, mid_y)
total_cost += length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0 total_cost += component.length * active_weights.danger_weight * (cost_s + cost_m + cost_e) / 3.0
else: else:
total_cost += length * active_weights.danger_weight * cost_e total_cost += component.length * active_weights.danger_weight * cost_e
return total_cost return total_cost
def component_penalty( def component_penalty(
@ -248,7 +155,7 @@ class CostEvaluator:
move_radius: float | None = None, move_radius: float | None = None,
weights: ObjectiveWeights | None = None, weights: ObjectiveWeights | None = None,
) -> float: ) -> float:
active_weights = self.objective_weights() if weights is None else weights active_weights = self._resolve_weights(weights)
penalty = 0.0 penalty = 0.0
if move_type == "sbend": if move_type == "sbend":
penalty = active_weights.sbend_penalty penalty = active_weights.sbend_penalty
@ -260,37 +167,18 @@ class CostEvaluator:
def path_cost( def path_cost(
self, self,
net_id: str,
start_port: Port, start_port: Port,
path: list[ComponentResult], path: list[ComponentResult],
*, *,
weights: ObjectiveWeights | None = None, weights: ObjectiveWeights | None = None,
) -> float: ) -> float:
active_weights = self.objective_weights() if weights is None else weights active_weights = self._resolve_weights(weights)
total = 0.0 total = 0.0
current_port = start_port current_port = start_port
for component in path: for component in path:
move_radius = None total += self.score_component(
if component.move_type == "bend90": component,
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
elif component.move_type == "sbend":
move_radius = None
penalty = self.component_penalty(
component.move_type,
move_radius=move_radius,
weights=active_weights,
)
total += self.evaluate_move(
component.collision_geometry,
component.end_port,
net_width=0.0,
net_id=net_id,
start_port=current_port, start_port=current_port,
length=component.length,
dilated_geometry=component.dilated_collision_geometry,
skip_static=True,
skip_congestion=(active_weights.congestion_penalty <= 0.0),
penalty=penalty,
weights=active_weights, weights=active_weights,
) )
current_port = component.end_port current_port = component.end_port

View file

@ -1,27 +0,0 @@
from __future__ import annotations
from typing import Literal
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
RETRYABLE_ROUTING_OUTCOMES = frozenset({"colliding", "partial", "unroutable"})
def infer_routing_outcome(
*,
has_path: bool,
reached_target: bool,
collision_count: int,
) -> RoutingOutcome:
if not has_path:
return "unroutable"
if not reached_target:
return "partial"
if collision_count > 0:
return "colliding"
return "completed"
def routing_outcome_needs_retry(outcome: RoutingOutcome) -> bool:
return outcome in RETRYABLE_ROUTING_OUTCOMES

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import math import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from inire.geometry.component_overlap import components_overlap, has_self_overlap from inire.geometry.component_overlap import components_overlap
from inire.geometry.components import Bend90, Straight from inire.geometry.components import Bend90, Straight
if TYPE_CHECKING: if TYPE_CHECKING:
@ -12,7 +12,8 @@ if TYPE_CHECKING:
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._search import AStarContext from inire.router._astar_types import AStarContext
def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool: def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any) -> bool:
current = parent_node current = parent_node
@ -24,10 +25,6 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any)
return False return False
def has_self_collision(path: Sequence[ComponentResult]) -> bool:
return has_self_overlap(path)
class PathRefiner: class PathRefiner:
__slots__ = ("context",) __slots__ = ("context",)
@ -42,17 +39,16 @@ class PathRefiner:
self, self,
path: Sequence[ComponentResult], path: Sequence[ComponentResult],
*, *,
net_id: str = "default",
start: Port | None = None, start: Port | None = None,
) -> float: ) -> float:
if not path: if not path:
return 0.0 return 0.0
actual_start = path[0].start_port if start is None else start actual_start = path[0].start_port if start is None else start
return self.score_path(net_id, actual_start, path) return self.score_path(actual_start, path)
def score_path(self, net_id: str, start: Port, path: Sequence[ComponentResult]) -> float: def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float:
weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options) weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights
return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights) return self.context.cost_evaluator.path_cost(start, path, weights=weights)
def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]: def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]:
ports = [start] ports = [start]
@ -291,11 +287,9 @@ class PathRefiner:
self, self,
net_id: str, net_id: str,
start: Port, start: Port,
target: Port,
net_width: float, net_width: float,
path: list[ComponentResult], path: list[ComponentResult],
) -> list[ComponentResult]: ) -> list[ComponentResult]:
_ = target
if not path: if not path:
return path return path
@ -306,7 +300,7 @@ class PathRefiner:
return path return path
best_path = path best_path = path
best_cost = self.score_path(net_id, start, path) best_cost = self.score_path(start, path)
for _ in range(3): for _ in range(3):
improved = False improved = False

View file

@ -1,68 +1,16 @@
from __future__ import annotations """Semi-private compatibility exports for router result types.
from dataclasses import dataclass, field These deep-module imports remain accessible for advanced use, but they are
from typing import TYPE_CHECKING unstable and may change without notice. Prefer importing public result types
from ``inire`` or ``inire.results``.
"""
from inire.router.outcomes import RoutingOutcome, infer_routing_outcome from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult
if TYPE_CHECKING: __all__ = [
from inire.geometry.components import ComponentResult "RouteMetrics",
from inire.model import LockedRoute "RoutingOutcome",
"RoutingReport",
"RoutingResult",
@dataclass(frozen=True, slots=True) "RoutingRunResult",
class RoutingReport: ]
static_collision_count: int = 0
dynamic_collision_count: int = 0
self_collision_count: int = 0
total_length: float = 0.0
@property
def collision_count(self) -> int:
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
@property
def is_valid(self) -> bool:
return self.collision_count == 0
@dataclass(frozen=True, slots=True)
class RouteMetrics:
nodes_expanded: int
moves_generated: int
moves_added: int
pruned_closed_set: int
pruned_hard_collision: int
pruned_cost: int
@dataclass(frozen=True, slots=True)
class RoutingResult:
net_id: str
path: tuple[ComponentResult, ...]
reached_target: bool = False
report: RoutingReport = field(default_factory=RoutingReport)
def __post_init__(self) -> None:
object.__setattr__(self, "path", tuple(self.path))
@property
def collisions(self) -> int:
return self.report.collision_count
@property
def outcome(self) -> RoutingOutcome:
return infer_routing_outcome(
has_path=bool(self.path),
reached_target=self.reached_target,
collision_count=self.report.collision_count,
)
@property
def is_valid(self) -> bool:
return self.outcome == "completed"
def as_locked_route(self) -> LockedRoute:
from inire.model import LockedRoute
return LockedRoute.from_path(self.path)

48
inire/seeds.py Normal file
View file

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

View file

@ -1,68 +0,0 @@
import time
from inire import NetSpec
from inire.geometry.primitives import Port
from inire.geometry.collision import RoutingWorld
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router._astar_types import AStarMetrics
from inire.router._router import PathFinder
from inire.tests.support import build_context
def benchmark_scaling() -> None:
print("Starting Scalability Benchmark...")
# 1. Memory Verification (20x20mm)
# Resolution 1um -> 20000 x 20000 grid
bounds = (0, 0, 20000, 20000)
print(f"Initializing DangerMap for {bounds} area...")
dm = DangerMap(bounds=bounds, resolution=1.0)
# nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB
mem_gb = dm.grid.nbytes / (1024**3)
print(f"DangerMap memory usage: {mem_gb:.2f} GB")
assert mem_gb < 2.0
# 2. Node Expansion Rate (50 nets)
engine = RoutingWorld(clearance=2.0)
# Use a smaller area for routing benchmark to keep it fast
routing_bounds = (0, 0, 1000, 1000)
danger_map = DangerMap(bounds=routing_bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
num_nets = 50
netlist = {}
for i in range(num_nets):
# Parallel nets spaced by 10um
netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0))
metrics = AStarMetrics()
pf = PathFinder(
build_context(
evaluator,
bounds=routing_bounds,
nets=(
NetSpec(net_id=net_id, start=start, target=target, width=2.0)
for net_id, (start, target) in netlist.items()
),
),
metrics=metrics,
)
print(f"Routing {num_nets} nets...")
start_time = time.monotonic()
results = pf.route_all()
end_time = time.monotonic()
total_time = end_time - start_time
print(f"Total routing time: {total_time:.2f} s")
print(f"Time per net: {total_time/num_nets:.4f} s")
if total_time > 0:
nodes_per_sec = metrics.total_nodes_expanded / total_time
print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s")
# Success rate
successes = sum(1 for r in results.values() if r.is_valid)
print(f"Success rate: {successes/num_nets * 100:.1f}%")
if __name__ == "__main__":
benchmark_scaling()

View file

@ -1,40 +1,44 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from time import perf_counter from time import perf_counter
from typing import Callable from typing import Callable
from shapely.geometry import Polygon, box from shapely.geometry import Polygon, box
from inire import NetSpec, RoutingResult from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
RoutingResult,
SearchOptions,
)
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarMetrics from inire.router._astar_types import AStarContext, AStarMetrics
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_context, build_pathfinder
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
@dataclass(frozen=True) ScenarioOutcome = tuple[float, int, int, int]
class ScenarioOutcome: ScenarioRun = Callable[[], ScenarioOutcome]
duration_s: float
total_results: int
valid_results: int
reached_targets: int
@dataclass(frozen=True)
class ScenarioDefinition:
name: str
run: Callable[[], ScenarioOutcome]
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome: def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
return ScenarioOutcome( return (
duration_s=duration_s, duration_s,
total_results=len(results), len(results),
valid_results=sum(1 for result in results.values() if result.is_valid), sum(1 for result in results.values() if result.is_valid),
reached_targets=sum(1 for result in results.values() if result.reached_target), sum(1 for result in results.values() if result.reached_target),
) )
@ -66,6 +70,39 @@ def _net_specs(
) )
def _build_options(**overrides: object) -> RoutingOptions:
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
return RoutingOptions(
search=SearchOptions(**search_overrides),
congestion=CongestionOptions(**congestion_overrides),
refinement=RefinementOptions(**refinement_overrides),
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
objective=ObjectiveWeights(**objective_overrides),
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...],
metrics: AStarMetrics | None = None,
**request_kwargs: object,
) -> PathFinder:
return PathFinder(
AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
_build_options(**request_kwargs),
),
metrics=metrics,
)
def _build_routing_stack( def _build_routing_stack(
*, *,
bounds: tuple[float, float, float, float], bounds: tuple[float, float, float, float],
@ -86,7 +123,7 @@ def _build_routing_stack(
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {})) evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
metrics = AStarMetrics() metrics = AStarMetrics()
pathfinder = build_pathfinder( pathfinder = _build_pathfinder(
evaluator, evaluator,
bounds=bounds, bounds=bounds,
nets=_net_specs(netlist, widths), nets=_net_specs(netlist, widths),
@ -150,9 +187,9 @@ def run_example_03() -> ScenarioOutcome:
) )
t0 = perf_counter() t0 = perf_counter()
results_a = pathfinder.route_all() results_a = pathfinder.route_all()
for polygon in results_a["netA"].as_locked_route().geometry: for polygon in results_a["netA"].locked_geometry:
engine.add_static_obstacle(polygon) engine.add_static_obstacle(polygon)
results_b = build_pathfinder( results_b = _build_pathfinder(
evaluator, evaluator,
bounds=(0, -50, 100, 50), bounds=(0, -50, 100, 50),
nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}), nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}),
@ -240,7 +277,7 @@ def run_example_06() -> ScenarioOutcome:
t0 = perf_counter() t0 = perf_counter()
combined_results: dict[str, RoutingResult] = {} combined_results: dict[str, RoutingResult] = {}
for evaluator, netlist, net_widths, request_kwargs in scenarios: for evaluator, netlist, net_widths, request_kwargs in scenarios:
pathfinder = build_pathfinder( pathfinder = _build_pathfinder(
evaluator, evaluator,
bounds=bounds, bounds=bounds,
nets=_net_specs(netlist, net_widths), nets=_net_specs(netlist, net_widths),
@ -296,9 +333,7 @@ def run_example_07() -> ScenarioOutcome:
) )
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None: def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4) _ = idx, current_results
evaluator.greedy_h_weight = new_greedy
metrics.reset_per_route()
t0 = perf_counter() t0 = perf_counter()
results = pathfinder.route_all(iteration_callback=iteration_callback) results = pathfinder.route_all(iteration_callback=iteration_callback)
@ -315,7 +350,7 @@ def run_example_08() -> ScenarioOutcome:
custom_evaluator = _build_evaluator(bounds) custom_evaluator = _build_evaluator(bounds)
t0 = perf_counter() t0 = perf_counter()
results_std = build_pathfinder( results_std = _build_pathfinder(
standard_evaluator, standard_evaluator,
bounds=bounds, bounds=bounds,
nets=_net_specs(netlist, widths), nets=_net_specs(netlist, widths),
@ -324,7 +359,7 @@ def run_example_08() -> ScenarioOutcome:
use_tiered_strategy=False, use_tiered_strategy=False,
metrics=AStarMetrics(), metrics=AStarMetrics(),
).route_all() ).route_all()
results_custom = build_pathfinder( results_custom = _build_pathfinder(
custom_evaluator, custom_evaluator,
bounds=bounds, bounds=bounds,
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}), nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
@ -351,7 +386,7 @@ def run_example_09() -> ScenarioOutcome:
widths=widths, widths=widths,
obstacles=obstacles, obstacles=obstacles,
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0}, evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None}, request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False},
) )
t0 = perf_counter() t0 = perf_counter()
results = pathfinder.route_all() results = pathfinder.route_all()
@ -359,14 +394,14 @@ def run_example_09() -> ScenarioOutcome:
return _summarize(results, t1 - t0) return _summarize(results, t1 - t0)
SCENARIOS: tuple[ScenarioDefinition, ...] = ( SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
ScenarioDefinition("example_01_simple_route", run_example_01), ("example_01_simple_route", run_example_01),
ScenarioDefinition("example_02_congestion_resolution", run_example_02), ("example_02_congestion_resolution", run_example_02),
ScenarioDefinition("example_03_locked_routes", run_example_03), ("example_03_locked_routes", run_example_03),
ScenarioDefinition("example_04_sbends_and_radii", run_example_04), ("example_04_sbends_and_radii", run_example_04),
ScenarioDefinition("example_05_orientation_stress", run_example_05), ("example_05_orientation_stress", run_example_05),
ScenarioDefinition("example_06_bend_collision_models", run_example_06), ("example_06_bend_collision_models", run_example_06),
ScenarioDefinition("example_07_large_scale_routing", run_example_07), ("example_07_large_scale_routing", run_example_07),
ScenarioDefinition("example_08_custom_bend_geometry", run_example_08), ("example_08_custom_bend_geometry", run_example_08),
ScenarioDefinition("example_09_unroutable_best_effort", run_example_09), ("example_09_unroutable_best_effort", run_example_09),
) )

View file

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

View file

@ -1,9 +1,11 @@
import importlib
import pytest
from shapely.geometry import box from shapely.geometry import box
from inire import ( from inire import (
CongestionOptions, CongestionOptions,
DiagnosticsOptions, DiagnosticsOptions,
LockedRoute,
NetSpec, NetSpec,
ObjectiveWeights, ObjectiveWeights,
Port, Port,
@ -16,6 +18,26 @@ from inire import (
from inire.geometry.components import Straight from inire.geometry.components import Straight
def test_root_module_exports_only_stable_surface() -> None:
import inire
assert not hasattr(inire, "RoutingWorld")
assert not hasattr(inire, "AStarContext")
assert not hasattr(inire, "PathFinder")
assert not hasattr(inire, "CostEvaluator")
assert not hasattr(inire, "DangerMap")
def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None:
router_module = importlib.import_module("inire.router._router")
search_module = importlib.import_module("inire.router._search")
collision_module = importlib.import_module("inire.geometry.collision")
assert hasattr(router_module, "PathFinder")
assert hasattr(search_module, "route_astar")
assert hasattr(collision_module, "RoutingWorld")
def test_route_problem_smoke() -> None: def test_route_problem_smoke() -> None:
problem = RoutingProblem( problem = RoutingProblem(
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
@ -44,7 +66,7 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
bend_penalty=50.0, bend_penalty=50.0,
sbend_penalty=150.0, sbend_penalty=150.0,
), ),
congestion=CongestionOptions(warm_start=None), congestion=CongestionOptions(warm_start_enabled=False),
refinement=RefinementOptions(enabled=True), refinement=RefinementOptions(enabled=True),
diagnostics=DiagnosticsOptions(capture_expanded=True), diagnostics=DiagnosticsOptions(capture_expanded=True),
) )
@ -61,10 +83,10 @@ def test_route_problem_locked_routes_become_static_obstacles() -> None:
problem = RoutingProblem( problem = RoutingProblem(
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),), nets=(NetSpec("crossing", Port(50, 10, 90), Port(50, 90, 90), width=2.0),),
locked_routes={"locked": LockedRoute.from_path(locked)}, static_obstacles=tuple(polygon for component in locked for polygon in component.physical_geometry),
) )
options = RoutingOptions( options = RoutingOptions(
congestion=CongestionOptions(max_iterations=1, warm_start=None), congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False), refinement=RefinementOptions(enabled=False),
) )
@ -86,13 +108,22 @@ def test_locked_routes_enable_incremental_requests_without_sessions() -> None:
problem_b = RoutingProblem( problem_b = RoutingProblem(
bounds=(0, -50, 100, 50), bounds=(0, -50, 100, 50),
nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),), nets=(NetSpec("netB", Port(50, -20, 90), Port(50, 20, 90), width=2.0),),
locked_routes={"netA": results_a.results_by_net["netA"].as_locked_route()}, static_obstacles=results_a.results_by_net["netA"].locked_geometry,
) )
results_b = route(problem_b, options=options) results_b = route(problem_b, options=options)
assert results_b.results_by_net["netB"].is_valid assert results_b.results_by_net["netB"].is_valid
def test_route_problem_rejects_untyped_initial_paths() -> None:
with pytest.raises(TypeError):
RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
initial_paths={"net1": (object(),)}, # type: ignore[dict-item]
)
def test_route_results_metrics_are_snapshots() -> None: def test_route_results_metrics_are_snapshots() -> None:
problem = RoutingProblem( problem = RoutingProblem(
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),

View file

@ -1,16 +1,16 @@
import math
import pytest import pytest
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire import RoutingResult from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
from inire.geometry.components import Bend90, Straight from inire.geometry.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_context, build_options, build_problem
from inire.utils.validation import validate_routing_result
BOUNDS = (0, -50, 150, 150) BOUNDS = (0, -50, 150, 150)
@ -23,15 +23,95 @@ def basic_evaluator() -> CostEvaluator:
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def _build_options(**search_overrides: object) -> RoutingOptions:
return RoutingOptions(search=SearchOptions(**search_overrides))
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
**search_overrides: object,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds),
_build_options(**search_overrides),
)
def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object):
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options, **config_overrides),
)
def _validate_routing_result(
result: RoutingResult,
static_obstacles: list[Polygon],
clearance: float,
expected_start: Port | None = None,
expected_end: Port | None = None,
) -> dict[str, object]:
if not result.path:
return {"is_valid": False, "reason": "No path found"}
connectivity_errors: list[str] = []
if expected_start:
first_port = result.path[0].start_port
dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y)
if dist_to_start > 0.005:
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
if abs(first_port.r - expected_start.r) > 0.1:
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
if expected_end:
last_port = result.path[-1].end_port
dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y)
if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
if abs(last_port.r - expected_end.r) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
report = engine.verify_path_report("validation", result.path)
is_valid = report.is_valid and not connectivity_errors
reasons = []
if report.static_collision_count:
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
if report.dynamic_collision_count:
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
if report.self_collision_count:
reasons.append(f"Found {report.self_collision_count} self-intersections.")
reasons.extend(connectivity_errors)
return {
"is_valid": is_valid,
"reason": " ".join(reasons),
"obstacle_collisions": report.static_collision_count,
"dynamic_collisions": report.dynamic_collision_count,
"self_intersections": report.self_collision_count,
"total_length": report.total_length,
"connectivity_ok": not connectivity_errors,
}
def test_astar_straight(basic_evaluator: CostEvaluator) -> None: def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
context = build_context(basic_evaluator, bounds=BOUNDS) context = _build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(50, 0, 0) target = Port(50, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"] assert validation["connectivity_ok"]
@ -40,15 +120,15 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
def test_astar_bend(basic_evaluator: CostEvaluator) -> None: def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0]) context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,))
start = Port(0, 0, 0) start = Port(0, 0, 0)
# 20um right, 20um up. Needs a 10um bend and a 10um bend. # 20um right, 20um up. Needs a 10um bend and a 10um bend.
target = Port(20, 20, 0) target = Port(20, 20, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"] assert validation["connectivity_ok"]
@ -61,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle]) basic_evaluator.danger_map.precompute([obstacle])
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000) context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=1000000)
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(60, 0, 0) target = Port(60, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
# Path should have detoured, so length > 50 # Path should have detoured, so length > 50
@ -76,15 +156,15 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None: def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
context = build_context(basic_evaluator, bounds=BOUNDS) context = _build_context(basic_evaluator, bounds=BOUNDS)
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(10.1, 0, 0) target = Port(10.1, 0, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
assert target.x == 10 assert target.x == 10
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
@ -93,7 +173,7 @@ def test_validate_routing_result_checks_expected_start() -> None:
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)] path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result( validation = _validate_routing_result(
result, result,
[], [],
clearance=2.0, clearance=2.0,
@ -110,7 +190,7 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None:
result = RoutingResult(net_id="test", path=[bend], reached_target=True) result = RoutingResult(net_id="test", path=[bend], reached_target=True)
obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)]) obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)])
validation = validate_routing_result( validation = _validate_routing_result(
result, result,
[obstacle], [obstacle],
clearance=2.0, clearance=2.0,
@ -122,26 +202,31 @@ def test_validate_routing_result_uses_exact_component_geometry() -> None:
def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None: def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None:
basic_evaluator.bend_penalty = 120.0 basic_evaluator = CostEvaluator(
basic_evaluator.sbend_penalty = 240.0 basic_evaluator.collision_engine,
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0]) basic_evaluator.danger_map,
bend_penalty=120.0,
sbend_penalty=240.0,
)
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,))
assert basic_evaluator.bend_penalty == 120.0
assert basic_evaluator.sbend_penalty == 240.0
assert context.options.search.bend_radii == (5.0,) assert context.options.search.bend_radii == (5.0,)
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0 assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc") context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
route_astar( route_astar(
Port(0, 0, 0), Port(0, 0, 0),
Port(30, 10, 0), Port(30, 10, 0),
net_width=2.0, net_width=2.0,
context=context, context=context,
bend_collision_type="clipped_bbox", config=SearchRunConfig.from_options(
return_partial=True, context.options,
bend_collision_type="clipped_bbox",
return_partial=True,
),
) )
assert context.options.search.bend_collision_type == "arc" assert context.options.search.bend_collision_type == "arc"
@ -151,12 +236,12 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle]) basic_evaluator.danger_map.precompute([obstacle])
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2) context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2)
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(60, 0, 0) target = Port(60, 0, 0)
partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True) partial_path = _route(context, start, target, return_partial=True)
no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False) no_partial_path = _route(context, start, target, return_partial=False)
assert partial_path is not None assert partial_path is not None
assert partial_path assert partial_path
@ -165,18 +250,18 @@ def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: Cos
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None: def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
context = build_context( context = _build_context(
basic_evaluator, basic_evaluator,
bounds=BOUNDS, bounds=BOUNDS,
bend_radii=[10.0], bend_radii=(10.0,),
sbend_radii=[10.0], sbend_radii=(10.0,),
sbend_offsets=[10.0], sbend_offsets=(10.0,),
max_straight_length=150.0, max_straight_length=150.0,
) )
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(100, 10, 0) target = Port(100, 10, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
assert path[-1].end_port == target assert path[-1].end_port == target
@ -195,22 +280,22 @@ def test_route_astar_supports_all_visibility_guidance_modes(
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)]) obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle]) basic_evaluator.danger_map.precompute([obstacle])
context = build_context( context = _build_context(
basic_evaluator, basic_evaluator,
bounds=BOUNDS, bounds=BOUNDS,
bend_radii=[10.0], bend_radii=(10.0,),
sbend_radii=[], sbend_radii=(),
max_straight_length=150.0, max_straight_length=150.0,
visibility_guidance=visibility_guidance, visibility_guidance=visibility_guidance,
) )
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(80, 50, 0) target = Port(80, 50, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, reached_target=True) result = RoutingResult(net_id="test", path=path, reached_target=True)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) validation = _validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"] assert validation["connectivity_ok"]
@ -219,8 +304,8 @@ def test_route_astar_supports_all_visibility_guidance_modes(
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None: def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
context = AStarContext( context = AStarContext(
basic_evaluator, basic_evaluator,
build_problem(bounds=BOUNDS), RoutingProblem(bounds=BOUNDS),
build_options( _build_options(
min_straight_length=1.0, min_straight_length=1.0,
max_straight_length=100.0, max_straight_length=100.0,
), ),
@ -230,6 +315,6 @@ def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_eval
targets = [Port(length, 0, 0) for length in range(10, 70, 10)] targets = [Port(length, 0, 0) for length in range(10, 70, 10)]
for target in targets: for target in targets:
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
assert path[-1].end_port == target assert path[-1].end_port == target

View file

@ -1,13 +1,41 @@
import pytest import pytest
import numpy import numpy
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.geometry.components import Straight from inire.geometry.components import Straight
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire import RoutingResult from inire import RoutingResult
from inire.tests.support import build_pathfinder
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
) -> PathFinder:
nets = tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
return PathFinder(
AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
RoutingOptions(
search=SearchOptions() if search is None else search,
congestion=CongestionOptions() if congestion is None else congestion,
),
),
)
def test_clearance_thresholds(): def test_clearance_thresholds():
""" """
@ -27,21 +55,21 @@ def test_clearance_thresholds():
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK. # 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
p2_ok = Port(0, 5, 0) p2_ok = Port(0, 5, 0)
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0) res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
is_v, count = ce.verify_path("net2", [res2_ok]) report_ok = ce.verify_path_report("net2", [res2_ok])
assert is_v, f"Gap 3 should be valid, but got {count} collisions" assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions"
# 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK. # 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK.
p2_exact = Port(0, 4, 0) p2_exact = Port(0, 4, 0)
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0) res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
is_v, count = ce.verify_path("net2", [res2_exact]) report_exact = ce.verify_path_report("net2", [res2_exact])
assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions" assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions"
# 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL. # 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL.
p2_fail = Port(0, 3, 0) p2_fail = Port(0, 3, 0)
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0) res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
is_v, count = ce.verify_path("net2", [res2_fail]) report_fail = ce.verify_path_report("net2", [res2_fail])
assert not is_v, "Gap 1.999 should be invalid" assert not report_fail.is_valid, "Gap 1.999 should be invalid"
assert count > 0 assert report_fail.collision_count > 0
def test_verify_all_nets_cases(): def test_verify_all_nets_cases():
""" """
@ -59,13 +87,12 @@ def test_verify_all_nets_cases():
} }
net_widths = {"net1": 2.0, "net2": 2.0} net_widths = {"net1": 2.0, "net2": 2.0}
results = build_pathfinder( results = _build_pathfinder(
evaluator, evaluator,
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
netlist=netlist_parallel_ok, netlist=netlist_parallel_ok,
net_widths=net_widths, net_widths=net_widths,
warm_start=None, congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
max_iterations=1,
).route_all() ).route_all()
assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}" assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
assert results["net2"].is_valid assert results["net2"].is_valid
@ -79,13 +106,12 @@ def test_verify_all_nets_cases():
engine.remove_path("net1") engine.remove_path("net1")
engine.remove_path("net2") engine.remove_path("net2")
results_p = build_pathfinder( results_p = _build_pathfinder(
evaluator, evaluator,
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
netlist=netlist_parallel_fail, netlist=netlist_parallel_fail,
net_widths=net_widths, net_widths=net_widths,
warm_start=None, congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
max_iterations=1,
).route_all() ).route_all()
# verify_all_nets should flag both as invalid because they cross-collide # verify_all_nets should flag both as invalid because they cross-collide
assert not results_p["net3"].is_valid assert not results_p["net3"].is_valid
@ -99,13 +125,12 @@ def test_verify_all_nets_cases():
engine.remove_path("net3") engine.remove_path("net3")
engine.remove_path("net4") engine.remove_path("net4")
results_c = build_pathfinder( results_c = _build_pathfinder(
evaluator, evaluator,
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
netlist=netlist_cross, netlist=netlist_cross,
net_widths=net_widths, net_widths=net_widths,
warm_start=None, congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
max_iterations=1,
).route_all() ).route_all()
assert not results_c["net5"].is_valid assert not results_c["net5"].is_valid
assert not results_c["net6"].is_valid assert not results_c["net6"].is_valid

View file

@ -1,65 +1,42 @@
from shapely.geometry import Polygon
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port
from inire.geometry.components import Straight from inire.geometry.components import Straight
from inire.geometry.primitives import Port
def _install_static_straight(
engine: RoutingWorld,
start: Port,
length: float,
*,
width: float,
dilation: float = 0.0,
) -> None:
obstacle = Straight.generate(start, length, width=width, dilation=dilation)
for polygon in obstacle.physical_geometry:
engine.add_static_obstacle(polygon)
def test_collision_detection() -> None: def test_collision_detection() -> None:
# Clearance = 2um
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0)
# 10x10 um obstacle at (10,10) direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)]) assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port)
engine.add_static_obstacle(obstacle)
# 1. Direct hit far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0)
test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)]) assert not engine.check_move_static(far_away, start_port=far_away.start_port)
assert engine.is_collision(test_poly, net_width=2.0)
# 2. Far away near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0)
test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]) assert engine.check_move_static(near_hit, start_port=near_hit.start_port)
assert not engine.is_collision(test_poly_far, net_width=2.0)
# 3. Near hit (within clearance)
# Obstacle edge at x=10.
# test_poly edge at x=9.
# Distance = 1.0 um.
# Required distance (Wi+C)/2 = 2.0. Collision!
test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)])
assert engine.is_collision(test_poly_near, net_width=2.0)
def test_safety_zone() -> None: def test_safety_zone() -> None:
# Use zero clearance for this test to verify the 2nm port safety zone
# against the physical obstacle boundary.
engine = RoutingWorld(clearance=0.0) engine = RoutingWorld(clearance=0.0)
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
engine.add_static_obstacle(obstacle)
# Port exactly on the boundary
start_port = Port(10, 12, 0) start_port = Port(10, 12, 0)
test_move = Straight.generate(start_port, 0.002, width=0.001)
# Move starting from this port that overlaps the obstacle by 1nm assert not engine.check_move_static(test_move, start_port=start_port)
# (Inside the 2nm safety zone)
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port)
def test_configurable_max_net_width() -> None:
# Large max_net_width (10.0) -> large pre-dilation (6.0)
engine = RoutingWorld(clearance=2.0, max_net_width=10.0)
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
engine.add_static_obstacle(obstacle)
test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)])
# physical check: dilated test_poly by C/2 = 1.0.
# Dilated test_poly bounds: (14, 19, 17, 26).
# obstacle: (20, 20, 25, 25). No physical collision.
assert not engine.is_collision(test_poly, net_width=2.0)
def test_ray_cast_width_clearance() -> None: def test_ray_cast_width_clearance() -> None:
@ -68,8 +45,7 @@ def test_ray_cast_width_clearance() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
# Obstacle at x=10 to 20 # Obstacle at x=10 to 20
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0)
engine.add_static_obstacle(obstacle)
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK. # 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
start_ok = Port(6, 50, 90) start_ok = Port(6, 50, 90)
@ -84,25 +60,24 @@ def test_ray_cast_width_clearance() -> None:
def test_check_move_static_clearance() -> None: def test_check_move_static_clearance() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)]) _install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0)
engine.add_static_obstacle(obstacle)
# Straight move of length 10 at x=8 (Width 2.0) # Straight move of length 10 at x=8 (Width 2.0)
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION. # Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
start = Port(8, 0, 90) start = Port(8, 0, 90)
res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2 res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2
assert engine.check_move_static(res, start_port=start, net_width=2.0) assert engine.check_move_static(res, start_port=start)
# Move at x=7. Gap = 3.0 == minimum. OK. # Move at x=7. Gap = 3.0 == minimum. OK.
start_ok = Port(7, 0, 90) start_ok = Port(7, 0, 90)
res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0) res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0)
assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0) assert not engine.check_move_static(res_ok, start_port=start_ok)
# 3. Same exact-boundary case. # 3. Same exact-boundary case.
start_exact = Port(7, 0, 90) start_exact = Port(7, 0, 90)
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0) res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0) assert not engine.check_move_static(res_exact, start_port=start_exact)
def test_verify_path_report_preserves_long_net_id() -> None: def test_verify_path_report_preserves_long_net_id() -> None:
@ -149,8 +124,8 @@ def test_remove_path_clears_dynamic_path() -> None:
dilated = [poly for component in path for poly in component.dilated_collision_geometry] dilated = [poly for component in path for poly in component.dilated_collision_geometry]
engine.add_path("netA", geoms, dilated_geometry=dilated) engine.add_path("netA", geoms, dilated_geometry=dilated)
assert {net_id for net_id, _ in engine.iter_dynamic_paths()} == {"netA"} assert {net_id for net_id, _ in engine._dynamic_paths.geometries.values()} == {"netA"}
engine.remove_path("netA") engine.remove_path("netA")
assert list(engine.iter_dynamic_paths()) == [] assert list(engine._dynamic_paths.geometries.values()) == []
assert len(engine._static_obstacles.geometries) == 0 assert len(engine._static_obstacles.geometries) == 0

View file

@ -1,13 +1,14 @@
import pytest import pytest
from shapely.geometry import Polygon
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import NetSpec
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._router import PathFinder from inire.router._router import PathFinder
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_context, build_pathfinder
BOUNDS = (0, -40, 100, 40) BOUNDS = (0, -40, 100, 40)
@ -21,13 +22,69 @@ def basic_evaluator() -> CostEvaluator:
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0) return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...] = (),
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
RoutingOptions(
search=SearchOptions() if search is None else search,
congestion=CongestionOptions() if congestion is None else congestion,
),
)
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
search: SearchOptions | None = None,
congestion: CongestionOptions | None = None,
) -> PathFinder:
nets = tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
return PathFinder(
_build_context(
evaluator,
bounds=bounds,
nets=nets,
search=search,
congestion=congestion,
),
)
def _route(context: AStarContext, start: Port, target: Port) -> object:
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0]) context = _build_context(
basic_evaluator,
bounds=BOUNDS,
search=SearchOptions(sbend_offsets=(2.0, 5.0)),
)
# Start at (0,0), target at (50, 2) -> 2um lateral offset # Start at (0,0), target at (50, 2) -> 2um lateral offset
# This matches one of our discretized SBend offsets. # This matches one of our discretized SBend offsets.
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(50, 2, 0) target = Port(50, 2, 0)
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
assert path is not None assert path is not None
# Check if any component in the path is an SBend # Check if any component in the path is an SBend
@ -39,38 +96,3 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
found_sbend = True found_sbend = True
break break
assert found_sbend assert found_sbend
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None:
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = build_pathfinder(
basic_evaluator,
bounds=BOUNDS,
netlist=netlist,
net_widths=net_widths,
bend_radii=[5.0, 10.0],
max_iterations=10,
base_penalty=1000.0,
)
# Force them into a narrow corridor that only fits ONE.
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])
basic_evaluator.collision_engine.add_static_obstacle(obs_top)
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
basic_evaluator.danger_map.precompute([obs_top, obs_bottom])
results = pf.route_all()
assert len(results) == 2
assert results["net1"].reached_target
assert results["net2"].reached_target
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

View file

@ -2,10 +2,11 @@ from __future__ import annotations
import os import os
import statistics import statistics
from collections.abc import Callable
import pytest import pytest
from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = {
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None: def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
_, total_results, valid_results, reached_targets = outcome
expected = EXPECTED_OUTCOMES[name] expected = EXPECTED_OUTCOMES[name]
assert outcome.total_results == expected["total_results"] assert total_results == expected["total_results"]
assert outcome.valid_results == expected["valid_results"] assert valid_results == expected["valid_results"]
assert outcome.reached_targets == expected["reached_targets"] assert reached_targets == expected["reached_targets"]
@pytest.mark.performance @pytest.mark.performance
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks") @pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS]) @pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS])
def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None: def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None:
name, run = scenario
timings = [] timings = []
for _ in range(PERFORMANCE_REPEATS): for _ in range(PERFORMANCE_REPEATS):
outcome = scenario.run() outcome = run()
_assert_expected_outcome(scenario.name, outcome) _assert_expected_outcome(name, outcome)
timings.append(outcome.duration_s) timings.append(outcome[0])
median_runtime = statistics.median(timings) median_runtime = statistics.median(timings)
assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, ( assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, (
f"{scenario.name} median runtime {median_runtime:.4f}s exceeded " f"{name} median runtime {median_runtime:.4f}s exceeded "
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s " f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
f"from timings {timings!r}" f"from timings {timings!r}"
) )

View file

@ -1,8 +1,11 @@
from inire import CongestionOptions, RoutingOptions, RoutingProblem
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_pathfinder
def test_failed_net_visibility() -> None: def test_failed_net_visibility() -> None:
""" """
@ -35,14 +38,21 @@ def test_failed_net_visibility() -> None:
"net1": (Port(0, 0, 0), Port(100, 0, 0)) "net1": (Port(0, 0, 0), Port(100, 0, 0))
} }
net_widths = {"net1": 1.0} net_widths = {"net1": 1.0}
pf = build_pathfinder( pf = PathFinder(
evaluator, AStarContext(
bounds=(0, 0, 100, 100), evaluator,
netlist=netlist, RoutingProblem(
net_widths=net_widths, bounds=(0, 0, 100, 100),
node_limit=10, nets=tuple(
max_iterations=1, NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
warm_start=None, for net_id, (start, target) in netlist.items()
),
),
RoutingOptions(
search=RoutingOptions().search.__class__(node_limit=10),
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
),
),
) )
# 4. Route # 4. Route
@ -59,9 +69,7 @@ def test_failed_net_visibility() -> None:
# 6. Verify Visibility # 6. Verify Visibility
# Check if net1 is in the collision engine # Check if net1 is in the collision engine
found_nets = set() found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()}
for nid, _poly in engine.iter_dynamic_paths():
found_nets.add(nid)
print(f"Nets found in engine: {found_nets}") print(f"Nets found in engine: {found_nets}")

View file

@ -6,10 +6,11 @@ from shapely.geometry import Point, Polygon
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem, SearchOptions
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_context
@st.composite @st.composite
@ -35,6 +36,29 @@ def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance
return all(point.distance(obstacle) >= required_gap for obstacle in obstacles) return all(point.distance(obstacle) >= required_gap for obstacle in obstacles)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
**search_overrides: object,
) -> AStarContext:
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds),
RoutingOptions(search=SearchOptions(**search_overrides)),
)
def _route(context: AStarContext, start: Port, target: Port):
return route_astar(
start,
target,
net_width=2.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
@settings(max_examples=3, deadline=None) @settings(max_examples=3, deadline=None)
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port()) @given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
@ -48,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
danger_map.precompute(obstacles) danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map) evaluator = CostEvaluator(engine, danger_map)
context = build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000) # Lower limit for fuzzing stability context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000)
# Check if start/target are inside obstacles (safety zone check) # Check if start/target are inside obstacles (safety zone check)
# The router should handle this gracefully (either route or return None) # The router should handle this gracefully (either route or return None)
try: try:
path = route_astar(start, target, net_width=2.0, context=context) path = _route(context, start, target)
# This is a crash-smoke test rather than a full correctness proof. # This is a crash-smoke test rather than a full correctness proof.
# If a full path is returned, it should at least terminate at the requested target. # If a full path is returned, it should at least terminate at the requested target.

View file

@ -1,7 +1,15 @@
import pytest
from shapely.geometry import box from shapely.geometry import box
from inire import NetSpec from inire import (
CongestionOptions,
DiagnosticsOptions,
NetSpec,
ObjectiveWeights,
RefinementOptions,
RoutingOptions,
RoutingProblem,
SearchOptions,
)
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90, Straight from inire.geometry.components import Bend90, Straight
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
@ -9,18 +17,15 @@ from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_context
DEFAULT_BOUNDS = (0, 0, 100, 100) DEFAULT_BOUNDS = (0, 0, 100, 100)
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"}
@pytest.fixture _SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
def basic_evaluator() -> CostEvaluator: _CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
engine = RoutingWorld(clearance=2.0) _REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
danger_map = DangerMap(bounds=DEFAULT_BOUNDS) _DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
danger_map.precompute([]) _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
return CostEvaluator(engine, danger_map)
def _request_nets( def _request_nets(
netlist: dict[str, tuple[Port, Port]], netlist: dict[str, tuple[Port, Port]],
@ -32,6 +37,37 @@ def _request_nets(
) )
def _build_options(**overrides: object) -> RoutingOptions:
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
return RoutingOptions(
search=SearchOptions(**search_overrides),
congestion=CongestionOptions(**congestion_overrides),
refinement=RefinementOptions(**refinement_overrides),
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
objective=ObjectiveWeights(**objective_overrides),
)
def _build_context(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
nets: tuple[NetSpec, ...] = (),
**request_overrides: object,
) -> AStarContext:
problem_overrides = {key: value for key, value in request_overrides.items() if key in _PROBLEM_FIELDS}
option_overrides = {key: value for key, value in request_overrides.items() if key not in _PROBLEM_FIELDS}
return AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets, **problem_overrides),
_build_options(**option_overrides),
)
def _build_pathfinder( def _build_pathfinder(
evaluator: CostEvaluator, evaluator: CostEvaluator,
*, *,
@ -42,7 +78,7 @@ def _build_pathfinder(
**request_overrides: object, **request_overrides: object,
) -> PathFinder: ) -> PathFinder:
return PathFinder( return PathFinder(
build_context( _build_context(
evaluator, evaluator,
bounds=bounds, bounds=bounds,
nets=_request_nets(netlist, net_widths), nets=_request_nets(netlist, net_widths),
@ -64,167 +100,6 @@ def _build_manual_path(start: Port, width: float, clearance: float, steps: list[
path.append(comp) path.append(comp)
curr = comp.end_port curr = comp.end_port
return path return path
def _path_signature(path: list) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]:
return [
(component.move_type, component.start_port.as_tuple(), component.end_port.as_tuple())
for component in path
]
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = _build_pathfinder(basic_evaluator, netlist=netlist, net_widths=net_widths)
results = pf.route_all()
assert len(results) == 2
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0
def test_pathfinder_crossing_detection(basic_evaluator: CostEvaluator) -> None:
# Net 1: (0, 25) -> (100, 25) Horizontal
# Net 2: (50, 0) -> (50, 50) Vertical
netlist = {
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths=net_widths,
max_iterations=1,
base_penalty=1.0,
warm_start=None,
)
results = pf.route_all()
# Both should be invalid because they cross
assert not results["net1"].is_valid
assert not results["net2"].is_valid
assert results["net1"].collisions > 0
assert results["net2"].collisions > 0
def test_route_all_respects_requested_net_order_in_callback(
basic_evaluator: CostEvaluator,
) -> None:
callback_orders: list[list[str]] = []
netlist = {
"short": (Port(0, 0, 0), Port(10, 0, 0)),
"long": (Port(0, 0, 0), Port(40, 10, 0)),
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
}
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths={net_id: 2.0 for net_id in netlist},
max_iterations=1,
warm_start=None,
sort_nets="longest",
enabled=False,
)
pf.route_all(
iteration_callback=lambda iteration, results: callback_orders.append(list(results)),
)
assert callback_orders == [["long", "mid", "short"]]
def test_route_all_invokes_iteration_callback_with_results(
basic_evaluator: CostEvaluator,
) -> None:
callback_results: list[dict[str, object]] = []
netlist = {
"net1": (Port(0, 0, 0), Port(10, 0, 0)),
"net2": (Port(0, 10, 0), Port(10, 10, 0)),
}
pf = _build_pathfinder(
basic_evaluator,
netlist=netlist,
net_widths={"net1": 2.0, "net2": 2.0},
)
results = pf.route_all(
iteration_callback=lambda iteration, iteration_results: callback_results.append(dict(iteration_results)),
)
assert len(callback_results) == 1
assert set(callback_results[0]) == {"net1", "net2"}
assert callback_results[0]["net1"].is_valid
assert callback_results[0]["net2"].is_valid
assert results["net1"].reached_target
assert results["net2"].reached_target
def test_route_all_uses_complete_initial_paths_without_rerouting(
basic_evaluator: CostEvaluator,
) -> None:
start = Port(0, 0, 0)
target = Port(20, 20, 0)
initial_path = _build_manual_path(
start,
2.0,
basic_evaluator.collision_engine.clearance,
[("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")],
)
pf = _build_pathfinder(
basic_evaluator,
netlist={"net": (start, target)},
net_widths={"net": 2.0},
bend_radii=[5.0],
max_iterations=1,
warm_start=None,
initial_paths={"net": tuple(initial_path)},
enabled=False,
)
result = pf.route_all()["net"]
assert result.is_valid
assert result.reached_target
assert _path_signature(result.path) == _path_signature(initial_path)
def test_route_all_retries_partial_initial_paths_across_iterations(
basic_evaluator: CostEvaluator,
) -> None:
start = Port(0, 0, 0)
target = Port(10, 0, 0)
partial_path = [Straight.generate(start, 5.0, 2.0, dilation=basic_evaluator.collision_engine.clearance / 2.0)]
pf = _build_pathfinder(
basic_evaluator,
netlist={"net": (start, target)},
net_widths={"net": 2.0},
max_iterations=2,
warm_start=None,
capture_expanded=True,
initial_paths={"net": tuple(partial_path)},
enabled=False,
)
iterations: list[int] = []
result = pf.route_all(iteration_callback=lambda iteration, results: iterations.append(iteration))["net"]
assert iterations == [0, 1]
assert result.is_valid
assert result.reached_target
assert result.outcome == "completed"
assert _path_signature(result.path) != _path_signature(partial_path)
assert pf.accumulated_expanded_nodes
def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None: def test_route_all_refreshes_static_caches_after_static_topology_changes() -> None:
netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))} netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))}
widths = {"net": 2.0} widths = {"net": 2.0}
@ -234,14 +109,14 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No
danger_map = DangerMap(bounds=(-20, -20, 60, 60)) danger_map = DangerMap(bounds=(-20, -20, 60, 60))
danger_map.precompute([]) danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map) evaluator = CostEvaluator(engine, danger_map)
context = build_context( context = _build_context(
evaluator, evaluator,
bounds=(-20, -20, 60, 60), bounds=(-20, -20, 60, 60),
nets=_request_nets(netlist, widths), nets=_request_nets(netlist, widths),
bend_radii=[10.0], bend_radii=[10.0],
max_straight_length=50.0, max_straight_length=50.0,
node_limit=50, node_limit=50,
warm_start=None, warm_start_enabled=False,
max_iterations=1, max_iterations=1,
enabled=False, enabled=False,
) )
@ -264,109 +139,6 @@ def test_route_all_refreshes_static_caches_after_static_topology_changes() -> No
assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [ assert [(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in auto_result.path] == [
(comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path (comp.move_type, comp.start_port.as_tuple(), comp.end_port.as_tuple()) for comp in manual_result.path
] ]
def test_pathfinder_refine_paths_reduces_locked_detour_bends() -> None:
bounds = (0, -50, 100, 50)
def build_pathfinder(
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
*,
refinement_enabled: bool,
) -> tuple[RoutingWorld, PathFinder]:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=250.0, sbend_penalty=500.0)
return engine, _build_pathfinder(
evaluator,
netlist=netlist,
net_widths=net_widths,
bounds=bounds,
bend_radii=[10.0],
enabled=refinement_enabled,
)
net_a = {"netA": (Port(10, 0, 0), Port(90, 0, 0))}
width_a = {"netA": 2.0}
net_b = {"netB": (Port(50, -20, 90), Port(50, 20, 90))}
width_b = {"netB": 2.0}
base_engine, base_pf = build_pathfinder(net_a, width_a, refinement_enabled=False)
base_results = base_pf.route_all()
for polygon in base_results["netA"].as_locked_route().geometry:
base_engine.add_static_obstacle(polygon)
base_result = _build_pathfinder(
base_pf.cost_evaluator,
netlist=net_b,
net_widths=width_b,
bounds=bounds,
bend_radii=[10.0],
enabled=False,
).route_all()["netB"]
refined_engine, refined_pf = build_pathfinder(net_a, width_a, refinement_enabled=True)
refined_results = refined_pf.route_all()
for polygon in refined_results["netA"].as_locked_route().geometry:
refined_engine.add_static_obstacle(polygon)
refined_result = _build_pathfinder(
refined_pf.cost_evaluator,
netlist=net_b,
net_widths=width_b,
bounds=bounds,
bend_radii=[10.0],
enabled=True,
).route_all()["netB"]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90")
assert base_result.is_valid
assert refined_result.is_valid
assert refined_bends < base_bends
assert refined_pf._path_cost(refined_result.path) < base_pf._path_cost(base_result.path)
def test_pathfinder_refine_paths_simplifies_triple_crossing_detours() -> None:
bounds = (0, 0, 100, 100)
netlist = {
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
}
net_widths = {net_id: 2.0 for net_id in netlist}
def build_pathfinder(*, refinement_enabled: bool) -> PathFinder:
engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.5, bend_penalty=250.0, sbend_penalty=500.0)
return _build_pathfinder(
evaluator,
netlist=netlist,
net_widths=net_widths,
bounds=bounds,
bend_radii=[10.0],
sbend_radii=[10.0],
base_penalty=1000.0,
enabled=refinement_enabled,
)
base_results = build_pathfinder(refinement_enabled=False).route_all()
refined_results = build_pathfinder(refinement_enabled=True).route_all()
for net_id in ("vertical_up", "vertical_down"):
base_result = base_results[net_id]
refined_result = refined_results[net_id]
base_bends = sum(1 for comp in base_result.path if comp.move_type == "bend90")
refined_bends = sum(1 for comp in refined_result.path if comp.move_type == "bend90")
assert base_result.is_valid
assert refined_result.is_valid
assert refined_bends < base_bends
def test_refine_path_handles_same_orientation_lateral_offset() -> None: def test_refine_path_handles_same_orientation_lateral_offset() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
danger_map = DangerMap(bounds=(-20, -20, 120, 120)) danger_map = DangerMap(bounds=(-20, -20, 120, 120))
@ -403,13 +175,13 @@ def test_refine_path_handles_same_orientation_lateral_offset() -> None:
) )
target = path[-1].end_port target = path[-1].end_port
refined = pf._refine_path("net", start, target, width, path) refined = pf.refiner.refine_path("net", start, width, path)
assert target == Port(60, 15, 0) assert target == Port(60, 15, 0)
assert sum(1 for comp in path if comp.move_type == "bend90") == 6 assert sum(1 for comp in path if comp.move_type == "bend90") == 6
assert sum(1 for comp in refined if comp.move_type == "bend90") == 4 assert sum(1 for comp in refined if comp.move_type == "bend90") == 4
assert refined[-1].end_port == target assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path) assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)
def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None: def test_refine_path_can_simplify_subpath_with_different_global_orientation() -> None:
@ -450,10 +222,10 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() ->
) )
target = path[-1].end_port target = path[-1].end_port
refined = pf._refine_path("net", start, target, width, path) refined = pf.refiner.refine_path("net", start, width, path)
assert target == Port(65, 30, 90) assert target == Port(65, 30, 90)
assert sum(1 for comp in path if comp.move_type == "bend90") == 7 assert sum(1 for comp in path if comp.move_type == "bend90") == 7
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5 assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
assert refined[-1].end_port == target assert refined[-1].end_port == target
assert pf._path_cost(refined) < pf._path_cost(path) assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)

View file

@ -1,9 +1,33 @@
from inire import RoutingOptions, RoutingProblem, SearchOptions
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.components import Bend90 from inire.geometry.components import Bend90
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import NetSpec
from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.tests.support import build_pathfinder
def _build_pathfinder(
evaluator: CostEvaluator,
*,
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]],
net_widths: dict[str, float],
search: SearchOptions | None = None,
) -> PathFinder:
nets = tuple(
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
for net_id, (start, target) in netlist.items()
)
return PathFinder(
AStarContext(
evaluator,
RoutingProblem(bounds=bounds, nets=nets),
RoutingOptions(search=SearchOptions() if search is None else search),
),
)
def test_arc_resolution_sagitta() -> None: def test_arc_resolution_sagitta() -> None:
@ -31,17 +55,17 @@ def test_locked_routes() -> None:
# 1. Route Net A # 1. Route Net A
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))} netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
results_a = build_pathfinder( results_a = _build_pathfinder(
evaluator, evaluator,
bounds=(0, -50, 100, 50), bounds=(0, -50, 100, 50),
netlist=netlist_a, netlist=netlist_a,
net_widths={"netA": 2.0}, net_widths={"netA": 2.0},
bend_radii=[5.0, 10.0], search=SearchOptions(bend_radii=(5.0, 10.0)),
).route_all() ).route_all()
assert results_a["netA"].is_valid assert results_a["netA"].is_valid
# 2. Treat Net A as locked geometry in the next run. # 2. Treat Net A as locked geometry in the next run.
for polygon in results_a["netA"].as_locked_route().geometry: for polygon in results_a["netA"].locked_geometry:
engine.add_static_obstacle(polygon) engine.add_static_obstacle(polygon)
# 3. Route Net B through the same space. It should detour or fail. # 3. Route Net B through the same space. It should detour or fail.
@ -49,12 +73,12 @@ def test_locked_routes() -> None:
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))} netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
# Route Net B # Route Net B
results_b = build_pathfinder( results_b = _build_pathfinder(
evaluator, evaluator,
bounds=(0, -50, 100, 50), bounds=(0, -50, 100, 50),
netlist=netlist_b, netlist=netlist_b,
net_widths={"netB": 2.0}, net_widths={"netB": 2.0},
bend_radii=[5.0, 10.0], search=SearchOptions(bend_radii=(5.0, 10.0)),
).route_all() ).route_all()
# Net B should be is_valid (it detoured) or at least not have collisions # Net B should be is_valid (it detoured) or at least not have collisions

View file

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

View file

@ -1,10 +1,11 @@
import unittest import unittest
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem
from inire.router._astar_types import AStarContext, SearchRunConfig
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.tests.support import build_context
class TestIntegerPorts(unittest.TestCase): class TestIntegerPorts(unittest.TestCase):
@ -13,12 +14,28 @@ class TestIntegerPorts(unittest.TestCase):
self.cost = CostEvaluator(self.ce) self.cost = CostEvaluator(self.ce)
self.bounds = (0, 0, 100, 100) self.bounds = (0, 0, 100, 100)
def _build_context(self) -> AStarContext:
return AStarContext(
self.cost,
RoutingProblem(bounds=self.bounds),
RoutingOptions(),
)
def _route(self, context: AStarContext, start: Port, target: Port):
return route_astar(
start,
target,
net_width=1.0,
context=context,
config=SearchRunConfig.from_options(context.options),
)
def test_route_reaches_integer_target(self): def test_route_reaches_integer_target(self):
context = build_context(self.cost, bounds=self.bounds) context = self._build_context()
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(12, 0, 0) target = Port(12, 0, 0)
path = route_astar(start, target, net_width=1.0, context=context) path = self._route(context, start, target)
self.assertIsNotNone(path) self.assertIsNotNone(path)
last_port = path[-1].end_port last_port = path[-1].end_port
@ -27,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase):
self.assertEqual(last_port.r, 0) self.assertEqual(last_port.r, 0)
def test_port_constructor_rounds_to_integer_lattice(self): def test_port_constructor_rounds_to_integer_lattice(self):
context = build_context(self.cost, bounds=self.bounds) context = self._build_context()
start = Port(0.0, 0.0, 0.0) start = Port(0.0, 0.0, 0.0)
target = Port(12.3, 0.0, 0.0) target = Port(12.3, 0.0, 0.0)
path = route_astar(start, target, net_width=1.0, context=context) path = self._route(context, start, target)
self.assertIsNotNone(path) self.assertIsNotNone(path)
self.assertEqual(target.x, 12) self.assertEqual(target.x, 12)
@ -39,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase):
self.assertEqual(last_port.x, 12) self.assertEqual(last_port.x, 12)
def test_half_step_inputs_use_integerized_targets(self): def test_half_step_inputs_use_integerized_targets(self):
context = build_context(self.cost, bounds=self.bounds) context = self._build_context()
start = Port(0.0, 0.0, 0.0) start = Port(0.0, 0.0, 0.0)
target = Port(7.5, 0.0, 0.0) target = Port(7.5, 0.0, 0.0)
path = route_astar(start, target, net_width=1.0, context=context) path = self._route(context, start, target)
self.assertIsNotNone(path) self.assertIsNotNone(path)
self.assertEqual(target.x, 8) self.assertEqual(target.x, 8)

View file

@ -1,80 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy
from inire.geometry.collision import RoutingWorld
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.router.results import RoutingResult
def validate_routing_result(
result: RoutingResult,
static_obstacles: list[Polygon],
clearance: float,
expected_start: Port | None = None,
expected_end: Port | None = None,
) -> dict[str, Any]:
"""
Perform a high-precision validation of a routed path.
Args:
result: The routing result to validate.
static_obstacles: List of static obstacle geometries.
clearance: Required minimum distance.
expected_start: Optional expected start port.
expected_end: Optional expected end port.
Returns:
A dictionary with validation results.
"""
if not result.path:
return {"is_valid": False, "reason": "No path found"}
connectivity_errors = []
if expected_start:
first_port = result.path[0].start_port
dist_to_start = numpy.sqrt((first_port.x - expected_start.x) ** 2 + (first_port.y - expected_start.y) ** 2)
if dist_to_start > 0.005:
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
if abs(first_port.r - expected_start.r) > 0.1:
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
if expected_end:
last_port = result.path[-1].end_port
dist_to_end = numpy.sqrt((last_port.x - expected_end.x) ** 2 + (last_port.y - expected_end.y) ** 2)
if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
if abs(last_port.r - expected_end.r) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
engine = RoutingWorld(clearance=clearance)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
report = engine.verify_path_report("validation", result.path)
is_valid = report.is_valid and len(connectivity_errors) == 0
reasons = []
if report.static_collision_count:
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
if report.dynamic_collision_count:
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
if report.self_collision_count:
reasons.append(f"Found {report.self_collision_count} self-intersections.")
if connectivity_errors:
reasons.extend(connectivity_errors)
return {
"is_valid": is_valid,
"reason": " ".join(reasons),
"obstacle_collisions": report.static_collision_count,
"dynamic_collisions": report.dynamic_collision_count,
"self_intersections": report.self_collision_count,
"total_length": report.total_length,
"connectivity_ok": len(connectivity_errors) == 0,
}

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.router.results import RoutingResult from inire.results import RoutingResult
def plot_routing_results( def plot_routing_results(