From 18b2f83a7b49a5272972aee8357fecddd739c086 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 8 Mar 2026 20:18:53 -0700 Subject: [PATCH] add s-bend example and surface config --- examples/04_sbends_and_radii.py | 81 ++++++++++++++++++++++++++++++ examples/sbends_radii.png | Bin 0 -> 35578 bytes inire/geometry/collision.py | 11 +++-- inire/geometry/components.py | 10 ++-- inire/router/astar.py | 85 +++++++++++++++++++++++--------- inire/router/config.py | 26 ++++++++++ inire/router/cost.py | 38 +++++++++++--- 7 files changed, 212 insertions(+), 39 deletions(-) create mode 100644 examples/04_sbends_and_radii.py create mode 100644 examples/sbends_radii.png create mode 100644 inire/router/config.py diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py new file mode 100644 index 0000000..cdf8826 --- /dev/null +++ b/examples/04_sbends_and_radii.py @@ -0,0 +1,81 @@ +from shapely.geometry import Polygon + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.astar import AStarRouter +from inire.router.config import CostConfig, RouterConfig +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.pathfinder import PathFinder +from inire.utils.visualization import plot_routing_results + + +def main() -> None: + print("Running Example 04: S-Bends and Multiple Radii...") + + # 1. Setup Environment + bounds = (0, 0, 150, 100) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + + # Create obstacles that force S-bends and turns + # Obstacle 1: Forces a vertical jog (S-bend) + obs1 = Polygon([(40, 20), (60, 20), (60, 60), (40, 60)]) + + # Obstacle 2: Forces a large radius turn + obs2 = Polygon([(80, 0), (100, 0), (100, 40), (80, 40)]) + + obstacles = [obs1, obs2] + for obs in obstacles: + engine.add_static_obstacle(obs) + + danger_map.precompute(obstacles) + + # 2. Configure Router with custom parameters (Directly via constructor) + evaluator = CostEvaluator( + engine, + danger_map, + unit_length_cost=1.0, + greedy_h_weight=1.2, + ) + + router = AStarRouter( + evaluator, + node_limit=500000, + bend_radii=[10.0, 30.0], # Allow standard and large bends + sbend_offsets=[-10.0, -5.0, 5.0, 10.0], # Allow larger S-bend offsets + sbend_radii=[20.0, 50.0], # Large S-bends + bend_penalty=10.0, # Lower penalty to encourage using the right bend + ) + + pf = PathFinder(router, evaluator) + + # 3. Define Netlist + # Net 1: Needs to S-bend around obs1 (gap at y=60-100? No, obs1 is y=20-60). + # Start at (10, 40), End at (140, 40). + # Obstacle 1 blocks 40-60. Net must go above or below. + # Obstacle 2 blocks 80-100 x 0-40. + + # Let's force a path that requires a large bend. + netlist = { + "large_bend_net": (Port(10, 10, 0), Port(140, 80, 0)), + "sbend_net": (Port(10, 50, 0), Port(70, 70, 0)), + } + net_widths = {"large_bend_net": 2.0, "sbend_net": 2.0} + + # 4. Route + results = pf.route_all(netlist, net_widths) + + # 5. Check Results + for nid, res in results.items(): + status = "Success" if res.is_valid else "Failed" + print(f"{nid}: {status}, collisions={res.collisions}") + + # 6. Visualize + fig, ax = plot_routing_results(results, obstacles, bounds) + fig.savefig("examples/sbends_radii.png") + print("Saved plot to examples/sbends_radii.png") + + +if __name__ == "__main__": + main() diff --git a/examples/sbends_radii.png b/examples/sbends_radii.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf1e1def8cb922a774016f79054a9c719c4249e GIT binary patch literal 35578 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

E`Wpa038$pq3#=#oqYinV}W!~ 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 literal 0 HcmV?d00001 diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index bd20d5e..1faf6e2 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -16,9 +16,10 @@ if TYPE_CHECKING: class CollisionEngine: """Manages spatial queries for collision detection.""" - def __init__(self, clearance: float, max_net_width: float = 2.0) -> None: + def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None: self.clearance = clearance self.max_net_width = max_net_width + self.safety_zone_radius = safety_zone_radius self.static_obstacles = rtree.index.Index() # To store geometries for precise checks self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon @@ -131,14 +132,14 @@ class CollisionEngine: is_near_start = False if start_port: - 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): + if (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and + abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius): is_near_start = True is_near_end = False if end_port: - 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): + if (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and + abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius): is_near_end = True if is_near_start or is_near_end: diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 10f5f8b..9dbbcff 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -34,7 +34,7 @@ class Straight: ex = start_port.x + dx ey = start_port.y + dy - + if snap_to_grid: ex = snap_search_grid(ex) ey = snap_search_grid(ey) @@ -89,10 +89,10 @@ class Bend90: # 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 end_port = Port(ex, ey, float(end_orientation)) - + actual_length = radius * np.pi / 2.0 # Generate arc geometry @@ -124,12 +124,12 @@ class SBend: 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 # Arc centers and angles (Relative to start orientation) direction = 1 if offset > 0 else -1 - + # Arc 1 c1_angle = rad_start + direction * np.pi / 2 cx1 = start_port.x + radius * np.cos(c1_angle) diff --git a/inire/router/astar.py b/inire/router/astar.py index 44b6b6a..b4ff307 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING import numpy as np from inire.geometry.components import Bend90, SBend, Straight +from inire.router.config import RouterConfig if TYPE_CHECKING: from inire.geometry.components import ComponentResult @@ -46,9 +47,46 @@ class AStarNode: class AStarRouter: - def __init__(self, cost_evaluator: CostEvaluator) -> None: + """Hybrid State-Lattice A* Router.""" + + def __init__( + self, + cost_evaluator: CostEvaluator, + node_limit: int = 1000000, + straight_lengths: list[float] | None = None, + bend_radii: list[float] | None = None, + sbend_offsets: list[float] | None = None, + sbend_radii: list[float] | None = None, + snap_to_target_dist: float = 20.0, + bend_penalty: float = 50.0, + sbend_penalty: float = 100.0, + ) -> None: + """ + Initialize the A* Router. + + Args: + cost_evaluator: The evaluator for path and proximity costs. + node_limit: Maximum number of nodes to expand before failing. + straight_lengths: List of lengths for straight move expansion. + bend_radii: List of radii for 90-degree bend moves. + sbend_offsets: List of lateral offsets for S-bend moves. + sbend_radii: List of radii for S-bend moves. + snap_to_target_dist: Distance threshold for lookahead snapping. + bend_penalty: Flat cost penalty for each 90-degree bend. + sbend_penalty: Flat cost penalty for each S-bend. + """ self.cost_evaluator = cost_evaluator - self.node_limit = 1000000 + self.config = RouterConfig( + node_limit=node_limit, + straight_lengths=straight_lengths if straight_lengths is not None else [1.0, 5.0, 25.0], + bend_radii=bend_radii if bend_radii is not None else [10.0], + sbend_offsets=sbend_offsets if sbend_offsets is not None else [-5.0, -2.0, 2.0, 5.0], + sbend_radii=sbend_radii if sbend_radii is not None else [10.0], + snap_to_target_dist=snap_to_target_dist, + bend_penalty=bend_penalty, + sbend_penalty=sbend_penalty, + ) + self.node_limit = self.config.node_limit self.total_nodes_expanded = 0 self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {} @@ -107,7 +145,7 @@ class AStarRouter: ) -> None: # 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: + if dist < self.config.snap_to_target_dist: # A. Try straight exact reach if abs(current.port.orientation - target.orientation) < 0.1: rad = np.radians(current.port.orientation) @@ -127,30 +165,37 @@ class AStarRouter: 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 + for radius in self.config.sbend_radii: + try: + res = SBend.generate(current.port, perp, radius, net_width) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend") + except ValueError: + pass # 2. Lattice Straights - for length in [1.0, 5.0, 25.0]: + lengths = self.config.straight_lengths + if dist < 5.0: + fine_steps = [0.1, 0.5] + lengths = sorted(list(set(lengths + fine_steps))) + + for length in lengths: res = Straight.generate(current.port, length, net_width) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}") # 3. Lattice Bends - for radius in [10.0]: + for radius in self.config.bend_radii: for direction in ["CW", "CCW"]: res = Bend90.generate(current.port, radius, net_width, direction) self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}") # 4. Discrete SBends - for offset in [-5.0, -2.0, 2.0, 5.0]: - try: - 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 + for offset in self.config.sbend_offsets: + for radius in self.config.sbend_radii: + try: + res = SBend.generate(current.port, offset, radius, net_width) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}") + except ValueError: + pass def _add_node( self, @@ -194,12 +239,10 @@ class AStarRouter: 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 \ @@ -226,12 +269,10 @@ class AStarRouter: 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 + move_cost += self.config.bend_penalty if "SB" in move_type: - move_cost += 100.0 + move_cost += self.config.sbend_penalty g_cost = parent.g_cost + move_cost h_cost = self.cost_evaluator.h_manhattan(result.end_port, target) diff --git a/inire/router/config.py b/inire/router/config.py new file mode 100644 index 0000000..f5e0529 --- /dev/null +++ b/inire/router/config.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class RouterConfig: + """Configuration parameters for the A* Router.""" + + node_limit: int = 500000 + straight_lengths: list[float] = field(default_factory=lambda: [1.0, 5.0, 25.0]) + bend_radii: list[float] = field(default_factory=lambda: [10.0]) + sbend_offsets: list[float] = field(default_factory=lambda: [-5.0, -2.0, 2.0, 5.0]) + sbend_radii: list[float] = field(default_factory=lambda: [10.0]) + snap_to_target_dist: float = 20.0 + bend_penalty: float = 50.0 + sbend_penalty: float = 100.0 + + +@dataclass +class CostConfig: + """Configuration parameters for the Cost Evaluator.""" + + unit_length_cost: float = 1.0 + greedy_h_weight: float = 1.1 + congestion_penalty: float = 10000.0 diff --git a/inire/router/cost.py b/inire/router/cost.py index 94ff177..f443879 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +from inire.router.config import CostConfig + if TYPE_CHECKING: from shapely.geometry import Polygon @@ -11,16 +13,38 @@ if TYPE_CHECKING: class CostEvaluator: - """Calculates total cost f(n) = g(n) + h(n).""" + """Calculates total path and proximity costs.""" - def __init__(self, collision_engine: CollisionEngine, danger_map: DangerMap) -> None: + def __init__( + self, + collision_engine: CollisionEngine, + danger_map: DangerMap, + unit_length_cost: float = 1.0, + greedy_h_weight: float = 1.1, + congestion_penalty: float = 10000.0, + ) -> None: + """ + Initialize the Cost Evaluator. + + Args: + collision_engine: The engine for intersection checks. + danger_map: Pre-computed grid for heuristic proximity costs. + unit_length_cost: Cost multiplier per micrometer of path length. + greedy_h_weight: Heuristic weighting (A* greedy factor). + congestion_penalty: Multiplier for path overlaps in negotiated congestion. + """ self.collision_engine = collision_engine self.danger_map = danger_map - # Cost weights - self.unit_length_cost = 1.0 - self.bend_cost_multiplier = 100.0 # Per turn penalty - self.greedy_h_weight = 1.1 - self.congestion_penalty = 10000.0 # Massive multiplier for overlaps + self.config = CostConfig( + unit_length_cost=unit_length_cost, + greedy_h_weight=greedy_h_weight, + congestion_penalty=congestion_penalty, + ) + + # Use config values + self.unit_length_cost = self.config.unit_length_cost + self.greedy_h_weight = self.config.greedy_h_weight + self.congestion_penalty = self.config.congestion_penalty def g_proximity(self, x: float, y: float) -> float: """Get proximity cost from the Danger Map."""