@mytec: feat: Phase 3.0 Architecture Refactor

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)
This commit is contained in:
2026-02-01 23:12:26 +02:00
parent 1dde56705a
commit defa3ad440
71 changed files with 7134 additions and 256 deletions

View File

@@ -0,0 +1,6 @@
"""
Core business logic for RFCP.
Existing modules: config.py, database.py
New modules: engine.py, grid.py, calculator.py, result.py
"""

View File

@@ -0,0 +1,103 @@
"""
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

240
backend/app/core/engine.py Normal file
View File

@@ -0,0 +1,240 @@
"""
CoverageEngine — main orchestrator for coverage calculations.
Coordinates data loading, model selection, parallel computation,
and result aggregation. Does NOT implement propagation physics
(delegated to models) or handle HTTP (delegated to API layer).
"""
import time
import asyncio
from enum import Enum
from dataclasses import dataclass
from typing import List, Optional, Callable, Awaitable
from app.propagation.base import PropagationModel, PropagationInput
from app.propagation.free_space import FreeSpaceModel
from app.propagation.okumura_hata import OkumuraHataModel
from app.propagation.cost231_hata import Cost231HataModel
from app.propagation.cost231_wi import Cost231WIModel
from app.propagation.itu_r_p1546 import ITUR_P1546Model
from app.propagation.longley_rice import LongleyRiceModel
from app.propagation.itu_r_p526 import KnifeEdgeDiffractionModel
from app.core.result import CoverageResult, PointResult, compute_stats
class BandType(Enum):
LTE = "lte" # 700-2600 MHz
UHF = "uhf" # 400-520 MHz
VHF = "vhf" # 136-174 MHz
CUSTOM = "custom" # User-defined
class PresetType(Enum):
FAST = "fast"
STANDARD = "standard"
DETAILED = "detailed"
FULL = "full"
@dataclass
class Site:
id: str
lat: float
lon: float
height: float # meters AGL
power: float # dBm
gain: float # dBi
frequency: float # MHz
band_type: BandType = BandType.LTE
azimuth: Optional[float] = None
beamwidth: float = 65
tilt: float = 0
environment: str = "urban"
@dataclass
class CoverageSettings:
radius: float = 10000
resolution: float = 200
min_signal: float = -120
preset: PresetType = PresetType.STANDARD
band_type: BandType = BandType.LTE
environment: str = "urban"
terrain_enabled: bool = True
buildings_enabled: bool = True
diffraction_enabled: bool = True
reflection_enabled: bool = False
# Legacy toggles (backward compat)
use_terrain: bool = True
use_buildings: bool = True
use_materials: bool = True
use_dominant_path: bool = False
use_street_canyon: bool = False
use_reflections: bool = False
use_water_reflection: bool = False
use_vegetation: bool = False
season: str = "summer"
rain_rate: float = 0.0
indoor_loss_type: str = "none"
use_atmospheric: bool = False
temperature_c: float = 15.0
humidity_percent: float = 50.0
ProgressCallback = Callable[[str, float, Optional[float]], Awaitable[None]]
class CoverageEngine:
"""
Main orchestrator for coverage calculations.
Selects the appropriate propagation model based on band type
and environment, then delegates to the existing coverage pipeline.
"""
_model_registry = {
(BandType.LTE, "urban"): Cost231HataModel,
(BandType.LTE, "suburban"): OkumuraHataModel,
(BandType.LTE, "rural"): OkumuraHataModel,
(BandType.LTE, "open"): FreeSpaceModel,
(BandType.UHF, "urban"): OkumuraHataModel,
(BandType.UHF, "suburban"): OkumuraHataModel,
(BandType.UHF, "rural"): LongleyRiceModel,
(BandType.VHF, "urban"): ITUR_P1546Model,
(BandType.VHF, "suburban"): ITUR_P1546Model,
(BandType.VHF, "rural"): LongleyRiceModel,
}
def __init__(self):
self._models = {}
self._init_models()
self.free_space = FreeSpaceModel()
self.diffraction = KnifeEdgeDiffractionModel()
def _init_models(self):
for key, model_cls in self._model_registry.items():
self._models[key] = model_cls()
def select_model(self, band: BandType, environment: str) -> PropagationModel:
key = (band, environment)
if key in self._models:
return self._models[key]
if (band, "urban") in self._models:
return self._models[(band, "urban")]
return OkumuraHataModel()
def get_available_models(self) -> dict:
models = {}
seen = set()
for (band, env), model in self._models.items():
if model.name not in seen:
seen.add(model.name)
models[model.name] = {
"frequency_range": model.frequency_range,
"distance_range": model.distance_range,
"bands": [],
}
models[model.name]["bands"].append(f"{band.value}/{env}")
return models
async def calculate(
self,
sites: List[Site],
settings: CoverageSettings,
progress_callback: Optional[ProgressCallback] = None,
) -> CoverageResult:
"""
Main calculation entry point.
Delegates actual per-point work to the legacy coverage_service
pipeline, wrapping it with the new clean interface.
"""
start_time = time.time()
model = self.select_model(settings.band_type, settings.environment)
if progress_callback:
await progress_callback("init", 0.05, None)
# Import legacy system
from app.services.coverage_service import (
coverage_service, SiteParams,
CoverageSettings as LegacySettings,
)
from app.services.parallel_coverage_service import CancellationToken
legacy_settings = LegacySettings(
radius=settings.radius,
resolution=settings.resolution,
min_signal=settings.min_signal,
use_terrain=settings.use_terrain,
use_buildings=settings.use_buildings,
use_materials=settings.use_materials,
use_dominant_path=settings.use_dominant_path,
use_street_canyon=settings.use_street_canyon,
use_reflections=settings.use_reflections,
use_water_reflection=settings.use_water_reflection,
use_vegetation=settings.use_vegetation,
season=settings.season,
rain_rate=settings.rain_rate,
indoor_loss_type=settings.indoor_loss_type,
use_atmospheric=settings.use_atmospheric,
temperature_c=settings.temperature_c,
humidity_percent=settings.humidity_percent,
preset=settings.preset.value if isinstance(settings.preset, PresetType) else settings.preset,
)
cancel_token = CancellationToken()
if progress_callback:
await progress_callback("calculating", 0.25, None)
legacy_sites = [
SiteParams(
lat=s.lat, lon=s.lon, height=s.height,
power=s.power, gain=s.gain, frequency=s.frequency,
azimuth=s.azimuth, beamwidth=s.beamwidth,
)
for s in sites
]
if len(legacy_sites) == 1:
points = await coverage_service.calculate_coverage(
legacy_sites[0], legacy_settings, cancel_token,
)
else:
points = await coverage_service.calculate_multi_site_coverage(
legacy_sites, legacy_settings, cancel_token,
)
if progress_callback:
await progress_callback("done", 1.0, None)
result_points = [
PointResult(
lat=p.lat, lon=p.lon, rsrp=p.rsrp,
distance=p.distance, path_loss=0.0,
terrain_loss=p.terrain_loss,
building_loss=p.building_loss,
diffraction_loss=0.0,
has_los=p.has_los,
model_used=model.name,
)
for p in points
]
computation_time = time.time() - start_time
return CoverageResult(
points=result_points,
stats=compute_stats(result_points),
computation_time=computation_time,
models_used=[model.name],
)
# Singleton
engine = CoverageEngine()

83
backend/app/core/grid.py Normal file
View File

@@ -0,0 +1,83 @@
"""
Grid generation for coverage calculations.
"""
import numpy as np
from dataclasses import dataclass
from typing import List, Tuple
from app.geometry.haversine import haversine_distance
@dataclass
class BoundingBox:
min_lat: float
min_lon: float
max_lat: float
max_lon: float
@dataclass
class Grid:
points: List[Tuple[float, float]]
bounding_box: BoundingBox
resolution: float
radius: float
class GridService:
"""Generate coverage grid points."""
@staticmethod
def generate(
center_lat: float,
center_lon: float,
radius: float,
resolution: float,
) -> Grid:
points = []
lat_step = resolution / 111000
lon_step = resolution / (111000 * np.cos(np.radians(center_lat)))
lat_delta = radius / 111000
lon_delta = radius / (111000 * np.cos(np.radians(center_lat)))
bbox = BoundingBox(
min_lat=center_lat - lat_delta,
min_lon=center_lon - lon_delta,
max_lat=center_lat + lat_delta,
max_lon=center_lon + lon_delta,
)
lat = center_lat - lat_delta
while lat <= center_lat + lat_delta:
lon = center_lon - lon_delta
while lon <= center_lon + lon_delta:
dist = haversine_distance(center_lat, center_lon, lat, lon)
if dist <= radius:
points.append((lat, lon))
lon += lon_step
lat += lat_step
return Grid(points=points, bounding_box=bbox, resolution=resolution, radius=radius)
@staticmethod
def generate_multi_site(sites: list, radius: float, resolution: float) -> Grid:
all_points = set()
min_lat = min_lon = float("inf")
max_lat = max_lon = float("-inf")
for site in sites:
grid = GridService.generate(site.lat, site.lon, radius, resolution)
for p in grid.points:
all_points.add((round(p[0], 7), round(p[1], 7)))
min_lat = min(min_lat, grid.bounding_box.min_lat)
min_lon = min(min_lon, grid.bounding_box.min_lon)
max_lat = max(max_lat, grid.bounding_box.max_lat)
max_lon = max(max_lon, grid.bounding_box.max_lon)
return Grid(
points=list(all_points),
bounding_box=BoundingBox(min_lat, min_lon, max_lat, max_lon),
resolution=resolution, radius=radius,
)

View File

@@ -0,0 +1,65 @@
"""
Coverage result aggregation and statistics.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class PointResult:
lat: float
lon: float
rsrp: float
distance: float
path_loss: float
terrain_loss: float
building_loss: float
diffraction_loss: float
has_los: bool
model_used: str
def to_dict(self) -> dict:
return {
"lat": self.lat, "lon": self.lon,
"rsrp": self.rsrp, "distance": self.distance,
"path_loss": self.path_loss, "terrain_loss": self.terrain_loss,
"building_loss": self.building_loss, "diffraction_loss": self.diffraction_loss,
"has_los": self.has_los, "model_used": self.model_used,
}
@dataclass
class CoverageResult:
points: List[PointResult]
stats: dict
computation_time: float
models_used: List[str]
def to_dict(self) -> dict:
return {
"points": [p.to_dict() for p in self.points],
"count": len(self.points),
"stats": self.stats,
"computation_time": round(self.computation_time, 2),
"models_used": self.models_used,
}
def compute_stats(points: List[PointResult]) -> dict:
if not points:
return {"min_rsrp": 0, "max_rsrp": 0, "avg_rsrp": 0,
"los_percentage": 0, "total_points": 0}
rsrp_values = [p.rsrp for p in points]
los_count = sum(1 for p in points if p.has_los)
return {
"min_rsrp": min(rsrp_values),
"max_rsrp": max(rsrp_values),
"avg_rsrp": sum(rsrp_values) / len(rsrp_values),
"los_percentage": los_count / len(points) * 100,
"total_points": len(points),
"points_with_buildings": sum(1 for p in points if p.building_loss > 0),
"points_with_terrain_loss": sum(1 for p in points if p.terrain_loss > 0),
}