diff --git a/.env.example b/.env.example index f58d8bc..0c45ee8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ LANDCOVER_PATH=/data/landcover CANOPY_PATH=/data/canopy API_V1_PREFIX=/api/v1 BUILDINGS_QUERY_LIMIT=5000 +P833_GAMMA_DB_PER_M=0.15 +P833_MAX_ATTENUATION_DB=25.0 POSTGIS_PORT=5432 REDIS_PORT=6379 API_PORT=8000 diff --git a/API.md b/API.md index 54c46b4..d163d27 100644 --- a/API.md +++ b/API.md @@ -20,6 +20,8 @@ - Частота в HTTP API: `frequency_mhz`. - DEM берётся из `DEM_PATH`, по умолчанию `/data/dem`. - Buildings берутся из PostGIS table `buildings`. +- Vegetation loss считается по формуле P.833 с коэффициентами из env: + `P833_GAMMA_DB_PER_M` и `P833_MAX_ATTENUATION_DB`. ## Health @@ -132,6 +134,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/profile \ "fresnel_clearance": 0.6, "include_buildings": true, "include_canopy": false, + "include_vegetation": true, "k_factor": 1.333, "samples": 128 } @@ -149,6 +152,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ "fresnel_clearance": 0.6, "include_buildings": true, "include_canopy": false, + "include_vegetation": true, "k_factor": 1.333, "samples": 128 }' | jq '{ @@ -158,7 +162,8 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ geometric_obstructions_count, building_obstructions_count, worst_obstruction, - diffraction_loss_db + diffraction_loss_db, + vegetation_loss_db }' ``` @@ -174,6 +179,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ - `building_obstructions_count`: количество геометрических препятствий типа `building`. - `worst_obstruction`: худшая точка по запасу относительно требуемого Fresnel clearance. - `diffraction_loss_db`: текущая Bullington-style оценка knife-edge loss. +- `vegetation_loss_db`: P.833 attenuation по `vegetation_depth_m` из WorldCover, если `include_vegetation=true`. Поля obstruction: @@ -261,6 +267,10 @@ curl -s -X POST http://localhost:5603/api/v1/link/budget \ - `fresnel_clear` - `link_viable` +При `include_vegetation=true` API сэмплит WorldCover вдоль трассы и добавляет +P.833 vegetation attenuation в `vegetation_db`. Если landcover raster отсутствует, +значение остаётся `0`, чтобы link budget продолжал работать. + Модели `p452` и `itm` пока возвращают `501 Not Implemented`. ## Antenna diff --git a/README.md b/README.md index e8491dc..5a2ad4a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ The `/api/v1/landcover/path` endpoint samples all `.tif`/`.tiff` files under `LANDCOVER_PATH` recursively. Canopy data is optional; when it is missing, `canopy_height_m` is returned as `null`. +When WorldCover is available, `/api/v1/link/budget` and `/api/v1/terrain/los` +can include P.833 vegetation attenuation. Tune the coefficients with +`P833_GAMMA_DB_PER_M` and `P833_MAX_ATTENUATION_DB` in `.env`. + For local Python development: ```bash diff --git a/api/app/__pycache__/config.cpython-313.pyc b/api/app/__pycache__/config.cpython-313.pyc index 4a947ac..17c7265 100644 Binary files a/api/app/__pycache__/config.cpython-313.pyc and b/api/app/__pycache__/config.cpython-313.pyc differ diff --git a/api/app/config.py b/api/app/config.py index 16f7035..80ad6e7 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -16,6 +16,8 @@ class Settings(BaseSettings): landcover_path: Path = Field(default=Path("/data/landcover")) canopy_path: Path = Field(default=Path("/data/canopy")) buildings_query_limit: int = Field(default=5000, gt=0) + p833_gamma_db_per_m: float = Field(default=0.15, ge=0) + p833_max_attenuation_db: float = Field(default=25.0, gt=0) @lru_cache diff --git a/api/app/models/__pycache__/terrain.cpython-313.pyc b/api/app/models/__pycache__/terrain.cpython-313.pyc index 9208ca5..526cac0 100644 Binary files a/api/app/models/__pycache__/terrain.cpython-313.pyc and b/api/app/models/__pycache__/terrain.cpython-313.pyc differ diff --git a/api/app/models/terrain.py b/api/app/models/terrain.py index 7918e7a..99a055f 100644 --- a/api/app/models/terrain.py +++ b/api/app/models/terrain.py @@ -44,6 +44,7 @@ class LosRequest(BaseModel): fresnel_clearance: float = Field(default=0.6, ge=0) include_buildings: bool = True include_canopy: bool = True + include_vegetation: bool = True k_factor: float = Field(default=1.333, gt=0) samples: int = Field(default=512, ge=2, le=10000) @@ -67,4 +68,5 @@ class LosResponse(BaseModel): geometric_obstructions_count: int building_obstructions_count: int diffraction_loss_db: float + vegetation_loss_db: float profile_ref: str diff --git a/api/app/services/__pycache__/link.cpython-313.pyc b/api/app/services/__pycache__/link.cpython-313.pyc index 3de992c..4c67c40 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 a3e2e76..3e7a723 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 6597a61..85fbf29 100644 --- a/api/app/services/link.py +++ b/api/app/services/link.py @@ -1,19 +1,29 @@ -from app.core.geo import GeoPoint +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.vegetation import vegetation_loss_along def link_budget(request: LinkBudgetRequest) -> 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) + vegetation_db = vegetation_loss_along( + points, + freq_hz=request.frequency_mhz * 1_000_000, + include_vegetation=request.include_vegetation, + ) budget = manual_link_budget( - tx=GeoPoint(lat=request.tx.lat, lon=request.tx.lon), - rx=GeoPoint(lat=request.rx.lat, lon=request.rx.lon), + tx=tx, + rx=rx, tx_power_dbm=request.tx.power_dbm, tx_gain_dbi=request.tx.gain_dbi, rx_gain_dbi=request.rx.gain_dbi, sensitivity_dbm=request.rx.sensitivity_dbm, frequency_mhz=request.frequency_mhz, + vegetation_db=vegetation_db, ) return LinkBudgetResponse(**budget.__dict__) diff --git a/api/app/services/terrain.py b/api/app/services/terrain.py index 779c53f..ae1ddff 100644 --- a/api/app/services/terrain.py +++ b/api/app/services/terrain.py @@ -20,6 +20,7 @@ from app.models.terrain import ( TerrainProfileResponse, ) from app.services import buildings as buildings_service +from app.services.vegetation import vegetation_loss_along def elevation_at(lat: float, lon: float, surface: str) -> ElevationResponse: @@ -101,6 +102,11 @@ def los( request.rx.height_agl, request.frequency_mhz * 1_000_000, ) + vegetation_loss = vegetation_loss_along( + points, + freq_hz=request.frequency_mhz * 1_000_000, + include_vegetation=request.include_vegetation, + ) def convert(sample: LosSample) -> Obstruction: return Obstruction( @@ -131,6 +137,7 @@ def los( geometric_obstructions_count=len(geometric_obstructions), building_obstructions_count=len(building_obstructions), diffraction_loss_db=diffraction_loss, + vegetation_loss_db=vegetation_loss, profile_ref=f"inline:{profile_request.samples}", ) diff --git a/api/app/services/vegetation.py b/api/app/services/vegetation.py new file mode 100644 index 0000000..b519102 --- /dev/null +++ b/api/app/services/vegetation.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +from app.config import get_settings +from app.core.geo import PathPoint +from app.core.landcover import LandcoverNotConfiguredError, landcover_path +from app.core.vegetation import P833Coefficients, p833_attenuation + + +def vegetation_loss_along( + points: list[PathPoint], + freq_hz: float, + include_vegetation: bool, + landcover_path_dir: str | Path | None = None, + canopy_path_dir: str | Path | None = None, +) -> float: + if not include_vegetation: + return 0.0 + + settings = get_settings() + landcover_dir = landcover_path_dir or settings.landcover_path + canopy_dir = canopy_path_dir or settings.canopy_path + + try: + path = landcover_path(points, landcover_dir, canopy_dir) + except LandcoverNotConfiguredError: + return 0.0 + + coefficients = P833Coefficients( + gamma_db_per_m=settings.p833_gamma_db_per_m, + max_attenuation_db=settings.p833_max_attenuation_db, + ) + return p833_attenuation( + depth_m=path.vegetation_depth_m, + freq_hz=freq_hz, + forest_type="unknown", + coefficients=coefficients, + ) 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 b5c6efa..06b8132 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 3a1288c..cbc508c 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -65,6 +65,7 @@ def test_los_endpoint_returns_obstruction_summary_fields() -> None: assert "geometric_obstructions" in data assert data["fresnel_violations_count"] == len(data["obstructions"]) assert data["geometric_obstructions_count"] == len(data["geometric_obstructions"]) + assert "vegetation_loss_db" in data assert data["building_obstructions_count"] == len( [item for item in data["geometric_obstructions"] if item["type"] == "building"] ) diff --git a/api/tests/test_vegetation_integration.py b/api/tests/test_vegetation_integration.py new file mode 100644 index 0000000..9dac631 --- /dev/null +++ b/api/tests/test_vegetation_integration.py @@ -0,0 +1,25 @@ +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) + 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.total_loss_db == result.fspl_db + 5.0