128 lines
3.7 KiB
Python
128 lines
3.7 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from math import sqrt
|
|
|
|
from app.core.surface import SurfaceProfile
|
|
|
|
C_M_PER_S = 299_792_458.0
|
|
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
|
|
obstruction_type: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LosResult:
|
|
los_clear: bool
|
|
geometric_los: bool
|
|
first_fresnel_clearance_pct: float
|
|
worst_sample: LosSample | None
|
|
obstructions: list[LosSample]
|
|
samples: list[LosSample]
|
|
|
|
|
|
def wavelength(freq_hz: float) -> float:
|
|
if freq_hz <= 0:
|
|
raise ValueError("freq_hz must be positive")
|
|
return C_M_PER_S / freq_hz
|
|
|
|
|
|
def fresnel_radius(lmbda: float, d1: float, d2: float, n: int = 1) -> float:
|
|
if lmbda <= 0:
|
|
raise ValueError("lmbda must be positive")
|
|
if d1 < 0 or d2 < 0:
|
|
raise ValueError("distances must be non-negative")
|
|
total = d1 + d2
|
|
if total == 0:
|
|
return 0.0
|
|
return sqrt(n * lmbda * d1 * d2 / total)
|
|
|
|
|
|
def earth_bulge(d1: float, d2: float, k: float) -> float:
|
|
if k <= 0:
|
|
raise ValueError("k must be positive")
|
|
return (d1 * d2) / (2 * k * EARTH_RADIUS_M)
|
|
|
|
|
|
def los_analysis(
|
|
profile: SurfaceProfile,
|
|
tx_height_agl: float,
|
|
rx_height_agl: float,
|
|
freq_hz: float,
|
|
clearance: float = 0.6,
|
|
k: float = 1.333,
|
|
) -> LosResult:
|
|
if not profile.samples:
|
|
raise ValueError("profile must contain samples")
|
|
|
|
total_distance = profile.distance_m
|
|
lmbda = wavelength(freq_hz)
|
|
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:
|
|
d1 = sample.distance_m
|
|
d2 = total_distance - d1
|
|
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)
|
|
required = clearance * f1
|
|
ratio = 100.0 if required == 0 else (clearance_m / required) * 100.0
|
|
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
|
|
):
|
|
worst_sample = los_sample
|
|
min_ratio = min(min_ratio, ratio)
|
|
if clearance_m < 0:
|
|
geometric_los = False
|
|
if clearance_m < required:
|
|
obstructions.append(los_sample)
|
|
|
|
return LosResult(
|
|
los_clear=not obstructions,
|
|
geometric_los=geometric_los,
|
|
first_fresnel_clearance_pct=100.0 if min_ratio == float("inf") else min_ratio,
|
|
worst_sample=worst_sample,
|
|
obstructions=obstructions,
|
|
samples=samples,
|
|
)
|