lots more refactoring
This commit is contained in:
parent
941d3e01df
commit
bc218a416b
43 changed files with 1433 additions and 1694 deletions
37
DOCS.md
37
DOCS.md
|
|
@ -11,9 +11,8 @@ This document describes the current public API for `inire`.
|
|||
- `bounds`
|
||||
- `nets`
|
||||
- `static_obstacles`
|
||||
- `locked_routes`
|
||||
- `initial_paths`
|
||||
- `clearance`
|
||||
- `max_net_width`
|
||||
- `safety_zone_radius`
|
||||
|
||||
### `RoutingOptions`
|
||||
|
|
@ -34,21 +33,39 @@ run = route(problem, options=options)
|
|||
|
||||
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
|
||||
run_a = route(problem_a)
|
||||
problem_b = RoutingProblem(
|
||||
bounds=problem_a.bounds,
|
||||
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)
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
|
|
@ -65,7 +82,6 @@ run_b = route(problem_b)
|
|||
| `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. |
|
||||
| `visibility_guidance` | `"tangent_corner"` | Visibility-derived straight candidate strategy. |
|
||||
| `initial_paths` | `None` | Optional user-supplied initial paths for warm starts. |
|
||||
|
||||
## 3. Objective Weights
|
||||
|
||||
|
|
@ -77,7 +93,6 @@ run_b = route(problem_b)
|
|||
| `bend_penalty` | `250.0` | Flat bend penalty before radius scaling. |
|
||||
| `sbend_penalty` | `500.0` | Flat S-bend penalty. |
|
||||
| `danger_weight` | `1.0` | Weight applied to danger-map proximity costs. |
|
||||
| `congestion_penalty` | `0.0` | Congestion weight used when explicitly scoring complete paths. |
|
||||
|
||||
## 4. Congestion Options
|
||||
|
||||
|
|
@ -89,9 +104,9 @@ run_b = route(problem_b)
|
|||
| `base_penalty` | `100.0` | Starting overlap penalty for negotiated congestion. |
|
||||
| `multiplier` | `1.5` | Multiplier applied after an iteration still needs retries. |
|
||||
| `use_tiered_strategy` | `True` | Use cheaper collision proxies in the first pass when applicable. |
|
||||
| `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. |
|
||||
| `sort_nets` | `None` | Optional deterministic routing order. |
|
||||
| `seed` | `None` | RNG seed for shuffled routing order. |
|
||||
|
||||
## 5. Refinement Options
|
||||
|
|
@ -126,7 +141,7 @@ run_b = route(problem_b)
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -50,7 +50,7 @@ if run.results_by_net["net1"].is_valid:
|
|||
print("Successfully routed net1!")
|
||||
```
|
||||
|
||||
For incremental workflows, feed prior routed results back into a new `RoutingProblem` via `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
|
||||
|
||||
|
|
@ -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)**.
|
||||
|
||||
## API Stability
|
||||
|
||||
The stable API lives at the package root and is centered on `route(problem, options=...)`.
|
||||
|
||||
Deep-module interfaces such as `inire.router._router.PathFinder`, `inire.router._search.route_astar`, and `inire.geometry.collision.RoutingWorld` remain accessible for advanced use, but they are unstable semi-private interfaces and may change without notice.
|
||||
|
||||
## Architecture
|
||||
|
||||
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:
|
||||
|
|
@ -76,7 +82,7 @@ For multi-net problems, the negotiated-congestion loop handles rip-up and rerout
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ def main() -> None:
|
|||
RoutingProblem(
|
||||
bounds=bounds,
|
||||
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,
|
||||
).results_by_net
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def main() -> None:
|
|||
bend_penalty=50.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)...")
|
||||
|
|
|
|||
|
|
@ -1,43 +1,59 @@
|
|||
"""
|
||||
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,
|
||||
DiagnosticsOptions as DiagnosticsOptions,
|
||||
LockedRoute as LockedRoute,
|
||||
NetSpec as NetSpec,
|
||||
ObjectiveWeights as ObjectiveWeights,
|
||||
RefinementOptions as RefinementOptions,
|
||||
RoutingOptions as RoutingOptions,
|
||||
RoutingProblem as RoutingProblem,
|
||||
RoutingRunResult as RoutingRunResult,
|
||||
SearchOptions as SearchOptions,
|
||||
route as route,
|
||||
) # noqa: PLC0414
|
||||
from .geometry.primitives import Port as Port # noqa: PLC0414
|
||||
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414
|
||||
from .router.results import RouteMetrics as RouteMetrics, RoutingReport as RoutingReport, RoutingResult as RoutingResult # noqa: PLC0414
|
||||
from .results import RoutingResult as RoutingResult, RoutingRunResult as RoutingRunResult # noqa: PLC0414
|
||||
from .seeds import Bend90Seed as Bend90Seed, PathSeed as PathSeed, SBendSeed as SBendSeed, StraightSeed as StraightSeed # noqa: PLC0414
|
||||
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
__version__ = '0.1'
|
||||
|
||||
|
||||
def route(
|
||||
problem: RoutingProblem,
|
||||
*,
|
||||
options: RoutingOptions | None = None,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
|
||||
) -> RoutingRunResult:
|
||||
from .router._stack import build_routing_stack
|
||||
|
||||
resolved_options = RoutingOptions() if options is None else options
|
||||
stack = build_routing_stack(problem, resolved_options)
|
||||
finder = stack.finder
|
||||
results = finder.route_all(iteration_callback=iteration_callback)
|
||||
return RoutingRunResult(
|
||||
results_by_net=results,
|
||||
metrics=finder.metrics.snapshot(),
|
||||
expanded_nodes=tuple(finder.accumulated_expanded_nodes),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Bend90",
|
||||
"Bend90Seed",
|
||||
"CongestionOptions",
|
||||
"DiagnosticsOptions",
|
||||
"LockedRoute",
|
||||
"NetSpec",
|
||||
"ObjectiveWeights",
|
||||
"PathSeed",
|
||||
"Port",
|
||||
"RefinementOptions",
|
||||
"RoutingOptions",
|
||||
"RoutingProblem",
|
||||
"RoutingReport",
|
||||
"RoutingResult",
|
||||
"RoutingRunResult",
|
||||
"RouteMetrics",
|
||||
"SBend",
|
||||
"SBendSeed",
|
||||
"SearchOptions",
|
||||
"Straight",
|
||||
"StraightSeed",
|
||||
"route",
|
||||
]
|
||||
|
|
|
|||
97
inire/api.py
97
inire/api.py
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -2,11 +2,5 @@
|
|||
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_ANGULAR = 1e-3
|
||||
TOLERANCE_GRID = 1e-6
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy
|
||||
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.dynamic_path_index import DynamicPathIndex
|
||||
from inire.geometry.index_helpers import grid_cell_span
|
||||
from inire.results import RoutingReport
|
||||
from inire.geometry.static_obstacle_index import StaticObstacleIndex
|
||||
from inire.router.results import RoutingReport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
|
@ -35,9 +35,7 @@ class RoutingWorld:
|
|||
|
||||
__slots__ = (
|
||||
"clearance",
|
||||
"max_net_width",
|
||||
"safety_zone_radius",
|
||||
"metrics",
|
||||
"grid_cell_size",
|
||||
"_dynamic_paths",
|
||||
"_static_obstacles",
|
||||
|
|
@ -46,27 +44,15 @@ class RoutingWorld:
|
|||
def __init__(
|
||||
self,
|
||||
clearance: float,
|
||||
max_net_width: float = 2.0,
|
||||
safety_zone_radius: float = 0.0021,
|
||||
) -> None:
|
||||
self.clearance = clearance
|
||||
self.max_net_width = max_net_width
|
||||
self.safety_zone_radius = safety_zone_radius
|
||||
|
||||
self.grid_cell_size = 50.0
|
||||
self._static_obstacles = StaticObstacleIndex(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:
|
||||
return self._static_obstacles.version
|
||||
|
||||
|
|
@ -87,31 +73,12 @@ class RoutingWorld:
|
|||
for obj_id in self._dynamic_paths.index.intersection(query_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:
|
||||
return self._static_obstacles.add_obstacle(polygon, dilated_geometry=dilated_geometry)
|
||||
|
||||
def remove_static_obstacle(self, obj_id: int) -> None:
|
||||
self._static_obstacles.remove_obstacle(obj_id)
|
||||
|
||||
def _invalidate_static_caches(self) -> None:
|
||||
self._static_obstacles.invalidate_caches()
|
||||
|
||||
def _ensure_static_tree(self) -> None:
|
||||
self._static_obstacles.ensure_tree()
|
||||
|
||||
|
|
@ -127,10 +94,6 @@ class RoutingWorld:
|
|||
def _ensure_dynamic_grid(self) -> None:
|
||||
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:
|
||||
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)
|
||||
|
||||
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)
|
||||
return reach < length - 0.001
|
||||
|
||||
|
|
@ -178,7 +140,6 @@ class RoutingWorld:
|
|||
if not geometry.intersects(raw_obstacle):
|
||||
return False
|
||||
|
||||
self.metrics["safety_zone_checks"] += 1
|
||||
intersection = geometry.intersection(raw_obstacle)
|
||||
if intersection.is_empty:
|
||||
return False
|
||||
|
|
@ -207,15 +168,13 @@ class RoutingWorld:
|
|||
result: ComponentResult,
|
||||
start_port: Port | None = None,
|
||||
end_port: Port | None = None,
|
||||
net_width: float | None = None,
|
||||
) -> 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
|
||||
if not static_obstacles.dilated:
|
||||
return False
|
||||
|
||||
self.metrics["static_tree_queries"] += 1
|
||||
self._ensure_static_tree()
|
||||
|
||||
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:
|
||||
dynamic_paths = self._dynamic_paths
|
||||
self.metrics["congestion_tree_queries"] += 1
|
||||
self._ensure_dynamic_tree()
|
||||
if dynamic_paths.tree is None:
|
||||
return 0
|
||||
|
|
@ -347,101 +305,6 @@ class RoutingWorld:
|
|||
return 0
|
||||
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:
|
||||
static_collision_count = 0
|
||||
dynamic_collision_count = 0
|
||||
|
|
@ -502,10 +365,6 @@ class RoutingWorld:
|
|||
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(
|
||||
self,
|
||||
origin: Port,
|
||||
|
|
|
|||
|
|
@ -8,31 +8,34 @@ if TYPE_CHECKING:
|
|||
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(
|
||||
component_a: ComponentResult,
|
||||
component_b: ComponentResult,
|
||||
prefer_actual: bool = False,
|
||||
) -> bool:
|
||||
bounds_a = component_bounds(component_a, prefer_actual=prefer_actual)
|
||||
bounds_b = component_bounds(component_b, prefer_actual=prefer_actual)
|
||||
polygons_a: tuple[Polygon, ...]
|
||||
polygons_b: tuple[Polygon, ...]
|
||||
if prefer_actual:
|
||||
polygons_a = component_a.physical_geometry
|
||||
polygons_b = component_b.physical_geometry
|
||||
bounds_a = (
|
||||
min(polygon.bounds[0] for polygon in polygons_a),
|
||||
min(polygon.bounds[1] for polygon in polygons_a),
|
||||
max(polygon.bounds[2] for polygon in polygons_a),
|
||||
max(polygon.bounds[3] for polygon in polygons_a),
|
||||
)
|
||||
bounds_b = (
|
||||
min(polygon.bounds[0] for polygon in polygons_b),
|
||||
min(polygon.bounds[1] for polygon in polygons_b),
|
||||
max(polygon.bounds[2] for polygon in polygons_b),
|
||||
max(polygon.bounds[3] for polygon in polygons_b),
|
||||
)
|
||||
else:
|
||||
polygons_a = component_a.collision_geometry
|
||||
polygons_b = component_b.collision_geometry
|
||||
bounds_a = component_a.total_bounds
|
||||
bounds_b = component_b.total_bounds
|
||||
|
||||
if not (
|
||||
bounds_a[0] < bounds_b[2]
|
||||
and bounds_a[2] > bounds_b[0]
|
||||
|
|
@ -41,18 +44,8 @@ def components_overlap(
|
|||
):
|
||||
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_b in polygons_b:
|
||||
if polygon_a.intersects(polygon_b) and not polygon_a.touches(polygon_b):
|
||||
return True
|
||||
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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from shapely.affinity import translate as shapely_translate
|
|||
from shapely.geometry import Polygon, box
|
||||
|
||||
from inire.constants import TOLERANCE_ANGULAR
|
||||
from inire.seeds import Bend90Seed, PathSegmentSeed, SBendSeed, StraightSeed
|
||||
from .primitives import Port, rotation_matrix2
|
||||
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ class ComponentResult:
|
|||
end_port: Port
|
||||
length: float
|
||||
move_type: MoveKind
|
||||
move_spec: PathSegmentSeed
|
||||
physical_geometry: tuple[Polygon, ...]
|
||||
dilated_collision_geometry: tuple[Polygon, ...]
|
||||
dilated_physical_geometry: tuple[Polygon, ...]
|
||||
|
|
@ -80,6 +82,7 @@ class ComponentResult:
|
|||
end_port=self.end_port.translate(dx, dy),
|
||||
length=self.length,
|
||||
move_type=self.move_type,
|
||||
move_spec=self.move_spec,
|
||||
physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.physical_geometry],
|
||||
dilated_collision_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_collision_geometry],
|
||||
dilated_physical_geometry=[shapely_translate(poly, dx, dy) for poly in self.dilated_physical_geometry],
|
||||
|
|
@ -235,6 +238,7 @@ class Straight:
|
|||
end_port=end_port,
|
||||
length=abs(length_f),
|
||||
move_type="straight",
|
||||
move_spec=StraightSeed(length=length_f),
|
||||
physical_geometry=geometry,
|
||||
dilated_collision_geometry=dilated_geometry,
|
||||
dilated_physical_geometry=dilated_geometry,
|
||||
|
|
@ -305,6 +309,7 @@ class Bend90:
|
|||
end_port=end_port,
|
||||
length=abs(radius) * numpy.pi / 2.0,
|
||||
move_type="bend90",
|
||||
move_spec=Bend90Seed(radius=radius, direction=direction),
|
||||
physical_geometry=physical_geometry,
|
||||
dilated_collision_geometry=dilated_collision_geometry,
|
||||
dilated_physical_geometry=dilated_physical_geometry,
|
||||
|
|
@ -394,6 +399,7 @@ class SBend:
|
|||
end_port=end_port,
|
||||
length=2.0 * radius * theta,
|
||||
move_type="sbend",
|
||||
move_spec=SBendSeed(offset=offset, radius=radius),
|
||||
physical_geometry=physical_geometry,
|
||||
dilated_collision_geometry=dilated_collision_geometry,
|
||||
dilated_physical_geometry=dilated_physical_geometry,
|
||||
|
|
|
|||
|
|
@ -87,8 +87,3 @@ class DynamicPathIndex:
|
|||
self.index.delete(obj_id, self.dilated[obj_id].bounds)
|
||||
del self.geometries[obj_id]
|
||||
del self.dilated[obj_id]
|
||||
|
||||
def clear_paths(self) -> None:
|
||||
if not self.geometries:
|
||||
return
|
||||
self.remove_obj_ids(list(self.geometries))
|
||||
|
|
|
|||
|
|
@ -61,11 +61,3 @@ ROT2_270 = numpy.array(((0, 1), (-1, 0)), dtype=numpy.int32)
|
|||
def rotation_matrix2(rotation_deg: int) -> NDArray[numpy.int32]:
|
||||
quadrant = (_normalize_angle(rotation_deg) // 90) % 4
|
||||
return (ROT2_0, ROT2_90, ROT2_180, ROT2_270)[quadrant]
|
||||
|
||||
|
||||
def rotation_matrix3(rotation_deg: int) -> NDArray[numpy.int32]:
|
||||
rot2 = rotation_matrix2(rotation_deg)
|
||||
rot3 = numpy.zeros((3, 3), dtype=numpy.int32)
|
||||
rot3[:2, :2] = rot2
|
||||
rot3[2, 2] = 1
|
||||
return rot3
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.router.results import RouteMetrics, RoutingResult
|
||||
from inire.seeds import PathSeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
NetOrder = Literal["user", "shortest", "longest"]
|
||||
VisibilityGuidance = Literal["off", "exact_corner", "tangent_corner"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NetSpec:
|
||||
net_id: str
|
||||
|
|
@ -21,37 +25,12 @@ class NetSpec:
|
|||
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)
|
||||
class ObjectiveWeights:
|
||||
unit_length_cost: float = 1.0
|
||||
bend_penalty: float = 250.0
|
||||
sbend_penalty: float = 500.0
|
||||
danger_weight: float = 1.0
|
||||
congestion_penalty: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -64,23 +43,13 @@ class SearchOptions:
|
|||
bend_radii: tuple[float, ...] = (50.0, 100.0)
|
||||
sbend_radii: tuple[float, ...] = (10.0,)
|
||||
bend_collision_type: BendCollisionModel = "arc"
|
||||
visibility_guidance: str = "tangent_corner"
|
||||
initial_paths: dict[str, tuple[ComponentResult, ...]] | None = None
|
||||
visibility_guidance: VisibilityGuidance = "tangent_corner"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "bend_radii", tuple(self.bend_radii))
|
||||
object.__setattr__(self, "sbend_radii", tuple(self.sbend_radii))
|
||||
if self.sbend_offsets is not None:
|
||||
object.__setattr__(self, "sbend_offsets", tuple(self.sbend_offsets))
|
||||
if self.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)
|
||||
|
|
@ -89,9 +58,9 @@ class CongestionOptions:
|
|||
base_penalty: float = 100.0
|
||||
multiplier: float = 1.5
|
||||
use_tiered_strategy: bool = True
|
||||
warm_start: str | None = "shortest"
|
||||
net_order: NetOrder = "user"
|
||||
warm_start_enabled: bool = True
|
||||
shuffle_nets: bool = False
|
||||
sort_nets: str | None = None
|
||||
seed: int | None = None
|
||||
|
||||
|
||||
|
|
@ -120,26 +89,18 @@ class RoutingProblem:
|
|||
bounds: tuple[float, float, float, float]
|
||||
nets: tuple[NetSpec, ...] = ()
|
||||
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
|
||||
max_net_width: float = 2.0
|
||||
safety_zone_radius: float = 0.0021
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "nets", tuple(self.nets))
|
||||
object.__setattr__(self, "static_obstacles", tuple(self.static_obstacles))
|
||||
initial_paths = dict(self.initial_paths)
|
||||
if any(not isinstance(seed, PathSeed) for seed in initial_paths.values()):
|
||||
raise TypeError("RoutingProblem.initial_paths values must be PathSeed instances")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"locked_routes",
|
||||
{
|
||||
net_id: _coerce_locked_route(route)
|
||||
for net_id, route in self.locked_routes.items()
|
||||
},
|
||||
"initial_paths",
|
||||
initial_paths,
|
||||
)
|
||||
|
||||
|
||||
@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
86
inire/results.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from inire.seeds import PathSeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
||||
|
||||
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingReport:
|
||||
static_collision_count: int = 0
|
||||
dynamic_collision_count: int = 0
|
||||
self_collision_count: int = 0
|
||||
total_length: float = 0.0
|
||||
|
||||
@property
|
||||
def collision_count(self) -> int:
|
||||
return self.static_collision_count + self.dynamic_collision_count + self.self_collision_count
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.collision_count == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteMetrics:
|
||||
nodes_expanded: int
|
||||
moves_generated: int
|
||||
moves_added: int
|
||||
pruned_closed_set: int
|
||||
pruned_hard_collision: int
|
||||
pruned_cost: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingResult:
|
||||
net_id: str
|
||||
path: tuple[ComponentResult, ...]
|
||||
reached_target: bool = False
|
||||
report: RoutingReport = field(default_factory=RoutingReport)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "path", tuple(self.path))
|
||||
|
||||
@property
|
||||
def collisions(self) -> int:
|
||||
return self.report.collision_count
|
||||
|
||||
@property
|
||||
def outcome(self) -> RoutingOutcome:
|
||||
if not self.path:
|
||||
return "unroutable"
|
||||
if not self.reached_target:
|
||||
return "partial"
|
||||
if self.report.collision_count > 0:
|
||||
return "colliding"
|
||||
return "completed"
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.outcome == "completed"
|
||||
|
||||
@property
|
||||
def locked_geometry(self) -> tuple[Polygon, ...]:
|
||||
polygons = []
|
||||
for component in self.path:
|
||||
polygons.extend(component.physical_geometry)
|
||||
return tuple(polygons)
|
||||
|
||||
def as_seed(self) -> PathSeed:
|
||||
return PathSeed(tuple(component.move_spec for component in self.path))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingRunResult:
|
||||
results_by_net: dict[str, RoutingResult]
|
||||
metrics: RouteMetrics
|
||||
expanded_nodes: tuple[tuple[int, int, int], ...] = ()
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
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.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:
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
|
@ -26,15 +26,12 @@ def process_move(
|
|||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
config: SearchRunConfig,
|
||||
move_class: MoveKind,
|
||||
params: tuple,
|
||||
skip_congestion: bool,
|
||||
bend_collision_type: BendCollisionModel,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
) -> None:
|
||||
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
|
||||
self_dilation = context.cost_evaluator.collision_engine.clearance / 2.0
|
||||
|
||||
|
|
@ -101,12 +98,9 @@ def process_move(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
move_class,
|
||||
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,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
config: SearchRunConfig,
|
||||
move_type: MoveKind,
|
||||
cache_key: tuple,
|
||||
move_radius: float | None = None,
|
||||
skip_congestion: bool = False,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
) -> None:
|
||||
metrics.moves_generated += 1
|
||||
metrics.total_moves_generated += 1
|
||||
|
|
@ -151,7 +142,7 @@ def add_node(
|
|||
if move_type == "straight":
|
||||
collision_found = ce.check_move_straight_static(parent_p, result.length, net_width=net_width)
|
||||
else:
|
||||
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p, net_width=net_width)
|
||||
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
|
||||
if collision_found:
|
||||
context.hard_collision_set.add(cache_key)
|
||||
metrics.pruned_hard_collision += 1
|
||||
|
|
@ -160,36 +151,23 @@ def add_node(
|
|||
context.static_safe_cache.add(cache_key)
|
||||
|
||||
total_overlaps = 0
|
||||
if not skip_congestion:
|
||||
if not config.skip_congestion:
|
||||
if cache_key in congestion_cache:
|
||||
total_overlaps = congestion_cache[cache_key]
|
||||
else:
|
||||
total_overlaps = context.cost_evaluator.collision_engine.check_move_congestion(result, net_id)
|
||||
congestion_cache[cache_key] = total_overlaps
|
||||
|
||||
if self_collision_check and component_hits_ancestor_chain(result, parent):
|
||||
if config.self_collision_check and component_hits_ancestor_chain(result, parent):
|
||||
return
|
||||
|
||||
penalty = context.cost_evaluator.component_penalty(
|
||||
move_type,
|
||||
move_radius=move_radius,
|
||||
)
|
||||
|
||||
move_cost = context.cost_evaluator.evaluate_move(
|
||||
result.collision_geometry,
|
||||
result.end_port,
|
||||
net_width,
|
||||
net_id,
|
||||
move_cost = context.cost_evaluator.score_component(
|
||||
result,
|
||||
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.total_pruned_cost += 1
|
||||
return
|
||||
|
|
@ -204,7 +182,11 @@ def add_node(
|
|||
metrics.total_pruned_closed_set += 1
|
||||
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))
|
||||
metrics.moves_added += 1
|
||||
metrics.total_moves_added += 1
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ from __future__ import annotations
|
|||
import math
|
||||
|
||||
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 ._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]:
|
||||
|
|
@ -129,13 +129,9 @@ def expand_moves(
|
|||
context: AStarContext,
|
||||
metrics: AStarMetrics,
|
||||
congestion_cache: dict[tuple, int],
|
||||
bend_collision_type: BendCollisionModel | None = None,
|
||||
max_cost: float | None = None,
|
||||
skip_congestion: bool = False,
|
||||
self_collision_check: bool = False,
|
||||
config: SearchRunConfig,
|
||||
) -> None:
|
||||
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
|
||||
prev_move_type, prev_straight_length = _previous_move_metadata(current)
|
||||
dx_t = target.x - cp.x
|
||||
|
|
@ -171,12 +167,9 @@ def expand_moves(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"straight",
|
||||
(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)
|
||||
|
|
@ -225,12 +218,9 @@ def expand_moves(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"straight",
|
||||
(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
|
||||
|
|
@ -256,12 +246,9 @@ def expand_moves(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"bend90",
|
||||
(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
|
||||
|
|
@ -293,10 +280,7 @@ def expand_moves(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
config,
|
||||
"sbend",
|
||||
(offset, radius),
|
||||
skip_congestion,
|
||||
bend_collision_type=effective_bend_collision_type,
|
||||
max_cost=max_cost,
|
||||
self_collision_check=self_collision_check,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
from inire.results import RouteMetrics
|
||||
from inire.router.visibility import VisibilityManager
|
||||
from inire.router.results import RouteMetrics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchRunConfig:
|
||||
bend_collision_type: BendCollisionModel
|
||||
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:
|
||||
__slots__ = ("port", "g_cost", "h_cost", "fh_cost", "parent", "component_result")
|
||||
|
||||
|
|
@ -96,6 +133,8 @@ class AStarMetrics:
|
|||
class AStarContext:
|
||||
__slots__ = (
|
||||
"cost_evaluator",
|
||||
"congestion_penalty",
|
||||
"min_bend_radius",
|
||||
"problem",
|
||||
"options",
|
||||
"max_cache_size",
|
||||
|
|
@ -115,10 +154,11 @@ class AStarContext:
|
|||
max_cache_size: int = 1000000,
|
||||
) -> None:
|
||||
self.cost_evaluator = cost_evaluator
|
||||
self.congestion_penalty = 0.0
|
||||
self.max_cache_size = max_cache_size
|
||||
self.problem = problem
|
||||
self.options = options
|
||||
self.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.move_cache_rel: dict[tuple, ComponentResult] = {}
|
||||
self.move_cache_abs: dict[tuple, ComponentResult] = {}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,17 @@ import time
|
|||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.model import NetSpec, RoutingOptions, RoutingProblem
|
||||
from inire.router._astar_types import AStarContext, AStarMetrics
|
||||
from inire.model import NetOrder, NetSpec
|
||||
from inire.results import RoutingOutcome, RoutingReport, RoutingResult
|
||||
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.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.results import RoutingReport, RoutingResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -31,10 +29,6 @@ class _RoutingState:
|
|||
initial_paths: dict[str, tuple[ComponentResult, ...]] | None
|
||||
accumulated_expanded_nodes: list[tuple[int, int, int]]
|
||||
|
||||
|
||||
__all__ = ["PathFinder"]
|
||||
|
||||
|
||||
class PathFinder:
|
||||
__slots__ = (
|
||||
"context",
|
||||
|
|
@ -53,83 +47,18 @@ class PathFinder:
|
|||
self.refiner = PathRefiner(self.context)
|
||||
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
|
||||
|
||||
@property
|
||||
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]:
|
||||
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
|
||||
all_geoms = []
|
||||
all_dilated = []
|
||||
for result in path:
|
||||
all_geoms.extend(result.collision_geometry)
|
||||
all_dilated.extend(result.dilated_collision_geometry)
|
||||
return all_geoms, 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(),
|
||||
)
|
||||
self.context.cost_evaluator.collision_engine.add_path(net_id, all_geoms, dilated_geometry=all_dilated)
|
||||
|
||||
def _routing_order(
|
||||
self,
|
||||
net_specs: dict[str, NetSpec],
|
||||
order: str,
|
||||
order: NetOrder,
|
||||
) -> list[str]:
|
||||
ordered_net_ids = list(net_specs.keys())
|
||||
if order == "user":
|
||||
|
|
@ -144,15 +73,26 @@ class PathFinder:
|
|||
def _build_greedy_warm_start_paths(
|
||||
self,
|
||||
net_specs: dict[str, NetSpec],
|
||||
order: str,
|
||||
order: NetOrder,
|
||||
) -> dict[str, tuple[ComponentResult, ...]]:
|
||||
greedy_paths: dict[str, tuple[ComponentResult, ...]] = {}
|
||||
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):
|
||||
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)
|
||||
run_config = SearchRunConfig.from_options(
|
||||
self.context.options,
|
||||
skip_congestion=True,
|
||||
max_cost=max_cost_limit,
|
||||
self_collision_check=True,
|
||||
node_limit=greedy_node_limit,
|
||||
)
|
||||
path = route_astar(
|
||||
net.start,
|
||||
net.target,
|
||||
|
|
@ -160,24 +100,24 @@ class PathFinder:
|
|||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
skip_congestion=True,
|
||||
max_cost=max_cost_limit,
|
||||
self_collision_check=True,
|
||||
node_limit=greedy_node_limit,
|
||||
config=run_config,
|
||||
)
|
||||
if not path:
|
||||
continue
|
||||
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._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
|
||||
|
||||
def _prepare_state(self) -> _RoutingState:
|
||||
problem = self.problem
|
||||
congestion = self.options.congestion
|
||||
initial_paths = self.options.search.initial_paths
|
||||
problem = self.context.problem
|
||||
congestion = self.context.options.congestion
|
||||
initial_paths = self._materialize_problem_initial_paths()
|
||||
net_specs = {net.net_id: net for net in problem.nets}
|
||||
num_nets = len(net_specs)
|
||||
state = _RoutingState(
|
||||
|
|
@ -190,27 +130,45 @@ class PathFinder:
|
|||
initial_paths=initial_paths,
|
||||
accumulated_expanded_nodes=[],
|
||||
)
|
||||
if state.initial_paths is None:
|
||||
warm_start_order = congestion.sort_nets if congestion.sort_nets is not None else congestion.warm_start
|
||||
if warm_start_order is not None:
|
||||
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, warm_start_order)
|
||||
if state.initial_paths is None and congestion.warm_start_enabled:
|
||||
state.initial_paths = self._build_greedy_warm_start_paths(net_specs, congestion.net_order)
|
||||
self.context.clear_static_caches()
|
||||
|
||||
if congestion.sort_nets and congestion.sort_nets != "user":
|
||||
state.ordered_net_ids = self._routing_order(net_specs, congestion.sort_nets)
|
||||
if congestion.net_order != "user":
|
||||
state.ordered_net_ids = self._routing_order(net_specs, congestion.net_order)
|
||||
return state
|
||||
|
||||
def _materialize_problem_initial_paths(self) -> dict[str, tuple[ComponentResult, ...]] | None:
|
||||
if not self.context.problem.initial_paths:
|
||||
return None
|
||||
|
||||
search = self.context.options.search
|
||||
net_specs = {net.net_id: net for net in self.context.problem.nets}
|
||||
initial_paths: dict[str, tuple[ComponentResult, ...]] = {}
|
||||
for net_id, seed in self.context.problem.initial_paths.items():
|
||||
if net_id not in net_specs:
|
||||
raise ValueError(f"Initial path provided for unknown net: {net_id}")
|
||||
net = net_specs[net_id]
|
||||
initial_paths[net_id] = materialize_path_seed(
|
||||
seed,
|
||||
start=net.start,
|
||||
net_width=net.width,
|
||||
search=search,
|
||||
clearance=self.context.cost_evaluator.collision_engine.clearance,
|
||||
)
|
||||
return initial_paths
|
||||
|
||||
def _route_net_once(
|
||||
self,
|
||||
state: _RoutingState,
|
||||
iteration: int,
|
||||
net_id: str,
|
||||
) -> RoutingResult:
|
||||
search = self.options.search
|
||||
congestion = self.options.congestion
|
||||
diagnostics = self.options.diagnostics
|
||||
search = self.context.options.search
|
||||
congestion = self.context.options.congestion
|
||||
diagnostics = self.context.options.diagnostics
|
||||
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:
|
||||
path: Sequence[ComponentResult] | None = state.initial_paths[net_id]
|
||||
|
|
@ -222,13 +180,8 @@ class PathFinder:
|
|||
if coll_model == "arc":
|
||||
coll_model = "clipped_bbox"
|
||||
|
||||
path = route_astar(
|
||||
net.start,
|
||||
net.target,
|
||||
net.width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
run_config = SearchRunConfig.from_options(
|
||||
self.context.options,
|
||||
bend_collision_type=coll_model,
|
||||
return_partial=True,
|
||||
store_expanded=diagnostics.capture_expanded,
|
||||
|
|
@ -236,26 +189,35 @@ class PathFinder:
|
|||
self_collision_check=(net_id in state.needs_self_collision_check),
|
||||
node_limit=search.node_limit,
|
||||
)
|
||||
path = route_astar(
|
||||
net.start,
|
||||
net.target,
|
||||
net.width,
|
||||
context=self.context,
|
||||
metrics=self.metrics,
|
||||
net_id=net_id,
|
||||
config=run_config,
|
||||
)
|
||||
|
||||
if diagnostics.capture_expanded and self.metrics.last_expanded_nodes:
|
||||
state.accumulated_expanded_nodes.extend(self.metrics.last_expanded_nodes)
|
||||
|
||||
if not path:
|
||||
return 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
|
||||
report = None
|
||||
self._install_path(net_id, path)
|
||||
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:
|
||||
state.needs_self_collision_check.add(net_id)
|
||||
|
||||
return self._build_routing_result(
|
||||
return RoutingResult(
|
||||
net_id=net_id,
|
||||
path=path,
|
||||
reached_target=reached_target,
|
||||
report=report,
|
||||
report=RoutingReport() if report is None else report,
|
||||
)
|
||||
|
||||
def _run_iteration(
|
||||
|
|
@ -265,7 +227,7 @@ class PathFinder:
|
|||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
|
||||
) -> dict[str, RoutingOutcome] | None:
|
||||
outcomes: dict[str, RoutingOutcome] = {}
|
||||
congestion = self.options.congestion
|
||||
congestion = self.context.options.congestion
|
||||
self.metrics.reset_per_route()
|
||||
|
||||
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:
|
||||
if time.monotonic() - state.start_time > state.timeout_s:
|
||||
self._finalize_dynamic_tree()
|
||||
return None
|
||||
|
||||
result = self._route_net_once(state, iteration, net_id)
|
||||
|
|
@ -290,30 +251,30 @@ class PathFinder:
|
|||
state: _RoutingState,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None,
|
||||
) -> bool:
|
||||
congestion = self.options.congestion
|
||||
congestion = self.context.options.congestion
|
||||
for iteration in range(congestion.max_iterations):
|
||||
outcomes = self._run_iteration(state, iteration, iteration_callback)
|
||||
if outcomes is None:
|
||||
return True
|
||||
if not any(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
|
||||
self.cost_evaluator.congestion_penalty *= congestion.multiplier
|
||||
self.context.congestion_penalty *= congestion.multiplier
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
for net_id in state.ordered_net_ids:
|
||||
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
|
||||
net = state.net_specs[net_id]
|
||||
self._remove_path(net_id)
|
||||
refined_path = self.refiner.refine_path(net_id, net.start, net.target, net.width, result.path)
|
||||
self.context.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
refined_path = self.refiner.refine_path(net_id, net.start, net.width, result.path)
|
||||
self._install_path(net_id, refined_path)
|
||||
report = self._verify_path_report(net_id, refined_path)
|
||||
state.results[net_id] = self._build_routing_result(
|
||||
report = self.context.cost_evaluator.collision_engine.verify_path_report(net_id, refined_path)
|
||||
state.results[net_id] = RoutingResult(
|
||||
net_id=net_id,
|
||||
path=refined_path,
|
||||
reached_target=result.reached_target,
|
||||
|
|
@ -322,17 +283,13 @@ class PathFinder:
|
|||
|
||||
def _verify_results(self, state: _RoutingState) -> 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)
|
||||
if not result or not result.path:
|
||||
final_results[net.net_id] = self._build_routing_result(
|
||||
net_id=net.net_id,
|
||||
path=[],
|
||||
reached_target=False,
|
||||
)
|
||||
final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
|
||||
continue
|
||||
report = self._verify_path_report(net.net_id, result.path)
|
||||
final_results[net.net_id] = self._build_routing_result(
|
||||
report = self.context.cost_evaluator.collision_engine.verify_path_report(net.net_id, result.path)
|
||||
final_results[net.net_id] = RoutingResult(
|
||||
net_id=net.net_id,
|
||||
path=result.path,
|
||||
reached_target=result.reached_target,
|
||||
|
|
@ -345,7 +302,7 @@ class PathFinder:
|
|||
*,
|
||||
iteration_callback: Callable[[int, dict[str, RoutingResult]], None] | None = None,
|
||||
) -> 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.metrics.reset_totals()
|
||||
self.metrics.reset_per_route()
|
||||
|
|
@ -358,5 +315,4 @@ class PathFinder:
|
|||
return self._verify_results(state)
|
||||
|
||||
self._refine_results(state)
|
||||
self._finalize_dynamic_tree()
|
||||
return self._verify_results(state)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ import heapq
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.geometry.components import BendCollisionModel
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
from ._astar_moves import expand_moves as _expand_moves
|
||||
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode
|
||||
from .results import RouteMetrics
|
||||
from ._astar_types import AStarContext, AStarMetrics, AStarNode as _AStarNode, SearchRunConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
|
|
@ -29,21 +27,14 @@ def route_astar(
|
|||
target: Port,
|
||||
net_width: float,
|
||||
context: AStarContext,
|
||||
*,
|
||||
metrics: AStarMetrics | None = None,
|
||||
net_id: str = "default",
|
||||
bend_collision_type: BendCollisionModel | None = None,
|
||||
return_partial: bool = False,
|
||||
store_expanded: bool = False,
|
||||
skip_congestion: bool = False,
|
||||
max_cost: float | None = None,
|
||||
self_collision_check: bool = False,
|
||||
node_limit: int | None = None,
|
||||
config: SearchRunConfig,
|
||||
) -> list[ComponentResult] | None:
|
||||
if metrics is None:
|
||||
metrics = AStarMetrics()
|
||||
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.cost_evaluator.set_target(target)
|
||||
|
|
@ -51,18 +42,21 @@ def route_astar(
|
|||
closed_set: dict[tuple[int, int, int], float] = {}
|
||||
congestion_cache: dict[tuple, int] = {}
|
||||
|
||||
start_node = _AStarNode(start, 0.0, context.cost_evaluator.h_manhattan(start, target))
|
||||
start_node = _AStarNode(
|
||||
start,
|
||||
0.0,
|
||||
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
|
||||
)
|
||||
heapq.heappush(open_set, start_node)
|
||||
best_node = start_node
|
||||
effective_node_limit = node_limit if node_limit is not None else search_options.node_limit
|
||||
nodes_expanded = 0
|
||||
|
||||
while open_set:
|
||||
if nodes_expanded >= effective_node_limit:
|
||||
return _reconstruct_path(best_node) if return_partial else None
|
||||
if nodes_expanded >= config.node_limit:
|
||||
return _reconstruct_path(best_node) if config.return_partial else None
|
||||
|
||||
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.total_pruned_cost += 1
|
||||
continue
|
||||
|
|
@ -75,7 +69,7 @@ def route_astar(
|
|||
continue
|
||||
closed_set[state] = current.g_cost
|
||||
|
||||
if store_expanded:
|
||||
if config.store_expanded:
|
||||
metrics.last_expanded_nodes.append(state)
|
||||
|
||||
nodes_expanded += 1
|
||||
|
|
@ -95,18 +89,7 @@ def route_astar(
|
|||
context,
|
||||
metrics,
|
||||
congestion_cache,
|
||||
bend_collision_type=effective_bend_collision_type,
|
||||
max_cost=max_cost,
|
||||
skip_congestion=skip_congestion,
|
||||
self_collision_check=self_collision_check,
|
||||
config=config,
|
||||
)
|
||||
|
||||
return _reconstruct_path(best_node) if return_partial else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AStarContext",
|
||||
"AStarMetrics",
|
||||
"RouteMetrics",
|
||||
"route_astar",
|
||||
]
|
||||
return _reconstruct_path(best_node) if config.return_partial else None
|
||||
|
|
|
|||
53
inire/router/_seed_materialization.py
Normal file
53
inire/router/_seed_materialization.py
Normal 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
52
inire/router/_stack.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutingStack:
|
||||
world: object
|
||||
danger_map: object
|
||||
evaluator: object
|
||||
context: object
|
||||
finder: object
|
||||
|
||||
|
||||
def build_routing_stack(problem: RoutingProblem, options: RoutingOptions) -> RoutingStack:
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
world = RoutingWorld(
|
||||
clearance=problem.clearance,
|
||||
safety_zone_radius=problem.safety_zone_radius,
|
||||
)
|
||||
for obstacle in problem.static_obstacles:
|
||||
world.add_static_obstacle(obstacle)
|
||||
|
||||
danger_map = DangerMap(bounds=problem.bounds)
|
||||
danger_map.precompute(list(problem.static_obstacles))
|
||||
|
||||
objective = options.objective
|
||||
evaluator = CostEvaluator(
|
||||
world,
|
||||
danger_map,
|
||||
unit_length_cost=objective.unit_length_cost,
|
||||
greedy_h_weight=options.search.greedy_h_weight,
|
||||
bend_penalty=objective.bend_penalty,
|
||||
sbend_penalty=objective.sbend_penalty,
|
||||
danger_weight=objective.danger_weight,
|
||||
)
|
||||
context = AStarContext(evaluator, problem, options)
|
||||
finder = PathFinder(context)
|
||||
return RoutingStack(
|
||||
world=world,
|
||||
danger_map=danger_map,
|
||||
evaluator=evaluator,
|
||||
context=context,
|
||||
finder=finder,
|
||||
)
|
||||
|
|
@ -5,13 +5,9 @@ from typing import TYPE_CHECKING
|
|||
import numpy as np
|
||||
|
||||
from inire.constants import TOLERANCE_LINEAR
|
||||
from inire.model import ObjectiveWeights, RoutingOptions
|
||||
from inire.model import ObjectiveWeights
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import ComponentResult, MoveKind
|
||||
from inire.geometry.primitives import Port
|
||||
|
|
@ -22,18 +18,13 @@ class CostEvaluator:
|
|||
__slots__ = (
|
||||
"collision_engine",
|
||||
"danger_map",
|
||||
"_unit_length_cost",
|
||||
"_search_weights",
|
||||
"_greedy_h_weight",
|
||||
"_bend_penalty",
|
||||
"_sbend_penalty",
|
||||
"_danger_weight",
|
||||
"_congestion_penalty",
|
||||
"_target_x",
|
||||
"_target_y",
|
||||
"_target_r",
|
||||
"_target_cos",
|
||||
"_target_sin",
|
||||
"_min_radius",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
|
|
@ -49,91 +40,25 @@ class CostEvaluator:
|
|||
actual_sbend_penalty = 2.0 * bend_penalty if sbend_penalty is None else sbend_penalty
|
||||
self.collision_engine = collision_engine
|
||||
self.danger_map = danger_map
|
||||
self._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._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_y = 0.0
|
||||
self._target_r = 0
|
||||
self._target_cos = 1.0
|
||||
self._target_sin = 0.0
|
||||
|
||||
self._min_radius = 50.0
|
||||
|
||||
@property
|
||||
def unit_length_cost(self) -> float:
|
||||
return self._unit_length_cost
|
||||
def default_weights(self) -> ObjectiveWeights:
|
||||
return self._search_weights
|
||||
|
||||
@unit_length_cost.setter
|
||||
def unit_length_cost(self, value: float) -> None:
|
||||
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 _resolve_weights(self, weights: ObjectiveWeights | None) -> ObjectiveWeights:
|
||||
return self._search_weights if weights is None else weights
|
||||
|
||||
def set_target(self, target: Port) -> None:
|
||||
self._target_x = target.x
|
||||
|
|
@ -143,12 +68,13 @@ class CostEvaluator:
|
|||
self._target_cos = np.cos(rad)
|
||||
self._target_sin = np.sin(rad)
|
||||
|
||||
def g_proximity(self, x: float, y: float) -> float:
|
||||
if self.danger_map is None:
|
||||
return 0.0
|
||||
return self._danger_weight * self.danger_map.get_cost(x, y)
|
||||
|
||||
def h_manhattan(self, current: Port, target: Port) -> float:
|
||||
def h_manhattan(
|
||||
self,
|
||||
current: Port,
|
||||
target: Port,
|
||||
*,
|
||||
min_bend_radius: float = 50.0,
|
||||
) -> float:
|
||||
tx, ty = target.x, target.y
|
||||
if abs(tx - self._target_x) > TOLERANCE_LINEAR or abs(ty - self._target_y) > TOLERANCE_LINEAR or target.r != self._target_r:
|
||||
self.set_target(target)
|
||||
|
|
@ -156,7 +82,7 @@ class CostEvaluator:
|
|||
dx = abs(current.x - tx)
|
||||
dy = abs(current.y - ty)
|
||||
dist = dx + dy
|
||||
bp = self._bend_penalty
|
||||
bp = self._search_weights.bend_penalty
|
||||
penalty = 0.0
|
||||
|
||||
curr_r = current.r
|
||||
|
|
@ -168,7 +94,7 @@ class CostEvaluator:
|
|||
v_dy = ty - current.y
|
||||
side_proj = v_dx * self._target_cos + v_dy * self._target_sin
|
||||
perp_dist = abs(v_dx * self._target_sin - v_dy * self._target_cos)
|
||||
if side_proj < 0 or (side_proj < self._min_radius and perp_dist > 0):
|
||||
if side_proj < 0 or (side_proj < min_bend_radius and perp_dist > 0):
|
||||
penalty += 2 * bp
|
||||
|
||||
if curr_r == 0:
|
||||
|
|
@ -188,46 +114,27 @@ class CostEvaluator:
|
|||
|
||||
return self._greedy_h_weight * (dist + penalty)
|
||||
|
||||
def evaluate_move(
|
||||
def score_component(
|
||||
self,
|
||||
geometry: Sequence[Polygon] | None,
|
||||
end_port: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
component: ComponentResult,
|
||||
*,
|
||||
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,
|
||||
) -> float:
|
||||
active_weights = self.objective_weights() if weights is None else weights
|
||||
_ = net_width
|
||||
active_weights = self._resolve_weights(weights)
|
||||
danger_map = self.danger_map
|
||||
end_port = component.end_port
|
||||
if danger_map is not None and not danger_map.is_within_bounds(end_port.x, end_port.y):
|
||||
return 1e15
|
||||
|
||||
total_cost = length * active_weights.unit_length_cost + penalty
|
||||
if not skip_static or not skip_congestion:
|
||||
if geometry is None:
|
||||
return 1e15
|
||||
collision_engine = self.collision_engine
|
||||
for i, poly in enumerate(geometry):
|
||||
dil_poly = dilated_geometry[i] if dilated_geometry else None
|
||||
if not skip_static and collision_engine.check_collision(
|
||||
poly,
|
||||
net_id,
|
||||
buffer_mode="static",
|
||||
start_port=start_port,
|
||||
end_port=end_port,
|
||||
dilated_geometry=dil_poly,
|
||||
):
|
||||
return 1e15
|
||||
if not skip_congestion:
|
||||
overlaps = collision_engine.check_collision(poly, net_id, buffer_mode="congestion", dilated_geometry=dil_poly)
|
||||
if isinstance(overlaps, int) and overlaps > 0:
|
||||
total_cost += overlaps * active_weights.congestion_penalty
|
||||
move_radius = None
|
||||
if component.move_type == "bend90":
|
||||
move_radius = component.length * 2.0 / np.pi if component.length > 0 else None
|
||||
total_cost = component.length * active_weights.unit_length_cost + self.component_penalty(
|
||||
component.move_type,
|
||||
move_radius=move_radius,
|
||||
weights=active_weights,
|
||||
)
|
||||
|
||||
if danger_map is not None and active_weights.danger_weight:
|
||||
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_y = (start_port.y + end_port.y) / 2.0
|
||||
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:
|
||||
total_cost += length * active_weights.danger_weight * cost_e
|
||||
total_cost += component.length * active_weights.danger_weight * cost_e
|
||||
return total_cost
|
||||
|
||||
def component_penalty(
|
||||
|
|
@ -248,7 +155,7 @@ class CostEvaluator:
|
|||
move_radius: float | None = None,
|
||||
weights: ObjectiveWeights | None = None,
|
||||
) -> float:
|
||||
active_weights = self.objective_weights() if weights is None else weights
|
||||
active_weights = self._resolve_weights(weights)
|
||||
penalty = 0.0
|
||||
if move_type == "sbend":
|
||||
penalty = active_weights.sbend_penalty
|
||||
|
|
@ -260,37 +167,18 @@ class CostEvaluator:
|
|||
|
||||
def path_cost(
|
||||
self,
|
||||
net_id: str,
|
||||
start_port: Port,
|
||||
path: list[ComponentResult],
|
||||
*,
|
||||
weights: ObjectiveWeights | None = None,
|
||||
) -> float:
|
||||
active_weights = self.objective_weights() if weights is None else weights
|
||||
active_weights = self._resolve_weights(weights)
|
||||
total = 0.0
|
||||
current_port = start_port
|
||||
for component in path:
|
||||
move_radius = None
|
||||
if component.move_type == "bend90":
|
||||
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,
|
||||
total += self.score_component(
|
||||
component,
|
||||
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,
|
||||
)
|
||||
current_port = component.end_port
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import math
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -12,7 +12,8 @@ if TYPE_CHECKING:
|
|||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import ComponentResult
|
||||
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:
|
||||
current = parent_node
|
||||
|
|
@ -24,10 +25,6 @@ def component_hits_ancestor_chain(component: ComponentResult, parent_node: Any)
|
|||
return False
|
||||
|
||||
|
||||
def has_self_collision(path: Sequence[ComponentResult]) -> bool:
|
||||
return has_self_overlap(path)
|
||||
|
||||
|
||||
class PathRefiner:
|
||||
__slots__ = ("context",)
|
||||
|
||||
|
|
@ -42,17 +39,16 @@ class PathRefiner:
|
|||
self,
|
||||
path: Sequence[ComponentResult],
|
||||
*,
|
||||
net_id: str = "default",
|
||||
start: Port | None = None,
|
||||
) -> float:
|
||||
if not path:
|
||||
return 0.0
|
||||
actual_start = path[0].start_port if start is None else start
|
||||
return self.score_path(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:
|
||||
weights = self.context.cost_evaluator.resolve_refiner_weights(self.context.options)
|
||||
return self.context.cost_evaluator.path_cost(net_id, start, path, weights=weights)
|
||||
def score_path(self, start: Port, path: Sequence[ComponentResult]) -> float:
|
||||
weights = self.context.options.refinement.objective or self.context.cost_evaluator.default_weights
|
||||
return self.context.cost_evaluator.path_cost(start, path, weights=weights)
|
||||
|
||||
def _path_ports(self, start: Port, path: Sequence[ComponentResult]) -> list[Port]:
|
||||
ports = [start]
|
||||
|
|
@ -291,11 +287,9 @@ class PathRefiner:
|
|||
self,
|
||||
net_id: str,
|
||||
start: Port,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
path: list[ComponentResult],
|
||||
) -> list[ComponentResult]:
|
||||
_ = target
|
||||
if not path:
|
||||
return path
|
||||
|
||||
|
|
@ -306,7 +300,7 @@ class PathRefiner:
|
|||
return path
|
||||
|
||||
best_path = path
|
||||
best_cost = self.score_path(net_id, start, path)
|
||||
best_cost = self.score_path(start, path)
|
||||
|
||||
for _ in range(3):
|
||||
improved = False
|
||||
|
|
|
|||
|
|
@ -1,68 +1,16 @@
|
|||
from __future__ import annotations
|
||||
"""Semi-private compatibility exports for router result types.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
These deep-module imports remain accessible for advanced use, but they are
|
||||
unstable and may change without notice. Prefer importing public result types
|
||||
from ``inire`` or ``inire.results``.
|
||||
"""
|
||||
|
||||
from inire.router.outcomes import RoutingOutcome, infer_routing_outcome
|
||||
from inire.results import RouteMetrics, RoutingOutcome, RoutingReport, RoutingResult, RoutingRunResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.model import LockedRoute
|
||||
|
||||
|
||||
@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:
|
||||
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)
|
||||
__all__ = [
|
||||
"RouteMetrics",
|
||||
"RoutingOutcome",
|
||||
"RoutingReport",
|
||||
"RoutingResult",
|
||||
"RoutingRunResult",
|
||||
]
|
||||
|
|
|
|||
48
inire/seeds.py
Normal file
48
inire/seeds.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
BendDirection = Literal["CW", "CCW"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StraightSeed:
|
||||
length: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "length", float(self.length))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Bend90Seed:
|
||||
radius: float
|
||||
direction: BendDirection
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "radius", float(self.radius))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SBendSeed:
|
||||
offset: float
|
||||
radius: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "offset", float(self.offset))
|
||||
object.__setattr__(self, "radius", float(self.radius))
|
||||
|
||||
|
||||
PathSegmentSeed = StraightSeed | Bend90Seed | SBendSeed
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PathSeed:
|
||||
segments: tuple[PathSegmentSeed, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
segments = tuple(self.segments)
|
||||
if any(not isinstance(segment, StraightSeed | Bend90Seed | SBendSeed) for segment in segments):
|
||||
raise TypeError("PathSeed segments must be StraightSeed, Bend90Seed, or SBendSeed instances")
|
||||
object.__setattr__(self, "segments", segments)
|
||||
|
|
@ -1,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()
|
||||
|
|
@ -1,40 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from inire import NetSpec, RoutingResult
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
RoutingResult,
|
||||
SearchOptions,
|
||||
)
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router._astar_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.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)
|
||||
class ScenarioOutcome:
|
||||
duration_s: float
|
||||
total_results: int
|
||||
valid_results: int
|
||||
reached_targets: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioDefinition:
|
||||
name: str
|
||||
run: Callable[[], ScenarioOutcome]
|
||||
ScenarioOutcome = tuple[float, int, int, int]
|
||||
ScenarioRun = Callable[[], ScenarioOutcome]
|
||||
|
||||
|
||||
def _summarize(results: dict[str, RoutingResult], duration_s: float) -> ScenarioOutcome:
|
||||
return ScenarioOutcome(
|
||||
duration_s=duration_s,
|
||||
total_results=len(results),
|
||||
valid_results=sum(1 for result in results.values() if result.is_valid),
|
||||
reached_targets=sum(1 for result in results.values() if result.reached_target),
|
||||
return (
|
||||
duration_s,
|
||||
len(results),
|
||||
sum(1 for result in results.values() if result.is_valid),
|
||||
sum(1 for result in results.values() if result.reached_target),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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(
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
|
|
@ -86,7 +123,7 @@ def _build_routing_stack(
|
|||
|
||||
evaluator = CostEvaluator(engine, danger_map, **(evaluator_kwargs or {}))
|
||||
metrics = AStarMetrics()
|
||||
pathfinder = build_pathfinder(
|
||||
pathfinder = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, widths),
|
||||
|
|
@ -150,9 +187,9 @@ def run_example_03() -> ScenarioOutcome:
|
|||
)
|
||||
t0 = perf_counter()
|
||||
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)
|
||||
results_b = build_pathfinder(
|
||||
results_b = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
nets=_net_specs({"netB": (Port(50, -20, 90), Port(50, 20, 90))}, {"netB": 2.0}),
|
||||
|
|
@ -240,7 +277,7 @@ def run_example_06() -> ScenarioOutcome:
|
|||
t0 = perf_counter()
|
||||
combined_results: dict[str, RoutingResult] = {}
|
||||
for evaluator, netlist, net_widths, request_kwargs in scenarios:
|
||||
pathfinder = build_pathfinder(
|
||||
pathfinder = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
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:
|
||||
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
|
||||
evaluator.greedy_h_weight = new_greedy
|
||||
metrics.reset_per_route()
|
||||
_ = idx, current_results
|
||||
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all(iteration_callback=iteration_callback)
|
||||
|
|
@ -315,7 +350,7 @@ def run_example_08() -> ScenarioOutcome:
|
|||
custom_evaluator = _build_evaluator(bounds)
|
||||
|
||||
t0 = perf_counter()
|
||||
results_std = build_pathfinder(
|
||||
results_std = _build_pathfinder(
|
||||
standard_evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs(netlist, widths),
|
||||
|
|
@ -324,7 +359,7 @@ def run_example_08() -> ScenarioOutcome:
|
|||
use_tiered_strategy=False,
|
||||
metrics=AStarMetrics(),
|
||||
).route_all()
|
||||
results_custom = build_pathfinder(
|
||||
results_custom = _build_pathfinder(
|
||||
custom_evaluator,
|
||||
bounds=bounds,
|
||||
nets=_net_specs({"custom_model": netlist["custom_bend"]}, {"custom_model": 2.0}),
|
||||
|
|
@ -351,7 +386,7 @@ def run_example_09() -> ScenarioOutcome:
|
|||
widths=widths,
|
||||
obstacles=obstacles,
|
||||
evaluator_kwargs={"bend_penalty": 50.0, "sbend_penalty": 150.0},
|
||||
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start": None},
|
||||
request_kwargs={"node_limit": 3, "bend_radii": [10.0], "warm_start_enabled": False},
|
||||
)
|
||||
t0 = perf_counter()
|
||||
results = pathfinder.route_all()
|
||||
|
|
@ -359,14 +394,14 @@ def run_example_09() -> ScenarioOutcome:
|
|||
return _summarize(results, t1 - t0)
|
||||
|
||||
|
||||
SCENARIOS: tuple[ScenarioDefinition, ...] = (
|
||||
ScenarioDefinition("example_01_simple_route", run_example_01),
|
||||
ScenarioDefinition("example_02_congestion_resolution", run_example_02),
|
||||
ScenarioDefinition("example_03_locked_routes", run_example_03),
|
||||
ScenarioDefinition("example_04_sbends_and_radii", run_example_04),
|
||||
ScenarioDefinition("example_05_orientation_stress", run_example_05),
|
||||
ScenarioDefinition("example_06_bend_collision_models", run_example_06),
|
||||
ScenarioDefinition("example_07_large_scale_routing", run_example_07),
|
||||
ScenarioDefinition("example_08_custom_bend_geometry", run_example_08),
|
||||
ScenarioDefinition("example_09_unroutable_best_effort", run_example_09),
|
||||
SCENARIOS: tuple[tuple[str, ScenarioRun], ...] = (
|
||||
("example_01_simple_route", run_example_01),
|
||||
("example_02_congestion_resolution", run_example_02),
|
||||
("example_03_locked_routes", run_example_03),
|
||||
("example_04_sbends_and_radii", run_example_04),
|
||||
("example_05_orientation_stress", run_example_05),
|
||||
("example_06_bend_collision_models", run_example_06),
|
||||
("example_07_large_scale_routing", run_example_07),
|
||||
("example_08_custom_bend_geometry", run_example_08),
|
||||
("example_09_unroutable_best_effort", run_example_09),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import importlib
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import box
|
||||
|
||||
from inire import (
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
LockedRoute,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
Port,
|
||||
|
|
@ -16,6 +18,26 @@ from inire import (
|
|||
from inire.geometry.components import Straight
|
||||
|
||||
|
||||
def test_root_module_exports_only_stable_surface() -> None:
|
||||
import inire
|
||||
|
||||
assert not hasattr(inire, "RoutingWorld")
|
||||
assert not hasattr(inire, "AStarContext")
|
||||
assert not hasattr(inire, "PathFinder")
|
||||
assert not hasattr(inire, "CostEvaluator")
|
||||
assert not hasattr(inire, "DangerMap")
|
||||
|
||||
|
||||
def test_deep_raw_stack_imports_remain_accessible_but_unstable() -> None:
|
||||
router_module = importlib.import_module("inire.router._router")
|
||||
search_module = importlib.import_module("inire.router._search")
|
||||
collision_module = importlib.import_module("inire.geometry.collision")
|
||||
|
||||
assert hasattr(router_module, "PathFinder")
|
||||
assert hasattr(search_module, "route_astar")
|
||||
assert hasattr(collision_module, "RoutingWorld")
|
||||
|
||||
|
||||
def test_route_problem_smoke() -> None:
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
|
|
@ -44,7 +66,7 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
|
|||
bend_penalty=50.0,
|
||||
sbend_penalty=150.0,
|
||||
),
|
||||
congestion=CongestionOptions(warm_start=None),
|
||||
congestion=CongestionOptions(warm_start_enabled=False),
|
||||
refinement=RefinementOptions(enabled=True),
|
||||
diagnostics=DiagnosticsOptions(capture_expanded=True),
|
||||
)
|
||||
|
|
@ -61,10 +83,10 @@ def test_route_problem_locked_routes_become_static_obstacles() -> None:
|
|||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
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(
|
||||
congestion=CongestionOptions(max_iterations=1, warm_start=None),
|
||||
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
|
||||
refinement=RefinementOptions(enabled=False),
|
||||
)
|
||||
|
||||
|
|
@ -86,13 +108,22 @@ def test_locked_routes_enable_incremental_requests_without_sessions() -> None:
|
|||
problem_b = RoutingProblem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
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)
|
||||
|
||||
assert results_b.results_by_net["netB"].is_valid
|
||||
|
||||
|
||||
def test_route_problem_rejects_untyped_initial_paths() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
nets=(NetSpec("net1", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
|
||||
initial_paths={"net1": (object(),)}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_route_results_metrics_are_snapshots() -> None:
|
||||
problem = RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import math
|
||||
|
||||
import pytest
|
||||
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.collision import RoutingWorld
|
||||
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.cost import CostEvaluator
|
||||
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)
|
||||
|
||||
|
|
@ -23,15 +23,95 @@ def basic_evaluator() -> CostEvaluator:
|
|||
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
||||
|
||||
|
||||
def _build_options(**search_overrides: object) -> RoutingOptions:
|
||||
return RoutingOptions(search=SearchOptions(**search_overrides))
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
**search_overrides: object,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds),
|
||||
_build_options(**search_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options, **config_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _validate_routing_result(
|
||||
result: RoutingResult,
|
||||
static_obstacles: list[Polygon],
|
||||
clearance: float,
|
||||
expected_start: Port | None = None,
|
||||
expected_end: Port | None = None,
|
||||
) -> dict[str, object]:
|
||||
if not result.path:
|
||||
return {"is_valid": False, "reason": "No path found"}
|
||||
|
||||
connectivity_errors: list[str] = []
|
||||
if expected_start:
|
||||
first_port = result.path[0].start_port
|
||||
dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y)
|
||||
if dist_to_start > 0.005:
|
||||
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
|
||||
if abs(first_port.r - expected_start.r) > 0.1:
|
||||
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
|
||||
|
||||
if expected_end:
|
||||
last_port = result.path[-1].end_port
|
||||
dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y)
|
||||
if dist_to_end > 0.005:
|
||||
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
|
||||
if abs(last_port.r - expected_end.r) > 0.1:
|
||||
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
|
||||
|
||||
engine = RoutingWorld(clearance=clearance)
|
||||
for obstacle in static_obstacles:
|
||||
engine.add_static_obstacle(obstacle)
|
||||
report = engine.verify_path_report("validation", result.path)
|
||||
is_valid = report.is_valid and not connectivity_errors
|
||||
|
||||
reasons = []
|
||||
if report.static_collision_count:
|
||||
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
|
||||
if report.dynamic_collision_count:
|
||||
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
|
||||
if report.self_collision_count:
|
||||
reasons.append(f"Found {report.self_collision_count} self-intersections.")
|
||||
reasons.extend(connectivity_errors)
|
||||
|
||||
return {
|
||||
"is_valid": is_valid,
|
||||
"reason": " ".join(reasons),
|
||||
"obstacle_collisions": report.static_collision_count,
|
||||
"dynamic_collisions": report.dynamic_collision_count,
|
||||
"self_intersections": report.self_collision_count,
|
||||
"total_length": report.total_length,
|
||||
"connectivity_ok": not connectivity_errors,
|
||||
}
|
||||
|
||||
|
||||
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, 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["connectivity_ok"]
|
||||
|
|
@ -40,15 +120,15 @@ def test_astar_straight(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)
|
||||
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
|
||||
target = Port(20, 20, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, 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["connectivity_ok"]
|
||||
|
|
@ -61,14 +141,14 @@ def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
|
|||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
|
||||
context = 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)
|
||||
target = Port(60, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, 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')}"
|
||||
# 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:
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(10.1, 0, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
assert target.x == 10
|
||||
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
validation = _validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
||||
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
|
||||
|
|
@ -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)]
|
||||
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
||||
|
||||
validation = validate_routing_result(
|
||||
validation = _validate_routing_result(
|
||||
result,
|
||||
[],
|
||||
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)
|
||||
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,
|
||||
[obstacle],
|
||||
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:
|
||||
basic_evaluator.bend_penalty = 120.0
|
||||
basic_evaluator.sbend_penalty = 240.0
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0])
|
||||
basic_evaluator = CostEvaluator(
|
||||
basic_evaluator.collision_engine,
|
||||
basic_evaluator.danger_map,
|
||||
bend_penalty=120.0,
|
||||
sbend_penalty=240.0,
|
||||
)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.0,))
|
||||
|
||||
assert basic_evaluator.bend_penalty == 120.0
|
||||
assert basic_evaluator.sbend_penalty == 240.0
|
||||
assert context.options.search.bend_radii == (5.0,)
|
||||
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
|
||||
|
||||
|
||||
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc")
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
|
||||
|
||||
route_astar(
|
||||
Port(0, 0, 0),
|
||||
Port(30, 10, 0),
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(
|
||||
context.options,
|
||||
bend_collision_type="clipped_bbox",
|
||||
return_partial=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert context.options.search.bend_collision_type == "arc"
|
||||
|
|
@ -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)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2)
|
||||
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), node_limit=2)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(60, 0, 0)
|
||||
|
||||
partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True)
|
||||
no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False)
|
||||
partial_path = _route(context, start, target, return_partial=True)
|
||||
no_partial_path = _route(context, start, target, return_partial=False)
|
||||
|
||||
assert partial_path is not None
|
||||
assert partial_path
|
||||
|
|
@ -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:
|
||||
context = build_context(
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bounds=BOUNDS,
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
sbend_offsets=[10.0],
|
||||
bend_radii=(10.0,),
|
||||
sbend_radii=(10.0,),
|
||||
sbend_offsets=(10.0,),
|
||||
max_straight_length=150.0,
|
||||
)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(100, 10, 0)
|
||||
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
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)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
context = build_context(
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bounds=BOUNDS,
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[],
|
||||
bend_radii=(10.0,),
|
||||
sbend_radii=(),
|
||||
max_straight_length=150.0,
|
||||
visibility_guidance=visibility_guidance,
|
||||
)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(80, 50, 0)
|
||||
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
result = RoutingResult(net_id="test", path=path, 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["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:
|
||||
context = AStarContext(
|
||||
basic_evaluator,
|
||||
build_problem(bounds=BOUNDS),
|
||||
build_options(
|
||||
RoutingProblem(bounds=BOUNDS),
|
||||
_build_options(
|
||||
min_straight_length=1.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)]
|
||||
|
||||
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[-1].end_port == target
|
||||
|
|
|
|||
|
|
@ -1,13 +1,41 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from shapely.geometry import Polygon
|
||||
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.components import Straight
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire 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():
|
||||
"""
|
||||
|
|
@ -27,21 +55,21 @@ def test_clearance_thresholds():
|
|||
# 1. Beyond minimum spacing: y=5. Gap = 5 - 2 = 3 > 2. OK.
|
||||
p2_ok = Port(0, 5, 0)
|
||||
res2_ok = Straight.generate(p2_ok, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_ok])
|
||||
assert is_v, f"Gap 3 should be valid, but got {count} collisions"
|
||||
report_ok = ce.verify_path_report("net2", [res2_ok])
|
||||
assert report_ok.is_valid, f"Gap 3 should be valid, but got {report_ok.collision_count} collisions"
|
||||
|
||||
# 2. Exactly at: y=4.0. Gap = 4.0 - 2.0 = 2.0. OK.
|
||||
p2_exact = Port(0, 4, 0)
|
||||
res2_exact = Straight.generate(p2_exact, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_exact])
|
||||
assert is_v, f"Gap exactly 2.0 should be valid, but got {count} collisions"
|
||||
report_exact = ce.verify_path_report("net2", [res2_exact])
|
||||
assert report_exact.is_valid, f"Gap exactly 2.0 should be valid, but got {report_exact.collision_count} collisions"
|
||||
|
||||
# 3. Slightly violating: y=3.999. Gap = 3.999 - 2.0 = 1.999 < 2.0. FAIL.
|
||||
p2_fail = Port(0, 3, 0)
|
||||
res2_fail = Straight.generate(p2_fail, 50.0, width=2.0, dilation=1.0)
|
||||
is_v, count = ce.verify_path("net2", [res2_fail])
|
||||
assert not is_v, "Gap 1.999 should be invalid"
|
||||
assert count > 0
|
||||
report_fail = ce.verify_path_report("net2", [res2_fail])
|
||||
assert not report_fail.is_valid, "Gap 1.999 should be invalid"
|
||||
assert report_fail.collision_count > 0
|
||||
|
||||
def test_verify_all_nets_cases():
|
||||
"""
|
||||
|
|
@ -59,13 +87,12 @@ def test_verify_all_nets_cases():
|
|||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
results = build_pathfinder(
|
||||
results = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_parallel_ok,
|
||||
net_widths=net_widths,
|
||||
warm_start=None,
|
||||
max_iterations=1,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
assert results["net1"].is_valid, f"Exactly at clearance should be valid, collisions={results['net1'].collisions}"
|
||||
assert results["net2"].is_valid
|
||||
|
|
@ -79,13 +106,12 @@ def test_verify_all_nets_cases():
|
|||
engine.remove_path("net1")
|
||||
engine.remove_path("net2")
|
||||
|
||||
results_p = build_pathfinder(
|
||||
results_p = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_parallel_fail,
|
||||
net_widths=net_widths,
|
||||
warm_start=None,
|
||||
max_iterations=1,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
# verify_all_nets should flag both as invalid because they cross-collide
|
||||
assert not results_p["net3"].is_valid
|
||||
|
|
@ -99,13 +125,12 @@ def test_verify_all_nets_cases():
|
|||
engine.remove_path("net3")
|
||||
engine.remove_path("net4")
|
||||
|
||||
results_c = build_pathfinder(
|
||||
results_c = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist_cross,
|
||||
net_widths=net_widths,
|
||||
warm_start=None,
|
||||
max_iterations=1,
|
||||
congestion=CongestionOptions(warm_start_enabled=False, max_iterations=1),
|
||||
).route_all()
|
||||
assert not results_c["net5"].is_valid
|
||||
assert not results_c["net6"].is_valid
|
||||
|
|
|
|||
|
|
@ -1,65 +1,42 @@
|
|||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.components import Straight
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
|
||||
def _install_static_straight(
|
||||
engine: RoutingWorld,
|
||||
start: Port,
|
||||
length: float,
|
||||
*,
|
||||
width: float,
|
||||
dilation: float = 0.0,
|
||||
) -> None:
|
||||
obstacle = Straight.generate(start, length, width=width, dilation=dilation)
|
||||
for polygon in obstacle.physical_geometry:
|
||||
engine.add_static_obstacle(polygon)
|
||||
|
||||
|
||||
def test_collision_detection() -> None:
|
||||
# Clearance = 2um
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0, dilation=1.0)
|
||||
|
||||
# 10x10 um obstacle at (10,10)
|
||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
direct_hit = Straight.generate(Port(12, 12.5, 0), 1.0, width=1.0, dilation=1.0)
|
||||
assert engine.check_move_static(direct_hit, start_port=direct_hit.start_port)
|
||||
|
||||
# 1. Direct hit
|
||||
test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)])
|
||||
assert engine.is_collision(test_poly, net_width=2.0)
|
||||
far_away = Straight.generate(Port(0, 2.5, 0), 5.0, width=5.0, dilation=1.0)
|
||||
assert not engine.check_move_static(far_away, start_port=far_away.start_port)
|
||||
|
||||
# 2. Far away
|
||||
test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
|
||||
assert not engine.is_collision(test_poly_far, net_width=2.0)
|
||||
|
||||
# 3. Near hit (within clearance)
|
||||
# Obstacle edge at x=10.
|
||||
# test_poly edge at x=9.
|
||||
# Distance = 1.0 um.
|
||||
# Required distance (Wi+C)/2 = 2.0. Collision!
|
||||
test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)])
|
||||
assert engine.is_collision(test_poly_near, net_width=2.0)
|
||||
near_hit = Straight.generate(Port(8, 12.5, 0), 1.0, width=5.0, dilation=1.0)
|
||||
assert engine.check_move_static(near_hit, start_port=near_hit.start_port)
|
||||
|
||||
|
||||
def test_safety_zone() -> None:
|
||||
# Use zero clearance for this test to verify the 2nm port safety zone
|
||||
# against the physical obstacle boundary.
|
||||
engine = RoutingWorld(clearance=0.0)
|
||||
_install_static_straight(engine, Port(10, 15, 0), 10.0, width=10.0)
|
||||
|
||||
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
# Port exactly on the boundary
|
||||
start_port = Port(10, 12, 0)
|
||||
|
||||
# Move starting from this port that overlaps the obstacle by 1nm
|
||||
# (Inside the 2nm safety zone)
|
||||
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
|
||||
|
||||
assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port)
|
||||
|
||||
|
||||
def test_configurable_max_net_width() -> None:
|
||||
# Large max_net_width (10.0) -> large pre-dilation (6.0)
|
||||
engine = 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)
|
||||
test_move = Straight.generate(start_port, 0.002, width=0.001)
|
||||
assert not engine.check_move_static(test_move, start_port=start_port)
|
||||
|
||||
|
||||
def test_ray_cast_width_clearance() -> None:
|
||||
|
|
@ -68,8 +45,7 @@ def test_ray_cast_width_clearance() -> None:
|
|||
engine = RoutingWorld(clearance=2.0)
|
||||
|
||||
# Obstacle at x=10 to 20
|
||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0)
|
||||
|
||||
# 1. Parallel move at x=6. Gap = 10 - 6 = 4.0. Clearly OK.
|
||||
start_ok = Port(6, 50, 90)
|
||||
|
|
@ -84,25 +60,24 @@ def test_ray_cast_width_clearance() -> None:
|
|||
|
||||
def test_check_move_static_clearance() -> None:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
obstacle = Polygon([(10, 0), (20, 0), (20, 100), (10, 100)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
_install_static_straight(engine, Port(10, 50, 0), 10.0, width=100.0, dilation=1.0)
|
||||
|
||||
# Straight move of length 10 at x=8 (Width 2.0)
|
||||
# Gap = 10 - 8 = 2.0 < 3.0. COLLISION.
|
||||
start = Port(8, 0, 90)
|
||||
res = Straight.generate(start, 10.0, width=2.0, dilation=1.0) # dilation = C/2
|
||||
|
||||
assert engine.check_move_static(res, start_port=start, net_width=2.0)
|
||||
assert engine.check_move_static(res, start_port=start)
|
||||
|
||||
# Move at x=7. Gap = 3.0 == minimum. OK.
|
||||
start_ok = Port(7, 0, 90)
|
||||
res_ok = Straight.generate(start_ok, 10.0, width=2.0, dilation=1.0)
|
||||
assert not engine.check_move_static(res_ok, start_port=start_ok, net_width=2.0)
|
||||
assert not engine.check_move_static(res_ok, start_port=start_ok)
|
||||
|
||||
# 3. Same exact-boundary case.
|
||||
start_exact = Port(7, 0, 90)
|
||||
res_exact = Straight.generate(start_exact, 10.0, width=2.0, dilation=1.0)
|
||||
assert not engine.check_move_static(res_exact, start_port=start_exact, net_width=2.0)
|
||||
assert not engine.check_move_static(res_exact, start_port=start_exact)
|
||||
|
||||
|
||||
def test_verify_path_report_preserves_long_net_id() -> None:
|
||||
|
|
@ -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]
|
||||
|
||||
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")
|
||||
assert list(engine.iter_dynamic_paths()) == []
|
||||
assert list(engine._dynamic_paths.geometries.values()) == []
|
||||
assert len(engine._static_obstacles.geometries) == 0
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import pytest
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire import CongestionOptions, RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.tests.support import build_context, build_pathfinder
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
nets: tuple[NetSpec, ...] = (),
|
||||
search: SearchOptions | None = None,
|
||||
congestion: CongestionOptions | None = None,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds, nets=nets),
|
||||
RoutingOptions(
|
||||
search=SearchOptions() if search is None else search,
|
||||
congestion=CongestionOptions() if congestion is None else congestion,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_pathfinder(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
search: SearchOptions | None = None,
|
||||
congestion: CongestionOptions | None = None,
|
||||
) -> PathFinder:
|
||||
nets = tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
return PathFinder(
|
||||
_build_context(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
nets=nets,
|
||||
search=search,
|
||||
congestion=congestion,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port) -> object:
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
|
||||
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
|
||||
context = build_context(basic_evaluator, bounds=BOUNDS, sbend_offsets=[2.0, 5.0])
|
||||
context = _build_context(
|
||||
basic_evaluator,
|
||||
bounds=BOUNDS,
|
||||
search=SearchOptions(sbend_offsets=(2.0, 5.0)),
|
||||
)
|
||||
# Start at (0,0), target at (50, 2) -> 2um lateral offset
|
||||
# This matches one of our discretized SBend offsets.
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 2, 0)
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
assert path is not None
|
||||
# Check if any component in the path is an SBend
|
||||
|
|
@ -39,38 +96,3 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
|
|||
found_sbend = True
|
||||
break
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import statistics
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from inire.tests.example_scenarios import SCENARIOS, ScenarioDefinition, ScenarioOutcome
|
||||
from inire.tests.example_scenarios import SCENARIOS, ScenarioOutcome
|
||||
|
||||
|
||||
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
|
||||
|
|
@ -39,25 +40,27 @@ EXPECTED_OUTCOMES = {
|
|||
|
||||
|
||||
def _assert_expected_outcome(name: str, outcome: ScenarioOutcome) -> None:
|
||||
_, total_results, valid_results, reached_targets = outcome
|
||||
expected = EXPECTED_OUTCOMES[name]
|
||||
assert outcome.total_results == expected["total_results"]
|
||||
assert outcome.valid_results == expected["valid_results"]
|
||||
assert outcome.reached_targets == expected["reached_targets"]
|
||||
assert total_results == expected["total_results"]
|
||||
assert valid_results == expected["valid_results"]
|
||||
assert reached_targets == expected["reached_targets"]
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.skipif(not RUN_PERFORMANCE, reason="set INIRE_RUN_PERFORMANCE=1 to run runtime regression checks")
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[scenario.name for scenario in SCENARIOS])
|
||||
def test_example_like_runtime_regression(scenario: ScenarioDefinition) -> None:
|
||||
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[name for name, _ in SCENARIOS])
|
||||
def test_example_like_runtime_regression(scenario: tuple[str, Callable[[], ScenarioOutcome]]) -> None:
|
||||
name, run = scenario
|
||||
timings = []
|
||||
for _ in range(PERFORMANCE_REPEATS):
|
||||
outcome = scenario.run()
|
||||
_assert_expected_outcome(scenario.name, outcome)
|
||||
timings.append(outcome.duration_s)
|
||||
outcome = run()
|
||||
_assert_expected_outcome(name, outcome)
|
||||
timings.append(outcome[0])
|
||||
|
||||
median_runtime = statistics.median(timings)
|
||||
assert median_runtime <= BASELINE_SECONDS[scenario.name] * REGRESSION_FACTOR, (
|
||||
f"{scenario.name} median runtime {median_runtime:.4f}s exceeded "
|
||||
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[scenario.name]:.4f}s "
|
||||
assert median_runtime <= BASELINE_SECONDS[name] * REGRESSION_FACTOR, (
|
||||
f"{name} median runtime {median_runtime:.4f}s exceeded "
|
||||
f"{REGRESSION_FACTOR:.1f}x baseline {BASELINE_SECONDS[name]:.4f}s "
|
||||
f"from timings {timings!r}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from inire import CongestionOptions, RoutingOptions, RoutingProblem
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.tests.support import build_pathfinder
|
||||
|
||||
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))
|
||||
}
|
||||
net_widths = {"net1": 1.0}
|
||||
pf = build_pathfinder(
|
||||
pf = PathFinder(
|
||||
AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist=netlist,
|
||||
net_widths=net_widths,
|
||||
node_limit=10,
|
||||
max_iterations=1,
|
||||
warm_start=None,
|
||||
nets=tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
),
|
||||
),
|
||||
RoutingOptions(
|
||||
search=RoutingOptions().search.__class__(node_limit=10),
|
||||
congestion=CongestionOptions(max_iterations=1, warm_start_enabled=False),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 4. Route
|
||||
|
|
@ -59,9 +69,7 @@ def test_failed_net_visibility() -> None:
|
|||
|
||||
# 6. Verify Visibility
|
||||
# Check if net1 is in the collision engine
|
||||
found_nets = set()
|
||||
for nid, _poly in engine.iter_dynamic_paths():
|
||||
found_nets.add(nid)
|
||||
found_nets = {net_id for net_id, _ in engine._dynamic_paths.geometries.values()}
|
||||
|
||||
print(f"Nets found in engine: {found_nets}")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ from shapely.geometry import Point, Polygon
|
|||
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.model import RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.tests.support import build_context
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def _build_context(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
bounds: tuple[float, float, float, float],
|
||||
**search_overrides: object,
|
||||
) -> AStarContext:
|
||||
return AStarContext(
|
||||
evaluator,
|
||||
RoutingProblem(bounds=bounds),
|
||||
RoutingOptions(search=SearchOptions(**search_overrides)),
|
||||
)
|
||||
|
||||
|
||||
def _route(context: AStarContext, start: Port, target: Port):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=2.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=3, deadline=None)
|
||||
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
|
||||
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
|
||||
|
|
@ -48,12 +72,12 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
|
|||
danger_map.precompute(obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = 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)
|
||||
# The router should handle this gracefully (either route or return None)
|
||||
try:
|
||||
path = route_astar(start, target, net_width=2.0, context=context)
|
||||
path = _route(context, start, target)
|
||||
|
||||
# This is a crash-smoke test rather than a full correctness proof.
|
||||
# If a full path is returned, it should at least terminate at the requested target.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import pytest
|
||||
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.components import Bend90, Straight
|
||||
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.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.tests.support import build_context
|
||||
|
||||
DEFAULT_BOUNDS = (0, 0, 100, 100)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator() -> CostEvaluator:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=DEFAULT_BOUNDS)
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map)
|
||||
|
||||
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets"}
|
||||
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
|
||||
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
|
||||
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
|
||||
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
|
||||
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
|
||||
|
||||
def _request_nets(
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
|
|
@ -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(
|
||||
evaluator: CostEvaluator,
|
||||
*,
|
||||
|
|
@ -42,7 +78,7 @@ def _build_pathfinder(
|
|||
**request_overrides: object,
|
||||
) -> PathFinder:
|
||||
return PathFinder(
|
||||
build_context(
|
||||
_build_context(
|
||||
evaluator,
|
||||
bounds=bounds,
|
||||
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)
|
||||
curr = comp.end_port
|
||||
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:
|
||||
netlist = {"net": (Port(0, 0, 0), Port(10, 10, 90))}
|
||||
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.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
context = build_context(
|
||||
context = _build_context(
|
||||
evaluator,
|
||||
bounds=(-20, -20, 60, 60),
|
||||
nets=_request_nets(netlist, widths),
|
||||
bend_radii=[10.0],
|
||||
max_straight_length=50.0,
|
||||
node_limit=50,
|
||||
warm_start=None,
|
||||
warm_start_enabled=False,
|
||||
max_iterations=1,
|
||||
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] == [
|
||||
(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:
|
||||
engine = RoutingWorld(clearance=2.0)
|
||||
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
|
||||
|
||||
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 sum(1 for comp in path if comp.move_type == "bend90") == 6
|
||||
assert sum(1 for comp in refined if comp.move_type == "bend90") == 4
|
||||
assert refined[-1].end_port == target
|
||||
assert pf._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:
|
||||
|
|
@ -450,10 +222,10 @@ def test_refine_path_can_simplify_subpath_with_different_global_orientation() ->
|
|||
)
|
||||
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 sum(1 for comp in path if comp.move_type == "bend90") == 7
|
||||
assert sum(1 for comp in refined if comp.move_type == "bend90") == 5
|
||||
assert refined[-1].end_port == target
|
||||
assert pf._path_cost(refined) < pf._path_cost(path)
|
||||
assert pf.refiner.path_cost(refined) < pf.refiner.path_cost(path)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
from inire import RoutingOptions, RoutingProblem, SearchOptions
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.geometry.components import Bend90
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.model import NetSpec
|
||||
from inire.router._astar_types import AStarContext
|
||||
from inire.router._router import PathFinder
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.danger_map import DangerMap
|
||||
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:
|
||||
|
|
@ -31,17 +55,17 @@ def test_locked_routes() -> None:
|
|||
|
||||
# 1. Route Net A
|
||||
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
|
||||
results_a = build_pathfinder(
|
||||
results_a = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist=netlist_a,
|
||||
net_widths={"netA": 2.0},
|
||||
bend_radii=[5.0, 10.0],
|
||||
search=SearchOptions(bend_radii=(5.0, 10.0)),
|
||||
).route_all()
|
||||
assert results_a["netA"].is_valid
|
||||
|
||||
# 2. Treat Net A as locked geometry in the next run.
|
||||
for polygon in results_a["netA"].as_locked_route().geometry:
|
||||
for polygon in results_a["netA"].locked_geometry:
|
||||
engine.add_static_obstacle(polygon)
|
||||
|
||||
# 3. Route Net B through the same space. It should detour or fail.
|
||||
|
|
@ -49,12 +73,12 @@ def test_locked_routes() -> None:
|
|||
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
|
||||
|
||||
# Route Net B
|
||||
results_b = build_pathfinder(
|
||||
results_b = _build_pathfinder(
|
||||
evaluator,
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist=netlist_b,
|
||||
net_widths={"netB": 2.0},
|
||||
bend_radii=[5.0, 10.0],
|
||||
search=SearchOptions(bend_radii=(5.0, 10.0)),
|
||||
).route_all()
|
||||
|
||||
# Net B should be is_valid (it detoured) or at least not have collisions
|
||||
|
|
|
|||
301
inire/tests/test_route_behavior.py
Normal file
301
inire/tests/test_route_behavior.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire import (
|
||||
Bend90Seed,
|
||||
CongestionOptions,
|
||||
DiagnosticsOptions,
|
||||
NetSpec,
|
||||
ObjectiveWeights,
|
||||
PathSeed,
|
||||
Port,
|
||||
RefinementOptions,
|
||||
RoutingOptions,
|
||||
RoutingProblem,
|
||||
RoutingResult,
|
||||
SearchOptions,
|
||||
StraightSeed,
|
||||
route,
|
||||
)
|
||||
|
||||
DEFAULT_BOUNDS = (0, 0, 100, 100)
|
||||
|
||||
_PROBLEM_FIELDS = set(RoutingProblem.__dataclass_fields__) - {"bounds", "nets", "static_obstacles"}
|
||||
_SEARCH_FIELDS = set(SearchOptions.__dataclass_fields__)
|
||||
_CONGESTION_FIELDS = set(CongestionOptions.__dataclass_fields__)
|
||||
_REFINEMENT_FIELDS = set(RefinementOptions.__dataclass_fields__)
|
||||
_DIAGNOSTICS_FIELDS = set(DiagnosticsOptions.__dataclass_fields__)
|
||||
_OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
|
||||
|
||||
|
||||
def _request_nets(
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
) -> tuple[NetSpec, ...]:
|
||||
return tuple(
|
||||
NetSpec(net_id=net_id, start=start, target=target, width=net_widths.get(net_id, 2.0))
|
||||
for net_id, (start, target) in netlist.items()
|
||||
)
|
||||
|
||||
|
||||
def _build_options(**overrides: object) -> RoutingOptions:
|
||||
search_overrides = {key: value for key, value in overrides.items() if key in _SEARCH_FIELDS}
|
||||
congestion_overrides = {key: value for key, value in overrides.items() if key in _CONGESTION_FIELDS}
|
||||
refinement_overrides = {key: value for key, value in overrides.items() if key in _REFINEMENT_FIELDS}
|
||||
diagnostics_overrides = {key: value for key, value in overrides.items() if key in _DIAGNOSTICS_FIELDS}
|
||||
objective_overrides = {key: value for key, value in overrides.items() if key in _OBJECTIVE_FIELDS}
|
||||
return RoutingOptions(
|
||||
search=SearchOptions(**search_overrides),
|
||||
congestion=CongestionOptions(**congestion_overrides),
|
||||
refinement=RefinementOptions(**refinement_overrides),
|
||||
diagnostics=DiagnosticsOptions(**diagnostics_overrides),
|
||||
objective=ObjectiveWeights(**objective_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _route_problem(
|
||||
*,
|
||||
netlist: dict[str, tuple[Port, Port]],
|
||||
net_widths: dict[str, float],
|
||||
bounds: tuple[float, float, float, float] = DEFAULT_BOUNDS,
|
||||
static_obstacles: tuple[Polygon, ...] = (),
|
||||
iteration_callback=None,
|
||||
**overrides: object,
|
||||
):
|
||||
problem_overrides = {key: value for key, value in overrides.items() if key in _PROBLEM_FIELDS}
|
||||
option_overrides = {key: value for key, value in overrides.items() if key not in _PROBLEM_FIELDS}
|
||||
problem = RoutingProblem(
|
||||
bounds=bounds,
|
||||
nets=_request_nets(netlist, net_widths),
|
||||
static_obstacles=static_obstacles,
|
||||
**problem_overrides,
|
||||
)
|
||||
return route(problem, options=_build_options(**option_overrides), iteration_callback=iteration_callback)
|
||||
|
||||
|
||||
def _bend_count(result: RoutingResult) -> int:
|
||||
return sum(1 for component in result.path if component.move_type == "bend90")
|
||||
|
||||
|
||||
def _build_manual_seed(steps: list[tuple[str, float | str]]) -> PathSeed:
|
||||
segments = []
|
||||
for kind, value in steps:
|
||||
if kind == "B":
|
||||
segments.append(Bend90Seed(radius=5.0, direction=value))
|
||||
else:
|
||||
segments.append(StraightSeed(length=value))
|
||||
return PathSeed(tuple(segments))
|
||||
|
||||
|
||||
def test_route_parallel_nets_are_valid() -> None:
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
)
|
||||
|
||||
assert len(run.results_by_net) == 2
|
||||
assert run.results_by_net["net1"].is_valid
|
||||
assert run.results_by_net["net2"].is_valid
|
||||
assert run.results_by_net["net1"].collisions == 0
|
||||
assert run.results_by_net["net2"].collisions == 0
|
||||
|
||||
|
||||
def test_route_reports_crossing_nets_without_congestion_resolution() -> None:
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 25, 0), Port(100, 25, 0)),
|
||||
"net2": (Port(50, 0, 90), Port(50, 50, 90)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
max_iterations=1,
|
||||
base_penalty=1.0,
|
||||
warm_start_enabled=False,
|
||||
)
|
||||
|
||||
assert not run.results_by_net["net1"].is_valid
|
||||
assert not run.results_by_net["net2"].is_valid
|
||||
assert run.results_by_net["net1"].collisions > 0
|
||||
assert run.results_by_net["net2"].collisions > 0
|
||||
|
||||
|
||||
def test_route_callback_respects_requested_net_order() -> None:
|
||||
callback_orders: list[list[str]] = []
|
||||
|
||||
_route_problem(
|
||||
netlist={
|
||||
"short": (Port(0, 0, 0), Port(10, 0, 0)),
|
||||
"long": (Port(0, 0, 0), Port(40, 10, 0)),
|
||||
"mid": (Port(0, 0, 0), Port(20, 0, 0)),
|
||||
},
|
||||
net_widths={"short": 2.0, "long": 2.0, "mid": 2.0},
|
||||
max_iterations=1,
|
||||
warm_start_enabled=False,
|
||||
net_order="longest",
|
||||
enabled=False,
|
||||
iteration_callback=lambda iteration, results: callback_orders.append(list(results)),
|
||||
)
|
||||
|
||||
assert callback_orders == [["long", "mid", "short"]]
|
||||
|
||||
|
||||
def test_route_callback_receives_iteration_results() -> None:
|
||||
callback_results: list[dict[str, RoutingResult]] = []
|
||||
|
||||
run = _route_problem(
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(10, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(10, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
iteration_callback=lambda iteration, results: callback_results.append(dict(results)),
|
||||
)
|
||||
|
||||
assert len(callback_results) == 1
|
||||
assert set(callback_results[0]) == {"net1", "net2"}
|
||||
assert callback_results[0]["net1"].is_valid
|
||||
assert callback_results[0]["net2"].is_valid
|
||||
assert run.results_by_net["net1"].reached_target
|
||||
assert run.results_by_net["net2"].reached_target
|
||||
|
||||
|
||||
def test_route_uses_complete_initial_paths_without_rerouting() -> None:
|
||||
initial_seed = _build_manual_seed([("S", 10.0), ("B", "CCW"), ("S", 10.0), ("B", "CW")])
|
||||
run = _route_problem(
|
||||
netlist={"net": (Port(0, 0, 0), Port(20, 20, 0))},
|
||||
net_widths={"net": 2.0},
|
||||
bend_radii=[5.0],
|
||||
max_iterations=1,
|
||||
warm_start_enabled=False,
|
||||
initial_paths={"net": initial_seed},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
result = run.results_by_net["net"]
|
||||
assert result.is_valid
|
||||
assert result.reached_target
|
||||
assert result.as_seed() == initial_seed
|
||||
|
||||
|
||||
def test_route_retries_partial_initial_paths_across_iterations() -> None:
|
||||
iterations: list[int] = []
|
||||
partial_seed = PathSeed((StraightSeed(length=5.0),))
|
||||
run = _route_problem(
|
||||
netlist={"net": (Port(0, 0, 0), Port(10, 0, 0))},
|
||||
net_widths={"net": 2.0},
|
||||
max_iterations=2,
|
||||
warm_start_enabled=False,
|
||||
capture_expanded=True,
|
||||
initial_paths={"net": partial_seed},
|
||||
enabled=False,
|
||||
iteration_callback=lambda iteration, results: iterations.append(iteration),
|
||||
)
|
||||
|
||||
result = run.results_by_net["net"]
|
||||
assert iterations == [0, 1]
|
||||
assert result.is_valid
|
||||
assert result.reached_target
|
||||
assert result.outcome == "completed"
|
||||
assert result.as_seed() != partial_seed
|
||||
assert run.expanded_nodes
|
||||
|
||||
|
||||
def test_route_negotiated_congestion_resolution() -> None:
|
||||
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)])
|
||||
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])
|
||||
run = _route_problem(
|
||||
bounds=(0, -40, 100, 40),
|
||||
netlist={
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
|
||||
},
|
||||
net_widths={"net1": 2.0, "net2": 2.0},
|
||||
static_obstacles=(obs_top, obs_bottom),
|
||||
bend_radii=(5.0, 10.0),
|
||||
max_iterations=10,
|
||||
base_penalty=1000.0,
|
||||
)
|
||||
|
||||
assert run.results_by_net["net1"].reached_target
|
||||
assert run.results_by_net["net2"].reached_target
|
||||
assert run.results_by_net["net1"].is_valid
|
||||
assert run.results_by_net["net2"].is_valid
|
||||
|
||||
|
||||
def test_route_refinement_reduces_locked_detour_bends() -> None:
|
||||
route_a = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netA": (Port(10, 0, 0), Port(90, 0, 0))},
|
||||
net_widths={"netA": 2.0},
|
||||
bend_radii=[10.0],
|
||||
enabled=False,
|
||||
)
|
||||
locked_geometry = route_a.results_by_net["netA"].locked_geometry
|
||||
|
||||
base_run = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
|
||||
net_widths={"netB": 2.0},
|
||||
static_obstacles=locked_geometry,
|
||||
bend_radii=[10.0],
|
||||
enabled=False,
|
||||
)
|
||||
refined_run = _route_problem(
|
||||
bounds=(0, -50, 100, 50),
|
||||
netlist={"netB": (Port(50, -20, 90), Port(50, 20, 90))},
|
||||
net_widths={"netB": 2.0},
|
||||
static_obstacles=locked_geometry,
|
||||
bend_radii=[10.0],
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
base_result = base_run.results_by_net["netB"]
|
||||
refined_result = refined_run.results_by_net["netB"]
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert _bend_count(refined_result) < _bend_count(base_result)
|
||||
|
||||
|
||||
def test_route_refinement_simplifies_triple_crossing_detours() -> None:
|
||||
base_run = _route_problem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist={
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
},
|
||||
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
base_penalty=1000.0,
|
||||
enabled=False,
|
||||
greedy_h_weight=1.5,
|
||||
bend_penalty=250.0,
|
||||
sbend_penalty=500.0,
|
||||
)
|
||||
refined_run = _route_problem(
|
||||
bounds=(0, 0, 100, 100),
|
||||
netlist={
|
||||
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
|
||||
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
|
||||
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
|
||||
},
|
||||
net_widths={"horizontal": 2.0, "vertical_up": 2.0, "vertical_down": 2.0},
|
||||
bend_radii=[10.0],
|
||||
sbend_radii=[10.0],
|
||||
base_penalty=1000.0,
|
||||
enabled=True,
|
||||
greedy_h_weight=1.5,
|
||||
bend_penalty=250.0,
|
||||
sbend_penalty=500.0,
|
||||
)
|
||||
|
||||
for net_id in ("vertical_up", "vertical_down"):
|
||||
base_result = base_run.results_by_net[net_id]
|
||||
refined_result = refined_run.results_by_net[net_id]
|
||||
assert base_result.is_valid
|
||||
assert refined_result.is_valid
|
||||
assert _bend_count(refined_result) < _bend_count(base_result)
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import unittest
|
||||
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.model import RoutingOptions, RoutingProblem
|
||||
from inire.router._astar_types import AStarContext, SearchRunConfig
|
||||
from inire.router._search import route_astar
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.geometry.collision import RoutingWorld
|
||||
from inire.tests.support import build_context
|
||||
|
||||
|
||||
class TestIntegerPorts(unittest.TestCase):
|
||||
|
|
@ -13,12 +14,28 @@ class TestIntegerPorts(unittest.TestCase):
|
|||
self.cost = CostEvaluator(self.ce)
|
||||
self.bounds = (0, 0, 100, 100)
|
||||
|
||||
def _build_context(self) -> AStarContext:
|
||||
return AStarContext(
|
||||
self.cost,
|
||||
RoutingProblem(bounds=self.bounds),
|
||||
RoutingOptions(),
|
||||
)
|
||||
|
||||
def _route(self, context: AStarContext, start: Port, target: Port):
|
||||
return route_astar(
|
||||
start,
|
||||
target,
|
||||
net_width=1.0,
|
||||
context=context,
|
||||
config=SearchRunConfig.from_options(context.options),
|
||||
)
|
||||
|
||||
def test_route_reaches_integer_target(self):
|
||||
context = build_context(self.cost, bounds=self.bounds)
|
||||
context = self._build_context()
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(12, 0, 0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
last_port = path[-1].end_port
|
||||
|
|
@ -27,11 +44,11 @@ class TestIntegerPorts(unittest.TestCase):
|
|||
self.assertEqual(last_port.r, 0)
|
||||
|
||||
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)
|
||||
target = Port(12.3, 0.0, 0.0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
self.assertEqual(target.x, 12)
|
||||
|
|
@ -39,11 +56,11 @@ class TestIntegerPorts(unittest.TestCase):
|
|||
self.assertEqual(last_port.x, 12)
|
||||
|
||||
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)
|
||||
target = Port(7.5, 0.0, 0.0)
|
||||
|
||||
path = route_astar(start, target, net_width=1.0, context=context)
|
||||
path = self._route(context, start, target)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
self.assertEqual(target.x, 8)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.results import RoutingResult
|
||||
from inire.results import RoutingResult
|
||||
|
||||
|
||||
def plot_routing_results(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue