add s-bend example and surface config

This commit is contained in:
Jan Petykiewicz 2026-03-08 20:18:53 -07:00
commit 18b2f83a7b
7 changed files with 212 additions and 39 deletions

View file

@ -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()

BIN
examples/sbends_radii.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -16,9 +16,10 @@ if TYPE_CHECKING:
class CollisionEngine: class CollisionEngine:
"""Manages spatial queries for collision detection.""" """Manages spatial queries for collision detection."""
def __init__(self, clearance: float, max_net_width: float = 2.0) -> None: def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None:
self.clearance = clearance self.clearance = clearance
self.max_net_width = max_net_width self.max_net_width = max_net_width
self.safety_zone_radius = safety_zone_radius
self.static_obstacles = rtree.index.Index() self.static_obstacles = rtree.index.Index()
# To store geometries for precise checks # To store geometries for precise checks
self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon
@ -131,14 +132,14 @@ class CollisionEngine:
is_near_start = False is_near_start = False
if start_port: if start_port:
if (abs(ix_minx - start_port.x) < 0.0021 and abs(ix_maxx - start_port.x) < 0.0021 and 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) < 0.0021 and abs(ix_maxy - start_port.y) < 0.0021): 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_start = True
is_near_end = False is_near_end = False
if end_port: if end_port:
if (abs(ix_minx - end_port.x) < 0.0021 and abs(ix_maxx - end_port.x) < 0.0021 and 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) < 0.0021 and abs(ix_maxy - end_port.y) < 0.0021): 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 is_near_end = True
if is_near_start or is_near_end: if is_near_start or is_near_end:

View file

@ -34,7 +34,7 @@ class Straight:
ex = start_port.x + dx ex = start_port.x + dx
ey = start_port.y + dy ey = start_port.y + dy
if snap_to_grid: if snap_to_grid:
ex = snap_search_grid(ex) ex = snap_search_grid(ex)
ey = snap_search_grid(ey) ey = snap_search_grid(ey)
@ -89,10 +89,10 @@ class Bend90:
# End port (snapped to lattice) # End port (snapped to lattice)
ex = snap_search_grid(cx + radius * np.cos(t_end)) ex = snap_search_grid(cx + radius * np.cos(t_end))
ey = snap_search_grid(cy + radius * np.sin(t_end)) ey = snap_search_grid(cy + radius * np.sin(t_end))
end_orientation = (start_port.orientation + turn_angle) % 360 end_orientation = (start_port.orientation + turn_angle) % 360
end_port = Port(ex, ey, float(end_orientation)) end_port = Port(ex, ey, float(end_orientation))
actual_length = radius * np.pi / 2.0 actual_length = radius * np.pi / 2.0
# Generate arc geometry # 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)) 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)) 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) end_port = Port(ex, ey, start_port.orientation)
actual_length = 2 * radius * theta actual_length = 2 * radius * theta
# Arc centers and angles (Relative to start orientation) # Arc centers and angles (Relative to start orientation)
direction = 1 if offset > 0 else -1 direction = 1 if offset > 0 else -1
# Arc 1 # Arc 1
c1_angle = rad_start + direction * np.pi / 2 c1_angle = rad_start + direction * np.pi / 2
cx1 = start_port.x + radius * np.cos(c1_angle) cx1 = start_port.x + radius * np.cos(c1_angle)

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
import numpy as np import numpy as np
from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.components import Bend90, SBend, Straight
from inire.router.config import RouterConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -46,9 +47,46 @@ class AStarNode:
class AStarRouter: 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.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.total_nodes_expanded = 0
self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {} self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {}
@ -107,7 +145,7 @@ class AStarRouter:
) -> None: ) -> None:
# 1. Snap-to-Target Look-ahead # 1. Snap-to-Target Look-ahead
dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2) 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 # A. Try straight exact reach
if abs(current.port.orientation - target.orientation) < 0.1: if abs(current.port.orientation - target.orientation) < 0.1:
rad = np.radians(current.port.orientation) rad = np.radians(current.port.orientation)
@ -127,30 +165,37 @@ class AStarRouter:
proj = dx * np.cos(rad) + dy * np.sin(rad) proj = dx * np.cos(rad) + dy * np.sin(rad)
perp = -dx * np.sin(rad) + dy * np.cos(rad) perp = -dx * np.sin(rad) + dy * np.cos(rad)
if proj > 0 and 0.5 <= abs(perp) < 20.0: if proj > 0 and 0.5 <= abs(perp) < 20.0:
try: for radius in self.config.sbend_radii:
res = SBend.generate(current.port, perp, 10.0, net_width) try:
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend") res = SBend.generate(current.port, perp, radius, net_width)
except ValueError: self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend")
pass except ValueError:
pass
# 2. Lattice Straights # 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) res = Straight.generate(current.port, length, net_width)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}") self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"S{length}")
# 3. Lattice Bends # 3. Lattice Bends
for radius in [10.0]: for radius in self.config.bend_radii:
for direction in ["CW", "CCW"]: for direction in ["CW", "CCW"]:
res = Bend90.generate(current.port, radius, net_width, direction) 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}") self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}")
# 4. Discrete SBends # 4. Discrete SBends
for offset in [-5.0, -2.0, 2.0, 5.0]: for offset in self.config.sbend_offsets:
try: for radius in self.config.sbend_radii:
res = SBend.generate(current.port, offset, 10.0, net_width) try:
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}") res = SBend.generate(current.port, offset, radius, net_width)
except ValueError: self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}")
pass except ValueError:
pass
def _add_node( def _add_node(
self, self,
@ -194,12 +239,10 @@ class AStarRouter:
for move_poly in result.geometry: for move_poly in result.geometry:
dilated_move = move_poly.buffer(dilation) dilated_move = move_poly.buffer(dilation)
curr_p = parent curr_p = parent
# Skip immediate parent
seg_idx = 0 seg_idx = 0
while curr_p and curr_p.component_result and seg_idx < 100: while curr_p and curr_p.component_result and seg_idx < 100:
if seg_idx > 0: if seg_idx > 0:
for prev_poly in curr_p.component_result.geometry: 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 \ if dilated_move.bounds[0] > prev_poly.bounds[2] + dilation or \
dilated_move.bounds[2] < prev_poly.bounds[0] - 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[1] > prev_poly.bounds[3] + dilation or \
@ -226,12 +269,10 @@ class AStarRouter:
if move_cost > 1e12: if move_cost > 1e12:
return return
# Substantial penalties for turns to favor straights,
# but low enough to allow detours in complex environments.
if "B" in move_type: if "B" in move_type:
move_cost += 50.0 move_cost += self.config.bend_penalty
if "SB" in move_type: if "SB" in move_type:
move_cost += 100.0 move_cost += self.config.sbend_penalty
g_cost = parent.g_cost + move_cost g_cost = parent.g_cost + move_cost
h_cost = self.cost_evaluator.h_manhattan(result.end_port, target) h_cost = self.cost_evaluator.h_manhattan(result.end_port, target)

26
inire/router/config.py Normal file
View file

@ -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

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.router.config import CostConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
@ -11,16 +13,38 @@ if TYPE_CHECKING:
class CostEvaluator: 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.collision_engine = collision_engine
self.danger_map = danger_map self.danger_map = danger_map
# Cost weights self.config = CostConfig(
self.unit_length_cost = 1.0 unit_length_cost=unit_length_cost,
self.bend_cost_multiplier = 100.0 # Per turn penalty greedy_h_weight=greedy_h_weight,
self.greedy_h_weight = 1.1 congestion_penalty=congestion_penalty,
self.congestion_penalty = 10000.0 # Massive multiplier for overlaps )
# Use config values
self.unit_length_cost = self.config.unit_length_cost
self.greedy_h_weight = self.config.greedy_h_weight
self.congestion_penalty = self.config.congestion_penalty
def g_proximity(self, x: float, y: float) -> float: def g_proximity(self, x: float, y: float) -> float:
"""Get proximity cost from the Danger Map.""" """Get proximity cost from the Danger Map."""