@mytec: feat: Phase 3.0 Architecture Refactor ✅
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)
This commit is contained in:
0
backend/tests/test_geometry/__init__.py
Normal file
0
backend/tests/test_geometry/__init__.py
Normal file
60
backend/tests/test_geometry/test_diffraction.py
Normal file
60
backend/tests/test_geometry/test_diffraction.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Unit tests for knife-edge diffraction calculations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.geometry.diffraction import knife_edge_loss
|
||||
|
||||
|
||||
def freq_to_wl(freq_mhz):
|
||||
return 300.0 / freq_mhz
|
||||
|
||||
|
||||
class TestKnifeEdgeLoss:
|
||||
def test_no_obstruction_low_loss(self):
|
||||
"""Negative h means clearance above LOS — loss should be small."""
|
||||
loss = knife_edge_loss(d1_m=500, d2_m=500, h_m=-10, wavelength_m=freq_to_wl(1800))
|
||||
assert loss >= 0
|
||||
assert loss < 3
|
||||
|
||||
def test_grazing_obstruction(self):
|
||||
"""h=0 means exactly at LOS line — ~6 dB loss."""
|
||||
loss = knife_edge_loss(d1_m=500, d2_m=500, h_m=0, wavelength_m=freq_to_wl(1800))
|
||||
assert 5 < loss < 8
|
||||
|
||||
def test_obstruction_increases_loss(self):
|
||||
wl = freq_to_wl(1800)
|
||||
loss_low = knife_edge_loss(d1_m=500, d2_m=500, h_m=1, wavelength_m=wl)
|
||||
loss_high = knife_edge_loss(d1_m=500, d2_m=500, h_m=10, wavelength_m=wl)
|
||||
assert loss_high > loss_low
|
||||
|
||||
def test_higher_freq_more_loss(self):
|
||||
"""Higher frequency = shorter wavelength = more diffraction loss."""
|
||||
loss_low_f = knife_edge_loss(d1_m=500, d2_m=500, h_m=5, wavelength_m=freq_to_wl(450))
|
||||
loss_high_f = knife_edge_loss(d1_m=500, d2_m=500, h_m=5, wavelength_m=freq_to_wl(1800))
|
||||
assert loss_high_f > loss_low_f
|
||||
|
||||
def test_zero_distance_safe(self):
|
||||
"""Should not crash on zero distances."""
|
||||
loss = knife_edge_loss(d1_m=0, d2_m=500, h_m=5, wavelength_m=freq_to_wl(900))
|
||||
assert loss >= 0
|
||||
|
||||
def test_large_clearance(self):
|
||||
"""Very deep clearance (large negative h) should have near-zero loss."""
|
||||
loss = knife_edge_loss(d1_m=500, d2_m=500, h_m=-50, wavelength_m=freq_to_wl(900))
|
||||
assert loss < 1.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
instance = TestKnifeEdgeLoss()
|
||||
for method_name in [m for m in dir(instance) if m.startswith("test_")]:
|
||||
try:
|
||||
getattr(instance, method_name)()
|
||||
print(f" PASS {method_name}")
|
||||
except Exception as e:
|
||||
print(f" FAIL {method_name}: {e}")
|
||||
print("\nAll tests completed.")
|
||||
74
backend/tests/test_geometry/test_haversine.py
Normal file
74
backend/tests/test_geometry/test_haversine.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Unit tests for haversine distance calculations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.geometry.haversine import haversine_distance, haversine_batch, points_to_local_coords
|
||||
|
||||
|
||||
class TestHaversineDistance:
|
||||
def test_same_point_is_zero(self):
|
||||
d = haversine_distance(50.45, 30.52, 50.45, 30.52)
|
||||
assert abs(d) < 1.0
|
||||
|
||||
def test_known_distance(self):
|
||||
# Kyiv to Kharkiv ≈ 410 km
|
||||
d = haversine_distance(50.45, 30.52, 49.99, 36.23)
|
||||
assert 400000 < d < 420000
|
||||
|
||||
def test_short_distance(self):
|
||||
# ~111m for 0.001 degree lat
|
||||
d = haversine_distance(50.0, 30.0, 50.001, 30.0)
|
||||
assert 100 < d < 120
|
||||
|
||||
|
||||
class TestHaversineBatch:
|
||||
def test_single_point(self):
|
||||
lats = np.array([50.001])
|
||||
lons = np.array([30.0])
|
||||
distances = haversine_batch(50.0, 30.0, lats, lons)
|
||||
assert len(distances) == 1
|
||||
assert 100 < distances[0] < 120
|
||||
|
||||
def test_multiple_points(self):
|
||||
lats = np.array([50.001, 50.01, 50.1])
|
||||
lons = np.array([30.0, 30.0, 30.0])
|
||||
distances = haversine_batch(50.0, 30.0, lats, lons)
|
||||
assert len(distances) == 3
|
||||
# Should be monotonically increasing
|
||||
assert distances[0] < distances[1] < distances[2]
|
||||
|
||||
|
||||
class TestLocalCoords:
|
||||
def test_same_point_is_origin(self):
|
||||
x, y = points_to_local_coords(50.0, 30.0, np.array([50.0]), np.array([30.0]))
|
||||
assert abs(x[0]) < 1.0
|
||||
assert abs(y[0]) < 1.0
|
||||
|
||||
def test_north_is_positive_y(self):
|
||||
x, y = points_to_local_coords(50.0, 30.0, np.array([50.001]), np.array([30.0]))
|
||||
assert y[0] > 0
|
||||
assert abs(x[0]) < 1.0
|
||||
|
||||
def test_east_is_positive_x(self):
|
||||
x, y = points_to_local_coords(50.0, 30.0, np.array([50.0]), np.array([30.001]))
|
||||
assert x[0] > 0
|
||||
assert abs(y[0]) < 1.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for cls in [TestHaversineDistance, TestHaversineBatch, TestLocalCoords]:
|
||||
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.")
|
||||
77
backend/tests/test_geometry/test_intersection.py
Normal file
77
backend/tests/test_geometry/test_intersection.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Unit tests for line-segment intersection calculations.
|
||||
|
||||
These require NumPy, so use __main__ block with conditional import.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
import numpy as np
|
||||
from app.geometry.intersection import line_segments_intersect_batch
|
||||
|
||||
|
||||
class TestLineSegmentIntersect:
|
||||
def test_crossing_lines(self):
|
||||
"""Two crossing segments should intersect."""
|
||||
# Line from (0,0)→(1,1) and (0,1)→(1,0)
|
||||
result = line_segments_intersect_batch(
|
||||
p1=np.array([0.0, 0.0]),
|
||||
p2=np.array([1.0, 1.0]),
|
||||
seg_starts=np.array([[0.0, 1.0]]),
|
||||
seg_ends=np.array([[1.0, 0.0]]),
|
||||
)
|
||||
assert result[0] == True
|
||||
|
||||
def test_parallel_lines(self):
|
||||
"""Parallel lines should not intersect."""
|
||||
result = line_segments_intersect_batch(
|
||||
p1=np.array([0.0, 0.0]),
|
||||
p2=np.array([1.0, 0.0]),
|
||||
seg_starts=np.array([[0.0, 1.0]]),
|
||||
seg_ends=np.array([[1.0, 1.0]]),
|
||||
)
|
||||
assert result[0] == False
|
||||
|
||||
def test_non_crossing(self):
|
||||
"""Segments that don't reach each other."""
|
||||
result = line_segments_intersect_batch(
|
||||
p1=np.array([0.0, 0.0]),
|
||||
p2=np.array([0.5, 0.5]),
|
||||
seg_starts=np.array([[0.8, 0.0]]),
|
||||
seg_ends=np.array([[0.8, 1.0]]),
|
||||
)
|
||||
assert result[0] == False
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""Batch test with multiple segments."""
|
||||
result = line_segments_intersect_batch(
|
||||
p1=np.array([0.0, 0.0]),
|
||||
p2=np.array([1.0, 1.0]),
|
||||
seg_starts=np.array([
|
||||
[0.0, 1.0], # crosses
|
||||
[2.0, 0.0], # doesn't cross
|
||||
[0.5, 0.0], # crosses
|
||||
]),
|
||||
seg_ends=np.array([
|
||||
[1.0, 0.0], # crosses
|
||||
[2.0, 1.0], # doesn't cross
|
||||
[0.5, 1.0], # crosses
|
||||
]),
|
||||
)
|
||||
assert result[0] == True
|
||||
assert result[1] == False
|
||||
assert result[2] == True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
instance = TestLineSegmentIntersect()
|
||||
for method_name in [m for m in dir(instance) if m.startswith("test_")]:
|
||||
try:
|
||||
getattr(instance, method_name)()
|
||||
print(f" PASS {method_name}")
|
||||
except Exception as e:
|
||||
print(f" FAIL {method_name}: {e}")
|
||||
print("\nAll tests completed.")
|
||||
103
backend/tests/test_geometry/test_los.py
Normal file
103
backend/tests/test_geometry/test_los.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
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.")
|
||||
Reference in New Issue
Block a user