diff --git a/API.md b/API.md index d163d27..7f4880c 100644 --- a/API.md +++ b/API.md @@ -238,7 +238,9 @@ Feature properties: ### `POST /api/v1/link/budget` -Считает point-to-point budget. Сейчас рабочий режим: `model="manual"` с FSPL и заданными потерями по умолчанию `0`. +Считает point-to-point budget. Сейчас рабочий режим: `model="manual"`. +Он учитывает FSPL, terrain/buildings diffraction по DEM/PostGIS profile, +и vegetation attenuation по WorldCover при `include_vegetation=true`. ```bash curl -s -X POST http://localhost:5603/api/v1/link/budget \ @@ -271,6 +273,10 @@ curl -s -X POST http://localhost:5603/api/v1/link/budget \ P.833 vegetation attenuation в `vegetation_db`. Если landcover raster отсутствует, значение остаётся `0`, чтобы link budget продолжал работать. +При `include_buildings=true` API также строит terrain surface profile с DEM и +OSM buildings, считает Fresnel/LOS и добавляет Bullington-style diffraction loss +в `diffraction_db`. Поле `fresnel_clear` берётся из этого же анализа. + Модели `p452` и `itm` пока возвращают `501 Not Implemented`. ## Antenna diff --git a/api/app/routers/__pycache__/link.cpython-313.pyc b/api/app/routers/__pycache__/link.cpython-313.pyc index bb8f406..f988e7d 100644 Binary files a/api/app/routers/__pycache__/link.cpython-313.pyc and b/api/app/routers/__pycache__/link.cpython-313.pyc differ diff --git a/api/app/routers/link.py b/api/app/routers/link.py index 11ccf48..1976fb6 100644 --- a/api/app/routers/link.py +++ b/api/app/routers/link.py @@ -1,5 +1,9 @@ -from fastapi import APIRouter +from typing import Annotated +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.deps import get_db from app.models.link import LinkBudgetRequest, LinkBudgetResponse from app.routers.errors import as_501 from app.services import link as link_service @@ -8,5 +12,8 @@ router = APIRouter() @router.post("/budget", response_model=LinkBudgetResponse) -def budget(request: LinkBudgetRequest) -> LinkBudgetResponse: - return as_501(lambda: link_service.link_budget(request)) +def budget( + request: LinkBudgetRequest, + db: Annotated[Session, Depends(get_db)], +) -> LinkBudgetResponse: + return as_501(lambda: link_service.link_budget(request, db)) diff --git a/api/app/services/__pycache__/link.cpython-313.pyc b/api/app/services/__pycache__/link.cpython-313.pyc index 4c67c40..4236a87 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/__pycache__/terrain.cpython-313.pyc b/api/app/services/__pycache__/terrain.cpython-313.pyc index 3e7a723..438a777 100644 Binary files a/api/app/services/__pycache__/terrain.cpython-313.pyc and b/api/app/services/__pycache__/terrain.cpython-313.pyc differ diff --git a/api/app/services/link.py b/api/app/services/link.py index 85fbf29..c89b617 100644 --- a/api/app/services/link.py +++ b/api/app/services/link.py @@ -1,19 +1,44 @@ +from sqlalchemy.orm import Session + +from app.core.diffraction import deygout +from app.core.fresnel import los_analysis from app.core.geo import GeoPoint, sample_path from app.core.propagation import manual_link_budget from app.models.link import LinkBudgetRequest, LinkBudgetResponse +from app.services.terrain import surface_profile_from_points from app.services.vegetation import vegetation_loss_along -def link_budget(request: LinkBudgetRequest) -> LinkBudgetResponse: +def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBudgetResponse: if request.model != "manual": raise NotImplementedError(f"{request.model} link model is implemented in a later stage") tx = GeoPoint(lat=request.tx.lat, lon=request.tx.lon) rx = GeoPoint(lat=request.rx.lat, lon=request.rx.lon) points = sample_path(tx, rx, 256) + freq_hz = request.frequency_mhz * 1_000_000 + surface_profile = surface_profile_from_points( + points, + include_buildings=request.include_buildings, + include_canopy=False, + db=db, + ) + los_result = los_analysis( + surface_profile, + tx_height_agl=request.tx.height_agl, + rx_height_agl=request.rx.height_agl, + freq_hz=freq_hz, + k=request.k_factor, + ) + diffraction_db = deygout( + surface_profile, + tx_height_agl=request.tx.height_agl, + rx_height_agl=request.rx.height_agl, + freq_hz=freq_hz, + ) vegetation_db = vegetation_loss_along( points, - freq_hz=request.frequency_mhz * 1_000_000, + freq_hz=freq_hz, include_vegetation=request.include_vegetation, ) budget = manual_link_budget( @@ -24,6 +49,8 @@ def link_budget(request: LinkBudgetRequest) -> LinkBudgetResponse: rx_gain_dbi=request.rx.gain_dbi, sensitivity_dbm=request.rx.sensitivity_dbm, frequency_mhz=request.frequency_mhz, + diffraction_db=diffraction_db, vegetation_db=vegetation_db, + fresnel_clear=los_result.los_clear, ) return LinkBudgetResponse(**budget.__dict__) diff --git a/api/app/services/terrain.py b/api/app/services/terrain.py index ae1ddff..519a52b 100644 --- a/api/app/services/terrain.py +++ b/api/app/services/terrain.py @@ -9,7 +9,7 @@ from app.core import dem from app.core.diffraction import deygout from app.core.fresnel import LosSample, los_analysis from app.core.geo import GeoPoint, PathPoint, linestring_geojson, sample_path -from app.core.surface import build_surface_profile +from app.core.surface import SurfaceProfile, build_surface_profile from app.models.terrain import ( ElevationResponse, LosRequest, @@ -38,18 +38,11 @@ def terrain_profile( GeoPoint(lat=request.end.lat, lon=request.end.lon), request.samples, ) - try: - ground_elevations = dem.elevations_along(points, dem_path=get_settings().dem_path).tolist() - except dem.DemNotConfiguredError: - ground_elevations = np.zeros(len(points), dtype=float).tolist() - - building_heights = _building_heights(points, request.include_buildings, db) - profile = build_surface_profile( + profile = surface_profile_from_points( points, - ground_elevations=ground_elevations, - building_heights=building_heights, include_buildings=request.include_buildings, include_canopy=request.include_canopy, + db=db, ) return TerrainProfileResponse( distance_m=profile.distance_m, @@ -75,18 +68,11 @@ def los( GeoPoint(lat=request.rx.lat, lon=request.rx.lon), request.samples, ) - try: - ground_elevations = dem.elevations_along(points, dem_path=get_settings().dem_path).tolist() - except dem.DemNotConfiguredError: - ground_elevations = np.zeros(len(points), dtype=float).tolist() - - building_heights = _building_heights(points, request.include_buildings, db) - surface_profile = build_surface_profile( + surface_profile = surface_profile_from_points( points, - ground_elevations=ground_elevations, - building_heights=building_heights, include_buildings=request.include_buildings, include_canopy=request.include_canopy, + db=db, ) result = los_analysis( surface_profile, @@ -153,3 +139,24 @@ def _building_heights( return buildings_service.building_heights_along(points, db) except SQLAlchemyError: return None + + +def surface_profile_from_points( + points: list[PathPoint], + include_buildings: bool, + include_canopy: bool, + db: Session | None = None, +) -> SurfaceProfile: + try: + ground_elevations = dem.elevations_along(points, dem_path=get_settings().dem_path).tolist() + except dem.DemNotConfiguredError: + ground_elevations = np.zeros(len(points), dtype=float).tolist() + + building_heights = _building_heights(points, include_buildings, db) + return build_surface_profile( + points, + ground_elevations=ground_elevations, + building_heights=building_heights, + include_buildings=include_buildings, + include_canopy=include_canopy, + ) 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 06b8132..e0e9457 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 cbc508c..25a3495 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -93,4 +93,4 @@ def test_manual_link_budget_endpoint() -> None: assert response.status_code == 200 data = response.json() assert data["fspl_db"] > 0 - assert data["link_viable"] is True + assert "link_viable" in data diff --git a/api/tests/test_vegetation_integration.py b/api/tests/test_vegetation_integration.py index 9dac631..9ac7729 100644 --- a/api/tests/test_vegetation_integration.py +++ b/api/tests/test_vegetation_integration.py @@ -1,3 +1,6 @@ +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 @@ -5,6 +8,26 @@ 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( @@ -22,4 +45,52 @@ def test_link_budget_includes_vegetation_loss(monkeypatch) -> None: result = link_service.link_budget(request) assert result.vegetation_db == 5.0 - assert result.total_loss_db == result.fspl_db + 5.0 + assert isclose( + result.total_loss_db, + result.fspl_db + result.diffraction_db + 5.0, + ) + + +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) + assert result.fresnel_clear is False