""" Point calculator — coordinates per-point propagation calculation. """ import math from typing import Optional from app.propagation.base import PropagationModel, PropagationInput from app.propagation.itu_r_p526 import KnifeEdgeDiffractionModel from app.core.result import PointResult class PointCalculator: """Calculates propagation for individual grid points.""" def __init__(self, model: PropagationModel, environment: str = "urban"): self.model = model self.environment = environment self.diffraction = KnifeEdgeDiffractionModel() def calculate_point( self, site_lat: float, site_lon: float, site_height: float, site_power: float, site_gain: float, site_frequency: float, point_lat: float, point_lon: float, distance: float, has_los: bool = True, terrain_clearance: Optional[float] = None, building_loss: float = 0.0, extra_loss: float = 0.0, azimuth: Optional[float] = None, beamwidth: float = 360, ) -> PointResult: if distance < 1: distance = 1 prop_input = PropagationInput( frequency_mhz=site_frequency, distance_m=distance, tx_height_m=site_height, rx_height_m=1.5, environment=self.environment, ) if self.model.is_valid_for(prop_input): output = self.model.calculate(prop_input) path_loss = output.path_loss_db else: from app.propagation.free_space import FreeSpaceModel output = FreeSpaceModel().calculate(prop_input) path_loss = output.path_loss_db antenna_loss = 0.0 if azimuth is not None and beamwidth < 360: antenna_loss = self._antenna_pattern_loss( site_lat, site_lon, point_lat, point_lon, azimuth, beamwidth, ) terrain_loss = 0.0 if terrain_clearance is not None and terrain_clearance < 0: terrain_loss = self.diffraction.calculate_clearance_loss( terrain_clearance, site_frequency, ) has_los = False rsrp = ( site_power + site_gain - path_loss - antenna_loss - terrain_loss - building_loss - extra_loss ) return PointResult( lat=point_lat, lon=point_lon, rsrp=rsrp, distance=distance, path_loss=path_loss, terrain_loss=terrain_loss, building_loss=building_loss, diffraction_loss=terrain_loss, has_los=has_los, model_used=self.model.name, ) @staticmethod def _antenna_pattern_loss( site_lat: float, site_lon: float, point_lat: float, point_lon: float, azimuth: float, beamwidth: float, ) -> float: lat1, lon1 = math.radians(site_lat), math.radians(site_lon) lat2, lon2 = math.radians(point_lat), math.radians(point_lon) dlon = lon2 - lon1 x = math.sin(dlon) * math.cos(lat2) y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) bearing = (math.degrees(math.atan2(x, y)) + 360) % 360 angle_diff = abs(bearing - azimuth) if angle_diff > 180: angle_diff = 360 - angle_diff half_bw = beamwidth / 2 if angle_diff <= half_bw: loss = 3 * (angle_diff / half_bw) ** 2 else: loss = 3 + 12 * ((angle_diff - half_bw) / half_bw) ** 2 loss = min(loss, 25) return loss