from __future__ import annotations from dataclasses import dataclass from math import exp @dataclass(frozen=True) class AntennaPattern: pattern: str = "omni" azimuth_deg: float = 0.0 tilt_deg: float = 0.0 gain_dbi: float = 0.0 beamwidth_h: float | None = None beamwidth_v: float | None = None front_to_back_db: float = 25.0 sidelobe_floor_db: float = -30.0 pattern_file: str | None = None def _angular_delta(angle: float, center: float) -> float: return (angle - center + 180.0) % 360.0 - 180.0 def _gaussian_drop(delta: float, beamwidth: float) -> float: if beamwidth <= 0: raise ValueError("beamwidth must be positive") sigma = beamwidth / 2.355 normalized = exp(-0.5 * (delta / sigma) ** 2) if normalized <= 0: return float("-inf") return 10.0 * (normalized - 1.0) def gain(pattern: AntennaPattern, az_deg: float, el_deg: float) -> float: if pattern.pattern == "omni": return pattern.gain_dbi if pattern.pattern == "file": from app.core.antenna_pattern_file import gain_from_file return gain_from_file(pattern, az_deg, el_deg) if pattern.pattern != "sector": raise ValueError(f"Unknown antenna pattern: {pattern.pattern}") if pattern.beamwidth_h is None or pattern.beamwidth_v is None: raise ValueError("sector pattern requires horizontal and vertical beamwidths") az_delta = _angular_delta(az_deg, pattern.azimuth_deg) el_delta = el_deg - pattern.tilt_deg h_drop = _gaussian_drop(az_delta, pattern.beamwidth_h) v_drop = _gaussian_drop(el_delta, pattern.beamwidth_v) attenuation = max(pattern.sidelobe_floor_db, h_drop + v_drop) if abs(az_delta) > 90: attenuation = min(attenuation, -pattern.front_to_back_db) return pattern.gain_dbi + attenuation