From b230f7277c9dcbafb314bd2d01ab02574c93361a Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 23 Jun 2026 15:31:40 +0300 Subject: [PATCH] added vegetation --- API.md | 31 ++++++++++++++++++ .../core/__pycache__/fresnel.cpython-313.pyc | Bin 4315 -> 4759 bytes api/app/core/fresnel.py | 24 ++++++++++++-- .../__pycache__/terrain.cpython-313.pyc | Bin 3937 -> 4550 bytes api/app/models/terrain.py | 17 ++++++++++ .../__pycache__/terrain.cpython-313.pyc | Bin 8579 -> 9654 bytes api/app/services/terrain.py | 21 ++++++++++++ .../test_api.cpython-313-pytest-9.0.3.pyc | Bin 19478 -> 20965 bytes api/tests/test_api.py | 10 ++++++ 9 files changed, 101 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index 858638f..393c4b9 100644 --- a/API.md +++ b/API.md @@ -199,6 +199,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ geometric_obstructions_count, building_obstructions_count, worst_obstruction, + fresnel_profile: .fresnel_profile[0:3], diffraction_loss_db, vegetation_loss_db }' @@ -211,6 +212,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ - `first_fresnel_clearance_pct`: минимальный процент доступного просвета относительно требуемого `fresnel_clearance * F1`. - `obstructions`: все точки, где `clearance_m < required_clearance_m`. - `geometric_obstructions`: только точки, где `clearance_m < 0`. +- `fresnel_profile`: полный профиль по всем `samples` для построения графика зоны Френеля. - `fresnel_violations_count`: количество нарушений зоны Френеля. - `geometric_obstructions_count`: количество геометрических пересечений. - `building_obstructions_count`: количество геометрических препятствий типа `building`. @@ -230,6 +232,35 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ } ``` +Поля `fresnel_profile` для графика: + +```json +{ + "i": 12, + "lat": 59.93623, + "lon": 30.30746, + "distance_m": 312.5, + "surface_m": 18.4, + "path_height_m": 29.7, + "obstacle_height_m": 18.42, + "clearance_m": 11.28, + "fresnel_radius_m": 11.9, + "required_clearance_m": 7.14, + "fresnel_lower_60pct_m": 22.56, + "fresnel_lower_100pct_m": 17.8, + "type": "terrain" +} +``` + +Для отрисовки профиля обычно достаточно: + +- `distance_m` по X. +- `surface_m` как земля/DSM. +- `path_height_m` как прямая TX-RX. +- `fresnel_lower_60pct_m` как нижняя граница требуемой свободной зоны. +- `fresnel_lower_100pct_m` как нижняя граница полной первой зоны Френеля. +- точки, где `clearance_m < required_clearance_m`, подсветить как нарушения. + ## Buildings ### `POST /api/v1/buildings/query` diff --git a/api/app/core/__pycache__/fresnel.cpython-313.pyc b/api/app/core/__pycache__/fresnel.cpython-313.pyc index 6726ff30962852cc8868fd5b65499566452d09b0..5f18a8367ea0455ec027b5ed22f961cbe1b9c123 100644 GIT binary patch delta 2165 zcmah~-ES0C6u+}GJ3Bi&GyDD7?S28JTPPKz5Nc!iC=|p3*<}*6B+a&6+Uj;%?rt>_ zlbT>+i4V59F%V-S&pyc?purbk&1MZ9$3*dw2Xz_(_q17#yTPt=0OHF~Y$Ky|g!Se#j`E-frJKoVS;1+BSS zwZme+ard}5+j30|jzcz|kcVFF<{PF%0@0QbHT{v65HZ7vmXI*x>6VaYzq^uLmi_I@ za3wg)AUnxtGG558lP6@S7B$QJ=1RR(t*_M@k5a6`e>`h>>XqfyTD4B&5a|L<0CgbE zBFZ5tbV&+8eGd@G$RECW(G161Lfp&`w1fdORA>nWGn8)$c~eb45YlZA1$JJz8_R)_ zCBI*JuUe}b3ynoO0*u{^a~C1$eeQp_{j4Y+NI7cSHCnA3)!GcL%rC9g=@C#JV%Nnv z5mTDu*(D*u{uGT<`(bNor%#kFpPeb4m^i1GXQqlgJp^p}D$2Nb>pXmOxtwzl%U7*X zsQc_%ZJ|nE1IZ}+%5ymFxD{dBT^ax|Us{VeE>9u4TC9vFtZWdwDx>>9g5NX12nE?rcv+|nhK=%6#~BV^bOKL}%l z!1kOsyB^Skx_cDUh2==XdPomD-Xhb%HXrOg;33%6GorenN70uN(__ygmc@%6J)*}j zQdxB5aot}Eb-g_Q8j0yB?$(ijN({gMUBJa#@xp>M{3YcDCTuZj2p!d9dZO$(CL2lJ zSL%C-UUHOcb8}FX@(HI}^UwvXP`7eZKfqp@%>l*JVEvR*`X$bkQikQ_?0V8rrt@97 zuIlkos6Q@4)gw?Jr~r1PEEyVl#Qi+fe3F}zz_Fx=j6CC8B=(6rBdyk2`Nl|8h$Rq0uZKJo27iEfNq$(1;dDM?2fCSr4v1>Gp zB!UDxbfzd$e1~)Z3CC5izC1Qz{)zzvo-wuv(Eq-~)a7IBf#Z2E|@BlKsFtjQZ2OJ$^K_$W+WCKcWxR-~M zmgNOgc+HKa6(?a!Y|os|OJ#;q;6iLmNk;K(mRwt@&vd2|P8{~V5-;@Lu!7QxQK@}U zU#im*$T9!A*TtSG*Mw~Tqe*sEO-z11vLTvkcw5ah)y#&=RQvACHr3pQ%N9u>b2r%X z7j}4}CLad-zfqd`@%y3sM)SnwW=?Mgb=yO{5nCpGdG@tBz#U>gsz)yeVz=cj+3bAc zNi&w-jtw+p17>QEnHe?{yUhNqnJ=3C!#h!5Ox}>~1n~ww({5>-H-3@|rcd1retL9s z_RfV?Z0KhtV*27Nr%k3F`0}Qb*jBPlC3{!iR`%^oaU@)La*1Q>T4CJ`#CKGWBHF~` z7M+=Tp(s$SKE)ndBCR&osNrmi+#`HcaHS4*dsL{h{$S)ZUTJG|S(n~eR z;LUZ6%51Mcd-4?Saahucp5$~7_0pnedc3h7a)K4uhX4F|oN)9Q5-$?xF9xruGwq$( z?DY19zamQ-UtXDCt5sj8Z-X+Pwf-&31cugSE@cxSk9p!s*e*ajDdO^N?rw7NXD*TJ zw+WDr6ws3p&t;z~5;y!L#&fUOF_P$me^pzZ;5fKAeKwEaVK`yC5x}j9ByEoaC8C9G duLETgh}u4YY0*}Qq_)j%jfnoIe$;UK^)CQuuQ~t# delta 1663 zcmZux&2Jk;6rZ)%YkR-^6+5668!>kSB zfXV@>;ebRn2aZku0w@v(xWKKVLP91YwYC!KH3y`vQWdEc67Q|!91tVz+uzLl&2Qe! zdoy1jepJ%;G)*SZW{W4@ch!6Pq9Dxh4~3Jl;B~PQIs%?bm<3j4L*cHF4$I4HcC%(J znS91~Pl)s9zOzF`Al)L5qseVC*@>mL#Z)IXuq_UBvW0E2&`D)>#0<;w&-{5$dYIq$ z=lc{8RSnGvzuU0qOuJQY?k2fk{QM}3gWN@y0P8`PMKpjW*E2}~c7gvW7UUhV)Me!O zuDF##o+E!;`^c=D)@t(xGhiUk?*t4X%YO;{BMkBT(pc7`=_|~%Ewf%_wdJ*z%_e|4 z$RoiepxVJ(XNFKqKQ}#hWu`heefDiysm>aH_A(5!QS=EP*napIi$yO!M=@)xd84}6 zs;`=C5#dl(!keUyQA$TcB6EunIRDzyAMfJHj zzZXu7^eZ3{8UkM!#SZS&(776iYLY!3LcjTmBnM?DlO5x2kY#%e?HLB9o zB&>wZQfUI#hKlgk4qF;3V1F4Hvp;~6JeT8l<-};ZcW6a2`zs*|DUyXkR|Q$a`*4I% zjjH^Gl8Xf|39yzz<%)bfFi4=lN7V6~yLk4iUG~*eMu4e+nTAG36G4MVb{>31?3B=y z(HufE2*!vyLA$oTQ8#UdRn!&1QglMAtkJTTtLqFC?u6|YTd6IX(8q#d!UW${3psB+ zT*~pUuUU>%+t@IzW$ywQ89uHJ58;NK@KPO04zD9ShJC%+Xx8f0-W_El{8Ozsj1hUU zIbjfkYHzMJyr>@T(uX>^LZ@VOa-&ZaDJ5^pu1?f6|1I)X z`VXblQPU6ALR&3t$vf)ElSz^oe)<}ph?Z{xTa{cQ1tjlrxM+wB53GCU5;L1EW_cY$ zV8_8>$ZVwN@16!`$Ghbojg3Lq@vm7;cDcthLjTLeLr_D6y}AD92C6E?mH;n7xVypO5Zah!y2MTixQpB>d;ibV4WB8_TV_`38Fr zyja}!_x$(R(N_XO)+J!}{KS`beSn@siBI`zur2ie4c#(&%Lm@_( float: @@ -66,14 +72,19 @@ def los_analysis( tx_elevation = profile.samples[0].ground_m + tx_height_agl rx_elevation = profile.samples[-1].ground_m + rx_height_agl obstructions: list[LosSample] = [] + samples: list[LosSample] = [] worst_sample: LosSample | None = None min_ratio = float("inf") geometric_los = True - for sample in profile.samples[1:-1]: + for sample in profile.samples: d1 = sample.distance_m d2 = total_distance - d1 - path_height = tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) + path_height = ( + tx_elevation + if total_distance == 0 + else tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) + ) obstacle_height = sample.surface_m + earth_bulge(d1, d2, k) clearance_m = path_height - obstacle_height f1 = fresnel_radius(lmbda, d1, d2) @@ -82,12 +93,20 @@ def los_analysis( obstruction_type = sample.dominant_obstruction los_sample = LosSample( + lat=sample.lat, + lon=sample.lon, distance_m=d1, + surface_m=sample.surface_m, + path_height_m=path_height, + obstacle_height_m=obstacle_height, clearance_m=clearance_m, fresnel_radius_m=f1, required_clearance_m=required, obstruction_type=obstruction_type, ) + samples.append(los_sample) + if d1 == 0 or d1 == total_distance: + continue if worst_sample is None or clearance_m - required < ( worst_sample.clearance_m - worst_sample.required_clearance_m ): @@ -104,4 +123,5 @@ def los_analysis( first_fresnel_clearance_pct=100.0 if min_ratio == float("inf") else min_ratio, worst_sample=worst_sample, obstructions=obstructions, + samples=samples, ) diff --git a/api/app/models/__pycache__/terrain.cpython-313.pyc b/api/app/models/__pycache__/terrain.cpython-313.pyc index dc74dd6c84b0a99f1a91a65230ace3113bce914c..4cb4170d3fb58638e72aa373742f7940534eee34 100644 GIT binary patch delta 690 zcmY+Bzi-n(6vyr6lGKiUcAPjkKia14PK?s3Vi2;eSbI*ZD-0-=t-h1D>)Aznip3mZ6vMh3J zX-{uG`XroTga2_h_pxMfGn}QBI4fr-mJ=|*ae>y!oBF#k{;R;hA>YNfWK9(Km*iDu zlFTNz#_SX^q>1CSg_CUBPFl(w+buO1?_^DFklzTLu;;kjf!9889jkTdyUx`*9J~xz z0o(+v0@fG?vft`=n_cIi)9p782U%|~?6>w^XH*I{VRj3!4FnY?hZ%*JUO%&o^doSo@`J7pWGfo z)gz=+B_E+Y%^49g=#5H*Dl}V&P=OZ85h~NtRD`Bzxf-D=txQK~nu^&At$ryken5*s zIlhJXsUm;QlHEHVw}roP3J|9)vS7uL%8Zm0s}$?XBZXs?nc-~7QW|_9+Y;-maTPO_ z1!ZZ0(n*@RLFSGZ-g83V>xIr0JBELgk%XMWJ zUJd|j7?N-DYDOg$ON>3T~w1wwWU~c2jYlyW~Bd5RaIH2iIfr6F)32J#Sod SGQV_P#k_uM{^r;g2lEGbKCT%6 delta 287 zcmX@6{7{bXGcPX}0}vD>TV=^`OyrYbyf#t&I!n5crtoG7#>bqDnv?%`DZcmPvRAwe3@Tz z@<)DM{xG11A`oE*#Kq~8bp fPO4o|29V1L#KoDDJq1&EMJH%}W#DBL1*-)Bwd6m? diff --git a/api/app/models/terrain.py b/api/app/models/terrain.py index 75c9a69..b7777a1 100644 --- a/api/app/models/terrain.py +++ b/api/app/models/terrain.py @@ -59,6 +59,22 @@ class Obstruction(BaseModel): type: Literal["terrain", "building", "canopy"] +class FresnelProfileSample(BaseModel): + i: int + lat: float + lon: float + distance_m: float + surface_m: float + path_height_m: float + obstacle_height_m: float + clearance_m: float + fresnel_radius_m: float + required_clearance_m: float + fresnel_lower_60pct_m: float + fresnel_lower_100pct_m: float + type: Literal["terrain", "building", "canopy"] + + class LosResponse(BaseModel): los_clear: bool geometric_los: bool @@ -66,6 +82,7 @@ class LosResponse(BaseModel): worst_obstruction: Obstruction | None obstructions: list[Obstruction] geometric_obstructions: list[Obstruction] + fresnel_profile: list[FresnelProfileSample] fresnel_violations_count: int geometric_obstructions_count: int building_obstructions_count: int diff --git a/api/app/services/__pycache__/terrain.cpython-313.pyc b/api/app/services/__pycache__/terrain.cpython-313.pyc index b71de5765069c7534645a58212748a6b03e4d27b..f74b3060180d261a27b88bfe30394c1bc6a8d4b1 100644 GIT binary patch delta 3092 zcma)8eQaA-6~FKKGyZn`RXefkIA4BA<0PeZl+ZS0>q0+NFN^0c#dOAVpBESRIlIr^ zIw(rn#I`>`qPjxx4^p*>KS1n@2q4C!b-+P+Zb99qFX;VzAu_}IxAV`%~NQaW^Y;~VU+#R z687v4r5R-95#*JVnJ z6{WDDhd~#S%igp8+a3nVK!|WJZDD`+T-!?9J`#P-tQl8D>C3vZ7B1P?iYvTnSH)?f ziav5GNJ#Css}s<d zLEtspx3Jb32bPzQ6Nbv*1348t`@s2jX>TB~)P9oy+EGCt3fww$wkrOY+yBAN&v&Pj zGqW28KfCL)F2cNBFmppEekUOfh;ifNoGn4tK0XREHgN0ixHTwt#g+e}t|#nCR|v-G zTk0xE&e5IgE{WuvU0u$Sv*wR?I+Hx+i?{p|d(%D3^6q1yeY&nSXIeeU-f^G)9NRK; zd4?^vc~jP&6EU()7x{vib^&Gd&94X&T`$!ft_T)~^@J z#ZtXS=_DF3$(1iX<|dM;S;|nQg?08j&o6~p*6=RPV;$<%`~*tN2ww)!JhkVbm>Z>f zrKS|>FK@__Ku>@{#Z1R(l6~S$2pg=w|A~_lXiA+-fJlf3em*Sj6(IkBV77tgI-{F`vQW%mP~<7Vb$7E?|VQ$D4*U2S}3>P23l9I4t=cRym}gFVc+a;>-Yld zN4x7GBXgoa z11^tko4ERH6bqYTK$`WrMx}ZQJ}5sm(HYe8?KaJ>_D={d_E!H39(Om!&RNfVzRk^1 z>}&663REsjM!rfEi%T<8x!#}(#XSfc8rM)WRFwK2g_c3bvm%Yq0kn(ttC~o!_Uw7O zggiI`3B~=;O`#FQIkw|l%EwUqIKo8)okovEfvCB=)!Y5{jkAyZ(-+PlEsKEF zLZ3xypu4z*eP@o!*F(ja;tw~ywhT~1^eR|S!cRSD*m@@LtYEp{?>5=CN7%1}cc%Wf zcAj(*BXM)~&yK0);@YREzHcUpQTA%+W^Oj@FmIEv z$^76`pBMu)8>4_h5o@qyCK9c>A~+@J`g5v3u?u z@n$^ze>mvR1cI!3;MNgbl1|Owbm7a7QR$I*3%l{_J6xsT0{gpLYvH#9VTk=JazwwR zq*!_&0g4L)sg)WqG*h`+E7m!i;=u$(wC9LlR;EEquOoaLKs5vCi^*NnIkX^ru+mch_FD}L5RWrc1LoZ%_N&5!U&3cVs z8B?6sXRfTQs_=PO|@1n#v0~V|_W#xf>=B`%MdVU2C$CVmv zoQwJr^Wx-@>)qHV(a0uWnD5~*eCu)!U!q<%QoQIyTwazO`flf(y?%RRKZ9&;gqoH}A;PbanxnX}ky2&)Vx=UjG>YB7$^LpUdYtdo zF?`3X2Cs2F#XMU)5l7I#PbYrPT8~|+U20V26ZFSyF23OI6U2WKfO`l#es(>c9$6Oz hbK4o(HlT3V8r(Laux%!0?~a9w=M7AY|HO#H{s%gAdQ$)Z delta 2066 zcmah~O>7fK6rNfCE&hrB5+`aQ%J9yoCzqV1u*Rofmo_mm2uN?h9aW@DU2mAaNcKfk}3dGC9( z56^sjyyJN&*jx~5&N zSXD!x)y%q~o6Vo3w<#Uq+ub{NWMG4=3!xK0@vtn)IfMZKTQ)Vzs_GX_ww2#>ANFrU z3qrd2#QmM*b=>()--LH3nnw5|pE8^W@jg*HSr~Oe0NZ0)6=t>5+b&ICV7qx(&UT5& zUFh12Fouxi)AF8Z21LbU`|5P5NgZx{$%QFSGA^wB>cc2F(7a{cdV=uoR zz0oZCf0MQZRWFuA-@>Ot$rZmTjS^FelT!&oj0dyB5VGk}eUmXl79>InP>YhkC>0=a zfMkds(EX)AoSbiG=_Ukp{#o4mrbm@0-&)TDg%x>{Km?H}sQdY|&{y$xBCr_ubKu7B zc622-FY_;Jz&|(9S_=-9IKPiNf7|L(;6HT1magP0$#Vx=DdrBnk`A||l4te^KiVJX zKZa)l8|?hc@NWLV6X$Xy+Py))A@0O_Tv%4OOn6j(N#YTIXQhMemDIpQC=b=oz*qM# z{*R=DW)v?QhtzBz!fu3D6tU&Y<;BXpWiW<4go!v9U8Q5(6+1eCJaE`^0;Oq$lK^(a zxB%p7%;ON0t@rDiLK&XI!~$m6Aio*QQG?%&O=pKN2si-LztE`G7J&QrJ!}-UVhH{DO*_zb&_?>me{pT z=VC{|)}EL4ps@Y?m+qs*J~SUgP!YrdPoV@%BWZDb4WVfpO=`f;)2{2Z~PyMv51}!}cuJjEW_^8Qcg2*w|i3 zTvJ#uW+GSsrWe3DyI7j-M+?FiO;1LmG|lB~y8kS?dRnfIr|1g8BYrG9x`O0WWT*Gn zL3S=;A&J}A1uSNBXb@NSGP--69wN0wBVTr?nrQ;jGdD6bhORmOy&GKN26SB63Q8g$ zvPjt_i-W!l*Q%y9SFBiDS$Rkefgz>&yYDE<0Lrf#4L->diZT3c&C@vzM zJgEeoff7FLxOVOWdJF5!D@klqyrjl#f4RKafWgxE!(5nNYd*=1(Jnh!sn8G?^acSWN_5B8!#%)a2Q{Mxdy(5Pus>@&WUpA5@X`ilVk riSQ!EoB93ubCi024Rx=%P FresnelProfileSample: + return FresnelProfileSample( + i=i, + lat=sample.lat, + lon=sample.lon, + distance_m=sample.distance_m, + surface_m=sample.surface_m, + path_height_m=sample.path_height_m, + obstacle_height_m=sample.obstacle_height_m, + clearance_m=sample.clearance_m, + fresnel_radius_m=sample.fresnel_radius_m, + required_clearance_m=sample.required_clearance_m, + fresnel_lower_60pct_m=sample.path_height_m - sample.required_clearance_m, + fresnel_lower_100pct_m=sample.path_height_m - sample.fresnel_radius_m, + type=sample.obstruction_type, + ) + geometric_obstructions = [ sample for sample in result.obstructions if sample.clearance_m < 0 ] @@ -133,6 +151,9 @@ def los( worst_obstruction=convert(result.worst_sample) if result.worst_sample else None, obstructions=[convert(sample) for sample in result.obstructions], geometric_obstructions=[convert(sample) for sample in geometric_obstructions], + fresnel_profile=[ + convert_fresnel_sample(i, sample) for i, sample in enumerate(result.samples) + ], fresnel_violations_count=len(result.obstructions), geometric_obstructions_count=len(geometric_obstructions), building_obstructions_count=len(building_obstructions), diff --git a/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc b/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc index 705de7ed44c5d2e70051d36a4d0b3d89eb777b94..7920574373b1ec6dd8d32653d5b07bb727417861 100644 GIT binary patch delta 1379 zcmb7EO>7%Q6rS0v9sjJoPV?jZP$zNY#OtjcJGKMZaRNw)v?UPL7@~^Aa^iKf;Mnf0 zt&&0;kT@Vj59mdR1C^+HDN!Ygd@CF{aH@o`sZb?L0SQDt-wU!jo2O1Sdz9Vi7LN z!=2E#Q1JTT?J+ad21a3_-wb!AoLGRmAXB`2WN@+2k-()JA(ZsIj*Dkc4zj5pz6z$4 zF*$6YWOAHg(d$MjE{a=S#U1Z{dv=p{Ou&a0AN=eb#U{8Ok>K-4q{AEFp>93~iKv;U zRuY^Fr8|5}=ivHK6n+o??W7HUBSHm3@oT;cp$)?xxN`_<# zOxx&f_f6%CXBCp34wlxKzOT)<=uoi56Z#C$x#Jm4Q%D&u#)sxCZ+g6?&9J16Bt}>i zlC*%TX_Zw?DO=8a85NZ@$#P!e$ut#{#~IieI4FP;e}QW}1~10@pcx;+%OK2*I!@6_ z!?apa^I5fUTC&5}zJ!B4iiWA}P08+#gZ@fz>{AY2p2Z&@d3cy&sLy48Q1-~W8i`)k&T zgR~X=z*GlZ{Xs?x)T~vx^)DrjuTC8ZZu49F?zI0dXA+D}WHQKm4%8{Wv)=5Yanpeu zJ*^QxJyRE@7Ku!P$SS^jT_!=+MfWKuQ_$sZ`SQR)r*V(5ZRdwdV&;Ep%zIB?|Gyk% zU8Td_{d&J#ceWHd{g=MvAtX4e%d@|5*4;T_Rp5UVjjKZS`KRAC;)_s!?wely5To=w z$P*0omtz@r<|X>mxa4udAM>+@ZCX*Pfn#rQMs|8+8?K!gv|OSg`~T7ApmyR(yBizt MAIDDYCIL$R1HR;RXaE2J delta 546 zcmaF5m~q++M!wIyyj%=GuqDMR>kHpTz6-L9nv?I#u3}s{xl7KTF?{kVITgm(&F|$Z z*%+fYXR11}0;x4xdl;FX@lL+0sI_^D&MB73vrM9y6(Xu8FZ425H zFmN*{09EGkOrB*HjL#vHD{X~XdD0j(wI@%oea09&x!g{B@;ZB8)uM2q*_tdxW None: data = response.json() assert "obstructions" in data assert "geometric_obstructions" in data + assert len(data["fresnel_profile"]) == 16 + assert { + "distance_m", + "surface_m", + "path_height_m", + "fresnel_radius_m", + "required_clearance_m", + "fresnel_lower_60pct_m", + "fresnel_lower_100pct_m", + }.issubset(data["fresnel_profile"][0]) assert data["fresnel_violations_count"] == len(data["obstructions"]) assert data["geometric_obstructions_count"] == len(data["geometric_obstructions"]) assert "vegetation_loss_db" in data