From 4df70fa2704c8a35b8ef12e61456f8852de13bd6 Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 26 Jun 2026 11:55:00 +0300 Subject: [PATCH] added formulas --- API.md | 30 +- FORMULAS.md | 921 ++---------------- .../__pycache__/diffraction.cpython-313.pyc | Bin 2718 -> 3139 bytes api/app/core/diffraction.py | 26 +- .../models/__pycache__/link.cpython-313.pyc | Bin 2078 -> 2203 bytes api/app/models/link.py | 3 + .../services/__pycache__/link.cpython-313.pyc | Bin 7068 -> 6925 bytes .../__pycache__/terrain.cpython-313.pyc | Bin 15337 -> 16275 bytes api/app/services/link.py | 83 +- api/app/services/terrain.py | 29 +- .../test_api.cpython-313-pytest-9.0.3.pyc | Bin 25275 -> 26711 bytes .../test_core.cpython-313-pytest-9.0.3.pyc | Bin 15094 -> 15146 bytes api/tests/test_api.py | 3 + api/tests/test_core.py | 2 +- api/tests/test_vegetation_integration.py | 13 +- api/tests/test_viewshed_and_canopy.py | 85 +- 16 files changed, 268 insertions(+), 927 deletions(-) diff --git a/API.md b/API.md index c8475c4..8734410 100644 --- a/API.md +++ b/API.md @@ -7,8 +7,8 @@ ## Состояние Реализации -- Работает: healthcheck, OpenAPI, DEM elevation/profile, OSM buildings query, landcover/canopy path sampling, terrain LOS/Fresnel/diffraction с учётом DEM и buildings, link budget (`manual`, `itm`, `p452`) с P.676 atmospheric loss и P.833 vegetation, antenna omni/sector/file, async coverage/viewshed jobs через Redis + Celery. -- Частично работает: coverage raster export (`format=geotiff|png`), canopy bootstrap script (landcover загружается, canopy — вручную). +- Работает: healthcheck, OpenAPI, DEM elevation/profile, OSM buildings query, landcover/canopy path sampling, terrain LOS/Fresnel/diffraction с учётом DEM, buildings и canopy surface layer, link budget (`manual`, `itm`, `p452`) с P.676 atmospheric loss, P.833 vegetation и canopy препятствиями, antenna omni/sector/file, async coverage/viewshed jobs через Redis + Celery. +- Частично работает: coverage raster export (`format=geotiff|png`), canopy bootstrap script (данные можно качать автоматически, но объём большой). - Пока не реализовано: полный P.1812 с ITU digital maps (используется terrain-based ITM + clutter), WhiteboxTools viewshed (используется `gdal_viewshed`). ## Общие Правила @@ -336,9 +336,10 @@ Feature properties: ### `POST /api/v1/link/budget` -Считает point-to-point budget. Сейчас рабочий режим: `model="manual"`. -Он учитывает FSPL, terrain/buildings diffraction по DEM/PostGIS profile, -и vegetation attenuation по WorldCover при `include_vegetation=true`. +Считает point-to-point budget. Рабочие модели: `manual`, `itm`, `p452`. +`manual` использует FSPL + Bullington/P.526 + P.676 + vegetation. `itm` +использует Longley-Rice ITM + P.676 + vegetation. `p452` использует pycraf +P.452 + vegetation. ```bash curl -s -X POST http://localhost:5603/api/v1/link/budget \ @@ -359,6 +360,9 @@ curl -s -X POST http://localhost:5603/api/v1/link/budget \ - `distance_km` - `fspl_db` - `diffraction_db` +- `propagation_model_loss_db` +- `propagation_excess_db` +- `knife_edge_diffraction_db` - `vegetation_db` - `atmospheric_db` - `total_loss_db` @@ -379,9 +383,12 @@ 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` берётся из этого же анализа. +API строит terrain surface profile с DEM, OSM buildings и canopy, считает +Fresnel/LOS и Bullington-style diffraction diagnostic loss. Для `model=manual` +`diffraction_db` равен `knife_edge_diffraction_db`. Для `model=itm|p452` +`diffraction_db` оставлен как active propagation excess над FSPL; для новых +интеграций используй явные поля `propagation_excess_db` и +`knife_edge_diffraction_db`. `link_viable` считается по фактическому `fade_margin_db > 0` после применённых потерь. Состояние зоны Френеля не выключает линк напрямую, а отдаётся отдельным @@ -394,8 +401,11 @@ OSM buildings, считает Fresnel/LOS и добавляет Bullington-style Диагностические поля `geometric_los`, `worst_obstruction` и счётчики obstruction помогают понять, почему качество Френеля ухудшилось. -Модели `p452` и `itm` используют ITU-R P.452 (pycraf) и Longley-Rice ITM (itmlogic) поверх DEM profile. -Поле `atmospheric_db` считается по ITU-R P.676 (itur). Флаг `include_canopy` учитывает canopy raster в surface profile. +Модели `p452` и `itm` используют ITU-R P.452 (pycraf) и Longley-Rice ITM +(itmlogic) поверх DEM profile. Поле `atmospheric_db` считается по ITU-R P.676 +(itur) для `manual` и `itm`; для `p452` оно равно `0`, чтобы не дублировать +атмосферные потери, уже входящие в P.452. Флаг `include_canopy` учитывает canopy +raster в surface profile. ## Antenna diff --git a/FORMULAS.md b/FORMULAS.md index 606a7c2..1e028da 100644 --- a/FORMULAS.md +++ b/FORMULAS.md @@ -41,7 +41,7 @@ $$ Функция: `sample_path(start, end, n)`. -Точки строятся через `pyproj.Geod` на эллипсоиде WGS84. Для каждой точки: +Точки строятся через `pyproj.Geod` на эллипсоиде WGS84: $$ d_i = @@ -53,8 +53,6 @@ $$ где $D$ — полная геодезическая длина. -Используется в terrain, LOS, link budget, coverage, landcover. - ### DEM Файл: `api/app/core/dem.py` @@ -80,27 +78,24 @@ $$ |------|----------| | `ground_m` | DEM, м AMSL | | `building_m` | высота здания, м | -| `canopy_m` | высота кроны, м | +| `canopy_m` | высота кроны из `CANOPY_PATH`, м | | `surface_m` | итоговая высота препятствия | +Canopy является optional layer: если `include_canopy=false`, файлов нет или отдельные точки не покрыты canopy-тайлами, соответствующая высота кроны считается `0.0`. + Тип препятствия (`dominant_obstruction`): `building` → `canopy` → `terrain`. -Примечание: в текущем `surface_profile_from_points()` растр кроны ещё не подмешивается автоматически в LOS-профиль. +API: `/terrain/profile`, `/terrain/los`, `/terrain/fresnel-slice`, `/link/budget`. ## 3. FSPL Файл: `api/app/core/propagation.py` -Функция: `fspl(freq_mhz, dist_km)`. - $$ L_{FS} = 32.44 + 20 \log_{10}(f_{MHz}) + 20 \log_{10}(d_{km}) $$ -Входы: - -- $f_{MHz} > 0$; -- $d_{km} > 0$. +Входы: $f_{MHz} > 0$, $d_{km} > 0$. Используется в `/coverage` (`model=fspl`) и как базовая составляющая `/link/budget`. @@ -108,8 +103,6 @@ $$ Файлы: `api/app/core/propagation.py`, `api/app/services/link.py`. -Базовая формула: - $$ P_{rx} = P_{tx} + G_{tx} + G_{rx} - L_{total} $$ @@ -124,7 +117,14 @@ $$ ### Активные слагаемые по модели -В ответе API поле `diffraction_db` для `itm` и `p452` сейчас фактически содержит **model excess** над FSPL, потому что отдельного поля `propagation_excess_db` пока нет. +В ответе API есть отдельные диагностические поля: + +| Поле | Смысл | +|------|-------| +| `propagation_model_loss_db` | полная потеря выбранной модели распространения до vegetation/atmosphere | +| `propagation_excess_db` | избыток выбранной модели над FSPL | +| `knife_edge_diffraction_db` | Bullington/P.526 diagnostic loss по surface profile | +| `diffraction_db` | active diffraction-like term, оставлен для обратной совместимости | | `model` | Суммарные потери `total_loss_db` | Что не добавляется отдельно | |---------|----------------------------------|------------------------------| @@ -132,23 +132,27 @@ $$ | `itm` | $L_{ITM} + L_{veg} + L_{atm}$ | отдельный Bullington/P.526 | | `p452` | $L_{P.452} + L_{veg}$ | отдельный Bullington/P.526 и отдельный P.676 | -Иначе говоря: +Для `manual`: $$ -L_{prop,excess} = L_{model} - L_{FS} +propagation\_model\_loss = L_{FS} + L_{diff} +$$ + +$$ +propagation\_excess = L_{diff} $$ Для `itm`: $$ -L_{total} = L_{FS} + L_{prop,excess} + L_{veg} + L_{atm} +L_{total} = L_{FS} + (L_{ITM} - L_{FS}) + L_{veg} + L_{atm} = L_{ITM} + L_{veg} + L_{atm} $$ Для `p452`: $$ -L_{total} = L_{FS} + L_{prop,excess} + L_{veg} +L_{total} = L_{FS} + (L_{P.452} - L_{FS}) + L_{veg} = L_{P.452} + L_{veg} $$ @@ -168,8 +172,6 @@ API: `POST /api/v1/link/budget`. Это Longley-Rice / ITM. В проекте он **не** является реализацией ITU-R P.530. -### Параметры - | Параметр | Значение | |----------|----------| | `climate` | 5 по умолчанию | @@ -188,9 +190,7 @@ API: `POST /api/v1/link/budget`. | `suburban` | 6 | | `rural` | 7 | -### Профиль PFL - -Профиль содержит DEM-высоты вдоль пути. Высоты концов дополняются AGL: +Профиль PFL содержит DEM-высоты вдоль пути. Высоты концов дополняются AGL: $$ h_0 = h_{DEM,tx} + h_{AGL,tx} @@ -206,9 +206,7 @@ $$ \Delta s = \frac{D_{km} \cdot 1000}{N - 1} $$ -### Потери - -В коде итоговая величина: +Итоговая величина в коде: $$ L_{ITM} = 8.685890 \cdot \ln(2 w_n d) + avar(0, 0, 0) @@ -216,17 +214,12 @@ $$ Здесь $8.685890 \approx 20 / \ln(10)$, а `avar` — статистическая поправка `itmlogic` для медианного случая. -API: - -- `POST /api/v1/coverage` (`model=itm`); -- `POST /api/v1/link/budget` (`model=itm`). +API: `/coverage` (`model=itm`), `/link/budget` (`model=itm`). ## 6. P.1812-подобная упрощённая модель Файл: `api/app/core/itm.py` -Функция: `p1812_path_loss(...)`. - Это **не полная** ITU-R P.1812. Реализация: $$ @@ -239,7 +232,7 @@ $$ | `suburban` | 6 | 4 дБ | | `rural` | 7 | 0 дБ | -API: `POST /api/v1/coverage` (`model=p1812`). +API: `/coverage` (`model=p1812`). ## 7. ITU-R P.452 @@ -247,8 +240,6 @@ API: `POST /api/v1/coverage` (`model=p1812`). Библиотека: [`bwinkel/pycraf`](https://github.com/bwinkel/pycraf). -Функция: `p452_path_loss(...)`. - Параметры `PathProp`: | Параметр | Значение | @@ -266,9 +257,9 @@ $$ L_{P.452} = loss\_complete(PathProp)[0] $$ -Полные уравнения P.452 находятся внутри `pycraf`. Для `/link/budget` отдельные Bullington/P.526 и P.676 поверх P.452 не добавляются, чтобы не учитывать те же эффекты дважды. +Полные уравнения P.452 находятся внутри `pycraf`. -API: `POST /api/v1/link/budget` (`model=p452`). +API: `/link/budget` (`model=p452`). ## 8. ITU-R P.676 @@ -276,8 +267,6 @@ API: `POST /api/v1/link/budget` (`model=p452`). Библиотека: [`inigodelportillo/ITU-Rpy`](https://github.com/inigodelportillo/ITU-Rpy). -Функция: `p676_gas_attenuation(freq_hz, distance_km)`. - $$ f_{GHz} = \frac{f_{Hz}}{10^9} $$ @@ -296,35 +285,21 @@ itu676.gaseous_attenuation_terrestrial_path( ) ``` -Параметры: +Параметры: высота 0 км, водяной пар 7.5 г/м³, давление 1013 гПа, температура 288 K, метод `exact`. -| Параметр | Значение | -|----------|----------| -| Высота | 0 км | -| Плотность водяного пара | 7.5 г/м³ | -| Давление | 1013 гПа | -| Температура | 288 K | -| Метод | `exact` | - -API: `POST /api/v1/link/budget` (`model=manual`, `model=itm`). Для `model=p452` отдельный P.676 не добавляется. +API: `/link/budget` для `manual` и `itm`. Для `p452` отдельный P.676 не добавляется. ## 9. ITU-R P.526: дифракция Файл: `api/app/core/diffraction.py` -### Параметр Френеля-Кирхгофа +Параметр Френеля-Кирхгофа: $$ v = h \sqrt{\frac{2(d_1+d_2)}{\lambda d_1 d_2}} $$ -где: - -- $h$ — превышение препятствия над хордой TX-RX; -- $d_1$, $d_2$ — расстояния до препятствия; -- $\lambda = c/f$. - -### Потери острого края +Потери острого края: $$ J(v) = @@ -334,9 +309,7 @@ J(v) = \end{cases} $$ -### Bullington-style equivalent edge - -Для внутренних точек профиля: +Bullington-style equivalent edge: $$ h_{path}(d_1) = h_{tx} + (h_{rx}-h_{tx})\frac{d_1}{D} @@ -350,14 +323,15 @@ $$ L_{diff} = J(\max_i v_i) $$ -В дифракции земная кривизна сейчас не учитывается, в отличие от LOS-модуля. Это физическая несогласованность на длинных пролётах: LOS может стать NLOS из-за `earth_bulge`, а `L_diff` останется заниженным. +Как и LOS-модуль, Bullington-style diffraction добавляет выпуклость Земли: + +$$ +h_i = h_{surface,i} + bulge(d_1,d_2,k) - h_{path}(d_1) +$$ Функция `deygout(...)` сейчас является compatibility-wrapper над `bullington_loss(...)`; многоэкранный Deygout не реализован. -API: - -- `POST /api/v1/terrain/los`; -- `POST /api/v1/link/budget` только для `model=manual`. +API: `/terrain/los`, `/link/budget` только для `model=manual`. ## 10. Френель и LOS @@ -383,15 +357,7 @@ $$ По умолчанию $k = 1.333$. -### LOS-анализ - -$$ -h_{tx} = h_{ground,0} + h_{AGL,tx} -$$ - -$$ -h_{rx} = h_{ground,N} + h_{AGL,rx} -$$ +LOS-анализ: $$ h_{path}(d_1) = h_{tx} + (h_{rx}-h_{tx})\frac{d_1}{D} @@ -411,26 +377,18 @@ $$ По умолчанию `clearance_fraction = 0.6`. -Критерии: - | Поле | Условие | |------|---------| | `geometric_los` | нет внутренних точек с `clearance < 0` | | `los_clear` | нет внутренних точек с `clearance < required` | | `first_fresnel_clearance_pct` | минимум `clearance / required * 100%` | -API: - -- `POST /api/v1/terrain/los`; -- `POST /api/v1/terrain/fresnel-slice`; -- `POST /api/v1/link/budget`. +API: `/terrain/los`, `/terrain/fresnel-slice`, `/link/budget`. ## 11. P.833-подобная растительность Файл: `api/app/core/vegetation.py` -Функция: `p833_attenuation(depth_m, freq_hz, forest_type, ...)`. - Это упрощённая экспоненциальная модель, вдохновлённая P.833, но **не полная ITU-R P.833**, потому что частота в формуле не используется: $$ @@ -447,11 +405,7 @@ $$ | `herbaceous_wetland` | 0.08 | 10 | | `unknown` | 0.15 | 25 | -Дефолты из `config.py`: $\gamma = 0.15$, $A_{max} = 25$. - -### Глубина растительности - -Файл: `api/app/core/landcover.py` +Глубина растительности: $$ d_{veg} = \sum \Delta s_i @@ -459,26 +413,19 @@ $$ Суммируются только сегменты `tree_cover` и `mangroves`. -API: - -- `POST /api/v1/terrain/los`; -- `POST /api/v1/link/budget`; -- `POST /api/v1/coverage` при `include_vegetation=true`; -- `POST /api/v1/landcover/path` для сегментации. +API: `/terrain/los`, `/link/budget`, `/coverage`, `/landcover/path`. ## 12. Диаграммы направленности антенн Файл: `api/app/core/antenna.py` -### Omni +Omni: $$ G(\varphi,\theta) = G_{dBi} $$ -### Sector - -Текущая реализация: +Sector: $$ \sigma = \frac{BW}{2.355} @@ -506,30 +453,20 @@ $$ G = G_{dBi} + A $$ -Важно: при такой формуле `beamwidth_h`/`beamwidth_v` **не являются HPBW** в обычном смысле. На краю полуширины спад получается около -5 дБ, а не -3 дБ. +Важно: при такой формуле `beamwidth_h`/`beamwidth_v` **не являются HPBW** в обычном смысле. -### File - -Файл: `api/app/core/antenna_pattern_file.py` - -Берётся ближайший CSV-сэмпл по az/el: +File pattern: $$ G = G_{dBi} + G_{relative} $$ -API: - -- `POST /api/v1/antenna/pattern`; -- `POST /api/v1/antenna/beam`; -- `POST /api/v1/coverage`. +API: `/antenna/pattern`, `/antenna/beam`, `/coverage`. ## 13. Coverage Файл: `api/app/core/coverage.py` -Мощность в точке: - $$ EIRP = P_{tx} + G_{ant}(azimuth, 0^\circ) $$ @@ -547,9 +484,7 @@ $$ 3. Пока $P_{rx} \ge level\_dbm$, точка считается покрытой. 4. Последняя покрытая точка образует радиальный контур. -Для ITM/p1812-like на каждом луче берётся DEM-профиль из 64 точек. Для растительности шаг сэмплирования около 250 м. - -API: `POST /api/v1/coverage` → Celery job → `GET /api/v1/jobs/{id}`. +API: `/coverage` → Celery job → `/jobs/{id}`. ## 14. Viewshed @@ -558,12 +493,12 @@ API: `POST /api/v1/coverage` → Celery job → `GET /api/v1/jobs/{id}`. Используется GDAL `gdal_viewshed`: 1. DEM-тайлы мозаичатся в UTM. -2. Передаются `-oz` (observer AGL), `-tz` (target AGL), `-md` (max distance). +2. Передаются `-oz`, `-tz`, `-md`. 3. Видимые пиксели (`255`) векторизуются в GeoJSON. -Кастомных RF-формул нет. `k_factor` из `ViewshedRequest` в `gdal_viewshed` сейчас не передаётся. +`k_factor` из `ViewshedRequest` в `gdal_viewshed` сейчас не передаётся. -API: `POST /api/v1/viewshed`. +API: `/viewshed`. ## 15. Здания @@ -588,7 +523,7 @@ height_m = default -> 9 м ``` -API: `POST /api/v1/buildings/query`, косвенно terrain/link. +API: `/buildings/query`, косвенно terrain/link. ## 16. Сводная таблица @@ -596,6 +531,7 @@ API: `POST /api/v1/buildings/query`, косвенно terrain/link. |--------|------|-----| | Haversine / WGS84 | `core/geo.py` | почти все расчёты | | DEM sampling | `core/dem.py` | terrain, link, coverage | +| Surface profile | `core/surface.py`, `services/terrain.py` | terrain, link | | FSPL | `core/propagation.py` | coverage, link | | ITM / Longley-Rice | `core/itm.py` | coverage, link | | p1812-like | `core/itm.py` | coverage | @@ -609,8 +545,6 @@ API: `POST /api/v1/buildings/query`, косвенно terrain/link. ## 17. Эталонные значения из тестов -Файл: `api/tests/test_core.py` - | Расчёт | Вход | Ожидаемый результат | |--------|------|---------------------| | FSPL | $f=433$ МГц, $d=12.45$ км | $107.08$ дБ | @@ -627,751 +561,14 @@ API: `POST /api/v1/buildings/query`, косвенно terrain/link. 2. `p1812` в coverage — упрощённая p1812-like модель: ITM + clutter. 3. Vegetation — p833-like модель без частотной зависимости. 4. P.452, ITM и P.676 делегированы внешним библиотекам. -5. `deygout()` по имени не соответствует реализации: сейчас это Bullington-style equivalent edge. -6. Дифракция не учитывает `earth_bulge`, а LOS учитывает. -7. `link_viable` зависит только от `fade_margin_db > 0`, не от качества Френеля. -8. Coverage строится радиальными контурами, не полноценной 2D-сеткой. -9. Viewshed не использует `k_factor`. -10. В API ответа `/link/budget` поле `diffraction_db` для `itm`/`p452` фактически несёт model excess над FSPL; лучше в будущем заменить или дополнить его полем `propagation_excess_db`. +5. `deygout()` оставлен для совместимости, но новый код использует явное `bullington_equivalent_loss()`. +6. `link_viable` зависит только от `fade_margin_db > 0`, не от качества Френеля. +7. Coverage строится радиальными контурами, не полноценной 2D-сеткой. +8. Viewshed не использует `k_factor`. +9. `diffraction_db` в `/link/budget` оставлен для обратной совместимости; для новой интеграции лучше читать `propagation_excess_db` и `knife_edge_diffraction_db`. ## Связанные документы - [API.md](API.md) - [README.md](README.md) - [SPEC.md](SPEC.md) -# Формулы и расчёты RadioApi - -Документ описывает все математические модели, формулы и алгоритмы, реализованные в проекте **RadioApi** (RF Propagation API). - -**Единицы по умолчанию:** - -| Величина | Единица | -|----------|---------| -| Координаты | градусы WGS84 (lat, lon) | -| Расстояние | метры (м), километры (км) | -| Высота | метры над уровнем земли (AGL) или над морем (AMSL) | -| Частота | МГц (в API) или Гц (внутри части модулей) | -| Мощность | дБм | -| Усиление | дБи | -| Потери | дБ | - -**Префикс API:** `/api/v1` - ---- - -## Содержание - -1. [Геометрия и профиль пути](#1-геометрия-и-профиль-пути) -2. [Профиль поверхности](#2-профиль-поверхности) -3. [Свободное пространство (FSPL)](#3-свободное-пространство-fspl) -4. [Бюджет радиолинии](#4-бюджет-радиолинии) -5. [ITM / ITU-R P.530](#5-itm--itu-r-p530) -6. [ITU-R P.1812 (упрощённо)](#6-itu-r-p1812-упрощённо) -7. [ITU-R P.452](#7-itu-r-p452) -8. [ITU-R P.676 (атмосфера)](#8-itu-r-p676-атмосфера) -9. [ITU-R P.526 (дифракция)](#9-itu-r-p526-дифракция) -10. [Зоны Френеля и LOS](#10-зоны-френеля-и-los) -11. [ITU-R P.833 (растительность)](#11-itu-r-p833-растительность) -12. [Диаграммы направленности антенн](#12-диаграммы-направленности-антенн) -13. [Покрытие (coverage)](#13-покрытие-coverage) -14. [Viewshed](#14-viewshed) -15. [Здания](#15-здания) -16. [Сводная таблица: модель → API](#16-сводная-таблица-модель--api) -17. [Эталонные значения (тесты)](#17-эталонные-значения-тесты) -18. [Ограничения реализации](#18-ограничения-реализации) - ---- - -## 1. Геометрия и профиль пути - -**Файл:** `api/app/core/geo.py` - -### 1.1. Расстояние Haversine - -Приближённое расстояние по сфере между двумя точками: - -\[ -a = \sin^2\frac{\Delta\varphi}{2} + \cos\varphi_1 \cos\varphi_2 \sin^2\frac{\Delta\lambda}{2} -\] - -\[ -d = 2 R_e \arcsin(\sqrt{a}) -\] - -где: - -- \(\varphi\) — широта (рад), -- \(\lambda\) — долгота (рад), -- \(R_e = 6\,371\,000\) м. - -**Функция:** `haversine(a, b)` → расстояние в метрах. - -### 1.2. Геодезическая линия WGS84 - -**Функция:** `sample_path(start, end, n)` - -- Строит \(n\) точек вдоль геодезической линии (эллипсоид WGS84, `pyproj.Geod`). -- Каждая точка получает поле `distance_m` — расстояние от начала пути. -- Шаг: \(d_{step} = D / (n - 1)\), где \(D\) — полная длина геодезической дуги. - -**Используется в:** профиле рельефа, LOS, link budget, coverage, landcover. - -### 1.3. Выборка высот DEM - -**Файл:** `api/app/core/dem.py` - -- Билинейная интерполяция высоты из GeoTIFF/COG. -- `elevation_at(lat, lon)` — одна точка. -- `elevations_along(points)` — массив высот вдоль пути. - -**API:** `GET /api/v1/terrain/elevation`, косвенно все terrain/link/coverage эндпоинты. - ---- - -## 2. Профиль поверхности - -**Файл:** `api/app/core/surface.py` - -Для каждой точки пути: - -\[ -h_{surface} = h_{ground} + \max(h_{building}, h_{canopy}) -\] - -| Поле | Описание | -|------|----------| -| `ground_m` | Высота рельефа (DEM), м AMSL | -| `building_m` | Высота здания в точке, м | -| `canopy_m` | Высота кроны в точке, м | -| `surface_m` | Итоговая высота препятствия | - -**Тип препятствия** (`dominant_obstruction`): `building` → `canopy` → `terrain`. - -**API:** `POST /api/v1/terrain/profile`, `/terrain/los`, `/terrain/fresnel-slice`, `/link/budget` - -> **Примечание:** в текущей реализации `surface_profile_from_points()` не подмешивает растр кроны — `canopy_heights` остаётся нулевым, если не задано иное. - ---- - -## 3. Свободное пространство (FSPL) - -**Файл:** `api/app/core/propagation.py` -**Функция:** `fspl(freq_mhz, dist_km)` - -\[ -L_{FS} = 32.44 + 20 \log_{10}(f_{MHz}) + 20 \log_{10}(d_{km}) \quad [\text{дБ}] -\] - -**Входы:** - -- \(f_{MHz} > 0\) — частота, МГц -- \(d_{km} > 0\) — расстояние, км - -**API:** - -- `POST /api/v1/coverage` (`model=fspl`) -- `POST /api/v1/link/budget` (базовая составляющая) - ---- - -## 4. Бюджет радиолинии - -**Файлы:** `api/app/core/propagation.py`, `api/app/services/link.py` -**Функция:** `manual_link_budget(...)` - -### 4.1. Суммарные потери - -\[ -L_{total} = L_{FS} + L_{diff} + L_{veg} + L_{atm} + L_{misc} + L_{prop,excess} -\] - -где: - -| Слагаемое | Источник | -|-----------|----------| -| \(L_{FS}\) | FSPL | -| \(L_{diff}\) | Дифракция (P.526, Bullington) | -| \(L_{veg}\) | Растительность (P.833) | -| \(L_{atm}\) | Атмосфера (P.676) | -| \(L_{misc}\) | Ручные доп. потери (только `model=manual`) | -| \(L_{prop,excess}\) | Избыток над FSPL для ITM/P.452 | - -### 4.2. Избыток распространения - -Для `model=itm`: - -\[ -L_{prop,excess} = L_{ITM} - L_{FS} -\] - -Для `model=p452`: - -\[ -L_{prop,excess} = L_{P.452} - L_{FS} -\] - -Для `model=manual`: \(L_{prop,excess} = 0\). - -> Дифракция и атмосфера добавляются **отдельно**, чтобы не дублировать эффекты, уже учтённые в ITM/P.452. - -### 4.3. Мощность на приёме и запас - -\[ -P_{rx} = P_{tx} + G_{tx} + G_{rx} - L_{total} \quad [\text{дБм}] -\] - -\[ -\text{margin} = P_{rx} - P_{sens} \quad [\text{дБ}] -\] - -\[ -\text{link\_viable} = (\text{margin} > 0) -\] - -**API:** `POST /api/v1/link/budget` - -**Параметры профиля:** 256 точек вдоль пути TX→RX. - ---- - -## 5. ITM / ITU-R P.530 - -**Файл:** `api/app/core/itm.py` -**Библиотека:** [`itmlogic`](https://github.com/gregdingle/itmlogic) (Longley-Rice) - -**Функция:** `itm_path_loss(tx, rx, ...)` - -### 5.1. Входные параметры проекта - -| Параметр | Значение по умолчанию | -|----------|----------------------| -| `climate` | 5 (urban) | -| `ipol` | 0 (вертикальная поляризация) | -| `eps` | 15 (диэлектрическая проницаемость земли) | -| `sgm` | 0.005 См/м (проводимость земли) | -| `ens` | 314 | -| `gma` | \(157 \times 10^{-9}\) | -| `wn` | \(f_{MHz} / 47.7\) | - -### 5.2. Профиль местности (PFL) - -- 64 точки вдоль геодезической линии (или переданный `elevation_profile_m`). -- Высоты концов: \(h_0 = h_{DEM,tx} + h_{AGL,tx}\), \(h_n = h_{DEM,rx} + h_{AGL,rx}\). -- Шаг PFL: \(\Delta s = D_{km} \cdot 1000 / (N - 1)\) м. - -### 5.3. Итоговые потери - -\[ -L_{ITM} = 8.685890 \cdot \ln(2 \cdot w_n \cdot d) + \text{avar}(z_{0.5}, 0, z_{0.5}) -\] - -где: - -- \(8.685890 \approx 20 / \ln(10)\) — перевод натурального логарифма в децибелы, -- `avar` — статистическая поправка itmlogic (медиана, квантиль 0.5). - -### 5.4. Коды климата - -| Среда | `climate` | -|-------|-----------| -| urban | 5 | -| suburban | 6 | -| rural | 7 | - -**API:** - -- `POST /api/v1/coverage` (`model=itm`) -- `POST /api/v1/link/budget` (`model=itm`) - ---- - -## 6. ITU-R P.1812 (упрощённо) - -**Файл:** `api/app/core/itm.py` -**Функция:** `p1812_path_loss(...)` - -> Это **не полная** рекомендация ITU-R P.1812, а упрощённая модель: - -\[ -L_{P.1812} = L_{ITM}(climate) + L_{clutter} -\] - -| Среда (`environment`) | `climate` | \(L_{clutter}\), дБ | -|-----------------------|-----------|---------------------| -| urban | 5 | +8 | -| suburban | 6 | +4 | -| rural | 7 | 0 | - -**API:** `POST /api/v1/coverage` (`model=p1812`) - ---- - -## 7. ITU-R P.452 - -**Файл:** `api/app/core/p452.py` -**Библиотека:** [`pycraf`](https://github.com/bwronk/pycraf) - -**Функция:** `p452_path_loss(...)` - -### 7.1. Параметры PathProp - -| Параметр | Значение | -|----------|----------| -| Температура | 293 K | -| Давление | 1013 гПа | -| Время | 50% | -| Разрешение профиля | \(\max(100\ \text{м},\ 10 \cdot d_{km})\) | -| Зона застройки TX/RX | URBAN / SUBURBAN / SPARSE | - -### 7.2. Профиль - -- Расстояния `hprof_dists` — массив в метрах. -- Высоты `hprof_heights` — DEM + AGL на концах. -- Азимут и обратный азимут TX→RX. - -### 7.3. Результат - -\[ -L_{P.452} = \text{loss\_complete}(PathProp)[0] -\] - -Полные уравнения P.452 реализованы внутри `pycraf` и в проекте не дублируются. - -**API:** `POST /api/v1/link/budget` (`model=p452`) - ---- - -## 8. ITU-R P.676 (атмосфера) - -**Файл:** `api/app/core/atmosphere.py` -**Библиотека:** [`itur`](https://github.com/inigodelportillo/ITU-Rpy) - -**Функция:** `p676_gas_attenuation(freq_hz, distance_km)` - -\[ -L_{atm} = A_{gas}(d_{km},\ f_{GHz},\ h_1,\ \rho_{H_2O},\ P,\ T) -\] - -Параметры атмосферы (фиксированные в проекте): - -| Параметр | Значение | -|----------|----------| -| \(h_1\) | 0 км | -| \(\rho_{H_2O}\) | 7.5 г/м³ | -| \(P\) | 1013 гПа | -| \(T\) | 288 K | -| Метод | `"exact"` | - -\[ -f_{GHz} = f_{Hz} / 10^9 -\] - -**API:** `POST /api/v1/link/budget` → поле `atmospheric_db` - ---- - -## 9. ITU-R P.526 (дифракция) - -**Файл:** `api/app/core/diffraction.py` - -### 9.1. Параметр Френеля-Кирхгофа - -**Функция:** `knife_edge_v(h, d1, d2, freq_hz)` - -\[ -v = h \sqrt{\frac{2(d_1 + d_2)}{\lambda \, d_1 \, d_2}} -\] - -где: - -- \(h\) — превышение препятствия над хордой TX–RX, м -- \(d_1, d_2\) — расстояния до препятствия от TX и RX, м -- \(\lambda = c / f\) — длина волны, м -- \(c = 299\,792\,458\) м/с - -### 9.2. Потери острого края J(v) - -**Функция:** `knife_edge_loss(v)` — ITU-R P.526: - -\[ -J(v) = \begin{cases} -0 & v \le -0.78 \\ -6.9 + 20 \log_{10}\!\left(\sqrt{(v - 0.1)^2 + 1} + v - 0.1\right) & v > -0.78 -\end{cases} -\] - -### 9.3. Bullington (эквивалентный край) - -**Функция:** `bullington_loss(profile, ...)` - -Для каждой внутренней точки профиля (\(i = 1 \ldots N-2\)): - -\[ -h_{path}(d_1) = h_{tx} + (h_{rx} - h_{tx}) \frac{d_1}{D} -\] - -\[ -h_i = h_{surface,i} - h_{path}(d_1) -\] - -\[ -v_i = \text{knife\_edge\_v}(h_i, d_1, d_2, f) -\] - -\[ -L_{diff} = J\!\left(\max_i v_i\right) -\] - -> Земная кривизна в дифракции **не** учитывается (только в LOS-анализе). - -### 9.4. Deygout - -**Функция:** `deygout(...)` — обёртка над `bullington_loss()` (многоэкранная Deygout не реализована). - -**API:** - -- `POST /api/v1/terrain/los` → `diffraction_loss_db` -- `POST /api/v1/link/budget` → `diffraction_db` - ---- - -## 10. Зоны Френеля и LOS - -**Файл:** `api/app/core/fresnel.py` - -### 10.1. Длина волны - -\[ -\lambda = \frac{c}{f} -\] - -### 10.2. Радиус n-й зоны Френеля - -**Функция:** `fresnel_radius(λ, d1, d2, n=1)` - -\[ -r_n = \sqrt{\frac{n \lambda d_1 d_2}{d_1 + d_2}} -\] - -### 10.3. Выпуклость Земли (earth bulge) - -**Функция:** `earth_bulge(d1, d2, k)` - -\[ -\text{bulge} = \frac{d_1 d_2}{2 k R_e} -\] - -По умолчанию \(k = 1.333\) (эффективный радиус Земли для радиоволн). - -### 10.4. LOS-анализ - -**Функция:** `los_analysis(profile, tx_height_agl, rx_height_agl, freq_hz, clearance=0.6, k=1.333)` - -Для каждой точки профиля: - -\[ -h_{tx} = h_{ground,0} + h_{AGL,tx} -\] - -\[ -h_{rx} = h_{ground,N} + h_{AGL,rx} -\] - -\[ -h_{path}(d_1) = h_{tx} + (h_{rx} - h_{tx}) \frac{d_1}{D} -\] - -\[ -h_{obstacle} = h_{surface} + \text{bulge}(d_1, d_2, k) -\] - -\[ -\text{clearance}_m = h_{path} - h_{obstacle} -\] - -\[ -r_{F1} = \text{fresnel\_radius}(\lambda, d_1, d_2, n=1) -\] - -\[ -\text{required} = \text{clearance\_fraction} \cdot r_{F1} -\] - -По умолчанию `clearance_fraction = 0.6` (60% первой зоны Френеля должно быть свободно). - -### 10.5. Критерии - -| Поле | Условие | -|------|---------| -| `geometric_los` | нет точек с `clearance_m < 0` | -| `los_clear` | нет точек с `clearance_m < required` | -| `first_fresnel_clearance_pct` | \(\min\left(\dfrac{\text{clearance}_m}{\text{required}} \times 100\%\right)\) | - -### 10.6. Fresnel slice (GeoJSON) - -**Файл:** `api/app/services/terrain.py` - -Строит 3D-полигон зоны Френеля вдоль пути: - -- Верх/низ: \(h_{path} \pm \text{required}\) -- Буфер радиусом \(r_{F1} \times \text{fresnel\_fraction}\) (мин. 0.1 м) в UTM - -**API:** - -- `POST /api/v1/terrain/los` -- `POST /api/v1/terrain/fresnel-slice` -- `POST /api/v1/link/budget` (fresnel_clear, worst_obstruction, …) - ---- - -## 11. ITU-R P.833 (растительность) - -**Файл:** `api/app/core/vegetation.py` -**Функция:** `p833_attenuation(depth_m, freq_hz, forest_type, ...)` - -Упрощённая экспоненциальная модель: - -\[ -A_{veg} = A_{max} \left(1 - e^{-(d \cdot \gamma) / A_{max}}\right) \quad [\text{дБ}] -\] - -где: - -- \(d\) — глубина прохождения через растительность, м -- \(\gamma\) — коэффициент затухания, дБ/м -- \(A_{max}\) — предельное затухание, дБ - -> Параметр `freq_hz` проверяется на положительность, но **в формулу не входит**. - -### 11.1. Коэффициенты по классам WorldCover - -| Класс | \(\gamma\), дБ/м | \(A_{max}\), дБ | -|-------|-----------------|----------------| -| tree_cover | 0.20 | 30 | -| mangroves | 0.22 | 32 | -| shrubland | 0.10 | 12 | -| grassland | 0.05 | 6 | -| cropland | 0.04 | 5 | -| herbaceous_wetland | 0.08 | 10 | -| unknown | 0.15 | 25 | - -Дефолты из `.env` / `config.py`: \(\gamma = 0.15\), \(A_{max} = 25\). - -### 11.2. Глубина растительности - -**Файл:** `api/app/core/landcover.py` - -\[ -d_{veg} = \sum \Delta s_i \quad \text{для сегментов с классом `tree_cover` или `mangroves`} -\] - -Доминирующий класс для P.833 — класс с наибольшей суммарной длиной сегментов вдоль пути. - -**API:** - -- `POST /api/v1/terrain/los` → `vegetation_loss_db` -- `POST /api/v1/link/budget` → `vegetation_db` -- `POST /api/v1/coverage` (`include_vegetation=true`) -- `POST /api/v1/landcover/path` (сегменты без P.833) - ---- - -## 12. Диаграммы направленности антенн - -**Файл:** `api/app/core/antenna.py` - -### 12.1. Omni - -\[ -G(\varphi, \theta) = G_{dBi} -\] - -### 12.2. Sector (гауссовы лепестки) - -\[ -\sigma = \frac{BW}{2.355} -\] - -\[ -\text{normalized} = e^{-0.5 \cdot (\Delta / \sigma)^2} -\] - -\[ -\text{drop} = 10 \cdot (\text{normalized} - 1) \quad [\text{дБ}] -\] - -\[ -A = \max(A_{floor},\ \text{drop}_h + \text{drop}_v) -\] - -Если \(|\Delta az| > 90°\): - -\[ -A = \min(A,\ -A_{F/B}) -\] - -\[ -G = G_{dBi} + A -\] - -| Параметр | По умолчанию | -|----------|--------------| -| `front_to_back_db` | 25 дБ | -| `sidelobe_floor_db` | −30 дБ | - -### 12.3. File (CSV) - -**Файл:** `api/app/core/antenna_pattern_file.py` - -Ближайший сэмпл из файла (az, el): - -\[ -G = G_{dBi} + G_{relative} -\] - -**API:** - -- `POST /api/v1/antenna/pattern` -- `POST /api/v1/antenna/beam` -- `POST /api/v1/coverage` (EIRP) - ---- - -## 13. Покрытие (coverage) - -**Файл:** `api/app/core/coverage.py` - -### 13.1. Мощность на приёме в точке - -\[ -P_{rx} = \text{EIRP} - L_{path} - L_{veg} + G_{rx} \quad [\text{дБм}] -\] - -\[ -\text{EIRP} = P_{tx} + G_{ant}(\text{azimuth}, 0°) -\] - -\(L_{path}\) — FSPL, ITM или P.1812 в зависимости от `model`. - -### 13.2. Радиальный контур - -Для каждого азимута \(\varphi = 0, \Delta\varphi, 2\Delta\varphi, \ldots, 360°\): - -1. Увеличивать расстояние \(d\) с шагом `range_step_m` от TX. -2. Пока \(P_{rx}(d, \varphi) \ge \text{level\_dbm}\) — точка входит в контур. -3. Последняя «хорошая» точка добавляется в полигон. - -Для ITM/P.1812: DEM-профиль из 64 точек на каждом радиальном луче. - -Для растительности: `sample_path` с шагом ~250 м. - -### 13.3. Выход - -GeoJSON `Polygon` для каждого уровня из `levels_dbm`. - -**API:** `POST /api/v1/coverage` → Celery job → `GET /api/v1/jobs/{id}` - -**Поддерживаемые модели:** `fspl`, `itm`, `p1812` (P.452 в coverage не используется). - ---- - -## 14. Viewshed - -**Файл:** `api/app/core/viewshed.py` - -Алгоритм **GDAL `gdal_viewshed`**: - -1. Мозаика DEM-тайлов в UTM по bbox радиуса. -2. Запуск: `-oz` (высота наблюдателя AGL), `-tz` (высота цели AGL), `-md` (макс. дистанция). -3. Видимые пиксели = 255 → векторизация в GeoJSON. - -> Кастомных RF-формул нет — стандартный алгоритм линии визирования GDAL. -> Параметр `k_factor` из запроса в GDAL **не передаётся**. - -**API:** `POST /api/v1/viewshed` → Celery job - ---- - -## 15. Здания - -**Файл:** `api/app/services/buildings.py` - -\[ -h_{building}(point) = \max_{polygon \ni point} height_m -\] - -**Оценка высоты** (`db/sql/normalize_buildings.sql`): - -``` -height_m = - OSM tags.height (если > 0) - иначе levels × 3.0 (если levels > 0) - иначе по типу: - garage/shed → 3 м - industrial/warehouse → 8 м - church → 12 м - default → 9 м -``` - -**API:** - -- `POST /api/v1/buildings/query` (геометрия и высоты) -- косвенно: terrain, los, fresnel-slice, link budget - ---- - -## 16. Сводная таблица: модель → API - -| Стандарт / модель | Файл | Эндпоинты | -|-------------------|------|-----------| -| Haversine / WGS84 | `core/geo.py` | все | -| DEM sampling | `core/dem.py` | `/terrain/*`, link, coverage | -| FSPL | `core/propagation.py` | `/coverage`, `/link/budget` | -| ITM (P.530) | `core/itm.py` | `/coverage`, `/link/budget` | -| P.1812 (упрощ.) | `core/itm.py` | `/coverage` | -| P.452 | `core/p452.py` | `/link/budget` | -| P.526 | `core/diffraction.py` | `/terrain/los`, `/link/budget` | -| P.676 | `core/atmosphere.py` | `/link/budget` | -| P.833 | `core/vegetation.py` | `/terrain/los`, `/link/budget`, `/coverage` | -| Fresnel / earth bulge | `core/fresnel.py` | `/terrain/los`, `/terrain/fresnel-slice`, `/link/budget` | -| Sector antenna | `core/antenna.py` | `/antenna/*`, `/coverage` | -| GDAL viewshed | `core/viewshed.py` | `/viewshed` | -| Landcover | `core/landcover.py` | `/landcover/path` | - ---- - -## 17. Эталонные значения (тесты) - -Из `api/tests/test_core.py`: - -| Расчёт | Вход | Ожидаемый результат | -|--------|------|---------------------| -| FSPL | \(f = 433\) МГц, \(d = 12.45\) км | \(L_{FS} \approx 107.08\) дБ | -| F1 radius | \(f = 433\) МГц, \(d_1 = d_2 = 6225\) м | \(r_1 \approx 46.45\) м | -| Earth bulge | \(d_1 = d_2 = 6225\) м, \(k = 1.333\) | \(\approx 2.28\) м | -| Knife-edge J(v) | \(v = -0.79\) | 0 дБ | -| Knife-edge J(v) | \(v = 0\) | 6.03 дБ | -| Knife-edge J(v) | \(v = 1\) | 13.93 дБ | -| P.833 | \(d = 100\) м, \(\gamma = 0.2\), \(A_{max} = 30\) | \(\approx 14.60\) дБ | - ---- - -## 18. Ограничения реализации - -1. **P.1812** — не полная рекомендация ITU-R, а ITM + clutter по типу среды. -2. **P.452, ITM, P.676** — делегированы внешним библиотекам (`pycraf`, `itmlogic`, `itur`). -3. **P.833** — упрощённая модель без зависимости от частоты. -4. **Canopy в LOS** — инфраструктура есть, но растр кроны в профиль поверхности пока не подмешивается автоматически. -5. **Deygout** — фактически Bullington single-edge. -6. **`link_viable`** зависит только от `fade_margin_db > 0`, не от Fresnel quality. -7. **Coverage** — радиальные контуры, не полноценная 2D-решётка. -8. **Viewshed** — `k_factor` из запроса не используется в GDAL. -9. **Дифракция** — без учёта земной кривизны (в отличие от LOS). - ---- - -## Связанные документы - -- [API.md](API.md) — описание эндпоинтов и параметров запросов -- [README.md](README.md) — установка, данные, bootstrap-скрипты -- [SPEC.md](SPEC.md) — спецификация проекта diff --git a/api/app/core/__pycache__/diffraction.cpython-313.pyc b/api/app/core/__pycache__/diffraction.cpython-313.pyc index fba477b775b9ea42ffc54aadf6d43f44aec58fe1..5d54d03dba01e77cdad890bd0e381f6465427c70 100644 GIT binary patch delta 1624 zcmaJ>OKclO7@qOIz3X*CUT$2>s%@IAih%}9KuW_S9I66o!fKI)RatJcah%%gGF}$~ z2XjLls}&Ll#0^BmfmU$fL@T&(LPT6rOO+~+sNl3HDI5?g|37Qvv^>W4%s2mh^X>my ze=zoSW_mZ3N)i}nPAqKPR0;VJH~k^RfbM>EY5G{_l)@B?IK&a3S5Ik71FSpJB-s#I z(lH#FBRxl)RF3@H$1xoRIUAH2|I1Q)EYR7fb^l$WAg1+bW;JQ2FMWQ*Omuax!h-ek z*>=6^UAcak(&6w=x|>cx9JbJv*6Phlz|`h`o`GHK(-D}~e!m=Q_QkW1IsT`W(M0iHDCX|xar#n)C#@JW!ydyiJNJhxX z6+(Q?)m{?&%)#m>i?8J2A--O$x-z2Thk2clJUEdDns2~8UZlYtL?sN1kK!k;Sm+fC zZ@326OO?_KJqB^r(r_OayT#86Jx28D9>g)pT3dfoB;s z=oczbvURUkT@TneSXwA<-8C4mRvPMBuld!W>6h!xR*O9hM1JE30B@1KOt`FkH2nUX zAJ1Ms{`~`mZKLpozHKaqe<(AZf0PxPJuWMH@h&O#>IT`T#lx=X&?luM#7-=2Q8oh% zoASqPNo)#s>?lstIAK(r(ZZ(TZpZFU?sM1cyxOi^tkgX}h+TgaxO4DpVYb0_lNj0e z<}RQ3W@TGH{hfH4{kOjd;l_S{UE)$V;R*wv-#EG0Y(QFTXKVFZuxY)QeM2Q9xDSdDf)sO9lpu%7catIc-s5IYXfg2>TZHVf0eF)FJulNI3b z86;RRK|DjmIJ%>zchoWdP46W2Upu;N$PtkxnMb!|i!2Rn#yd!6UeIPf-`Gjs2plus zDM;b%65Dvrx$W`3fK97!k6-`xcjxv~wh`9|-^#d9oH~tVy!ZaVHc(23?`TgX;D6#v zZM?%pu!-Y##+HlIQ2?|>cqDPX)%iwqwO#iX*gPCzJGFj+DH159H^`w|V#g1zYQr-d|$ zMU}&`UPPlZi-z)nSa|dM0LCv=+>ddyH`}n^6rv5pxQdW7?W{{AZ;l&}G*MqAbloy? zdPZN)>zQ>D_3m?*#TFV^;=PYt1aEskxl0_MLfx{A7oi4)}Bs(u4`@V@sm3 zjwVs#dDM!|&mv^WN!dHjx0M5_3vnuq6--@C3V>3N;tE1>f&MhvQj+o&CSAR%zb6%z z6-tNe{-l(Ko|&ayrJhdkKAxz_Ye^U95K@qe(m;V(Qu20$FHxvZ;M%v})JQ8Fp>8bT z?TCYFgu+U|DUn=uNzW82Tp@dtMB!31Tnc-?#9_XZLT^}_O149wgDw@Nm~~e&E4qeIsv| zTyl)MPe2@0r(7CQsC#Sj`I)!M{A+%mfHy%)HqxCirA4BJ0|n4Dmhb{*SpZlXfLa=ZejbC6_(K86di?{X C7U!`5 diff --git a/api/app/core/diffraction.py b/api/app/core/diffraction.py index d0e314a..2a73010 100644 --- a/api/app/core/diffraction.py +++ b/api/app/core/diffraction.py @@ -2,7 +2,7 @@ from __future__ import annotations from math import log10, sqrt -from app.core.fresnel import wavelength +from app.core.fresnel import earth_bulge, wavelength from app.core.surface import SurfaceProfile @@ -24,6 +24,7 @@ def bullington_loss( tx_height_agl: float, rx_height_agl: float, freq_hz: float, + k: float = 1.333, ) -> float: """Bullington-style equivalent edge loss for a terrain profile. @@ -45,17 +46,34 @@ def bullington_loss( d1 = sample.distance_m d2 = total_distance - d1 path_height = tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) - h = sample.surface_m - path_height + h = sample.surface_m + earth_bulge(d1, d2, k) - path_height max_v = max(max_v, knife_edge_v(h, d1, d2, freq_hz)) return knife_edge_loss(max_v) +def bullington_equivalent_loss( + profile: SurfaceProfile, + tx_height_agl: float, + rx_height_agl: float, + freq_hz: float, + k: float = 1.333, +) -> float: + return bullington_loss(profile, tx_height_agl, rx_height_agl, freq_hz, k=k) + + def deygout( profile: SurfaceProfile, tx_height_agl: float, rx_height_agl: float, freq_hz: float, + k: float = 1.333, ) -> float: - """Compatibility wrapper; use Bullington equivalent loss for multi-edge profiles.""" - return bullington_loss(profile, tx_height_agl, rx_height_agl, freq_hz) + """Compatibility wrapper; this is Bullington-style, not multi-edge Deygout.""" + return bullington_equivalent_loss( + profile, + tx_height_agl, + rx_height_agl, + freq_hz, + k=k, + ) diff --git a/api/app/models/__pycache__/link.cpython-313.pyc b/api/app/models/__pycache__/link.cpython-313.pyc index 35eb881a5c2d693d75b5f920575e55e76a1a462d..2d9a3d27f0d3dddeefdae0961b190da1c367cd42 100644 GIT binary patch delta 240 zcmbOyFk6uCGcPX}0}vG3+GUBeZsgm{%*4ewnTJt!@&o2=)+{ls!D8w_=@<}89L|ye zv$&KPVz`4PEg6e=Vz`0wQXtV_=_1~A5lxxRn^}q(eQ!w?6y+BrrYDwU=I6!d=BK3Q z#OLG}7ssb0-4aEXO07svg$PS#=Vhj)#;2yFr^csbrll1nCWAGnBu!RhOJ#JN+{32E zIAii=wkn|;@+y~ERBlKsTxL;#uqW%XZ)EhCe4kxUP>j);afakq1`u7O40IU)-33J~ delta 112 zcmbO&I8T7@GcPX}0}xD(wa!|}vXO5yGvk}d+>COQA24r|n;197M`N;tsQc4SuZhmOdDiR+8Tq<}{E#dvR_1`|wt@Mh+lbM8Io z`%dmXcRuKO_kwUF2pl3KKREQ#=bJ(f2Lp4<`TT-5zf{mt*_W3WUPFQg z#BMCgB-~8iqlz7&+tA>TLd0AT?=e0YHT$>zG|48oV+lgYdOpJRnKgcp2{Us9B=vxRqG36Kt=}R_tE*jNI*kkXr5k9vnwvpW- ztRBrd@z%d^b5-tH!ZvU=+vPLZk`B!^;h!1&TlvhoMU>>bO$kyr9a;RJcFfo8RC(2< za#KUcd8aWc^f@#ySsR7}a|E8@0uiy_gHXnV(6|pFm7O$=`w?;=1!+EwRei9^#jq1T z;bgC*u``!yY)MZiQoT^-zQ#WI$l=8jeCFt6$fkKz2mI)WV!D+9_>^}*EB}+t)n`e% z+j$5J&DaYgf(8$SM)*Y-w$Ilnp-ub&^Wb)Fj>(I9VKF_QURoe+c%zWX=Bpi;%FOLa zr2$}Hq^diq*5IWq@eI&Z<8>#@2$Yn4fN5WzijaY@%{ zXTm$KZCnR0xHGO1;_6)#XcOsU@RhrZ)1RXD(*(fo>1FDOp(t?CGs)4y-Q+F42zNc- znxX^nhF9?#oV&~gN?hQ;!9H%e(f>~b#-xXWk7o?d zwcfGTQ8D{qur6&k8V8JqZi4V(-HqWO38)~Sm3eenFd=yoKRTQ>nHq@8Si(%Pft=fe zUMU%~e3S4d+b!EXL(%U`c3ZYPnPfLyuFthxBvCPw-cLUI06l4PO~~o%IXdxN4Q_!^ zAHlQmNBtN03f%MeB{aHpg+hVCB84deF|3mx1uD`TeZulQU84ZdAdlnxR$flyZrWNp z`DY>hSqfFDY9y^l&r_f^?6FUhmR`AgCRTs;h@s=D#px?9Iqg1+_T+p+7yrN SozbhmRVOP;kPh}(D9>O1K7Awr delta 1484 zcmb_bU1(fI6rQJ}(l=)|UA0g}p~IbT&YW}R zobSv%^Uk4qrjM4+uB)f8C=`mXPtToO*jT|P)SGbgN{(?HPy9%AG{yZRE03H&DE~b| zxn5WiJK%RA1iys+@SD)5_BDNNekui%!XuoL5pshKzqbWHe8YwD6HsP?^aL~YH}MeE zg>Eq|90Y)ul|ZtOFn0{waI}d#|G>rD;Iau3@RmA_f<{nP7y|xY)eZ+@Qfk9|2fRHUtGGh^h6cj^_&k=1O z{3P{Y<9*rk`yDZNyvZ%=A3(MVkICN!eN8U87n)Yv53(`jpf71fzmD_@uVY9xJ z1h07$H~?3?j^T0J$O*TdLW}??YKJsUgvNlBIts6vqA8-V1T#8VOl93d#VnxPufPhk`k1Y-?xSxik6c{KDxu=cU3caMe7_l0p&O zFel|A5lk}YFT$|zXB>j-zPPj}aUuS~_$@Cym{uw8LQFd=vrb+YTxw+YKD(~ZHcR;%cW}DP^E3?{*4u@TB(Yas&%HXTdiv+s?k&> zn%c7Z_jFO!ZuyWNzZb&@e}D@d zaX4V>Y+2{YbND)Rs_Pi>>S5dwvbayuejv{Q#bR!ODri?X;9Ynw+>Uc_I{YObhj=8D zo}s333bPa(3KIl+u$v>xJbk41qGYL!!Wyhb#&BulMkJ4iXal-7g=Z-=QZ+m&Bez6> za@ZE1B8`7;rg+p{gtINJxEoelVx8wP=F3*Hj;Qdt@Kxu!b|9CZKY6>MtiK6Av<$N} F&>v7{bLjv8 diff --git a/api/app/services/__pycache__/terrain.cpython-313.pyc b/api/app/services/__pycache__/terrain.cpython-313.pyc index 3b199a8090cb9d78f91dc3e4c6f18befe7f64fc9..b316da694c9751ae076b5430e2976e49f10f80a6 100644 GIT binary patch delta 4022 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 LinkBu freq_hz=freq_hz, k=request.k_factor, ) - diffraction_db = deygout( + knife_edge_diffraction_db = bullington_equivalent_loss( surface_profile, tx_height_agl=request.tx.height_agl, rx_height_agl=request.rx.height_agl, freq_hz=freq_hz, + k=request.k_factor, ) vegetation_db = vegetation_loss_along( points, @@ -47,55 +48,46 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu ) atmospheric_db = atmospheric_loss_between(tx, rx, freq_hz) - active_diffraction_db = diffraction_db + fspl_db = manual_link_budget( + tx, + rx, + request.tx.power_dbm, + request.tx.gain_dbi, + request.rx.gain_dbi, + request.rx.sensitivity_dbm, + request.frequency_mhz, + ).fspl_db + active_diffraction_db = knife_edge_diffraction_db active_atmospheric_db = atmospheric_db + propagation_model_loss_db = fspl_db + knife_edge_diffraction_db + propagation_excess_db = knife_edge_diffraction_db if request.model == "manual": - propagation_db = 0.0 + pass elif request.model == "itm": - propagation_db = ( - itm_loss( - tx, - rx, - tx_height_agl=request.tx.height_agl, - rx_height_agl=request.rx.height_agl, - freq_mhz=request.frequency_mhz, - elevation_profile_m=elevation_profile, - dem_path=str(settings.dem_path), - ) - - manual_link_budget( - tx, - rx, - request.tx.power_dbm, - request.tx.gain_dbi, - request.rx.gain_dbi, - request.rx.sensitivity_dbm, - request.frequency_mhz, - ).fspl_db + propagation_model_loss_db = itm_loss( + tx, + rx, + tx_height_agl=request.tx.height_agl, + rx_height_agl=request.rx.height_agl, + freq_mhz=request.frequency_mhz, + elevation_profile_m=elevation_profile, + dem_path=str(settings.dem_path), ) - active_diffraction_db = propagation_db + propagation_excess_db = propagation_model_loss_db - fspl_db + active_diffraction_db = propagation_excess_db elif request.model == "p452": - propagation_db = ( - p452_loss( - tx, - rx, - tx_height_agl=request.tx.height_agl, - rx_height_agl=request.rx.height_agl, - freq_mhz=request.frequency_mhz, - elevation_profile_m=elevation_profile, - dem_path=str(settings.dem_path), - ) - - manual_link_budget( - tx, - rx, - request.tx.power_dbm, - request.tx.gain_dbi, - request.rx.gain_dbi, - request.rx.sensitivity_dbm, - request.frequency_mhz, - ).fspl_db + propagation_model_loss_db = p452_loss( + tx, + rx, + tx_height_agl=request.tx.height_agl, + rx_height_agl=request.rx.height_agl, + freq_mhz=request.frequency_mhz, + elevation_profile_m=elevation_profile, + dem_path=str(settings.dem_path), ) - active_diffraction_db = propagation_db + propagation_excess_db = propagation_model_loss_db - fspl_db + active_diffraction_db = propagation_excess_db active_atmospheric_db = 0.0 else: raise NotImplementedError(f"{request.model} link model is not supported") @@ -131,6 +123,9 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu geometric_obstructions_count=len(geometric_obstructions), building_obstructions_count=len(building_obstructions), worst_obstruction=_convert_obstruction(los_result.worst_sample), + propagation_model_loss_db=propagation_model_loss_db, + propagation_excess_db=propagation_excess_db, + knife_edge_diffraction_db=knife_edge_diffraction_db, data_sources=DataSources( dem=bool(raster_files(settings.dem_path)), buildings=request.include_buildings and db is not None, diff --git a/api/app/services/terrain.py b/api/app/services/terrain.py index 8bc6e6a..36efa58 100644 --- a/api/app/services/terrain.py +++ b/api/app/services/terrain.py @@ -10,10 +10,10 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.core import dem -from app.core.diffraction import deygout +from app.core.diffraction import bullington_equivalent_loss from app.core.fresnel import LosSample, los_analysis from app.core.geo import GeoPoint, PathPoint, linestring_geojson, sample_path -from app.core.raster_sampling import raster_files +from app.core.raster_sampling import RasterNotConfiguredError, raster_files, sample_along from app.core.surface import SurfaceProfile, build_surface_profile from app.models.common import DataSources from app.models.terrain import ( @@ -67,6 +67,8 @@ def terrain_profile( dem_used=bool(raster_files(get_settings().dem_path)), include_buildings=request.include_buildings, db=db, + canopy_used=request.include_canopy + and bool(raster_files(get_settings().canopy_path)), ), ) @@ -103,11 +105,12 @@ def los( request.fresnel_clearance, request.k_factor, ) - diffraction_loss = deygout( + diffraction_loss = bullington_equivalent_loss( surface_profile, request.tx.height_agl, request.rx.height_agl, request.frequency_mhz * 1_000_000, + k=request.k_factor, ) vegetation_loss = vegetation_loss_along( points, @@ -247,6 +250,24 @@ def _building_heights( return None +def _canopy_heights(points: list[PathPoint], include_canopy: bool) -> list[float] | None: + if not include_canopy: + return None + try: + values = sample_along( + points, + get_settings().canopy_path, + "canopy", + require_all=False, + ) + except RasterNotConfiguredError: + return None + + values = np.nan_to_num(values, nan=0.0, posinf=0.0, neginf=0.0) + values = np.maximum(values, 0.0) + return values.tolist() + + def surface_profile_from_points( points: list[PathPoint], include_buildings: bool, @@ -259,10 +280,12 @@ def surface_profile_from_points( ground_elevations = np.zeros(len(points), dtype=float).tolist() building_heights = _building_heights(points, include_buildings, db) + canopy_heights = _canopy_heights(points, include_canopy) return build_surface_profile( points, ground_elevations=ground_elevations, building_heights=building_heights, + canopy_heights=canopy_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 47063d4800bb3e4b1698de0234464527d3cf3246..7cad457f7e4bfd7317384013cb94c2a203cdde88 100644 GIT binary patch delta 484 zcmdmel=1onM&8f7yj%=GFu~R?>&iyns4ylc?#YarTAL?_Jz$$$U}d@aT%LeMfnAZ>4_zo`FZiV`6;P6@j3a$#qlXgw?vVp zQY(^EA;OZ`d6{Xc@u?~4sqrb9X=z1?$zaVXNt55jHF9qRx~UF`i|v^=SH)jstXI3h zqPIfjBDdak7AU&JqIZE?4Z^*G#D*&X3a(Ir^Dd(B;KG+#^loq~U0~5#kaCe*>pBY* zU1HI?z^w$~UIDV}VH~&$py+}WIPW3~4=#L}Me7DPHJmPvUma Z95%W6DWy57c17DKGiCU4NH8jaWdTT%C9(hj diff --git a/api/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc b/api/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc index b4912c484feaabe10fc7bbb7d27b484ec82727ba..971c719cfece66a0df9a227e1c4a01268e583eee 100644 GIT binary patch delta 856 zcmY*WK}Zx)7@mK2W_NaGXLZ+B-Bo8+Mcri`@vy6~(Po8cftZboG%6U#uAzgBXc$(T z5kgRce?*5MyH$rM#)GGjPSGh*FeK;_L=~y&Q*jVc8=_2)!W|&HkInGIGd$jhB-Ky9bJ9%EA}zb zW%ZKUI%_{W*2GB;CqmqV?e!u2Uftz(G)#s^pwGS=766vFa%H>pA zQ4v#|qnMzxCQFt}7fsIaIx54Aup`Nmhc~36J_IQ!oOBy9PSiB=lI}D72)u%`O#ukv zO+^LYT4f!f`4)vWeE3AE!1uKr{O@YV@=Dn;;bSGl%eudmBd^tQM^OrvaU#Ab7*))R zK9}F1$tqrJ_ObP@oZ8= zv&y-liW$}CDlc@4@}(ZHC2v#@v`LzqVNAgY7ypwHEV2s`JSBy2S8XL-A({&glAV3e z+{Dc6^yHj%hE%u^-GE+9)Msk_mP`c8PeGtSU&JEejOD>?U#sA?#%a-u8})hd5~0mx z&Fx~Q;a1h7#QNmZp_kn|k@R*bz1h8$dYjl0hV6F^b$sL^@qIl{fhS;5X7y7TMTli# z9H(Nd(f}34D2$^kejQAEIlk|LNgUG@$k|I;6~Gm|-!=w|_F-G2$DJqMxEsw_1XCSR zVTzV;^)w!68s6z>k)~;uLP5n(!3gAWuOq?D5CJFT3JiB@uwwUgZn4mVLRty+Q7io{ T%cQVKVFiz;Bk&N1)9Zf$#l^+B delta 740 zcmZWnO=uHA7|plICcD|JCTU}S+UzE+iHYe}#HMS5*m!AFqOmR>$~K2!H9?zHHnbFK zjUe_=kRl&g3ko@V5M@O?mL9~L2YU*F7cU}6QS>OzG}&I9VIFVZ`{sSaFfW$=EQ-HG z(QPAR=6LSb$=BjnA5_hszD0m3v(^^|)CAAAPZgIaOwiC4h*wyP0&49aZA|F_`r+X zNeNdanXq`2GCx-WP0&35740UhqTPfaV%+Tn1oLyWc3a3Rab zH1DXOILXU!deC|Y-D|O{Av`Apa8DiqA4X#}Q6o;r)vVO-%-?9@X6y|VuoyopiiSuW z;}8W7pT$o*%aj(d6Tk1S5Sk>b`5xzn9`H{CZMMDgi92Qf9g4D%8RC4qLV+bwg)%-*Z3xsiW+{|WP2YpM`8M6L!yG$&zn?{BZw diff --git a/api/tests/test_api.py b/api/tests/test_api.py index a20cd7a..018b396 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -147,4 +147,7 @@ def test_manual_link_budget_endpoint() -> None: assert data["fspl_db"] > 0 assert "link_viable" in data assert data["fresnel_quality"] in {"clear", "partial", "blocked"} + assert "propagation_model_loss_db" in data + assert "propagation_excess_db" in data + assert "knife_edge_diffraction_db" in data assert "data_sources" in data diff --git a/api/tests/test_core.py b/api/tests/test_core.py index a97ebe8..df6c1a4 100644 --- a/api/tests/test_core.py +++ b/api/tests/test_core.py @@ -71,7 +71,7 @@ def test_bullington_loss_uses_dominant_equivalent_edge() -> None: ), ], ) - v = knife_edge_v(20, 5_000, 5_000, 433_000_000) + v = knife_edge_v(20 + earth_bulge(5_000, 5_000, 1.333), 5_000, 5_000, 433_000_000) assert isclose(bullington_loss(profile, 0, 0, 433_000_000), knife_edge_loss(v)) diff --git a/api/tests/test_vegetation_integration.py b/api/tests/test_vegetation_integration.py index aeb5a07..8115491 100644 --- a/api/tests/test_vegetation_integration.py +++ b/api/tests/test_vegetation_integration.py @@ -94,6 +94,9 @@ def test_link_budget_includes_diffraction_loss(monkeypatch) -> None: 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, @@ -156,7 +159,7 @@ def test_link_viable_depends_on_margin_not_fresnel_quality(monkeypatch) -> None: 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, "deygout", lambda *args, **kwargs: 99.0) + monkeypatch.setattr(link_service, "bullington_equivalent_loss", lambda *args, **kwargs: 99.0) monkeypatch.setattr( link_service, "surface_profile_from_points", @@ -199,6 +202,9 @@ def test_itm_link_budget_does_not_double_count_diffraction(monkeypatch) -> None: 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) @@ -206,7 +212,7 @@ def test_itm_link_budget_does_not_double_count_diffraction(monkeypatch) -> None: 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, "deygout", lambda *args, **kwargs: 88.0) + monkeypatch.setattr(link_service, "bullington_equivalent_loss", lambda *args, **kwargs: 88.0) monkeypatch.setattr( link_service, "surface_profile_from_points", @@ -249,5 +255,8 @@ def test_p452_link_budget_does_not_double_count_diffraction_or_atmosphere(monkey 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) diff --git a/api/tests/test_viewshed_and_canopy.py b/api/tests/test_viewshed_and_canopy.py index f5c6482..312df4f 100644 --- a/api/tests/test_viewshed_and_canopy.py +++ b/api/tests/test_viewshed_and_canopy.py @@ -2,13 +2,15 @@ from __future__ import annotations import sys from pathlib import Path +from types import SimpleNamespace import numpy as np import rasterio from rasterio.transform import from_origin -from app.core.geo import GeoPoint +from app.core.geo import GeoPoint, PathPoint from app.core.viewshed import _find_dem_tiles, _radius_bbox +from app.services import terrain as terrain_service sys.path.append(str(Path(__file__).resolve().parents[2] / "scripts")) import bootstrap_canopy # noqa: E402 @@ -16,6 +18,10 @@ import bootstrap_canopy # noqa: E402 def _write_dem(path: Path, west: float) -> None: data = np.ones((10, 10), dtype="float32") + _write_raster(path, west, data) + + +def _write_raster(path: Path, west: float, data: np.ndarray) -> None: transform = from_origin(west, 60.0, 0.01, 0.01) with rasterio.open( path, @@ -32,6 +38,10 @@ def _write_dem(path: Path, west: float) -> None: dataset.write(data, 1) +def _settings(dem_path: Path, canopy_path: Path) -> SimpleNamespace: + return SimpleNamespace(dem_path=dem_path, canopy_path=canopy_path) + + def test_viewshed_dem_search_uses_radius_bbox(tmp_path: Path) -> None: _write_dem(tmp_path / "west.tif", 30.0) _write_dem(tmp_path / "east.tif", 30.1) @@ -42,6 +52,79 @@ def test_viewshed_dem_search_uses_radius_bbox(tmp_path: Path) -> None: assert {tile.name for tile in tiles} == {"west.tif", "east.tif"} +def test_surface_profile_uses_canopy_heights(monkeypatch, tmp_path: Path) -> None: + dem_dir = tmp_path / "dem" + canopy_dir = tmp_path / "canopy" + dem_dir.mkdir() + canopy_dir.mkdir() + _write_raster(dem_dir / "dem.tif", 30.0, np.full((10, 10), 2.0, dtype="float32")) + _write_raster( + canopy_dir / "canopy.tif", + 30.0, + np.full((10, 10), 12.0, dtype="float32"), + ) + monkeypatch.setattr(terrain_service, "get_settings", lambda: _settings(dem_dir, canopy_dir)) + points = [ + PathPoint(lat=59.95, lon=30.05, distance_m=0), + PathPoint(lat=59.94, lon=30.06, distance_m=1_000), + ] + + profile = terrain_service.surface_profile_from_points( + points, + include_buildings=False, + include_canopy=True, + ) + + assert [sample.ground_m for sample in profile.samples] == [2.0, 2.0] + assert [sample.canopy_m for sample in profile.samples] == [12.0, 12.0] + assert [sample.surface_m for sample in profile.samples] == [14.0, 14.0] + + +def test_surface_profile_ignores_canopy_when_disabled(monkeypatch, tmp_path: Path) -> None: + dem_dir = tmp_path / "dem" + canopy_dir = tmp_path / "canopy" + dem_dir.mkdir() + canopy_dir.mkdir() + _write_raster(dem_dir / "dem.tif", 30.0, np.full((10, 10), 2.0, dtype="float32")) + _write_raster( + canopy_dir / "canopy.tif", + 30.0, + np.full((10, 10), 12.0, dtype="float32"), + ) + monkeypatch.setattr(terrain_service, "get_settings", lambda: _settings(dem_dir, canopy_dir)) + points = [PathPoint(lat=59.95, lon=30.05, distance_m=0)] + + profile = terrain_service.surface_profile_from_points( + points, + include_buildings=False, + include_canopy=False, + ) + + assert profile.samples[0].canopy_m == 0.0 + assert profile.samples[0].surface_m == 2.0 + + +def test_surface_profile_treats_missing_canopy_as_optional( + monkeypatch, + tmp_path: Path, +) -> None: + dem_dir = tmp_path / "dem" + canopy_dir = tmp_path / "canopy" + dem_dir.mkdir() + _write_raster(dem_dir / "dem.tif", 30.0, np.full((10, 10), 2.0, dtype="float32")) + monkeypatch.setattr(terrain_service, "get_settings", lambda: _settings(dem_dir, canopy_dir)) + points = [PathPoint(lat=59.95, lon=30.05, distance_m=0)] + + profile = terrain_service.surface_profile_from_points( + points, + include_buildings=False, + include_canopy=True, + ) + + assert profile.samples[0].canopy_m == 0.0 + assert profile.samples[0].surface_m == 2.0 + + def test_canopy_selects_intersecting_tiles_from_geojson_index() -> None: index = { "type": "FeatureCollection",