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 0000000..83e2a7d Binary files /dev/null and b/examples/congestion.png differ diff --git a/examples/locked.png b/examples/locked.png new file mode 100644 index 0000000..7701636 Binary files /dev/null and b/examples/locked.png differ diff --git a/examples/simple_route.png b/examples/simple_route.png new file mode 100644 index 0000000..a012440 Binary files /dev/null and b/examples/simple_route.png differ 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"