Compare commits

...

2 commits

Author SHA1 Message Date
c9bb8d6469 consistency and speed 2026-03-09 02:26:27 -07:00
58873692d6 more bend work; bounds constrain edges 2026-03-09 01:48:18 -07:00
17 changed files with 367 additions and 255 deletions

53
DOCS.md
View file

@ -6,23 +6,23 @@ This document describes the user-tunable parameters for the `inire` auto-router.
The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor. The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor.
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- | | :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ |
| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | | `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. |
| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search in open space. | | `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search. |
| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow the router to pick the best fit. | | `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow best-fit selection. |
| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | | `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). |
| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | | `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). |
| `snap_to_target_dist`| `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | | `snap_to_target_dist` | `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. |
| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | | `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. |
| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | | `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. |
| `bend_collision_type`| `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | | `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. |
| `bend_clip_margin` | `float` | 10.0 | Margin (µm) for the `"clipped_bbox"` collision model. | | `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide before the bounding box corners are clipped. |
### Bend Collision Models ### Bend Collision Models
* `"arc"`: High-fidelity model following the exact curved waveguide geometry. * `"arc"`: High-fidelity model following the exact curved waveguide geometry.
* `"bbox"`: Conservative model using the axis-aligned bounding box of the bend. Fast but blocks more space. * `"bbox"`: Conservative model using the axis-aligned bounding box of the bend. Fast but blocks more space.
* `"clipped_bbox"`: A middle ground that uses the bounding box but clips corners that are far from the waveguide. * `"clipped_bbox"`: A middle ground that starts with the bounding box but applies 45-degree linear cuts to the inner and outer corners. The `bend_clip_margin` defines the extra safety distance from the waveguide edge to the cut line.
--- ---
@ -30,11 +30,11 @@ The `AStarRouter` is the core pathfinding engine. It can be configured directly
The `CostEvaluator` defines the "goodness" of a path. The `CostEvaluator` defines the "goodness" of a path.
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- | | :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- |
| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | | `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. |
| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g., `1.5`) are faster but may produce longer paths. | | `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. |
| `congestion_penalty`| `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | | `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. |
--- ---
@ -42,19 +42,19 @@ The `CostEvaluator` defines the "goodness" of a path.
The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm.
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- | | :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- |
| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | | `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. |
| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. This value is multiplied by `1.5` each iteration if congestion persists. | | `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.|
--- ---
## 4. CollisionEngine Parameters ## 4. CollisionEngine Parameters
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- | | :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ |
| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | | `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). |
| `safety_zone_radius`| `float` | 0.0021 | Radius (µm) around ports where collisions are ignored to allow PDK boundary incidence. | | `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. |
--- ---
@ -62,6 +62,7 @@ The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion
- **Coordinates**: Micrometers (µm). - **Coordinates**: Micrometers (µm).
- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. - **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves.
- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. - **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**.
- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost.
--- ---

View file

@ -74,12 +74,17 @@ Check the `examples/` directory for ready-to-run scripts demonstrating core feat
* **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. Generates `03_locked_paths.png`. * **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. Generates `03_locked_paths.png`.
* **`examples/04_sbends_and_radii.py`**: Complex paths using parametric S-bends and multiple bend radii. Generates `04_sbends_and_radii.png`. * **`examples/04_sbends_and_radii.py`**: Complex paths using parametric S-bends and multiple bend radii. Generates `04_sbends_and_radii.png`.
* **`examples/05_orientation_stress.py`**: Stress test for various port orientation combinations (U-turns, opposite directions). Generates `05_orientation_stress.png`. * **`examples/05_orientation_stress.py`**: Stress test for various port orientation combinations (U-turns, opposite directions). Generates `05_orientation_stress.png`.
* **`examples/06_bend_collision_models.py`**: Comparison of different collision models for bends (Arc vs. BBox vs. Clipped BBox). Generates `06_bend_collision_models.png`.
Run an example: Run an example:
```bash ```bash
python3 examples/01_simple_route.py python3 examples/01_simple_route.py
``` ```
## Documentation
Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**.
## Architecture ## Architecture
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:

View file

@ -28,7 +28,7 @@ def main() -> None:
"vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)),
"vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)),
} }
net_widths = {nid: 2.0 for nid in netlist} net_widths = dict.fromkeys(netlist, 2.0)
# 3. Route with Negotiated Congestion # 3. Route with Negotiated Congestion
# We increase the base penalty to encourage faster divergence # We increase the base penalty to encourage faster divergence

View file

@ -28,7 +28,7 @@ def main() -> None:
"bus_2": (Port(10, 60, 0), Port(110, 65, 0)), "bus_2": (Port(10, 60, 0), Port(110, 65, 0)),
} }
print("Phase 1: Routing bus (3 nets)...") print("Phase 1: Routing bus (3 nets)...")
results_p1 = pf.route_all(netlist_p1, {nid: 2.0 for nid in netlist_p1}) results_p1 = pf.route_all(netlist_p1, dict.fromkeys(netlist_p1, 2.0))
# Lock all Phase 1 nets # Lock all Phase 1 nets
path_polys = [] path_polys = []
@ -50,10 +50,10 @@ def main() -> None:
"cross_left": (Port(30, 10, 90), Port(30, 110, 90)), "cross_left": (Port(30, 10, 90), Port(30, 110, 90)),
"cross_right": (Port(80, 110, 270), Port(80, 10, 270)), # Top to bottom "cross_right": (Port(80, 110, 270), Port(80, 10, 270)), # Top to bottom
} }
print("Phase 2: Routing crossing nets around locked bus...") print("Phase 2: Routing crossing nets around locked bus...")
# We use a slightly different width for variety # We use a slightly different width for variety
results_p2 = pf.route_all(netlist_p2, {nid: 1.5 for nid in netlist_p2}) results_p2 = pf.route_all(netlist_p2, dict.fromkeys(netlist_p2, 1.5))
# 4. Check Results # 4. Check Results
for nid, res in results_p2.items(): for nid, res in results_p2.items():

View file

@ -1,4 +1,3 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port from inire.geometry.primitives import Port

View file

@ -16,26 +16,26 @@ def main() -> None:
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds) danger_map = DangerMap(bounds=bounds)
danger_map.precompute([]) danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1) evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1)
router = AStarRouter(evaluator, node_limit=100000) router = AStarRouter(evaluator, node_limit=100000)
pf = PathFinder(router, evaluator) pf = PathFinder(router, evaluator)
# 2. Define Netlist with various orientation challenges # 2. Define Netlist with various orientation challenges
netlist = { netlist = {
# Opposite directions: requires two 90-degree bends to flip orientation # Opposite directions: requires two 90-degree bends to flip orientation
"opposite": (Port(10, 80, 0), Port(90, 80, 180)), "opposite": (Port(10, 80, 0), Port(90, 80, 180)),
# 90-degree turn: standard L-shape # 90-degree turn: standard L-shape
"turn_90": (Port(10, 60, 0), Port(40, 90, 90)), "turn_90": (Port(10, 60, 0), Port(40, 90, 90)),
# Output behind input: requires a full U-turn # Output behind input: requires a full U-turn
"behind": (Port(80, 40, 0), Port(20, 40, 0)), "behind": (Port(80, 40, 0), Port(20, 40, 0)),
# Sharp return: output is behind and oriented towards the input # Sharp return: output is behind and oriented towards the input
"return_loop": (Port(80, 20, 0), Port(40, 10, 180)), "return_loop": (Port(80, 20, 0), Port(40, 10, 180)),
} }
net_widths = {nid: 2.0 for nid in netlist} net_widths = dict.fromkeys(netlist, 2.0)
# 3. Route # 3. Route
results = pf.route_all(netlist, net_widths) results = pf.route_all(netlist, net_widths)

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,70 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.astar import AStarRouter
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import PathFinder
from inire.utils.visualization import plot_routing_results
def main() -> None:
print("Running Example 06: Bend Collision Models...")
# 1. Setup Environment
# Give room for 10um bends near the edges
bounds = (-20, -20, 170, 170)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
# Create three scenarios with identical obstacles
# We'll space them out vertically
obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)])
obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)])
obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)])
obstacles = [obs_arc, obs_bbox, obs_clipped]
for obs in obstacles:
engine.add_static_obstacle(obs)
danger_map.precompute(obstacles)
# We'll run three separate routers since collision_type is a router-level config
evaluator = CostEvaluator(engine, danger_map)
# Scenario 1: Standard 'arc' model (High fidelity)
router_arc = AStarRouter(evaluator, bend_collision_type="arc")
netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))}
# Scenario 2: 'bbox' model (Conservative axis-aligned box)
router_bbox = AStarRouter(evaluator, bend_collision_type="bbox")
netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))}
# Scenario 3: 'clipped_bbox' model (Balanced)
router_clipped = AStarRouter(evaluator, bend_collision_type="clipped_bbox", bend_clip_margin=1.0)
netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))}
# 2. Route each scenario
print("Routing Scenario 1 (Arc)...")
res_arc = PathFinder(router_arc, evaluator).route_all(netlist_arc, {"arc_model": 2.0})
print("Routing Scenario 2 (BBox)...")
res_bbox = PathFinder(router_bbox, evaluator).route_all(netlist_bbox, {"bbox_model": 2.0})
print("Routing Scenario 3 (Clipped BBox)...")
res_clipped = PathFinder(router_clipped, evaluator).route_all(netlist_clipped, {"clipped_model": 2.0})
# 3. Combine results for visualization
all_results = {**res_arc, **res_bbox, **res_clipped}
all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped}
# 4. Visualize
# Note: plot_routing_results will show the 'collision geometry' used by the router
# since that's what's stored in res.path[i].geometry
fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists)
fig.savefig("examples/06_bend_collision_models.png")
print("Saved plot to examples/06_bend_collision_models.png")
if __name__ == "__main__":
main()

View file

@ -1,149 +1,141 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
import rtree import rtree
from shapely.geometry import Point, Polygon from shapely.geometry import Point, Polygon
from shapely.ops import unary_union
from shapely.prepared import prep from shapely.prepared import prep
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.prepared import PreparedGeometry from shapely.prepared import PreparedGeometry
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
class CollisionEngine: class CollisionEngine:
"""Manages spatial queries for collision detection.""" """Manages spatial queries for collision detection with unified dilation logic."""
def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None: def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None:
self.clearance = clearance self.clearance = clearance
self.max_net_width = max_net_width self.max_net_width = max_net_width
self.safety_zone_radius = safety_zone_radius self.safety_zone_radius = safety_zone_radius
self.static_obstacles = rtree.index.Index()
# To store geometries for precise checks # Static obstacles: store raw geometries to avoid double-dilation
self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon self.static_index = rtree.index.Index()
self.prepared_obstacles: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry self.static_geometries: dict[int, Polygon] = {} # ID -> Polygon
self._id_counter = 0 self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry
self._static_id_counter = 0
# Dynamic paths for multi-net congestion # Dynamic paths for multi-net congestion
self.dynamic_paths = rtree.index.Index() self.dynamic_index = rtree.index.Index()
# obj_id -> (net_id, geometry) # obj_id -> (net_id, raw_geometry)
self.path_geometries: dict[int, tuple[str, Polygon]] = {} self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {}
self._dynamic_id_counter = 0 self._dynamic_id_counter = 0
def add_static_obstacle(self, polygon: Polygon, pre_dilate: bool = True) -> None: def add_static_obstacle(self, polygon: Polygon) -> None:
"""Add a static obstacle to the engine.""" """Add a static obstacle (raw geometry) to the engine."""
_ = pre_dilate # Keep for API compatibility obj_id = self._static_id_counter
obj_id = self._id_counter self._static_id_counter += 1
self._id_counter += 1
self.obstacle_geometries[obj_id] = polygon self.static_geometries[obj_id] = polygon
self.prepared_obstacles[obj_id] = prep(polygon) self.static_prepared[obj_id] = prep(polygon)
self.static_index.insert(obj_id, polygon.bounds)
# Index the bounding box of the original polygon
# We query with dilated moves, so original bounds are enough
self.static_obstacles.insert(obj_id, polygon.bounds)
def add_path(self, net_id: str, geometry: list[Polygon]) -> None: def add_path(self, net_id: str, geometry: list[Polygon]) -> None:
"""Add a net's routed path to the dynamic R-Tree.""" """Add a net's routed path (raw geometry) to the dynamic index."""
# Dilate by clearance/2 for congestion
dilation = self.clearance / 2.0
for poly in geometry: for poly in geometry:
dilated = poly.buffer(dilation)
obj_id = self._dynamic_id_counter obj_id = self._dynamic_id_counter
self._dynamic_id_counter += 1 self._dynamic_id_counter += 1
self.path_geometries[obj_id] = (net_id, dilated) self.dynamic_geometries[obj_id] = (net_id, poly)
self.dynamic_paths.insert(obj_id, dilated.bounds) self.dynamic_index.insert(obj_id, poly.bounds)
def remove_path(self, net_id: str) -> None: def remove_path(self, net_id: str) -> None:
"""Remove a net's path from the dynamic R-Tree.""" """Remove a net's path from the dynamic index."""
to_remove = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id]
for obj_id in to_remove: for obj_id in to_remove:
nid, dilated = self.path_geometries.pop(obj_id) nid, poly = self.dynamic_geometries.pop(obj_id)
self.dynamic_paths.delete(obj_id, dilated.bounds) self.dynamic_index.delete(obj_id, poly.bounds)
def lock_net(self, net_id: str) -> None: def lock_net(self, net_id: str) -> None:
"""Move a net's dynamic path to static obstacles permanently.""" """Move a net's dynamic path to static obstacles permanently."""
to_move = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id]
for obj_id in to_move: for obj_id in to_move:
nid, dilated = self.path_geometries.pop(obj_id) nid, poly = self.dynamic_geometries.pop(obj_id)
self.dynamic_paths.delete(obj_id, dilated.bounds) self.dynamic_index.delete(obj_id, poly.bounds)
self.add_static_obstacle(poly)
# Add to static (already dilated for clearance)
new_static_id = self._id_counter
self._id_counter += 1
self.obstacle_geometries[new_static_id] = dilated
self.prepared_obstacles[new_static_id] = prep(dilated)
self.static_obstacles.insert(new_static_id, dilated.bounds)
def count_congestion(self, geometry: Polygon, net_id: str) -> int:
"""Count how many other nets collide with this geometry."""
dilation = self.clearance / 2.0
test_poly = geometry.buffer(dilation)
return self.count_congestion_prebuffered(test_poly, net_id)
def count_congestion_prebuffered(self, dilated_geometry: Polygon, net_id: str) -> int:
"""Count how many other nets collide with this pre-dilated geometry."""
candidates = self.dynamic_paths.intersection(dilated_geometry.bounds)
count = 0
for obj_id in candidates:
other_net_id, other_poly = self.path_geometries[obj_id]
if other_net_id != net_id and dilated_geometry.intersects(other_poly):
count += 1
return count
def is_collision( def is_collision(
self, self,
geometry: Polygon, geometry: Polygon,
net_width: float, net_width: float = 2.0,
start_port: Port | None = None, start_port: Port | None = None,
end_port: Port | None = None, end_port: Port | None = None
) -> bool: ) -> bool:
"""Check if a geometry (e.g. a Move) collides with static obstacles.""" """Alias for check_collision(buffer_mode='static') for backward compatibility."""
_ = net_width # Width is already integrated into engine dilation settings _ = net_width
dilation = self.clearance / 2.0 res = self.check_collision(geometry, "default", buffer_mode="static", start_port=start_port, end_port=end_port)
test_poly = geometry.buffer(dilation) return bool(res)
return self.is_collision_prebuffered(test_poly, start_port=start_port, end_port=end_port)
def is_collision_prebuffered( def count_congestion(self, geometry: Polygon, net_id: str) -> int:
self, """Alias for check_collision(buffer_mode='congestion') for backward compatibility."""
dilated_geometry: Polygon, res = self.check_collision(geometry, net_id, buffer_mode="congestion")
return int(res)
def check_collision(
self,
geometry: Polygon,
net_id: str,
buffer_mode: Literal["static", "congestion"] = "static",
start_port: Port | None = None, start_port: Port | None = None,
end_port: Port | None = None, end_port: Port | None = None
) -> bool: ) -> bool | int:
"""Check if a pre-dilated geometry collides with static obstacles.""" """
# Query R-Tree using the bounds of the dilated move Check for collisions using unified dilation logic.
candidates = self.static_obstacles.intersection(dilated_geometry.bounds)
If buffer_mode == "static":
Returns True if geometry collides with static obstacles (buffered by full clearance).
If buffer_mode == "congestion":
Returns count of other nets colliding with geometry (both buffered by clearance/2).
"""
if buffer_mode == "static":
# Buffered move vs raw static obstacle
# Distance must be >= clearance
test_poly = geometry.buffer(self.clearance)
candidates = self.static_index.intersection(test_poly.bounds)
for obj_id in candidates:
if self.static_prepared[obj_id].intersects(test_poly):
# Safety zone check (using exact intersection area/bounds)
if start_port or end_port:
intersection = test_poly.intersection(self.static_geometries[obj_id])
if intersection.is_empty:
continue
ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds
is_safe = False
for p in [start_port, end_port]:
if p and (abs(ix_minx - p.x) < self.safety_zone_radius and
abs(ix_maxx - p.x) < self.safety_zone_radius and
abs(ix_miny - p.y) < self.safety_zone_radius and
abs(ix_maxy - p.y) < self.safety_zone_radius):
is_safe = True
break
if is_safe:
continue
return True
return False
for obj_id in candidates: else: # buffer_mode == "congestion"
# Use prepared geometry for fast intersection # Both paths buffered by clearance/2 => Total separation = clearance
if self.prepared_obstacles[obj_id].intersects(dilated_geometry): dilation = self.clearance / 2.0
# Check safety zone (2nm radius) test_poly = geometry.buffer(dilation)
if start_port or end_port: candidates = self.dynamic_index.intersection(test_poly.bounds)
obstacle = self.obstacle_geometries[obj_id]
intersection = dilated_geometry.intersection(obstacle) count = 0
for obj_id in candidates:
if intersection.is_empty: other_net_id, other_poly = self.dynamic_geometries[obj_id]
continue if other_net_id != net_id:
# Buffer the other path segment too
# Precise check: is every point in the intersection close to either port? if test_poly.intersects(other_poly.buffer(dilation)):
ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds count += 1
return count
is_near_start = False
if start_port:
if (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and
abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius):
is_near_start = True
is_near_end = False
if end_port:
if (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and
abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius):
is_near_end = True
if is_near_start or is_near_end:
continue
return True
return False

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import NamedTuple, Literal, Union from typing import NamedTuple, Literal, Any
import numpy as np import numpy as np
from shapely.geometry import Polygon, box from shapely.geometry import Polygon, box
@ -35,7 +35,7 @@ class Straight:
ex = start_port.x + dx ex = start_port.x + dx
ey = start_port.y + dy ey = start_port.y + dy
if snap_to_grid: if snap_to_grid:
ex = snap_search_grid(ex) ex = snap_search_grid(ex)
ey = snap_search_grid(ey) ey = snap_search_grid(ey)
@ -84,39 +84,83 @@ def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start
def _apply_collision_model( def _apply_collision_model(
arc_poly: Polygon, arc_poly: Polygon,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
radius: float, radius: float,
width: float,
cx: float = 0.0,
cy: float = 0.0,
clip_margin: float = 10.0 clip_margin: float = 10.0
) -> list[Polygon]: ) -> list[Polygon]:
"""Applies the specified collision model to an arc geometry.""" """Applies the specified collision model to an arc geometry."""
if isinstance(collision_type, Polygon): if isinstance(collision_type, Polygon):
return [collision_type] return [collision_type]
if collision_type == "arc": if collision_type == "arc":
return [arc_poly] return [arc_poly]
# Get bounding box # Get bounding box
minx, miny, maxx, maxy = arc_poly.bounds minx, miny, maxx, maxy = arc_poly.bounds
bbox = box(minx, miny, maxx, maxy) bbox = box(minx, miny, maxx, maxy)
if collision_type == "bbox": if collision_type == "bbox":
return [bbox] return [bbox]
if collision_type == "clipped_bbox": if collision_type == "clipped_bbox":
safe_zone = arc_poly.buffer(clip_margin) res_poly = bbox
return [bbox.intersection(safe_zone)]
# Determine quadrant signs from arc centroid relative to center
# This ensures we always cut 'into' the box correctly
ac = arc_poly.centroid
sx = 1.0 if ac.x >= cx else -1.0
sy = 1.0 if ac.y >= cy else -1.0
r_out_cut = radius + width / 2.0 + clip_margin
r_in_cut = radius - width / 2.0 - clip_margin
corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)]
for px, py in corners:
dx, dy = px - cx, py - cy
dist = np.sqrt(dx**2 + dy**2)
if dist > r_out_cut:
# Outer corner: remove part furthest from center
# We want minimum distance to line to be r_out_cut
d_cut = r_out_cut * np.sqrt(2)
elif r_in_cut > 0 and dist < r_in_cut:
# Inner corner: remove part closest to center
# We want maximum distance to line to be r_in_cut
d_cut = r_in_cut
else:
continue
# The cut line is sx*(x-cx) + sy*(y-cy) = d_cut
# sx*x + sy*y = sx*cx + sy*cy + d_cut
val = cx * sx + cy * sy + d_cut
try:
p1 = (px, py)
p2 = (px, (val - sx * px) / sy)
p3 = ((val - sy * py) / sx, py)
triangle = Polygon([p1, p2, p3])
if triangle.is_valid and triangle.area > 1e-9:
res_poly = res_poly.difference(triangle)
except ZeroDivisionError:
continue
return [res_poly]
return [arc_poly] return [arc_poly]
class Bend90: class Bend90:
@staticmethod @staticmethod
def generate( def generate(
start_port: Port, start_port: Port,
radius: float, radius: float,
width: float, width: float,
direction: str = "CW", direction: str = "CW",
sagitta: float = 0.01, sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0 clip_margin: float = 10.0
@ -133,9 +177,11 @@ class Bend90:
ex = snap_search_grid(cx + radius * np.cos(t_end)) ex = snap_search_grid(cx + radius * np.cos(t_end))
ey = snap_search_grid(cy + radius * np.sin(t_end)) ey = snap_search_grid(cy + radius * np.sin(t_end))
end_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360)) end_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360))
arc_polys = _get_arc_polygons(cx, cy, radius, width, t_start, t_end, sagitta) arc_polys = _get_arc_polygons(cx, cy, radius, width, t_start, t_end, sagitta)
collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin) collision_polys = _apply_collision_model(
arc_polys[0], collision_type, radius, width, cx, cy, clip_margin
)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0) return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0)
@ -143,10 +189,10 @@ class Bend90:
class SBend: class SBend:
@staticmethod @staticmethod
def generate( def generate(
start_port: Port, start_port: Port,
offset: float, offset: float,
radius: float, radius: float,
width: float, width: float,
sagitta: float = 0.01, sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0 clip_margin: float = 10.0
@ -162,7 +208,7 @@ class SBend:
ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start))
ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start))
end_port = Port(ex, ey, start_port.orientation) end_port = Port(ex, ey, start_port.orientation)
direction = 1 if offset > 0 else -1 direction = 1 if offset > 0 else -1
c1_angle = rad_start + direction * np.pi / 2 c1_angle = rad_start + direction * np.pi / 2
cx1 = start_port.x + radius * np.cos(c1_angle) cx1 = start_port.x + radius * np.cos(c1_angle)
@ -180,6 +226,14 @@ class SBend:
arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0] arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0]
arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0] arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0]
combined_arc = unary_union([arc1, arc2]) combined_arc = unary_union([arc1, arc2])
collision_polys = _apply_collision_model(combined_arc, collision_type, radius, clip_margin) if collision_type == "clipped_bbox":
col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin)
col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin)
collision_polys = [unary_union(col1 + col2)]
else:
collision_polys = _apply_collision_model(
combined_arc, collision_type, radius, width, 0, 0, clip_margin
)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta) return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta)

View file

@ -189,7 +189,7 @@ class AStarRouter:
lengths = self.config.straight_lengths lengths = self.config.straight_lengths
if dist < 5.0: if dist < 5.0:
fine_steps = [0.1, 0.5] fine_steps = [0.1, 0.5]
lengths = sorted(list(set(lengths + fine_steps))) lengths = sorted(set(lengths + fine_steps))
for length in lengths: for length in lengths:
res = Straight.generate(current.port, length, net_width) res = Straight.generate(current.port, length, net_width)
@ -255,7 +255,9 @@ class AStarRouter:
else: else:
hard_coll = False hard_coll = False
for poly in result.geometry: for poly in result.geometry:
if self.cost_evaluator.collision_engine.is_collision(poly, net_width, start_port=parent.port, end_port=result.end_port): if self.cost_evaluator.collision_engine.check_collision(
poly, net_id, buffer_mode="static", start_port=parent.port, end_port=result.end_port
):
hard_coll = True hard_coll = True
break break
self._collision_cache[cache_key] = hard_coll self._collision_cache[cache_key] = hard_coll
@ -300,8 +302,6 @@ class AStarRouter:
# Turn penalties scaled by radius to favor larger turns # Turn penalties scaled by radius to favor larger turns
ref_radius = 10.0 ref_radius = 10.0
if "B" in move_type and move_radius is not None: if "B" in move_type and move_radius is not None:
# Scale penalty: larger radius -> smaller penalty
# e.g. radius 10 -> factor 1.0, radius 30 -> factor 0.33
penalty_factor = ref_radius / move_radius penalty_factor = ref_radius / move_radius
move_cost += self.config.bend_penalty * penalty_factor move_cost += self.config.bend_penalty * penalty_factor
elif "SB" in move_type and move_radius is not None: elif "SB" in move_type and move_radius is not None:

View file

@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, TYPE_CHECKING, Any from typing import Literal, Any
if TYPE_CHECKING:
from shapely.geometry import Polygon
@dataclass @dataclass

View file

@ -40,7 +40,7 @@ class CostEvaluator:
greedy_h_weight=greedy_h_weight, greedy_h_weight=greedy_h_weight,
congestion_penalty=congestion_penalty, congestion_penalty=congestion_penalty,
) )
# Use config values # Use config values
self.unit_length_cost = self.config.unit_length_cost self.unit_length_cost = self.config.unit_length_cost
self.greedy_h_weight = self.config.greedy_h_weight self.greedy_h_weight = self.config.greedy_h_weight
@ -73,25 +73,24 @@ class CostEvaluator:
"""Calculate the cost of a single move (Straight, Bend, SBend).""" """Calculate the cost of a single move (Straight, Bend, SBend)."""
_ = net_width # Unused _ = net_width # Unused
total_cost = length * self.unit_length_cost total_cost = length * self.unit_length_cost
# 1. Hard Collision check (Static obstacles)
# We buffer by the full clearance to ensure distance >= clearance
hard_dilation = self.collision_engine.clearance
for poly in geometry:
dilated_poly = poly.buffer(hard_dilation)
if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port):
return 1e15 # Impossible cost for hard collisions
# 2. Soft Collision check (Negotiated Congestion) # 1. Boundary Check (Centerline based for compatibility)
# We buffer by clearance/2 because both paths are buffered by clearance/2 if not self.danger_map.is_within_bounds(end_port.x, end_port.y):
soft_dilation = self.collision_engine.clearance / 2.0 return 1e15
# 2. Collision Check
for poly in geometry: for poly in geometry:
dilated_poly = poly.buffer(soft_dilation) # Hard Collision (Static obstacles)
overlaps = self.collision_engine.count_congestion_prebuffered(dilated_poly, net_id) if self.collision_engine.check_collision(
if overlaps > 0: poly, net_id, buffer_mode="static", start_port=start_port, end_port=end_port
):
return 1e15
# Soft Collision (Negotiated Congestion)
overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode="congestion")
if isinstance(overlaps, int) and overlaps > 0:
total_cost += overlaps * self.congestion_penalty total_cost += overlaps * self.congestion_penalty
# 3. Proximity cost from Danger Map # 3. Proximity cost from Danger Map
total_cost += self.g_proximity(end_port.x, end_port.y) total_cost += self.g_proximity(end_port.x, end_port.y)
return total_cost return total_cost

View file

@ -3,13 +3,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import numpy as np import numpy as np
import shapely
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
class DangerMap: class DangerMap:
"""A pre-computed grid for heuristic proximity costs.""" """A pre-computed grid for heuristic proximity costs, vectorized for performance."""
def __init__( def __init__(
self, self,
@ -28,47 +29,36 @@ class DangerMap:
self.width_cells = int(np.ceil((self.maxx - self.minx) / self.resolution)) self.width_cells = int(np.ceil((self.maxx - self.minx) / self.resolution))
self.height_cells = int(np.ceil((self.maxy - self.miny) / self.resolution)) self.height_cells = int(np.ceil((self.maxy - self.miny) / self.resolution))
# Use uint8 for memory efficiency if normalized, or float16/float32.
# Let's use float32 for simplicity and precision in the prototype.
# For a 1000x1000 grid, this is only 4MB.
# For 20000x20000, it's 1.6GB.
self.grid = np.zeros((self.width_cells, self.height_cells), dtype=np.float32) self.grid = np.zeros((self.width_cells, self.height_cells), dtype=np.float32)
def precompute(self, obstacles: list[Polygon]) -> None: def precompute(self, obstacles: list[Polygon]) -> None:
"""Pre-compute the proximity costs for the entire grid.""" """Pre-compute the proximity costs for the entire grid using vectorized operations."""
# For each cell, find distance to nearest obstacle.
# This is a distance transform problem.
# For the prototype, we can use a simpler approach or scipy.ndimage.distance_transform_edt.
from scipy.ndimage import distance_transform_edt from scipy.ndimage import distance_transform_edt
# Create a binary mask of obstacles # 1. Create a binary mask of obstacles
mask = np.ones((self.width_cells, self.height_cells), dtype=bool) mask = np.ones((self.width_cells, self.height_cells), dtype=bool)
# Rasterize obstacles (simplified: mark cells whose center is inside an obstacle)
# This is slow for many obstacles; in a real engine, we'd use a faster rasterizer. # Create coordinate grids
from shapely.geometry import Point x_coords = np.linspace(self.minx + self.resolution/2, self.maxx - self.resolution/2, self.width_cells)
y_coords = np.linspace(self.miny + self.resolution/2, self.maxy - self.resolution/2, self.height_cells)
xv, yv = np.meshgrid(x_coords, y_coords, indexing='ij')
for poly in obstacles: for poly in obstacles:
# Get bounding box in grid coordinates # Use shapely.contains_xy for fast vectorized point-in-polygon check
p_minx, p_miny, p_maxx, p_maxy = poly.bounds in_poly = shapely.contains_xy(poly, xv, yv)
x_start = max(0, int((p_minx - self.minx) / self.resolution)) mask[in_poly] = False
x_end = min(self.width_cells, int((p_maxx - self.minx) / self.resolution) + 1)
y_start = max(0, int((p_miny - self.miny) / self.resolution))
y_end = min(self.height_cells, int((p_maxy - self.miny) / self.resolution) + 1)
for ix in range(x_start, x_end): # 2. Distance transform (mask=True for empty space)
cx = self.minx + (ix + 0.5) * self.resolution
for iy in range(y_start, y_end):
cy = self.miny + (iy + 0.5) * self.resolution
if poly.contains(Point(cx, cy)):
mask[ix, iy] = False
# Distance transform (mask=True for empty space)
distances = distance_transform_edt(mask) * self.resolution distances = distance_transform_edt(mask) * self.resolution
# Proximity cost: k / d^2 if d < threshold, else 0 # 3. Proximity cost: k / d^2 if d < threshold, else 0
# To avoid division by zero, we cap distances at a small epsilon (e.g. 0.1um) # Cap distances at a small epsilon (e.g. 0.1um) to avoid division by zero
safe_distances = np.maximum(distances, 0.1) safe_distances = np.maximum(distances, 0.1)
self.grid = np.where(distances < self.safety_threshold, self.k / (safe_distances**2), 0.0).astype(np.float32) self.grid = np.where(
distances < self.safety_threshold,
self.k / (safe_distances**2),
0.0
).astype(np.float32)
def is_within_bounds(self, x: float, y: float) -> bool: def is_within_bounds(self, x: float, y: float) -> bool:
"""Check if a coordinate is within the design bounds.""" """Check if a coordinate is within the design bounds."""

View file

@ -79,7 +79,7 @@ class PathFinder:
logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s") logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s")
if path: if path:
# 3. Add to R-Tree # 3. Add to index
all_geoms = [] all_geoms = []
for res in path: for res in path:
all_geoms.extend(res.geometry) all_geoms.extend(res.geometry)
@ -88,7 +88,11 @@ class PathFinder:
# Check if this new path has any congestion # Check if this new path has any congestion
collision_count = 0 collision_count = 0
for poly in all_geoms: for poly in all_geoms:
collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) overlaps = self.cost_evaluator.collision_engine.check_collision(
poly, net_id, buffer_mode="congestion"
)
if isinstance(overlaps, int):
collision_count += overlaps
if collision_count > 0: if collision_count > 0:
any_congestion = True any_congestion = True
@ -120,9 +124,12 @@ class PathFinder:
collision_count = 0 collision_count = 0
for comp in res.path: for comp in res.path:
for poly in comp.geometry: for poly in comp.geometry:
collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) overlaps = self.cost_evaluator.collision_engine.check_collision(
poly, net_id, buffer_mode="congestion"
)
if isinstance(overlaps, int):
collision_count += overlaps
final_results[net_id] = RoutingResult(net_id, res.path, collision_count == 0, collision_count) final_results[net_id] = RoutingResult(net_id, res.path, collision_count == 0, collision_count)
return final_results return final_results

View file

@ -1,6 +1,4 @@
import numpy as np
import pytest import pytest
from shapely.geometry import Point
from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port, rotate_port, translate_port from inire.geometry.primitives import Port, rotate_port, translate_port
@ -66,7 +64,7 @@ def test_bend_collision_models() -> None:
# 1. BBox model # 1. BBox model
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
# Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Arc CCW R=10 from (0,0,0) ends at (10,10,90).
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
minx, miny, maxx, maxy = res_bbox.geometry[0].bounds minx, miny, maxx, maxy = res_bbox.geometry[0].bounds
assert minx <= 0.0 + 1e-6 assert minx <= 0.0 + 1e-6
@ -89,7 +87,7 @@ def test_sbend_collision_models() -> None:
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
# Geometry should be a single bounding box polygon # Geometry should be a single bounding box polygon
assert len(res_bbox.geometry) == 1 assert len(res_bbox.geometry) == 1
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
assert res_bbox.geometry[0].area > res_arc.geometry[0].area assert res_bbox.geometry[0].area > res_arc.geometry[0].area
@ -100,15 +98,15 @@ def test_sbend_continuity() -> None:
offset = 4.0 offset = 4.0
radius = 20.0 radius = 20.0
width = 1.0 width = 1.0
res = SBend.generate(start, offset, radius, width) res = SBend.generate(start, offset, radius, width)
# Target orientation should be same as start # Target orientation should be same as start
assert abs(res.end_port.orientation - 90.0) < 1e-6 assert abs(res.end_port.orientation - 90.0) < 1e-6
# For a port at 90 deg, +offset is a shift in -x direction # For a port at 90 deg, +offset is a shift in -x direction
assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 assert abs(res.end_port.x - (10.0 - offset)) < 1e-6
# Geometry should be connected (unary_union results in 1 polygon) # Geometry should be connected (unary_union results in 1 polygon)
assert len(res.geometry) == 1 assert len(res.geometry) == 1
assert res.geometry[0].is_valid assert res.geometry[0].is_valid
@ -119,17 +117,17 @@ def test_arc_sagitta_precision() -> None:
start = Port(0, 0, 0) start = Port(0, 0, 0)
radius = 100.0 # Large radius to make sagitta significant radius = 100.0 # Large radius to make sagitta significant
width = 2.0 width = 2.0
# Coarse: 1um sagitta # Coarse: 1um sagitta
res_coarse = Bend90.generate(start, radius, width, sagitta=1.0) res_coarse = Bend90.generate(start, radius, width, sagitta=1.0)
# Fine: 0.01um (10nm) sagitta # Fine: 0.01um (10nm) sagitta
res_fine = Bend90.generate(start, radius, width, sagitta=0.01) res_fine = Bend90.generate(start, radius, width, sagitta=0.01)
# Number of segments should be significantly higher for fine # Number of segments should be significantly higher for fine
# Exterior points = (segments + 1) * 2 # Exterior points = (segments + 1) * 2
pts_coarse = len(res_coarse.geometry[0].exterior.coords) pts_coarse = len(res_coarse.geometry[0].exterior.coords)
pts_fine = len(res_fine.geometry[0].exterior.coords) pts_fine = len(res_fine.geometry[0].exterior.coords)
assert pts_fine > pts_coarse * 2 assert pts_fine > pts_coarse * 2
@ -139,20 +137,20 @@ def test_component_transform_invariance() -> None:
start0 = Port(0, 0, 0) start0 = Port(0, 0, 0)
radius = 10.0 radius = 10.0
width = 2.0 width = 2.0
res0 = Bend90.generate(start0, radius, width, direction="CCW") res0 = Bend90.generate(start0, radius, width, direction="CCW")
# Transform: Translate (10, 10) then Rotate 90 # Transform: Translate (10, 10) then Rotate 90
dx, dy = 10.0, 5.0 dx, dy = 10.0, 5.0
angle = 90.0 angle = 90.0
# 1. Transform the generated geometry # 1. Transform the generated geometry
p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle)
# 2. Generate at transformed start # 2. Generate at transformed start
start_transformed = rotate_port(translate_port(start0, dx, dy), angle) start_transformed = rotate_port(translate_port(start0, dx, dy), angle)
res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW")
assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6
assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6
assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6

View file

@ -3,8 +3,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from shapely.geometry import Point, Polygon from shapely.geometry import Polygon
from shapely.ops import unary_union
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
@ -28,7 +27,7 @@ def validate_routing_result(
obstacle_collision_geoms = [] obstacle_collision_geoms = []
self_intersection_geoms = [] self_intersection_geoms = []
connectivity_errors = [] connectivity_errors = []
# 1. Connectivity Check # 1. Connectivity Check
total_length = 0.0 total_length = 0.0
for i, comp in enumerate(result.path): for i, comp in enumerate(result.path):
@ -38,7 +37,7 @@ def validate_routing_result(
if expected_end: if expected_end:
last_port = result.path[-1].end_port last_port = result.path[-1].end_port
dist_to_end = np.sqrt((last_port.x - expected_end.x)**2 + (last_port.y - expected_end.y)**2) dist_to_end = np.sqrt((last_port.x - expected_end.x)**2 + (last_port.y - expected_end.y)**2)
if dist_to_end > 0.005: if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
if abs(last_port.orientation - expected_end.orientation) > 0.1: if abs(last_port.orientation - expected_end.orientation) > 0.1:
connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}") connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}")
@ -46,9 +45,9 @@ def validate_routing_result(
# 2. Geometry Buffering # 2. Geometry Buffering
dilation_half = clearance / 2.0 dilation_half = clearance / 2.0
dilation_full = clearance dilation_full = clearance
dilated_for_self = [] dilated_for_self = []
for i, comp in enumerate(result.path): for i, comp in enumerate(result.path):
for poly in comp.geometry: for poly in comp.geometry:
# Check against obstacles # Check against obstacles
@ -58,7 +57,7 @@ def validate_routing_result(
intersection = d_full.intersection(obs) intersection = d_full.intersection(obs)
if intersection.area > 1e-9: if intersection.area > 1e-9:
obstacle_collision_geoms.append(intersection) obstacle_collision_geoms.append(intersection)
# Save for self-intersection check # Save for self-intersection check
dilated_for_self.append(poly.buffer(dilation_half)) dilated_for_self.append(poly.buffer(dilation_half))
@ -68,13 +67,13 @@ def validate_routing_result(
if j > i + 1: # Non-adjacent if j > i + 1: # Non-adjacent
if seg_i.intersects(seg_j): if seg_i.intersects(seg_j):
overlap = seg_i.intersection(seg_j) overlap = seg_i.intersection(seg_j)
if overlap.area > 1e-6: if overlap.area > 1e-6:
self_intersection_geoms.append((i, j, overlap)) self_intersection_geoms.append((i, j, overlap))
is_valid = (len(obstacle_collision_geoms) == 0 and is_valid = (len(obstacle_collision_geoms) == 0 and
len(self_intersection_geoms) == 0 and len(self_intersection_geoms) == 0 and
len(connectivity_errors) == 0) len(connectivity_errors) == 0)
reasons = [] reasons = []
if obstacle_collision_geoms: if obstacle_collision_geoms:
reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.") reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")