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!") 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 ## Architecture
`inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types:

View file

@ -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: class CollisionEngine:
"""Manages spatial queries for collision detection.""" """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.clearance = clearance
self.max_net_width = max_net_width self.max_net_width = max_net_width
self.safety_zone_radius = safety_zone_radius
self.static_obstacles = rtree.index.Index() self.static_obstacles = rtree.index.Index()
# To store geometries for precise checks # To store geometries for precise checks
self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon
@ -41,9 +40,16 @@ class CollisionEngine:
self.obstacle_geometries[obj_id] = polygon self.obstacle_geometries[obj_id] = polygon
self.prepared_obstacles[obj_id] = prep(polygon) self.prepared_obstacles[obj_id] = prep(polygon)
# Index the bounding box of the original polygon # Index the bounding box of the polygon (dilated for broad prune)
# We query with dilated moves, so original bounds are enough # Spec: "All user-provided obstacles are pre-dilated by (W_max + C)/2"
self.static_obstacles.insert(obj_id, polygon.bounds) 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: def add_path(self, net_id: str, geometry: list[Polygon]) -> None:
"""Add a net's routed path to the dynamic R-Tree.""" """Add a net's routed path to the dynamic R-Tree."""
@ -81,15 +87,11 @@ class CollisionEngine:
"""Count how many other nets collide with this geometry.""" """Count how many other nets collide with this geometry."""
dilation = self.clearance / 2.0 dilation = self.clearance / 2.0
test_poly = geometry.buffer(dilation) test_poly = geometry.buffer(dilation)
return self.count_congestion_prebuffered(test_poly, net_id) candidates = self.dynamic_paths.intersection(test_poly.bounds)
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 count = 0
for obj_id in candidates: for obj_id in candidates:
other_net_id, other_poly = self.path_geometries[obj_id] 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 count += 1
return count return count
@ -104,46 +106,35 @@ class CollisionEngine:
_ = net_width # Width is already integrated into engine dilation settings _ = net_width # Width is already integrated into engine dilation settings
dilation = self.clearance / 2.0 dilation = self.clearance / 2.0
test_poly = geometry.buffer(dilation) test_poly = geometry.buffer(dilation)
return self.is_collision_prebuffered(test_poly, start_port=start_port, end_port=end_port)
def is_collision_prebuffered( # Broad prune with R-Tree
self, candidates = self.static_obstacles.intersection(test_poly.bounds)
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: for obj_id in candidates:
# Use prepared geometry for fast intersection # Use prepared geometry for fast intersection
if self.prepared_obstacles[obj_id].intersects(dilated_geometry): if self.prepared_obstacles[obj_id].intersects(test_poly):
# Check safety zone (2nm radius) # Check safety zone (2nm = 0.002 um)
if start_port or end_port: if start_port or end_port:
obstacle = self.obstacle_geometries[obj_id] obstacle = self.obstacle_geometries[obj_id]
intersection = dilated_geometry.intersection(obstacle) intersection = test_poly.intersection(obstacle)
if intersection.is_empty: if intersection.is_empty:
continue continue
# Precise check: is every point in the intersection close to either port? # Create safety zone polygons
ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds safety_zones = []
is_near_start = False
if start_port: 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 safety_zones.append(Point(start_port.x, start_port.y).buffer(0.002))
abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius):
is_near_start = True
is_near_end = False
if end_port: if 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 safety_zones.append(Point(end_port.x, end_port.y).buffer(0.002))
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: 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 continue
return True return True
return False return False

View file

@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import NamedTuple, Literal, Union from typing import NamedTuple
import numpy as np import numpy as np
from shapely.geometry import Polygon, box from shapely.geometry import Polygon
from shapely.ops import unary_union
from .primitives import Port from .primitives import Port
@ -13,40 +12,32 @@ SEARCH_GRID_SNAP_UM = 1.0
def snap_search_grid(value: float) -> float: 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 return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM
class ComponentResult(NamedTuple): 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] geometry: list[Polygon]
end_port: Port end_port: Port
length: float
class Straight: class Straight:
@staticmethod @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.""" """Generate a straight waveguide segment."""
# Calculate end port position
rad = np.radians(start_port.orientation) rad = np.radians(start_port.orientation)
dx = length * np.cos(rad) dx = length * np.cos(rad)
dy = length * np.sin(rad) dy = length * np.sin(rad)
ex = start_port.x + dx end_port = Port(start_port.x + dx, start_port.y + dy, start_port.orientation)
ey = start_port.y + dy
if snap_to_grid: # Create polygon (centered on port)
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
half_w = width / 2.0 half_w = width / 2.0
# Points relative to start port (0,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 # Transform points
cos_val = np.cos(rad) cos_val = np.cos(rad)
@ -57,129 +48,123 @@ class Straight:
ty = start_port.y + px * sin_val + py * cos_val ty = start_port.y + px * sin_val + py * cos_val
poly_points.append((tx, ty)) 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: 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.""" """Calculate number of segments for an arc to maintain a maximum sagitta."""
if radius <= 0: if radius <= 0:
return 1 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)) ratio = max(0.0, min(1.0, 1.0 - sagitta / radius))
theta_max = 2.0 * np.arccos(ratio) theta_max = 2.0 * np.arccos(ratio)
if theta_max < 1e-9: if theta_max == 0:
return 16 return 16
num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max)) num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max))
return max(8, num) return max(4, 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: class Bend90:
@staticmethod @staticmethod
def generate( def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult:
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.""" """Generate a 90-degree bend."""
# direction: 'CW' (-90) or 'CCW' (+90)
turn_angle = -90 if direction == "CW" else 90 turn_angle = -90 if direction == "CW" else 90
# Calculate center of the arc
rad_start = np.radians(start_port.orientation) rad_start = np.radians(start_port.orientation)
c_angle = rad_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(c_angle) cx = start_port.x + radius * np.cos(center_angle)
cy = start_port.y + radius * np.sin(c_angle) cy = start_port.y + radius * np.sin(center_angle)
t_start = c_angle + np.pi
t_end = t_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
ex = snap_search_grid(cx + radius * np.cos(t_end)) # Center to start is radius at center_angle + pi
ey = snap_search_grid(cy + radius * np.sin(t_end)) theta_start = center_angle + np.pi
end_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360)) 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) ex = cx + radius * np.cos(theta_end)
collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin) 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: class SBend:
@staticmethod @staticmethod
def generate( def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult:
start_port: Port, """Generate a parametric S-bend (two tangent arcs). Only for offset < 2*radius."""
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: if abs(offset) >= 2 * radius:
raise ValueError(f"SBend offset {offset} must be less than 2*radius {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)) 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) dx = 2 * radius * np.sin(theta)
dy = offset dy = offset
# End port
rad_start = np.radians(start_port.orientation) rad_start = np.radians(start_port.orientation)
ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) ex = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)
ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) ey = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)
end_port = Port(ex, ey, start_port.orientation) end_port = Port(ex, ey, start_port.orientation)
# Geometry: two arcs
# First arc center
direction = 1 if offset > 0 else -1 direction = 1 if offset > 0 else -1
c1_angle = rad_start + direction * np.pi / 2 center_angle1 = rad_start + direction * np.pi / 2
cx1 = start_port.x + radius * np.cos(c1_angle) cx1 = start_port.x + radius * np.cos(center_angle1)
cy1 = start_port.y + radius * np.sin(c1_angle) cy1 = start_port.y + radius * np.sin(center_angle1)
ts1, te1 = c1_angle + np.pi, c1_angle + np.pi + direction * theta
ex_raw = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start) # Second arc center
ey_raw = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start) center_angle2 = rad_start - direction * np.pi / 2
c2_angle = rad_start - direction * np.pi / 2 cx2 = ex + radius * np.cos(center_angle2)
cx2 = ex_raw + radius * np.cos(c2_angle) cy2 = ey + radius * np.sin(center_angle2)
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] # Generate points for both arcs
arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0] num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta)
combined_arc = unary_union([arc1, arc2]) # 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 heapq
import logging import logging
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING
import numpy as np import numpy as np
from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.components import Bend90, SBend, Straight
from inire.router.config import RouterConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -47,60 +46,19 @@ class AStarNode:
class AStarRouter: class AStarRouter:
"""Hybrid State-Lattice A* Router.""" def __init__(self, cost_evaluator: CostEvaluator) -> None:
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.cost_evaluator = cost_evaluator
self.config = RouterConfig( self.node_limit = 100000
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.total_nodes_expanded = 0
self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {} 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*.""" """Route a single net using A*."""
self._collision_cache.clear() self._collision_cache.clear()
open_set: list[AStarNode] = [] open_set: list[AStarNode] = []
# Key: (x, y, orientation) rounded to 1nm # Key: (x, y, orientation)
closed_set: set[tuple[float, float, float]] = set() closed_set: set[tuple[float, float, float]] = set()
start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target)) start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target))
@ -115,28 +73,27 @@ class AStarRouter:
current = heapq.heappop(open_set) current = heapq.heappop(open_set)
# Prune if already visited state = (current.port.x, current.port.y, current.port.orientation)
state = (round(current.port.x, 3), round(current.port.y, 3), round(current.port.orientation, 2))
if state in closed_set: if state in closed_set:
continue continue
closed_set.add(state) closed_set.add(state)
nodes_expanded += 1 nodes_expanded += 1
self.total_nodes_expanded += 1 self.total_nodes_expanded += 1
if nodes_expanded % 5000 == 0: # Check if we reached the target (Snap-to-Target)
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 ( if (
abs(current.port.x - target.x) < 1e-6 abs(current.port.x - target.x) < 1e-6
and abs(current.port.y - target.y) < 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) return self._reconstruct_path(current)
# Expansion # Look-ahead snapping
self._expand_moves(current, target, net_width, net_id, open_set, closed_set) 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 return None
@ -147,80 +104,29 @@ class AStarRouter:
net_width: float, net_width: float,
net_id: str, net_id: str,
open_set: list[AStarNode], open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
) -> None: ) -> None:
# 1. Snap-to-Target Look-ahead # 1. Straights
dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2) for length in [0.5, 1.0, 5.0, 25.0]:
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) 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 # 2. Bends
for radius in self.config.bend_radii: for radius in [5.0, 10.0, 20.0]:
for direction in ["CW", "CCW"]: for direction in ["CW", "CCW"]:
res = Bend90.generate( res = Bend90.generate(current.port, radius, net_width, direction)
current.port, self._add_node(current, res, target, net_width, net_id, open_set, f"B{radius}{direction}")
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)
# 4. Discrete SBends # 3. Parametric SBends
for offset in self.config.sbend_offsets: dx = target.x - current.port.x
for radius in self.config.sbend_radii: 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: try:
res = SBend.generate( # Use a standard radius for expansion
current.port, res = SBend.generate(current.port, local_dy, 20.0, net_width)
offset, self._add_node(current, res, target, net_width, net_id, open_set, f"SB{local_dy}")
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: except ValueError:
pass pass
@ -232,19 +138,12 @@ class AStarRouter:
net_width: float, net_width: float,
net_id: str, net_id: str,
open_set: list[AStarNode], open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
move_type: str, move_type: str,
move_radius: float | None = 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 = ( cache_key = (
round(parent.port.x, 3), parent.port.x,
round(parent.port.y, 3), parent.port.y,
round(parent.port.orientation, 2), parent.port.orientation,
move_type, move_type,
net_width, net_width,
net_id, net_id,
@ -262,66 +161,49 @@ class AStarRouter:
if hard_coll: if hard_coll:
return return
# 3. Check for Self-Intersection (Limited to last 100 segments for performance) move_cost = self.cost_evaluator.evaluate_move(result.geometry, result.end_port, net_width, net_id, start_port=parent.port)
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) g_cost = parent.g_cost + move_cost + self._step_cost(result)
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
h_cost = self.cost_evaluator.h_manhattan(result.end_port, target) h_cost = self.cost_evaluator.h_manhattan(result.end_port, target)
new_node = AStarNode(result.end_port, g_cost, h_cost, parent, result) new_node = AStarNode(result.end_port, g_cost, h_cost, parent, result)
heapq.heappush(open_set, new_node) 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]: def _reconstruct_path(self, end_node: AStarNode) -> list[ComponentResult]:
path = [] path = []
curr: AStarNode | None = end_node curr = end_node
while curr and curr.component_result: while curr.component_result:
path.append(curr.component_result) path.append(curr.component_result)
curr = curr.parent curr = curr.parent
return path[::-1] 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 typing import TYPE_CHECKING
from inire.router.config import CostConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
@ -13,38 +11,16 @@ if TYPE_CHECKING:
class CostEvaluator: class CostEvaluator:
"""Calculates total path and proximity costs.""" """Calculates total cost f(n) = g(n) + h(n)."""
def __init__( def __init__(self, collision_engine: CollisionEngine, danger_map: DangerMap) -> None:
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.collision_engine = collision_engine
self.danger_map = danger_map self.danger_map = danger_map
self.config = CostConfig( # Cost weights
unit_length_cost=unit_length_cost, self.unit_length_cost = 1.0
greedy_h_weight=greedy_h_weight, self.bend_cost_multiplier = 10.0
congestion_penalty=congestion_penalty, self.greedy_h_weight = 1.1
) self.congestion_penalty = 100.0 # Multiplier for overlaps
# 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: def g_proximity(self, x: float, y: float) -> float:
"""Get proximity cost from the Danger Map.""" """Get proximity cost from the Danger Map."""
@ -68,30 +44,19 @@ class CostEvaluator:
net_width: float, net_width: float,
net_id: str, net_id: str,
start_port: Port | None = None, start_port: Port | None = None,
length: float = 0.0,
) -> float: ) -> float:
"""Calculate the cost of a single move (Straight, Bend, SBend).""" """Calculate the cost of a single move (Straight, Bend, SBend)."""
_ = net_width # Unused total_cost = 0.0
total_cost = length * self.unit_length_cost # Strict collision check
# 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: for poly in geometry:
dilated_poly = poly.buffer(hard_dilation) if self.collision_engine.is_collision(poly, net_width, start_port=start_port, end_port=end_port):
if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port): return 1e9 # Massive cost for hard collisions
return 1e15 # Impossible cost for hard collisions
# 2. Soft Collision check (Negotiated Congestion) # Negotiated Congestion Cost
# We buffer by clearance/2 because both paths are buffered by clearance/2 overlaps = self.collision_engine.count_congestion(poly, net_id)
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 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) total_cost += self.g_proximity(end_port.x, end_port.y)
return total_cost return total_cost

View file

@ -28,7 +28,7 @@ class PathFinder:
def __init__(self, router: AStarRouter, cost_evaluator: CostEvaluator) -> None: def __init__(self, router: AStarRouter, cost_evaluator: CostEvaluator) -> None:
self.router = router self.router = router
self.cost_evaluator = cost_evaluator self.cost_evaluator = cost_evaluator
self.max_iterations = 10 self.max_iterations = 20
self.base_congestion_penalty = 100.0 self.base_congestion_penalty = 100.0
def route_all(self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float]) -> dict[str, RoutingResult]: 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() start_time = time.monotonic()
num_nets = len(netlist) 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): for iteration in range(self.max_iterations):
any_congestion = False any_congestion = False

View file

@ -1,87 +1,71 @@
import pytest 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 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 @pytest.fixture
def basic_evaluator() -> CostEvaluator: def basic_evaluator():
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100)) danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([]) danger_map.precompute([])
return CostEvaluator(engine, danger_map) return CostEvaluator(engine, danger_map)
def test_astar_straight(basic_evaluator) -> None:
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator) router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(50, 0, 0) target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0) path = router.route(start, target, net_width=2.0)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) assert len(path) > 0
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) # 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')}" def test_astar_bend(basic_evaluator) -> None:
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) router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0) start = Port(0, 0, 0)
# 20um right, 20um up. Needs a 10um bend and a 10um bend. target = Port(20, 20, 90)
# 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) path = router.route(start, target, net_width=2.0)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) assert any("Bend90" in str(res) or hasattr(res, 'geometry') for res in path) # Loose check
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) 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')}" def test_astar_obstacle(basic_evaluator) -> None:
assert validation["connectivity_ok"]
def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
# Add an obstacle in the middle of a straight path # Add an obstacle in the middle of a straight path
# Obstacle from x=20 to 40, y=-20 to 20 obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)])
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle]) basic_evaluator.danger_map.precompute([obstacle])
router = AStarRouter(basic_evaluator) router = AStarRouter(basic_evaluator)
router.node_limit = 1000000 # Give it more room for detour
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(60, 0, 0) target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0) path = router.route(start, target, net_width=2.0)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) # Path should have diverted (check that it's not a single straight)
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) # 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')}" def test_astar_snap_to_target_lookahead(basic_evaluator) -> None:
# 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) router = AStarRouter(basic_evaluator)
# Target is NOT on 1um grid # Target is NOT on 1um grid
start = Port(0, 0, 0) 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) path = router.route(start, target, net_width=2.0)
assert path is not None assert path is not None
result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) assert abs(path[-1].end_port.x - 10.005) < 1e-6
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,56 +1,54 @@
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
def test_collision_detection() -> None: def test_collision_detection() -> None:
# Clearance = 2um # Clearance = 2um
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
# 10x10 um obstacle at (10,10) # Static obstacle at (10, 10) with size 5x5
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) engine.add_static_obstacle(obstacle)
# Net width = 2um
# Dilation = (W+C)/2 = (2+2)/2 = 2.0um
# 1. Direct hit # 1. Direct hit
test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)]) test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)])
assert engine.is_collision(test_poly, net_width=2.0) assert engine.is_collision(test_poly, net_width=2.0) is True
# 2. Far away # 2. Far away
test_poly_far = Polygon([(0,0), (5,0), (5,5), (0,5)]) test_poly_far = Polygon([(0,0), (5,0), (5,5), (0,5)])
assert not engine.is_collision(test_poly_far, net_width=2.0) assert engine.is_collision(test_poly_far, net_width=2.0) is False
# 3. Near hit (within clearance) # 3. Near hit (within clearance)
# Obstacle edge at x=10. # Obstacle is at (10,10).
# test_poly edge at x=9. # test_poly is at (8,10) to (9,15).
# Distance = 1.0 um. # Centerline at 8.5. Distance to 10 is 1.5.
# Required distance (Wi+C)/2 = 2.0. Collision! # Required distance (Wi+C)/2 = 2.0. Collision!
test_poly_near = Polygon([(8,10), (9,10), (9,15), (8,15)]) test_poly_near = Polygon([(8,10), (9,10), (9,15), (8,15)])
assert engine.is_collision(test_poly_near, net_width=2.0) assert engine.is_collision(test_poly_near, net_width=2.0) is True
def test_safety_zone() -> None: def test_safety_zone() -> None:
# Use zero clearance for this test to verify the 2nm port safety zone # Use zero clearance for this test to verify the 2nm port safety zone
# against the physical obstacle boundary. # against the physical obstacle boundary.
engine = CollisionEngine(clearance=0.0) 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) engine.add_static_obstacle(obstacle)
# Port exactly on the boundary # Port exactly on the boundary (x=10)
start_port = Port(10.0, 12.0, 0) start_port = Port(10.0, 12.0, 0.0)
# Move starting from this port that overlaps the obstacle by 1nm # A very narrow waveguide (1nm width) that overlaps by 1nm.
# (Inside the 2nm safety zone) # 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)]) 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: def test_configurable_max_net_width() -> None:
# Large max_net_width (10.0) -> large pre-dilation (6.0) # Large max_net_width (10.0) -> large pre-dilation (6.0)
engine = CollisionEngine(clearance=2.0, max_net_width=10.0) engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)]) obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
engine.add_static_obstacle(obstacle) 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. # physical check: dilated test_poly by C/2 = 1.0.
# Dilated test_poly bounds: (14, 19, 17, 26). # Dilated test_poly bounds: (14, 19, 17, 26).
# obstacle: (20, 20, 25, 25). No physical collision. # 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 import pytest
from inire.geometry.components import Bend90, SBend, Straight
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.geometry.components import Straight, Bend90, SBend
def test_straight_generation() -> None: def test_straight_generation() -> None:
start = Port(0, 0, 0) start = Port(0, 0, 0)
@ -10,83 +8,68 @@ def test_straight_generation() -> None:
width = 2.0 width = 2.0
result = Straight.generate(start, length, width) result = Straight.generate(start, length, width)
# End port check
assert result.end_port.x == 10.0 assert result.end_port.x == 10.0
assert result.end_port.y == 0.0 assert result.end_port.y == 0.0
assert result.end_port.orientation == 0.0 assert result.end_port.orientation == 0.0
assert len(result.geometry) == 1
# Bounds of the polygon # Geometry check
minx, miny, maxx, maxy = result.geometry[0].bounds poly = result.geometry[0]
assert poly.area == length * width
# Check bounds
minx, miny, maxx, maxy = poly.bounds
assert minx == 0.0 assert minx == 0.0
assert maxx == 10.0 assert maxx == 10.0
assert miny == -1.0 assert miny == -1.0
assert maxy == 1.0 assert maxy == 1.0
def test_bend90_generation() -> None: def test_bend90_generation() -> None:
start = Port(0, 0, 0) start = Port(0, 0, 0)
radius = 10.0 radius = 10.0
width = 2.0 width = 2.0
# CW bend (0 -> 270)
result_cw = Bend90.generate(start, radius, width, direction='CW')
# CW bend # End port (center is at (0, -10))
result_cw = Bend90.generate(start, radius, width, direction="CW") # 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.x == 10.0
assert result_cw.end_port.y == -10.0 assert result_cw.end_port.y == -10.0
assert result_cw.end_port.orientation == 270.0 assert result_cw.end_port.orientation == 270.0
# CCW bend # CCW bend (0 -> 90)
result_ccw = Bend90.generate(start, radius, width, direction="CCW") result_ccw = Bend90.generate(start, radius, width, direction='CCW')
assert result_ccw.end_port.x == 10.0 assert result_ccw.end_port.x == 10.0
assert result_ccw.end_port.y == 10.0 assert result_ccw.end_port.y == 10.0
assert result_ccw.end_port.orientation == 90.0 assert result_ccw.end_port.orientation == 90.0
def test_sbend_generation() -> None: def test_sbend_generation() -> None:
start = Port(0, 0, 0) start = Port(0, 0, 0)
offset = 5.0 offset = 5.0
radius = 10.0 radius = 10.0
width = 2.0 width = 2.0
result = SBend.generate(start, offset, radius, width) result = SBend.generate(start, offset, radius, width)
# End port check
assert result.end_port.y == 5.0 assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.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 # 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) SBend.generate(start, 25.0, 10.0, 2.0)
def test_bend_snapping() -> None:
def test_bend_collision_models() -> None: # Radius that results in non-integer coords
radius = 10.1234
start = Port(0, 0, 0) start = Port(0, 0, 0)
radius = 10.0 result = Bend90.generate(start, radius, 2.0, direction='CCW')
width = 2.0 # End port should be snapped to 1µm (SEARCH_GRID_SNAP_UM)
# ex = 10.1234, ey = 10.1234
# 1. BBox model # snapped: ex = 10.0, ey = 10.0 if we round to nearest 1.0?
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") # SEARCH_GRID_SNAP_UM = 1.0
# Arc CCW R=10 from (0,0,0) ends at (10,10,90). assert result.end_port.x == 10.0
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) assert result.end_port.y == 10.0
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,58 +1,60 @@
import pytest 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 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 @pytest.fixture
def basic_evaluator() -> CostEvaluator: def basic_evaluator():
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
# Wider bounds to allow going around (y from -40 to 40) # Wider bounds to allow going around (y from -40 to 40)
danger_map = DangerMap(bounds=(0, -40, 100, 40)) danger_map = DangerMap(bounds=(0, -40, 100, 40))
danger_map.precompute([]) danger_map.precompute([])
return CostEvaluator(engine, danger_map) return CostEvaluator(engine, danger_map)
def test_astar_sbend(basic_evaluator) -> None:
def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator) router = AStarRouter(basic_evaluator)
# Start at (0,0), target at (50, 2) -> 2um lateral offset # Start at (0,0), target at (50, 3) -> 3um lateral offset
# This matches one of our discretized SBend offsets.
start = Port(0, 0, 0) start = Port(0, 0, 0)
target = Port(50, 2, 0) target = Port(50, 3, 0)
path = router.route(start, target, net_width=2.0) path = router.route(start, target, net_width=2.0)
assert path is not None assert path is not None
# Check if any component in the path is an SBend # Check if any component in the path is an SBend
found_sbend = False found_sbend = False
for res in path: for res in path:
# Check if the end port orientation is same as start # SBend should align us with the target y=3
# and it's not a single straight (which would have y=0) if abs(res.end_port.y - 3.0) < 1e-6 and res.end_port.orientation == 0:
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1:
found_sbend = True found_sbend = True
break
assert found_sbend 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) router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator) pf = PathFinder(router, basic_evaluator)
pf.max_iterations = 10 pf.max_iterations = 10
netlist = { netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)), "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} net_widths = {"net1": 2.0, "net2": 2.0}
# Force them into a narrow corridor that only fits ONE. # Tiny obstacles to block net1 and net2 direct paths?
obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall # No, let's block the space BETWEEN them so they must choose
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) # 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_top)
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
basic_evaluator.danger_map.precompute([obs_top, 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["net1"].is_valid
assert results["net2"].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.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.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.geometry.primitives import Port
def test_cost_calculation() -> None: def test_cost_calculation() -> None:
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
# 50x50 um area, 1um resolution # 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50)) danger_map = DangerMap(bounds=(0, 0, 50, 50), resolution=1.0, safety_threshold=10.0, k=1.0)
danger_map.precompute([])
# 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) evaluator = CostEvaluator(engine, danger_map)
p1 = Port(0, 0, 0) # 1. Cost far from obstacle
p2 = Port(10, 10, 0) cost_far = evaluator.g_proximity(5.0, 5.0)
assert cost_far == 0.0
h = evaluator.h_manhattan(p1, p2) # 2. Cost near obstacle (d=1.0)
# Manhattan distance = 20. Orientation penalty = 0. # Cell center (20.5, 20.5) is inside. Cell (19.5, 20.5) center to boundary (20, 20.5) is 0.5.
# Weighted by 1.1 -> 22.0 # Scipy EDT gives distance to mask=False.
assert abs(h - 22.0) < 1e-6 cost_near = evaluator.g_proximity(19.0, 25.0)
assert cost_near > 0.0
# Orientation penalty # 3. Collision cost
p3 = Port(10, 10, 90) engine.add_static_obstacle(obstacle)
h_wrong = evaluator.h_manhattan(p1, p3) test_poly = Polygon([(22, 22), (23, 22), (23, 23), (22, 23)])
assert h_wrong > h # 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 import pytest
from hypothesis import given, settings, strategies as st from hypothesis import given, settings, strategies as st
from shapely.geometry import Polygon from shapely.geometry import Polygon
@ -14,7 +12,7 @@ from inire.utils.validation import validate_routing_result
@st.composite @st.composite
def random_obstacle(draw: Any) -> Polygon: def random_obstacle(draw):
x = draw(st.floats(min_value=0, max_value=20)) x = draw(st.floats(min_value=0, max_value=20))
y = 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)) w = draw(st.floats(min_value=1, max_value=5))
@ -23,7 +21,7 @@ def random_obstacle(draw: Any) -> Polygon:
@st.composite @st.composite
def random_port(draw: Any) -> Port: def random_port(draw):
x = draw(st.floats(min_value=0, max_value=20)) x = draw(st.floats(min_value=0, max_value=20))
y = 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])) 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) @settings(max_examples=3, deadline=None)
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port()) @given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None: def test_fuzz_astar_no_crash(obstacles, start, target) -> None:
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
for obs in obstacles: for obs in obstacles:
engine.add_static_obstacle(obs) engine.add_static_obstacle(obs)
@ -56,11 +54,10 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port
result, result,
obstacles, obstacles,
clearance=2.0, clearance=2.0,
expected_start=start, start_port_coord=(start.x, start.y),
expected_end=target, end_port_coord=(target.x, target.y),
) )
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
except Exception as e: except Exception as e:
# Unexpected exceptions are failures # Unexpected exceptions are failures
pytest.fail(f"Router crashed with {type(e).__name__}: {e}") pytest.fail(f"Router crashed with {type(e).__name__}: {e}")

View file

@ -1,35 +1,51 @@
import pytest import pytest
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router.astar import AStarRouter from inire.geometry.collision import CollisionEngine
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap 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.router.pathfinder import PathFinder
@pytest.fixture @pytest.fixture
def basic_evaluator() -> CostEvaluator: def basic_evaluator():
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100)) danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([]) danger_map.precompute([])
return CostEvaluator(engine, danger_map) return CostEvaluator(engine, danger_map)
def test_pathfinder_parallel(basic_evaluator) -> None:
def test_pathfinder_parallel(basic_evaluator: CostEvaluator) -> None:
router = AStarRouter(basic_evaluator) router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator) pf = PathFinder(router, basic_evaluator)
netlist = { netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)), "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} net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths) results = pf.route_all(netlist, net_widths)
assert len(results) == 2
assert results["net1"].is_valid assert results["net1"].is_valid
assert results["net2"].is_valid assert results["net2"].is_valid
assert results["net1"].collisions == 0 assert results["net1"].collisions == 0
assert results["net2"].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 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 @st.composite
def port_strategy(draw: Any) -> Port: def port_strategy(draw):
x = draw(st.floats(min_value=-1e6, max_value=1e6)) x = draw(st.floats(min_value=-1e6, max_value=1e6))
y = 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])) orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation) return Port(x, y, orientation)
def test_port_snapping() -> None: def test_port_snapping() -> None:
p = Port(0.123456, 0.654321, 90) p = Port(0.123456, 0.654321, 90)
assert p.x == 0.123 assert p.x == 0.123
assert p.y == 0.654 assert p.y == 0.654
assert p.orientation == 90.0
@given(p=port_strategy()) @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 # Rotating 90 degrees 4 times should return to same orientation
p_rot = p p_rot = p
for _ in range(4): for _ in range(4):
p_rot = rotate_port(p_rot, 90) p_rot = rotate_port(p_rot, 90)
assert abs(p_rot.x - p.x) < 1e-6 assert p_rot.orientation == p.orientation
assert abs(p_rot.y - p.y) < 1e-6 # Coordinates should be close (floating point error) but snapped to 1nm
assert (p_rot.orientation % 360) == (p.orientation % 360) 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))
@given( def test_translate_snapping(p, dx, dy) -> None:
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) p_trans = translate_port(p, dx, dy)
# Check that snapped result is indeed multiple of GRID_SNAP_UM (0.001 um = 1nm) # 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.x * 1000 - round(p_trans.x * 1000)) < 1e-6
assert abs(p_trans.y * 1000 - round(p_trans.y * 1000)) < 1e-6 assert abs(p_trans.y * 1000 - round(p_trans.y * 1000)) < 1e-6
def test_orientation_normalization() -> None: def test_orientation_normalization() -> None:
p = Port(0, 0, 360) p = Port(0, 0, 360)
assert p.orientation == 0.0 assert p.orientation == 0.0
p2 = Port(0, 0, -90) p2 = Port(0, 0, -90)
assert p2.orientation == 270.0 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.geometry.primitives import Port
from inire.router.astar import AStarRouter from inire.geometry.collision import CollisionEngine
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap 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.router.pathfinder import PathFinder
from inire.geometry.components import Bend90
def test_arc_resolution_sagitta() -> None: def test_arc_resolution_sagitta() -> None:
start = Port(0, 0, 0) start = Port(0, 0, 0)
@ -22,7 +21,6 @@ def test_arc_resolution_sagitta() -> None:
assert pts_fine > pts_coarse assert pts_fine > pts_coarse
def test_locked_paths() -> None: def test_locked_paths() -> None:
engine = CollisionEngine(clearance=2.0) engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 100, 50)) 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 # 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). # 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. # 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. # Our A* will find a detour around the static obstacle.
assert results_b["netB"].is_valid assert results_b["netB"].is_valid
@ -59,4 +56,5 @@ def test_locked_paths() -> None:
for pa in poly_a: for pa in poly_a:
for pb in poly_b: 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 from __future__ import annotations
import numpy as np from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from shapely.geometry import Point, Polygon from shapely.geometry import Point
from shapely.ops import unary_union from shapely.ops import unary_union
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.primitives import Port from shapely.geometry import Polygon
from inire.router.pathfinder import RoutingResult from inire.router.pathfinder import RoutingResult
@ -15,9 +15,9 @@ def validate_routing_result(
result: RoutingResult, result: RoutingResult,
static_obstacles: list[Polygon], static_obstacles: list[Polygon],
clearance: float, clearance: float,
expected_start: Port | None = None, start_port_coord: tuple[float, float] | None = None,
expected_end: Port | None = None, end_port_coord: tuple[float, float] | None = None,
) -> dict[str, Any]: ) -> dict[str, any]:
""" """
Perform a high-precision validation of a routed path. Perform a high-precision validation of a routed path.
Returns a dictionary with validation results. Returns a dictionary with validation results.
@ -25,71 +25,33 @@ def validate_routing_result(
if not result.path: if not result.path:
return {"is_valid": False, "reason": "No path found"} return {"is_valid": False, "reason": "No path found"}
obstacle_collision_geoms = [] collision_geoms = []
self_intersection_geoms = [] # High-precision safety zones
connectivity_errors = [] 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 # Buffer by C/2
total_length = 0.0 dilation = clearance / 2.0
for i, comp in enumerate(result.path):
total_length += comp.length
# Boundary check for comp in result.path:
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 poly in comp.geometry: for poly in comp.geometry:
# Check against obstacles dilated = poly.buffer(dilation)
d_full = poly.buffer(dilation_full)
for obs in static_obstacles: for obs in static_obstacles:
if d_full.intersects(obs): if dilated.intersects(obs):
intersection = d_full.intersection(obs) intersection = dilated.intersection(obs)
if intersection.area > 1e-9: if safe_poly:
obstacle_collision_geoms.append(intersection) # Remove safe zones from intersection
intersection = intersection.difference(safe_poly)
# Save for self-intersection check if not intersection.is_empty and intersection.area > 1e-9:
dilated_for_self.append(poly.buffer(dilation_half)) 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 { return {
"is_valid": is_valid, "is_valid": len(collision_geoms) == 0,
"reason": " ".join(reasons), "collisions": collision_geoms,
"obstacle_collisions": obstacle_collision_geoms, "collision_count": len(collision_geoms),
"self_intersections": self_intersection_geoms,
"total_length": total_length,
"connectivity_ok": len(connectivity_errors) == 0,
} }

View file

@ -3,14 +3,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING: if TYPE_CHECKING:
from matplotlib.axes import Axes from matplotlib.axes import Axes
from matplotlib.figure import Figure from matplotlib.figure import Figure
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.router.pathfinder import RoutingResult from inire.router.pathfinder import RoutingResult
@ -18,7 +16,6 @@ def plot_routing_results(
results: dict[str, RoutingResult], results: dict[str, RoutingResult],
static_obstacles: list[Polygon], static_obstacles: list[Polygon],
bounds: tuple[float, float, float, float], bounds: tuple[float, float, float, float],
netlist: dict[str, tuple[Port, Port]] | None = None,
) -> tuple[Figure, Axes]: ) -> tuple[Figure, Axes]:
"""Plot obstacles and routed paths using matplotlib.""" """Plot obstacles and routed paths using matplotlib."""
fig, ax = plt.subplots(figsize=(10, 10)) fig, ax = plt.subplots(figsize=(10, 10))
@ -31,49 +28,18 @@ def plot_routing_results(
# Plot paths # Plot paths
colors = plt.get_cmap("tab10") colors = plt.get_cmap("tab10")
for i, (net_id, res) in enumerate(results.items()): for i, (net_id, res) in enumerate(results.items()):
# Use modulo to avoid index out of range for many nets color = colors(i)
color: str | tuple[float, ...] = colors(i % 10)
if not res.is_valid: if not res.is_valid:
color = "red" # Highlight failing nets color = "red" # Highlight failing nets
label_added = False for comp in res.path:
for j, comp in enumerate(res.path):
# 1. Plot geometry
for poly in comp.geometry: for poly in comp.geometry:
# Handle both Polygon and MultiPolygon (e.g. from SBend) x, y = poly.exterior.xy
geoms = [poly] if hasattr(poly, "exterior") else poly.geoms ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if i == 0 else "")
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_xlim(bounds[0], bounds[2])
ax.set_ylim(bounds[1], bounds[3]) ax.set_ylim(bounds[1], bounds[3])
ax.set_aspect("equal") ax.set_aspect("equal")
ax.set_title("Inire Routing Results") ax.set_title("Inire Routing Results")
# Only show legend if we have labels plt.grid(True)
handles, labels = ax.get_legend_handles_labels()
if labels:
ax.legend()
ax.grid(alpha=0.6)
return fig, ax return fig, ax

73
uv.lock generated
View file

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