Major refactoring of RFCP backend: - Modular propagation models (8 models) - SharedMemoryManager for terrain data - ProcessPoolExecutor parallel processing - WebSocket progress streaming - Building filtering pipeline (351k → 15k) - 82 unit tests Performance: Standard preset 38s → 5s (7.6x speedup) Known issue: Detailed preset timeout (fix in 3.1.0)
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""
|
|
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
|