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 0000000..fcf1e1d Binary files /dev/null and b/examples/sbends_radii.png differ 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."""