@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_models/__init__.py
Normal file
0
backend/tests/test_models/__init__.py
Normal file
90
backend/tests/test_models/test_cost231.py
Normal file
90
backend/tests/test_models/test_cost231.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Unit tests for COST-231 Hata and COST-231 Walfisch-Ikegami models.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.propagation.base import PropagationInput
|
||||
from app.propagation.cost231_hata import Cost231HataModel
|
||||
from app.propagation.cost231_wi import Cost231WIModel
|
||||
|
||||
|
||||
def make_input(**kwargs) -> PropagationInput:
|
||||
defaults = {
|
||||
"frequency_mhz": 1800,
|
||||
"distance_m": 5000,
|
||||
"tx_height_m": 30,
|
||||
"rx_height_m": 1.5,
|
||||
"environment": "urban",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return PropagationInput(**defaults)
|
||||
|
||||
|
||||
class TestCost231Hata:
|
||||
def test_typical_range(self):
|
||||
model = Cost231HataModel()
|
||||
out = model.calculate(make_input())
|
||||
assert 130 < out.path_loss_db < 170
|
||||
|
||||
def test_model_name(self):
|
||||
model = Cost231HataModel()
|
||||
assert model.name == "COST-231-Hata"
|
||||
|
||||
def test_frequency_range(self):
|
||||
model = Cost231HataModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=1500))
|
||||
assert model.is_valid_for(make_input(frequency_mhz=2000))
|
||||
assert not model.is_valid_for(make_input(frequency_mhz=900))
|
||||
|
||||
def test_distance_increases_loss(self):
|
||||
model = Cost231HataModel()
|
||||
loss_2 = model.calculate(make_input(distance_m=2000)).path_loss_db
|
||||
loss_10 = model.calculate(make_input(distance_m=10000)).path_loss_db
|
||||
assert loss_10 > loss_2
|
||||
|
||||
def test_urban_vs_suburban(self):
|
||||
model = Cost231HataModel()
|
||||
urban = model.calculate(make_input(environment="urban")).path_loss_db
|
||||
suburban = model.calculate(make_input(environment="suburban")).path_loss_db
|
||||
assert suburban < urban
|
||||
|
||||
|
||||
class TestCost231WI:
|
||||
def test_typical_range(self):
|
||||
model = Cost231WIModel()
|
||||
out = model.calculate(make_input(distance_m=500))
|
||||
assert 80 < out.path_loss_db < 160
|
||||
|
||||
def test_model_name(self):
|
||||
model = Cost231WIModel()
|
||||
assert model.name == "COST-231-WI"
|
||||
|
||||
def test_distance_increases_loss(self):
|
||||
model = Cost231WIModel()
|
||||
loss_200 = model.calculate(make_input(distance_m=200)).path_loss_db
|
||||
loss_1000 = model.calculate(make_input(distance_m=1000)).path_loss_db
|
||||
assert loss_1000 > loss_200
|
||||
|
||||
def test_frequency_range(self):
|
||||
model = Cost231WIModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=800))
|
||||
assert model.is_valid_for(make_input(frequency_mhz=2000))
|
||||
assert not model.is_valid_for(make_input(frequency_mhz=400))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for cls_name, cls in [("COST231Hata", TestCost231Hata), ("COST231WI", TestCost231WI)]:
|
||||
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 AssertionError as e:
|
||||
print(f" FAIL {cls_name}.{method_name}: {e}")
|
||||
except Exception as e:
|
||||
print(f" ERROR {cls_name}.{method_name}: {e}")
|
||||
print("\nAll tests completed.")
|
||||
82
backend/tests/test_models/test_free_space.py
Normal file
82
backend/tests/test_models/test_free_space.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Detailed unit tests for the Free Space Path Loss model.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.propagation.base import PropagationInput
|
||||
from app.propagation.free_space import FreeSpaceModel
|
||||
|
||||
|
||||
def make_input(**kwargs) -> PropagationInput:
|
||||
defaults = {
|
||||
"frequency_mhz": 1800,
|
||||
"distance_m": 1000,
|
||||
"tx_height_m": 30,
|
||||
"rx_height_m": 1.5,
|
||||
"environment": "urban",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return PropagationInput(**defaults)
|
||||
|
||||
|
||||
class TestFreeSpaceModel:
|
||||
def test_formula_accuracy(self):
|
||||
"""FSPL = 20*log10(d_km) + 20*log10(f_MHz) + 32.45"""
|
||||
model = FreeSpaceModel()
|
||||
# At 1km, 1000MHz: 20*0 + 20*60 + 32.45 = 92.45 dB
|
||||
out = model.calculate(make_input(distance_m=1000, frequency_mhz=1000))
|
||||
expected = 32.45 + 20 * math.log10(1.0) + 20 * math.log10(1000)
|
||||
assert abs(out.path_loss_db - expected) < 0.1
|
||||
|
||||
def test_6db_per_distance_doubling(self):
|
||||
model = FreeSpaceModel()
|
||||
loss_1 = model.calculate(make_input(distance_m=1000)).path_loss_db
|
||||
loss_2 = model.calculate(make_input(distance_m=2000)).path_loss_db
|
||||
assert abs((loss_2 - loss_1) - 6.02) < 0.1
|
||||
|
||||
def test_6db_per_frequency_doubling(self):
|
||||
model = FreeSpaceModel()
|
||||
loss_1 = model.calculate(make_input(frequency_mhz=900)).path_loss_db
|
||||
loss_2 = model.calculate(make_input(frequency_mhz=1800)).path_loss_db
|
||||
assert abs((loss_2 - loss_1) - 6.02) < 0.1
|
||||
|
||||
def test_always_los(self):
|
||||
model = FreeSpaceModel()
|
||||
out = model.calculate(make_input())
|
||||
assert out.is_los is True
|
||||
|
||||
def test_model_name(self):
|
||||
model = FreeSpaceModel()
|
||||
assert model.name == "Free-Space"
|
||||
|
||||
def test_wide_frequency_range(self):
|
||||
model = FreeSpaceModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=1))
|
||||
assert model.is_valid_for(make_input(frequency_mhz=100000))
|
||||
|
||||
def test_very_short_distance(self):
|
||||
model = FreeSpaceModel()
|
||||
out = model.calculate(make_input(distance_m=10))
|
||||
assert out.path_loss_db > 0
|
||||
assert out.path_loss_db < 80
|
||||
|
||||
def test_very_long_distance(self):
|
||||
model = FreeSpaceModel()
|
||||
out = model.calculate(make_input(distance_m=100000))
|
||||
assert out.path_loss_db > 120
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
instance = TestFreeSpaceModel()
|
||||
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.")
|
||||
93
backend/tests/test_models/test_okumura_hata.py
Normal file
93
backend/tests/test_models/test_okumura_hata.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Detailed unit tests for the Okumura-Hata model.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.propagation.base import PropagationInput
|
||||
from app.propagation.okumura_hata import OkumuraHataModel
|
||||
|
||||
|
||||
def make_input(**kwargs) -> PropagationInput:
|
||||
defaults = {
|
||||
"frequency_mhz": 900,
|
||||
"distance_m": 5000,
|
||||
"tx_height_m": 30,
|
||||
"rx_height_m": 1.5,
|
||||
"environment": "urban",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return PropagationInput(**defaults)
|
||||
|
||||
|
||||
class TestOkumuraHata:
|
||||
def test_urban_typical_range(self):
|
||||
model = OkumuraHataModel()
|
||||
out = model.calculate(make_input())
|
||||
# 900MHz, 5km, urban: expect ~130-155 dB
|
||||
assert 120 < out.path_loss_db < 160
|
||||
|
||||
def test_environment_ordering(self):
|
||||
"""Urban > suburban > rural path loss."""
|
||||
model = OkumuraHataModel()
|
||||
urban = model.calculate(make_input(environment="urban")).path_loss_db
|
||||
suburban = model.calculate(make_input(environment="suburban")).path_loss_db
|
||||
rural = model.calculate(make_input(environment="rural")).path_loss_db
|
||||
assert urban > suburban > rural
|
||||
|
||||
def test_distance_increases_loss(self):
|
||||
model = OkumuraHataModel()
|
||||
loss_1 = model.calculate(make_input(distance_m=2000)).path_loss_db
|
||||
loss_5 = model.calculate(make_input(distance_m=5000)).path_loss_db
|
||||
loss_10 = model.calculate(make_input(distance_m=10000)).path_loss_db
|
||||
assert loss_1 < loss_5 < loss_10
|
||||
|
||||
def test_frequency_increases_loss(self):
|
||||
model = OkumuraHataModel()
|
||||
loss_450 = model.calculate(make_input(frequency_mhz=450)).path_loss_db
|
||||
loss_900 = model.calculate(make_input(frequency_mhz=900)).path_loss_db
|
||||
assert loss_900 > loss_450
|
||||
|
||||
def test_higher_tx_reduces_loss(self):
|
||||
model = OkumuraHataModel()
|
||||
loss_low = model.calculate(make_input(tx_height_m=10)).path_loss_db
|
||||
loss_high = model.calculate(make_input(tx_height_m=50)).path_loss_db
|
||||
assert loss_high < loss_low
|
||||
|
||||
def test_valid_frequency_range(self):
|
||||
model = OkumuraHataModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=150))
|
||||
assert model.is_valid_for(make_input(frequency_mhz=1500))
|
||||
assert not model.is_valid_for(make_input(frequency_mhz=2000))
|
||||
|
||||
def test_valid_distance_range(self):
|
||||
model = OkumuraHataModel()
|
||||
assert model.is_valid_for(make_input(distance_m=500))
|
||||
assert model.is_valid_for(make_input(distance_m=20000))
|
||||
# Out of range
|
||||
assert not model.is_valid_for(make_input(distance_m=50))
|
||||
|
||||
def test_model_name(self):
|
||||
model = OkumuraHataModel()
|
||||
assert model.name == "Okumura-Hata"
|
||||
|
||||
def test_open_environment(self):
|
||||
"""Open environment should have even less loss than rural."""
|
||||
model = OkumuraHataModel()
|
||||
rural = model.calculate(make_input(environment="rural")).path_loss_db
|
||||
open_area = model.calculate(make_input(environment="open")).path_loss_db
|
||||
assert open_area < rural
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
instance = TestOkumuraHata()
|
||||
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.")
|
||||
198
backend/tests/test_models/test_propagation.py
Normal file
198
backend/tests/test_models/test_propagation.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Unit tests for propagation models.
|
||||
|
||||
Run: cd backend && python -m pytest tests/test_models/test_propagation.py -v
|
||||
"""
|
||||
|
||||
import math
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from app.propagation.base import PropagationInput
|
||||
from app.propagation.free_space import FreeSpaceModel
|
||||
from app.propagation.okumura_hata import OkumuraHataModel
|
||||
from app.propagation.cost231_hata import Cost231HataModel
|
||||
from app.propagation.cost231_wi import Cost231WIModel
|
||||
from app.propagation.itu_r_p1546 import ITUR_P1546Model
|
||||
from app.propagation.longley_rice import LongleyRiceModel
|
||||
from app.propagation.itu_r_p526 import KnifeEdgeDiffractionModel
|
||||
|
||||
|
||||
def make_input(**kwargs) -> PropagationInput:
|
||||
defaults = {
|
||||
"frequency_mhz": 1800,
|
||||
"distance_m": 1000,
|
||||
"tx_height_m": 30,
|
||||
"rx_height_m": 1.5,
|
||||
"environment": "urban",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return PropagationInput(**defaults)
|
||||
|
||||
|
||||
class TestFreeSpaceModel:
|
||||
def test_basic_fspl(self):
|
||||
model = FreeSpaceModel()
|
||||
output = model.calculate(make_input(distance_m=1000, frequency_mhz=1800))
|
||||
# FSPL at 1km, 1800MHz ≈ 97.5 dB
|
||||
assert 95 < output.path_loss_db < 100
|
||||
assert output.is_los is True
|
||||
assert output.model_name == "Free-Space"
|
||||
|
||||
def test_distance_increases_loss(self):
|
||||
model = FreeSpaceModel()
|
||||
loss_1km = model.calculate(make_input(distance_m=1000)).path_loss_db
|
||||
loss_2km = model.calculate(make_input(distance_m=2000)).path_loss_db
|
||||
# Doubling distance adds ~6 dB
|
||||
assert 5 < (loss_2km - loss_1km) < 7
|
||||
|
||||
def test_frequency_increases_loss(self):
|
||||
model = FreeSpaceModel()
|
||||
loss_900 = model.calculate(make_input(frequency_mhz=900)).path_loss_db
|
||||
loss_1800 = model.calculate(make_input(frequency_mhz=1800)).path_loss_db
|
||||
# Doubling frequency adds ~6 dB
|
||||
assert 5 < (loss_1800 - loss_900) < 7
|
||||
|
||||
def test_valid_range(self):
|
||||
model = FreeSpaceModel()
|
||||
assert model.is_valid_for(make_input(distance_m=100))
|
||||
assert model.is_valid_for(make_input(distance_m=100000))
|
||||
|
||||
|
||||
class TestOkumuraHata:
|
||||
def test_urban_loss(self):
|
||||
model = OkumuraHataModel()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=900, distance_m=5000,
|
||||
tx_height_m=30, environment="urban",
|
||||
))
|
||||
# Typical urban loss at 5km, 900MHz: 130-150 dB
|
||||
assert 120 < output.path_loss_db < 160
|
||||
assert output.model_name == "Okumura-Hata"
|
||||
|
||||
def test_suburban_less_than_urban(self):
|
||||
model = OkumuraHataModel()
|
||||
inp = make_input(frequency_mhz=900, distance_m=5000)
|
||||
urban = model.calculate(PropagationInput(**{**inp.__dict__, "environment": "urban"}))
|
||||
suburban = model.calculate(PropagationInput(**{**inp.__dict__, "environment": "suburban"}))
|
||||
assert suburban.path_loss_db < urban.path_loss_db
|
||||
|
||||
def test_rural_less_than_suburban(self):
|
||||
model = OkumuraHataModel()
|
||||
inp = make_input(frequency_mhz=900, distance_m=5000)
|
||||
suburban = model.calculate(PropagationInput(**{**inp.__dict__, "environment": "suburban"}))
|
||||
rural = model.calculate(PropagationInput(**{**inp.__dict__, "environment": "rural"}))
|
||||
assert rural.path_loss_db < suburban.path_loss_db
|
||||
|
||||
def test_valid_range(self):
|
||||
model = OkumuraHataModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=900, distance_m=5000))
|
||||
assert not model.is_valid_for(make_input(frequency_mhz=2000, distance_m=5000))
|
||||
|
||||
|
||||
class TestCost231Hata:
|
||||
def test_basic_loss(self):
|
||||
model = Cost231HataModel()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=1800, distance_m=5000,
|
||||
))
|
||||
assert 130 < output.path_loss_db < 170
|
||||
assert output.model_name == "COST-231-Hata"
|
||||
|
||||
def test_valid_range(self):
|
||||
model = Cost231HataModel()
|
||||
assert model.is_valid_for(make_input(frequency_mhz=1800, distance_m=5000))
|
||||
assert not model.is_valid_for(make_input(frequency_mhz=900, distance_m=5000))
|
||||
|
||||
|
||||
class TestCost231WI:
|
||||
def test_basic_loss(self):
|
||||
model = Cost231WIModel()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=1800, distance_m=500,
|
||||
environment="urban",
|
||||
))
|
||||
assert 80 < output.path_loss_db < 160
|
||||
assert output.model_name == "COST-231-WI"
|
||||
|
||||
|
||||
class TestITUR_P1546:
|
||||
def test_basic_loss(self):
|
||||
model = ITUR_P1546Model()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=450, distance_m=10000,
|
||||
))
|
||||
assert 80 < output.path_loss_db < 160
|
||||
assert output.model_name == "ITU-R-P.1546"
|
||||
|
||||
|
||||
class TestLongleyRice:
|
||||
def test_basic_loss(self):
|
||||
model = LongleyRiceModel()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=150, distance_m=20000,
|
||||
terrain_roughness_m=50,
|
||||
))
|
||||
assert 90 < output.path_loss_db < 160
|
||||
assert output.model_name == "Longley-Rice"
|
||||
|
||||
def test_flat_terrain_is_los(self):
|
||||
model = LongleyRiceModel()
|
||||
output = model.calculate(make_input(
|
||||
frequency_mhz=150, distance_m=5000,
|
||||
terrain_roughness_m=5,
|
||||
))
|
||||
assert output.is_los is True
|
||||
|
||||
|
||||
class TestKnifeEdgeDiffraction:
|
||||
def test_no_obstruction(self):
|
||||
loss = KnifeEdgeDiffractionModel.calculate_loss(
|
||||
d1_m=500, d2_m=500, h_m=-5, wavelength_m=0.167,
|
||||
)
|
||||
assert loss >= 0
|
||||
|
||||
def test_obstruction_increases_loss(self):
|
||||
loss_low = KnifeEdgeDiffractionModel.calculate_loss(
|
||||
d1_m=500, d2_m=500, h_m=1, wavelength_m=0.167,
|
||||
)
|
||||
loss_high = KnifeEdgeDiffractionModel.calculate_loss(
|
||||
d1_m=500, d2_m=500, h_m=10, wavelength_m=0.167,
|
||||
)
|
||||
assert loss_high > loss_low
|
||||
|
||||
def test_clearance_loss_positive_clearance(self):
|
||||
loss = KnifeEdgeDiffractionModel.calculate_clearance_loss(5.0, 1800)
|
||||
assert loss == 0.0
|
||||
|
||||
def test_clearance_loss_negative_clearance(self):
|
||||
loss = KnifeEdgeDiffractionModel.calculate_clearance_loss(-10.0, 1800)
|
||||
assert loss > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick run without pytest
|
||||
for cls_name, cls in [
|
||||
("FreeSpace", TestFreeSpaceModel),
|
||||
("OkumuraHata", TestOkumuraHata),
|
||||
("COST231Hata", TestCost231Hata),
|
||||
("COST231WI", TestCost231WI),
|
||||
("ITU-R-P.1546", TestITUR_P1546),
|
||||
("LongleyRice", TestLongleyRice),
|
||||
("KnifeEdge", TestKnifeEdgeDiffraction),
|
||||
]:
|
||||
instance = cls()
|
||||
methods = [m for m in dir(instance) if m.startswith("test_")]
|
||||
for method_name in methods:
|
||||
try:
|
||||
getattr(instance, method_name)()
|
||||
print(f" PASS {cls_name}.{method_name}")
|
||||
except AssertionError as e:
|
||||
print(f" FAIL {cls_name}.{method_name}: {e}")
|
||||
except Exception as e:
|
||||
print(f" ERROR {cls_name}.{method_name}: {e}")
|
||||
|
||||
print("\nAll tests completed.")
|
||||
Reference in New Issue
Block a user