@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:
@@ -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
|
||||
"""
|
||||
|
||||
Binary file not shown.
103
backend/app/core/calculator.py
Normal file
103
backend/app/core/calculator.py
Normal 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
240
backend/app/core/engine.py
Normal 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
83
backend/app/core/grid.py
Normal 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,
|
||||
)
|
||||
65
backend/app/core/result.py
Normal file
65
backend/app/core/result.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user