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 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