diff --git a/.gitignore b/.gitignore index 246818a..d8deefe 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ data/**/*.tiff data/**/*.pbf data/**/*.osm data/**/*.osm.pbf +scripts/data/**/*.tif \ No newline at end of file diff --git a/FORMULAS.md b/FORMULAS.md index 4465531..606a7c2 100644 --- a/FORMULAS.md +++ b/FORMULAS.md @@ -1,5 +1,646 @@ # Формулы и расчёты RadioApi +Документ описывает расчётные модели, формулы и ограничения, реализованные в **RadioApi**. + +**Единицы по умолчанию** + +| Величина | Единица | +|----------|---------| +| Координаты | градусы WGS84 (`lat`, `lon`) | +| Расстояние | м или км | +| Высота | м AGL или м AMSL | +| Частота | МГц в API, Гц/ГГц во внутренних моделях | +| Мощность | дБм | +| Усиление | дБи | +| Потери | дБ | + +Префикс API: `/api/v1`. + +## 1. Геометрия и профиль пути + +Файл: `api/app/core/geo.py` + +### 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}) +$$ + +где $R_e = 6\,371\,000$ м. + +Функция: `haversine(a, b)` → расстояние в метрах. + +### Геодезическая линия WGS84 + +Функция: `sample_path(start, end, n)`. + +Точки строятся через `pyproj.Geod` на эллипсоиде WGS84. Для каждой точки: + +$$ +d_i = +\begin{cases} +i \cdot D/(n-1), & i < n-1 \\ +D, & i = n-1 +\end{cases} +$$ + +где $D$ — полная геодезическая длина. + +Используется в terrain, LOS, link budget, coverage, landcover. + +### DEM + +Файл: `api/app/core/dem.py` + +Высоты выбираются из GeoTIFF/COG: + +- `elevation_at(lat, lon)` — одна точка; +- `elevations_along(points)` — массив точек. + +Используется в `/terrain/elevation`, `/terrain/profile`, `/terrain/los`, `/terrain/fresnel-slice`, `/link/budget`, `/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`. + +Примечание: в текущем `surface_profile_from_points()` растр кроны ещё не подмешивается автоматически в LOS-профиль. + +## 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$. + +Используется в `/coverage` (`model=fspl`) и как базовая составляющая `/link/budget`. + +## 4. Бюджет радиолинии + +Файлы: `api/app/core/propagation.py`, `api/app/services/link.py`. + +Базовая формула: + +$$ +P_{rx} = P_{tx} + G_{tx} + G_{rx} - L_{total} +$$ + +$$ +fade\_margin = P_{rx} - P_{sens} +$$ + +$$ +link\_viable = (fade\_margin > 0) +$$ + +### Активные слагаемые по модели + +В ответе API поле `diffraction_db` для `itm` и `p452` сейчас фактически содержит **model excess** над FSPL, потому что отдельного поля `propagation_excess_db` пока нет. + +| `model` | Суммарные потери `total_loss_db` | Что не добавляется отдельно | +|---------|----------------------------------|------------------------------| +| `manual` | $L_{FS} + L_{diff} + L_{veg} + L_{atm}$ | нет | +| `itm` | $L_{ITM} + L_{veg} + L_{atm}$ | отдельный Bullington/P.526 | +| `p452` | $L_{P.452} + L_{veg}$ | отдельный Bullington/P.526 и отдельный P.676 | + +Иначе говоря: + +$$ +L_{prop,excess} = L_{model} - L_{FS} +$$ + +Для `itm`: + +$$ +L_{total} = L_{FS} + L_{prop,excess} + L_{veg} + L_{atm} + = L_{ITM} + L_{veg} + L_{atm} +$$ + +Для `p452`: + +$$ +L_{total} = L_{FS} + L_{prop,excess} + L_{veg} + = L_{P.452} + L_{veg} +$$ + +Для `manual`: + +$$ +L_{total} = L_{FS} + L_{diff} + L_{veg} + L_{atm} +$$ + +API: `POST /api/v1/link/budget`. + +## 5. ITM / Longley-Rice + +Файл: `api/app/core/itm.py` + +Библиотека: [`edwardoughton/itmlogic`](https://github.com/edwardoughton/itmlogic). + +Это Longley-Rice / ITM. В проекте он **не** является реализацией ITU-R P.530. + +### Параметры + +| Параметр | Значение | +|----------|----------| +| `climate` | 5 по умолчанию | +| `ipol` | 0 | +| `eps` | 15 | +| `sgm` | 0.005 См/м | +| `ens` | 314 | +| `gma` | $157 \times 10^{-9}$ | +| `wn` | $f_{MHz} / 47.7$ | + +Коды климата: + +| Среда | `climate` | +|-------|-----------| +| `urban` | 5 | +| `suburban` | 6 | +| `rural` | 7 | + +### Профиль PFL + +Профиль содержит DEM-высоты вдоль пути. Высоты концов дополняются AGL: + +$$ +h_0 = h_{DEM,tx} + h_{AGL,tx} +$$ + +$$ +h_N = h_{DEM,rx} + h_{AGL,rx} +$$ + +Шаг профиля: + +$$ +\Delta s = \frac{D_{km} \cdot 1000}{N - 1} +$$ + +### Потери + +В коде итоговая величина: + +$$ +L_{ITM} = 8.685890 \cdot \ln(2 w_n d) + avar(0, 0, 0) +$$ + +Здесь $8.685890 \approx 20 / \ln(10)$, а `avar` — статистическая поправка `itmlogic` для медианного случая. + +API: + +- `POST /api/v1/coverage` (`model=itm`); +- `POST /api/v1/link/budget` (`model=itm`). + +## 6. P.1812-подобная упрощённая модель + +Файл: `api/app/core/itm.py` + +Функция: `p1812_path_loss(...)`. + +Это **не полная** ITU-R P.1812. Реализация: + +$$ +L_{p1812\_like} = L_{ITM}(climate) + L_{clutter} +$$ + +| Среда | `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` + +Библиотека: [`bwinkel/pycraf`](https://github.com/bwinkel/pycraf). + +Функция: `p452_path_loss(...)`. + +Параметры `PathProp`: + +| Параметр | Значение | +|----------|----------| +| Частота | `freq_mhz * u.MHz` | +| Температура | 293 K | +| Давление | 1013 гПа | +| Время | 50% | +| Разрешение профиля | $\max(100\text{ м}, 10 \cdot d_{km})$ | +| Clutter zone | URBAN / SUBURBAN / SPARSE | + +Итог: + +$$ +L_{P.452} = loss\_complete(PathProp)[0] +$$ + +Полные уравнения P.452 находятся внутри `pycraf`. Для `/link/budget` отдельные Bullington/P.526 и P.676 поверх P.452 не добавляются, чтобы не учитывать те же эффекты дважды. + +API: `POST /api/v1/link/budget` (`model=p452`). + +## 8. ITU-R P.676 + +Файл: `api/app/core/atmosphere.py` + +Библиотека: [`inigodelportillo/ITU-Rpy`](https://github.com/inigodelportillo/ITU-Rpy). + +Функция: `p676_gas_attenuation(freq_hz, distance_km)`. + +$$ +f_{GHz} = \frac{f_{Hz}}{10^9} +$$ + +Вызов: + +```python +itu676.gaseous_attenuation_terrestrial_path( + distance_km, + freq_ghz, + 0, + 7.5, + 1013, + 288, + "exact", +) +``` + +Параметры: + +| Параметр | Значение | +|----------|----------| +| Высота | 0 км | +| Плотность водяного пара | 7.5 г/м³ | +| Давление | 1013 гПа | +| Температура | 288 K | +| Метод | `exact` | + +API: `POST /api/v1/link/budget` (`model=manual`, `model=itm`). Для `model=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) = +\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} +$$ + +### Bullington-style equivalent edge + +Для внутренних точек профиля: + +$$ +h_{path}(d_1) = h_{tx} + (h_{rx}-h_{tx})\frac{d_1}{D} +$$ + +$$ +h_i = h_{surface,i} - h_{path}(d_1) +$$ + +$$ +L_{diff} = J(\max_i v_i) +$$ + +В дифракции земная кривизна сейчас не учитывается, в отличие от LOS-модуля. Это физическая несогласованность на длинных пролётах: LOS может стать NLOS из-за `earth_bulge`, а `L_diff` останется заниженным. + +Функция `deygout(...)` сейчас является compatibility-wrapper над `bullington_loss(...)`; многоэкранный Deygout не реализован. + +API: + +- `POST /api/v1/terrain/los`; +- `POST /api/v1/link/budget` только для `model=manual`. + +## 10. Френель и LOS + +Файл: `api/app/core/fresnel.py` + +Длина волны: + +$$ +\lambda = \frac{c}{f} +$$ + +Радиус n-й зоны Френеля: + +$$ +r_n = \sqrt{\frac{n \lambda d_1 d_2}{d_1+d_2}} +$$ + +Выпуклость Земли: + +$$ +bulge = \frac{d_1 d_2}{2 k R_e} +$$ + +По умолчанию $k = 1.333$. + +### LOS-анализ + +$$ +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} + bulge(d_1,d_2,k) +$$ + +$$ +clearance = h_{path} - h_{obstacle} +$$ + +$$ +required = clearance\_fraction \cdot r_1 +$$ + +По умолчанию `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`. + +## 11. P.833-подобная растительность + +Файл: `api/app/core/vegetation.py` + +Функция: `p833_attenuation(depth_m, freq_hz, forest_type, ...)`. + +Это упрощённая экспоненциальная модель, вдохновлённая P.833, но **не полная ITU-R P.833**, потому что частота в формуле не используется: + +$$ +A_{veg} = A_{max}\left(1 - e^{-(d\gamma)/A_{max}}\right) +$$ + +| Класс 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 | + +Дефолты из `config.py`: $\gamma = 0.15$, $A_{max} = 25$. + +### Глубина растительности + +Файл: `api/app/core/landcover.py` + +$$ +d_{veg} = \sum \Delta s_i +$$ + +Суммируются только сегменты `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` для сегментации. + +## 12. Диаграммы направленности антенн + +Файл: `api/app/core/antenna.py` + +### Omni + +$$ +G(\varphi,\theta) = G_{dBi} +$$ + +### Sector + +Текущая реализация: + +$$ +\sigma = \frac{BW}{2.355} +$$ + +$$ +normalized = e^{-0.5(\Delta/\sigma)^2} +$$ + +$$ +drop = 10(normalized - 1) +$$ + +$$ +A = \max(A_{floor}, drop_h + drop_v) +$$ + +Если $|\Delta az| > 90^\circ$: + +$$ +A = \min(A, -A_{F/B}) +$$ + +$$ +G = G_{dBi} + A +$$ + +Важно: при такой формуле `beamwidth_h`/`beamwidth_v` **не являются HPBW** в обычном смысле. На краю полуширины спад получается около -5 дБ, а не -3 дБ. + +### File + +Файл: `api/app/core/antenna_pattern_file.py` + +Берётся ближайший CSV-сэмпл по az/el: + +$$ +G = G_{dBi} + G_{relative} +$$ + +API: + +- `POST /api/v1/antenna/pattern`; +- `POST /api/v1/antenna/beam`; +- `POST /api/v1/coverage`. + +## 13. Coverage + +Файл: `api/app/core/coverage.py` + +Мощность в точке: + +$$ +EIRP = P_{tx} + G_{ant}(azimuth, 0^\circ) +$$ + +$$ +P_{rx} = EIRP - L_{path} - L_{veg} + G_{rx} +$$ + +`L_path` выбирается по `model`: FSPL, ITM или p1812-like. + +Алгоритм контура: + +1. Для каждого азимута от 0 до 360 градусов. +2. Идти от TX с шагом `range_step_m`. +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}`. + +## 14. Viewshed + +Файл: `api/app/core/viewshed.py` + +Используется GDAL `gdal_viewshed`: + +1. DEM-тайлы мозаичатся в UTM. +2. Передаются `-oz` (observer AGL), `-tz` (target AGL), `-md` (max distance). +3. Видимые пиксели (`255`) векторизуются в GeoJSON. + +Кастомных RF-формул нет. `k_factor` из `ViewshedRequest` в `gdal_viewshed` сейчас не передаётся. + +API: `POST /api/v1/viewshed`. + +## 15. Здания + +Файл: `api/app/services/buildings.py` + +Для точки профиля: + +$$ +h_{building}(point) = \max_{polygon \ni point} height_m +$$ + +Оценка высоты в `db/sql/normalize_buildings.sql`: + +```text +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/link. + +## 16. Сводная таблица + +| Модель | Файл | API | +|--------|------|-----| +| Haversine / WGS84 | `core/geo.py` | почти все расчёты | +| DEM sampling | `core/dem.py` | terrain, link, coverage | +| FSPL | `core/propagation.py` | coverage, link | +| ITM / Longley-Rice | `core/itm.py` | coverage, link | +| p1812-like | `core/itm.py` | coverage | +| P.452 | `core/p452.py` | link | +| P.526 / Bullington-style | `core/diffraction.py` | terrain/los, manual link | +| P.676 | `core/atmosphere.py` | manual/itm link | +| p833-like vegetation | `core/vegetation.py` | terrain/los, link, coverage | +| Fresnel / earth bulge | `core/fresnel.py` | terrain/los, fresnel-slice, link | +| Antenna | `core/antenna.py` | antenna, coverage | +| GDAL viewshed | `core/viewshed.py` | viewshed | + +## 17. Эталонные значения из тестов + +Файл: `api/tests/test_core.py` + +| Расчёт | Вход | Ожидаемый результат | +|--------|------|---------------------| +| FSPL | $f=433$ МГц, $d=12.45$ км | $107.08$ дБ | +| F1 | $f=433$ МГц, $d_1=d_2=6225$ м | $46.45$ м | +| Earth bulge | $d_1=d_2=6225$ м, $k=1.333$ | $2.28$ м | +| Knife-edge | $v=-0.79$ | $0$ дБ | +| Knife-edge | $v=0$ | $6.03$ дБ | +| Knife-edge | $v=1$ | $13.93$ дБ | +| Vegetation | $d=100$ м, $\gamma=0.2$, $A_{max}=30$ | $14.60$ дБ | + +## 18. Ограничения реализации + +1. ITM — Longley-Rice через `itmlogic`, не ITU-R P.530. +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`. + +## Связанные документы + +- [API.md](API.md) +- [README.md](README.md) +- [SPEC.md](SPEC.md) +# Формулы и расчёты RadioApi + Документ описывает все математические модели, формулы и алгоритмы, реализованные в проекте **RadioApi** (RF Propagation API). **Единицы по умолчанию:** diff --git a/api/app/routers/__pycache__/jobs.cpython-313.pyc b/api/app/routers/__pycache__/jobs.cpython-313.pyc index b5b04b2..134ea2f 100644 Binary files a/api/app/routers/__pycache__/jobs.cpython-313.pyc and b/api/app/routers/__pycache__/jobs.cpython-313.pyc differ diff --git a/api/app/services/__pycache__/link.cpython-313.pyc b/api/app/services/__pycache__/link.cpython-313.pyc index cbb64b4..8e6eacf 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 852b508..5e01845 100644 --- a/api/app/services/link.py +++ b/api/app/services/link.py @@ -47,6 +47,9 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu ) atmospheric_db = atmospheric_loss_between(tx, rx, freq_hz) + active_diffraction_db = diffraction_db + active_atmospheric_db = atmospheric_db + if request.model == "manual": propagation_db = 0.0 elif request.model == "itm": @@ -70,6 +73,7 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu request.frequency_mhz, ).fspl_db ) + active_diffraction_db = propagation_db elif request.model == "p452": propagation_db = ( p452_loss( @@ -91,6 +95,8 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu request.frequency_mhz, ).fspl_db ) + active_diffraction_db = propagation_db + active_atmospheric_db = 0.0 else: raise NotImplementedError(f"{request.model} link model is not supported") @@ -102,9 +108,9 @@ def link_budget(request: LinkBudgetRequest, db: Session | None = None) -> LinkBu rx_gain_dbi=request.rx.gain_dbi, sensitivity_dbm=request.rx.sensitivity_dbm, frequency_mhz=request.frequency_mhz, - diffraction_db=diffraction_db + propagation_db, + diffraction_db=active_diffraction_db, vegetation_db=vegetation_db, - atmospheric_db=atmospheric_db, + atmospheric_db=active_atmospheric_db, fresnel_clear=los_result.los_clear, ) geometric_obstructions = [ diff --git a/api/tests/test_vegetation_integration.py b/api/tests/test_vegetation_integration.py index 90cf74c..aeb5a07 100644 --- a/api/tests/test_vegetation_integration.py +++ b/api/tests/test_vegetation_integration.py @@ -151,3 +151,103 @@ def test_link_viable_depends_on_margin_not_fresnel_quality(monkeypatch) -> None: assert result.fresnel_quality == "blocked" assert result.fresnel_clear is False assert result.link_viable is True + + +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, + "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="itm", + include_vegetation=True, + ) + base = link_service.manual_link_budget( + request.tx, + request.rx, + request.tx.power_dbm, + request.tx.gain_dbi, + request.rx.gain_dbi, + request.rx.sensitivity_dbm, + request.frequency_mhz, + ).fspl_db + monkeypatch.setattr(link_service, "itm_loss", lambda *args, **kwargs: base + 12.0) + + result = link_service.link_budget(request) + + assert result.diffraction_db == 12.0 + assert result.atmospheric_db == 2.0 + assert isclose(result.total_loss_db, result.fspl_db + 12.0 + 3.0 + 2.0) + + +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, + "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="p452", + include_vegetation=True, + ) + base = link_service.manual_link_budget( + request.tx, + request.rx, + request.tx.power_dbm, + request.tx.gain_dbi, + request.rx.gain_dbi, + request.rx.sensitivity_dbm, + request.frequency_mhz, + ).fspl_db + monkeypatch.setattr(link_service, "p452_loss", lambda *args, **kwargs: base + 14.0) + + result = link_service.link_budget(request) + + assert result.diffraction_db == 14.0 + assert result.atmospheric_db == 0.0 + assert isclose(result.total_loss_db, result.fspl_db + 14.0 + 3.0)