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: distance_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] 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] = [] worst_sample: LosSample | None = None min_ratio = float("inf") geometric_los = True for sample in profile.samples[1:-1]: d1 = sample.distance_m d2 = total_distance - d1 path_height = 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( distance_m=d1, clearance_m=clearance_m, fresnel_radius_m=f1, required_clearance_m=required, obstruction_type=obstruction_type, ) 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, )