Major refactoring of RFCP backend: - Modular propagation models (8 models) - SharedMemoryManager for terrain data - ProcessPoolExecutor parallel processing - WebSocket progress streaming - Building filtering pipeline (351k → 15k) - 82 unit tests Performance: Standard preset 38s → 5s (7.6x speedup) Known issue: Detailed preset timeout (fix in 3.1.0)
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""
|
|
Unit tests for line-of-sight and Fresnel zone calculations.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import math
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
|
|
from app.geometry.los import fresnel_radius, check_los_terrain
|
|
|
|
|
|
def freq_to_wavelength(freq_mhz):
|
|
return 300.0 / freq_mhz
|
|
|
|
|
|
class TestFresnelRadius:
|
|
def test_positive_result(self):
|
|
r = fresnel_radius(500, 500, freq_to_wavelength(1800))
|
|
assert r > 0
|
|
|
|
def test_symmetric(self):
|
|
wl = freq_to_wavelength(900)
|
|
r1 = fresnel_radius(300, 700, wl)
|
|
r2 = fresnel_radius(700, 300, wl)
|
|
assert abs(r1 - r2) < 0.001
|
|
|
|
def test_lower_freq_larger_radius(self):
|
|
r_high = fresnel_radius(500, 500, freq_to_wavelength(1800))
|
|
r_low = fresnel_radius(500, 500, freq_to_wavelength(900))
|
|
assert r_low > r_high
|
|
|
|
def test_center_is_maximum(self):
|
|
"""Fresnel radius is largest at the midpoint of the path."""
|
|
wl = freq_to_wavelength(900)
|
|
r_center = fresnel_radius(500, 500, wl)
|
|
r_offset = fresnel_radius(200, 800, wl)
|
|
assert r_center > r_offset
|
|
|
|
def test_known_value(self):
|
|
"""First Fresnel zone radius at midpoint of 1km path at 1GHz ~ 8.66m."""
|
|
# F1 = sqrt(lambda * d1 * d2 / (d1+d2))
|
|
# lambda = 0.3m at 1000MHz, d1=d2=500m
|
|
# F1 = sqrt(0.3 * 500 * 500 / 1000) = sqrt(75) ~ 8.66m
|
|
r = fresnel_radius(500, 500, freq_to_wavelength(1000))
|
|
assert 8.0 < r < 9.5
|
|
|
|
def test_zero_distance(self):
|
|
r = fresnel_radius(0, 500, freq_to_wavelength(900))
|
|
assert r == 0.0
|
|
|
|
|
|
class TestCheckLosTerrain:
|
|
def test_flat_terrain_has_los(self):
|
|
profile = [
|
|
{"elevation": 100, "distance": 0},
|
|
{"elevation": 100, "distance": 250},
|
|
{"elevation": 100, "distance": 500},
|
|
{"elevation": 100, "distance": 750},
|
|
{"elevation": 100, "distance": 1000},
|
|
]
|
|
result = check_los_terrain(profile, tx_height=30, rx_height=1.5)
|
|
assert result["has_los"] is True
|
|
assert result["clearance"] > 0
|
|
|
|
def test_hill_blocks_los(self):
|
|
profile = [
|
|
{"elevation": 100, "distance": 0},
|
|
{"elevation": 100, "distance": 250},
|
|
{"elevation": 200, "distance": 500}, # 100m hill
|
|
{"elevation": 100, "distance": 750},
|
|
{"elevation": 100, "distance": 1000},
|
|
]
|
|
result = check_los_terrain(profile, tx_height=10, rx_height=1.5)
|
|
assert result["has_los"] is False
|
|
assert result["blocked_at"] is not None
|
|
|
|
def test_empty_profile(self):
|
|
result = check_los_terrain([], tx_height=30, rx_height=1.5)
|
|
assert result["has_los"] is True
|
|
|
|
def test_high_antenna_clears_hill(self):
|
|
profile = [
|
|
{"elevation": 100, "distance": 0},
|
|
{"elevation": 110, "distance": 500},
|
|
{"elevation": 100, "distance": 1000},
|
|
]
|
|
# TX at 150m (100+50), RX at 101.5m. LOS at 500m ≈ 125.75m, terrain=110m → clear
|
|
result = check_los_terrain(profile, tx_height=50, rx_height=1.5)
|
|
assert result["has_los"] is True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for cls in [TestFresnelRadius, TestCheckLosTerrain]:
|
|
instance = cls()
|
|
for method_name in [m for m in dir(instance) if m.startswith("test_")]:
|
|
try:
|
|
getattr(instance, method_name)()
|
|
print(f" PASS {cls.__name__}.{method_name}")
|
|
except Exception as e:
|
|
print(f" FAIL {cls.__name__}.{method_name}: {e}")
|
|
print("\nAll tests completed.")
|