From bcf0ce76e75c1a8f95c9cc2d1270d2537e0926cc Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 23 Jun 2026 09:41:06 +0300 Subject: [PATCH] added bootstrap buildings --- .../__pycache__/diffraction.cpython-313.pyc | Bin 3506 -> 2718 bytes api/app/core/diffraction.py | 78 ++++++++---------- .../test_core.cpython-313-pytest-9.0.3.pyc | Bin 10756 -> 15094 bytes api/tests/test_core.py | 63 +++++++++++++- 4 files changed, 96 insertions(+), 45 deletions(-) diff --git a/api/app/core/__pycache__/diffraction.cpython-313.pyc b/api/app/core/__pycache__/diffraction.cpython-313.pyc index 83212c8fbad1cfe2884b8d22ea4f0d61e9785e3c..fba477b775b9ea42ffc54aadf6d43f44aec58fe1 100644 GIT binary patch delta 1531 zcmZWp%WoS+7@v(FyWWTMSUXDNBy8F|>VWN1U4TV25BA_UyB{V9xIcBKYV-6okn$tNxzx z`D%m;cM}M-!4^Kv2e4H5KEVNyK&eOy4*9VdOoJnKd{O9m=jq~LPcHmf_$A$qpC0f| z4f@_405~9l;&9I{PxoNXfH?i=uUmCZQ;n)^8VSqZ))YzkqONYqnqt_JQmHDEW?GiC zX+o(XOSS@`tQt}cnwzSoBw2Rtz9NoB-vI?L#kVfCD}+{F?H3Dk%75k z*>V}>fQp7}wiL-WiN#tWv07lu_f1%lWY4J&Mi*iOVI+{=!ajy1*J_%IQrun3Csw6T zlIg40r0nIbc#?gI2ml~v0>qVg`7at!#9Lgtc*ymk>$$WgW5AZnR#b$`C@Ur1WvbAu z8w^6!@`iH{sD1M-NB95jVa+4Kum^F^tb*-(+)Tr1q|1`{$1vArcKB8 z^=I&m1CccSwn_?!r>?OzH<15CUrCHTs{i}KDL)`bJH*U(-8Bt-2Y61=zzRuT>1X@T%ZbaEIm`R`jq2L)xUJVUTTKwYcbjS@;}ofR+z)3^YB-ChIXsP_ z$-)?M*Sv1g6=l0>)@_(V$JekPUj%iDT<-e8yKOG}!k;DATR{=SNJ|*TxIw7cb!fD7 z{z~Rb?>NqSNf~@VGGjV=>4}6fkO1)9M`#`K>kF7C1jcWGr1JnWOS)O9YsyWC(cur3 z^|)0OrfZYbMbyU=A6k#av6BE=Pl6zF;e^H#1H$4-2umyoa%UWt-lJ&hV~RLofp}q| R>qbP+kBHdu9}bHI&VS63fKLDb literal 3506 zcmb7GU2GHC6~1H7_-~xpP8^4Y1STdxyx9cOB|t-(pIz8i7(zGpps0!P4)NGAwa4L( z9e&hmQ|*gUTM@b-OjT*kQ^QlK)Cc<1s!#1(MwS}yC@Zbht+r8LvV|>GRkc0mjvc(a z$-^Go*XNvj?%#KQ?tRwM;vqmE^o(5nuZxhspwJ+j#c6@iUNP;8csFt3 z2xp3lrsWGtA(Pki$2L>Q=F>yROkTf63()Jit}LlpRZABZKx&N@X-b(_zf1F}Oje!e zaE>~`!HAs@?%5S!?h_dfGBQGl0yb$|%pT)sx2 z)kHS0>(mePxZ$UN3z}t8bGk}r-@8@wwbjJdm9~58FV0kYD)WzytSXQ8Kj|@gUaxi? ze{#*}I`#B*qid`h8aD#thB&^)k3WM6$UN?_K@MOTrau4%Fy$;vlc1Mvb$mraULqT_ zHIV8hO(@$NT8)>a*6H*6{__! z&>)KL(%bJ{UkR7F_m6D&f-9GP{KGX@*M>K=a=q$3uza>AdVf+_nJl+G?7VxSB0u`x zlklU25ge}iP8i~eHU0z*z(hNP`4+hTGP!KO%wHBRJMK5Z7bny8e1yT<)PMp2Z+jWg zJKM(Wc+BZ$C)VeYIfVM9t9Bm0y%vvYv9nD~*mVsXu5rwk<6|xvTz%Ze>VfMjpA%wk z*)9`VkR8W)RtHe)h&#e0X8(G-8m?mmVd1$IomS_IRL?A_(i!z;I$tbEO&KF$ibzF0 zqot+7f+{8Rxs0Z01qt&L559kYp1`pqkh2|14 zW48Mw&P~@#1}L^kCUM(N-jvC1=rx^-n}YDRr`3Y#)Ro*~R@F_nZq>d-4kJIKCDj|I zU)7R}`HWUbEU20ELP0m3jlQ?6dq=nSrXf{8uWc9JZFZ3URp*lAI zf<*8gSoa+?dFlfj4`((z? zYHU;Z#@nXB=*wh5quF-px>c2 zHH&9q1UChb!7XzQH?@=RT!-1+yX@A@cI%NnUvszOF6Y~E9(F$O%q5xKVl%%PfXc}# zDC|^gfwl2g{1VA6Y*gzk*jhC6ci9^c4A+Z>=9_sja*vQCc<+J^C-!Mh*{!)Q+$2OK z5|Le)4r$vgrUxV0xs94%_Qz}^GmSDO`{ThdnQ=8RLj;-TN6i{YWMPUGbu0NGcnC|r zh?7ddo1SDwFDTl)n#fVSSxtc|T3V$&Xo#ho-GY%0lh3ND0`y`sm?8=ZE3{27(={ZA zzNl!4T;0S{Zs1z3JsYN?b-HK`$n9-~& zdJiglvB9h|1wEf#Qgt+C_fd1R48(Glo8G*jl)9d|{ki|w|vgG(Mbg*)! z8j6&}4?BA+a<#L+Hx8(k$V{iF*rRU+Piu~t}{?gf6N7sYI-9$w; zKzged=v)tkjX=0EyME}j0pGys(i<<=IOCmT5{LKVA*bAQ1k71I9v%<_YE1op{}f&g0EG@fi-@BfjI?eVze`W=u4?EJ>3{C9a}(Ti wFVCIaY8~PRYc2b>?C}3_`<^WU{=sj-SGJtUxkzjKmK!+_afqLbz_A7Y51jYjl>h($ diff --git a/api/app/core/diffraction.py b/api/app/core/diffraction.py index 2f97fd3..d0e314a 100644 --- a/api/app/core/diffraction.py +++ b/api/app/core/diffraction.py @@ -19,53 +19,43 @@ def knife_edge_v(h: float, d1: float, d2: float, freq_hz: float) -> float: return h * sqrt(2 * (d1 + d2) / (lmbda * d1 * d2)) +def bullington_loss( + profile: SurfaceProfile, + tx_height_agl: float, + rx_height_agl: float, + freq_hz: float, +) -> float: + """Bullington-style equivalent edge loss for a terrain profile. + + The current implementation uses the dominant obstacle relative to the TX-RX + chord as the equivalent Bullington edge, then applies ITU-R P.526 J(v). + """ + if len(profile.samples) < 3: + return 0.0 + + total_distance = profile.distance_m + if total_distance <= 0: + return 0.0 + + tx_elevation = profile.samples[0].ground_m + tx_height_agl + rx_elevation = profile.samples[-1].ground_m + rx_height_agl + max_v = float("-inf") + + for sample in profile.samples[1:-1]: + d1 = sample.distance_m + d2 = total_distance - d1 + path_height = tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) + h = sample.surface_m - path_height + max_v = max(max_v, knife_edge_v(h, d1, d2, freq_hz)) + + return knife_edge_loss(max_v) + + def deygout( profile: SurfaceProfile, tx_height_agl: float, rx_height_agl: float, freq_hz: float, ) -> float: - """Recursive Deygout diffraction loss using the dominant edge and subprofiles.""" - if len(profile.samples) < 3: - return 0.0 - - endpoint_heights = { - 0: profile.samples[0].ground_m + tx_height_agl, - len(profile.samples) - 1: profile.samples[-1].ground_m + rx_height_agl, - } - - def sample_height(index: int) -> float: - return endpoint_heights.get(index, profile.samples[index].surface_m) - - def solve(left: int, right: int) -> float: - if right - left < 2: - return 0.0 - - left_sample = profile.samples[left] - right_sample = profile.samples[right] - span_m = right_sample.distance_m - left_sample.distance_m - left_height = sample_height(left) - right_height = sample_height(right) - max_v = float("-inf") - max_index: int | None = None - - for index in range(left + 1, right): - sample = profile.samples[index] - d1 = sample.distance_m - left_sample.distance_m - d2 = right_sample.distance_m - sample.distance_m - path_height = left_height + (right_height - left_height) * (d1 / span_m) - h = sample.surface_m - path_height - v = knife_edge_v(h, d1, d2, freq_hz) - if v > max_v: - max_v = v - max_index = index - - if max_index is None: - return 0.0 - - main_loss = knife_edge_loss(max_v) - if main_loss == 0.0: - return 0.0 - return main_loss + solve(left, max_index) + solve(max_index, right) - - return solve(0, len(profile.samples) - 1) + """Compatibility wrapper; use Bullington equivalent loss for multi-edge profiles.""" + return bullington_loss(profile, tx_height_agl, rx_height_agl, freq_hz) diff --git a/api/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc b/api/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc index 9aa53190613106a9e6ce9954eb7531f4abbd8efb..b4912c484feaabe10fc7bbb7d27b484ec82727ba 100644 GIT binary patch delta 4526 zcmb7HYitzP6`tAM+4uWpd;MBtY&`e@eg}-P9i^BUu)z}}qNUo5?eTWQ?(A}B?G#YE zL~0U#L~Yz5Doq4(oBlAWgi=|Rs8m&vT2<{sQ zY*eZR$xfxZ)8@&8*nN4K%#M^RuqHV6FoN?0+pE)Tg6njH;D*N{usue>gAMlP!^jTk z!b`ho1j$FGogn$C)C5w1O3g8ruh_(>ah}bY;>t`SrAWMbh^_3@=UmhCi9}qEWm0k! z^h(ZkMvl)(QE4_NX$pIZVnI!_eRWP@p3I3e(s7cSizlRu4unR8odCRzz;1gSz3^1^~~62so#lUATAQ+yEmDunUf2 zfP;hW8^K=c)l|@;PBc2oy6J4Jb5F1kP}a90pk57H>}ATgt@UM*A~=0!wQkeZ>z&2E zi?cI7cd%wUSf}7B29yr=ezisYi=}+9*d^$3z^<~fp~3ChHeghadn{qW-C=7tGK_4F zRN&%bDT%IB!I3qq6P}8!ZjhDr@EG9X2HA8h#_*o;O*d?Bf{c9KoMYoTeL~Ctq~x4+ zHm+nuc}9vRbLJRH&C9bOS*PdYiCLgj)Xs=<3cl@R&Z20X8BK2Dj)`9aBHZ+Yv`Jgm z5rKiPJZDlUhbm+*n9g6;I3e8B0<6==E7l7IDLhD(dwHdWszAsa_o{2Q=4=}^zr-uN z^=q2k*;8}@DIee2S!ke(x`MYxYOr81n6yUl8as@Finl$NPB15tK7@XR8YT<)5{ml( zC};NZ4&p$K6TysteS9(Ev5+rg430!2o&l5&APgc50Z{tP7TC16z@{WAW;**7{BpyE zLfX*3Dq{9 za$4i5FBNl|QLv>JrcC>Rj8c~{XJHv-XOF7sz>Z46K4tEKG;u1{qbr06Pno`2y=DmL zBAAqRsXu6-BBrh~a%Zd6MMEPd79@6cB4`EIAl7lxu85f?ssA)u)i#4>g7gB!jH&B^ z)v7*pYg!Ky%hH-P641sEM2|#6%Vdxey%mZ6jS`ZVjX?D8 z#o)Ds;gOj+hWCwcN)SUSe{&Y4*8w(fu`FZsg`(UeOr1pHgi2fJWy)xEcc@rSYciCr)I~^R80<2iB%^%G+}s5oKT@N~M%k zDV2sHB1j$NNF;YN~T33m7Jd&z+?N-*+y_;Ep;|jaW_<4}=)?6Cd3NV)7hSRm-_TYa-<(2Nmw>CmGOYGJ5rO9jUizk*(fOaDg z+^DWy@?0HUn!Yx;C@zaz9*f2L$PbM?tYA3%Lk8O13NfzQW#diLnsfMKcJ%(vrd7kh z60qmMGO*{s`$Jdym(H%+`ar(vrLbljxMRrwuNwwdZGFY6CfvfXqHe9Mrm1fm2Ce`# z<{JTzeC_lA#4Z6hq6OMs0&dKU%fO9!vDhPc-1KVdZTK6*z`s7Phv_M}F}^v%j(QC% zzR=sc2155U?%14lCWSXWIJPLUkG_lLS3@MG{pMzs3cQuLA&ej+ zzVO9|BMazWvZK_ywbgJx_-Abw_f;@Wt9$Bx%66>$wQkyIMAqbu_$8RM%SpeQY6^Jq zQ;0iZLy)ha1AG2um9K4Af8Mks8wLeAgYfi{_`FSSCfIiAIIv333`llmo4S)EK&2pa z6gIWCqgyk0P*9|qOo~L|R3ORfh30DRU2sgSeBAsY8^ZL#>xc(_#`#@2GoA!9aakdC z>R3xR8&<#GaxAMId9eAMTTG`rW>Q4zm?M%RO9|~-Nw2>6QRVza2RMqQ#h93hr(|L; zkIkPfpB}GrzGB#d6q7Pq6n$}S2B?kTeVmLS9KxmyNs*k%*?=s>q?94?^HPqROQggM z!7Cg+?9eZh;2R@2-|GxbmD;+P4uN!HCC*lIShy-)lWwuv_PKM*@2Hvj+t delta 1461 zcma)6O>7%g5Z>o_*N)@AcsGe{(!?pa>^iZXGvXF?7xS;UF0S<8CQX%!S7bLgZ)V=S zH}hs@pYDF}R{ZZ+EF$1qn_9Zvk`Cf&7Cv}+t(y~cK^Ir!)dVD}Nk~?QVc6G6tEs9C zvd=@SN_7NAs-rN9vaAngh1)?GLl*H_8rhJ~G8^Ib3v^BYT3_s!Nz$WbLBATi!772C zQIBto>k0gYb#_70lXPI^$Pdoo6~q1&6Ub6No2)ZU;kQ^J#k|Cwc4Nn^ncH=<*0gM! zf6Er!RN&D7i}1h1^DN@#C6$R9o)>l3Ll1&d0x2Z96x~w)8^ zdnavmB~tfn$h6SW>yfrhFiX&%6XxK1o%Ttklk;JLj(d^dB?K>;S?=7c?O4z zj?MWCRBmLZ#p|fZ@Po|n!2&YZE4&|=I^0GIGYDQ}%c8CUYuB!S{u!;Gt{hJAcZ-|H zDgIWx)$R2IIrSGvi$IjLLjG1z=Zo2kA!NVoe^~!OQ#@w72eNLLUX^Q-;5I{x2n|4gwsoCb1V?oVm zJ7&AhXBM;K4jRku>f)y?OI6bX5W~MWwVW5;+-)?SM%#ug|L5X7#$39z*7cG`r&HRp zz%1>6X}8U0Z$|vNr3v%tLMguAmQ1tf&pvZe^hA+3Q?cJvND%?dKzP(_-$7@TY e(TBq9BVpxdVdWu~_QR!{u@I{~Q3hGwcm5X~j4A;D diff --git a/api/tests/test_core.py b/api/tests/test_core.py index 3dff795..a97ebe8 100644 --- a/api/tests/test_core.py +++ b/api/tests/test_core.py @@ -3,9 +3,10 @@ from math import isclose import pytest from app.core.antenna import AntennaPattern, gain -from app.core.diffraction import knife_edge_loss +from app.core.diffraction import bullington_loss, knife_edge_loss, knife_edge_v from app.core.fresnel import earth_bulge, fresnel_radius, wavelength from app.core.propagation import fspl +from app.core.surface import SurfaceProfile, SurfaceSample from app.core.vegetation import P833Coefficients, p833_attenuation @@ -34,6 +35,66 @@ def test_knife_edge_loss_reference_values(v: float, expected: float) -> None: assert isclose(knife_edge_loss(v), expected, abs_tol=0.05) +def test_bullington_loss_uses_dominant_equivalent_edge() -> None: + profile = SurfaceProfile( + distance_m=10_000, + samples=[ + SurfaceSample( + i=0, + lat=0, + lon=0, + distance_m=0, + ground_m=0, + building_m=0, + canopy_m=0, + surface_m=0, + ), + SurfaceSample( + i=1, + lat=0, + lon=0, + distance_m=5_000, + ground_m=20, + building_m=0, + canopy_m=0, + surface_m=20, + ), + SurfaceSample( + i=2, + lat=0, + lon=0, + distance_m=10_000, + ground_m=0, + building_m=0, + canopy_m=0, + surface_m=0, + ), + ], + ) + v = knife_edge_v(20, 5_000, 5_000, 433_000_000) + + assert isclose(bullington_loss(profile, 0, 0, 433_000_000), knife_edge_loss(v)) + + +def test_bullington_loss_does_not_sum_every_dem_sample() -> None: + samples = [ + SurfaceSample( + i=i, + lat=0, + lon=0, + distance_m=i * 100, + ground_m=5 if 20 <= i <= 80 else 0, + building_m=0, + canopy_m=0, + surface_m=5 if 20 <= i <= 80 else 0, + ) + for i in range(101) + ] + profile = SurfaceProfile(distance_m=10_000, samples=samples) + + assert bullington_loss(profile, 0, 0, 433_000_000) < 30 + + def test_p833_formula_with_supplied_coefficients() -> None: attenuation = p833_attenuation( depth_m=100,