more s-bend fixes and add docs

This commit is contained in:
jan 2026-03-08 22:02:07 -07:00
commit 41a2d9f058
10 changed files with 249 additions and 116 deletions

View file

@ -1,9 +1,10 @@
from __future__ import annotations
from typing import NamedTuple
from typing import NamedTuple, Literal, Union
import numpy as np
from shapely.geometry import Polygon
from shapely.geometry import Polygon, box
from shapely.ops import unary_union
from .primitives import Port
@ -34,7 +35,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)
@ -71,46 +72,85 @@ def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) ->
return max(8, num)
def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start: float, t_end: float, sagitta: float = 0.01) -> list[Polygon]:
"""Helper to generate arc-shaped polygons."""
num_segments = _get_num_segments(radius, float(np.degrees(abs(t_end - t_start))), sagitta)
angles = np.linspace(t_start, t_end, num_segments + 1)
inner_radius = radius - width / 2.0
outer_radius = radius + width / 2.0
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 [Polygon(inner_points + outer_points)]
def _apply_collision_model(
arc_poly: Polygon,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon,
radius: float,
clip_margin: float = 10.0
) -> list[Polygon]:
"""Applies the specified collision model to an arc geometry."""
if isinstance(collision_type, Polygon):
return [collision_type]
if collision_type == "arc":
return [arc_poly]
# Get bounding box
minx, miny, maxx, maxy = arc_poly.bounds
bbox = box(minx, miny, maxx, maxy)
if collision_type == "bbox":
return [bbox]
if collision_type == "clipped_bbox":
safe_zone = arc_poly.buffer(clip_margin)
return [bbox.intersection(safe_zone)]
return [arc_poly]
class Bend90:
@staticmethod
def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult:
def generate(
start_port: Port,
radius: float,
width: float,
direction: str = "CW",
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0
) -> ComponentResult:
"""Generate a 90-degree bend."""
turn_angle = -90 if direction == "CW" else 90
# Calculate center
rad_start = np.radians(start_port.orientation)
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)
t_start = c_angle + np.pi
t_end = t_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
# 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_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360))
arc_polys = _get_arc_polygons(cx, cy, radius, width, t_start, t_end, sagitta)
collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin)
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
num_segments = _get_num_segments(radius, 90, sagitta)
angles = np.linspace(t_start, t_end, num_segments + 1)
inner_radius = radius - width / 2.0
outer_radius = radius + width / 2.0
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, length=actual_length)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0)
class SBend:
@staticmethod
def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult:
def generate(
start_port: Port,
offset: float,
radius: float,
width: float,
sagitta: float = 0.01,
collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc",
clip_margin: float = 10.0
) -> ComponentResult:
"""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}")
@ -118,42 +158,28 @@ class SBend:
theta = np.arccos(1 - abs(offset) / (2 * radius))
dx = 2 * radius * np.sin(theta)
dy = offset
# End port (snapped to lattice)
rad_start = np.radians(start_port.orientation)
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)
cy1 = start_port.y + radius * np.sin(c1_angle)
t_start1 = c1_angle + np.pi
t_end1 = t_start1 + direction * theta
ts1, te1 = c1_angle + np.pi, c1_angle + np.pi + direction * theta
# 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
te2 = c2_angle + np.pi
ts2 = te2 + direction * theta
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, 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)
arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0]
arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0]
combined_arc = unary_union([arc1, arc2])
collision_polys = _apply_collision_model(combined_arc, collision_type, radius, clip_margin)
return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta)

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import heapq
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
import numpy as np
@ -60,6 +60,8 @@ class AStarRouter:
snap_to_target_dist: float = 20.0,
bend_penalty: float = 50.0,
sbend_penalty: float = 100.0,
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] = "arc",
bend_clip_margin: float = 10.0,
) -> None:
"""
Initialize the A* Router.
@ -74,6 +76,8 @@ class AStarRouter:
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.
bend_collision_type: Type of collision model for bends ('arc', 'bbox', 'clipped_bbox').
bend_clip_margin: Margin for 'clipped_bbox' collision model.
"""
self.cost_evaluator = cost_evaluator
self.config = RouterConfig(
@ -85,6 +89,8 @@ class AStarRouter:
snap_to_target_dist=snap_to_target_dist,
bend_penalty=bend_penalty,
sbend_penalty=sbend_penalty,
bend_collision_type=bend_collision_type,
bend_clip_margin=bend_clip_margin,
)
self.node_limit = self.config.node_limit
self.total_nodes_expanded = 0
@ -167,8 +173,15 @@ class AStarRouter:
if proj > 0 and 0.5 <= abs(perp) < 20.0:
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")
res = SBend.generate(
current.port,
perp,
radius,
net_width,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, "SnapSBend", move_radius=radius)
except ValueError:
pass
@ -185,15 +198,29 @@ class AStarRouter:
# 3. Lattice Bends
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}")
res = Bend90.generate(
current.port,
radius,
net_width,
direction,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"B{radius}{direction}", move_radius=radius)
# 4. Discrete SBends
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}")
res = SBend.generate(
current.port,
offset,
radius,
net_width,
collision_type=self.config.bend_collision_type,
clip_margin=self.config.bend_clip_margin
)
self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f"SB{offset}R{radius}", move_radius=radius)
except ValueError:
pass
@ -207,6 +234,7 @@ class AStarRouter:
open_set: list[AStarNode],
closed_set: set[tuple[float, float, float]],
move_type: str,
move_radius: float | None = None,
) -> 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))
@ -269,9 +297,19 @@ class AStarRouter:
if move_cost > 1e12:
return
if "B" in move_type:
# Turn penalties scaled by radius to favor larger turns
ref_radius = 10.0
if "B" in move_type and move_radius is not None:
# Scale penalty: larger radius -> smaller penalty
# e.g. radius 10 -> factor 1.0, radius 30 -> factor 0.33
penalty_factor = ref_radius / move_radius
move_cost += self.config.bend_penalty * penalty_factor
elif "SB" in move_type and move_radius is not None:
penalty_factor = ref_radius / move_radius
move_cost += self.config.sbend_penalty * penalty_factor
elif "B" in move_type:
move_cost += self.config.bend_penalty
if "SB" in move_type:
elif "SB" in move_type:
move_cost += self.config.sbend_penalty
g_cost = parent.g_cost + move_cost

View file

@ -1,13 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, TYPE_CHECKING, Any
if TYPE_CHECKING:
from shapely.geometry import Polygon
@dataclass
class RouterConfig:
"""Configuration parameters for the A* Router."""
node_limit: int = 500000
node_limit: int = 1000000
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])
@ -15,6 +19,8 @@ class RouterConfig:
snap_to_target_dist: float = 20.0
bend_penalty: float = 50.0
sbend_penalty: float = 100.0
bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] | Any = "arc"
bend_clip_margin: float = 10.0
@dataclass

View file

@ -80,7 +80,6 @@ class CostEvaluator:
for poly in geometry:
dilated_poly = poly.buffer(hard_dilation)
if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port):
# print(f"DEBUG: Hard collision detected at {end_port}")
return 1e15 # Impossible cost for hard collisions
# 2. Soft Collision check (Negotiated Congestion)

View file

@ -50,19 +50,43 @@ def test_sbend_generation() -> None:
result = SBend.generate(start, offset, radius, width)
assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.0
assert len(result.geometry) == 2
assert len(result.geometry) == 1 # Now uses unary_union
# Verify failure for large offset
with pytest.raises(ValueError, match=r"SBend offset .* must be less than 2\*radius"):
SBend.generate(start, 25.0, 10.0, 2.0)
def test_bend_snapping() -> None:
# Radius that results in non-integer coords
radius = 10.1234
def test_bend_collision_models() -> None:
start = Port(0, 0, 0)
result = Bend90.generate(start, radius, width=2.0, direction="CCW")
radius = 10.0
width = 2.0
# 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
# 1. BBox model
res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox")
# Arc CCW R=10 from (0,0,0) ends at (10,10,90).
# Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10)
minx, miny, maxx, maxy = res_bbox.geometry[0].bounds
assert minx <= 0.0 + 1e-6
assert maxx >= 10.0 - 1e-6
assert miny <= 0.0 + 1e-6
assert maxy >= 10.0 - 1e-6
# 2. Clipped BBox model
res_clipped = Bend90.generate(start, radius, width, direction="CCW", collision_type="clipped_bbox", clip_margin=1.0)
# Area should be less than full bbox
assert res_clipped.geometry[0].area < res_bbox.geometry[0].area
def test_sbend_collision_models() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox")
# Geometry should be a single bounding box polygon
assert len(res_bbox.geometry) == 1
res_arc = SBend.generate(start, offset, radius, width, collision_type="arc")
assert res_bbox.geometry[0].area > res_arc.geometry[0].area

View file

@ -30,9 +30,9 @@ def test_astar_sbend(basic_evaluator: CostEvaluator) -> None:
# Check if any component in the path is an SBend
found_sbend = False
for res in path:
# Check if it has 2 polygons (characteristic of our SBend implementation)
# and end port orientation is same as start
if len(res.geometry) == 2:
# Check if the end port orientation is same as start
# and it's not a single straight (which would have y=0)
if abs(res.end_port.y - start.y) > 0.1 and abs(res.end_port.orientation - start.orientation) < 0.1:
found_sbend = True
break
assert found_sbend
@ -50,11 +50,6 @@ def test_pathfinder_negotiated_congestion_resolution(basic_evaluator: CostEvalua
net_widths = {"net1": 2.0, "net2": 2.0}
# Force them into a narrow corridor that only fits ONE.
# Obstacles creating a wide wall with a narrow 2um gap at y=5.
# Gap y: 4 to 6. Center y=5.
# 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, 15), (20, 10)]) # Lower wall
obs_bottom = Polygon([(20, 4), (30, 4), (30, -15), (20, -10)])

View file

@ -36,9 +36,12 @@ def plot_routing_results(
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 not label_added else "")
label_added = True
# Handle both Polygon and MultiPolygon (e.g. from SBend)
geoms = [poly] if hasattr(poly, "exterior") else poly.geoms
for g in geoms:
x, y = g.exterior.xy
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])