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