from math import isclose from app.core.surface import SurfaceProfile, SurfaceSample from app.models.coverage import LinkRx, LinkTx from app.models.link import LinkBudgetRequest from app.services import link as link_service def test_link_budget_includes_vegetation_loss(monkeypatch) -> None: monkeypatch.setattr(link_service, "vegetation_loss_along", lambda *args, **kwargs: 5.0) monkeypatch.setattr( link_service, "surface_profile_from_points", lambda points, *args, **kwargs: SurfaceProfile( distance_m=points[-1].distance_m, samples=[ SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=0, building_m=0, canopy_m=0, surface_m=0, ) for index, point in enumerate(points) ], ), ) request = LinkBudgetRequest( tx=LinkTx(lat=60.17, lon=24.94, height_agl=30, power_dbm=37, gain_dbi=8), rx=LinkRx( lat=60.25, lon=25.10, height_agl=2, gain_dbi=2, sensitivity_dbm=-110, ), frequency_mhz=433, model="manual", include_vegetation=True, ) result = link_service.link_budget(request) assert result.vegetation_db == 5.0 assert result.los_clear is result.fresnel_clear assert result.fresnel_violations_count >= 0 assert isclose( result.total_loss_db, result.fspl_db + result.diffraction_db + result.vegetation_db + result.atmospheric_db, ) def test_link_budget_includes_diffraction_loss(monkeypatch) -> None: monkeypatch.setattr(link_service, "vegetation_loss_along", lambda *args, **kwargs: 0.0) def fake_profile(points, *args, **kwargs): midpoint = len(points) // 2 samples = [] for index, point in enumerate(points): obstacle = 20.0 if index == midpoint else 0.0 samples.append( SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=obstacle, building_m=0, canopy_m=0, surface_m=obstacle, ) ) return SurfaceProfile(distance_m=points[-1].distance_m, samples=samples) monkeypatch.setattr(link_service, "surface_profile_from_points", fake_profile) request = LinkBudgetRequest( tx=LinkTx(lat=60.17, lon=24.94, height_agl=0, power_dbm=37, gain_dbi=8), rx=LinkRx( lat=60.25, lon=25.10, height_agl=0, gain_dbi=2, sensitivity_dbm=-110, ), frequency_mhz=433, model="manual", include_buildings=False, include_vegetation=False, ) result = link_service.link_budget(request) assert result.diffraction_db > 0 assert result.knife_edge_diffraction_db == result.diffraction_db assert result.propagation_excess_db == result.diffraction_db assert isclose(result.propagation_model_loss_db, result.fspl_db + result.diffraction_db) assert isclose( result.total_loss_db, result.fspl_db + result.diffraction_db + result.atmospheric_db, ) assert result.fresnel_clear is False assert result.los_clear is False assert result.fresnel_quality == "blocked" assert result.geometric_los is False assert result.fresnel_violations_count > 0 assert result.geometric_obstructions_count > 0 assert result.worst_obstruction is not None def test_link_viable_depends_on_margin_not_fresnel_quality(monkeypatch) -> None: monkeypatch.setattr(link_service, "vegetation_loss_along", lambda *args, **kwargs: 0.0) def fake_profile(points, *args, **kwargs): midpoint = len(points) // 2 return SurfaceProfile( distance_m=points[-1].distance_m, samples=[ SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=20.0 if index == midpoint else 0.0, building_m=0, canopy_m=0, surface_m=20.0 if index == midpoint else 0.0, ) for index, point in enumerate(points) ], ) monkeypatch.setattr(link_service, "surface_profile_from_points", fake_profile) request = LinkBudgetRequest( tx=LinkTx(lat=60.17, lon=24.94, height_agl=0, power_dbm=70, gain_dbi=8), rx=LinkRx( lat=60.25, lon=25.10, height_agl=0, gain_dbi=2, sensitivity_dbm=-120, ), frequency_mhz=433, model="manual", include_buildings=False, include_vegetation=False, ) result = link_service.link_budget(request) assert result.fade_margin_db > 0 assert result.fresnel_quality == "blocked" assert result.fresnel_clear is False assert result.link_viable is True def test_itm_link_budget_does_not_double_count_diffraction(monkeypatch) -> None: monkeypatch.setattr(link_service, "vegetation_loss_along", lambda *args, **kwargs: 3.0) monkeypatch.setattr(link_service, "atmospheric_loss_between", lambda *args, **kwargs: 2.0) monkeypatch.setattr(link_service, "bullington_equivalent_loss", lambda *args, **kwargs: 99.0) monkeypatch.setattr( link_service, "surface_profile_from_points", lambda points, *args, **kwargs: SurfaceProfile( distance_m=points[-1].distance_m, samples=[ SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=0, building_m=0, canopy_m=0, surface_m=0, ) for index, point in enumerate(points) ], ), ) request = LinkBudgetRequest( tx=LinkTx(lat=60.17, lon=24.94, height_agl=30, power_dbm=37, gain_dbi=8), rx=LinkRx(lat=60.25, lon=25.10, height_agl=2, gain_dbi=2, sensitivity_dbm=-110), frequency_mhz=433, model="itm", include_vegetation=True, ) base = link_service.manual_link_budget( request.tx, request.rx, request.tx.power_dbm, request.tx.gain_dbi, request.rx.gain_dbi, request.rx.sensitivity_dbm, request.frequency_mhz, ).fspl_db monkeypatch.setattr(link_service, "itm_loss", lambda *args, **kwargs: base + 12.0) result = link_service.link_budget(request) assert result.diffraction_db == 12.0 assert result.knife_edge_diffraction_db == 99.0 assert result.propagation_model_loss_db == result.fspl_db + 12.0 assert result.propagation_excess_db == 12.0 assert result.atmospheric_db == 2.0 assert isclose(result.total_loss_db, result.fspl_db + 12.0 + 3.0 + 2.0) def test_p452_link_budget_does_not_double_count_diffraction_or_atmosphere(monkeypatch) -> None: monkeypatch.setattr(link_service, "vegetation_loss_along", lambda *args, **kwargs: 3.0) monkeypatch.setattr(link_service, "atmospheric_loss_between", lambda *args, **kwargs: 99.0) monkeypatch.setattr(link_service, "bullington_equivalent_loss", lambda *args, **kwargs: 88.0) monkeypatch.setattr( link_service, "surface_profile_from_points", lambda points, *args, **kwargs: SurfaceProfile( distance_m=points[-1].distance_m, samples=[ SurfaceSample( i=index, lat=point.lat, lon=point.lon, distance_m=point.distance_m, ground_m=0, building_m=0, canopy_m=0, surface_m=0, ) for index, point in enumerate(points) ], ), ) request = LinkBudgetRequest( tx=LinkTx(lat=60.17, lon=24.94, height_agl=30, power_dbm=37, gain_dbi=8), rx=LinkRx(lat=60.25, lon=25.10, height_agl=2, gain_dbi=2, sensitivity_dbm=-110), frequency_mhz=433, model="p452", include_vegetation=True, ) base = link_service.manual_link_budget( request.tx, request.rx, request.tx.power_dbm, request.tx.gain_dbi, request.rx.gain_dbi, request.rx.sensitivity_dbm, request.frequency_mhz, ).fspl_db monkeypatch.setattr(link_service, "p452_loss", lambda *args, **kwargs: base + 14.0) result = link_service.link_budget(request) assert result.diffraction_db == 14.0 assert result.knife_edge_diffraction_db == 88.0 assert result.propagation_model_loss_db == result.fspl_db + 14.0 assert result.propagation_excess_db == 14.0 assert result.atmospheric_db == 0.0 assert isclose(result.total_loss_db, result.fspl_db + 14.0 + 3.0)