From 58873692d67aaa9736901867a98b75896c12df0c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Mar 2026 01:48:18 -0700 Subject: [PATCH] more bend work; bounds constrain edges --- DOCS.md | 53 +++++++------- README.md | 5 ++ examples/02_congestion_resolution.py | 2 +- examples/03_locked_paths.py | 6 +- examples/04_sbends_and_radii.py | 1 - examples/05_orientation_stress.py | 12 ++-- examples/06_bend_collision_models.png | Bin 0 -> 43641 bytes examples/06_bend_collision_models.py | 70 ++++++++++++++++++ inire/geometry/collision.py | 22 +++--- inire/geometry/components.py | 100 ++++++++++++++++++++------ inire/router/astar.py | 36 +++++----- inire/router/config.py | 4 +- inire/router/cost.py | 13 +++- inire/tests/test_components.py | 30 ++++---- inire/utils/validation.py | 21 +++--- 15 files changed, 251 insertions(+), 124 deletions(-) create mode 100644 examples/06_bend_collision_models.png create mode 100644 examples/06_bend_collision_models.py diff --git a/DOCS.md b/DOCS.md index ea6292c..b6c8a1b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -6,23 +6,23 @@ This document describes the user-tunable parameters for the `inire` auto-router. 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. | +| 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. | +| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow best-fit selection. | +| `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 | Extra space (µm) around the waveguide before the bounding box corners are clipped. | ### 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. +* `"clipped_bbox"`: A middle ground that starts with the bounding box but applies 45-degree linear cuts to the inner and outer corners. The `bend_clip_margin` defines the extra safety distance from the waveguide edge to the cut line. --- @@ -30,11 +30,11 @@ The `AStarRouter` is the core pathfinding engine. It can be configured directly 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. | +| 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`) speed up search. | +| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | --- @@ -42,19 +42,19 @@ The `CostEvaluator` defines the "goodness" of a path. The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. -| Parameter | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | -| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. This value is multiplied by `1.5` each iteration if congestion persists. | +| Parameter | Type | Default | Description | +| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | +| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | +| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| --- ## 4. 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. | +| 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 for PDK boundary incidence. | --- @@ -62,6 +62,7 @@ The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion - **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**. +- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost. --- diff --git a/README.md b/README.md index af1915f..dffee6d 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,17 @@ Check the `examples/` directory for ready-to-run scripts demonstrating core feat * **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. Generates `03_locked_paths.png`. * **`examples/04_sbends_and_radii.py`**: Complex paths using parametric S-bends and multiple bend radii. Generates `04_sbends_and_radii.png`. * **`examples/05_orientation_stress.py`**: Stress test for various port orientation combinations (U-turns, opposite directions). Generates `05_orientation_stress.png`. +* **`examples/06_bend_collision_models.py`**: Comparison of different collision models for bends (Arc vs. BBox vs. Clipped BBox). Generates `06_bend_collision_models.png`. Run an example: ```bash python3 examples/01_simple_route.py ``` +## Documentation + +Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. + ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index 34e39f5..3485eb9 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -28,7 +28,7 @@ def main() -> None: "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } - net_widths = {nid: 2.0 for nid in netlist} + net_widths = dict.fromkeys(netlist, 2.0) # 3. Route with Negotiated Congestion # We increase the base penalty to encourage faster divergence diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index ad630ed..4937783 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -28,7 +28,7 @@ def main() -> None: "bus_2": (Port(10, 60, 0), Port(110, 65, 0)), } print("Phase 1: Routing bus (3 nets)...") - results_p1 = pf.route_all(netlist_p1, {nid: 2.0 for nid in netlist_p1}) + results_p1 = pf.route_all(netlist_p1, dict.fromkeys(netlist_p1, 2.0)) # Lock all Phase 1 nets path_polys = [] @@ -50,10 +50,10 @@ def main() -> None: "cross_left": (Port(30, 10, 90), Port(30, 110, 90)), "cross_right": (Port(80, 110, 270), Port(80, 10, 270)), # Top to bottom } - + print("Phase 2: Routing crossing nets around locked bus...") # We use a slightly different width for variety - results_p2 = pf.route_all(netlist_p2, {nid: 1.5 for nid in netlist_p2}) + results_p2 = pf.route_all(netlist_p2, dict.fromkeys(netlist_p2, 1.5)) # 4. Check Results for nid, res in results_p2.items(): diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index 3b8fa28..8a7402e 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,4 +1,3 @@ -from shapely.geometry import Polygon from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index a84306d..ddff8b9 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -16,26 +16,26 @@ def main() -> None: engine = CollisionEngine(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) - + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1) router = AStarRouter(evaluator, node_limit=100000) pf = PathFinder(router, evaluator) - + # 2. Define Netlist with various orientation challenges netlist = { # Opposite directions: requires two 90-degree bends to flip orientation "opposite": (Port(10, 80, 0), Port(90, 80, 180)), - + # 90-degree turn: standard L-shape "turn_90": (Port(10, 60, 0), Port(40, 90, 90)), - + # Output behind input: requires a full U-turn "behind": (Port(80, 40, 0), Port(20, 40, 0)), - + # Sharp return: output is behind and oriented towards the input "return_loop": (Port(80, 20, 0), Port(40, 10, 180)), } - net_widths = {nid: 2.0 for nid in netlist} + net_widths = dict.fromkeys(netlist, 2.0) # 3. Route results = pf.route_all(netlist, net_widths) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png new file mode 100644 index 0000000000000000000000000000000000000000..9088508ea9dba641fcf96ed27caa6e78f2dee49e GIT binary patch literal 43641 zcmeFZXH-;6w>H{F6ckK|5=1wM1O)>b35tM?l9OZs$x(6!1E3(F1W}M28fb!K$qFI@ zl9Mz^L{?faf_&iTF{=Z<^tulqUn9%9qIx>nV!IiE0VJy(>MIZR1UiNRnF zV{hM5#$fhLp#Kl-hgam1yMDo65{^>$993*h99<0TjWKctj&@eIj#lP|XPu4h9n5WQ zu5n+v%q_t6z{J7y%Ehy0j*fN?!aO|I|M5OYS6{=gwuBY+{$t@TOV(SZ<|Na*=Ouhsvo`WU)S#b>h`+eiTgWu zinygL)6qc6@0APktFEg_dw2iCSNQa4p7v{b`nfm5p zDkd`gpuwYh_-)=Pe}e-s3=HO01YgHK@=uuE!sNfxBqS)%4xGg-$yH{w{ zmtX5%RcF|qrCAVNlD^+_eG->{@8SqgOmkXSmujp-i%@{>MxyI-pDCa1P=$L_iFH3) zP+iB5ve(5v!yDCE>iDp*Fka`Wds{2jJJw6?^jF;%&4SN}3U7YM&sQ!mY3{(qd(-Dd zV=&gCqG&(2(Z9Z^nH0h#QcT#MsowDpK7CEvyIP-1Bd2SsuYk{@i#=PbNLJKi*(z^K zY-6gxqBkct?e1G;Q#TWnH1yG#!Sa&tRW!f+8Eo28u;C{T?O)nj8Ma)W>Jwi0c5SKQ zLcH0pudj~c@#i8VBcnw;C*%--1Q{Hn<(*JfC+>VOr8lApp zVxCbwr{vuxTn<-mg>q_Ys?)NXvT~AnXBvl)kR}XPn3PpeXx#Lg zMc64J&nPS^3Wv|F(qa;Ej_90J?ak5UgSFVV`;qYU?{9CDKRuz~g3->+%@I3sq23$) z!)N9nZe&>Z7jy9OsfF=+EKmK(8xZG8Oh}NZUhmG0h>ScR)szac6nyH+O)5I>>sh0a z!M$HV!Va;3g&&Xe$VronNt^7%krrCTdzrWYP@HFFop7n%7~B=_GZv{q%qnQSktVch}O0XQ{{wNez201g|fVT)sNl43?>db>TH~ z|KzudZQO+*EZcvWK4m=KM~^$|4!o4rA~eoexlFJuTm2bCW6o?_x%Aq5dm*0d+_@X* z*C}2s;}lmP{=B8@Iae3WZynu}rJ4NpnyGA@*h0^ z63XmzdudBmvYwTPM?o@xIinSs)f3+V?*8G!xbb4*D*gQdXNi^b{iu#^&ekiys zb4uAfufLNw75v8hVyphaj?hG}o=5i8YuB7e*(Lt{-=g{M)Ya8BwzR0@20va+3`D@ z<0E$5SpFEfD1Glz=b3?w!wme4T~#4nrQ>nh<8ui&o~Y{TW^ZnKVmGAM&rc==ac6eU zz^<)3H$5IAGV`)_CE03XSH&8^E>Jv?l&u^)3w021Pl z^xt!?znsfS7adAl3{N= z+D|ZZI7m^+u!!QGAs!DO<{hd{FY&=z3|CdM77-#QBO)S%Lst}nax*h2dksqZBj3H# z!RflYuftuX#n_N9#8b6;GxYlWvrX^n7LnNs;arE-I8k>9ZW7bR_SVK7<<(Vv@3k@a z5kIu&jR9y3CRnZN~Vre|e2g{tO^!Hjrr3iffyj|C2FclZ)E_==7 zo1(IE7*sWT2M3a<%gW54lSSXou3K_)a>5?W$KO1?j|gi*%1{yZoORfyGm^HPb~|9 zACq8hw3i9v%Y0hE=G~NYIlt{t7E}!0RVP84>MiG`iFQdEvDE~qKqnX&@CLR1llnXB z8m=i?1edsF*Z^5*#5{4uK9sA(Zam3q0U|29ggL57&D5jW0LojK>g>PkR?PXAIg!{(`m-PH zp*Yii<+QkW1uE}Qfttn_CRay8d&AD(S^OEqBO8|0Q@c~KgChUELG5CI_~wmP(Uo^I zrQ>J37TaZnCsV>5p^Rs`%nrfk>49Rr_=$pfqSa@+K-hUI9iLMsd*#X%`YVP{A%{gm zWkq*z@Zdq=k*CL5nFvrgS65w~W(G7N6Ib&Aih{x8b<3S)Wo0|wTruJ`?_gb;?396O zyT06SyEGEOOngkmEjR@w)c5lNU`0#c-U+GLR;{IZuZ~d2#a-7#Fk-r7IOp9v#$ZOF z`S&FruzM`Nzc+B7%Bxq^(#ljymYxh(5Q8bs0$7!Fhr9F^U;_8?SQp}eVKZro2(?tz z)3buReCn#MLo*-!<@>&{ye;k803j6x0mF{(&P=@%MTCj($*VuZP_=X#-goug z6Cjt=?10L0tGCN$Dp&N>R8`rpT~h%hh{`;}WCsTGj2?wL{JFndhm}l5G5;Hy8uYoy zAW`vlxy!6NO!~!>cT$=VUu~m2RI8||`Ib;(%Y%=+qJ8aLLWu!-6Z}bOLRUSqOT>9f z5y~2i?tbs5zC-?X_4SZ*f+m;TS#+A)IZhoyyK!q{C92sZ$WiEsm!b6HbD?&g-;(if zXuJ%?7O@L|);u9iP>%0p-GEA)7Fuy6LFxBj=!{p8hH z7O7S7gc#sZi~Ia`lj8cX#)@CqsJ!YCp{h$)cIY_#S|l z-Q3)uXgzo2NHaaYW!&QxAs5xOaS#DF~LkGDL<;^9}3!k$Tn;hox^ zKD1IEs_%cr1FpD4-?rsl%}6a1WX=3+KRNy|ZxpTU%PP#7v^Pn1;(HNZbeeCtz;)${ z5(fu|FmM61->@B!lz{0i0zK1BmI-|gSc1GcQC01#_VFpz;f6g)Tqosqg$i2ppQ3~R>?j|a-5BvuF(-tf+l)gmjwH)~HF#4Kli1!vrpQKT}9*OWc zzz`Wx_eH@8uOW}w%4lBmF!dar3^A{DvyK#O**X!==kWXPCGFz7VZ^=LB}49aP+my` zG>f1V5ue-Bm7!8(*8UHI^Bw!>bj||pMf)8k@R<*zA;KG3Sy^3>iBHhc;abIabP!Vg z`ST|)0CT8!XBy7!B^m^ZKuq{b@<06fSvV95yBp+FbHEp5a6IU_&|LQ8F;$H=n@o*!naHf(|!1mwdUJLLq-iCq~Z%LpT( zTn(fLUWOpEnCwV(|Mg0S*W}x&OFADENxiwfu*_X~Ml#qAAj7$U2cMSA!J0N%6cGVu zQVo|`0qu~zd$$YfXXdpQ!O;W~1@+UTWVI~7YT#^u08nr z^3TfJ+8UwKqvT5nlgIZBMqV?;)s4kPFO>xfYQ}q}nodqmtCXu~2!Mi!Rz?7XwW__n zT)hCI*No_d`O`4#;kM?-nu^Rj*&s-PqPAQraT&%3DRyW?fdBZX^9?EmI#D}*$(O5jrq}r#?+uz`6)M>t~GH--3yENUe0YpMh>;mL) zghlF^eFkLJWW6?)yMc2nnf7Vud!<77%Z4FZG2LHc3E@7n1T-$Aa~lGq^Gh&8iE0e-7c@vq#Jp0@fY21!7wkP^Pu= z4E^HVi5VyH3&s65ogm08j*IVTG6>jo8HI|b`9PdUUorA$ohSD78EbgHA(m?)pE z>ik(3XktF$^bU=-T#PLnihuBMi=GvoJ zpY65v&iL(=0A}xdKn|13oToEO9mf4hg<9Dt?eSU@cn>oPtD?CCbdfPPS|3m{GUvLu zHXctxyBIcCLVo^Lr-`;3M)e_)y(H^ALOVaX3}BAWxmU`Dtfb#>5LauH-MAaF!f5~f z`gCDuA+UJiCT~x97s+v#J00B``q>~&tiQD6MHxLEM4YfL*sksyr$pTyD~%CX%z^4H zUDA)&;JKt%@nNXS`yvaAGo`B}O{tEdL>5KFtkP}}7Fw?xR=vhTCN z&`=FePfsY&|Ah2sp?UuwDbgR+$ZHhN!Os4&b==NID4 z?7X75bsZO)1xUYRY{lkx%h@zWa3HqVZ(Hr9I4!=lsDhL<2x{2mR_z2k$G>%xO<7Sf z!4jvJ8J%CF-jCxFlXCc7W&iaYlf=S5DsoHe4Eiwf0u=GHNy_owlVLl$~cjF zFqs5;r4H<~YJ1%hA@8!^(Uyfq_0(8pW8+j%mvWKlf~sV__;#Wn6&_b8L?EpbGS0nT33)YNo` zv}v4ouM4Vqwq}8JsQC7WoH8OOwgYndV$ATSG}6*|yg(U6?{os`$8w~`Ppi_ssMuyO zGb`&NmaNbLYbQZKc0+YbK-G%b^Uou05Uqq_<(Ygo=hl~UeZ1P1faIV#j_9^fgS@r` zx1$77?fSH>4;MCC$RX{F=bszstIB?b-B|?932k)H3X%+sjLu^f#J38ObSJvlCMm0+ z(0g3rdLqbj7C%2f4@(i>S!>mLfB%MSJS{CPVmymT4qp8;5|#p~WHN}Ss!e};o(`!u z2SSCDPLngiM*|_C+i>?ap#e0^IiM}5e!72obG05S*O@Bf22=nT9xe5FSXZvTI1-7W zl8_l3mZ##+V-Vub15Mzw?CI(oezZ6{tglYmQJ|NHvH9-WQxj^<9|OAtU?+2q`JW%3 zKn$ARczNywr8y)JATEu~&DjfOK;-=oojw-#yUJ!zSWQEN1PVf~${lO#>=4oA?@w|d zk(0+Nf$w8}f4J7{vek~`n#-^k4m45CZ1u6RG1vf;2`giEg2ZX4jfsls4bk2@E03mz zdp2gPbE%nxyEMn`OQ|gw%*q8R$mP~Rv&_-FvK}~hM6-#dqgT9`7wa^`CD^hatDCmw z!xIkX<{9hD(_C0iPR!T`H)6)sU*;dRud$K3zy3F^zMR-dU{|uOTUjO!FS5=e-@J=boXSbuSUvue65F zLq9}K$2G>tW8=x3@|h0UT5PqKYsN!Mo`Qm`3YvfT1qGyGAk;T#S9`c@bG|7u zE{+?EzH{-%6B=T6$xz42uum?EU*M!8)}QO^EkSW88+)zI>$B}?JyfBI+zOETlh+o; zOYTiSqrUr)OnJ;?3{odo9H3C6kVO)3vq|S+fq?}5EuzNAQYTKK7jO_tXMlr9Xybl( zILbO5b9EcoJqZ~aUth5R00!N6^-uP9JooANzGxzV>KXOPQJlcEWX#CITExqj2^}5EP>ya#IjB)c$5?Co?+2Q zsr?}S@WVQsS<8S%M07O4V&gjF-|7vyIKVOQLveayeD>DYUBBy)q=C8r4^XP;Yb&&C zo>fq?dP#g?Y?6Qm=&H_1$=A{_s*&_57y&^!oiM zPci+Vy^grfj2ACnm<)3t3I4mOFDHH|6g*$3U^bUqyLII33Cc;(u9RWd{jx6wu5$sz zKCdMLlJ3Ff1NiWY6)D3YnY%+_>4p>!OBV}900F4Pst~g8&^;o_9F&5v`q6e!?oSPU z@diw!28m+Kxx5>vz$^-6)2YV?(24N!cO!U_@4?q2**c~33Hrm^Wpo$sCBQyZht(E_ z%?)fl7ht70vA-m@LNC{QE=WHepu=WGbTs{->u9j-ojc(`QXz3?LHRCjDHdP-u?Hm; zRJpI2ya1x3Zo8xJwG=5G!k7Wf3GMYS47RFLQgt_aL;pqAKc0(0mBfAG+@ROWpjL@Z zEMN__R2+`E%YloF%W2qa#e92nZGFCp2at|q!}+K#^8z3!TU)a}y@0au$d`an>Ht8% zthZBAy(NbbbA)aZ&|Dk*I}LWAJSbBU8djPdw>g(4R33 zW|u6Xb}EQxH-ulf6ISLjn*%7l^i%tf`1y@3cL)bQtM8Yw9RPy5OKkPMU?u5=9n)De z%F46=JZk{tg32qp6ah)+;?@X_k0SVjOGpk46tH@zoUe{x~C}2@*7Pv6VV_}FlAXF72V5?qYGsucX6`Y)#fes@N z<(-fNFG#p@Z!X{HsG>QBke4V3fMwIg{k*0vj0n*ouyXNako(F^Hcb5l1U%*J0zz%z z4%g86lEBl7^PI;*(U%3?;#L2&SW2&NGR5E8e@?U7yVAnK0xazB;Dis_^u9bNSrfXk zIGOzU^DS6vF~&9#C*ldH{>ZHLyLrwUbPm9{utNfnZwYEB=K$N015bIROT4$-51+Yy z?@TDJg`c_`Kx54?4u@+RoqJllwrz~eNmg@UAu#155oK8GC{ z+*9PaW{-GdZpFN;>sy0Ulzuk}Wlne~!yaFA_sCHEt(uaMY8*cIuRfHpw&+vn1=$7$ z2L2IlCA4PNw4jf5nyRoFpa!T1^`Smmt=nGPi%zHfkLo#Jc7k{60_;W{kd#g8%IHk2 z#4xJ;3lp51gxNAQG+dmkXJ+nM*aPMJlJ-gO3WM7HpwFi(#EXp>Lmlr06TUVy>Z0a9 zfP8=hvx|!AG6_4j!JE54WD?c~4Lgz*R2v1@XR(=?nKk|Q${Z&Rx;@^zFHW3;v;}zX zpNem@E0!Pzk0{N8yV#8%#f657%m^Brn)Z5FZR#h3d zN(G6B2>xR`po6xgC6@U&Kao)JM$yf?G-Jg9Jg_F45)ydevCgwY+26k1MTCH2Nhh>E zS!CveBn+_w93!Onkzj+Rcc#gNo*BW%($UgRe!JF+u*q?~ohJQlWoR3K8+?zfA!szP z0P-F2Fi4Z6xa8hjA2}j*dCYQ>$d{n9* zU*G_f9aOS;_sUn?eT8=?4>2lmLV7aQ z@WhJ(ha}b1)WGU9pZ9d)-7F|#7W!Cm>aS4E3XKaqqB~(wQt{KBq@`k6L5eos{|q-y zIydw6PK-tA+t>{nN9m@aB$cgm^lv9wTOiWrK=9@5GXaSQ60&+w&qKuY05l%XF(L^3 z^H&&3XvW^TqoSpy1^NK-$A3j4&)zGtpO`#$#CY|su<(^SgwLNH!F6)L_5zm31tXNl zh4%;WeY$+8yQ}M~VV)gX{06IEO;PbIzMB)*d3rL;?f@C^NIV6d2VoR|6Cm8Vq%>U- zt-btfWdqp3feDHqbFK&qXlCDy7K{Fu(rqss3;QM<;5rEB@cts8(9GVeH)2IxUX%TU zG~wCoRR@n(8@k9CoI|jf}J7x)p?j%&`UeqWvKW~p(5$*<|&-*5yP#IvkU-R@J zxIs6=t)UU%>eZ|CIvQYsA*Rezxfmu7Oaj}1JW(L9NJj-#&T=`LtlL})^0iThmWYgw zj*f^oZ_<%%R<$t$%NNaRzBWGwIUIRdHE@5nxx4o4pMa8X!JE+A)s>!>#v z;ih4k4Y&Y4$mK&J@z2{XAn)b)7BW=U$p7G_35j<*+9qcP!z}w#L)BgCU}_DGD*)0; zmoxbJ{P0_y{wVCF)_;-VvvJ-)I*!HE#F>V+S_arhd%6Va&c; z+oqt;^@+&tlR^2$vCD)Qnd<4(ZLurfyO$1t2-$66NbQCG3(#J~C`%fen~@b9 zbLek#=z`&1(cO(rNOn&9Gn%bkV$q$chV&zQ`+Vr1N=5AiaE<rPvr4Q+4)UcSEU5yJGa^Df6s2k0+EFoD7{DPex_}qGOX8^=S&YMA{%a3wg4}_t&~7`Lz8~Y8M!q{lLs0^t*Qw`q!T6s3`=VBvW5)8#VE+$c81BD@$H_-qqXDHB z;;-0{4})PmMgGdvJ;7lDj^1!rul`PiY18XVKoE4pt>65n#$09~->@*nHQ{XlLHMMk zB-+efm>)k-rj+Osdec4!LfD;=I>>jw=3E_tNRdF|&nei3e9;Mudj3i? z6XrfQd2ZB@NP12nztuO0hCZ>KM|;yiotXlhdQEM)NBqW zIr6kwGqSSoK9WdiSqIS(+X0v6gJ#Rq{_RA*-5{t)qXDYsLHFkD3Um+JdVzei>Fx7m z8afqG^CRY&7If!j(0x2yt9awaji;Qu|Gi?7koUvF!y{k4QZiiw*h)ms_5G9N5l4dT zfJC5}W_44yn>QZ|yUZ{qW@0dAjAQ@}*24dd-s}5+@8}Zh4uo)cvj1=gxKnsV_7vc8 zAbVLNbPQ&IoLsQuTMXvu-*^UHyvP=~>DdwHG+~g>S$$etdo>qIH}FZtd<9M6Q$6|N5tD zV_bmu4pTT{IiKCv#b9`!27QLjl&+zsl_FssyxAQ|VRHy}-`@bh&(ltCloVe6&zqx2 zZH#i=@_*SI6=^lN`yZJ+ z#7posO$Yq6s!u_q0lAkwN!`f}TWKuKo=(V|Uk`>gy-dD@tiJ*Ki9_r~mg0S}+}JPy z(K|zn7rWJ8OMQS9C!)LcP4CXRqhH6G@|0QIn)9w3*Md7UN5*@*m{N zYVbf8>I(@cSe6&$MGLE)RMlG9HJ|2`Rnihwtr9uxbLJ5_9_gNwTN3^!L;wHybO1Tb zeOVCJ0NLbrRC@wMcw?h1RPF`~zS;2_K;=+nUU9>K3(+D7HiDUmx=hre)~DPte+kk! zh&GGh^>9K`EPIEHok_3>SyEskpMkaWQ7H%dtKouBm=b^uvgpb`f$oWp&Q2Cx>14nP zg@CBl)YXrnr0^L+_pB^xsesDVVccbO%0Cm22b~5)?|*b*EBKnfU3nk@i;2My{tC1I zy~x0C{$D?BA@jdMiA;LqD;rMrOfJYL2@DQag9b% zA43C{a5$GB0}Xk8`)ST(q3*DuGMH+lp&%HA6k_Brt;6IxLf=-8r3dtWf-WksxrAgC zzDDwql0u9}3YA$9+ub(_;GOe+;0A&M;*3(PF;YGJshKhX=;2W(_!z7m0GUf#T^ASFTw2Dmp9t$Nv%MK%aO!AIni%NT`X(ktTC|jYgm10#g*!PoOQCnq~L94YjUMR2(dduSxD9j*2^i--TDII-8NeWsP6QXZNc$ha79J||5zS2k90fU~TBd2syD6d^C!! z`jYkqfi3^kdp?b=OM8}!U85zbu52nSs0(1^0o8l@g)0s1&k#*bahr6wK{+*?aLMRejq7f>!)g zeDtM;!dJV0EmFV z89HkGsEX`HZeqhO@ayGkMRphAejYmpyazTsC-G|3I2CpL^x0(%yl%fkSDu4L zAJfusn1!Xq3CexI{f!HlS1(!iE?bxV3N#Nq?fLq_(-Z00O7dQIU1sr zbaS4r-a9^baxr813|TcC7seQTzG} zjZ9JOGIy<>Y%~p5sB0HjCZr^ebEJBWyOSh$RWDmy~Ot?|8IJRjv#@sB;Y;3sg81nnO2BNV=b(p6IKect2q~DzG%$1mK2gJb*BM4$$csHja8XJq){;J(5SVmbXo@24SatkS%FO&i z#mH2@FjkE$jpx(lphwWs(WYN~ufmtJC*H^=@7}LvHm=&3i`Y!gOI_>MB&Xgzp`->O zpU(5gTRTBUk9hssnLS`H3`g(*z?J5jk!ALxeoX2TW6I}m-1!1WLZ;^5_ z>fs84D#ki!5m`I^s^AgFWY~%H3>!PgL5qR_nX$n+Y(8N+Ov=90=-0F7;hlqa5;XgE zQ+D-K->j@NR8p>=u^9GApW+Uk7x2c759_7-_P4yTM-Rkn2tgh9=xzp73qDHm00YrIiiytSMhlld@eBh4I~K zA+hBRJvNF@#uM%+8?^Y_NtE<4*?WZ3hQGY%qLY+WyyK%iC%VaP({3K&(LGg|HSQftbdxJdzr1tL_D7>S>puV*AaUzv7#q1*JFau%t>-{y{ZlR zy60@&UmI35Y;g1U-uhlFqC<4AF((TCVYObL_ZnRKXtX{ts^Ca!7J$$%Km8FltL zCLB&@CpffO3wSLttDPJVU=}g_8PUFRuV~ieF>HsPi>`8~y%b*7Sw!;qm{yMW>s|qn zL%DyGqG-4=ta4-f68Fp8M?qz${m+#MXoe+bcHC5By6^FbAiJ+|*`oO|#RG@BuRrI% z60wRe1f=pBCIc0kl(kwvQ8-b3V0QQ*N)%bXNjobnA0)PcQ{N#954vyhp^9)y4#Hld z04RgfH_{TP&=4YSs-JEY()c}Y-2Tl~YII%pHdf8hzIooR7*EYT%i$7oN^*Lb_NSHPCGL{oQ63w>xbn#aYs(ByU8Iw zymee8ec5AWM$OHw6s(O7vls$FODML*B=h}R$!SVIRgkIB2^xOwL8W<63{cwu>UvVo zS(n%MUU=T)wNZ8P;9J>QfxDBHp^<|l!au~afNH28|cR$<_`>F`?lrfyt#qnGibu=HHUbkXJ$x3cZulkUGhZFB)V{OyfJ zH_MU*We@XD9UN_fLo(5#q?PqvOCF2$!l1}|`8V+TJIVasb8(w$hbgw0gXCm86XJDo zmyA#9`YarR2L57NPPB@=P-J~mpwR#wO~|e0RVdjaUUggc#6{+cXAP37A`MQ-iSYLU6=4dZ>W zHiw1Bqc{soI+baPdyTMli8Qx<1g&I|FE zOYUin&Kv~uhJS&3?Dy|t5CQbKF_^ybUn^H{GdO`!oEM$s3 zE!(O5v};TKfz8FxgU%OA6Ln*uK7Rt0=fQR8Cd}r@}^T zUZEpGE3a*xEZmb_Axz}QbEkytQ_gt&p^?pK?j-C~eE)UZT{_q3M5oc4Gal=$ASr2+ zXb&QRlKc(raAE-;FcX`-8mOyP@@G~~-e6YfrxmULb@xpL1>luMhLJEDK?0iV%Zvcl z%Eog<9t;=g#YAo5+MU(Qbq8hSx=e??>s3)J1n+aW*MP|g75Vl{y<5svPVoY6RK+RO zgTE&EZ?$eN?V~+;ve?iMT@u*}KMve|C#WB>ejU?V2?@;HaGkk_U#|_0e_+nob3EU6)T2N*BGl)guMIf0?o4 z@cMJAa_f@}0ovTSG!7&r35s`|2Z0i)W4u6{%4C_2=@5#ol#;CpD-WIO zUFq2uHP(g(Zbt!`4oBg27mn0AuALXxnDkH0A3Qjz?pgB5oiw;Ydtk3t>CJ@c z%z4_(zY5T#GHj?5u~x~ye@M1wCSb(~d;K~v zma9!Liz%Ghqo$~w7|EqkY-lXeud24b(@6+cRO}AcVcCuGy$za@1eM@Y*OjY{%e=y| z{Gha*Q^*I~Ek-@(keT)ic&Bll)9Z(4K#9#lleHUBw+L6L=(*N~!55P8>Nz%^qM(nc zD6!S1i=;N$?L1kAyDD3wA2(5LKO`+UPY16Iu#xc-drj>U!!8(JRC!^$| zqBe_yCPiT-dL`JBre?qrU&b#YyHaD5u0eX2Q%B=|kPPWeJHhzfd zzSx=lQ6f55j;0^@t6nGg2{KYU&2%Y6*6j-}&Z}cH@0}S?&!U8=H36^N_heV9(FU9C zx)xBI_;Yhm zYqO>@pZ9%LTE0&4yeQ|zi*N#L^XN1_CFR$%xM6f2l9P)e9(%;xM9f+6y7=awCzMoF z#-7#EGXpx2R>KL3A9D@$O9x8J)pW}=BFAW7aUaL{3Zn^r4DMbkQLEa5k_wp0uUyWk zqm+#nE{!vRK=W1aeg)f8c*@K5`WX^t@&-g&n8TcU7-u z>*VQ8_pQcSaC!zRSm#2%8fzL(wVoAqxb=w-WFB;O*jy%EGVvuGJ#vH-mq?;}tM(rf zUGwKL`wWNK8$^d?9tNHGa_`cMLi$`REXk2vd;SXOW;ej3;}4ShB;6ueBE(2}8mVic zX5t|6r5%of>b!5?J|KKA$wsOf3zFXcd~S;A$@{b<)erb$sn}i(BC5GU@-*hiK4^A; zZkET}(ffNvbk6LP^al@1yY%YWGB`hY$5|tp^kkbJIAbA-0J5c_r3kV-qk519p1$f- zBnzARjUYJ?`pw50eJ6??Tym!Jo(JdTW^-!TCkdIxg2Km5jv82)hOt6?X5sr!ODBV* zmcK(5Z2RUs)u?v&rff0^@8w4v$n)Y--;4S92z2;_eIPv~Q z@(b8q&*V`MaawH7ry28o7&_p0BAtDV67023aO@BQB9*nX7Ukl&|Z9c)*&EIUM?sIxw?;2h^jB{d6NXqB* z+Tu%|`1oqobIwq+V$#XG+B++WUB4&u80xu(Wc07qP`?ZfOhS^~ncQ3lc{U#UKEvLA z#g`gBv22T8gjL|h4MSlR0p8`VeIVCZBT~5IZfjW^3}-jgYLksN9ZgoXC@_Qf$iw7$_M?z!NTTm;7mW4I^4)7 zw7gt)!m+|mzYeIKoC2vca4xOOGQ7vjm-AN#vi(0hjd;_AS!ZKA3Key_|bP z^k%Dfs=U9);Q;hn7dE$a>t{Gf2g+Zc`waCKku>ipA|LnJ`Ec>Ub<45-3gz*5(MOX&3_4N9;R4^S z>{re6{0O@A;>?Oq`~WlG*2iU*>=Y=Hda^CYC?6$vqPD+FZgoWd#YO4? zccQqnwa;QO0neajW?zKU8Y(>TDVf&84N=3Z-GuVPqPmd}nRrPwEBN&EbXu;x-)2## z{z7h5M&Y^Rhzqwn3pLSk^v(1dN9hp)yCEK3As&zHGL3b5?N2?G8XUhZnW*^w6F-Px zTlKnW!5(;CQk-~tSy@>me(yJPXzVCkNtAhzvZB`WBb>onL#a^4&r+;AGDC$b#04!Q~i{qSUUogaTZ{j2$1rx!y(pt8N^AvSi@1SL-f} z%waUdHt4Q&x;f0PRsG}qzD>U1;yW$Gj20ud$t4=G_N=~@%Y#hTEzl+FxYgWQ#9wlE zPfMw6$eZud{jY!jX7}myaU4sb;nb-&&hD{k< zEC-_9V7**-wfL5n%eT$!z#xVyIff(F>bZK^B-^)%a;g6N_3gZpS45&x2$}XEjEZHXShK7=ao?}o#s zUESe6i8Zu4N;4~5(N-j=Fh8hldwx^m;6nVB(Bb$lDkNP6T4FQ{X4}0KzP-xcz|wmT zspXds1Q!e3JZHSK!gs!Bs-4-}Ckk>#Z(ui!{Q9cdY0OPrI=+eFS5(+zO(EO%>CuL}` z7im}T5i_kdwQVR}p%~l<7=Ab;1W(;SosHq|A!WcA)s%^y+En_l<;$}gUfL89x<*s3 zjghY0*7upG(RG^+F8ecZaJ`PlJF&2^P(6#$>JR>p_p180E#rxweEB=_7&FQ;Xnu%#Y9uB+4*2wma4{Y(H za-?)4o-!p74WHEUq4nFgJL4?6p4)xsq;AC9w|&BXdjc9RA3|q>ax2!OPhQeYBYbys zViJ&R&=jR!9Z4rg9q1}f^zW}eQ@h9-m=Hk_AM?jayyBR@Hz=QN6hnQb(J?&b!-I9 zs7l*EmJU6YHc}gax|^U|$R<@|_Z0Z0&>oB!5iy9Tx}%p%9bYJX)^tOxQP?Q@n24iD z`h9LJZQYvIPW)PfY|Vz&EXJ)daPUf|;UfcM>PG|V#t3x?Q}9W&$e>tVL%0>=`A zkKc7{28Cx%^NeD^wq$qWj$-8WVD{r?c$!6$n)}i))d^BQUM1kr;hA9#*ht7T01xZu z=qNb1@EzPN%(nmxYEc<35I=Wy{KKHLN7rvy$8zx7a(nq@EkeHSj%_t)*|v@W9- z)p46W(G}KOlJkIwrh|sN4pxd-F3*ymDnM2F4|w^^(Agxmv*{oT4;#^{@+<|*j@)Cn zxzI|J3r&>Jan^&5w$jqkS)zwZ{C;cNRqeB57Xu0xdJs=;<#P1D+MCd!0OI#NqrG}Z zeP5%K%BUl;V5U@~X=f?rYQPJX`Wr0N?uA#XbUo@yEO`!#Nb-%PayB%#w+vVl{*0|q zs$EoipLtilT#X+ur5+^ZjF&q8C8Xl76gHLoXv zfeKO|wqU7gNnjwQ(l`>tUwFj8c0!`B!7EYz8$BORxQQ_4E=!hujcLa^2asNt%;sjc z(F6^@@~OAbb?=#kQ$P$buMVG`paF($@8SU{NNqnpJ>hdAs>;E$fQHr~U5BDRVbqn5 zP9>nH>Yyi8Sbu+i1D?|nmIBW&(VVT?$kFrJaspG%3>YT)Fh0P$KC9v$P}j8X!CB75 zsZIsd9N)&Z<0-=>kfWM=g^rW^{OHT#QDB-NT+7Z1u~%F?^#@PTNRf~$+PzJ0eQ(uY z&wDi9eWN;_|3fgeXcD3=gc#-frk3}3E8Z{YhyD8W-V>JF9vUnHK6kxe(B>*uGPB1 z6P>|;$yKFsb~ZM3d4+ca51;36@@)1-L}iv$s7jwb%XCu$;sKy_2GgA(H5_Y+&H5md zFdW*YUf;us!Gxg6djm~NV5ztL5U#zv`1!cP1RMqJLdWc}(c(T;(5tWnXYkQEe06A5 zii4B$=t%=q)mHr=nl4N*j@IasDC=?pwU?1 z(wSvvYv%xXWi2jp4-=*|4JKk)^ZQ?5v@zg zU`@wfSXB8cICZtZaI@L6$heE$ zN=&%SA~@!rIz+r_&lqy|k&C3{&y8diGBjMV=HX6k?~k+Qah2$Kv$qcCqMU{EX24z0 z^Kjr#=jD-c#@)FxbOMY8`}*@wB{vl!fB!aX>e%_Jm#A)tBXAVGa@kdUG5n4FyA?HBH6Lgs6(`#s)uT5j?Ag~V9shZ?EhCQ`29 zxqjKzbF!V&1a}PVxsg}n9l9}U%I}gDJ++?n7mDZvDoiINR7;1QSnN$t64WoYAFmPv zD}y%v)EV89z9~x8*O`!!chC6a;`(E4_HFTj3zTj(YXe#EQc!o=o7G#Tl^lWC+pi%( z_6-dZ@_$MCEn9WXZFvwiTn2MNT_1TCa*nJFmNkIOj}7bSB!y_fAs?;_7cLBZnRM)7 zkh-m)KqFp#j*pKoGb7`m`0|=(*s`34F=2A#AbKnbv`qYCovHu|2YLh9ID!bX65cm{g>h3bXIh}VdNCyB2Gm8 z;q=upIc$`af0p6urd{8~4Y7&zivyQrju-SYtgo^t>gMDV?IWlAxMK#-DHvuRyDH3a zPc0|xr=eQU)a&f$t#aOFoWF9FqzrO6DIJW9?g746zG-5rnSJuKnpos)!>i#ju~mA> zU22gtYR2tn)|RIf&4<)Ww~U4VulC*ps;X_<65VD+0YNaJfC4HRR8TS~NrHgD1_23* zS82r*r9MvRpW>~h`} zrf!PS6)|K`4!M=di-kp+1W{d%tEikcw#~XpMo=}EZeXtd?y5}R&nIP9>8fWIDnDJn z+`T*PAP(E{)6V#n35V^09j#da2~LCTRUNAEx;|2{F!joqOiQ zVX~v^yDqMm4jCq6)l^vB_fK|wk4~eNQJ&K%0&T8KAT>8VJ$=PX+REVVoTjcgQo(8F zgV+kK`hJSrLbrn6y>mL_2)`>py2aMc+1c4vksq`JU}ph&#?NrCsW6d}!dL;q9gZUqwB+;5ccHNM zFHK~WaG*m0Fs&tUrRPq~YC~o?Rw-HX za-vOthszljAD=F%tJH!i-z^&>CXZRNZDG)ZU2s8o1Z_* zx?^MA7H2xsGAPeKxwYl#y`---#{52So)i(jwxYIaP5x}xq~JmGUKc9r3lZl*Ln93| z65XiHfC&H$M0vog^H@*f?FvM-fyh)MXedlV@GB4qnUJ0TpFq}6f|3u=7tjuZ*kqu{ zP`tb2-~>vAU7sy7ZKgHia4S01#IMPYQb6eP&L!cUU`7ZvVaa9;%nJZR`)jwiuTE24 zW$ecj!Y`_HF2SP3FP^3iIe%o7TAkg@{VG%pzy_1ha4wY;CASyOSh6u{+YkU7==J9~ z&2DsqRtM}VxEMxFAAm>PGi4(qNOJ5QQ&re{*~1#2a{BEBo8*T!$;}Gg26r2zR|L(H z5R(tI3y~1okrWcl^PJa!VFHkL3jpeWfT$pE1W5it93&pGN+39sKbt=Vid88{><;jD zX6sbrxMh$MLKG@coRG(2{Xx4X8HiX524|li29CP*M6)Q8DuGN%X$^d`^%|%Uzb!6K z3}mXR8yY$+mo$~W<6M#=&#jtdcDCx(GE@%=jJwxqsxd*jVByxTk_zqGZ9zpbcKU7) zVa3I|0!D)Y_yQwm`KMLNu8G-x8D$Wx1^Nc)z4`|SGmc%-I0C5Roa)DC4-cO+0VJ{l zgl&QB%6(*;9aaeBVjVa_^t+VAH&QlO901yxVG*YZ-26g9l7auFaz3x0lPCq!I4Unq z0U!q&J*@!F(VaR2f<3LRt-75`YCksv|HdnM`rHml-z4bmR_a+EJ^T`=lybnYT-UA^ z89#%;)VfC@5EQ6%{Sy*aDC9FJ5Z(=h%q_}t0T}=~WgMi`Jggvyh|m9FKJ~pgbbJ~3 z>P!fjN(N|seF{02)iWcknkdYKgnIfw(Hn2;BVF z6_td?LS29j$H>O!EyM|JeUHD9lJLGFKx82#=RxoaBT-lwbJ8EdV{I3P4tH2^{sV;o zC~o^*gG7GN!$zmp1m0-&>m%pK7CY3|9hY)e(U6_*Dv_Od+$cCZ4N&nPYf z8#bhMfwY@nZx0Ym;G~TM^s@~-l0-RrI?#~=6!>9b4f1QCR=~MUmYyNchl|NDEE(!0l4$n&A<3WrRZbwn z8^#nn3c&%BVowi8!P-R5=vjM`gME*8`v#2?OfAAe8b}XboSb1uRdP)ii~2zF_v|l5 zq=>)R@X(#t1T{u9>I4|fdx#tZF8=fhH^qVOGqr1e2(3N3H=j(*R8m(PjQbT-DLWrS z`glKt5ilaz)b5MbU6IvETmRh@v7?HH4))!=p`-5D0 z9ee&LXCnpXdF5Z}3pTqmfwTGQm+0K@vt}50D@G;Sv3frqnM7ntx}^ZTiL= zsiu@KOZm4w_ArOJ+`7losWSXR&s0Vml06&23we|4q`f3b1b1E_UFDGiUL?c1G%C18~rHn9{j490nHsqsP@)+wvOj-lc~ggr}qxiP9s%8Jz!R$`Ix z9x3)lbJd8|_)jW^$#5!Vv))nu5qqap!Tne?qFG5{v_sqe50cFPg%Y;|l@Jz70%Cu& z4hJYvCju7$63-c`b(?vgfo*^1&K1Oe zum^jfxf$ekiphc9pyiF65owC0nGTns6VfOGT0et zg1u(R&Pw`a36Mca%$lpAzWKJl-zN$JWu0I=*~Fj6pw$3qUnB{ii}mMxqe=GMR(dwJ z4u<&9PoO~4Qh4Ms_5S=SV9m<_jvPABG};8*1w@#|3)+FgB85pu<57EX$3OsUy^K#9 z^6vkN34m00kX$p!a!ov)yoT2$h4e?&EcR_DD7KopHx-!(BtNs2U9XXzp%+^*q zrkkLqY79^s^8A@FKK`vh<$pm1Sa%cvfafUKIks+<;^g^2k zSUf;cB1`N0i=&DNLX6Ru*wah;PZx4|{uu#5fGr7J2Ori$=#rNIgwjvQ0V4$1I>0sl zf>dG@Wcjg>8c>9Ge7y&vT9A%Dj&=AA(+Bo^;Cs*{TledI*b{RlNkgVF;-tNK{BITn z-}o3^UhIVkSuj(>fvd`|LU;M{PX>PO+Y)^bM&?))z3VBTQtowCg4S1@+ z7i??#Iz>#5mw;(yeCQ5D$dR#vkp^Q}ByY~SvZ+)ce(2$m-KkldUli)f=Q);F7x{{c z_+*b!xt9}HH*!~Gcf9(3PSVSg0rq{_j3unf`PS7Rl6IjtW3PVo4rxQhsY4&OYrbCAlSW&1_b&6>JA^*)w&xxAS&Lp5fH@|4wM z?NpOCPb)G&Qv1XlgSV(Kd993&NeUX71;C`A+Toj4^!eEbJz$4m#@lp zG|ozN6~^DW6m1$J;w?OJ%v6W&>&OZhjRHMBWGe|^$vLj8z+`x}NAiBW`Y@Kb#<|pr=;zN=B z3W2P8U<@pI{EllZPq{-`u-DUE5-eO%>m zmITgTuRPgo({|P!oQ=!CN`&6iXQ;4}QIsrX+WKXh2^&$*E|tG{91LWN??^H2jU*JZ z`P{w_bCLl)Cw%_dt%pDSi~j#wNAX-ZC;IdC=F6@3A814`PCnoN{X^1z`Fl#rGj;v6 z$#<$*5|zMl0$3c4y6(q4|0>fgHzp>gGq>|09u!!&V!5OZpg?h7&@}_Sw!49sB%pG} z{d*><&cCpD&g+@L#49J8Ppbh&-M>raVXDx@lM~nXVG@9YJrHSEq>(qIundcp#OOM0 zVO)DUi<;%{EC|(6BoV4u{ir&&;oM1Z=;h|ldd>qA`}JA1C%GZREDhulpSUeG4RSur zF4!U?2CNm1n$_TtJ>D#6@mv899?aGRfD7C4h2IUdXaxdSW3~bt_MXb47%88u-J(YY zabpM1n+)gXW7X8UJ(0c70fZ}pfrJk*CE)Vc>W&6wnfXnB=QV3)p&cR{{%p8X|MU;; z)N=Q*cVKS@+lj6VHhCB7k1FRk{M|`R@7!+>fgM!uq{T}P@{}XLe~*JeuWy@S-;HF@ zJO$)1je7ZgC^e86mm=39yTHg)c7fW8EFeCyFn{p=Vh_%+PV+uPe9MJ77&v#~jk?ow za%PLh=}{jGY)RCh#F5YCB>yy1@aj$6r!GJ8nZk!Pm85QWpE%sspi~Eld_W(`j}NW&Ifx%UMH@J=hOt)goq6!Wja$<+k!WCTv@~-S1L6?|%}VB6yOkLh}IG9p>}kO?cVM zxT{Bfi0Z*j&S%WH`Ad3PSDOx-AV)y_$j;M4=B=QeF6n9At`;6X;EAF)d$^f51T#ZZ z?hDHYpB-dG%_Jg*Z9!50jOMe0{Cy^Y6*}H>`0qx++VEIoHLrPXF~G?HULMxO zHx%bQcB!k6!BpP^xr@BtKI3Nj%Y;ya1Kphc#(tjX2|fcB)ia+NxG%|^KE5Uh-rlz+ zkn6da6ayO`?kx2LS}30jmtXeUC*of#lKZ0X0mIn|N&Fv6kHY!5N$VQZOi?_kaNq-) z&*;+s+p5jsN%@hfiHR#H2iB~U6%3Idhy{TqTZyNhLp}qcU!*2*ZA+w49w|G<%LgU%M1KS$ADYZ^u(6GadqqahX4}>rB4-6HVdqmE4Xzs zsdiTB3|3Z2CFJ4K@qEV2;bhHTRpbYZpM@#_sH%&Sa6an`D9au z8?vusLkO(<)ovf!CiMQanY3RuJ5^-j7%Q^7E{wfPBK`#101;$VGP-8~2$GQ7!+ru) ziOGkG8`j?sowbNiIMWXT7e?QJxUv=26La{VTV8!-kwvUlvA-5S}-kW zKGh>BYtF#1I)0qGqAbDO|5Ef6R;oPPCcWZiJCO`96A%BGy=kUbtY*wlA>`k*-AKw8$TVPt|vOlr^8GD6~g7Mt&|S zFQ+Tx8L{Zh;x4%pvc&ZJvx?uSH4|-9YO8JF1sk!BeHciSw*vdQ4FqeUiUA=0rBQwa zsLF9b!iGq&tX`;v*w@sWk!Fx@S_buS&U1}M|IET~1ab8`);)zL&4VC~2OwuR=9`Cf zuyRW0GU)Z2jkG9EhwOii)ih&i3$HGJX(OlPmSsj^C1EXhxsAN*bNn5d0&C3#1G=-p zKAoE)ILV|tNkj=%&WVJt$y$r8vHOI(J(IFi6I21g!eCLq>-1(Qph8DkuKj>1oBr9s znL~sI&kRy+0^22d9VOpbyvhw6u&Dg}>o>6q(a_lFZpTdOQGGMfyo_goOyvg0-FIP> zK|XkfH4!WGoz+QQKIw($YeBAs^QUKAo-rj8CNh`EpI>6q$yN1REt!u97{IldDyY=C zdPnVwS3qPT=ZvzEgGDwoPJT#zkeKXoFU#(jMCIe|wLTJppi%zNT?HiSig@kjTX=#w zn(P3^OwcH@H-MOLKEPe9n}i3gkMXG-x(tX z(a#>oojI@Z+C|G|U>~%lUDpWfj%O@yb38+2bWC^dltj{OqeO-GT{#WoJ1yJImAk=e z$#hcLzfNZFjvIfE-^^9d!t z8GGe&f~77mHZGpwnqsn}yEyjy2JJL%wRbAkrvZC2P@%Ik&HhO3?f+$(Cr)Qk_uG;b4_`elbf#`*|)iM2pu%H42 z=H!ku=vkop4oG_wWP_(566L43`{T|L%WDy!Up)y*W`qcx0D>X%fa4fh$(g3U)5Q4q zGA`+sVVJCNSjP3ipX6NVIbkjJ76vKwpu%n!d0eQB`bXdN%hYTwc4yX<)3aVTTn>o2 zXQxB(YVvjGgFt@1qFhMlz#?_0(x=59fXh=9*#0k0~smlH)I(rP+# zBUoA**bKJDu8>_{7S>QH8tL18U#_=h#?kkfA@H4g*ZtslIg_h|?>dwE3_J);Z|Ir1 zB^ge6WqhT~nO}u9GDC&w@`%yxSy%5zu4esKJJgwKERx&(DIc7o7qB=_)vYmmsYh-B ztko?)At{`@Yc$svJ)A(iIp?CgTAbf+Eb%LV@Yjc5GDoi*x+8tmpqI;|l!fe$a4p-b ze&K6$t(OStM!pZq>4&s%jB$fbHXHF(gGyo<2D*KJ$W_drefgd(<4JF} z>Wm@Vjis$ig%8>#156~A+9tP$*0t)q#58A=Gy8INZB~E##;?A;cXBuGqdWICV$18? zL}4HTadP~I5R~izz3ViPoSKB-NB~`SZf-J=t3Jz!i&rE>Pl|hr(7;c0z>)p?s0L)7 z>!|d&3uKt6c@0;thThpyStR>l^yGwUIIW%6={8X-du^2_Yo1fWT@(2PhU?Y^rRkgR z6Zq`c@5{{f3f_2GWl1Bk@PX`e&$Y2K3-y_qvvNY=)ikcO&i9|Lvx&Qx2&&C~?e>ic z?4P|a)K|c!X1lgB9j)X};i#&1b>rm)lSrC8U0t+s8|QjlU}8#XhO`Z3{ozYBv?QSK^;mWEV;I z-EYuRs%vZdK|a``A`VxGZV#)3jas>49y@AOeTU$%F?U-0fg)jT=Z2YWVR;e58A^FO z&g%W+srVz^n#UHx^BhaEyn>Ice*O{h)YIlWX-!JTd`!|u!))t;ItBsFm9<8|sk zy9QRNGfg9*RSiq7K2VyFb|w!G52};E;B!|Wa#b13Qn&gYGm(HWRH=A?3TB}yY_@!9 zz(K5@i#pJP#C?1`dA3`Q_u@$BipOju+4#Fne;<;&-WmGTMsc^nOo8Aqq2Mhi+(&_{ z&0lJ3#uX}MjU9aXi#^UeQ!hWC-Bwo(@D~`Ixwq=XMM*<$UHJ1jMvH8Juh8b&TnE${ zu>-hhVpP;^sJP;V>SkqUW!=T?jGFC;O<1Yk>M?(5@8Dn!bq%IKb9j)y@fs5+2gmr4 zH9MC$O%QBABPC~g*BU+j4TkA1?1!-`@;v%rbR_X&C_*TTp%PO zngj*4(G~1#Et%S%g%GcyvLx0)hBb!$?MYzIS!0)HeAciu24afjc( z9t9s*ZV>@?T{hxf79BuB>)l)Q<;!!xh;0Gjz;UxGNIZJ%eUZv#%VOjyH$U+7orw=& zo?jz@8$`yetgadY<^cCv7@`gedw=8q^V|371#uk0GLr2Ofou}~63s|#{YQU6e*SVn z|8{*v6EySYemKo9l!=%=aK|3(Lz{!J0JpI)Wxu_H(w zp*lOn(fcr3j(dOAreqm2gpAxNd;{oEP#4%w%=YKwQT|s1r4Ml&efJkZOOVOuuJo`z zxH>%eTgNPT;l}nJtA&dK0GVF+E{M;~RR*f`xo4CzNQLh;{*Pm*8pANxa9Sn+;G)wY zU28>y65LfmmDsMo9?O(M4F3KT{{%6=;lj`txEQ1!16-2bH{F73c@2qB*^m^qto9DR zWs9hCM11H|Pw1d)MWD0~_Icl%Zj@!li{GL`bs)ys7k__0hF!IB4*5>fam0=T2T%=O z9hHFVLGLm;r{8ot*xM^YQUz5DblL83Vd=2e>AsMO!K{1Z+aC+Ci21dbM~J=hi$HMs z4r`F-$7*T{KK_GruX}rES`uJf zop%N2P!gx3f-vx8@5RKKHNToiK>@nye?F|;S1vjTGFd~KSJ61coDhO%hL08oT^>FM z{vRXtb_p7??BqPWy#9CT$ljK<_Vh3}ERhkowRUzULTyboNU9lv6Xfs_gCWCr^=~U1 zo5@L|bfpF@Z@P(?7Fpbw9V{Quc9z0$Fqs z;B%gm8cDZerLWUoom%H7gO1!AYJrmygu5lTMwagWy`xkGe3ftd`=jHvV)(ZnFzLWn z-Lh?9mo|pR<6j-x71h#xxyq71cC$sm3(y^3@FV(3URrY0P83N^a}k;g(tD#mNJK3B z)}o({28w}^aj&|(+sOO-Mm?rJBSeek$ z2hXu@@4V15OU2v-8Q92iG$SOOY-UP@S7w(g;R^5ZJyw;$eCUVkr`$ud-wmk1yA&7y zpT0)aNcbEcAdQ3ROvh~}9_GO7FB#iv87VH)4x%%OijptYUp`&*>K8tU(K?79ZlT6E zlbs6ICQ>*1aCCOz%3td;`kZ8~M4|SE(>ajjDBFA8uSy?VACLFxElb2+eMq#bC>*~C+U2L{-9_AYKPhTHv_szAn9}Bh*0k-8# z4D+S-G2E`!9KN|x|ExIr$@PgfIZv>NVAV{ba$bLwmo~L#UJlY!Y3U&2CIWphPY926 z;~}HJ{Pmz#klV+qtIw>NqjNyWbj9JU_T&-A5|seXp$6`;(BYOh_-N8qPoZ0lP;x3B)b62ZrA9MYPJkanN?x#4dOeu) zjo6Cm>gpnJxoO%n{6BM0i+cUHUyIC%LfFQHL5xaS!FPGMRWbDp7>QgA)vlWdQ1X0%=Pqwo?(d( z=?Iq>U&HXujY{LtQ)oReEiqf+YZz;KH)^Lb=v`&p9Wa%jIjR@mx<=k%4VmdDH32j4 z1*jg?C|x|sZdfnnB%@0+`jfA=>i59Tjlhwhop%NSK*z_|&qc%#kPs_Kt|pA2;yr-(y=qdM=`m(EIWch`GK0)Diw<0k)1khKjIn@H!$RR- zPqAocVm&`$*%**X{bG5YgChp68fH;`&a+||j;Q)=6yxsFEo(OH1}4cSD{)a#Sv%bH z$M$;(I1O#dLUD^`cM>n9Nz3b}u3ZJI_=-3TToB%E>+d(K)xP-J;$W{9s!Qk$g}Em| zAA;QQ$ioLHWPwj}=uG7fkw#)q!1!y?And zuhpxoHi)_t%=;|ui>m;%RG-6e+BXp%230=<@!_VFyd3ewIi>=endTb}g3*^*q!h6V zoRh0N;m$>Wwn8?3kZU%AIWS#%RGevrJILeXsU`NSzQc_m=uGTtda+>%)g_!jS#&U@ zKzQy`w6n~!XSu+ljE{?pyVl_WZ(P=e_~3rNZ(*34jIYFmH@ZiEHzs!C-VqLz_LDX% zx@$19t3h(Rxlb8hGK`0j>g=BTxdjvg*}YlZY$M!3iTImd_1dg|_>it%2*y#1tjmT1 zRId?K0HMdO?(Vj}zBDTy7K3#^KQ+@lQB*K+1;{1(@X}F@8dw6Xp`^z;pFMumF2JaD zb)J7oeHRC{DqfJ(04>-VDe6Pq6J@@g7kwiV($i((Wp6-@p)_|Y`@5j1Bk&GPM+hc= zI%;)*awgyD%L8es_Oi_J9%=Dl*eSX2I_>{zqyM+5j-SBn|K2Jr^c~Q=%du=u5~$AO z;lqcwsPNJ0S(eTpL_jo9jF~K*5;Vs=lXgX zb*GuO$#c^|^J6E7PDtXO=7l2hLxg&O&Qwfcj;juq)X6xH)oRZGZK zNb8O!TVP$HwQTYzTX4FN`SRFrOFIeqi)TORsEGIW?eK4|{9$v}*i2fjB6z3KU&ZtE zsn?w^ADB1CyLoXT&$4H#Oem^POoZe*`-1I)`4%%6bXo2PsmH5)Qgy?fCz;jftFLa^ zZRa^>7kBYIR{1s5in?K7G;Ky30_~_G5=*@=uTy~P9i}AO2Zg-16!qI zeqTE@9Pg<`bn_&3*AnSwPr0dgt^8o^ysdUmDuiP<<&Ua)`~EiOg!`;vzGHS^2^=ms z;y)|Jq;X2Q@JgD#wbf6b#o}$JrIN(W+{?|0=Azx%x=6~-wzZhe{jc$nv~-{d`pwh$bhXfq(yom$ znT;u5hW@mC4Bw}_q9U%1KQ-b7!nL>_{L)~zA0jq@X!^s`RDHTCPkK6vP5~kf^HG}5 z_n_oM4l3;D>kpRq8*59f#QDui$?ErRC``32US(0Wt-r&`Z2Dp9tD#diHvE<&$5tcB zl5qM8k*F}OfS?k&0y{-(PpoB|m7zjYdee@5!SyQX!Qs0qG3Ku}D?4)4a$-f}S>ziG z_(e5evJyrp^}9rt%v-QuxFDcjd^1g7XZa&5l{mgrGv=8|=`H5e76ig<>I~dk>b%RB z`1l=%ONS3{^ypYxZ(Nn>_+fe4D}VhV*kzsj9DXtkX{$HhqaZS@$#`;P`qNbGW^*-> z?r~S@bPsQb+qtE=SYrp7zNT;AU4IzW6N~P6T*PYWG)p{eIP9;*^S|g)Xf#f21|AOG!_ujADY-#>UtuxQ5|SyvbtvIZ(w zQY96YMDQ-MO^x|Cswo8QcglC|gmGkS-pTpJs@N4QV=}$3j!HFUErX$W+aKw8`xmEN z%vKhDXK;&>1!bAUsVZbVdH+XXree{?u)-5R$2|suXytxTP*5z-32r>QOzLqg)n(w@ zKBS8V>6?nSR>iHA0TfG^_Xr~u@bBJge{F_@LWKt}0L60SI7c=ZQ%}^iOGce|PNH$cMa8FmQ10o&XB*z3)E@Ygzbvk?pTbCI z+z>R6TP(86-dX#p@l4PkO$jN1ToLcB4U*pNwP#Eb4V(OQaEY_;gpSC`r)@4;t7e#c zteW;DZ>P_$IdI{7>I(iKb4_!GPL0)yUV3|9g$FXSYcCXDBo&g#AAEiOw_UjN))KW8 zZZzy#L&w5=FgkvLuz_fwA6-qxvVXdxDDDmZoTGd<4$5Wh{hN-D>7ZQm-oMHCXb;LQ z?*04V!!pWmDdzOe9U6u03CRVyjv1n|CeD&!VPVe1$g>m}HYa?pQu>p2Vxj9RpIxyR z+I@7vj<03BB8Fb>MR2-wFVP_X^a^)ChE)D7^>qh<5M>+oslU6Eq9-SW4CeV{6Z=}<=p^=$o$)+?IqM;G zU?V!~jdx9}XJuU%@r~#s!b4E43Cc&Qb%8>fU~4&3k_vu8|aQ*Oz)GUH-h@~{G1 z7D@m&NL-qcDRGCY2=vfq>61Q0waft~i7Ca#t%n9*_(Tr16`ldtmJdD90esghxIs&` z_f}~}aQn5Xs#s(6)Y4S9Dpiz@~A#ALo=9@$H(5qrl-%X7rPp0b?3+@`t_Hd^{rZ?ymBQ- z+7L>8xOuOExn{+fy4pKKarUfSM=>imx46o!WLMXHgf@hX5jC4!fc+DLuF)dBKsB+u zu?XG6*Fs@?J{t|r|`Di;yTtE|;a+;7A|$(FL=!j*Pu4tDlPvz}GL?VXkW znk7C7dDf7Y;-by0HJ2HGy1YMw5&Shd1ZD-NGqr2E|LSO*u#YoHd6NeeK_!!o_GiVh z#9NxwA1E{0zC;UmK2aL6tj6+}sN%>zA0Rl~=fGv^60N?rJ-10`aK{HEU1E_xSxyyY zqpl2$dElR^E3RLDAz*wLwuwGI$%L8b znGbU}&*92fcy63X@C-S^VV2L@L7U)?%ljJnO-A1mkxd zM$-w{wMySuVuTXWt2}(M`C2#080L8=#7>_xjM|0UudOE8YplE(33?(X>~RUpDLXw0 zPPQ%+T%27Eh9-L%bB(-!Z^w?{bf6CL5$g=vL=m++*(})=Su8Y^nS>TZn2+~he11F( zR!K9&e7vqofKh`ZOZT8&%6Vf|dm0+1m2(O1PVqBIJ7ZOKkuUu-Gud$lvg4E;t8c9Q zc+joRL(73#5%nBGG=zjF$O9-FnwCmQCg`#F(_tyn3jS&MnD#)?#?SrgG|l(z;i0}6 zqe;!-(NW%3kgJS)a>Q7ak=8duorW^@1$02I4VsR==FBN_95Fp|G7G`$F=8A(UO`@P z5Osh7d9nvXM}R`gm75dYSDLRWK-XYSppK#_Ga91Mr3-Ke5n7Co z-$cG5nP5j9_w5&S9KD4IkM!-(1A9?P-F-Ba&a`kzd=NJdtZ9M=`&MRWu-gnP#a?cA z-8H4(S~e`T!t>sK8-E*v$^7LE*h|Syh!#W(?p8)jy?5`ETT3%7agTP>V^eI2;G8Jl zb&A-T>EF2*KK6&7&X4QVuwz9WB}LcGnDm`CWpW~SCrY@4J9=&cx6(%@U$tKfIMv6m zY`jcAHSrNE{eJ29uEb<}h-q8d8O`Y_Ti94hVMSDUcelb=1R8EWjeq|zYEGdcISiZ0 zH;zlq*UqC(yC4XoFpk8>OI{3p^)dRYt_Y7L33`4HxaYScV&WSw*Fb%HNpPErNcUq& z(Xg2Zbd}6WxQW0u&gPlJYN@HI5SqVH+wIV&G|c@T#suan3jb3Fot+3U`NMy(RxV&< zu7L=R#tFE^e2@pM!W?K$kRdNhrWO>=ljj`Acv0e_IaX?=XwFF%DtInCJix$2zuS=; zdzH%Z&Rogjb`o!1eeMFCuS5}qF?=&1)C{58ETazA&YLA?1T>I=Vs6`<0~wBujg2cN z8nC)f7YZ&(!Q)9Daus-q>JsMzwZa;J!jq^lJCvV{2kO@pDpLj2xh*QH=h{ysW5!-T zwI2|qYGiZXe)}o6z>BHEg6zbht5%YhAjy=pq&yKc7U46@<;&lra_JvjEp|D_FliZA zIjtXGX1K$ZoNZ%sjC*OwgUTP?yvnP^bdr8&#cR1e#4Ir^Twzm#E%TvemprL zQ-8lpVk%2EEN+o|B-(V1PxeSZ7yYd{MtPF^S&T{gI##vjSvtu)s;Ll4_+1?~IT#r8 zGM&0LjlZDvbJA_X;}o80Ex9GKRrN72xkZ_xJ(WbsJztmi=yse}2e*)7C+l|hl970C z_^fQQgL3YRrpU+s*F=*)4VSO%xb&qL`YHQBRA4njL|si4FU2%ftK9B~ z$8BE*?b4{ub=Xt}gjoAa=#nja3KqRluHgK2Bf-^cAEDKW$_!?BUeAu77X?GYDtlLd%uePq+odSL|{Z(zG%y-iG940TXGs=UoUNO^a1$}OBe;y0tL6_o zPE@SO^?&G6?QR`jt-R;8{nocMcVdJumPesf@JtR_@*Be4{gFA+IeJVT{t9EOGK3;W zuG|!ptzOpko2W}rE+VKX_3R+p?GdRKr*>m=3?4HWbD2@xAU5c=Z%KvlP2IcxvDjw2 zEyl~W9(EsntG;jRvF>+z&AX^5+^|&6w>=Mx)F!CNI&;4!*Q`Hnaj_}>%g{JqgMzHA zcaB;A>}L1zM-LL~s&oQ1;sOFr&+va$<|OCzR3a0!=LxE=XKe}jatnJZ=|20j3%k+} z%j zqz3jkrp}Lw>M1gVJzq2(Sx=rwXiideV^o~=tJ(RQdLZnGVsh8;M&1`=sS%|-MrN=KSFW5#KbYU#FXrA0vK)9`= zgX!eacRZ?nO-mHqFPQJ;q)o?zypIXFz~T|fGQd!=wQ zVy06yR3#;BFf4S)IaHNbE&ESy$jX;=C+YWBZ=F`ZD(I)&y_&Oj`hk2F4UeNpJ`XF~ zD8s_63$E2LH;KC|rQR(p*m5zn>HKocfo|f;s={Y}m&u69>)##qcjwS2G?aUZi>c7G zeYm<`jj7>5qPfb#z+7ib^!K#yKPp_!g}zJ<(#|(kCtP=Z$J4)^ezsw0dDNvS@i&u% z)As9hBgm%@_nF*2X{GHpS{Z zSis${HMvrNs~%hQR1!bl&A0W*vaev5ynLJoce#_KV0;yva~!{fTTQ8F+lk%lW0h~~ z-#;U{-LRB3tQ@-=yd>|i#drw+P^T)K(3n502GwcPiZ=|ytlL$103)#iNdq}h9dCo( zWNxhyTv=MS3unTpSvJ}q_&)mVuzpl8BYJ(p3|8&J)uQ9}I)`smpV0M8VubGYDe8}%GpzSq>(B%Vac0G+BT9_{*mpKfXEcu&(CA=OEfF^rx2 z)F2+!c4KwrC>C@%J6JmxTp8~Be81iIl z?d?rP`C1bb6Qn{8nTZ6LizV)dwFoYFE{{^*OwP@HR$W{_&~v-!>xw8u8p8Z$fGp0! z=MvTDxO0zH(lYdEXDE$l!cvgT%0Y)KIw9f16He655j-w!i89IS3?mhvdRPKG<_y}l zurLKeUXgg^3{8@6w8jf?UhW35$%6+sPCP3myoJic&I1e}PJ-(~`}HKU*{0aU~P-Zan4#&rvi@C7{1F$Oq!`2wV&m zgMeq;JU1v*>j7z@8`s$OAYK513%USVRV^){y1Kdy6O9#PQm@E{$2lfOeT8>rdOQR_oJcuPbuoF7fC(FLBd^*j +-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< literal 0 HcmV?d00001 diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py new file mode 100644 index 0000000..30fc4b5 --- /dev/null +++ b/examples/06_bend_collision_models.py @@ -0,0 +1,70 @@ +from shapely.geometry import Polygon + +from inire.geometry.collision import CollisionEngine +from inire.geometry.primitives import Port +from inire.router.astar import AStarRouter +from inire.router.cost import CostEvaluator +from inire.router.danger_map import DangerMap +from inire.router.pathfinder import PathFinder +from inire.utils.visualization import plot_routing_results + + +def main() -> None: + print("Running Example 06: Bend Collision Models...") + + # 1. Setup Environment + # Give room for 10um bends near the edges + bounds = (-20, -20, 170, 170) + engine = CollisionEngine(clearance=2.0) + danger_map = DangerMap(bounds=bounds) + + # Create three scenarios with identical obstacles + # We'll space them out vertically + obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]) + obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]) + obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) + + obstacles = [obs_arc, obs_bbox, obs_clipped] + for obs in obstacles: + engine.add_static_obstacle(obs) + danger_map.precompute(obstacles) + + # We'll run three separate routers since collision_type is a router-level config + evaluator = CostEvaluator(engine, danger_map) + + # Scenario 1: Standard 'arc' model (High fidelity) + router_arc = AStarRouter(evaluator, bend_collision_type="arc") + netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} + + # Scenario 2: 'bbox' model (Conservative axis-aligned box) + router_bbox = AStarRouter(evaluator, bend_collision_type="bbox") + netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} + + # Scenario 3: 'clipped_bbox' model (Balanced) + router_clipped = AStarRouter(evaluator, bend_collision_type="clipped_bbox", bend_clip_margin=1.0) + netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} + + # 2. Route each scenario + print("Routing Scenario 1 (Arc)...") + res_arc = PathFinder(router_arc, evaluator).route_all(netlist_arc, {"arc_model": 2.0}) + + print("Routing Scenario 2 (BBox)...") + res_bbox = PathFinder(router_bbox, evaluator).route_all(netlist_bbox, {"bbox_model": 2.0}) + + print("Routing Scenario 3 (Clipped BBox)...") + res_clipped = PathFinder(router_clipped, evaluator).route_all(netlist_clipped, {"clipped_model": 2.0}) + + # 3. Combine results for visualization + all_results = {**res_arc, **res_bbox, **res_clipped} + all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} + + # 4. Visualize + # Note: plot_routing_results will show the 'collision geometry' used by the router + # since that's what's stored in res.path[i].geometry + fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) + fig.savefig("examples/06_bend_collision_models.png") + print("Saved plot to examples/06_bend_collision_models.png") + + +if __name__ == "__main__": + main() diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 1faf6e2..a85f75e 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -3,8 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING import rtree -from shapely.geometry import Point, Polygon -from shapely.ops import unary_union +from shapely.geometry import Polygon from shapely.prepared import prep if TYPE_CHECKING: @@ -129,19 +128,16 @@ class CollisionEngine: # Precise check: is every point in the intersection close to either port? ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds - + is_near_start = False - if start_port: - if (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and - abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius): - is_near_start = True - + if start_port and (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and + abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius): + is_near_start = True + is_near_end = False - if end_port: - if (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and - abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius): - is_near_end = True - + if end_port and (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and + abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius): + is_near_end = True if is_near_start or is_near_end: continue diff --git a/inire/geometry/components.py b/inire/geometry/components.py index da238be..82df9b5 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import NamedTuple, Literal, Union +from typing import NamedTuple, Literal, Any import numpy as np from shapely.geometry import Polygon, box @@ -35,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) @@ -84,39 +84,83 @@ def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start def _apply_collision_model( - arc_poly: Polygon, + 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.""" 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)] - + 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 [arc_poly] class Bend90: @staticmethod def generate( - start_port: Port, - radius: float, - width: float, - direction: str = "CW", + 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 @@ -133,9 +177,11 @@ class Bend90: 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) + collision_polys = _apply_collision_model( + 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) @@ -143,10 +189,10 @@ class Bend90: class SBend: @staticmethod def generate( - start_port: Port, - offset: float, - radius: float, - width: float, + 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 @@ -162,7 +208,7 @@ class SBend: ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) end_port = Port(ex, ey, start_port.orientation) - + direction = 1 if offset > 0 else -1 c1_angle = rad_start + direction * np.pi / 2 cx1 = start_port.x + radius * np.cos(c1_angle) @@ -180,6 +226,14 @@ class SBend: 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) + + 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)] + else: + collision_polys = _apply_collision_model( + combined_arc, collision_type, radius, width, 0, 0, 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 f77c044..4d0e295 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -120,7 +120,7 @@ class AStarRouter: if state in closed_set: continue closed_set.add(state) - + nodes_expanded += 1 self.total_nodes_expanded += 1 @@ -162,7 +162,7 @@ class AStarRouter: 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") - + # B. Try SBend exact reach if abs(current.port.orientation - target.orientation) < 0.1: rad = np.radians(current.port.orientation) @@ -174,9 +174,9 @@ class AStarRouter: 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 @@ -189,8 +189,8 @@ class AStarRouter: lengths = self.config.straight_lengths if dist < 5.0: fine_steps = [0.1, 0.5] - lengths = sorted(list(set(lengths + fine_steps))) - + 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}") @@ -199,9 +199,9 @@ class AStarRouter: for radius in self.config.bend_radii: 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 @@ -213,9 +213,9 @@ class AStarRouter: 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 @@ -276,7 +276,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) @@ -286,10 +286,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 ) diff --git a/inire/router/config.py b/inire/router/config.py index b9af31f..0a9e115 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -1,10 +1,8 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal, TYPE_CHECKING, Any +from typing import Literal, Any -if TYPE_CHECKING: - from shapely.geometry import Polygon @dataclass diff --git a/inire/router/cost.py b/inire/router/cost.py index 17a18a3..354a98e 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -40,7 +40,7 @@ class CostEvaluator: greedy_h_weight=greedy_h_weight, congestion_penalty=congestion_penalty, ) - + # Use config values self.unit_length_cost = self.config.unit_length_cost self.greedy_h_weight = self.config.greedy_h_weight @@ -73,12 +73,19 @@ class CostEvaluator: """Calculate the cost of a single move (Straight, Bend, SBend).""" _ = net_width # Unused total_cost = length * self.unit_length_cost - - # 1. Hard Collision check (Static obstacles) + + # 1. Hard Collision & Boundary Check # We buffer by the full clearance to ensure distance >= clearance hard_dilation = self.collision_engine.clearance for poly in geometry: dilated_poly = poly.buffer(hard_dilation) + + # Boundary Check: Physical edges must stay within design bounds + minx, miny, maxx, maxy = dilated_poly.bounds + if not (self.danger_map.is_within_bounds(minx, miny) and + self.danger_map.is_within_bounds(maxx, maxy)): + return 1e15 # Out of bounds is impossible + if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port): return 1e15 # Impossible cost for hard collisions diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 37da884..3ce6b18 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,6 +1,4 @@ -import numpy as np import pytest -from shapely.geometry import Point from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port, rotate_port, translate_port @@ -66,7 +64,7 @@ def test_bend_collision_models() -> None: # 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). + # 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 @@ -89,7 +87,7 @@ def test_sbend_collision_models() -> None: 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 @@ -100,15 +98,15 @@ def test_sbend_continuity() -> None: offset = 4.0 radius = 20.0 width = 1.0 - + res = SBend.generate(start, offset, radius, width) - + # Target orientation should be same as start assert abs(res.end_port.orientation - 90.0) < 1e-6 - + # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 - + # Geometry should be connected (unary_union results in 1 polygon) assert len(res.geometry) == 1 assert res.geometry[0].is_valid @@ -119,17 +117,17 @@ def test_arc_sagitta_precision() -> None: start = Port(0, 0, 0) radius = 100.0 # Large radius to make sagitta significant width = 2.0 - + # Coarse: 1um sagitta res_coarse = Bend90.generate(start, radius, width, sagitta=1.0) # Fine: 0.01um (10nm) sagitta res_fine = Bend90.generate(start, radius, width, sagitta=0.01) - + # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 pts_coarse = len(res_coarse.geometry[0].exterior.coords) pts_fine = len(res_fine.geometry[0].exterior.coords) - + assert pts_fine > pts_coarse * 2 @@ -139,20 +137,20 @@ def test_component_transform_invariance() -> None: start0 = Port(0, 0, 0) radius = 10.0 width = 2.0 - + res0 = Bend90.generate(start0, radius, width, direction="CCW") - + # Transform: Translate (10, 10) then Rotate 90 dx, dy = 10.0, 5.0 angle = 90.0 - + # 1. Transform the generated geometry p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) - + # 2. Generate at transformed start start_transformed = rotate_port(translate_port(start0, dx, dy), angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") - + assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 6dd8986..662fd81 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,8 +3,7 @@ from __future__ import annotations import numpy as np from typing import TYPE_CHECKING, Any -from shapely.geometry import Point, Polygon -from shapely.ops import unary_union +from shapely.geometry import Polygon if TYPE_CHECKING: from inire.geometry.primitives import Port @@ -28,7 +27,7 @@ def validate_routing_result( obstacle_collision_geoms = [] self_intersection_geoms = [] connectivity_errors = [] - + # 1. Connectivity Check total_length = 0.0 for i, comp in enumerate(result.path): @@ -38,7 +37,7 @@ def validate_routing_result( 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) - if dist_to_end > 0.005: + 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: connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}") @@ -46,9 +45,9 @@ def validate_routing_result( # 2. Geometry Buffering dilation_half = clearance / 2.0 dilation_full = clearance - + dilated_for_self = [] - + for i, comp in enumerate(result.path): for poly in comp.geometry: # Check against obstacles @@ -58,7 +57,7 @@ def validate_routing_result( intersection = d_full.intersection(obs) if intersection.area > 1e-9: obstacle_collision_geoms.append(intersection) - + # Save for self-intersection check dilated_for_self.append(poly.buffer(dilation_half)) @@ -68,13 +67,13 @@ def validate_routing_result( if j > i + 1: # Non-adjacent if seg_i.intersects(seg_j): overlap = seg_i.intersection(seg_j) - if overlap.area > 1e-6: + 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 + is_valid = (len(obstacle_collision_geoms) == 0 and + len(self_intersection_geoms) == 0 and len(connectivity_errors) == 0) - + reasons = [] if obstacle_collision_geoms: reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")