Compare commits

..

No commits in common. "43a9a6cb3afe9051da208315bcbbe1cc13fd43b4" and "f600b52f32438f4368484094de2d01d16bcf4953" have entirely different histories.

28 changed files with 442 additions and 1139 deletions

53
DOCS.md
View file

@ -1,53 +0,0 @@
# 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,19 +55,6 @@ 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

@ -1,58 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,70 +0,0 @@
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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View file

@ -16,10 +16,9 @@ if TYPE_CHECKING:
class CollisionEngine:
"""Manages spatial queries for collision detection."""
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) -> 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
@ -41,9 +40,16 @@ class CollisionEngine:
self.obstacle_geometries[obj_id] = polygon
self.prepared_obstacles[obj_id] = prep(polygon)
# 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)
# 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)
def add_path(self, net_id: str, geometry: list[Polygon]) -> None:
"""Add a net's routed path to the dynamic R-Tree."""
@ -81,15 +87,11 @@ class CollisionEngine:
"""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)
candidates = self.dynamic_paths.intersection(test_poly.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):
if other_net_id != net_id and test_poly.intersects(other_poly):
count += 1
return count
@ -104,46 +106,35 @@ 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)
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)
# Broad prune with R-Tree
candidates = self.static_obstacles.intersection(test_poly.bounds)
for obj_id in candidates:
# Use prepared geometry for fast intersection
if self.prepared_obstacles[obj_id].intersects(dilated_geometry):
# Check safety zone (2nm radius)
if self.prepared_obstacles[obj_id].intersects(test_poly):
# Check safety zone (2nm = 0.002 um)
if start_port or end_port:
obstacle = self.obstacle_geometries[obj_id]
intersection = dilated_geometry.intersection(obstacle)
intersection = test_poly.intersection(obstacle)
if intersection.is_empty:
continue
# 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
# Create safety zone polygons
safety_zones = []
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
safety_zones.append(Point(start_port.x, start_port.y).buffer(0.002))
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
safety_zones.append(Point(end_port.x, end_port.y).buffer(0.002))
if is_near_start or is_near_end:
continue
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
return True
return False

View file

@ -1,10 +1,9 @@
from __future__ import annotations
from typing import NamedTuple, Literal, Union
from typing import NamedTuple
import numpy as np
from shapely.geometry import Polygon, box
from shapely.ops import unary_union
from shapely.geometry import Polygon
from .primitives import Port
@ -13,40 +12,32 @@ SEARCH_GRID_SNAP_UM = 1.0
def snap_search_grid(value: float) -> float:
"""Snap a coordinate to the nearest search grid unit."""
"""Snap a coordinate to the nearest 1µm."""
return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM
class ComponentResult(NamedTuple):
"""The result of a component generation: geometry, final port, and physical length."""
"""The result of a component generation: geometry and the final port."""
geometry: list[Polygon]
end_port: Port
length: float
class Straight:
@staticmethod
def generate(start_port: Port, length: float, width: float, snap_to_grid: bool = True) -> ComponentResult:
def generate(start_port: Port, length: float, width: float) -> 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)
ex = start_port.x + dx
ey = start_port.y + dy
end_port = Port(start_port.x + dx, start_port.y + dy, start_port.orientation)
if snap_to_grid:
ex = snap_search_grid(ex)
ey = snap_search_grid(ey)
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
# Create polygon (centered on port)
half_w = width / 2.0
# Points relative to start port (0,0)
points = [(0, half_w), (actual_length, half_w), (actual_length, -half_w), (0, -half_w)]
points = [(0, half_w), (length, half_w), (length, -half_w), (0, -half_w)]
# Transform points
cos_val = np.cos(rad)
@ -57,129 +48,123 @@ 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, length=actual_length)
return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port)
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 < 1e-9:
if theta_max == 0:
return 16
num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max))
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]
return max(4, num)
class Bend90:
@staticmethod
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:
def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> 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)
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_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)
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))
# 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)
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)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0)
# 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)
class SBend:
@staticmethod
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)."""
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."""
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 = 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))
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)
end_port = Port(ex, ey, start_port.orientation)
# Geometry: two arcs
# First arc center
direction = 1 if offset > 0 else -1
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
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)
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
# 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)
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])
# 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)
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,12 +2,11 @@ from __future__ import annotations
import heapq
import logging
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING
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
@ -47,60 +46,19 @@ class AStarNode:
class AStarRouter:
"""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.
"""
def __init__(self, cost_evaluator: CostEvaluator) -> None:
self.cost_evaluator = cost_evaluator
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.node_limit = 100000
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) rounded to 1nm
# Key: (x, y, orientation)
closed_set: set[tuple[float, float, float]] = set()
start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target))
@ -115,28 +73,27 @@ class AStarRouter:
current = heapq.heappop(open_set)
# Prune if already visited
state = (round(current.port.x, 3), round(current.port.y, 3), round(current.port.orientation, 2))
state = (current.port.x, current.port.y, current.port.orientation)
if state in closed_set:
continue
closed_set.add(state)
nodes_expanded += 1
self.total_nodes_expanded += 1
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
# Check if we reached the target (Snap-to-Target)
if (
abs(current.port.x - target.x) < 1e-6
and abs(current.port.y - target.y) < 1e-6
and abs(current.port.orientation - target.orientation) < 0.1
and current.port.orientation == target.orientation
):
return self._reconstruct_path(current)
# Expansion
self._expand_moves(current, target, net_width, net_id, open_set, closed_set)
# 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)
return None
@ -147,82 +104,31 @@ class AStarRouter:
net_width: float,
net_id: str,
open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
) -> None:
# 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:
# 1. Straights
for length in [0.5, 1.0, 5.0, 25.0]:
res = Straight.generate(current.port, length, net_width)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}")
self._add_node(current, res, target, net_width, net_id, open_set, f"S{length}")
# 3. Lattice Bends
for radius in self.config.bend_radii:
# 2. Bends
for radius in [5.0, 10.0, 20.0]:
for direction in ["CW", "CCW"]:
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)
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}")
# 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
# 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
def _add_node(
self,
@ -232,19 +138,12 @@ 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 = (
round(parent.port.x, 3),
round(parent.port.y, 3),
round(parent.port.orientation, 2),
parent.port.x,
parent.port.y,
parent.port.orientation,
move_type,
net_width,
net_id,
@ -262,66 +161,49 @@ class AStarRouter:
if hard_coll:
return
# 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
move_cost = self.cost_evaluator.evaluate_move(result.geometry, result.end_port, net_width, net_id, start_port=parent.port)
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
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
g_cost = parent.g_cost + move_cost + self._step_cost(result)
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: AStarNode | None = end_node
while curr and curr.component_result:
curr = end_node
while curr.component_result:
path.append(curr.component_result)
curr = curr.parent
return path[::-1]

View file

@ -1,32 +0,0 @@
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,8 +2,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from inire.router.config import CostConfig
if TYPE_CHECKING:
from shapely.geometry import Polygon
@ -13,38 +11,16 @@ if TYPE_CHECKING:
class CostEvaluator:
"""Calculates total path and proximity costs."""
"""Calculates total cost f(n) = g(n) + h(n)."""
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.
"""
def __init__(self, collision_engine: CollisionEngine, danger_map: DangerMap) -> None:
self.collision_engine = collision_engine
self.danger_map = danger_map
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
# 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
def g_proximity(self, x: float, y: float) -> float:
"""Get proximity cost from the Danger Map."""
@ -68,30 +44,19 @@ 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)."""
_ = 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
total_cost = 0.0
# Strict collision check
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
if self.collision_engine.is_collision(poly, net_width, start_port=start_port, end_port=end_port):
return 1e9 # Massive cost for hard collisions
# 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
# Negotiated Congestion Cost
overlaps = self.collision_engine.count_congestion(poly, net_id)
total_cost += overlaps * self.congestion_penalty
# 3. Proximity cost from Danger Map
# 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 = 10
self.max_iterations = 20
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(60.0, 10.0 * num_nets * self.max_iterations)
session_timeout = max(30.0, 0.5 * num_nets * self.max_iterations)
for iteration in range(self.max_iterations):
any_congestion = False

View file

@ -1,87 +1,71 @@
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() -> CostEvaluator:
def basic_evaluator():
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: CostEvaluator) -> None:
def test_astar_straight(basic_evaluator) -> 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
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 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
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:
def test_astar_bend(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
# 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)
target = Port(20, 20, 90)
path = router.route(start, target, net_width=2.0)
assert path is not None
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 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
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
assert validation["connectivity_ok"]
def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
def test_astar_obstacle(basic_evaluator) -> None:
# Add an obstacle in the middle of a straight path
# Obstacle from x=20 to 40, y=-20 to 20
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)])
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(60, 0, 0)
target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
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)
# 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)
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:
def test_astar_snap_to_target_lookahead(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
# Target is NOT on 1um grid
start = Port(0, 0, 0)
target = Port(10.1, 0, 0)
target = Port(10.005, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
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')}"
assert abs(path[-1].end_port.x - 10.005) < 1e-6

View file

@ -1,56 +1,54 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
def test_collision_detection() -> None:
# Clearance = 2um
engine = CollisionEngine(clearance=2.0)
# 10x10 um obstacle at (10,10)
obstacle = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])
# Static obstacle at (10, 10) with size 5x5
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
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)
test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)])
assert engine.is_collision(test_poly, net_width=2.0) is True
# 2. Far away
test_poly_far = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
assert not engine.is_collision(test_poly_far, net_width=2.0)
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
# 3. Near hit (within clearance)
# Obstacle edge at x=10.
# test_poly edge at x=9.
# Distance = 1.0 um.
# Obstacle is at (10,10).
# test_poly is at (8,10) to (9,15).
# Centerline at 8.5. Distance to 10 is 1.5.
# 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)
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
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), (20, 10), (20, 20), (10, 20)])
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
engine.add_static_obstacle(obstacle)
# Port exactly on the boundary
start_port = Port(10.0, 12.0, 0)
# Port exactly on the boundary (x=10)
start_port = Port(10.0, 12.0, 0.0)
# Move starting from this port that overlaps the obstacle by 1nm
# (Inside the 2nm safety zone)
# 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).
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
assert not engine.is_collision(test_poly, net_width=0.001, start_port=start_port)
assert engine.is_collision(test_poly, net_width=0.001, start_port=start_port) is False
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)
@ -58,4 +56,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 not engine.is_collision(test_poly, net_width=2.0)
assert engine.is_collision(test_poly, net_width=2.0) is False

View file

@ -1,8 +1,6 @@
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)
@ -10,83 +8,68 @@ 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
# Bounds of the polygon
minx, miny, maxx, maxy = result.geometry[0].bounds
# Geometry check
poly = result.geometry[0]
assert poly.area == length * width
# Check bounds
minx, miny, maxx, maxy = poly.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')
# CW bend
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)
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
result_ccw = Bend90.generate(start, radius, width, direction="CCW")
# CCW bend (0 -> 90)
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
assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.0
assert len(result.geometry) == 1 # Now uses unary_union
# Geometry check (two arcs)
assert len(result.geometry) == 2
# Verify failure for large offset
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
with pytest.raises(ValueError):
SBend.generate(start, 25.0, 10.0, 2.0)
def test_bend_collision_models() -> None:
def test_bend_snapping() -> None:
# Radius that results in non-integer coords
radius = 10.1234
start = Port(0, 0, 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
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

View file

@ -1,58 +1,60 @@
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() -> CostEvaluator:
def basic_evaluator():
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: CostEvaluator) -> None:
def test_astar_sbend(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
# Start at (0,0), target at (50, 2) -> 2um lateral offset
# This matches one of our discretized SBend offsets.
# Start at (0,0), target at (50, 3) -> 3um lateral offset
start = Port(0, 0, 0)
target = Port(50, 2, 0)
target = Port(50, 3, 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:
# 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
# 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
assert found_sbend
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvaluator) -> None:
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator) -> 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}
# 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)])
# 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.
# 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])
@ -64,3 +66,5 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

View file

@ -1,25 +1,36 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.geometry.primitives import Port
def test_cost_calculation() -> None:
engine = CollisionEngine(clearance=2.0)
# 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50))
danger_map.precompute([])
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])
evaluator = CostEvaluator(engine, danger_map)
p1 = Port(0, 0, 0)
p2 = Port(10, 10, 0)
# 1. Cost far from obstacle
cost_far = evaluator.g_proximity(5.0, 5.0)
assert cost_far == 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
# 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
# Orientation penalty
p3 = Port(10, 10, 90)
h_wrong = evaluator.h_manhattan(p1, p3)
assert h_wrong > h
# 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

View file

@ -1,5 +1,3 @@
from typing import Any
import pytest
from hypothesis import given, settings, strategies as st
from shapely.geometry import Polygon
@ -14,7 +12,7 @@ from inire.utils.validation import validate_routing_result
@st.composite
def random_obstacle(draw: Any) -> Polygon:
def random_obstacle(draw):
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))
@ -23,7 +21,7 @@ def random_obstacle(draw: Any) -> Polygon:
@st.composite
def random_port(draw: Any) -> Port:
def random_port(draw):
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]))
@ -32,7 +30,7 @@ def random_port(draw: Any) -> Port:
@settings(max_examples=3, deadline=None)
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
def test_fuzz_astar_no_crash(obstacles, start, target) -> None:
engine = CollisionEngine(clearance=2.0)
for obs in obstacles:
engine.add_static_obstacle(obs)
@ -56,11 +54,10 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
result,
obstacles,
clearance=2.0,
expected_start=start,
expected_end=target,
start_port_coord=(start.x, start.y),
end_port_coord=(target.x, target.y),
)
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,35 +1,51 @@
import pytest
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.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
@pytest.fixture
def basic_evaluator() -> CostEvaluator:
def basic_evaluator():
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: CostEvaluator) -> None:
def test_pathfinder_parallel(basic_evaluator) -> 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,51 +1,43 @@
from typing import Any
from hypothesis import given, strategies as st
from inire.geometry.primitives import Port, rotate_port, translate_port
from inire.geometry.primitives import Port, translate_port, rotate_port
@st.composite
def port_strategy(draw: Any) -> Port:
def port_strategy(draw):
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: Port) -> None:
def test_port_transform_invariants(p) -> 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 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)
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
@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:
@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:
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,11 +1,10 @@
from inire.geometry.collision import CollisionEngine
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.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
def test_arc_resolution_sagitta() -> None:
start = Port(0, 0, 0)
@ -22,7 +21,6 @@ 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))
@ -49,7 +47,6 @@ 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
@ -59,4 +56,5 @@ def test_locked_paths() -> None:
for pa in poly_a:
for pb in poly_b:
assert not pa.intersects(pb)
# Check physical clearance
assert not pa.buffer(1.0).intersects(pb.buffer(1.0))

View file

@ -1,13 +1,13 @@
from __future__ import annotations
import numpy as np
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from shapely.geometry import Point, Polygon
from shapely.geometry import Point
from shapely.ops import unary_union
if TYPE_CHECKING:
from inire.geometry.primitives import Port
from shapely.geometry import Polygon
from inire.router.pathfinder import RoutingResult
@ -15,9 +15,9 @@ def validate_routing_result(
result: RoutingResult,
static_obstacles: list[Polygon],
clearance: float,
expected_start: Port | None = None,
expected_end: Port | None = None,
) -> dict[str, Any]:
start_port_coord: tuple[float, float] | None = None,
end_port_coord: tuple[float, float] | None = None,
) -> dict[str, any]:
"""
Perform a high-precision validation of a routed path.
Returns a dictionary with validation results.
@ -25,71 +25,33 @@ def validate_routing_result(
if not result.path:
return {"is_valid": False, "reason": "No path found"}
obstacle_collision_geoms = []
self_intersection_geoms = []
connectivity_errors = []
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
# 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}")
# 2. Geometry Buffering
dilation_half = clearance / 2.0
dilation_full = clearance
dilated_for_self = []
for i, comp in enumerate(result.path):
for comp in result.path:
for poly in comp.geometry:
# Check against obstacles
d_full = poly.buffer(dilation_full)
dilated = poly.buffer(dilation)
for obs in static_obstacles:
if d_full.intersects(obs):
intersection = d_full.intersection(obs)
if intersection.area > 1e-9:
obstacle_collision_geoms.append(intersection)
if dilated.intersects(obs):
intersection = dilated.intersection(obs)
if safe_poly:
# Remove safe zones from intersection
intersection = intersection.difference(safe_poly)
# Save for self-intersection check
dilated_for_self.append(poly.buffer(dilation_half))
# 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)
if not intersection.is_empty and intersection.area > 1e-9:
collision_geoms.append(intersection)
return {
"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,
"is_valid": len(collision_geoms) == 0,
"collisions": collision_geoms,
"collision_count": len(collision_geoms),
}

View file

@ -3,14 +3,12 @@ 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
@ -18,7 +16,6 @@ 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))
@ -31,49 +28,18 @@ def plot_routing_results(
# Plot paths
colors = plt.get_cmap("tab10")
for i, (net_id, res) in enumerate(results.items()):
# Use modulo to avoid index out of range for many nets
color: str | tuple[float, ...] = colors(i % 10)
color = colors(i)
if not res.is_valid:
color = "red" # Highlight failing nets
label_added = False
for j, comp in enumerate(res.path):
# 1. Plot geometry
for comp in res.path:
for poly in comp.geometry:
# 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)
x, y = poly.exterior.xy
ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if i == 0 else "")
ax.set_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3])
ax.set_aspect("equal")
ax.set_title("Inire Routing Results")
# Only show legend if we have labels
handles, labels = ax.get_legend_handles_labels()
if labels:
ax.legend()
ax.grid(alpha=0.6)
plt.grid(True)
return fig, ax

73
uv.lock generated
View file

@ -178,7 +178,6 @@ dependencies = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
@ -195,7 +194,6 @@ requires-dist = [
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "rtree" },
{ name = "scipy" },
{ name = "shapely" },
]
@ -632,77 +630,6 @@ 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"