Files
rfcp/backend/tests/test_models/test_propagation.py
mytec defa3ad440 @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)
2026-02-01 23:12:26 +02:00

199 lines
6.9 KiB
Python

"""
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.")