diff --git a/API.md b/API.md index 11081d1..5c9afa2 100644 --- a/API.md +++ b/API.md @@ -337,6 +337,7 @@ curl -s -X POST http://localhost:5603/api/v1/link/budget \ - `rx_power_dbm` - `fade_margin_db` - `fresnel_clear` +- `fresnel_quality`: `clear`, `partial` или `blocked` - `link_viable` - `los_clear` - `geometric_los` @@ -352,9 +353,18 @@ P.833 vegetation attenuation в `vegetation_db`. Если landcover raster от При `include_buildings=true` API также строит terrain surface profile с DEM и OSM buildings, считает Fresnel/LOS и добавляет Bullington-style diffraction loss -в `diffraction_db`. Поле `fresnel_clear` берётся из этого же анализа. Диагностические -поля `geometric_los`, `worst_obstruction` и счётчики obstruction помогают понять, -почему `link_viable=false`. +в `diffraction_db`. Поле `fresnel_clear` берётся из этого же анализа. + +`link_viable` считается по фактическому `fade_margin_db > 0` после применённых +потерь. Состояние зоны Френеля не выключает линк напрямую, а отдаётся отдельным +качеством: + +- `clear`: нет нарушений требуемых 60% первой зоны Френеля. +- `partial`: геометрическая LOS есть, но 60% зоны Френеля частично перекрыта. +- `blocked`: геометрическая линия TX-RX пересекает terrain/buildings/canopy. + +Диагностические поля `geometric_los`, `worst_obstruction` и счётчики obstruction +помогают понять, почему качество Френеля ухудшилось. Модели `p452` и `itm` пока возвращают `501 Not Implemented`. diff --git a/api/app/core/__pycache__/propagation.cpython-313.pyc b/api/app/core/__pycache__/propagation.cpython-313.pyc index 66eb576..63b9152 100644 Binary files a/api/app/core/__pycache__/propagation.cpython-313.pyc and b/api/app/core/__pycache__/propagation.cpython-313.pyc differ diff --git a/api/app/core/propagation.py b/api/app/core/propagation.py index 2ed734c..9bd97a7 100644 --- a/api/app/core/propagation.py +++ b/api/app/core/propagation.py @@ -65,5 +65,5 @@ def manual_link_budget( rx_power_dbm=rx_power, fade_margin_db=margin, fresnel_clear=fresnel_clear, - link_viable=margin > 0 and fresnel_clear, + link_viable=margin > 0, ) diff --git a/api/app/models/__pycache__/link.cpython-313.pyc b/api/app/models/__pycache__/link.cpython-313.pyc index 0adeabd..16bb94c 100644 Binary files a/api/app/models/__pycache__/link.cpython-313.pyc and b/api/app/models/__pycache__/link.cpython-313.pyc differ diff --git a/api/app/models/link.py b/api/app/models/link.py index 8f2dac6..ea4acbd 100644 --- a/api/app/models/link.py +++ b/api/app/models/link.py @@ -27,6 +27,7 @@ class LinkBudgetResponse(BaseModel): rx_power_dbm: float fade_margin_db: float fresnel_clear: bool + fresnel_quality: Literal["clear", "partial", "blocked"] link_viable: bool los_clear: bool geometric_los: bool diff --git a/api/app/services/__pycache__/link.cpython-313.pyc b/api/app/services/__pycache__/link.cpython-313.pyc index b61dcc9..5c631a3 100644 Binary files a/api/app/services/__pycache__/link.cpython-313.pyc and b/api/app/services/__pycache__/link.cpython-313.pyc differ diff --git a/api/app/services/link.py b/api/app/services/link.py index 0e0b1f9..fadfeba 100644 --- a/api/app/services/link.py +++ b/api/app/services/link.py @@ -68,6 +68,7 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu ] return LinkBudgetResponse( **budget.__dict__, + fresnel_quality=_fresnel_quality(los_result.los_clear, los_result.geometric_los), los_clear=los_result.los_clear, geometric_los=los_result.geometric_los, first_fresnel_clearance_pct=los_result.first_fresnel_clearance_pct, @@ -85,6 +86,14 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu ) +def _fresnel_quality(los_clear: bool, geometric_los: bool) -> str: + if los_clear: + return "clear" + if geometric_los: + return "partial" + return "blocked" + + def _convert_obstruction(sample: LosSample | None) -> Obstruction | None: if sample is None: return None diff --git a/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc b/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc index 5ba29df..f413cad 100644 Binary files a/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc and b/api/tests/__pycache__/test_api.cpython-313-pytest-9.0.3.pyc differ diff --git a/api/tests/test_api.py b/api/tests/test_api.py index ae13fff..61af882 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -123,4 +123,5 @@ def test_manual_link_budget_endpoint() -> None: data = response.json() assert data["fspl_db"] > 0 assert "link_viable" in data + assert data["fresnel_quality"] in {"clear", "partial", "blocked"} assert "data_sources" in data diff --git a/api/tests/test_vegetation_integration.py b/api/tests/test_vegetation_integration.py index f326843..03e35e3 100644 --- a/api/tests/test_vegetation_integration.py +++ b/api/tests/test_vegetation_integration.py @@ -97,7 +97,54 @@ def test_link_budget_includes_diffraction_loss(monkeypatch) -> None: assert isclose(result.total_loss_db, result.fspl_db + result.diffraction_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