From 41a2d9f0589dd778adf349f453975d75c2d66106 Mon Sep 17 00:00:00 2001 From: jan Date: Sun, 8 Mar 2026 22:02:07 -0700 Subject: [PATCH] more s-bend fixes and add docs --- DOCS.md | 53 ++++++++++++++ examples/04_sbends_and_radii.py | 61 +++++++--------- examples/sbends_radii.png | Bin 35578 -> 36462 bytes inire/geometry/components.py | 124 +++++++++++++++++++------------- inire/router/astar.py | 56 ++++++++++++--- inire/router/config.py | 8 ++- inire/router/cost.py | 1 - inire/tests/test_components.py | 40 ++++++++--- inire/tests/test_congestion.py | 11 +-- inire/utils/visualization.py | 9 ++- 10 files changed, 248 insertions(+), 115 deletions(-) create mode 100644 DOCS.md diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..fe585ea --- /dev/null +++ b/DOCS.md @@ -0,0 +1,53 @@ +# Inire Configuration & API Documentation + +This document describes the user-tunable parameters for the `inire` auto-router. + +## 1. AStarRouter Parameters + +The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor. + +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | +| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search in open space. | +| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow the router to pick the best fit. | +| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | +| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | +| `snap_to_target_dist`| `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | +| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | +| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | +| `bend_collision_type`| `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | +| `bend_clip_margin` | `float` | 10.0 | Margin (µm) for the `"clipped_bbox"` collision model. | + +### Bend Collision Models +* `"arc"`: High-fidelity model following the exact curved waveguide geometry. +* `"bbox"`: Conservative model using the axis-aligned bounding box of the bend. Fast but blocks more space. +* `"clipped_bbox"`: A middle ground that uses the bounding box but clips corners that are far from the waveguide. + +--- + +## 2. CostEvaluator Parameters + +The `CostEvaluator` defines the "goodness" of a path. + +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | +| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g., `1.5`) are faster but may produce longer paths. | +| `congestion_penalty`| `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | + +--- + +## 3. CollisionEngine Parameters + +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | +| `safety_zone_radius`| `float` | 0.0021 | Radius (µm) around ports where collisions are ignored to allow PDK boundary incidence. | + +--- + +## 4. Physical Units & Precision +- **Coordinates**: Micrometers (µm). +- **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. +- **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index cdf8826..c3d2b0b 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -14,54 +14,43 @@ def main() -> None: print("Running Example 04: S-Bends and Multiple Radii...") # 1. Setup Environment - bounds = (0, 0, 150, 100) + bounds = (0, 0, 100, 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) + danger_map.precompute([]) + + # 2. Configure Router evaluator = CostEvaluator( - engine, + engine, danger_map, unit_length_cost=1.0, - greedy_h_weight=1.2, + greedy_h_weight=1.5, ) - + + # We want a 45 degree switchover for S-bend. + # Offset O = 2 * R * (1 - cos(theta)) + # If R = 10, O = 5.86 + 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 + node_limit=50000, + bend_radii=[10.0, 30.0], + sbend_offsets=[5.0], # Use a simpler offset + sbend_radii=[10.0], + bend_penalty=10.0, + sbend_penalty=20.0, + snap_to_target_dist=50.0, # Large snap range ) - + 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. + # start (10, 50), target (60, 55) -> 5um offset netlist = { - "large_bend_net": (Port(10, 10, 0), Port(140, 80, 0)), - "sbend_net": (Port(10, 50, 0), Port(70, 70, 0)), + "sbend_only": (Port(10, 50, 0), Port(60, 55, 0)), + "multi_radii": (Port(10, 10, 0), Port(90, 90, 0)), } - net_widths = {"large_bend_net": 2.0, "sbend_net": 2.0} + net_widths = {"sbend_only": 2.0, "multi_radii": 2.0} # 4. Route results = pf.route_all(netlist, net_widths) @@ -72,7 +61,7 @@ def main() -> None: print(f"{nid}: {status}, collisions={res.collisions}") # 6. Visualize - fig, ax = plot_routing_results(results, obstacles, bounds) + fig, ax = plot_routing_results(results, [], bounds) fig.savefig("examples/sbends_radii.png") print("Saved plot to examples/sbends_radii.png") diff --git a/examples/sbends_radii.png b/examples/sbends_radii.png index fcf1e1def8cb922a774016f79054a9c719c4249e..ea7b0e002f6ffa4cbf47dde4debdcb74450d30a0 100644 GIT binary patch literal 36462 zcmeFZc{J7C8#jDZQc~^)DuwPsW=hChNkZmhos{;lwQlQHj{V)=y{~;;pX+mduKhwuLHaNy9VG^X zIV^MgrV0kLcN~2k*bo0AAKy9x{}aYZY2s9^O>s_!b|x5kL!8Y6Yup2KqjQcXcJ}7h zR(#x-c)71~p1Y63+1Ly7@I3s_C%CQc%y>TOI5fg22W@U^*<&!&hUkk7FOh7H!T5E_ z+`Nu`6fxcF^ytLs(DIz?n+Gn`Y{A!V%t|RGu_y+)r96S#{f9eVPmZ8(ybdaTCqv)xl2MXA%5v>G1^S1I zeXOuB@aHXtoPPJ4$0tquQfgymb_I8$ zrL)|#oRjwDuW#>$Cs;9FbpCL#@W-|}--Zi3oW})wE}At;c&@Fk;;nj%==d#y{m%-i z3~odVJbX{fsi`!$A$0N$i#+=RU#9j4+1t0jM>_STyt%08BHU;0zO%K$zs?8;HfnHq z0*)Zo`^l3%Ypd8k16S3gJIY2zanp{)?H_cKh3tRbZ%b9{DswO1+}h%JP~MUx-_oXG z@Zr-_iZHG1{+(@&N+0U5=xBrI#Et%v*7jiAoe@b;3V zf#+7P_u(_YLfs<{SzB8>ySi$ud+bb35;D|3Xy-D?3|ITL{2r`ann;eesPsNuH1On* z==;MM4Bx;pC(N}Fev4LCatey6+Q18RqK`g4dHR%wp8mteOtE5BRh3EK#)|9roswyn zvBt=vnWCu^M~}wN^cIJu@BFT$Zp$>5Cf~P@_3~vEc)^ZXNgv(Du6Su`ndUP0jXKVq zxf;ec_$xU%Ih&}cu7HGevGQ zEIxa-PtRj*R6Exqgu7s1&)UWY!LZWX1-3)6vm@_eH|~ejNw#z6t}RWrwZLNVyDm@L z^>%n9MSopinrbWe^nCvOIU6sp(n&pBpn8VB-Y+pBAt5>mkCJ1@j*X0sCBJ=pYKhpb z0gKMIu(rLq>FF?6b#iLxC6Bmqx^nz2^Y%2YAndQ^O$qn;uNNnOX2##RapMFXU8=yt zuJNjq2BIAS)R+pkt50EvntflsY|Ai^aG2@QY7*Z_3+2{J9gA{R@T22z=F0Dx&*(O= zZ{W9RPI!?dAI?(A%Os;HzLvmp=WPtUk@@t`%(~jz@AW+8X;EUXlVAzTj~=ZGjcsOs zxpnhqFt<(-E@eN}sTkN?7(#}fm38;CV9Dn7p3h|HGQtCN@N z6+6Bcak842+Dmcd{?L~f?}Z&w8JtG~n@1(Rsg!+=&@Y0yhiZASbar;ueft&+BY`pS zJI~)+TU(o$ujk=$Z`xYv�}v{Xwr-JuomZC;?6VEbePDvw+d{XSmpTkG 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 literal 35578 zcmeFZcTiN@(>6NlF`>Xw!GHtcZq>d2{j9P;XV2biuhpyj>8GDwFIALe85!6Z zFc=IYR_=xx2D5D#{oS<#{zoyMG6+9p>~Ct@t6M*?cQmp!!6+Kp+dQ(ie`I#=n1hL} zotd@OdEv8XgvA8!Kd>`Bd-~WzdwUx@2@w&?|LZzoYg<#1fApD?;amY=Dv#UH($L!C)WD$gvVO8SZ~nTIA%eb>%LteF})?%iMimENF1f?aH+0`?ZE| zrt?J+gYVxhc?dl>I=mG|fWfrYCKvA6{0;M${pQcxPdu5>FP^*qZrXx=IkRP}1p0|N zB_p#F{Ve}$7t9O%d5hV?vH8n^V=>#%FL$^8*G)YCbCdtf$$#wRKYIsr`#-MWKko5A zA>;pb7?trmW7@!e=k&pJ!?N{P?Rna#<&~5&y5f|KY65vOwNKLwHB&Xto;_PQ7^u*Y zKVR!K*QOfAmcDffcDK+Q2~k^4zFJfB6%ge4E&g(MuugYb&XQ zjdhy8udi=NWMmqTLMTpG5#w1{m50zk`B6=>LHaE}HfKvhYm#EC=ay~v^Qbvyt)~>< zox8cVJlFcGJ|ZMIc<|#|=`ual=k@FM;pTW{9ZoJTa$Bla>P_E+oYcI_3q6j}w_daJ zC$|#cUv@OF4d!RN_~`9H5recn!uc^>bcv9kKkuPC1}ZU(V=zwp0Zpjz&JH(MSVjZ} z?%%#+NA10jk#p;Tfq}IPoq5)VaBt=KTYfFY&NH>oqQ$oV^%rhfbTZ+TVt8;zQ<0-J z4-XH~u*_q5p=`sl5CDh_zJI?XM#fLnW%i-h^XKM`QIco`*REf0%YSSvx!C_qKv3{9 zag0mkvGn7b)z4mgL>+!>SAO}zCoZ1vR>Ca3sv_w+kCT&o0bdclU;VdbSN6y&3(1Yu z86NDrckg(G3_srU+QG!0^5Mgo?K^j-YLN|=e??d;nFmEiy46avMT$RnUzx`ShKpJx zaa?wIIMR|x3kwpnXusV;glB&2f&UzSe$-0k#*KgO85^Ug(}sqkv&r$Y{^s5Jk9}Uf z!aaPL@|>9~qr`O~sx5sRVJ`gs{rkhOFORFhObkv;kiUGnf##6OdmzDqTrwZtAi1Ci zFDr2R^lcb!^MJp!!AQrL|NIy=`R&`o*p^^^^;*Na16n-yO-)DVsn%KpU;SAB{yPRf zoEz`am0q2!I8p)#FtYrJJzhr`-O z<0|vT$saDT+8Q_L{ zK%wBUYN?v(wP8Z~g}7%s7}cs?bIeT;Hgvaa+2R7GjIZGd8(v%J>%e#DYZ2Yu<~vOb z!PZ#!?Tghibk}H^IzKe7hi}s;94{QbVAU;dNzGOCmz;mgJ@VeMH-Ni9FJszuI4CFx z-|IYNHb-wOT>R-PsL{vuo-SHbU9FO5)iVhDf;Y{lH;x9>SP{L@#YJ^%55{b+ig zyqfdFvZY@UIgbZ!DZZD`NY}|}X^xjg|Kzx2JD9e>h)JOWZuVrWe~@Jcd!g`GxCJ+M z+fF75Y)( z{a8+iCf#@UOdw<-BHokS9ax_)|ZzZgI^?v9eeO$KL-XxjXwV~eX*vdrnauG^)<(3 zN&@x(wrYhB9>Od+`-anF(J05FLrG9816SfQr$mp3_teY}ZQZ?Vmr9thJ~!5nO?0`{ zqQI6W-{UfxUO1LZtqs$6=rqyDw8qHveB~KO({}IUaG-+lC9^wVDIuYunz3`_LOLmk zld!S2U@OjENDI{+v=Of@+q!LA8`-do!JqElgYT$mY*haA=@Y%DD35`GVYXhZFU6q5 zB`_f_?!?;aOe3J0xjCd; zMX((Pu4C9StNb6&<;R8sWH|$(qodIiiR!$TeWm(_?sFPTCX0cya~*~j<(^yQ<>bUi z=C~y6hp{>KqwR{a{+w-$V%;%dQ`atCx)eg9JwpF&_v>5p`Nk{pq89DdR(YsU_3X6NBFR;o9QjVi0F^K3AbdN5=Hb%;k~M3TEjvo4?)03x=|- zdL+<>3Oc2z_TWL1aM|kgv}^aw^z{A1U!&(mu)BBf4jjx;NQ#e(Q*v?pnYjqQby~vB z-JSKw5ofic@87?}TRqvy*;I%h&AwO3Py)LqlR8%TJF`wnqT$=O+wQP62`*#QJZljw zjl2QVnTG7e%*+hdB9#(zbsK)7SKs%b5UKl9^b_Pkk;A51m!(HZx(b{-rwE6p zxhtixvSXVoD>3FD_C{7#Rv}PoebHFdvTOEL9u-{t^wlQe{Qj-mw{JI_9;|U$qJ_;h zU1uhGtSwJZO*Q|h@ZQKCkG<}#M52A+I#yd-t5lfWt!wHBuLfoi91-EFCP#jB%#xC# zx@6G;(V?=kawI9jswGHSil}Aae6wZBc>Mdv_#~zGF141CivwQVen-yVGXDBXRaG@0 zR7f`oZNQVaUNw#P6t!iWsr#2$H8(edD-V58AD+0jvM^dFQbvjQmm;O5ofgvm(4Urz z_PF>nCQSLn-XrI3l$Mq*kLC5*uP+TnPmkWd9VxCVq?=30YLrU-{P~*KKmVk-E{reF zwkp>(;uNYTTBF3Qqu4~vaZ*lQFBQtcL%FnEECc(L9^*ZBtUqB)!wcRW+Y&uqveEyH zxxu_htk)@id0~Q_*XxqH8u|}x^@l6-W9H3qmHdV)eLH6lT)TGdg8i_HV)zB@#R$dA z4!_^v3LqA&JF$K;G;2wCIbO10z{qw%?x>iReUUx}qvU-aGW%7ftOxb86m?~|1lv{Q zNER+#ya!v#5JH|#kwZG%wc#P>%z^j{L)WpKERQ75K|~uRCP%fz-SnlTYKioKcQEfM%#l}6$g%E|a$gzCb6K0~XiHH&+2cU9 z1ec&T(Obd_z~$Aey^7IN`uq0nqj%+`Ce)T#5m*i$OiEIWp!nX(Re7O50qje@sT2*}4nB0c2)Z z%zSbtCvCvWWQ>fA3c-%;hu%BVBkdZR6R<(++5Y_MNhtt*kn5zR>*h%_#yFmWm^Is` zWjL}tO71{LS0`L_yDm(m1NIg-(XGGed~>?4n7&=TX!vEv43CvDvC66{3qW^lSDZc= zIQ`hJ?>1FQTZnL^Q?PTR9bw#aV^l5J_#NAruf$w^wqwMauo9t@W1dK>3ky^B)_m77 zhFf2oAP9!fSz$(mDpY$J2&+C;K=m3@p zn|b)T5zcWR1{3q^oiw4;s(9*Fx?X`AgbUKREe4Y^g(#=;^o2fx;Z9~woHr8(3g6%^ zhrJFNs0=l{uS=E!3d+>m6-X;jU6`b0*dwzL(^FDXcy3rEY%7T=>>wu_v%WuxgJU{l?ZA8yllg)cdus&k(-5p&oHyG+O@7op<1cYq^T_ zglWHi2?z+tAM{NmF`>DUVKhGcoMF$NkT-9Rad9>Dgm2l>b7TGRTgD2Tqj5bs@1)!d z0obVV%%O`@+*?{(VS~ogg}0rEiMXbx7nv|%S~D;(P#0!uViFRq0clH6{R4FagI=s@ zb5>?1Y;fr-G#$8XL7B0%pUuyLs!D=3H$5>F;9>fLfK=&fN7}U0o#= zv9E4fGd7s@b8Z&hiH;GJs_XI-KA(Qd0Ps z*%=#xd#tonFN~_0K%Vc>w0L zLeQ+`N=`~LpZQgfk^y+D3m&UZ2w`KfSb2H*mTa?D7IyZufRnQFfhSI!Ku{PZHaN-0+(|b#b4hrizwa`v&ELKSmj*h0Hw}c#vP248t*SB{kQ`_is1DmFg69Utpo#-=6 zjAFo5Zrg{j5$jyR3%}MNd0doMAb1D0z0){%J@J ztG94_w{WkUu~q>j(q=U%)rPP;_t64 zs^Ozf#JYoBKDro@nUxh19!^R~;BM{MyUBLtDr8yJXvt6!ZEKvNq^-2H=fYR zdo%ccC9PfG)2}5N!xIq*?9c;4|APyOlV6;~T zs{_Cd2oOB}@D$7ID-(EtK!UIzAo^$-8O0*+4)BLhO3DD47Nv|p@Z*v$b<>ZiPkMQP zmt<7&oMHEF)rIk%oclF_@@)%n_f(BkO@_UD0bxE1j*9YVnE|97g1|s|xs0fFubz6+ z?SP6Od;~DRVE|q2tXg`p{rz{rTt_zJ=g~fo70AiV6JuhIF&;i613)R&#vROF6=ru9 z63tY|Ai*};5Z9^!lnATe4a%&B~!gyZ;W-^w^(QM8;k&WLOp zjHmp^&PND=_4CWiaPV-DzEM6J%PPJ-a}_cnN|>Ps5r`7A zrD<9h zpG`afKS$x7%}1?^V=vkLdI%TuTAHFnbH2+W2S2LGc=Y0Jgcxn@?0B#vBO`^=wSuCL zzMrw|Exv;gB&YjqvwX>OSVEVn%0qMGMHA+onI>Ijgfj3VvC~6+E~7#SNkTTMAusO( zz$mF~V{HPBh}&$eD5z0A5upPU*QEb%}-tPx)?wo4!iXpQ0r0JCGP5 zK|%4R(e9eyqeHoYCG8z^0WRkagk)G!-BJT^bRKJSS_s{=Ol@h5R7!>{o|0$Xcb?Sv z!{#A`K$d@| zUE2qv>tj7dTCkM{_^FF4D+rcx{^fx3Lx7IJ(S6J)_{FxgxcG3iJstK#1VX$FB|99u zAA3J&h$I3!Leyc?bH<|=fGTE4*Hwi$T?pwJD<@|LB-4<}-X85BYZ(|sox1%tW(Ol1 zg1>tXoB>pAU;joCQvOA^Lb-!^d&j2`&$<2{Has75SSor ztQaEF2Ow^m0VEO(>6h((-Jbj1sg6?Wn}_rhAgK#eKI!oJcV`y9PAG2}C3rAw#5U3a zISWgVjw=CJ;&dL|=f{3Y4KcA0bfo~?Ob>bQJrYMQTh{@iL9HW0ALfA>;cQsM^ht{* zpr72QYk0w7lkEYi(P3wkn=lR`kAbJ7qu>yE$&M0xeJ8RE7%P9YL48Mu7SKI`u%?Uv z7=|RmIhTh~$vO zsE^@lyx+VxtNZ1G5D3#CtVB*NTbHfn0vuIZ(z}0u@Q8IDsV!K-Na~O06TMdPoH4cN z9|j^n{CfT<>FGi!$G#vR;5;qkLGNQE& z4ek2IR=7Jq_F)5$*U;>1tIR_xudnRg<=bMB)ps&s+o|jS4V!8i{D(i4{xD}}Jza5T z&_-g=VlJdoZEm0Bn%u_+r={bbK7A^`eb!%kT^rCyGhlaw%wa2!0)fCs@PJsn+)=ik z2%fperh*Z<2-?Ji2=?6G>mtRIPYnobcn&EyZNwb+ZLB-$1J@Rq@GlHp2DdSbQf3J8 zs>w?4Q5Zzw!Ho-Tu=Oc_LP~zVHjtB+iF7VVbzgHzlBC?1AAWsx1Uz!#`tm5`%BR78 zuz`SgPbo*Gf%{NKY~-b($oQcsS0(V@9f3RwD1pER0-)rUTpA2QB_|t3&&!)DRsI*7 z?Do`~kA|l_$8D-RI?iud%YQ1LBjvXE0r=1zs4PWucKh}#AjGvwqp?A!%-{ zNyq=y{U_*&rvogk`(ZO&D@euxuI9l4X$622lHiHrnb$znww1cOO3qQ#5uZK>ubi5Y zaO1pb1LbF>Z(EkBGFA;je-2_*r@kKL-9AfHPY#v#0K#6>ZP5l2Y5~ZWAkMTvl7dGS zg+TD$N7g1Qm?1MtpL|RK-U$I7NRGO)&EkOo9j)RbP%QI$lKux$iuvcCzpyRWnI#jT zK=BYDZ1_dXaM%dI=0nyr^l@s7O5VOrVNYax!BA8xQ1PVe-b`%g?!VZ<%$b%-F4Y0I zC_<0|Kv3)s6*D~SDB$P>1qBrn6B8k9HDRgQosS?Lp25~hK*ef(V{y?5tEQIZ_Y$#R1YQ@-^vqVbv#&9WTSjAstAf= z-nC17Wa_2sC*?FjqT3p2a%F7eqZC||+D1a2iRCBHWB$v<&;K|qAwcaAc2XTDdR>0L zkfsbzy#oBQ(&>KUcDNa02_tx!UY^)M>T*5s5Ay!BijCwt4WR}Rnww9tyw6q zTdnj_eYmKpqe@w8ft~54_ev%vCde3*9}P@Q7$y?VVAfx3KC?gDV|~eV4Ol=#tD&e5 zq|hD622pB}YtfCT`HThTg}t&LgR7Uh^hys1X0YX%F$9T6D14TKqSdC?st?xeguk1DY{k#Hvyv}k_;iHO5a1N zXu+Wnipr93^$3^|4nAdNXLoB!Tr2N9u^A&yz41uU4lF)buVdon?QH?M0OG!32x!{s z-=4h=cWLwH@<;&_65!{@9@4XU0{GM(=+)>8Yu{UyrPBdFJOo-8amY|%OGfYtJc5&# zMLtyRW}zxD0u>ZhD6eD`(gmPcqmPnBLH8>J6EIwtU++w3fyATtf7+xoz!p)uf>nc^ zdf~yh!^nq9*beS$O^dC)^Xh=z$rvR7E2xxeYioP>_vib+9g0fV=5@ggek(7?#x6HY z|GL?Ss2;0X#gH#;(Ci42y^LoB|LhGm zGC0XL{jE0b`45mL5rg?xSz_?@hWzBgUJKDZn<#WL@PK+3>me-O6P3~L+_@8&0QvEq)4Yui zK}D!@Y+hQ@8UzGTJJz@Pv|TUs(K#S_fIbnoGpN`E^q%H-b99uCM1gN(XYz)O+v3Kq zG=<@#PT>XCk(+NC^ZGw}y8oZ_bd%}hh^$&i3@&6y!2toP>md;lsi47NWerQoPqVE{+j|5)VTzY<^n)i%e=5h`F(6Q9$vT3HIkrgh} zhx04ZxP{{szV&sFaHk0lHoHcr&PVff;i0temKQm?qmyna9-xhC%@9h`72%P+PVmTx z$VhgmV7T7rN}-ysJATml^1}u(k>C@fpyJ2^H6FYfvLP9GC$l9wzV?z%%zp3V7Ear?S6bl`GieE!RvU75HV##LGHHU@wqrYJ z2Scm(?@M-3Y*hceRL{!Up|BVkfu3YUbyhC=H+^f;kTE6*mkAa8pzvbXn%Bnls%=RcpJ~ znpAM~625r7R1?^U>c*ij?__bHbRbWnIkdw&A#=XT-kUC;D>E zkHh9ieT@#^!XJ7vu(zTK(xeQue86`oC94Bh)&^yXQ&^yHLR=Qc(}6G%IDS0VtptLf zK2*(7z}v{Khf)&|AvEQ>p~h%3xaCHU%@UBhps;W|)EPsdv@wa7XERkBHY6z|`^{{p~*X$J!Pv8pq#)S1FhVv!;S5_y&Ne8`VbIS8a&Kp@fH zZTocOBXtY`Er`(|+9`v36J$0}bd)Mvn~(Sfn?}?6J85O&a4K^yD_@#Xi{W~jgf8_^ zDS7Rv2dD zy13@(#IwNe>xCpQe?-ED3R$eXj(us%Il~MFbp

E`Wpa038$pq3#=#oqYinV}W!~ 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 diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 9dbbcff..da238be 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -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) diff --git a/inire/router/astar.py b/inire/router/astar.py index b4ff307..f77c044 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -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 diff --git a/inire/router/config.py b/inire/router/config.py index f5e0529..b9af31f 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -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 diff --git a/inire/router/cost.py b/inire/router/cost.py index f443879..17a18a3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -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) diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 3922741..ffb43a4 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -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 diff --git a/inire/tests/test_congestion.py b/inire/tests/test_congestion.py index 7512252..6126055 100644 --- a/inire/tests/test_congestion.py +++ b/inire/tests/test_congestion.py @@ -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)]) diff --git a/inire/utils/visualization.py b/inire/utils/visualization.py index 34222b5..44667ff 100644 --- a/inire/utils/visualization.py +++ b/inire/utils/visualization.py @@ -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])