@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,21 @@
"""
Propagation models for RF coverage calculation.
Each model implements the PropagationModel interface and is stateless/thread-safe.
"""
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
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.itu_r_p526 import KnifeEdgeDiffractionModel
from app.propagation.longley_rice import LongleyRiceModel
__all__ = [
"PropagationModel", "PropagationInput", "PropagationOutput",
"FreeSpaceModel", "OkumuraHataModel", "Cost231HataModel",
"Cost231WIModel", "ITUR_P1546Model", "KnifeEdgeDiffractionModel",
"LongleyRiceModel",
]

View File

@@ -0,0 +1,87 @@
"""
Abstract base class for all propagation models.
Each model implements a single, well-defined propagation algorithm.
Models are stateless and can be called concurrently.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class PropagationInput:
"""Input for propagation calculation."""
frequency_mhz: float
distance_m: float
tx_height_m: float
rx_height_m: float
environment: str = "urban" # urban, suburban, rural, open
# Optional terrain info
terrain_clearance_m: Optional[float] = None
terrain_roughness_m: Optional[float] = None
# Optional building info
building_height_m: Optional[float] = None
street_width_m: Optional[float] = None
building_separation_m: Optional[float] = None
@dataclass
class PropagationOutput:
"""Output from propagation calculation."""
path_loss_db: float
model_name: str
is_los: bool
breakdown: dict = field(default_factory=dict)
class PropagationModel(ABC):
"""
Abstract base class for all propagation models.
Each model implements a single, well-defined propagation algorithm.
Models are stateless and can be called concurrently.
"""
@property
@abstractmethod
def name(self) -> str:
"""Model name for logging/display."""
pass
@property
@abstractmethod
def frequency_range(self) -> tuple:
"""Valid frequency range (min_mhz, max_mhz)."""
pass
@property
@abstractmethod
def distance_range(self) -> tuple:
"""Valid distance range (min_m, max_m)."""
pass
@abstractmethod
def calculate(self, input: PropagationInput) -> PropagationOutput:
"""
Calculate path loss for given input.
This method MUST be:
- Stateless (no side effects)
- Thread-safe (can be called concurrently)
- Fast (no I/O, no heavy computation)
"""
pass
def is_valid_for(self, input: PropagationInput) -> bool:
"""Check if this model is valid for given input."""
freq_min, freq_max = self.frequency_range
dist_min, dist_max = self.distance_range
return (
freq_min <= input.frequency_mhz <= freq_max and
dist_min <= input.distance_m <= dist_max
)

View File

@@ -0,0 +1,62 @@
"""
COST-231 Hata model (extension of Okumura-Hata).
Valid for:
- Frequency: 1500-2000 MHz
- Distance: 1-20 km
Better for LTE bands than original Okumura-Hata.
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class Cost231HataModel(PropagationModel):
@property
def name(self) -> str:
return "COST-231-Hata"
@property
def frequency_range(self) -> tuple:
return (1500, 2000)
@property
def distance_range(self) -> tuple:
return (100, 20000)
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = max(input.distance_m / 1000, 0.1)
hb = max(input.tx_height_m, 1.0)
hm = max(input.rx_height_m, 1.0)
# Mobile antenna correction (medium city)
a_hm = (1.1 * math.log10(f) - 0.7) * hm - (1.56 * math.log10(f) - 0.8)
# Metropolitan center correction
C_m = 3 if input.environment == "urban" else 0
L = (
46.3
+ 33.9 * math.log10(f)
- 13.82 * math.log10(hb)
- a_hm
+ (44.9 - 6.55 * math.log10(hb)) * math.log10(d)
+ C_m
)
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=False,
breakdown={
"base_loss": 46.3,
"frequency_term": 33.9 * math.log10(f),
"height_gain": -13.82 * math.log10(hb),
"mobile_correction": -a_hm,
"distance_term": (44.9 - 6.55 * math.log10(hb)) * math.log10(d),
"metro_correction": C_m,
},
)

View File

@@ -0,0 +1,114 @@
"""
COST-231 Walfisch-Ikegami model.
Valid for:
- Frequency: 800-2000 MHz
- Distance: 20m-5km
- Urban microcell environments
Accounts for building heights, street widths, and building separation.
Reference: COST 231 Final Report, Chapter 4.
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class Cost231WIModel(PropagationModel):
@property
def name(self) -> str:
return "COST-231-WI"
@property
def frequency_range(self) -> tuple:
return (800, 2000)
@property
def distance_range(self) -> tuple:
return (20, 5000)
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = max(input.distance_m / 1000, 0.02) # km
hb = max(input.tx_height_m, 4.0)
hm = max(input.rx_height_m, 1.0)
# Building parameters (defaults for typical urban)
h_roof = input.building_height_m or 15.0 # avg building height
w = input.street_width_m or 20.0 # street width
b = input.building_separation_m or 30.0 # building separation
delta_hb = hb - h_roof # TX above rooftop
delta_hm = h_roof - hm # rooftop above RX
# Free space loss
L_fs = 32.45 + 20 * math.log10(d) + 20 * math.log10(f)
# LOS case
if delta_hb > 0 and d < 0.5:
L = L_fs
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=True,
breakdown={"free_space": L_fs, "rooftop_diffraction": 0, "multiscreen": 0},
)
# Rooftop-to-street diffraction (L_rts)
phi = 90.0 # street orientation angle (worst case)
if phi < 35:
L_ori = -10 + 0.354 * phi
elif phi < 55:
L_ori = 2.5 + 0.075 * (phi - 35)
else:
L_ori = 4.0 - 0.114 * (phi - 55)
L_rts = (
-16.9
- 10 * math.log10(w)
+ 10 * math.log10(f)
+ 20 * math.log10(delta_hm)
+ L_ori
)
# Multi-screen diffraction (L_msd)
if delta_hb > 0:
L_bsh = -18 * math.log10(1 + delta_hb)
k_a = 54
k_d = 18
else:
L_bsh = 0
k_a = 54 - 0.8 * abs(delta_hb)
if d >= 0.5:
k_a = max(k_a, 54 - 0.8 * abs(delta_hb) * (d / 0.5))
k_d = 18 - 15 * abs(delta_hb) / h_roof
k_f = -4 + 0.7 * (f / 925 - 1) # medium city
if input.environment == "urban":
k_f = -4 + 1.5 * (f / 925 - 1)
L_msd = (
L_bsh
+ k_a
+ k_d * math.log10(d)
+ k_f * math.log10(f)
- 9 * math.log10(b)
)
# Total NLOS loss
if L_rts + L_msd > 0:
L = L_fs + L_rts + L_msd
else:
L = L_fs
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=False,
breakdown={
"free_space": L_fs,
"rooftop_diffraction": max(L_rts, 0),
"multiscreen": max(L_msd, 0),
},
)

View File

@@ -0,0 +1,43 @@
"""
Free Space Path Loss (FSPL) model.
Used as baseline and for LOS conditions.
FSPL = 20*log10(d) + 20*log10(f) + 32.45
where d in km, f in MHz
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class FreeSpaceModel(PropagationModel):
"""Free Space Path Loss — theoretical minimum propagation loss."""
@property
def name(self) -> str:
return "Free-Space"
@property
def frequency_range(self) -> tuple:
return (1, 100000)
@property
def distance_range(self) -> tuple:
return (1, 1000000) # 1m to 1000km
def calculate(self, input: PropagationInput) -> PropagationOutput:
d_km = max(input.distance_m / 1000, 0.001)
f = input.frequency_mhz
L = 20 * math.log10(d_km) + 20 * math.log10(f) + 32.45
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=True,
breakdown={
"distance_loss": 20 * math.log10(d_km),
"frequency_loss": 20 * math.log10(f),
"constant": 32.45,
},
)

View File

@@ -0,0 +1,74 @@
"""
ITU-R P.1546 model for point-to-area predictions.
Valid for:
- Frequency: 30-3000 MHz
- Distance: 1-1000 km
- Time percentages: 1%, 10%, 50%
Best for: VHF/UHF broadcasting and land mobile services.
Reference: ITU-R P.1546-6 (2019)
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class ITUR_P1546Model(PropagationModel):
"""
Simplified P.1546 implementation.
Full implementation would include terrain clearance angle,
mixed path (land/sea), and time variability.
"""
@property
def name(self) -> str:
return "ITU-R-P.1546"
@property
def frequency_range(self) -> tuple:
return (30, 3000)
@property
def distance_range(self) -> tuple:
return (1000, 1000000) # 1-1000 km
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = max(input.distance_m / 1000, 1.0) # km
h1 = max(input.tx_height_m, 1.0)
# Nominal frequency bands
if f < 100:
f_nom = 100
elif f < 600:
f_nom = 600
else:
f_nom = 2000
# Basic field strength at 1 kW ERP (from curves, simplified regression)
E_ref = 106.9 - 20 * math.log10(d) # dBuV/m at 1kW
# Height gain for transmitter
delta_h1 = 20 * math.log10(h1 / 10) if h1 > 10 else 0
# Frequency correction
delta_f = 20 * math.log10(f / f_nom)
# Convert field strength to path loss
# L = 139.3 - E + 20*log10(f) (for 50 Ohm)
E = E_ref + delta_h1 - delta_f
L = 139.3 - E + 20 * math.log10(f)
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=d < 5,
breakdown={
"reference_field": E_ref,
"height_gain": delta_h1,
"frequency_correction": delta_f,
"path_loss": L,
},
)

View File

@@ -0,0 +1,87 @@
"""
Knife-edge diffraction model based on ITU-R P.526.
Used for calculating additional loss when terrain or obstacles
block the line of sight between TX and RX.
Reference: ITU-R P.526-15
"""
import math
class KnifeEdgeDiffractionModel:
"""
Single knife-edge diffraction model.
Stateless utility — not a full PropagationModel since it calculates
additional loss, not total path loss.
"""
@staticmethod
def calculate_loss(
d1_m: float,
d2_m: float,
h_m: float,
wavelength_m: float,
) -> float:
"""
Calculate diffraction loss over single knife edge.
Args:
d1_m: Distance from TX to obstacle
d2_m: Distance from obstacle to RX
h_m: Obstacle height above LOS line (positive = above)
wavelength_m: Signal wavelength
Returns:
Loss in dB (always >= 0)
"""
if d1_m <= 0 or d2_m <= 0 or wavelength_m <= 0:
return 0.0
# Fresnel-Kirchhoff parameter
v = h_m * math.sqrt(2 * (d1_m + d2_m) / (wavelength_m * d1_m * d2_m))
# Diffraction loss (Lee approximation)
if v < -0.78:
L = 0.0
elif v < 0:
L = 6.02 + 9.11 * v - 1.27 * v ** 2
elif v < 2.4:
L = 6.02 + 9.11 * v + 1.65 * v ** 2
else:
L = 12.95 + 20 * math.log10(v)
return max(0.0, L)
@staticmethod
def calculate_clearance_loss(
clearance_m: float,
frequency_mhz: float,
) -> float:
"""
Simplified diffraction loss from terrain clearance.
Matches the existing coverage_service._diffraction_loss logic.
Args:
clearance_m: Minimum LOS clearance (negative = blocked)
frequency_mhz: Signal frequency
Returns:
Loss in dB (0 if positive clearance)
"""
if clearance_m >= 0:
return 0.0
v = abs(clearance_m) / 10
if v <= 0:
loss = 0.0
elif v < 2.4:
loss = 6.02 + 9.11 * v - 1.27 * v ** 2
else:
loss = 13.0 + 20 * math.log10(v)
return min(loss, 40.0)

View File

@@ -0,0 +1,75 @@
"""
Longley-Rice Irregular Terrain Model (ITM).
Best for:
- VHF/UHF over irregular terrain
- Point-to-point links
- Distances 1-2000 km
Note: This is a simplified area-mode version.
Full implementation requires terrain profile data.
Reference: NTIA Report 82-100
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class LongleyRiceModel(PropagationModel):
@property
def name(self) -> str:
return "Longley-Rice"
@property
def frequency_range(self) -> tuple:
return (20, 20000) # 20 MHz to 20 GHz
@property
def distance_range(self) -> tuple:
return (1000, 2000000) # 1-2000 km
def calculate(self, input: PropagationInput) -> PropagationOutput:
"""
Simplified Longley-Rice (area mode).
For proper implementation, use splat! or NTIA ITM reference.
"""
f = input.frequency_mhz
d = max(input.distance_m / 1000, 1.0)
h1 = max(input.tx_height_m, 1.0)
h2 = max(input.rx_height_m, 1.0)
# Terrain irregularity parameter (simplified)
delta_h = input.terrain_roughness_m or 90 # Default: rolling hills
# Free space loss
L_fs = 32.45 + 20 * math.log10(d) + 20 * math.log10(f)
# Terrain clutter loss (simplified)
if delta_h < 10:
L_terrain = 0 # Flat
elif delta_h < 50:
L_terrain = 5 # Gently rolling
elif delta_h < 150:
L_terrain = 10 # Rolling hills
else:
L_terrain = 15 # Mountains
# Height gain
h_eff = h1 + h2
height_gain = 10 * math.log10(h_eff / 20) if h_eff > 20 else 0
L = L_fs + L_terrain - height_gain
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=delta_h < 10 and d < 10,
breakdown={
"free_space_loss": L_fs,
"terrain_loss": L_terrain,
"height_gain": height_gain,
},
)

View File

@@ -0,0 +1,74 @@
"""
Okumura-Hata empirical propagation model.
Valid for:
- Frequency: 150-1500 MHz
- Distance: 1-20 km
- TX height: 30-200 m
- RX height: 1-10 m
Reference: Hata (1980), "Empirical Formula for Propagation Loss
in Land Mobile Radio Services"
"""
import math
from app.propagation.base import PropagationModel, PropagationInput, PropagationOutput
class OkumuraHataModel(PropagationModel):
@property
def name(self) -> str:
return "Okumura-Hata"
@property
def frequency_range(self) -> tuple:
return (150, 1500)
@property
def distance_range(self) -> tuple:
return (100, 20000) # Extended to 100m minimum for practical use
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = max(input.distance_m / 1000, 0.1) # km, min 100m
hb = max(input.tx_height_m, 1.0)
hm = max(input.rx_height_m, 1.0)
# Mobile antenna height correction factor
if input.environment == "urban" and f >= 400:
# Large city
a_hm = 3.2 * (math.log10(11.75 * hm) ** 2) - 4.97
else:
# Medium/small city
a_hm = (1.1 * math.log10(f) - 0.7) * hm - (1.56 * math.log10(f) - 0.8)
# Basic path loss (urban)
L_urban = (
69.55
+ 26.16 * math.log10(f)
- 13.82 * math.log10(hb)
- a_hm
+ (44.9 - 6.55 * math.log10(hb)) * math.log10(d)
)
# Environment correction
if input.environment == "suburban":
L = L_urban - 2 * (math.log10(f / 28) ** 2) - 5.4
elif input.environment == "rural":
L = L_urban - 4.78 * (math.log10(f) ** 2) + 18.33 * math.log10(f) - 35.94
elif input.environment == "open":
L = L_urban - 4.78 * (math.log10(f) ** 2) + 18.33 * math.log10(f) - 40.94
else:
L = L_urban
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=False,
breakdown={
"basic_loss": L_urban,
"environment_correction": L - L_urban,
"antenna_correction": a_hm,
},
)