From 8eb0dbf64ae7ffe00177c089691df20c439355bf Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Mar 2026 03:19:01 -0700 Subject: [PATCH] style --- examples/06_bend_collision_models.png | Bin 43641 -> 43053 bytes inire/geometry/collision.py | 176 ++++++++---- inire/geometry/components.py | 398 ++++++++++++++++++-------- inire/geometry/primitives.py | 107 +++++-- inire/router/astar.py | 262 ++++++++++------- inire/router/cost.py | 91 ++++-- inire/router/danger_map.py | 105 +++++-- inire/router/pathfinder.py | 87 ++++-- inire/utils/validation.py | 32 ++- inire/utils/visualization.py | 45 +-- 10 files changed, 911 insertions(+), 392 deletions(-) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png index 9088508ea9dba641fcf96ed27caa6e78f2dee49e..09eeeead138218a3147424e5748d38c95715f90a 100644 GIT binary patch literal 43053 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

UoI%*wb}fO9K@S! zm32uq$VwB|YytlSF$e|x{Z*$)tDeGesNFo1i6ME!kOlTFCV3fu+4Yqv|FmVBG)wjC z0oTGK6kvzNFxceSxQ`~5ak8`HC6>$=>nrhoK9L~9yZ!Q7d0HAXhzB=9G{u7({Z4Ie zW7Fb39Ulo;QJ&4+1)p>lKk2{=DQR_jcJVJ?`1`L{;3_#KJ}r~M>A_v;U8}E^Em$QE zMlnRk#3>XIbNXmK2C+*KwvH5%8SsRJ*6t@8Ld*KDrX|4nd;S=wtLGtB^K^BNU=}H@ zm7SAgq}eW-{OEqlAaA8;HK6O`PGMt0v=cxasm`FFOdSrwFkzq!466 z^rMaV)8H~Pw0pL&v$I<q+YRktCH8C>|DrNC@F;ba{5O`{#K1IUsf#?Y7UmdKgC zJ2Q>CaSo=dV3r1JbX-((DxHl&iiwX(h&5e9`v)nV5|`Pqfk-&;G#m&CnuoODN|=EK zkS>?Q-0exjFn}pY*r>Q8eB!wv*~52`j9gCwPbU0zIFhW$E-w`WXFwJ!5vo*p7EW;o zImO4KV#H{e@4IG07=d#(1xI#{xq*-QrrwFx!{@t zgUGSTsav#q>7;b-CpF3x-u!xcr&>cNRxt$McY5}1ydaq`>2bJ1@5rz?r=e`Jx)yRA zoH`~-F3lP)gQTd;9j?U}z71va{s)F0_elvq}R5Lv2@6cqwD7W@Gc=?~VwSgkcW z8b;%7KPe{{CcWirl_j*v<(4dDmhJZB+0MA3e!K!B@r0>U=Ov6n)E#+gFs05sp+L#X z!pk@t+IstHE6I-;Wb_Q37N#)nVAyM-%&x_oGQc0c!yk^aI46&;MDOJhH!ESYP`XK+ z^&`r|H0FXQNv_r_XQ$JFfvzqBXqLE#-m5E|4xOKwVFn8xJw%iAwDHm`n4u5;RmU^c zEEd{=1<8KgfdO33hOLenS|>{Km?NnT{>0txv^Bu9BdHJbgw-Ik2~NUK&b9X!%hNlm z4<#hmT@#~Iuq_!&72Ut5+*Uxe(y6Y6OcKTrGNq0MdFb*42GISH0$V$6oOnVNt9wsY zwk~ebE_d3*+WH22*z29rNGwsqdm8{-^ov{`n=iv+sn^>__tFu^SjigN0U;E6+?oKQnNetvDXW7iy%{DAd z!A`y$-=7BJ-VOaC-ucY=&i)Sfs@=K!d=T~oj|2;Ov6jIdM!vrHoyC_h;;>et+N(B)cfZeF6wUN!1S%9pekCZP?^uBm%ML-?X= zYE~tizL!23d0mm=ex{Dk*zR4^wNJt2IyV0?KzrP4vjss=C=EU0{Uc$=rfW)h)_xLx zXuoQSp@^HQk+AUtyWMZ8cYB#Z2`P@RU2jlty?!_Z zS?ge5Amk8%K+eKKY`1U;41y_ryRu4;B_CqZp|^M@RTBuH?+wBJLw@=E&5M_5W~Eh= z4f4`H6w||P7ups+-VZnlK04;^N#$f=wR_X=TQ_bD(;3hVS9-I$Sark%F?nU-)$*v_ zQX{IbjPS=j&Xn^|#{WKT3~r4vvGA%T-qK>(QW=L?+TfVIH~q7f>LQAIm^%j27av{Q zD%xPE-L;m{kX?qX?o4jS!j+-OrKd~#>!LS#a`guExHaP&!SW6S^wSw^oZK*W!kND) zrZjWI+k;zKvRm3&n9Pifv)6rsQV~{Mi(FD-5|9R>VR`~Hq2+!BDA7XF#JRUEIx4Cj zVBs5eko}j(K=DL3$7S#6;5XXR;UVwIjvJ0@85(O(B?ywb61_2cwq=Yqc~(uGU6HL# zeM{!^^%~Bsr382FT9+Uir_zWzL$i-^nNmF({+a#wrg9i#VsI`v`10Q8=cvQCBDOwi z=@#OqJ;Fl$?E2RJu&V5>o2R0pYp9P1{rC}^Z1#{CheVbo#WDC7OHE5(hBL_N_T1d} z!~=Ch^J$Ez2Du^>6dCmcadpniog+>kos1vj?zoJU@>J_r%ktM=i~i0N`bz9K~iiG~>(1#YW9!6`6~MG4b#!DT0n*j8Ucs+lPZIFU5_9|5j~|mEjDo~6v27(5Ezu.A?+|GmzkiTIgO*=txm1RuSz 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 literal 43641 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 +-u4XsL8NQn z+TpJO_m!Lcd#ZZ?abV!{?~e6~2_WsYeG+{Oh>q;XWdub~Zq=FCp#3XIn=V5^^9PXc zK_xs{i`O3m%0~Mq531$NTtl${2&wu7gP*Zx0ELa7ohvUd|1kqk3%?nfnkq4$ZUI5F zNYD`7Wu)W48T@;Y@Eq z$6Qn$0py)SAqgBGs9`S=a??a!Aff|IWezSbuE#~PkOl+)h-s_yt3|XdZ8#+q4dofy z>4UNYgE=l+6)vq%k6PVnceB3(6*vRE?d{Hp?I0LFju$K@900K+URwpJX*vlYKifT` zT=h-lEC7i6CiE0jSODrMi|sm3IEiTo06XYq_?Cv(9o7<=Ge}-z1QH zab)1*c2&fd$FuE~4(|sxP}AN45wK-xgL?se$0yMT8Fb|bIUq1J2cj^GW^$ReATv4W z1ZhEL`;e)84Q=h{wKbRBH8&k$VPS|6`LCt#Zlt$x+_4QGb6QE(TtOwR8?4nEmVU{` zh0x7G4wS`_&&Me`J-y+$+nv6b!~zc<#Aj!>Wtwz5K-tzflj+#WN^XYHwUXVUt5w~Z zda{o?D+E@r%_VDYPqq@7o@TnqChmY76hr0nx}Y24QUT0O)*b5cUS33kuWuPmg9HXlJ=H9IA1K5v2?#?eT$aO{ zDWFfif9cWqyb)Oa;{ByTIvOx25m10@qdBk`;?D#%ow8due4Ml)!CF1?l(yZ#)U+Lf z*}q)kBmfKmksoVohz$X-`#B_3v#loNQRUK55)Q)hy;h@jNGon)auTxokaC`VGXf9{ zEW{#Zql1h7-=PEqyuNav>dhNfvd*7Sp>z;kgm+!A?Bney0>ops=`&@kLHrJ@>(_cz zCIG;g2ky5Ol;tK1_!6;hM6)Rdw*>{Ynk`ZRn>44o4&7IiO zEUGbR;DW?dt4VHeT!)53>il(5>EdX9jW6kuiYLdvS@3Kf_2anUDb8BjS^)B_EB(9M z{V-H(-i#CfnRf*g)tDsU@SavJJUp)`vgc zoys)@H=4Y&U>?Fca0VLuanN>^a5W+^ux7++#9z7ETD=!2E3~Espkzegq6zjA;LTDp z>v^Qo3l}}>`Ic%Ys3ud)3%!-6nLZjo-kD1bmJV3$qn*!pBhP^TXk7cfTfH&JnYRbn zCZ}0np+$#@p`pB#)LW>k&3!LAq!*y&h~7Wblb31E`E2#v?sR9Sc*#av`tk^yi(StO z4C@Y9o1lMmGF1To=#@uwMUyqKMRTi669GFwz_jH}^jKM_-DnUfM@;j!j8|4Lt&;l@ z?1u&onm=GtN;zvy(T#w;@Y;UH->+j0ADe(`AFZ_Be4l^`-vL9k>AqlmL4g{89TgN5 zS~%XF43ZWzD_nRT92`s#?@CqN@FY`(A57ip8!f*<=BFc++j4nA*2Vts?E}mqNM5{u z5TBZQ&t+?vy3-}DGK#A|&obT_W@%zt+H68ZrqiYkfI3&h46J_Rmxn{~Uk_9~fP2inu}1+Y2$Z{dg8*MD z0-^wK3xLGm@2|aw7O6YTWN&nJ6vyS~^T5I|8K7p`1iQsbJRn}3oz!l0*;+RD^rSR6 z&05;S_`-`}S%m&5xdvAOoPa&lC|>z?fOuRXV#5!fVP#_~eac94Ggtrs*%J`7TAJz( zUMLR<1mieb08NZ1>$ZpNw8S<8UH+U&kBu!m?q1pl$YAavmSXm7I`VBxXum`Fm|db` zHB=b%?`Uis05Xl)_ZNryKv%h_$YE`Rcb?P+3VGTB$U=jjY%w?_q)3XbsI?o|JA*bI z)Rm-+8y59w{{VKYV|(GpJML|odyZ9S{LnbC^_A*#AgPzlKKE#GY8#eKWP4o zcf?PjFx_#4Ndv$uCH=on-0CZoUS9BYIu!eDk*^^nTocI(fK~Eko&qK4{#POA{>yIv uXJ9vU{J(40p*QpIAa4JwJO2aSx#NQUfN8dg*Ox=RDIqE&l6FVy#eV~p^FV3< diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 908a9f6..e2d79cc 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,24 +1,52 @@ from __future__ import annotations from typing import TYPE_CHECKING, Literal - import rtree -from shapely.geometry import Point, Polygon from shapely.prepared import prep if TYPE_CHECKING: + from shapely.geometry import Polygon from shapely.prepared import PreparedGeometry from inire.geometry.primitives import Port class CollisionEngine: - """Manages spatial queries for collision detection with unified dilation logic.""" + """ + Manages spatial queries for collision detection with unified dilation logic. + """ + __slots__ = ( + 'clearance', 'max_net_width', 'safety_zone_radius', + 'static_index', 'static_geometries', 'static_prepared', '_static_id_counter', + 'dynamic_index', 'dynamic_geometries', '_dynamic_id_counter' + ) - def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None: + clearance: float + """ Minimum required distance between any two waveguides or obstacles """ + + max_net_width: float + """ Maximum width of any net in the session (used for pre-dilation) """ + + safety_zone_radius: float + """ Radius around ports where collisions are ignored """ + + def __init__( + self, + clearance: float, + max_net_width: float = 2.0, + safety_zone_radius: float = 0.0021, + ) -> None: + """ + Initialize the Collision Engine. + + Args: + clearance: Minimum required distance (um). + max_net_width: Maximum net width (um). + safety_zone_radius: Safety radius around ports (um). + """ self.clearance = clearance self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius - + # Static obstacles: store raw geometries to avoid double-dilation self.static_index = rtree.index.Index() self.static_geometries: dict[int, Polygon] = {} # ID -> Polygon @@ -32,7 +60,12 @@ class CollisionEngine: self._dynamic_id_counter = 0 def add_static_obstacle(self, polygon: Polygon) -> None: - """Add a static obstacle (raw geometry) to the engine.""" + """ + Add a static obstacle (raw geometry) to the engine. + + Args: + polygon: Raw obstacle geometry. + """ obj_id = self._static_id_counter self._static_id_counter += 1 @@ -41,7 +74,13 @@ class CollisionEngine: self.static_index.insert(obj_id, polygon.bounds) def add_path(self, net_id: str, geometry: list[Polygon]) -> None: - """Add a net's routed path (raw geometry) to the dynamic index.""" + """ + Add a net's routed path (raw geometry) to the dynamic index. + + Args: + net_id: Identifier for the net. + geometry: List of raw polygons in the path. + """ for poly in geometry: obj_id = self._dynamic_id_counter self._dynamic_id_counter += 1 @@ -49,14 +88,24 @@ class CollisionEngine: self.dynamic_index.insert(obj_id, poly.bounds) def remove_path(self, net_id: str) -> None: - """Remove a net's path from the dynamic index.""" + """ + Remove a net's path from the dynamic index. + + Args: + net_id: Identifier for the net to remove. + """ to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_remove: nid, poly = self.dynamic_geometries.pop(obj_id) self.dynamic_index.delete(obj_id, poly.bounds) def lock_net(self, net_id: str) -> None: - """Move a net's dynamic path to static obstacles permanently.""" + """ + Move a net's dynamic path to static obstacles permanently. + + Args: + net_id: Identifier for the net to lock. + """ to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] for obj_id in to_move: nid, poly = self.dynamic_geometries.pop(obj_id) @@ -64,59 +113,81 @@ class CollisionEngine: self.add_static_obstacle(poly) def is_collision( - self, - geometry: Polygon, - net_width: float = 2.0, - start_port: Port | None = None, - end_port: Port | None = None - ) -> bool: - """Alias for check_collision(buffer_mode='static') for backward compatibility.""" + self, + geometry: Polygon, + net_width: float = 2.0, + start_port: Port | None = None, + end_port: Port | None = None, + ) -> bool: + """ + Alias for check_collision(buffer_mode='static') for backward compatibility. + + Args: + geometry: Move geometry to check. + net_width: Width of the net (unused). + start_port: Starting port for safety check. + end_port: Ending port for safety check. + + Returns: + True if collision detected. + """ _ = net_width - res = self.check_collision(geometry, "default", buffer_mode="static", start_port=start_port, end_port=end_port) + res = self.check_collision(geometry, 'default', buffer_mode='static', start_port=start_port, end_port=end_port) return bool(res) def count_congestion(self, geometry: Polygon, net_id: str) -> int: - """Alias for check_collision(buffer_mode='congestion') for backward compatibility.""" - res = self.check_collision(geometry, net_id, buffer_mode="congestion") + """ + Alias for check_collision(buffer_mode='congestion') for backward compatibility. + + Args: + geometry: Move geometry to check. + net_id: Identifier for the net. + + Returns: + Number of overlapping nets. + """ + res = self.check_collision(geometry, net_id, buffer_mode='congestion') return int(res) def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", - start_port: Port | None = None, - end_port: Port | None = None - ) -> bool | int: + self, + geometry: Polygon, + net_id: str, + buffer_mode: Literal['static', 'congestion'] = 'static', + start_port: Port | None = None, + end_port: Port | None = None, + ) -> bool | int: """ Check for collisions using unified dilation logic. - - If buffer_mode == "static": - Returns True if geometry collides with static obstacles (buffered by full clearance). - If buffer_mode == "congestion": - Returns count of other nets colliding with geometry (both buffered by clearance/2). + + Args: + geometry: Raw geometry to check. + net_id: Identifier for the net. + buffer_mode: 'static' (full clearance) or 'congestion' (shared). + start_port: Optional start port for safety zone. + end_port: Optional end port for safety zone. + + Returns: + Boolean if static, integer count if congestion. """ - if buffer_mode == "static": - # Buffered move vs raw static obstacle - # Distance must be >= clearance + if buffer_mode == 'static': test_poly = geometry.buffer(self.clearance) candidates = self.static_index.intersection(test_poly.bounds) - + for obj_id in candidates: if self.static_prepared[obj_id].intersects(test_poly): - # Safety zone check (using exact intersection area/bounds) if start_port or end_port: intersection = test_poly.intersection(self.static_geometries[obj_id]) if intersection.is_empty: continue - + ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds - + is_safe = False for p in [start_port, end_port]: - if p and (abs(ix_minx - p.x) < self.safety_zone_radius and + if p and (abs(ix_minx - p.x) < self.safety_zone_radius and abs(ix_maxx - p.x) < self.safety_zone_radius and - abs(ix_miny - p.y) < self.safety_zone_radius and + abs(ix_miny - p.y) < self.safety_zone_radius and abs(ix_maxy - p.y) < self.safety_zone_radius): is_safe = True break @@ -125,17 +196,14 @@ class CollisionEngine: return True return False - else: # buffer_mode == "congestion" - # Both paths buffered by clearance/2 => Total separation = clearance - dilation = self.clearance / 2.0 - test_poly = geometry.buffer(dilation) - candidates = self.dynamic_index.intersection(test_poly.bounds) - - count = 0 - for obj_id in candidates: - other_net_id, other_poly = self.dynamic_geometries[obj_id] - if other_net_id != net_id: - # Buffer the other path segment too - if test_poly.intersects(other_poly.buffer(dilation)): - count += 1 - return count + # buffer_mode == 'congestion' + dilation = self.clearance / 2.0 + test_poly = geometry.buffer(dilation) + candidates = self.dynamic_index.intersection(test_poly.bounds) + + count = 0 + for obj_id in candidates: + other_net_id, other_poly = self.dynamic_geometries[obj_id] + if other_net_id != net_id and test_poly.intersects(other_poly.buffer(dilation)): + count += 1 + return count diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 82df9b5..595b9dd 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,37 +1,85 @@ from __future__ import annotations -from typing import NamedTuple, Literal, Any - -import numpy as np +from typing import Literal, cast, TYPE_CHECKING, Union, Any +import numpy from shapely.geometry import Polygon, box from shapely.ops import unary_union from .primitives import Port +if TYPE_CHECKING: + from collections.abc import Sequence + + # Search Grid Snap (1.0 µm) SEARCH_GRID_SNAP_UM = 1.0 def snap_search_grid(value: float) -> float: - """Snap a coordinate to the nearest search grid unit.""" + """ + Snap a coordinate to the nearest search grid unit. + + Args: + value: Value to snap. + + Returns: + Snapped value. + """ return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM -class ComponentResult(NamedTuple): - """The result of a component generation: geometry, final port, and physical length.""" +class ComponentResult: + """ + The result of a component generation: geometry, final port, and physical length. + """ + __slots__ = ('geometry', 'end_port', 'length') geometry: list[Polygon] + """ List of polygons representing the component geometry """ + end_port: Port + """ The final port after the component """ + length: float + """ Physical length of the component path """ + + def __init__( + self, + geometry: list[Polygon], + end_port: Port, + length: float, + ) -> None: + self.geometry = geometry + self.end_port = end_port + self.length = length class Straight: + """ + Move generator for straight waveguide segments. + """ @staticmethod - def generate(start_port: Port, length: float, width: float, snap_to_grid: bool = True) -> ComponentResult: - """Generate a straight waveguide segment.""" - rad = np.radians(start_port.orientation) - dx = length * np.cos(rad) - dy = length * np.sin(rad) + def generate( + start_port: Port, + length: float, + width: float, + snap_to_grid: bool = True, + ) -> ComponentResult: + """ + Generate a straight waveguide segment. + + Args: + start_port: Port to start from. + length: Requested length. + width: Waveguide width. + snap_to_grid: Whether to snap the end port to the search grid. + + Returns: + A ComponentResult containing the straight segment. + """ + rad = numpy.radians(start_port.orientation) + dx = length * numpy.cos(rad) + dy = length * numpy.sin(rad) ex = start_port.x + dx ey = start_port.y + dy @@ -41,7 +89,7 @@ class Straight: ey = snap_search_grid(ey) 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) + actual_length = numpy.sqrt((end_port.x - start_port.x)**2 + (end_port.y - start_port.y)**2) # Create polygon half_w = width / 2.0 @@ -49,8 +97,8 @@ class Straight: points = [(0, half_w), (actual_length, half_w), (actual_length, -half_w), (0, -half_w)] # Transform points - cos_val = np.cos(rad) - sin_val = np.sin(rad) + cos_val = numpy.cos(rad) + sin_val = numpy.sin(rad) poly_points = [] for px, py in points: tx = start_port.x + px * cos_val - py * sin_val @@ -61,38 +109,156 @@ class Straight: 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.""" + """ + Calculate number of segments for an arc to maintain a maximum sagitta. + + Args: + radius: Arc radius. + angle_deg: Total angle turned. + sagitta: Maximum allowed deviation. + + Returns: + Minimum number of segments needed. + """ if radius <= 0: return 1 ratio = max(0.0, min(1.0, 1.0 - sagitta / radius)) - theta_max = 2.0 * np.arccos(ratio) + theta_max = 2.0 * numpy.arccos(ratio) if theta_max < 1e-9: return 16 - num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max)) + num = int(numpy.ceil(numpy.radians(abs(angle_deg)) / theta_max)) 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) +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. + + Args: + cx, cy: Center coordinates. + radius: Arc radius. + width: Waveguide width. + t_start, t_end: Start and end angles (radians). + sagitta: Geometric fidelity. + + Returns: + List containing the arc polygon. + """ + num_segments = _get_num_segments(radius, float(numpy.degrees(abs(t_end - t_start))), sagitta) + angles = numpy.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)] + inner_points = [(cx + inner_radius * numpy.cos(a), cy + inner_radius * numpy.sin(a)) for a in angles] + outer_points = [(cx + outer_radius * numpy.cos(a), cy + outer_radius * numpy.sin(a)) for a in reversed(angles)] return [Polygon(inner_points + outer_points)] +def _clip_bbox( + bbox: Polygon, + cx: float, + cy: float, + radius: float, + width: float, + clip_margin: float, + arc_poly: Polygon, + ) -> Polygon: + """ + Clips corners of a bounding box for better collision modeling. + + Args: + bbox: Initial bounding box. + cx, cy: Arc center. + radius: Arc radius. + width: Waveguide width. + clip_margin: Minimum distance from waveguide. + arc_poly: The original arc polygon. + + Returns: + The clipped polygon. + """ + res_poly = bbox + # Determine quadrant signs from arc centroid relative to center + ac = arc_poly.centroid + qsx = 1.0 if ac.x >= cx else -1.0 + qsy = 1.0 if ac.y >= cy else -1.0 + + r_out_cut = radius + width / 2.0 + clip_margin + r_in_cut = radius - width / 2.0 - clip_margin + + minx, miny, maxx, maxy = bbox.bounds + corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)] + for px, py in corners: + dx, dy = px - cx, py - cy + dist = numpy.sqrt(dx**2 + dy**2) + + # Check if corner is far enough to be clipped + if dist > r_out_cut: + # Outer corner: remove part furthest from center + # To be conservative, line is at distance r_out_cut from center. + # Equation: sx*x + sy*y = sx*cx + sy*cy + r_out_cut * sqrt(2) + d_line = r_out_cut * numpy.sqrt(2) + elif r_in_cut > 0 and dist < r_in_cut: + # Inner corner: remove part closest to center + # To be safe, line intercept must not exceed r_in_cut. + # Equation: sx*x + sy*y = sx*cx + sy*cy + r_in_cut + d_line = r_in_cut + else: + continue + + # Normal vector components from center to corner + # Using rounded signs for stability + sx = 1.0 if dx > 1e-6 else (-1.0 if dx < -1e-6 else qsx) + sy = 1.0 if dy > 1e-6 else (-1.0 if dy < -1e-6 else qsy) + + # val calculation based on d_line + val = sx * cx + sy * cy + d_line + + try: + # Create a triangle to remove. + # Vertices: corner, intersection with x=px edge, intersection with y=py edge + p1 = (px, py) + p2 = (px, (val - sx * px) / sy) + p3 = ((val - sy * py) / sx, py) + + triangle = Polygon([p1, p2, p3]) + if triangle.is_valid and triangle.area > 1e-9: + res_poly = cast('Polygon', res_poly.difference(triangle)) + except ZeroDivisionError: + continue + return res_poly + + def _apply_collision_model( - arc_poly: Polygon, - collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, - radius: float, - width: float, - cx: float = 0.0, - cy: float = 0.0, - clip_margin: float = 10.0 -) -> list[Polygon]: - """Applies the specified collision model to an arc geometry.""" + arc_poly: Polygon, + collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, + radius: float, + width: float, + cx: float = 0.0, + cy: float = 0.0, + clip_margin: float = 10.0, + ) -> list[Polygon]: + """ + Applies the specified collision model to an arc geometry. + + Args: + arc_poly: High-fidelity arc. + collision_type: Model type or custom polygon. + radius: Arc radius. + width: Waveguide width. + cx, cy: Arc center. + clip_margin: Safety margin for clipping. + + Returns: + List of polygons representing the collision model. + """ if isinstance(collision_type, Polygon): return [collision_type] @@ -107,75 +273,50 @@ def _apply_collision_model( return [bbox] if collision_type == "clipped_bbox": - res_poly = bbox - - # Determine quadrant signs from arc centroid relative to center - # This ensures we always cut 'into' the box correctly - ac = arc_poly.centroid - sx = 1.0 if ac.x >= cx else -1.0 - sy = 1.0 if ac.y >= cy else -1.0 - - r_out_cut = radius + width / 2.0 + clip_margin - r_in_cut = radius - width / 2.0 - clip_margin - - corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)] - for px, py in corners: - dx, dy = px - cx, py - cy - dist = np.sqrt(dx**2 + dy**2) - - if dist > r_out_cut: - # Outer corner: remove part furthest from center - # We want minimum distance to line to be r_out_cut - d_cut = r_out_cut * np.sqrt(2) - elif r_in_cut > 0 and dist < r_in_cut: - # Inner corner: remove part closest to center - # We want maximum distance to line to be r_in_cut - d_cut = r_in_cut - else: - continue - - # The cut line is sx*(x-cx) + sy*(y-cy) = d_cut - # sx*x + sy*y = sx*cx + sy*cy + d_cut - val = cx * sx + cy * sy + d_cut - - try: - p1 = (px, py) - p2 = (px, (val - sx * px) / sy) - p3 = ((val - sy * py) / sx, py) - - triangle = Polygon([p1, p2, p3]) - if triangle.is_valid and triangle.area > 1e-9: - res_poly = res_poly.difference(triangle) - except ZeroDivisionError: - continue - - return [res_poly] + return [_clip_bbox(bbox, cx, cy, radius, width, clip_margin, arc_poly)] return [arc_poly] class Bend90: + """ + Move generator for 90-degree bends. + """ @staticmethod 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 - 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) + 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. - ex = snap_search_grid(cx + radius * np.cos(t_end)) - ey = snap_search_grid(cy + radius * np.sin(t_end)) + Args: + start_port: Port to start from. + radius: Bend radius. + width: Waveguide width. + direction: "CW" or "CCW". + sagitta: Geometric fidelity. + collision_type: Collision model. + clip_margin: Margin for clipped_bbox. + + Returns: + A ComponentResult containing the bend. + """ + turn_angle = -90 if direction == "CW" else 90 + rad_start = numpy.radians(start_port.orientation) + c_angle = rad_start + (numpy.pi / 2 if direction == "CCW" else -numpy.pi / 2) + cx = start_port.x + radius * numpy.cos(c_angle) + cy = start_port.y + radius * numpy.sin(c_angle) + t_start = c_angle + numpy.pi + t_end = t_start + (numpy.pi / 2 if direction == "CCW" else -numpy.pi / 2) + + ex = snap_search_grid(cx + radius * numpy.cos(t_end)) + ey = snap_search_grid(cy + radius * numpy.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) @@ -183,55 +324,72 @@ class Bend90: arc_polys[0], collision_type, radius, width, cx, cy, clip_margin ) - return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0) + return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * numpy.pi / 2.0) class SBend: + """ + Move generator for parametric S-bends. + """ @staticmethod 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).""" + 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). + + Args: + start_port: Port to start from. + offset: Lateral offset. + radius: Arc radii. + width: Waveguide width. + sagitta: Geometric fidelity. + collision_type: Collision model. + clip_margin: Margin for clipped_bbox. + + Returns: + A ComponentResult containing the S-bend. + """ if abs(offset) >= 2 * radius: raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}") - theta = np.arccos(1 - abs(offset) / (2 * radius)) - dx = 2 * radius * np.sin(theta) + theta = numpy.arccos(1 - abs(offset) / (2 * radius)) + dx = 2 * radius * numpy.sin(theta) dy = offset - 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)) + rad_start = numpy.radians(start_port.orientation) + ex = snap_search_grid(start_port.x + dx * numpy.cos(rad_start) - dy * numpy.sin(rad_start)) + ey = snap_search_grid(start_port.y + dx * numpy.sin(rad_start) + dy * numpy.cos(rad_start)) end_port = Port(ex, ey, start_port.orientation) direction = 1 if offset > 0 else -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) - ts1, te1 = c1_angle + np.pi, c1_angle + np.pi + direction * theta + c1_angle = rad_start + direction * numpy.pi / 2 + cx1 = start_port.x + radius * numpy.cos(c1_angle) + cy1 = start_port.y + radius * numpy.sin(c1_angle) + ts1, te1 = c1_angle + numpy.pi, c1_angle + numpy.pi + direction * theta - 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) - te2 = c2_angle + np.pi + ex_raw = start_port.x + dx * numpy.cos(rad_start) - dy * numpy.sin(rad_start) + ey_raw = start_port.y + dx * numpy.sin(rad_start) + dy * numpy.cos(rad_start) + c2_angle = rad_start - direction * numpy.pi / 2 + cx2 = ex_raw + radius * numpy.cos(c2_angle) + cy2 = ey_raw + radius * numpy.sin(c2_angle) + te2 = c2_angle + numpy.pi ts2 = te2 + direction * theta 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]) if collision_type == "clipped_bbox": col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin) col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin) - collision_polys = [unary_union(col1 + col2)] + collision_polys = [cast('Polygon', unary_union(col1 + col2))] else: + combined_arc = cast('Polygon', unary_union([arc1, arc2])) collision_polys = _apply_collision_model( combined_arc, collision_type, radius, width, 0, 0, clip_margin ) diff --git a/inire/geometry/primitives.py b/inire/geometry/primitives.py index 74d2dc0..7128dfb 100644 --- a/inire/geometry/primitives.py +++ b/inire/geometry/primitives.py @@ -1,50 +1,111 @@ from __future__ import annotations -from dataclasses import dataclass -import numpy as np + +import numpy + # 1nm snap (0.001 µm) GRID_SNAP_UM = 0.001 + def snap_nm(value: float) -> float: - """Snap a coordinate to the nearest 1nm (0.001 um).""" + """ + Snap a coordinate to the nearest 1nm (0.001 um). + + Args: + value: Coordinate value to snap. + + Returns: + Snapped coordinate value. + """ return round(value / GRID_SNAP_UM) * GRID_SNAP_UM -@dataclass(frozen=True) -class Port: - """A port defined by (x, y, orientation) in micrometers.""" - x: float - y: float - orientation: float # Degrees: 0, 90, 180, 270 - def __post_init__(self) -> None: +class Port: + """ + A port defined by (x, y, orientation) in micrometers. + """ + __slots__ = ('x', 'y', 'orientation') + + x: float + """ x-coordinate in micrometers """ + + y: float + """ y-coordinate in micrometers """ + + orientation: float + """ Orientation in degrees: 0, 90, 180, 270 """ + + def __init__( + self, + x: float, + y: float, + orientation: float, + ) -> None: + """ + Initialize and snap a Port. + + Args: + x: Initial x-coordinate. + y: Initial y-coordinate. + orientation: Initial orientation in degrees. + """ # Snap x, y to 1nm - # We need to use object.__setattr__ because the dataclass is frozen. - snapped_x = snap_nm(self.x) - snapped_y = snap_nm(self.y) + self.x = snap_nm(x) + self.y = snap_nm(y) # Ensure orientation is one of {0, 90, 180, 270} - norm_orientation = int(round(self.orientation)) % 360 + norm_orientation = int(round(orientation)) % 360 if norm_orientation not in {0, 90, 180, 270}: norm_orientation = (round(norm_orientation / 90) * 90) % 360 - object.__setattr__(self, "x", snapped_x) - object.__setattr__(self, "y", snapped_y) - object.__setattr__(self, "orientation", float(norm_orientation)) + self.orientation = float(norm_orientation) + + def __repr__(self) -> str: + return f'Port(x={self.x}, y={self.y}, orientation={self.orientation})' + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Port): + return False + return (self.x == other.x and + self.y == other.y and + self.orientation == other.orientation) + + def __hash__(self) -> int: + return hash((self.x, self.y, self.orientation)) def translate_port(port: Port, dx: float, dy: float) -> Port: - """Translate a port by (dx, dy).""" + """ + Translate a port by (dx, dy). + + Args: + port: Port to translate. + dx: x-offset. + dy: y-offset. + + Returns: + A new translated Port. + """ return Port(port.x + dx, port.y + dy, port.orientation) def rotate_port(port: Port, angle: float, origin: tuple[float, float] = (0, 0)) -> Port: - """Rotate a port by a multiple of 90 degrees around an origin.""" + """ + Rotate a port by a multiple of 90 degrees around an origin. + + Args: + port: Port to rotate. + angle: Angle to rotate by (degrees). + origin: (x, y) origin to rotate around. + + Returns: + A new rotated Port. + """ ox, oy = origin px, py = port.x, port.y - rad = np.radians(angle) - qx = ox + np.cos(rad) * (px - ox) - np.sin(rad) * (py - oy) - qy = oy + np.sin(rad) * (px - ox) + np.cos(rad) * (py - oy) + rad = numpy.radians(angle) + qx = ox + numpy.cos(rad) * (px - ox) - numpy.sin(rad) * (py - oy) + qy = oy + numpy.sin(rad) * (px - ox) + numpy.cos(rad) * (py - oy) return Port(qx, qy, port.orientation + angle) - diff --git a/inire/router/astar.py b/inire/router/astar.py index 93df12e..754049f 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -2,9 +2,10 @@ from __future__ import annotations import heapq import logging +import functools from typing import TYPE_CHECKING, Literal -import numpy as np +import numpy from inire.geometry.components import Bend90, SBend, Straight from inire.router.config import RouterConfig @@ -17,17 +18,44 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +@functools.total_ordering class AStarNode: + """ + A node in the A* search graph. + """ + __slots__ = ('port', 'g_cost', 'h_cost', 'f_cost', 'parent', 'component_result', 'count') + + port: Port + """ Port representing the state at this node """ + + g_cost: float + """ Actual cost from start to this node """ + + h_cost: float + """ Heuristic cost from this node to target """ + + f_cost: float + """ Total estimated cost (g + h) """ + + parent: AStarNode | None + """ Parent node in the search tree """ + + component_result: ComponentResult | None + """ The component move that led to this node """ + + count: int + """ Unique insertion order for tie-breaking """ + _count = 0 def __init__( - self, - port: Port, - g_cost: float, - h_cost: float, - parent: AStarNode | None = None, - component_result: ComponentResult | None = None, - ) -> None: + self, + port: Port, + g_cost: float, + h_cost: float, + parent: AStarNode | None = None, + component_result: ComponentResult | None = None, + ) -> None: self.port = port self.g_cost = g_cost self.h_cost = h_cost @@ -45,39 +73,62 @@ class AStarNode: return self.h_cost < other.h_cost return self.count < other.count + def __eq__(self, other: object) -> bool: + if not isinstance(other, AStarNode): + return False + return self.count == other.count + class AStarRouter: - """Hybrid State-Lattice A* Router.""" + """ + Hybrid State-Lattice A* Router. + """ + __slots__ = ('cost_evaluator', 'config', 'node_limit', 'total_nodes_expanded', '_collision_cache') + + cost_evaluator: CostEvaluator + """ The evaluator for path and proximity costs """ + + config: RouterConfig + """ Search configuration parameters """ + + node_limit: int + """ Maximum nodes to expand before failure """ + + total_nodes_expanded: int + """ Counter for debugging/profiling """ + + _collision_cache: dict[tuple[float, float, float, str, float, str], bool] + """ Internal cache for move collision checks """ 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, - bend_collision_type: Literal["arc", "bbox", "clipped_bbox"] = "arc", - bend_clip_margin: float = 10.0, - ) -> None: + 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, + bend_collision_type: Literal['arc', 'bbox', 'clipped_bbox'] = 'arc', + bend_clip_margin: float = 10.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. - bend_collision_type: Type of collision model for bends ('arc', 'bbox', 'clipped_bbox'). - bend_clip_margin: Margin for 'clipped_bbox' collision model. + cost_evaluator: Path cost evaluator. + node_limit: Node expansion limit. + straight_lengths: Allowed straight lengths (um). + bend_radii: Allowed 90-deg radii (um). + sbend_offsets: Allowed S-bend lateral offsets (um). + sbend_radii: Allowed S-bend radii (um). + snap_to_target_dist: Radius for target lookahead (um). + bend_penalty: Penalty for 90-degree turns. + sbend_penalty: Penalty for S-bends. + bend_collision_type: Collision model for bends. + bend_clip_margin: Margin for clipped_bbox model. """ self.cost_evaluator = cost_evaluator self.config = RouterConfig( @@ -94,10 +145,27 @@ class AStarRouter: ) self.node_limit = self.config.node_limit self.total_nodes_expanded = 0 - self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {} + self._collision_cache = {} - def route(self, start: Port, target: Port, net_width: float, net_id: str = "default") -> list[ComponentResult] | None: - """Route a single net using A*.""" + def route( + self, + start: Port, + target: Port, + net_width: float, + net_id: str = 'default', + ) -> list[ComponentResult] | None: + """ + Route a single net using A*. + + Args: + start: Starting port. + target: Target port. + net_width: Waveguide width (um). + net_id: Optional net identifier. + + Returns: + List of moves forming the path, or None if failed. + """ self._collision_cache.clear() open_set: list[AStarNode] = [] # Key: (x, y, orientation) rounded to 1nm @@ -110,7 +178,7 @@ class AStarRouter: while open_set: if nodes_expanded >= self.node_limit: - logger.warning(f" AStar failed: node limit {self.node_limit} reached.") + logger.warning(f' AStar failed: node limit {self.node_limit} reached.') return None current = heapq.heappop(open_set) @@ -120,19 +188,17 @@ class AStarRouter: if state in closed_set: continue closed_set.add(state) - + nodes_expanded += 1 self.total_nodes_expanded += 1 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}") + logger.info(f'Nodes expanded: {nodes_expanded}, current: {current.port}, g: {current.g_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 abs(current.port.orientation - target.orientation) < 0.1 - ): + if (abs(current.port.x - target.x) < 1e-6 and + abs(current.port.y - target.y) < 1e-6 and + abs(current.port.orientation - target.orientation) < 0.1): return self._reconstruct_path(current) # Expansion @@ -141,47 +207,47 @@ class AStarRouter: return None def _expand_moves( - self, - current: AStarNode, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: set[tuple[float, float, float]], - ) -> None: + self, + current: AStarNode, + target: Port, + net_width: float, + net_id: str, + open_set: list[AStarNode], + closed_set: set[tuple[float, float, float]], + ) -> None: # 1. Snap-to-Target Look-ahead - dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2) + dist = numpy.sqrt((current.port.x - target.x)**2 + (current.port.y - target.y)**2) 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) + rad = numpy.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) + proj = dx * numpy.cos(rad) + dy * numpy.sin(rad) + perp = -dx * numpy.sin(rad) + dy * numpy.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") - + 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) + rad = numpy.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) + proj = dx * numpy.cos(rad) + dy * numpy.sin(rad) + perp = -dx * numpy.sin(rad) + dy * numpy.cos(rad) 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, + 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) + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, 'SnapSBend', move_radius=radius) except ValueError: pass @@ -190,52 +256,52 @@ class AStarRouter: if dist < 5.0: fine_steps = [0.1, 0.5] lengths = sorted(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}") + self._add_node(current, res, target, net_width, net_id, open_set, closed_set, f'S{length}') # 3. Lattice Bends 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, + 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) + 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, + 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) + 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 def _add_node( - self, - parent: AStarNode, - result: ComponentResult, - target: Port, - net_width: float, - net_id: str, - open_set: list[AStarNode], - closed_set: set[tuple[float, float, float]], - move_type: str, - move_radius: float | None = None, - ) -> None: + self, + parent: AStarNode, + result: ComponentResult, + target: Port, + net_width: float, + net_id: str, + 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)) if state in closed_set: @@ -256,7 +322,7 @@ class AStarRouter: hard_coll = False for poly in result.geometry: if self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="static", start_port=parent.port, end_port=result.end_port + poly, net_id, buffer_mode='static', start_port=parent.port, end_port=result.end_port ): hard_coll = True break @@ -268,7 +334,7 @@ class AStarRouter: dilation = self.cost_evaluator.collision_engine.clearance / 2.0 for move_poly in result.geometry: dilated_move = move_poly.buffer(dilation) - curr_p = parent + curr_p: AStarNode | None = parent seg_idx = 0 while curr_p and curr_p.component_result and seg_idx < 100: if seg_idx > 0: @@ -278,7 +344,7 @@ class AStarRouter: 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) @@ -288,10 +354,10 @@ class AStarRouter: seg_idx += 1 move_cost = self.cost_evaluator.evaluate_move( - result.geometry, - result.end_port, - net_width, - net_id, + result.geometry, + result.end_port, + net_width, + net_id, start_port=parent.port, length=result.length ) @@ -301,15 +367,15 @@ class AStarRouter: # Turn penalties scaled by radius to favor larger turns ref_radius = 10.0 - if "B" in move_type and move_radius is not None: + if 'B' in move_type and move_radius is not None: 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: + 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: + elif 'B' in move_type: move_cost += self.config.bend_penalty - elif "SB" in move_type: + elif 'SB' in move_type: move_cost += self.config.sbend_penalty g_cost = parent.g_cost + move_cost diff --git a/inire/router/cost.py b/inire/router/cost.py index 996e135..6b2ff54 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -13,16 +13,33 @@ if TYPE_CHECKING: class CostEvaluator: - """Calculates total path and proximity costs.""" + """ + Calculates total path and proximity costs. + """ + __slots__ = ('collision_engine', 'danger_map', 'config', 'unit_length_cost', 'greedy_h_weight', 'congestion_penalty') + + collision_engine: CollisionEngine + """ The engine for intersection checks """ + + danger_map: DangerMap + """ Pre-computed grid for heuristic proximity costs """ + + config: CostConfig + """ Parameter configuration """ + + unit_length_cost: float + greedy_h_weight: float + congestion_penalty: float + """ Cached weight values for performance """ 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: + 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. @@ -47,11 +64,28 @@ class CostEvaluator: self.congestion_penalty = self.config.congestion_penalty def g_proximity(self, x: float, y: float) -> float: - """Get proximity cost from the Danger Map.""" + """ + Get proximity cost from the Danger Map. + + Args: + x, y: Coordinate to check. + + Returns: + Proximity cost at location. + """ return self.danger_map.get_cost(x, y) def h_manhattan(self, current: Port, target: Port) -> float: - """Heuristic: weighted Manhattan distance + orientation penalty.""" + """ + Heuristic: weighted Manhattan distance + orientation penalty. + + Args: + current: Current port state. + target: Target port state. + + Returns: + Heuristic cost estimate. + """ dist = abs(current.x - target.x) + abs(current.y - target.y) # Orientation penalty if not aligned with target entry @@ -62,19 +96,32 @@ class CostEvaluator: return self.greedy_h_weight * (dist + penalty) def evaluate_move( - self, - geometry: list[Polygon], - end_port: Port, - 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).""" + self, + geometry: list[Polygon], + end_port: Port, + 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). + + Args: + geometry: List of polygons in the move. + end_port: Port at the end of the move. + net_width: Width of the waveguide (unused). + net_id: Identifier for the net. + start_port: Port at the start of the move. + length: Physical path length of the move. + + Returns: + Total cost of the move, or 1e15 if invalid. + """ _ = net_width # Unused total_cost = length * self.unit_length_cost - # 1. Boundary Check (Centerline based for compatibility) + # 1. Boundary Check if not self.danger_map.is_within_bounds(end_port.x, end_port.y): return 1e15 @@ -82,12 +129,12 @@ class CostEvaluator: for poly in geometry: # Hard Collision (Static obstacles) if self.collision_engine.check_collision( - poly, net_id, buffer_mode="static", start_port=start_port, end_port=end_port + poly, net_id, buffer_mode='static', start_port=start_port, end_port=end_port ): return 1e15 # Soft Collision (Negotiated Congestion) - overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode="congestion") + overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode='congestion') if isinstance(overlaps, int) and overlaps > 0: total_cost += overlaps * self.congestion_penalty diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 209edf0..bc85537 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -1,8 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING - -import numpy as np +import numpy import shapely if TYPE_CHECKING: @@ -10,38 +9,76 @@ if TYPE_CHECKING: class DangerMap: - """A pre-computed grid for heuristic proximity costs, vectorized for performance.""" + """ + A pre-computed grid for heuristic proximity costs, vectorized for performance. + """ + __slots__ = ('minx', 'miny', 'maxx', 'maxy', 'resolution', 'safety_threshold', 'k', 'width_cells', 'height_cells', 'grid') + + minx: float + miny: float + maxx: float + maxy: float + """ Boundary coordinates of the map """ + + resolution: float + """ Grid cell size in micrometers """ + + safety_threshold: float + """ Distance below which proximity costs are applied """ + + k: float + """ Cost multiplier constant """ + + width_cells: int + height_cells: int + """ Grid dimensions in cells """ + + grid: numpy.ndarray + """ 2D array of pre-computed costs """ def __init__( - self, - bounds: tuple[float, float, float, float], - resolution: float = 1.0, - safety_threshold: float = 10.0, - k: float = 1.0, - ) -> None: - # bounds: (minx, miny, maxx, maxy) + self, + bounds: tuple[float, float, float, float], + resolution: float = 1.0, + safety_threshold: float = 10.0, + k: float = 1.0, + ) -> None: + """ + Initialize the Danger Map. + + Args: + bounds: (minx, miny, maxx, maxy) in um. + resolution: Cell size (um). + safety_threshold: Proximity limit (um). + k: Penalty multiplier. + """ self.minx, self.miny, self.maxx, self.maxy = bounds self.resolution = resolution self.safety_threshold = safety_threshold self.k = k # Grid dimensions - self.width_cells = int(np.ceil((self.maxx - self.minx) / self.resolution)) - self.height_cells = int(np.ceil((self.maxy - self.miny) / self.resolution)) + self.width_cells = int(numpy.ceil((self.maxx - self.minx) / self.resolution)) + self.height_cells = int(numpy.ceil((self.maxy - self.miny) / self.resolution)) - self.grid = np.zeros((self.width_cells, self.height_cells), dtype=np.float32) + self.grid = numpy.zeros((self.width_cells, self.height_cells), dtype=numpy.float32) def precompute(self, obstacles: list[Polygon]) -> None: - """Pre-compute the proximity costs for the entire grid using vectorized operations.""" + """ + Pre-compute the proximity costs for the entire grid using vectorized operations. + + Args: + obstacles: List of static obstacle geometries. + """ from scipy.ndimage import distance_transform_edt # 1. Create a binary mask of obstacles - mask = np.ones((self.width_cells, self.height_cells), dtype=bool) - + mask = numpy.ones((self.width_cells, self.height_cells), dtype=bool) + # Create coordinate grids - x_coords = np.linspace(self.minx + self.resolution/2, self.maxx - self.resolution/2, self.width_cells) - y_coords = np.linspace(self.miny + self.resolution/2, self.maxy - self.resolution/2, self.height_cells) - xv, yv = np.meshgrid(x_coords, y_coords, indexing='ij') + x_coords = numpy.linspace(self.minx + self.resolution/2, self.maxx - self.resolution/2, self.width_cells) + y_coords = numpy.linspace(self.miny + self.resolution/2, self.maxy - self.resolution/2, self.height_cells) + xv, yv = numpy.meshgrid(x_coords, y_coords, indexing='ij') for poly in obstacles: # Use shapely.contains_xy for fast vectorized point-in-polygon check @@ -53,19 +90,35 @@ class DangerMap: # 3. Proximity cost: k / d^2 if d < threshold, else 0 # Cap distances at a small epsilon (e.g. 0.1um) to avoid division by zero - safe_distances = np.maximum(distances, 0.1) - self.grid = np.where( - distances < self.safety_threshold, - self.k / (safe_distances**2), + safe_distances = numpy.maximum(distances, 0.1) + self.grid = numpy.where( + distances < self.safety_threshold, + self.k / (safe_distances**2), 0.0 - ).astype(np.float32) + ).astype(numpy.float32) def is_within_bounds(self, x: float, y: float) -> bool: - """Check if a coordinate is within the design bounds.""" + """ + Check if a coordinate is within the design bounds. + + Args: + x, y: Coordinate to check. + + Returns: + True if within [min, max] for both axes. + """ return self.minx <= x <= self.maxx and self.miny <= y <= self.maxy def get_cost(self, x: float, y: float) -> float: - """Get the proximity cost at a specific coordinate.""" + """ + Get the proximity cost at a specific coordinate. + + Args: + x, y: Coordinate to look up. + + Returns: + Pre-computed cost, or 1e15 if out of bounds. + """ ix = int((x - self.minx) / self.resolution) iy = int((y - self.miny) / self.resolution) diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 19ef9fd..05235b7 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -16,22 +16,47 @@ logger = logging.getLogger(__name__) @dataclass class RoutingResult: + """ + Result of a single net routing operation. + """ net_id: str + """ Identifier for the net """ + path: list[ComponentResult] + """ List of moves forming the path """ + is_valid: bool + """ Whether the path is collision-free """ + collisions: int + """ Number of detected collisions/overlaps """ class PathFinder: - """Multi-net router using Negotiated Congestion.""" + """ + Multi-net router using Negotiated Congestion. + """ + __slots__ = ('router', 'cost_evaluator', 'max_iterations', 'base_congestion_penalty') + + router: AStarRouter + """ The A* search engine """ + + cost_evaluator: CostEvaluator + """ The evaluator for path costs """ + + max_iterations: int + """ Maximum number of rip-up and reroute iterations """ + + base_congestion_penalty: float + """ Starting penalty for overlaps """ def __init__( - self, - router: AStarRouter, - cost_evaluator: CostEvaluator, - max_iterations: int = 10, - base_congestion_penalty: float = 100.0, - ) -> None: + self, + router: AStarRouter, + cost_evaluator: CostEvaluator, + max_iterations: int = 10, + base_congestion_penalty: float = 100.0, + ) -> None: """ Initialize the PathFinder. @@ -46,8 +71,21 @@ class PathFinder: self.max_iterations = max_iterations self.base_congestion_penalty = base_congestion_penalty - def route_all(self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float]) -> dict[str, RoutingResult]: - """Route all nets in the netlist using Negotiated Congestion.""" + def route_all( + self, + netlist: dict[str, tuple[Port, Port]], + net_widths: dict[str, float], + ) -> dict[str, RoutingResult]: + """ + Route all nets in the netlist using Negotiated Congestion. + + Args: + netlist: Mapping of net_id to (start_port, target_port). + net_widths: Mapping of net_id to waveguide width. + + Returns: + Mapping of net_id to RoutingResult. + """ results: dict[str, RoutingResult] = {} self.cost_evaluator.congestion_penalty = self.base_congestion_penalty @@ -57,15 +95,14 @@ class PathFinder: for iteration in range(self.max_iterations): any_congestion = False - logger.info(f"PathFinder Iteration {iteration}...") + logger.info(f'PathFinder Iteration {iteration}...') # Sequence through nets for net_id, (start, target) in netlist.items(): # Timeout check elapsed = time.monotonic() - start_time if elapsed > session_timeout: - logger.warning(f"PathFinder TIMEOUT after {elapsed:.2f}s") - # Return whatever we have so far + logger.warning(f'PathFinder TIMEOUT after {elapsed:.2f}s') return self._finalize_results(results, netlist) width = net_widths.get(net_id, 2.0) @@ -76,7 +113,7 @@ class PathFinder: # 2. Reroute with current congestion info net_start = time.monotonic() path = self.router.route(start, target, width, net_id=net_id) - logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s") + logger.debug(f' Net {net_id} routed in {time.monotonic() - net_start:.4f}s') if path: # 3. Add to index @@ -89,7 +126,7 @@ class PathFinder: collision_count = 0 for poly in all_geoms: overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="congestion" + poly, net_id, buffer_mode='congestion' ) if isinstance(overlaps, int): collision_count += overlaps @@ -110,11 +147,23 @@ class PathFinder: return self._finalize_results(results, netlist) - def _finalize_results(self, results: dict[str, RoutingResult], netlist: dict[str, tuple[Port, Port]]) -> dict[str, RoutingResult]: - """Final check: re-verify all nets against the final static paths.""" - logger.debug(f"Finalizing results for nets: {list(results.keys())}") + def _finalize_results( + self, + results: dict[str, RoutingResult], + netlist: dict[str, tuple[Port, Port]], + ) -> dict[str, RoutingResult]: + """ + Final check: re-verify all nets against the final static paths. + + Args: + results: Results from the routing loop. + netlist: The original netlist. + + Returns: + Refined results with final collision counts. + """ + logger.debug(f'Finalizing results for nets: {list(results.keys())}') final_results = {} - # Ensure all nets in the netlist are present in final_results for net_id in netlist: res = results.get(net_id) if not res or not res.path: @@ -125,7 +174,7 @@ class PathFinder: for comp in res.path: for poly in comp.geometry: overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="congestion" + poly, net_id, buffer_mode='congestion' ) if isinstance(overlaps, int): collision_count += overlaps diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 662fd81..eaacd42 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -1,11 +1,10 @@ from __future__ import annotations -import numpy as np from typing import TYPE_CHECKING, Any - -from shapely.geometry import Polygon +import numpy if TYPE_CHECKING: + from shapely.geometry import Polygon from inire.geometry.primitives import Port from inire.router.pathfinder import RoutingResult @@ -19,8 +18,18 @@ def validate_routing_result( ) -> dict[str, Any]: """ Perform a high-precision validation of a routed path. - Returns a dictionary with validation results. + + Args: + result: The routing result to validate. + static_obstacles: List of static obstacle geometries. + clearance: Required minimum distance. + expected_start: Optional expected start port. + expected_end: Optional expected end port. + + Returns: + A dictionary with validation results. """ + _ = expected_start if not result.path: return {"is_valid": False, "reason": "No path found"} @@ -30,13 +39,13 @@ def validate_routing_result( # 1. Connectivity Check total_length = 0.0 - for i, comp in enumerate(result.path): + for comp in result.path: total_length += comp.length # 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) + dist_to_end = numpy.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: @@ -48,7 +57,7 @@ def validate_routing_result( dilated_for_self = [] - for i, comp in enumerate(result.path): + for comp in result.path: for poly in comp.geometry: # Check against obstacles d_full = poly.buffer(dilation_full) @@ -64,11 +73,10 @@ def validate_routing_result( # 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)) + if j > i + 1 and seg_i.intersects(seg_j): # Non-adjacent + 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 diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 30e5bee..697607a 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -1,14 +1,13 @@ from __future__ import annotations from typing import TYPE_CHECKING - import matplotlib.pyplot as plt -import numpy as np +import numpy +from shapely.geometry import MultiPolygon, Polygon if TYPE_CHECKING: from matplotlib.axes import Axes from matplotlib.figure import Figure - from shapely.geometry import Polygon from inire.geometry.primitives import Port from inire.router.pathfinder import RoutingResult @@ -20,7 +19,18 @@ def plot_routing_results( bounds: tuple[float, float, float, float], netlist: dict[str, tuple[Port, Port]] | None = None, ) -> tuple[Figure, Axes]: - """Plot obstacles and routed paths using matplotlib.""" + """ + Plot obstacles and routed paths using matplotlib. + + Args: + results: Dictionary of net_id to RoutingResult. + static_obstacles: List of static obstacle polygons. + bounds: Plot limits (minx, miny, maxx, maxy). + netlist: Optional original netlist for port visualization. + + Returns: + The matplotlib Figure and Axes objects. + """ fig, ax = plt.subplots(figsize=(10, 10)) # Plot static obstacles (gray) @@ -37,43 +47,42 @@ def plot_routing_results( color = "red" # Highlight failing nets label_added = False - for j, comp in enumerate(res.path): + for _j, comp in enumerate(res.path): # 1. Plot geometry for poly in comp.geometry: # Handle both Polygon and MultiPolygon (e.g. from SBend) - geoms = [poly] if hasattr(poly, "exterior") else poly.geoms + if isinstance(poly, MultiPolygon): + geoms = list(poly.geoms) + else: + geoms = [poly] + 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 # 2. Plot subtle port orientation arrow for internal ports - # (Every segment's end_port except possibly the last one if it matches target) p = comp.end_port - rad = np.radians(p.orientation) - u = np.cos(rad) - v = np.sin(rad) - - # Internal ports get smaller, narrower, semi-transparent arrows + rad = numpy.radians(p.orientation) + u = numpy.cos(rad) + v = numpy.sin(rad) ax.quiver(p.x, p.y, u, v, color="black", scale=40, width=0.003, alpha=0.3, pivot="tail", zorder=4) # 3. Plot main arrows for netlist ports (if provided) if netlist and net_id in netlist: start_p, target_p = netlist[net_id] for p in [start_p, target_p]: - rad = np.radians(p.orientation) - u = np.cos(rad) - v = np.sin(rad) - # Netlist ports get prominent arrows + rad = numpy.radians(p.orientation) + u = numpy.cos(rad) + v = numpy.sin(rad) ax.quiver(p.x, p.y, u, v, color="black", scale=25, width=0.005, pivot="tail", zorder=6) 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() - ax.grid(alpha=0.6) + plt.grid(True) return fig, ax