Compare commits

..

5 commits

28 changed files with 1139 additions and 442 deletions

53
DOCS.md Normal file
View file

@ -0,0 +1,53 @@
# Inire Configuration & API Documentation
This document describes the user-tunable parameters for the `inire` auto-router.
## 1. AStarRouter Parameters
The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor.
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `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. |
| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow the router to pick the best fit. |
| `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). |
| `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. |
| `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_clip_margin` | `float` | 10.0 | Margin (µm) for the `"clipped_bbox"` collision model. |
### Bend Collision Models
* `"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.
* `"clipped_bbox"`: A middle ground that uses the bounding box but clips corners that are far from the waveguide.
---
## 2. CostEvaluator Parameters
The `CostEvaluator` defines the "goodness" of a path.
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `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. |
| `congestion_penalty`| `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. |
---
## 3. CollisionEngine Parameters
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `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. |
---
## 4. Physical Units & Precision
- **Coordinates**: Micrometers (µm).
- **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**.

View file

@ -55,6 +55,19 @@ if results["net1"].is_valid:
print("Successfully routed net1!")
```
## Usage Examples
Check the `examples/` directory for ready-to-run scripts demonstrating core features:
* **`examples/01_simple_route.py`**: Basic single-net routing with visualization.
* **`examples/02_congestion_resolution.py`**: Multi-net routing resolving bottlenecks using Negotiated Congestion.
* **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths.
Run an example:
```bash
python3 examples/01_simple_route.py
```
## Architecture
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:

View file

@ -0,0 +1,58 @@
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 01: Simple Route...")
# 1. Setup Environment
# Define the routing area bounds (minx, miny, maxx, maxy)
bounds = (0, 0, 100, 100)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
# Add a simple rectangular obstacle
obstacle = Polygon([(30, 20), (70, 20), (70, 40), (30, 40)])
engine.add_static_obstacle(obstacle)
# Precompute the danger map (distance field) for heuristics
danger_map.precompute([obstacle])
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
pf = PathFinder(router, evaluator)
# 2. Define Netlist
# Route from (10, 10) to (90, 50)
# The obstacle at y=20-40 blocks the direct path.
netlist = {
"simple_net": (Port(10, 10, 0), Port(90, 50, 0)),
}
net_widths = {"simple_net": 2.0}
# 3. Route
results = pf.route_all(netlist, net_widths)
# 4. Check Results
if results["simple_net"].is_valid:
print("Success! Route found.")
print(f"Path collisions: {results['simple_net'].collisions}")
else:
print("Failed to route.")
# 5. Visualize
fig, ax = plot_routing_results(results, [obstacle], bounds, netlist=netlist)
fig.savefig("examples/simple_route.png")
print("Saved plot to examples/simple_route.png")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,54 @@
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 02: Congestion Resolution (Crossing)...")
# 1. Setup Environment (Open space)
bounds = (0, 0, 100, 100)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
pf = PathFinder(router, evaluator)
# 2. Define Netlist
# Two nets that MUST cross.
# Since crossings are illegal in single-layer routing, one net must detour around the other.
netlist = {
"horizontal": (Port(10, 50, 0), Port(90, 50, 0)),
"vertical": (Port(50, 10, 90), Port(50, 90, 90)),
}
net_widths = {"horizontal": 2.0, "vertical": 2.0}
# 3. Route with Negotiated Congestion
# We increase the base penalty to encourage faster divergence
pf.base_congestion_penalty = 500.0
results = pf.route_all(netlist, net_widths)
# 4. Check Results
all_valid = all(r.is_valid for r in results.values())
if all_valid:
print("Success! Congestion resolved (one net detoured).")
else:
print("Some nets failed or have collisions.")
for nid, res in results.items():
print(f" {nid}: valid={res.is_valid}, collisions={res.collisions}")
# 5. Visualize
fig, ax = plot_routing_results(results, [], bounds, netlist=netlist)
fig.savefig("examples/congestion.png")
print("Saved plot to examples/congestion.png")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,78 @@
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 03: Locked Paths (Incremental Routing)...")
# 1. Setup Environment
bounds = (0, 0, 100, 100)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([]) # No initial obstacles
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
pf = PathFinder(router, evaluator)
# 2. Phase 1: Route a "Critical" Net
# This net gets priority and takes the best path.
netlist_phase1 = {
"critical_net": (Port(10, 50, 0), Port(90, 50, 0)),
}
print("Phase 1: Routing critical_net...")
results1 = pf.route_all(netlist_phase1, {"critical_net": 3.0}) # Wider trace
if not results1["critical_net"].is_valid:
print("Error: Phase 1 failed.")
return
# 3. Lock the Critical Net
# This converts the dynamic path into a static obstacle in the collision engine.
print("Locking critical_net...")
engine.lock_net("critical_net")
# Update danger map to reflect the new obstacle (optional but recommended for heuristics)
# Extract polygons from result
path_polys = [p for comp in results1["critical_net"].path for p in comp.geometry]
danger_map.precompute(path_polys)
# 4. Phase 2: Route a Secondary Net
# This net must route *around* the locked critical_net.
# Start and end points force a crossing path if it were straight.
netlist_phase2 = {
"secondary_net": (Port(50, 10, 90), Port(50, 90, 90)),
}
print("Phase 2: Routing secondary_net around locked path...")
results2 = pf.route_all(netlist_phase2, {"secondary_net": 2.0})
if results2["secondary_net"].is_valid:
print("Success! Secondary net routed around locked path.")
else:
print("Failed to route secondary net.")
# 5. Visualize
# Combine results and netlists for plotting
all_results = {**results1, **results2}
all_netlists = {**netlist_phase1, **netlist_phase2}
# Note: 'critical_net' is now in engine.static_obstacles internally,
# but for visualization we plot it from the result object to see it clearly.
# We pass an empty list for 'static_obstacles' to plot_routing_results
# because we want to see the path colored, not grayed out as an obstacle.
fig, ax = plot_routing_results(all_results, [], bounds, netlist=all_netlists)
fig.savefig("examples/locked.png")
print("Saved plot to examples/locked.png")
if __name__ == "__main__":
main()

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.config import CostConfig, RouterConfig
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 04: S-Bends and Multiple Radii...")
# 1. Setup Environment
bounds = (0, 0, 100, 100)
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=bounds)
danger_map.precompute([])
# 2. Configure Router
evaluator = CostEvaluator(
engine,
danger_map,
unit_length_cost=1.0,
greedy_h_weight=1.5,
)
# We want a 45 degree switchover for S-bend.
# Offset O = 2 * R * (1 - cos(theta))
# If R = 10, O = 5.86
router = AStarRouter(
evaluator,
node_limit=50000,
bend_radii=[10.0, 30.0],
sbend_offsets=[5.0], # Use a simpler offset
sbend_radii=[10.0],
bend_penalty=10.0,
sbend_penalty=20.0,
snap_to_target_dist=50.0, # Large snap range
)
pf = PathFinder(router, evaluator)
# 3. Define Netlist
# start (10, 50), target (60, 55) -> 5um offset
netlist = {
"sbend_only": (Port(10, 50, 0), Port(60, 55, 0)),
"multi_radii": (Port(10, 10, 0), Port(90, 90, 0)),
}
net_widths = {"sbend_only": 2.0, "multi_radii": 2.0}
# 4. Route
results = pf.route_all(netlist, net_widths)
# 5. Check Results
for nid, res in results.items():
status = "Success" if res.is_valid else "Failed"
print(f"{nid}: {status}, collisions={res.collisions}")
# 6. Visualize
fig, ax = plot_routing_results(results, [], bounds, netlist=netlist)
fig.savefig("examples/sbends_radii.png")
print("Saved plot to examples/sbends_radii.png")
if __name__ == "__main__":
main()

BIN
examples/congestion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
examples/locked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
examples/sbends_radii.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
examples/simple_route.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -16,9 +16,10 @@ if TYPE_CHECKING:
class CollisionEngine:
"""Manages spatial queries for collision detection."""
def __init__(self, clearance: float, max_net_width: float = 2.0) -> None:
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.static_obstacles = rtree.index.Index()
# To store geometries for precise checks
self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon
@ -40,16 +41,9 @@ class CollisionEngine:
self.obstacle_geometries[obj_id] = polygon
self.prepared_obstacles[obj_id] = prep(polygon)
# Index the bounding box of the polygon (dilated for broad prune)
# Spec: "All user-provided obstacles are pre-dilated by (W_max + C)/2"
dilation = (self.max_net_width + self.clearance) / 2.0
dilated_bounds = (
polygon.bounds[0] - dilation,
polygon.bounds[1] - dilation,
polygon.bounds[2] + dilation,
polygon.bounds[3] + dilation,
)
self.static_obstacles.insert(obj_id, dilated_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:
"""Add a net's routed path to the dynamic R-Tree."""
@ -87,11 +81,15 @@ class CollisionEngine:
"""Count how many other nets collide with this geometry."""
dilation = self.clearance / 2.0
test_poly = geometry.buffer(dilation)
candidates = self.dynamic_paths.intersection(test_poly.bounds)
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 test_poly.intersects(other_poly):
if other_net_id != net_id and dilated_geometry.intersects(other_poly):
count += 1
return count
@ -106,35 +104,46 @@ class CollisionEngine:
_ = net_width # Width is already integrated into engine dilation settings
dilation = self.clearance / 2.0
test_poly = geometry.buffer(dilation)
return self.is_collision_prebuffered(test_poly, start_port=start_port, end_port=end_port)
# Broad prune with R-Tree
candidates = self.static_obstacles.intersection(test_poly.bounds)
def is_collision_prebuffered(
self,
dilated_geometry: Polygon,
start_port: Port | None = None,
end_port: Port | None = None,
) -> bool:
"""Check if a pre-dilated geometry collides with static obstacles."""
# Query R-Tree using the bounds of the dilated move
candidates = self.static_obstacles.intersection(dilated_geometry.bounds)
for obj_id in candidates:
# Use prepared geometry for fast intersection
if self.prepared_obstacles[obj_id].intersects(test_poly):
# Check safety zone (2nm = 0.002 um)
if self.prepared_obstacles[obj_id].intersects(dilated_geometry):
# Check safety zone (2nm radius)
if start_port or end_port:
obstacle = self.obstacle_geometries[obj_id]
intersection = test_poly.intersection(obstacle)
intersection = dilated_geometry.intersection(obstacle)
if intersection.is_empty:
continue
# Create safety zone polygons
safety_zones = []
# Precise check: is every point in the intersection close to either port?
ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds
is_near_start = False
if start_port:
safety_zones.append(Point(start_port.x, start_port.y).buffer(0.002))
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:
safety_zones.append(Point(end_port.x, end_port.y).buffer(0.002))
if safety_zones:
safe_poly = unary_union(safety_zones)
# Remove safe zones from intersection
remaining_collision = intersection.difference(safe_poly)
if remaining_collision.is_empty or remaining_collision.area < 1e-9:
continue
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,9 +1,10 @@
from __future__ import annotations
from typing import NamedTuple
from typing import NamedTuple, Literal, Union
import numpy as np
from shapely.geometry import Polygon
from shapely.geometry import Polygon, box
from shapely.ops import unary_union
from .primitives import Port
@ -12,32 +13,40 @@ SEARCH_GRID_SNAP_UM = 1.0
def snap_search_grid(value: float) -> float:
"""Snap a coordinate to the nearest 1µm."""
"""Snap a coordinate to the nearest search grid unit."""
return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM
class ComponentResult(NamedTuple):
"""The result of a component generation: geometry and the final port."""
"""The result of a component generation: geometry, final port, and physical length."""
geometry: list[Polygon]
end_port: Port
length: float
class Straight:
@staticmethod
def generate(start_port: Port, length: float, width: float) -> ComponentResult:
def generate(start_port: Port, length: float, width: float, snap_to_grid: bool = True) -> ComponentResult:
"""Generate a straight waveguide segment."""
# Calculate end port position
rad = np.radians(start_port.orientation)
dx = length * np.cos(rad)
dy = length * np.sin(rad)
end_port = Port(start_port.x + dx, start_port.y + dy, start_port.orientation)
ex = start_port.x + dx
ey = start_port.y + dy
if snap_to_grid:
ex = snap_search_grid(ex)
ey = snap_search_grid(ey)
# Create polygon (centered on port)
end_port = Port(ex, ey, start_port.orientation)
actual_length = np.sqrt((end_port.x - start_port.x)**2 + (end_port.y - start_port.y)**2)
# Create polygon
half_w = width / 2.0
# Points relative to start port (0,0)
points = [(0, half_w), (length, half_w), (length, -half_w), (0, -half_w)]
points = [(0, half_w), (actual_length, half_w), (actual_length, -half_w), (0, -half_w)]
# Transform points
cos_val = np.cos(rad)
@ -48,123 +57,129 @@ class Straight:
ty = start_port.y + px * sin_val + py * cos_val
poly_points.append((tx, ty))
return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port)
return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port, length=actual_length)
def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int:
"""Calculate number of segments for an arc to maintain a maximum sagitta."""
if radius <= 0:
return 1
# angle_deg is absolute angle turned
# s = R(1 - cos(theta/2)) => cos(theta/2) = 1 - s/R
# theta = 2 * acos(1 - s/R)
# n = total_angle / theta
ratio = max(0.0, min(1.0, 1.0 - sagitta / radius))
theta_max = 2.0 * np.arccos(ratio)
if theta_max == 0:
if theta_max < 1e-9:
return 16
num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max))
return max(4, num)
return max(8, num)
def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start: float, t_end: float, sagitta: float = 0.01) -> list[Polygon]:
"""Helper to generate arc-shaped polygons."""
num_segments = _get_num_segments(radius, float(np.degrees(abs(t_end - t_start))), sagitta)
angles = np.linspace(t_start, t_end, num_segments + 1)
inner_radius = radius - width / 2.0
outer_radius = radius + width / 2.0
inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles]
outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)]
return [Polygon(inner_points + outer_points)]
def _apply_collision_model(
arc_poly: Polygon,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
radius: float,
clip_margin: float = 10.0
) -> list[Polygon]:
"""Applies the specified collision model to an arc geometry."""
if isinstance(collision_type, Polygon):
return [collision_type]
if collision_type == "arc":
return [arc_poly]
# Get bounding box
minx, miny, maxx, maxy = arc_poly.bounds
bbox = box(minx, miny, maxx, maxy)
if collision_type == "bbox":
return [bbox]
if collision_type == "clipped_bbox":
safe_zone = arc_poly.buffer(clip_margin)
return [bbox.intersection(safe_zone)]
return [arc_poly]
class Bend90:
@staticmethod
def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult:
def generate(
start_port: Port,
radius: float,
width: float,
direction: str = "CW",
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0
) -> ComponentResult:
"""Generate a 90-degree bend."""
# direction: 'CW' (-90) or 'CCW' (+90)
turn_angle = -90 if direction == "CW" else 90
# Calculate center of the arc
rad_start = np.radians(start_port.orientation)
center_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
cx = start_port.x + radius * np.cos(center_angle)
cy = start_port.y + radius * np.sin(center_angle)
c_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
cx = start_port.x + radius * np.cos(c_angle)
cy = start_port.y + radius * np.sin(c_angle)
t_start = c_angle + np.pi
t_end = t_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
# Center to start is radius at center_angle + pi
theta_start = center_angle + np.pi
theta_end = theta_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
ex = snap_search_grid(cx + radius * np.cos(t_end))
ey = snap_search_grid(cy + radius * np.sin(t_end))
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)
collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin)
ex = cx + radius * np.cos(theta_end)
ey = cy + radius * np.sin(theta_end)
# End port orientation
end_orientation = (start_port.orientation + turn_angle) % 360
snapped_ex = snap_search_grid(ex)
snapped_ey = snap_search_grid(ey)
end_port = Port(snapped_ex, snapped_ey, float(end_orientation))
# Generate arc geometry
num_segments = _get_num_segments(radius, 90, sagitta)
angles = np.linspace(theta_start, theta_end, num_segments + 1)
inner_radius = radius - width / 2.0
outer_radius = radius + width / 2.0
inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles]
outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)]
return ComponentResult(geometry=[Polygon(inner_points + outer_points)], end_port=end_port)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0)
class SBend:
@staticmethod
def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult:
"""Generate a parametric S-bend (two tangent arcs). Only for offset < 2*radius."""
def generate(
start_port: Port,
offset: float,
radius: float,
width: float,
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0
) -> ComponentResult:
"""Generate a parametric S-bend (two tangent arcs)."""
if abs(offset) >= 2 * radius:
raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}")
# Analytical length: L = 2 * sqrt(O * (2*R - O/4)) is for a specific S-bend type.
# Standard S-bend with two equal arcs:
# Offset O = 2 * R * (1 - cos(theta))
# theta = acos(1 - O / (2*R))
theta = np.arccos(1 - abs(offset) / (2 * radius))
# Length of one arc = R * theta
# Total length of S-bend = 2 * R * theta (arc length)
# Horizontal distance dx = 2 * R * sin(theta)
dx = 2 * radius * np.sin(theta)
dy = offset
# End port
rad_start = np.radians(start_port.orientation)
ex = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)
ey = start_port.y + dx * np.sin(rad_start) + dy * np.cos(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))
end_port = Port(ex, ey, start_port.orientation)
# Geometry: two arcs
# First arc center
direction = 1 if offset > 0 else -1
center_angle1 = rad_start + direction * np.pi / 2
cx1 = start_port.x + radius * np.cos(center_angle1)
cy1 = start_port.y + radius * np.sin(center_angle1)
c1_angle = rad_start + direction * np.pi / 2
cx1 = start_port.x + radius * np.cos(c1_angle)
cy1 = start_port.y + radius * np.sin(c1_angle)
ts1, te1 = c1_angle + np.pi, c1_angle + np.pi + direction * theta
# Second arc center
center_angle2 = rad_start - direction * np.pi / 2
cx2 = ex + radius * np.cos(center_angle2)
cy2 = ey + radius * np.sin(center_angle2)
# Generate points for both arcs
num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta)
# Arc 1: theta_start1 to theta_end1
theta_start1 = center_angle1 + np.pi
theta_end1 = theta_start1 - direction * theta
# Arc 2: theta_start2 to theta_end2
theta_start2 = center_angle2
theta_end2 = theta_start2 + direction * theta
def get_arc_points(cx: float, cy: float, r_inner: float, r_outer: float, t_start: float, t_end: float) -> list[tuple[float, float]]:
angles = np.linspace(t_start, t_end, num_segments + 1)
inner = [(cx + r_inner * np.cos(a), cy + r_inner * np.sin(a)) for a in angles]
outer = [(cx + r_outer * np.cos(a), cy + r_outer * np.sin(a)) for a in reversed(angles)]
return inner + outer
poly1 = Polygon(get_arc_points(cx1, cy1, radius - width / 2, radius + width / 2, theta_start1, theta_end1))
poly2 = Polygon(get_arc_points(cx2, cy2, radius - width / 2, radius + width / 2, theta_end2, theta_start2))
return ComponentResult(geometry=[poly1, poly2], end_port=end_port)
ex_raw = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)
ey_raw = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)
c2_angle = rad_start - direction * np.pi / 2
cx2 = ex_raw + radius * np.cos(c2_angle)
cy2 = ey_raw + radius * np.sin(c2_angle)
te2 = c2_angle + np.pi
ts2 = te2 + direction * theta
arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0]
arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0]
combined_arc = unary_union([arc1, arc2])
collision_polys = _apply_collision_model(combined_arc, collision_type, radius, clip_margin)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta)

View file

@ -2,11 +2,12 @@ from __future__ import annotations
import heapq
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import numpy as np
from inire.geometry.components import Bend90, SBend, Straight
from inire.router.config import RouterConfig
if TYPE_CHECKING:
from inire.geometry.components import ComponentResult
@ -46,19 +47,60 @@ class AStarNode:
class AStarRouter:
def __init__(self, cost_evaluator: CostEvaluator) -> None:
"""Hybrid State-Lattice A* Router."""
def __init__(
self,
cost_evaluator: CostEvaluator,
node_limit: int = 1000000,
straight_lengths: list[float] | None = None,
bend_radii: list[float] | None = None,
sbend_offsets: list[float] | None = None,
sbend_radii: list[float] | None = None,
snap_to_target_dist: float = 20.0,
bend_penalty: float = 50.0,
sbend_penalty: float = 100.0,
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] = "arc",
bend_clip_margin: float = 10.0,
) -> None:
"""
Initialize the A* Router.
Args:
cost_evaluator: The evaluator for path and proximity costs.
node_limit: Maximum number of nodes to expand before failing.
straight_lengths: List of lengths for straight move expansion.
bend_radii: List of radii for 90-degree bend moves.
sbend_offsets: List of lateral offsets for S-bend moves.
sbend_radii: List of radii for S-bend moves.
snap_to_target_dist: Distance threshold for lookahead snapping.
bend_penalty: Flat cost penalty for each 90-degree bend.
sbend_penalty: Flat cost penalty for each S-bend.
bend_collision_type: Type of collision model for bends ('arc', 'bbox', 'clipped_bbox').
bend_clip_margin: Margin for 'clipped_bbox' collision model.
"""
self.cost_evaluator = cost_evaluator
self.node_limit = 100000
self.config = RouterConfig(
node_limit=node_limit,
straight_lengths=straight_lengths if straight_lengths is not None else [1.0, 5.0, 25.0],
bend_radii=bend_radii if bend_radii is not None else [10.0],
sbend_offsets=sbend_offsets if sbend_offsets is not None else [-5.0, -2.0, 2.0, 5.0],
sbend_radii=sbend_radii if sbend_radii is not None else [10.0],
snap_to_target_dist=snap_to_target_dist,
bend_penalty=bend_penalty,
sbend_penalty=sbend_penalty,
bend_collision_type=bend_collision_type,
bend_clip_margin=bend_clip_margin,
)
self.node_limit = self.config.node_limit
self.total_nodes_expanded = 0
self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {}
def route(
self, start: Port, target: Port, net_width: float, net_id: str = "default"
) -> list[ComponentResult] | None:
def route(self, start: Port, target: Port, net_width: float, net_id: str = "default") -> list[ComponentResult] | None:
"""Route a single net using A*."""
self._collision_cache.clear()
open_set: list[AStarNode] = []
# Key: (x, y, orientation)
# Key: (x, y, orientation) rounded to 1nm
closed_set: set[tuple[float, float, float]] = set()
start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target))
@ -73,27 +115,28 @@ class AStarRouter:
current = heapq.heappop(open_set)
state = (current.port.x, current.port.y, current.port.orientation)
# Prune if already visited
state = (round(current.port.x, 3), round(current.port.y, 3), round(current.port.orientation, 2))
if state in closed_set:
continue
closed_set.add(state)
nodes_expanded += 1
self.total_nodes_expanded += 1
# Check if we reached the target (Snap-to-Target)
if nodes_expanded % 5000 == 0:
logger.info(f"Nodes expanded: {nodes_expanded}, current port: {current.port}, g: {current.g_cost:.1f}, h: {current.h_cost:.1f}")
# Check if we reached the target exactly
if (
abs(current.port.x - target.x) < 1e-6
and abs(current.port.y - target.y) < 1e-6
and current.port.orientation == target.orientation
and abs(current.port.orientation - target.orientation) < 0.1
):
return self._reconstruct_path(current)
# Look-ahead snapping
if self._try_snap_to_target(current, target, net_width, net_id, open_set):
pass
# Expand neighbors
self._expand_moves(current, target, net_width, net_id, open_set)
# Expansion
self._expand_moves(current, target, net_width, net_id, open_set, closed_set)
return None
@ -104,31 +147,82 @@ class AStarRouter:
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
) -> None:
# 1. Straights
for length in [0.5, 1.0, 5.0, 25.0]:
# 1. Snap-to-Target Look-ahead
dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2)
if dist < self.config.snap_to_target_dist:
# A. Try straight exact reach
if abs(current.port.orientation - target.orientation) < 0.1:
rad = np.radians(current.port.orientation)
dx = target.x - current.port.x
dy = target.y - current.port.y
proj = dx * np.cos(rad) + dy * np.sin(rad)
perp = -dx * np.sin(rad) + dy * np.cos(rad)
if proj > 0 and abs(perp) < 1e-6:
res = Straight.generate(current.port, proj, net_width, snap_to_grid=False)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapStraight")
# B. Try SBend exact reach
if abs(current.port.orientation - target.orientation) < 0.1:
rad = np.radians(current.port.orientation)
dx = target.x - current.port.x
dy = target.y - current.port.y
proj = dx * np.cos(rad) + dy * np.sin(rad)
perp = -dx * np.sin(rad) + dy * np.cos(rad)
if proj > 0 and 0.5 <= abs(perp) < 20.0:
for radius in self.config.sbend_radii:
try:
res = SBend.generate(
current.port,
perp,
radius,
net_width,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend", move_radius=radius)
except ValueError:
pass
# 2. Lattice Straights
lengths = self.config.straight_lengths
if dist < 5.0:
fine_steps = [0.1, 0.5]
lengths = sorted(list(set(lengths + fine_steps)))
for length in lengths:
res = Straight.generate(current.port, length, net_width)
self._add_node(current, res, target, net_width, net_id, open_set, f"S{length}")
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}")
# 2. Bends
for radius in [5.0, 10.0, 20.0]:
# 3. Lattice Bends
for radius in self.config.bend_radii:
for direction in ["CW", "CCW"]:
res = Bend90.generate(current.port, radius, net_width, direction)
self._add_node(current, res, target, net_width, net_id, open_set, f"B{radius}{direction}")
res = Bend90.generate(
current.port,
radius,
net_width,
direction,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}", move_radius=radius)
# 3. Parametric SBends
dx = target.x - current.port.x
dy = target.y - current.port.y
rad = np.radians(current.port.orientation)
local_dy = -dx * np.sin(rad) + dy * np.cos(rad)
if 0 < abs(local_dy) < 40.0: # Match max 2*R
try:
# Use a standard radius for expansion
res = SBend.generate(current.port, local_dy, 20.0, net_width)
self._add_node(current, res, target, net_width, net_id, open_set, f"SB{local_dy}")
except ValueError:
pass
# 4. Discrete SBends
for offset in self.config.sbend_offsets:
for radius in self.config.sbend_radii:
try:
res = SBend.generate(
current.port,
offset,
radius,
net_width,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}", move_radius=radius)
except ValueError:
pass
def _add_node(
self,
@ -138,12 +232,19 @@ class AStarRouter:
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
move_type: str,
move_radius: float | None = None,
) -> None:
# Check closed set before adding to open set
state = (round(result.end_port.x, 3), round(result.end_port.y, 3), round(result.end_port.orientation, 2))
if state in closed_set:
return
cache_key = (
parent.port.x,
parent.port.y,
parent.port.orientation,
round(parent.port.x, 3),
round(parent.port.y, 3),
round(parent.port.orientation, 2),
move_type,
net_width,
net_id,
@ -161,49 +262,66 @@ class AStarRouter:
if hard_coll:
return
move_cost = self.cost_evaluator.evaluate_move(result.geometry, result.end_port, net_width, net_id, start_port=parent.port)
# 3. Check for Self-Intersection (Limited to last 100 segments for performance)
dilation = self.cost_evaluator.collision_engine.clearance / 2.0
for move_poly in result.geometry:
dilated_move = move_poly.buffer(dilation)
curr_p = parent
seg_idx = 0
while curr_p and curr_p.component_result and seg_idx < 100:
if seg_idx > 0:
for prev_poly in curr_p.component_result.geometry:
if dilated_move.bounds[0] > prev_poly.bounds[2] + dilation or \
dilated_move.bounds[2] < prev_poly.bounds[0] - dilation or \
dilated_move.bounds[1] > prev_poly.bounds[3] + dilation or \
dilated_move.bounds[3] < prev_poly.bounds[1] - dilation:
continue
dilated_prev = prev_poly.buffer(dilation)
if dilated_move.intersects(dilated_prev):
overlap = dilated_move.intersection(dilated_prev)
if overlap.area > 1e-6:
return
curr_p = curr_p.parent
seg_idx += 1
g_cost = parent.g_cost + move_cost + self._step_cost(result)
move_cost = self.cost_evaluator.evaluate_move(
result.geometry,
result.end_port,
net_width,
net_id,
start_port=parent.port,
length=result.length
)
if move_cost > 1e12:
return
# Turn penalties scaled by radius to favor larger turns
ref_radius = 10.0
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
move_cost += self.config.bend_penalty * penalty_factor
elif "SB" in move_type and move_radius is not None:
penalty_factor = ref_radius / move_radius
move_cost += self.config.sbend_penalty * penalty_factor
elif "B" in move_type:
move_cost += self.config.bend_penalty
elif "SB" in move_type:
move_cost += self.config.sbend_penalty
g_cost = parent.g_cost + move_cost
h_cost = self.cost_evaluator.h_manhattan(result.end_port, target)
new_node = AStarNode(result.end_port, g_cost, h_cost, parent, result)
heapq.heappush(open_set, new_node)
def _step_cost(self, result: ComponentResult) -> float:
_ = result # Unused in base implementation
return 0.0
def _try_snap_to_target(
self,
current: AStarNode,
target: Port,
net_width: float,
net_id: str,
open_set: list[AStarNode],
) -> bool:
dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2)
if dist > 10.0:
return False
if current.port.orientation == target.orientation:
rad = np.radians(current.port.orientation)
dx = target.x - current.port.x
dy = target.y - current.port.y
proj = dx * np.cos(rad) + dy * np.sin(rad)
perp = -dx * np.sin(rad) + dy * np.cos(rad)
if proj > 0 and abs(perp) < 1e-6:
res = Straight.generate(current.port, proj, net_width)
self._add_node(current, res, target, net_width, net_id, open_set, "SnapTarget")
return True
return False
def _reconstruct_path(self, end_node: AStarNode) -> list[ComponentResult]:
path = []
curr = end_node
while curr.component_result:
curr: AStarNode | None = end_node
while curr and curr.component_result:
path.append(curr.component_result)
curr = curr.parent
return path[::-1]

32
inire/router/config.py Normal file
View file

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, TYPE_CHECKING, Any
if TYPE_CHECKING:
from shapely.geometry import Polygon
@dataclass
class RouterConfig:
"""Configuration parameters for the A* Router."""
node_limit: int = 1000000
straight_lengths: list[float] = field(default_factory=lambda: [1.0, 5.0, 25.0])
bend_radii: list[float] = field(default_factory=lambda: [10.0])
sbend_offsets: list[float] = field(default_factory=lambda: [-5.0, -2.0, 2.0, 5.0])
sbend_radii: list[float] = field(default_factory=lambda: [10.0])
snap_to_target_dist: float = 20.0
bend_penalty: float = 50.0
sbend_penalty: float = 100.0
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc"
bend_clip_margin: float = 10.0
@dataclass
class CostConfig:
"""Configuration parameters for the Cost Evaluator."""
unit_length_cost: float = 1.0
greedy_h_weight: float = 1.1
congestion_penalty: float = 10000.0

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from inire.router.config import CostConfig
if TYPE_CHECKING:
from shapely.geometry import Polygon
@ -11,16 +13,38 @@ if TYPE_CHECKING:
class CostEvaluator:
"""Calculates total cost f(n) = g(n) + h(n)."""
"""Calculates total path and proximity costs."""
def __init__(self, collision_engine: CollisionEngine, danger_map: DangerMap) -> None:
def __init__(
self,
collision_engine: CollisionEngine,
danger_map: DangerMap,
unit_length_cost: float = 1.0,
greedy_h_weight: float = 1.1,
congestion_penalty: float = 10000.0,
) -> None:
"""
Initialize the Cost Evaluator.
Args:
collision_engine: The engine for intersection checks.
danger_map: Pre-computed grid for heuristic proximity costs.
unit_length_cost: Cost multiplier per micrometer of path length.
greedy_h_weight: Heuristic weighting (A* greedy factor).
congestion_penalty: Multiplier for path overlaps in negotiated congestion.
"""
self.collision_engine = collision_engine
self.danger_map = danger_map
# Cost weights
self.unit_length_cost = 1.0
self.bend_cost_multiplier = 10.0
self.greedy_h_weight = 1.1
self.congestion_penalty = 100.0 # Multiplier for overlaps
self.config = CostConfig(
unit_length_cost=unit_length_cost,
greedy_h_weight=greedy_h_weight,
congestion_penalty=congestion_penalty,
)
# Use config values
self.unit_length_cost = self.config.unit_length_cost
self.greedy_h_weight = self.config.greedy_h_weight
self.congestion_penalty = self.config.congestion_penalty
def g_proximity(self, x: float, y: float) -> float:
"""Get proximity cost from the Danger Map."""
@ -44,19 +68,30 @@ class CostEvaluator:
net_width: float,
net_id: str,
start_port: Port | None = None,
length: float = 0.0,
) -> float:
"""Calculate the cost of a single move (Straight, Bend, SBend)."""
total_cost = 0.0
# Strict collision check
_ = net_width # Unused
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:
if self.collision_engine.is_collision(poly, net_width, start_port=start_port, end_port=end_port):
return 1e9 # Massive cost for hard collisions
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
# Negotiated Congestion Cost
overlaps = self.collision_engine.count_congestion(poly, net_id)
total_cost += overlaps * self.congestion_penalty
# 2. Soft Collision check (Negotiated Congestion)
# We buffer by clearance/2 because both paths are buffered by clearance/2
soft_dilation = self.collision_engine.clearance / 2.0
for poly in geometry:
dilated_poly = poly.buffer(soft_dilation)
overlaps = self.collision_engine.count_congestion_prebuffered(dilated_poly, net_id)
if overlaps > 0:
total_cost += overlaps * self.congestion_penalty
# Proximity cost from Danger Map
# 3. Proximity cost from Danger Map
total_cost += self.g_proximity(end_port.x, end_port.y)
return total_cost

View file

@ -28,7 +28,7 @@ class PathFinder:
def __init__(self, router: AStarRouter, cost_evaluator: CostEvaluator) -> None:
self.router = router
self.cost_evaluator = cost_evaluator
self.max_iterations = 20
self.max_iterations = 10
self.base_congestion_penalty = 100.0
def route_all(self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float]) -> dict[str, RoutingResult]:
@ -38,7 +38,7 @@ class PathFinder:
start_time = time.monotonic()
num_nets = len(netlist)
session_timeout = max(30.0, 0.5 * num_nets * self.max_iterations)
session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations)
for iteration in range(self.max_iterations):
any_congestion = False

View file

@ -1,71 +1,87 @@
import pytest
import numpy as np
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
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 RoutingResult
from inire.utils.validation import validate_routing_result
@pytest.fixture
def basic_evaluator():
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_astar_straight(basic_evaluator) -> None:
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert len(path) > 0
# Final port should be target
assert abs(path[-1].end_port.x - 50.0) < 1e-6
assert path[-1].end_port.y == 0.0
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
def test_astar_bend(basic_evaluator) -> None:
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
# Path should be exactly 50um (or slightly more if it did weird things, but here it's straight)
assert abs(validation["total_length"] - 50.0) < 1e-6
def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
target = Port(20, 20, 90)
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
# From (0,0,0) -> Bend90 CW R=10 -> (10, -10, 270) ??? No.
# Try: (0,0,0) -> Bend90 CCW R=10 -> (10, 10, 90) -> Straight 10 -> (10, 20, 90) -> Bend90 CW R=10 -> (20, 30, 0)
target = Port(20, 20, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert any("Bend90" in str(res) or hasattr(res, 'geometry') for res in path) # Loose check
assert abs(path[-1].end_port.x - 20.0) < 1e-6
assert abs(path[-1].end_port.y - 20.0) < 1e-6
assert path[-1].end_port.orientation == 90.0
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
def test_astar_obstacle(basic_evaluator) -> None:
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
# Add an obstacle in the middle of a straight path
obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)])
# Obstacle from x=20 to 40, y=-20 to 20
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
router = AStarRouter(basic_evaluator)
router.node_limit = 1000000 # Give it more room for detour
start = Port(0, 0, 0)
target = Port(50, 0, 0)
target = Port(60, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
# Path should have diverted (check that it's not a single straight)
# The path should go around the 5um half-width obstacle.
# Total wire length should be > 50.
sum(np.sqrt((p.end_port.x - p.geometry[0].bounds[0])**2 + (p.end_port.y - p.geometry[0].bounds[1])**2) for p in path)
# That's a rough length estimate.
# Better: check that no part of the path collides.
for res in path:
for poly in res.geometry:
assert not poly.intersects(obstacle)
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
def test_astar_snap_to_target_lookahead(basic_evaluator) -> None:
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
# Path should have detoured, so length > 50
assert validation["total_length"] > 50.0
def test_astar_snap_to_target_lookahead(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
# Target is NOT on 1um grid
start = Port(0, 0, 0)
target = Port(10.005, 0, 0)
target = Port(10.1, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert abs(path[-1].end_port.x - 10.005) < 1e-6
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0)
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"

View file

@ -1,54 +1,56 @@
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
def test_collision_detection() -> None:
# Clearance = 2um
engine = CollisionEngine(clearance=2.0)
# Static obstacle at (10, 10) with size 5x5
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
# 10x10 um obstacle at (10,10)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
engine.add_static_obstacle(obstacle)
# Net width = 2um
# Dilation = (W+C)/2 = (2+2)/2 = 2.0um
# 1. Direct hit
test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)])
assert engine.is_collision(test_poly, net_width=2.0) is True
test_poly = Polygon([(12, 12), (13, 12), (13, 13), (12, 13)])
assert engine.is_collision(test_poly, net_width=2.0)
# 2. Far away
test_poly_far = Polygon([(0,0), (5,0), (5,5), (0,5)])
assert engine.is_collision(test_poly_far, net_width=2.0) is False
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 is at (10,10).
# test_poly is at (8,10) to (9,15).
# Centerline at 8.5. Distance to 10 is 1.5.
# 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) is True
test_poly_near = Polygon([(8, 10), (9, 10), (9, 15), (8, 15)])
assert engine.is_collision(test_poly_near, net_width=2.0)
def test_safety_zone() -> None:
# Use zero clearance for this test to verify the 2nm port safety zone
# against the physical obstacle boundary.
engine = CollisionEngine(clearance=0.0)
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
engine.add_static_obstacle(obstacle)
# Port exactly on the boundary (x=10)
start_port = Port(10.0, 12.0, 0.0)
# Port exactly on the boundary
start_port = Port(10.0, 12.0, 0)
# A very narrow waveguide (1nm width) that overlaps by 1nm.
# Overlap is from x=10 to x=10.001, y=11.9995 to 12.0005.
# This fits entirely within a 2nm radius of (10.0, 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 engine.is_collision(test_poly, net_width=0.001, start_port=start_port) is False
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 = CollisionEngine(clearance=2.0, max_net_width=10.0)
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
engine.add_static_obstacle(obstacle)
@ -56,4 +58,4 @@ def test_configurable_max_net_width() -> None:
# 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 engine.is_collision(test_poly, net_width=2.0) is False
assert not engine.is_collision(test_poly, net_width=2.0)

View file

@ -1,6 +1,8 @@
import pytest
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port
from inire.geometry.components import Straight, Bend90, SBend
def test_straight_generation() -> None:
start = Port(0, 0, 0)
@ -8,68 +10,83 @@ def test_straight_generation() -> None:
width = 2.0
result = Straight.generate(start, length, width)
# End port check
assert result.end_port.x == 10.0
assert result.end_port.y == 0.0
assert result.end_port.orientation == 0.0
assert len(result.geometry) == 1
# Geometry check
poly = result.geometry[0]
assert poly.area == length * width
# Check bounds
minx, miny, maxx, maxy = poly.bounds
# Bounds of the polygon
minx, miny, maxx, maxy = result.geometry[0].bounds
assert minx == 0.0
assert maxx == 10.0
assert miny == -1.0
assert maxy == 1.0
def test_bend90_generation() -> None:
start = Port(0, 0, 0)
radius = 10.0
width = 2.0
# CW bend (0 -> 270)
result_cw = Bend90.generate(start, radius, width, direction='CW')
# End port (center is at (0, -10))
# End port is at (10, -10) relative to center if it was 90-degree turn?
# No, from center (0, -10), start is (0, 0) which is 90 deg.
# Turn -90 deg -> end is at 0 deg from center -> (10, -10)
# CW bend
result_cw = Bend90.generate(start, radius, width, direction="CW")
assert result_cw.end_port.x == 10.0
assert result_cw.end_port.y == -10.0
assert result_cw.end_port.orientation == 270.0
# CCW bend (0 -> 90)
result_ccw = Bend90.generate(start, radius, width, direction='CCW')
# CCW bend
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
assert result_ccw.end_port.x == 10.0
assert result_ccw.end_port.y == 10.0
assert result_ccw.end_port.orientation == 90.0
def test_sbend_generation() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
result = SBend.generate(start, offset, radius, width)
# End port check
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.0
# Geometry check (two arcs)
assert len(result.geometry) == 2
assert len(result.geometry) == 1 # Now uses unary_union
# Verify failure for large offset
with pytest.raises(ValueError):
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
SBend.generate(start, 25.0, 10.0, 2.0)
def test_bend_snapping() -> None:
# Radius that results in non-integer coords
radius = 10.1234
def test_bend_collision_models() -> None:
start = Port(0, 0, 0)
result = Bend90.generate(start, radius, 2.0, direction='CCW')
# End port should be snapped to 1µm (SEARCH_GRID_SNAP_UM)
# ex = 10.1234, ey = 10.1234
# snapped: ex = 10.0, ey = 10.0 if we round to nearest 1.0?
# SEARCH_GRID_SNAP_UM = 1.0
assert result.end_port.x == 10.0
assert result.end_port.y == 10.0
radius = 10.0
width = 2.0
# 1. BBox model
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
# Arc CCW R=10 from (0,0,0) ends at (10,10,90).
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
minx, miny, maxx, maxy = res_bbox.geometry[0].bounds
assert minx <= 0.0 + 1e-6
assert maxx >= 10.0 - 1e-6
assert miny <= 0.0 + 1e-6
assert maxy >= 10.0 - 1e-6
# 2. Clipped BBox model
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0)
# Area should be less than full bbox
assert res_clipped.geometry[0].area < res_bbox.geometry[0].area
def test_sbend_collision_models() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
# Geometry should be a single bounding box polygon
assert len(res_bbox.geometry) == 1
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
assert res_bbox.geometry[0].area > res_arc.geometry[0].area

View file

@ -1,60 +1,58 @@
import pytest
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
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
@pytest.fixture
def basic_evaluator():
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
# Wider bounds to allow going around (y from -40 to 40)
danger_map = DangerMap(bounds=(0, -40, 100, 40))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_astar_sbend(basic_evaluator) -> None:
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
# Start at (0,0), target at (50, 3) -> 3um lateral offset
# 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, 3, 0)
target = Port(50, 2, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
# Check if any component in the path is an SBend
found_sbend = False
for res in path:
# SBend should align us with the target y=3
if abs(res.end_port.y - 3.0) < 1e-6 and res.end_port.orientation == 0:
found_sbend = True
# Check if the end port orientation is same as start
# and it's not a single straight (which would have y=0)
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1:
found_sbend = True
break
assert found_sbend
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator) -> None:
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
pf.max_iterations = 10
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0))
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
# Tiny obstacles to block net1 and net2 direct paths?
# No, let's block the space BETWEEN them so they must choose
# to either stay far apart or squeeze together.
# Actually, let's block their direct paths and force them
# into a narrow corridor that only fits ONE.
# 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)])
# Obstacles creating a wide wall with a narrow 2um gap at y=5.
# Gap y: 4 to 6. Center y=5.
# Net 1 (y=0) and Net 2 (y=10) both want to go to y=5 to pass.
# But only ONE fits at y=5.
obs_top = Polygon([(20, 6), (30, 6), (30, 30), (20, 30)])
obs_bottom = Polygon([(20, 4), (30, 4), (30, -30), (20, -30)])
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])
@ -66,5 +64,3 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator) -> None:
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

View file

@ -1,36 +1,25 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
def test_cost_calculation() -> None:
engine = CollisionEngine(clearance=2.0)
# 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50), resolution=1.0, safety_threshold=10.0, k=1.0)
# Add a central obstacle
# Grid cells are indexed from self.minx.
obstacle = Polygon([(20,20), (30,20), (30,30), (20,30)])
danger_map.precompute([obstacle])
danger_map = DangerMap(bounds=(0, 0, 50, 50))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
# 1. Cost far from obstacle
cost_far = evaluator.g_proximity(5.0, 5.0)
assert cost_far == 0.0
p1 = Port(0, 0, 0)
p2 = Port(10, 10, 0)
# 2. Cost near obstacle (d=1.0)
# Cell center (20.5, 20.5) is inside. Cell (19.5, 20.5) center to boundary (20, 20.5) is 0.5.
# Scipy EDT gives distance to mask=False.
cost_near = evaluator.g_proximity(19.0, 25.0)
assert cost_near > 0.0
h = evaluator.h_manhattan(p1, p2)
# Manhattan distance = 20. Orientation penalty = 0.
# Weighted by 1.1 -> 22.0
assert abs(h - 22.0) < 1e-6
# 3. Collision cost
engine.add_static_obstacle(obstacle)
test_poly = Polygon([(22, 22), (23, 22), (23, 23), (22, 23)])
# end_port at (22.5, 22.5)
move_cost = evaluator.evaluate_move(
[test_poly], Port(22.5, 22.5, 0), net_width=2.0, net_id="net1"
)
assert move_cost == 1e9
# Orientation penalty
p3 = Port(10, 10, 90)
h_wrong = evaluator.h_manhattan(p1, p3)
assert h_wrong > h

View file

@ -1,3 +1,5 @@
from typing import Any
import pytest
from hypothesis import given, settings, strategies as st
from shapely.geometry import Polygon
@ -12,7 +14,7 @@ from inire.utils.validation import validate_routing_result
@st.composite
def random_obstacle(draw):
def random_obstacle(draw: Any) -> Polygon:
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
w = draw(st.floats(min_value=1, max_value=5))
@ -21,7 +23,7 @@ def random_obstacle(draw):
@st.composite
def random_port(draw):
def random_port(draw: Any) -> Port:
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
@ -30,7 +32,7 @@ def random_port(draw):
@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, start, target) -> None:
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
engine = CollisionEngine(clearance=2.0)
for obs in obstacles:
engine.add_static_obstacle(obs)
@ -54,10 +56,11 @@ def test_fuzz_astar_no_crash(obstacles, start, target) -> None:
result,
obstacles,
clearance=2.0,
start_port_coord=(start.x, start.y),
end_port_coord=(target.x, target.y),
expected_start=start,
expected_end=target,
)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
except Exception as e:
# Unexpected exceptions are failures
pytest.fail(f"Router crashed with {type(e).__name__}: {e}")

View file

@ -1,51 +1,35 @@
import pytest
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
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
@pytest.fixture
def basic_evaluator():
def basic_evaluator() -> CostEvaluator:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_pathfinder_parallel(basic_evaluator) -> None:
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0))
"net2": (Port(0, 10, 0), Port(50, 10, 0)),
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
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_congestion(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
# Net1 blocks Net2
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(25, -10, 90), Port(25, 10, 90))
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
# Verify both nets are valid and collision-free
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

View file

@ -1,43 +1,51 @@
from typing import Any
from hypothesis import given, strategies as st
from inire.geometry.primitives import Port, translate_port, rotate_port
from inire.geometry.primitives import Port, rotate_port, translate_port
@st.composite
def port_strategy(draw):
def port_strategy(draw: Any) -> Port:
x = draw(st.floats(min_value=-1e6, max_value=1e6))
y = draw(st.floats(min_value=-1e6, max_value=1e6))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation)
def test_port_snapping() -> None:
p = Port(0.123456, 0.654321, 90)
assert p.x == 0.123
assert p.y == 0.654
assert p.orientation == 90.0
@given(p=port_strategy())
def test_port_transform_invariants(p) -> None:
def test_port_transform_invariants(p: Port) -> None:
# Rotating 90 degrees 4 times should return to same orientation
p_rot = p
for _ in range(4):
p_rot = rotate_port(p_rot, 90)
assert p_rot.orientation == p.orientation
# Coordinates should be close (floating point error) but snapped to 1nm
assert abs(p_rot.x - p.x) < 1e-9
assert abs(p_rot.y - p.y) < 1e-9
assert abs(p_rot.x - p.x) < 1e-6
assert abs(p_rot.y - p.y) < 1e-6
assert (p_rot.orientation % 360) == (p.orientation % 360)
@given(p=port_strategy(), dx=st.floats(min_value=-1000, max_value=1000), dy=st.floats(min_value=-1000, max_value=1000))
def test_translate_snapping(p, dx, dy) -> None:
@given(
p=port_strategy(),
dx=st.floats(min_value=-1000, max_value=1000),
dy=st.floats(min_value=-1000, max_value=1000),
)
def test_translate_snapping(p: Port, dx: float, dy: float) -> None:
p_trans = translate_port(p, dx, dy)
# Check that snapped result is indeed multiple of GRID_SNAP_UM (0.001 um = 1nm)
# Multiplication is more stable for this check
assert abs(p_trans.x * 1000 - round(p_trans.x * 1000)) < 1e-6
assert abs(p_trans.y * 1000 - round(p_trans.y * 1000)) < 1e-6
def test_orientation_normalization() -> None:
p = Port(0, 0, 360)
assert p.orientation == 0.0
p2 = Port(0, 0, -90)
assert p2.orientation == 270.0
p3 = Port(0, 0, 95) # Should snap to 90
assert p3.orientation == 90.0

View file

@ -1,10 +1,11 @@
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
from inire.geometry.components import Bend90
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
def test_arc_resolution_sagitta() -> None:
start = Port(0, 0, 0)
@ -21,6 +22,7 @@ def test_arc_resolution_sagitta() -> None:
assert pts_fine > pts_coarse
def test_locked_paths() -> None:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 100, 50))
@ -47,6 +49,7 @@ def test_locked_paths() -> None:
# Net B should be is_valid (it detoured) or at least not have collisions
# with Net A in the dynamic set (because netA is now static).
# Since netA is static, netB will see it as a HARD collision if it tries to cross.
# Our A* will find a detour around the static obstacle.
assert results_b["netB"].is_valid
@ -56,5 +59,4 @@ def test_locked_paths() -> None:
for pa in poly_a:
for pb in poly_b:
# Check physical clearance
assert not pa.buffer(1.0).intersects(pb.buffer(1.0))
assert not pa.intersects(pb)

View file

@ -1,13 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from typing import TYPE_CHECKING, Any
from shapely.geometry import Point
from shapely.geometry import Point, Polygon
from shapely.ops import unary_union
if TYPE_CHECKING:
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.router.pathfinder import RoutingResult
@ -15,9 +15,9 @@ def validate_routing_result(
result: RoutingResult,
static_obstacles: list[Polygon],
clearance: float,
start_port_coord: tuple[float, float] | None = None,
end_port_coord: tuple[float, float] | None = None,
) -> dict[str, any]:
expected_start: Port | None = None,
expected_end: Port | None = None,
) -> dict[str, Any]:
"""
Perform a high-precision validation of a routed path.
Returns a dictionary with validation results.
@ -25,33 +25,71 @@ def validate_routing_result(
if not result.path:
return {"is_valid": False, "reason": "No path found"}
collision_geoms = []
# High-precision safety zones
safe_zones = []
if start_port_coord:
safe_zones.append(Point(start_port_coord).buffer(0.002))
if end_port_coord:
safe_zones.append(Point(end_port_coord).buffer(0.002))
safe_poly = unary_union(safe_zones) if safe_zones else None
obstacle_collision_geoms = []
self_intersection_geoms = []
connectivity_errors = []
# 1. Connectivity Check
total_length = 0.0
for i, comp in enumerate(result.path):
total_length += comp.length
# Buffer by C/2
dilation = clearance / 2.0
# Boundary check
if expected_end:
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)
if dist_to_end > 0.005:
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
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}")
for comp in result.path:
# 2. Geometry Buffering
dilation_half = clearance / 2.0
dilation_full = clearance
dilated_for_self = []
for i, comp in enumerate(result.path):
for poly in comp.geometry:
dilated = poly.buffer(dilation)
# Check against obstacles
d_full = poly.buffer(dilation_full)
for obs in static_obstacles:
if dilated.intersects(obs):
intersection = dilated.intersection(obs)
if safe_poly:
# Remove safe zones from intersection
intersection = intersection.difference(safe_poly)
if d_full.intersects(obs):
intersection = d_full.intersection(obs)
if intersection.area > 1e-9:
obstacle_collision_geoms.append(intersection)
# Save for self-intersection check
dilated_for_self.append(poly.buffer(dilation_half))
if not intersection.is_empty and intersection.area > 1e-9:
collision_geoms.append(intersection)
# 3. Self-intersection
for i, seg_i in enumerate(dilated_for_self):
for j, seg_j in enumerate(dilated_for_self):
if j > i + 1: # Non-adjacent
if seg_i.intersects(seg_j):
overlap = seg_i.intersection(seg_j)
if overlap.area > 1e-6:
self_intersection_geoms.append((i, j, overlap))
is_valid = (len(obstacle_collision_geoms) == 0 and
len(self_intersection_geoms) == 0 and
len(connectivity_errors) == 0)
reasons = []
if obstacle_collision_geoms:
reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")
if self_intersection_geoms:
# report which indices
idx_str = ", ".join([f"{i}-{j}" for i, j, _ in self_intersection_geoms[:5]])
reasons.append(f"Found {len(self_intersection_geoms)} self-intersections (e.g. {idx_str}).")
if connectivity_errors:
reasons.extend(connectivity_errors)
return {
"is_valid": len(collision_geoms) == 0,
"collisions": collision_geoms,
"collision_count": len(collision_geoms),
"is_valid": is_valid,
"reason": " ".join(reasons),
"obstacle_collisions": obstacle_collision_geoms,
"self_intersections": self_intersection_geoms,
"total_length": total_length,
"connectivity_ok": len(connectivity_errors) == 0,
}

View file

@ -3,12 +3,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.router.pathfinder import RoutingResult
@ -16,6 +18,7 @@ def plot_routing_results(
results: dict[str, RoutingResult],
static_obstacles: list[Polygon],
bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]] | None = None,
) -> tuple[Figure, Axes]:
"""Plot obstacles and routed paths using matplotlib."""
fig, ax = plt.subplots(figsize=(10, 10))
@ -28,18 +31,49 @@ def plot_routing_results(
# Plot paths
colors = plt.get_cmap("tab10")
for i, (net_id, res) in enumerate(results.items()):
color = colors(i)
# Use modulo to avoid index out of range for many nets
color: str | tuple[float, ...] = colors(i % 10)
if not res.is_valid:
color = "red" # Highlight failing nets
for comp in res.path:
label_added = False
for j, comp in enumerate(res.path):
# 1. Plot geometry
for poly in comp.geometry:
x, y = poly.exterior.xy
ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if i == 0 else "")
# Handle both Polygon and MultiPolygon (e.g. from SBend)
geoms = [poly] if hasattr(poly, "exterior") else poly.geoms
for g in geoms:
x, y = g.exterior.xy
ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if not label_added else "")
label_added = True
# 2. Plot subtle port orientation arrow for internal ports
# (Every segment's end_port except possibly the last one if it matches target)
p = comp.end_port
rad = np.radians(p.orientation)
u = np.cos(rad)
v = np.sin(rad)
# Internal ports get smaller, narrower, semi-transparent arrows
ax.quiver(p.x, p.y, u, v, color="black", scale=40, width=0.003, alpha=0.3, pivot="tail", zorder=4)
# 3. Plot main arrows for netlist ports (if provided)
if netlist and net_id in netlist:
start_p, target_p = netlist[net_id]
for p in [start_p, target_p]:
rad = np.radians(p.orientation)
u = np.cos(rad)
v = np.sin(rad)
# Netlist ports get prominent arrows
ax.quiver(p.x, p.y, u, v, color="black", scale=25, width=0.005, pivot="tail", zorder=6)
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
ax.set_aspect("equal")
ax.set_title("Inire Routing Results")
plt.grid(True)
# Only show legend if we have labels
handles, labels = ax.get_legend_handles_labels()
if labels:
ax.legend()
ax.grid(alpha=0.6)
return fig, ax

73
uv.lock generated
View file

@ -178,6 +178,7 @@ dependencies = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
@ -194,6 +195,7 @@ requires-dist = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
@ -630,6 +632,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572 },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199 },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001 },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719 },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595 },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429 },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952 },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063 },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449 },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708 },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135 },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977 },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601 },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667 },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159 },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771 },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910 },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 },
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 },
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 },
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 },
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 },
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 },
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 },
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 },
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 },
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 },
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 },
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 },
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 },
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 },
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 },
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 },
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 },
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 },
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 },
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 },
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 },
]
[[package]]
name = "shapely"
version = "2.1.2"