diff --git a/API.md b/API.md index 858638f..393c4b9 100644 --- a/API.md +++ b/API.md @@ -199,6 +199,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ geometric_obstructions_count, building_obstructions_count, worst_obstruction, + fresnel_profile: .fresnel_profile[0:3], diffraction_loss_db, vegetation_loss_db }' @@ -211,6 +212,7 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ - `first_fresnel_clearance_pct`: минимальный процент доступного просвета относительно требуемого `fresnel_clearance * F1`. - `obstructions`: все точки, где `clearance_m < required_clearance_m`. - `geometric_obstructions`: только точки, где `clearance_m < 0`. +- `fresnel_profile`: полный профиль по всем `samples` для построения графика зоны Френеля. - `fresnel_violations_count`: количество нарушений зоны Френеля. - `geometric_obstructions_count`: количество геометрических пересечений. - `building_obstructions_count`: количество геометрических препятствий типа `building`. @@ -230,6 +232,35 @@ curl -s -X POST http://localhost:5603/api/v1/terrain/los \ } ``` +Поля `fresnel_profile` для графика: + +```json +{ + "i": 12, + "lat": 59.93623, + "lon": 30.30746, + "distance_m": 312.5, + "surface_m": 18.4, + "path_height_m": 29.7, + "obstacle_height_m": 18.42, + "clearance_m": 11.28, + "fresnel_radius_m": 11.9, + "required_clearance_m": 7.14, + "fresnel_lower_60pct_m": 22.56, + "fresnel_lower_100pct_m": 17.8, + "type": "terrain" +} +``` + +Для отрисовки профиля обычно достаточно: + +- `distance_m` по X. +- `surface_m` как земля/DSM. +- `path_height_m` как прямая TX-RX. +- `fresnel_lower_60pct_m` как нижняя граница требуемой свободной зоны. +- `fresnel_lower_100pct_m` как нижняя граница полной первой зоны Френеля. +- точки, где `clearance_m < required_clearance_m`, подсветить как нарушения. + ## Buildings ### `POST /api/v1/buildings/query` diff --git a/api/app/core/__pycache__/fresnel.cpython-313.pyc b/api/app/core/__pycache__/fresnel.cpython-313.pyc index 6726ff3..5f18a83 100644 Binary files a/api/app/core/__pycache__/fresnel.cpython-313.pyc and b/api/app/core/__pycache__/fresnel.cpython-313.pyc differ diff --git a/api/app/core/fresnel.py b/api/app/core/fresnel.py index 8e98b39..f883bb1 100644 --- a/api/app/core/fresnel.py +++ b/api/app/core/fresnel.py @@ -11,7 +11,12 @@ EARTH_RADIUS_M = 6_371_000.0 @dataclass(frozen=True) class LosSample: + lat: float + lon: float distance_m: float + surface_m: float + path_height_m: float + obstacle_height_m: float clearance_m: float fresnel_radius_m: float required_clearance_m: float @@ -25,6 +30,7 @@ class LosResult: first_fresnel_clearance_pct: float worst_sample: LosSample | None obstructions: list[LosSample] + samples: list[LosSample] def wavelength(freq_hz: float) -> float: @@ -66,14 +72,19 @@ def los_analysis( tx_elevation = profile.samples[0].ground_m + tx_height_agl rx_elevation = profile.samples[-1].ground_m + rx_height_agl obstructions: list[LosSample] = [] + samples: list[LosSample] = [] worst_sample: LosSample | None = None min_ratio = float("inf") geometric_los = True - for sample in profile.samples[1:-1]: + for sample in profile.samples: d1 = sample.distance_m d2 = total_distance - d1 - path_height = tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) + path_height = ( + tx_elevation + if total_distance == 0 + else tx_elevation + (rx_elevation - tx_elevation) * (d1 / total_distance) + ) obstacle_height = sample.surface_m + earth_bulge(d1, d2, k) clearance_m = path_height - obstacle_height f1 = fresnel_radius(lmbda, d1, d2) @@ -82,12 +93,20 @@ def los_analysis( obstruction_type = sample.dominant_obstruction los_sample = LosSample( + lat=sample.lat, + lon=sample.lon, distance_m=d1, + surface_m=sample.surface_m, + path_height_m=path_height, + obstacle_height_m=obstacle_height, clearance_m=clearance_m, fresnel_radius_m=f1, required_clearance_m=required, obstruction_type=obstruction_type, ) + samples.append(los_sample) + if d1 == 0 or d1 == total_distance: + continue if worst_sample is None or clearance_m - required < ( worst_sample.clearance_m - worst_sample.required_clearance_m ): @@ -104,4 +123,5 @@ def los_analysis( first_fresnel_clearance_pct=100.0 if min_ratio == float("inf") else min_ratio, worst_sample=worst_sample, obstructions=obstructions, + samples=samples, ) diff --git a/api/app/models/__pycache__/terrain.cpython-313.pyc b/api/app/models/__pycache__/terrain.cpython-313.pyc index dc74dd6..4cb4170 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 75c9a69..b7777a1 100644 --- a/api/app/models/terrain.py +++ b/api/app/models/terrain.py @@ -59,6 +59,22 @@ class Obstruction(BaseModel): type: Literal["terrain", "building", "canopy"] +class FresnelProfileSample(BaseModel): + i: int + lat: float + lon: float + distance_m: float + surface_m: float + path_height_m: float + obstacle_height_m: float + clearance_m: float + fresnel_radius_m: float + required_clearance_m: float + fresnel_lower_60pct_m: float + fresnel_lower_100pct_m: float + type: Literal["terrain", "building", "canopy"] + + class LosResponse(BaseModel): los_clear: bool geometric_los: bool @@ -66,6 +82,7 @@ class LosResponse(BaseModel): worst_obstruction: Obstruction | None obstructions: list[Obstruction] geometric_obstructions: list[Obstruction] + fresnel_profile: list[FresnelProfileSample] fresnel_violations_count: int geometric_obstructions_count: int building_obstructions_count: int diff --git a/api/app/services/__pycache__/terrain.cpython-313.pyc b/api/app/services/__pycache__/terrain.cpython-313.pyc index b71de57..f74b306 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/terrain.py b/api/app/services/terrain.py index 44bfa88..712f68b 100644 --- a/api/app/services/terrain.py +++ b/api/app/services/terrain.py @@ -14,6 +14,7 @@ from app.core.surface import SurfaceProfile, build_surface_profile from app.models.common import DataSources from app.models.terrain import ( ElevationResponse, + FresnelProfileSample, LosRequest, LosResponse, Obstruction, @@ -117,6 +118,23 @@ def los( type=sample.obstruction_type, ) + def convert_fresnel_sample(i: int, sample: LosSample) -> FresnelProfileSample: + return FresnelProfileSample( + i=i, + lat=sample.lat, + lon=sample.lon, + distance_m=sample.distance_m, + surface_m=sample.surface_m, + path_height_m=sample.path_height_m, + obstacle_height_m=sample.obstacle_height_m, + clearance_m=sample.clearance_m, + fresnel_radius_m=sample.fresnel_radius_m, + required_clearance_m=sample.required_clearance_m, + fresnel_lower_60pct_m=sample.path_height_m - sample.required_clearance_m, + fresnel_lower_100pct_m=sample.path_height_m - sample.fresnel_radius_m, + type=sample.obstruction_type, + ) + geometric_obstructions = [ sample for sample in result.obstructions if sample.clearance_m < 0 ] @@ -133,6 +151,9 @@ def los( worst_obstruction=convert(result.worst_sample) if result.worst_sample else None, obstructions=[convert(sample) for sample in result.obstructions], geometric_obstructions=[convert(sample) for sample in geometric_obstructions], + fresnel_profile=[ + convert_fresnel_sample(i, sample) for i, sample in enumerate(result.samples) + ], fresnel_violations_count=len(result.obstructions), geometric_obstructions_count=len(geometric_obstructions), building_obstructions_count=len(building_obstructions), 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 705de7e..7920574 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 fffd920..9b4e649 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -79,6 +79,16 @@ def test_los_endpoint_returns_obstruction_summary_fields() -> None: data = response.json() assert "obstructions" in data assert "geometric_obstructions" in data + assert len(data["fresnel_profile"]) == 16 + assert { + "distance_m", + "surface_m", + "path_height_m", + "fresnel_radius_m", + "required_clearance_m", + "fresnel_lower_60pct_m", + "fresnel_lower_100pct_m", + }.issubset(data["fresnel_profile"][0]) assert data["fresnel_violations_count"] == len(data["obstructions"]) assert data["geometric_obstructions_count"] == len(data["geometric_obstructions"]) assert "vegetation_loss_db" in data