From 82aaf066e235545dad914baa257def17dc4277c3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 8 Mar 2026 14:40:36 -0700 Subject: [PATCH] initial pass on examples --- README.md | 13 ++ examples/01_simple_route.py | 58 +++++++++ examples/02_congestion_resolution.py | 54 +++++++++ examples/03_locked_paths.py | 76 ++++++++++++ examples/congestion.png | Bin 0 -> 34473 bytes examples/locked.png | Bin 0 -> 35592 bytes examples/simple_route.png | Bin 0 -> 26058 bytes inire/geometry/collision.py | 44 ++++--- inire/geometry/components.py | 133 ++++++++++---------- inire/router/astar.py | 173 ++++++++++++++++----------- inire/router/cost.py | 34 +++--- inire/router/pathfinder.py | 4 +- inire/tests/test_astar.py | 55 +++++---- inire/tests/test_components.py | 2 +- inire/tests/test_congestion.py | 9 +- inire/tests/test_fuzz.py | 5 +- inire/utils/validation.py | 92 +++++++++----- inire/utils/visualization.py | 11 +- uv.lock | 73 +++++++++++ 19 files changed, 599 insertions(+), 237 deletions(-) create mode 100644 examples/01_simple_route.py create mode 100644 examples/02_congestion_resolution.py create mode 100644 examples/03_locked_paths.py create mode 100644 examples/congestion.png create mode 100644 examples/locked.png create mode 100644 examples/simple_route.png diff --git a/README.md b/README.md index 4fba041..4aea8d0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ if results["net1"].is_valid: print("Successfully routed net1!") ``` +## Usage Examples + +Check the `examples/` directory for ready-to-run scripts demonstrating core features: + +* **`examples/01_simple_route.py`**: Basic single-net routing with visualization. +* **`examples/02_congestion_resolution.py`**: Multi-net routing resolving bottlenecks using Negotiated Congestion. +* **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. + +Run an example: +```bash +python3 examples/01_simple_route.py +``` + ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: diff --git a/examples/01_simple_route.py b/examples/01_simple_route.py new file mode 100644 index 0000000..cb2b893 --- /dev/null +++ b/examples/01_simple_route.py @@ -0,0 +1,58 @@ +from shapely.geometry import Polygon + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.astar import AStarRouter +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.pathfinder import PathFinder +from inire.utils.visualization import plot_routing_results + + +def main() -> None: + print("Running Example 01: Simple Route...") + + # 1. Setup Environment + # Define the routing area bounds (minx, miny, maxx, maxy) + bounds = (0, 0, 100, 100) + + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + + # Add a simple rectangular obstacle + obstacle = Polygon([(30, 20), (70, 20), (70, 40), (30, 40)]) + engine.add_static_obstacle(obstacle) + + # Precompute the danger map (distance field) for heuristics + danger_map.precompute([obstacle]) + + evaluator = CostEvaluator(engine, danger_map) + router = AStarRouter(evaluator) + pf = PathFinder(router, evaluator) + + # 2. Define Netlist + # Route from (10, 10) to (90, 50) + # The obstacle at y=20-40 blocks the direct path. + netlist = { + "simple_net": (Port(10, 10, 0), Port(90, 50, 0)), + } + net_widths = {"simple_net": 2.0} + + # 3. Route + results = pf.route_all(netlist, net_widths) + + # 4. Check Results + if results["simple_net"].is_valid: + print("Success! Route found.") + print(f"Path collisions: {results['simple_net'].collisions}") + else: + print("Failed to route.") + + # 5. Visualize + fig, ax = plot_routing_results(results, [obstacle], bounds) + fig.savefig("examples/simple_route.png") + print("Saved plot to examples/simple_route.png") + + +if __name__ == "__main__": + main() diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py new file mode 100644 index 0000000..7ebb888 --- /dev/null +++ b/examples/02_congestion_resolution.py @@ -0,0 +1,54 @@ + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.astar import AStarRouter +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.pathfinder import PathFinder +from inire.utils.visualization import plot_routing_results + + +def main() -> None: + print("Running Example 02: Congestion Resolution (Crossing)...") + + # 1. Setup Environment (Open space) + bounds = (0, 0, 100, 100) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + danger_map.precompute([]) + + evaluator = CostEvaluator(engine, danger_map) + router = AStarRouter(evaluator) + pf = PathFinder(router, evaluator) + + # 2. Define Netlist + # Two nets that MUST cross. + # Since crossings are illegal in single-layer routing, one net must detour around the other. + netlist = { + "horizontal": (Port(10, 50, 0), Port(90, 50, 0)), + "vertical": (Port(50, 10, 90), Port(50, 90, 90)), + } + net_widths = {"horizontal": 2.0, "vertical": 2.0} + + # 3. Route with Negotiated Congestion + # We increase the base penalty to encourage faster divergence + pf.base_congestion_penalty = 500.0 + results = pf.route_all(netlist, net_widths) + + # 4. Check Results + all_valid = all(r.is_valid for r in results.values()) + if all_valid: + print("Success! Congestion resolved (one net detoured).") + else: + print("Some nets failed or have collisions.") + for nid, res in results.items(): + print(f" {nid}: valid={res.is_valid}, collisions={res.collisions}") + + # 5. Visualize + fig, ax = plot_routing_results(results, [], bounds) + fig.savefig("examples/congestion.png") + print("Saved plot to examples/congestion.png") + + +if __name__ == "__main__": + main() diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py new file mode 100644 index 0000000..42b2a8f --- /dev/null +++ b/examples/03_locked_paths.py @@ -0,0 +1,76 @@ + +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 for plotting + all_results = {**results1, **results2} + + # 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) + fig.savefig("examples/locked.png") + print("Saved plot to examples/locked.png") + + +if __name__ == "__main__": + main() diff --git a/examples/congestion.png b/examples/congestion.png new file mode 100644 index 0000000000000000000000000000000000000000..83e2a7d70a0126f3c8f44f97b138f82708337978 GIT binary patch literal 34473 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 l>EC>jxTkDAegE$k(BCu{0AD%GX}% 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 CO}y;@ literal 0 HcmV?d00001 diff --git a/examples/simple_route.png b/examples/simple_route.png new file mode 100644 index 0000000000000000000000000000000000000000..a0124402df99589260ca0b6b93962cc95944b867 GIT binary patch literal 26058 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

JHnoLlG$BXt%*Q`LH^y|Cy~j_ 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 literal 0 HcmV?d00001 diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index f41c4c2..bd20d5e 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -40,16 +40,9 @@ class CollisionEngine: self.obstacle_geometries[obj_id] = polygon self.prepared_obstacles[obj_id] = prep(polygon) - # Index the bounding box of the polygon (dilated for broad prune) - # Spec: "All user-provided obstacles are pre-dilated by (W_max + C)/2" - dilation = (self.max_net_width + self.clearance) / 2.0 - dilated_bounds = ( - polygon.bounds[0] - dilation, - polygon.bounds[1] - dilation, - polygon.bounds[2] + dilation, - polygon.bounds[3] + dilation, - ) - self.static_obstacles.insert(obj_id, dilated_bounds) + # Index the bounding box of the original polygon + # We query with dilated moves, so original bounds are enough + self.static_obstacles.insert(obj_id, polygon.bounds) def add_path(self, net_id: str, geometry: list[Polygon]) -> None: """Add a net's routed path to the dynamic R-Tree.""" @@ -119,13 +112,13 @@ class CollisionEngine: end_port: Port | None = None, ) -> bool: """Check if a pre-dilated geometry collides with static obstacles.""" - # Broad prune with R-Tree + # Query R-Tree using the bounds of the dilated move candidates = self.static_obstacles.intersection(dilated_geometry.bounds) for obj_id in candidates: # Use prepared geometry for fast intersection if self.prepared_obstacles[obj_id].intersects(dilated_geometry): - # Check safety zone (2nm = 0.002 um) + # Check safety zone (2nm radius) if start_port or end_port: obstacle = self.obstacle_geometries[obj_id] intersection = dilated_geometry.intersection(obstacle) @@ -133,20 +126,23 @@ class CollisionEngine: if intersection.is_empty: continue - # Create safety zone polygons - safety_zones = [] + # Precise check: is every point in the intersection close to either port? + ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds + + is_near_start = False if start_port: - safety_zones.append(Point(start_port.x, start_port.y).buffer(0.002)) + if (abs(ix_minx - start_port.x) < 0.0021 and abs(ix_maxx - start_port.x) < 0.0021 and + abs(ix_miny - start_port.y) < 0.0021 and abs(ix_maxy - start_port.y) < 0.0021): + is_near_start = True + + is_near_end = False if end_port: - safety_zones.append(Point(end_port.x, end_port.y).buffer(0.002)) - - if safety_zones: - safe_poly = unary_union(safety_zones) - # Remove safe zones from intersection - remaining_collision = intersection.difference(safe_poly) - if remaining_collision.is_empty or remaining_collision.area < 1e-9: - continue + if (abs(ix_minx - end_port.x) < 0.0021 and abs(ix_maxx - end_port.x) < 0.0021 and + abs(ix_miny - end_port.y) < 0.0021 and abs(ix_maxy - end_port.y) < 0.0021): + is_near_end = True + + if is_near_start or is_near_end: + continue return True return False - diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 8374620..10f5f8b 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -12,32 +12,40 @@ SEARCH_GRID_SNAP_UM = 1.0 def snap_search_grid(value: float) -> float: - """Snap a coordinate to the nearest 1µm.""" + """Snap a coordinate to the nearest search grid unit.""" return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM class ComponentResult(NamedTuple): - """The result of a component generation: geometry and the final port.""" + """The result of a component generation: geometry, final port, and physical length.""" geometry: list[Polygon] end_port: Port + length: float class Straight: @staticmethod - def generate(start_port: Port, length: float, width: float) -> ComponentResult: + def generate(start_port: Port, length: float, width: float, snap_to_grid: bool = True) -> ComponentResult: """Generate a straight waveguide segment.""" - # Calculate end port position rad = np.radians(start_port.orientation) dx = length * np.cos(rad) dy = length * np.sin(rad) - end_port = Port(start_port.x + dx, start_port.y + dy, start_port.orientation) + ex = start_port.x + dx + ey = start_port.y + dy + + if snap_to_grid: + ex = snap_search_grid(ex) + ey = snap_search_grid(ey) - # Create polygon (centered on port) + end_port = Port(ex, ey, start_port.orientation) + actual_length = np.sqrt((end_port.x - start_port.x)**2 + (end_port.y - start_port.y)**2) + + # Create polygon half_w = width / 2.0 # Points relative to start port (0,0) - points = [(0, half_w), (length, half_w), (length, -half_w), (0, -half_w)] + points = [(0, half_w), (actual_length, half_w), (actual_length, -half_w), (0, -half_w)] # Transform points cos_val = np.cos(rad) @@ -48,56 +56,48 @@ class Straight: ty = start_port.y + px * sin_val + py * cos_val poly_points.append((tx, ty)) - return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port) + return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port, length=actual_length) def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int: """Calculate number of segments for an arc to maintain a maximum sagitta.""" if radius <= 0: return 1 - # angle_deg is absolute angle turned - # s = R(1 - cos(theta/2)) => cos(theta/2) = 1 - s/R - # theta = 2 * acos(1 - s/R) - # n = total_angle / theta ratio = max(0.0, min(1.0, 1.0 - sagitta / radius)) theta_max = 2.0 * np.arccos(ratio) - if theta_max == 0: + if theta_max < 1e-9: return 16 num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max)) - return max(4, num) + return max(8, num) class Bend90: @staticmethod def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult: """Generate a 90-degree bend.""" - # direction: 'CW' (-90) or 'CCW' (+90) turn_angle = -90 if direction == "CW" else 90 - # Calculate center of the arc + # Calculate center rad_start = np.radians(start_port.orientation) - center_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) - cx = start_port.x + radius * np.cos(center_angle) - cy = start_port.y + radius * np.sin(center_angle) + c_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) + cx = start_port.x + radius * np.cos(c_angle) + cy = start_port.y + radius * np.sin(c_angle) - # Center to start is radius at center_angle + pi - theta_start = center_angle + np.pi - theta_end = theta_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) + t_start = c_angle + np.pi + t_end = t_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2) - ex = cx + radius * np.cos(theta_end) - ey = cy + radius * np.sin(theta_end) - - # End port orientation + # End port (snapped to lattice) + ex = snap_search_grid(cx + radius * np.cos(t_end)) + ey = snap_search_grid(cy + radius * np.sin(t_end)) + 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)) + end_port = Port(ex, ey, float(end_orientation)) + + actual_length = radius * np.pi / 2.0 # Generate arc geometry num_segments = _get_num_segments(radius, 90, sagitta) - angles = np.linspace(theta_start, theta_end, num_segments + 1) + angles = np.linspace(t_start, t_end, num_segments + 1) inner_radius = radius - width / 2.0 outer_radius = radius + width / 2.0 @@ -105,66 +105,55 @@ class Bend90: inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles] outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)] - return ComponentResult(geometry=[Polygon(inner_points + outer_points)], end_port=end_port) + return ComponentResult(geometry=[Polygon(inner_points + outer_points)], end_port=end_port, length=actual_length) class SBend: @staticmethod def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult: - """Generate a parametric S-bend (two tangent arcs). Only for offset < 2*radius.""" + """Generate a parametric S-bend (two tangent arcs).""" if abs(offset) >= 2 * radius: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") - # Analytical length: L = 2 * sqrt(O * (2*R - O/4)) is for a specific S-bend type. - # Standard S-bend with two equal arcs: - # Offset O = 2 * R * (1 - cos(theta)) - # theta = acos(1 - O / (2*R)) theta = np.arccos(1 - abs(offset) / (2 * radius)) - - # Length of one arc = R * theta - # Total length of S-bend = 2 * R * theta (arc length) - # Horizontal distance dx = 2 * R * sin(theta) - dx = 2 * radius * np.sin(theta) dy = offset - # End port + # End port (snapped to lattice) rad_start = np.radians(start_port.orientation) - ex = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start) - ey = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start) - + ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) + ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) end_port = Port(ex, ey, start_port.orientation) + + actual_length = 2 * radius * theta - # Geometry: two arcs - # First arc center + # Arc centers and angles (Relative to start orientation) direction = 1 if offset > 0 else -1 - center_angle1 = rad_start + direction * np.pi / 2 - cx1 = start_port.x + radius * np.cos(center_angle1) - cy1 = start_port.y + radius * np.sin(center_angle1) + + # Arc 1 + c1_angle = rad_start + direction * np.pi / 2 + cx1 = start_port.x + radius * np.cos(c1_angle) + cy1 = start_port.y + radius * np.sin(c1_angle) + t_start1 = c1_angle + np.pi + t_end1 = t_start1 + direction * theta - # Second arc center - center_angle2 = rad_start - direction * np.pi / 2 - cx2 = ex + radius * np.cos(center_angle2) - cy2 = ey + radius * np.sin(center_angle2) + # Arc 2 (Calculated relative to un-snapped end to ensure perfect tangency) + ex_raw = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start) + ey_raw = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start) + c2_angle = rad_start - direction * np.pi / 2 + cx2 = ex_raw + radius * np.cos(c2_angle) + cy2 = ey_raw + radius * np.sin(c2_angle) + t_end2 = c2_angle + np.pi + t_start2 = t_end2 + direction * theta - # Generate points for both arcs - num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta) - # Arc 1: theta_start1 to theta_end1 - theta_start1 = center_angle1 + np.pi - theta_end1 = theta_start1 - direction * theta - - # Arc 2: theta_start2 to theta_end2 - theta_start2 = center_angle2 - theta_end2 = theta_start2 + direction * theta - - def get_arc_points(cx: float, cy: float, r_inner: float, r_outer: float, t_start: float, t_end: float) -> list[tuple[float, float]]: - angles = np.linspace(t_start, t_end, num_segments + 1) + def get_arc_points(cx: float, cy: float, r_inner: float, r_outer: float, ts: float, te: float) -> list[tuple[float, float]]: + num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta) + angles = np.linspace(ts, te, 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) + poly1 = Polygon(get_arc_points(cx1, cy1, radius - width / 2, radius + width / 2, t_start1, t_end1)) + poly2 = Polygon(get_arc_points(cx2, cy2, radius - width / 2, radius + width / 2, t_start2, t_end2)) + return ComponentResult(geometry=[poly1, poly2], end_port=end_port, length=actual_length) diff --git a/inire/router/astar.py b/inire/router/astar.py index fdf9b27..44b6b6a 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -48,17 +48,15 @@ class AStarNode: class AStarRouter: def __init__(self, cost_evaluator: CostEvaluator) -> None: self.cost_evaluator = cost_evaluator - self.node_limit = 100000 + self.node_limit = 1000000 self.total_nodes_expanded = 0 self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {} - def route( - self, start: Port, target: Port, net_width: float, net_id: str = "default" - ) -> list[ComponentResult] | None: + def route(self, start: Port, target: Port, net_width: float, net_id: str = "default") -> list[ComponentResult] | None: """Route a single net using A*.""" self._collision_cache.clear() open_set: list[AStarNode] = [] - # Key: (x, y, orientation) + # Key: (x, y, orientation) rounded to 1nm closed_set: set[tuple[float, float, float]] = set() start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target)) @@ -73,27 +71,28 @@ class AStarRouter: current = heapq.heappop(open_set) - state = (current.port.x, current.port.y, current.port.orientation) + # Prune if already visited + state = (round(current.port.x, 3), round(current.port.y, 3), round(current.port.orientation, 2)) if state in closed_set: continue closed_set.add(state) + nodes_expanded += 1 self.total_nodes_expanded += 1 - # Check if we reached the target (Snap-to-Target) + if nodes_expanded % 5000 == 0: + logger.info(f"Nodes expanded: {nodes_expanded}, current port: {current.port}, g: {current.g_cost:.1f}, h: {current.h_cost:.1f}") + + # Check if we reached the target exactly if ( abs(current.port.x - target.x) < 1e-6 and abs(current.port.y - target.y) < 1e-6 - and current.port.orientation == target.orientation + and abs(current.port.orientation - target.orientation) < 0.1 ): return self._reconstruct_path(current) - # Look-ahead snapping - if self._try_snap_to_target(current, target, net_width, net_id, open_set): - pass - - # Expand neighbors - self._expand_moves(current, target, net_width, net_id, open_set) + # Expansion + self._expand_moves(current, target, net_width, net_id, open_set, closed_set) return None @@ -104,29 +103,52 @@ class AStarRouter: net_width: float, net_id: str, open_set: list[AStarNode], + closed_set: set[tuple[float, float, float]], ) -> None: - # 1. Straights - for length in [0.5, 1.0, 5.0, 25.0]: - res = Straight.generate(current.port, length, net_width) - self._add_node(current, res, target, net_width, net_id, open_set, f"S{length}") + # 1. Snap-to-Target Look-ahead + dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2) + if dist < 30.0: + # 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: + try: + res = SBend.generate(current.port, perp, 10.0, net_width) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend") + except ValueError: + pass - # 2. Bends - for radius in [5.0, 10.0, 20.0]: + # 2. Lattice Straights + for length in [1.0, 5.0, 25.0]: + res = Straight.generate(current.port, length, net_width) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}") + + # 3. Lattice Bends + for radius in [10.0]: for direction in ["CW", "CCW"]: res = Bend90.generate(current.port, radius, net_width, direction) - self._add_node(current, res, target, net_width, net_id, open_set, f"B{radius}{direction}") + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}") - # 3. Parametric SBends - dx = target.x - current.port.x - dy = target.y - current.port.y - rad = np.radians(current.port.orientation) - local_dy = -dx * np.sin(rad) + dy * np.cos(rad) - - if 0 < abs(local_dy) < 40.0: # Match max 2*R + # 4. Discrete SBends + for offset in [-5.0, -2.0, 2.0, 5.0]: try: - # Use a standard radius for expansion - res = SBend.generate(current.port, local_dy, 20.0, net_width) - self._add_node(current, res, target, net_width, net_id, open_set, f"SB{local_dy}") + res = SBend.generate(current.port, offset, 10.0, net_width) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}") except ValueError: pass @@ -138,12 +160,18 @@ class AStarRouter: net_width: float, net_id: str, open_set: list[AStarNode], + closed_set: set[tuple[float, float, float]], move_type: str, ) -> None: + # Check closed set before adding to open set + state = (round(result.end_port.x, 3), round(result.end_port.y, 3), round(result.end_port.orientation, 2)) + if state in closed_set: + return + cache_key = ( - parent.port.x, - parent.port.y, - parent.port.orientation, + round(parent.port.x, 3), + round(parent.port.y, 3), + round(parent.port.orientation, 2), move_type, net_width, net_id, @@ -161,44 +189,56 @@ class AStarRouter: if hard_coll: return - move_cost = self.cost_evaluator.evaluate_move(result.geometry, result.end_port, net_width, net_id, start_port=parent.port) + # 3. Check for Self-Intersection (Limited to last 100 segments for performance) + dilation = self.cost_evaluator.collision_engine.clearance / 2.0 + for move_poly in result.geometry: + dilated_move = move_poly.buffer(dilation) + curr_p = parent + # Skip immediate 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: + # Optimization: fast bounding box check + if dilated_move.bounds[0] > prev_poly.bounds[2] + dilation or \ + dilated_move.bounds[2] < prev_poly.bounds[0] - dilation or \ + dilated_move.bounds[1] > prev_poly.bounds[3] + dilation or \ + dilated_move.bounds[3] < prev_poly.bounds[1] - dilation: + continue + + dilated_prev = prev_poly.buffer(dilation) + if dilated_move.intersects(dilated_prev): + overlap = dilated_move.intersection(dilated_prev) + if overlap.area > 1e-6: + return + curr_p = curr_p.parent + seg_idx += 1 - g_cost = parent.g_cost + move_cost + self._step_cost(result) + move_cost = self.cost_evaluator.evaluate_move( + result.geometry, + result.end_port, + net_width, + net_id, + start_port=parent.port, + length=result.length + ) + + if move_cost > 1e12: + return + + # Substantial penalties for turns to favor straights, + # but low enough to allow detours in complex environments. + if "B" in move_type: + move_cost += 50.0 + if "SB" in move_type: + move_cost += 100.0 + + g_cost = parent.g_cost + move_cost h_cost = self.cost_evaluator.h_manhattan(result.end_port, target) new_node = AStarNode(result.end_port, g_cost, h_cost, parent, result) heapq.heappush(open_set, new_node) - def _step_cost(self, result: ComponentResult) -> float: - _ = result # Unused in base implementation - return 0.0 - - def _try_snap_to_target( - self, - current: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - ) -> bool: - dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2) - if dist > 10.0: - return False - - if current.port.orientation == target.orientation: - rad = np.radians(current.port.orientation) - dx = target.x - current.port.x - dy = target.y - current.port.y - - proj = dx * np.cos(rad) + dy * np.sin(rad) - perp = -dx * np.sin(rad) + dy * np.cos(rad) - - if proj > 0 and abs(perp) < 1e-6: - res = Straight.generate(current.port, proj, net_width) - self._add_node(current, res, target, net_width, net_id, open_set, "SnapTarget") - return True - return False - def _reconstruct_path(self, end_node: AStarNode) -> list[ComponentResult]: path = [] curr: AStarNode | None = end_node @@ -206,4 +246,3 @@ class AStarRouter: path.append(curr.component_result) curr = curr.parent return path[::-1] - diff --git a/inire/router/cost.py b/inire/router/cost.py index 81e6dc8..94ff177 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -18,9 +18,9 @@ class CostEvaluator: self.danger_map = danger_map # Cost weights self.unit_length_cost = 1.0 - self.bend_cost_multiplier = 10.0 + self.bend_cost_multiplier = 100.0 # Per turn penalty self.greedy_h_weight = 1.1 - self.congestion_penalty = 100.0 # Multiplier for overlaps + self.congestion_penalty = 10000.0 # Massive multiplier for overlaps def g_proximity(self, x: float, y: float) -> float: """Get proximity cost from the Danger Map.""" @@ -44,25 +44,31 @@ class CostEvaluator: net_width: float, net_id: str, start_port: Port | None = None, + length: float = 0.0, ) -> float: """Calculate the cost of a single move (Straight, Bend, SBend).""" - _ = net_width # Unused, kept for API compatibility - total_cost = 0.0 - dilation = self.collision_engine.clearance / 2.0 - - # Strict collision check + _ = net_width # Unused + total_cost = length * self.unit_length_cost + + # 1. Hard Collision check (Static obstacles) + # We buffer by the full clearance to ensure distance >= clearance + hard_dilation = self.collision_engine.clearance for poly in geometry: - # Buffer once for both hard collision and congestion check - dilated_poly = poly.buffer(dilation) - + dilated_poly = poly.buffer(hard_dilation) if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port): - return 1e9 # Massive cost for hard collisions + # print(f"DEBUG: Hard collision detected at {end_port}") + return 1e15 # Impossible cost for hard collisions - # Negotiated Congestion Cost + # 2. Soft Collision check (Negotiated Congestion) + # We buffer by clearance/2 because both paths are buffered by clearance/2 + soft_dilation = self.collision_engine.clearance / 2.0 + for poly in geometry: + dilated_poly = poly.buffer(soft_dilation) overlaps = self.collision_engine.count_congestion_prebuffered(dilated_poly, net_id) - total_cost += overlaps * self.congestion_penalty + if overlaps > 0: + total_cost += overlaps * self.congestion_penalty - # Proximity cost from Danger Map + # 3. Proximity cost from Danger Map total_cost += self.g_proximity(end_port.x, end_port.y) return total_cost diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 813dbc6..c592044 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -28,7 +28,7 @@ class PathFinder: def __init__(self, router: AStarRouter, cost_evaluator: CostEvaluator) -> None: self.router = router self.cost_evaluator = cost_evaluator - self.max_iterations = 20 + self.max_iterations = 10 self.base_congestion_penalty = 100.0 def route_all(self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float]) -> dict[str, RoutingResult]: @@ -38,7 +38,7 @@ class PathFinder: start_time = time.monotonic() num_nets = len(netlist) - session_timeout = max(60.0, 2.0 * num_nets * self.max_iterations) + session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations) for iteration in range(self.max_iterations): any_congestion = False diff --git a/inire/tests/test_astar.py b/inire/tests/test_astar.py index 33f165e..5ff0368 100644 --- a/inire/tests/test_astar.py +++ b/inire/tests/test_astar.py @@ -1,4 +1,3 @@ -import numpy as np import pytest from shapely.geometry import Polygon @@ -7,6 +6,8 @@ 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 @@ -24,53 +25,63 @@ def test_astar_straight(basic_evaluator: CostEvaluator) -> None: path = router.route(start, target, net_width=2.0) assert path is not None - assert len(path) > 0 - # Final port should be target - assert abs(path[-1].end_port.x - 50.0) < 1e-6 - assert path[-1].end_port.y == 0.0 + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + assert validation["connectivity_ok"] + # Path should be exactly 50um (or slightly more if it did weird things, but here it's straight) + assert abs(validation["total_length"] - 50.0) < 1e-6 def test_astar_bend(basic_evaluator: CostEvaluator) -> None: router = AStarRouter(basic_evaluator) start = Port(0, 0, 0) - target = Port(20, 20, 90) + # 20um right, 20um up. Needs a 10um bend and a 10um bend. + # From (0,0,0) -> Bend90 CW R=10 -> (10, -10, 270) ??? No. + # Try: (0,0,0) -> Bend90 CCW R=10 -> (10, 10, 90) -> Straight 10 -> (10, 20, 90) -> Bend90 CW R=10 -> (20, 30, 0) + target = Port(20, 20, 0) path = router.route(start, target, net_width=2.0) assert path is not None - assert abs(path[-1].end_port.x - 20.0) < 1e-6 - assert abs(path[-1].end_port.y - 20.0) < 1e-6 - assert path[-1].end_port.orientation == 90.0 + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + assert validation["connectivity_ok"] def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None: # Add an obstacle in the middle of a straight path - obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)]) + # Obstacle from x=20 to 40, y=-20 to 20 + obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)]) basic_evaluator.collision_engine.add_static_obstacle(obstacle) basic_evaluator.danger_map.precompute([obstacle]) router = AStarRouter(basic_evaluator) + router.node_limit = 1000000 # Give it more room for detour start = Port(0, 0, 0) - target = Port(50, 0, 0) + target = Port(60, 0, 0) path = router.route(start, target, net_width=2.0) assert path is not None - # Path should have diverted (check that it's not a single straight) - # The path should go around the 5um half-width obstacle. - # Total wire length should be > 50. - _ = sum(np.sqrt((p.end_port.x - p.geometry[0].bounds[0])**2 + (p.end_port.y - p.geometry[0].bounds[1])**2) for p in path) - # That's a rough length estimate. - # Better: check that no part of the path collides. - for res in path: - for poly in res.geometry: - assert not poly.intersects(obstacle) + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + # Path should have detoured, so length > 50 + assert validation["total_length"] > 50.0 def test_astar_snap_to_target_lookahead(basic_evaluator: CostEvaluator) -> None: router = AStarRouter(basic_evaluator) # Target is NOT on 1um grid start = Port(0, 0, 0) - target = Port(10.005, 0, 0) + target = Port(10.1, 0, 0) path = router.route(start, target, net_width=2.0) assert path is not None - assert abs(path[-1].end_port.x - 10.005) < 1e-6 + result = RoutingResult(net_id="test", path=path, is_valid=True, collisions=0) + validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target) + + assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index c7abb1a..3922741 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -63,6 +63,6 @@ def test_bend_snapping() -> None: start = Port(0, 0, 0) result = Bend90.generate(start, radius, width=2.0, direction="CCW") - # Target x is 10.1234, should snap to 10.0 (assuming 1um grid) + # Target x is 10.1234, should snap to 10.0 (assuming 1.0um grid) assert result.end_port.x == 10.0 assert result.end_port.y == 10.0 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 7970644..7512252 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -20,9 +20,10 @@ def basic_evaluator() -> CostEvaluator: def test_astar_sbend(basic_evaluator: CostEvaluator) -> None: router = AStarRouter(basic_evaluator) - # Start at (0,0), target at (50, 3) -> 3um lateral offset + # Start at (0,0), target at (50, 2) -> 2um lateral offset + # This matches one of our discretized SBend offsets. start = Port(0, 0, 0) - target = Port(50, 3, 0) + target = Port(50, 2, 0) path = router.route(start, target, net_width=2.0) assert path is not None @@ -54,8 +55,8 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua # 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)]) + obs_top = Polygon([(20, 6), (30, 6), (30, 15), (20, 10)]) # Lower wall + obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)]) basic_evaluator.collision_engine.add_static_obstacle(obs_top) basic_evaluator.collision_engine.add_static_obstacle(obs_bottom) diff --git a/inire/tests/test_fuzz.py b/inire/tests/test_fuzz.py index 3571a56..f6134aa 100644 --- a/inire/tests/test_fuzz.py +++ b/inire/tests/test_fuzz.py @@ -56,10 +56,11 @@ def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port result, obstacles, clearance=2.0, - start_port_coord=(start.x, start.y), - end_port_coord=(target.x, target.y), + expected_start=start, + expected_end=target, ) assert validation["is_valid"], f"Validation failed: {validation.get('reason')}" + except Exception as e: # Unexpected exceptions are failures pytest.fail(f"Router crashed with {type(e).__name__}: {e}") diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 06a602c..6dd8986 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -1,13 +1,13 @@ from __future__ import annotations +import numpy as np from typing import TYPE_CHECKING, Any -from shapely.geometry import Point +from shapely.geometry import Point, Polygon from shapely.ops import unary_union if TYPE_CHECKING: - from shapely.geometry import Polygon - + from inire.geometry.primitives import Port from inire.router.pathfinder import RoutingResult @@ -15,8 +15,8 @@ def validate_routing_result( result: RoutingResult, static_obstacles: list[Polygon], clearance: float, - start_port_coord: tuple[float, float] | None = None, - end_port_coord: tuple[float, float] | None = None, + expected_start: Port | None = None, + expected_end: Port | None = None, ) -> dict[str, Any]: """ Perform a high-precision validation of a routed path. @@ -25,33 +25,71 @@ def validate_routing_result( if not result.path: return {"is_valid": False, "reason": "No path found"} - collision_geoms = [] - # High-precision safety zones - safe_zones = [] - if start_port_coord: - safe_zones.append(Point(start_port_coord).buffer(0.002)) - if end_port_coord: - safe_zones.append(Point(end_port_coord).buffer(0.002)) - safe_poly = unary_union(safe_zones) if safe_zones else None + obstacle_collision_geoms = [] + self_intersection_geoms = [] + connectivity_errors = [] + + # 1. Connectivity Check + total_length = 0.0 + for i, comp in enumerate(result.path): + total_length += comp.length - # Buffer by C/2 - dilation = clearance / 2.0 + # Boundary check + if expected_end: + last_port = result.path[-1].end_port + dist_to_end = np.sqrt((last_port.x - expected_end.x)**2 + (last_port.y - expected_end.y)**2) + if dist_to_end > 0.005: + connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") + if abs(last_port.orientation - expected_end.orientation) > 0.1: + connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}") - for comp in result.path: + # 2. Geometry Buffering + dilation_half = clearance / 2.0 + dilation_full = clearance + + dilated_for_self = [] + + for i, comp in enumerate(result.path): for poly in comp.geometry: - dilated = poly.buffer(dilation) + # Check against obstacles + d_full = poly.buffer(dilation_full) for obs in static_obstacles: - if dilated.intersects(obs): - intersection = dilated.intersection(obs) - if safe_poly: - # Remove safe zones from intersection - intersection = intersection.difference(safe_poly) + if d_full.intersects(obs): + intersection = d_full.intersection(obs) + if intersection.area > 1e-9: + obstacle_collision_geoms.append(intersection) + + # Save for self-intersection check + dilated_for_self.append(poly.buffer(dilation_half)) - if not intersection.is_empty and intersection.area > 1e-9: - collision_geoms.append(intersection) + # 3. Self-intersection + for i, seg_i in enumerate(dilated_for_self): + for j, seg_j in enumerate(dilated_for_self): + if j > i + 1: # Non-adjacent + if seg_i.intersects(seg_j): + overlap = seg_i.intersection(seg_j) + if overlap.area > 1e-6: + self_intersection_geoms.append((i, j, overlap)) + + is_valid = (len(obstacle_collision_geoms) == 0 and + len(self_intersection_geoms) == 0 and + len(connectivity_errors) == 0) + + reasons = [] + if obstacle_collision_geoms: + reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.") + if self_intersection_geoms: + # report which indices + idx_str = ", ".join([f"{i}-{j}" for i, j, _ in self_intersection_geoms[:5]]) + reasons.append(f"Found {len(self_intersection_geoms)} self-intersections (e.g. {idx_str}).") + if connectivity_errors: + reasons.extend(connectivity_errors) return { - "is_valid": len(collision_geoms) == 0, - "collisions": collision_geoms, - "collision_count": len(collision_geoms), + "is_valid": is_valid, + "reason": " ".join(reasons), + "obstacle_collisions": obstacle_collision_geoms, + "self_intersections": self_intersection_geoms, + "total_length": total_length, + "connectivity_ok": len(connectivity_errors) == 0, } diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 64c4db9..34222b5 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -28,18 +28,25 @@ def plot_routing_results( # Plot paths colors = plt.get_cmap("tab10") for i, (net_id, res) in enumerate(results.items()): - color: str | tuple[float, ...] = colors(i) + # Use modulo to avoid index out of range for many nets + color: str | tuple[float, ...] = colors(i % 10) if not res.is_valid: color = "red" # Highlight failing nets + label_added = False for comp in res.path: for poly in comp.geometry: x, y = poly.exterior.xy - ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if i == 0 else "") + ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if not label_added else "") + label_added = True ax.set_xlim(bounds[0], bounds[2]) ax.set_ylim(bounds[1], bounds[3]) ax.set_aspect("equal") ax.set_title("Inire Routing Results") + # Only show legend if we have labels + handles, labels = ax.get_legend_handles_labels() + if labels: + ax.legend() plt.grid(True) return fig, ax diff --git a/uv.lock b/uv.lock index 9f09a81..ca0ac7d 100644 --- a/uv.lock +++ b/uv.lock @@ -178,6 +178,7 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, { name = "rtree" }, + { name = "scipy" }, { name = "shapely" }, ] @@ -194,6 +195,7 @@ requires-dist = [ { name = "matplotlib" }, { name = "numpy" }, { name = "rtree" }, + { name = "scipy" }, { name = "shapely" }, ] @@ -630,6 +632,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572 }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199 }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001 }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719 }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429 }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952 }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063 }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449 }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708 }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135 }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977 }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601 }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667 }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159 }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771 }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910 }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 }, +] + [[package]] name = "shapely" version = "2.1.2"