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)
53 KiB
RFCP Phase 3.0: Complete Architecture Refactor
Date: February 1, 2025
Type: Major Refactor
Version: 3.0.0
Author: Claude + Oleg
📋 Executive Summary
This document describes a complete architectural refactor of RFCP (RF Coverage Planner) to address fundamental performance and maintainability issues that accumulated through iterative patching (Phases 2.0-2.5.1).
Goals:
- Clean, modular architecture
- 10x performance improvement for Detailed preset
- Proper memory management (< 2GB peak)
- Reliable app lifecycle (close actually works!)
- UHF/VHF band support readiness
- Maintainable, testable codebase
Non-Goals:
- Complete UI redesign (current UI is good)
- New features beyond UHF/VHF tab
- Cloud deployment (stays desktop-first)
🏗️ Current Architecture Problems
Problem 1: Monolithic Coverage Service
coverage_service.py = 800+ lines
- Data loading
- Grid generation
- Point calculation
- Parallel orchestration
- Result aggregation
- ALL propagation models mixed together
Impact: Can't test individual components, can't swap models, hard to optimize.
Problem 2: Memory Explosion
Main process: Load 350k buildings (500MB)
Worker 1: Copy 350k buildings (500MB)
Worker 2: Copy 350k buildings (500MB)
... × 6 workers
Total: 3-4 GB just for building data!
Impact: 8GB RAM usage, OOM on smaller machines.
Problem 3: Nested Loop Hell
for point in grid: # 868 points
for building in buildings: # 50 buildings
for wall in walls: # 300 walls
for polygon in obstacles: # 50 polygons
check_intersection() # 4 nested loops!
Impact: O(n⁴) complexity, 340ms/point.
Problem 4: Electron-Python Lifecycle
Electron starts → spawns Python
User clicks X → Electron closes → Python orphaned
Multiple kill strategies → still doesn't work reliably
Impact: Zombie processes, user frustration.
Problem 5: No Clear Separation of Concerns
Propagation physics mixed with:
- File I/O
- Caching logic
- Parallel processing
- HTTP API handling
- Progress reporting
Impact: Can't unit test propagation models.
🎯 New Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ RFCP Desktop App │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Electron │ │ React │ │ WebSocket │ │
│ │ Shell │◄─┤ Frontend │◄─┤ Connection │ │
│ └──────┬──────┘ └─────────────┘ └───────────┬─────────────┘ │
│ │ │ │
│ │ spawn + lifecycle │ bidirectional │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Python Backend ││
│ │ ┌───────────────────────────────────────────────────────┐ ││
│ │ │ FastAPI + WebSocket │ ││
│ │ └───────────────────────────────────────────────────────┘ ││
│ │ │ ││
│ │ ┌───────────────────────────┴───────────────────────────┐ ││
│ │ │ Coverage Engine │ ││
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ ││
│ │ │ │ GridService │ │CalcService │ │ ResultService │ │ ││
│ │ │ └─────────────┘ └──────┬──────┘ └─────────────────┘ │ ││
│ │ └─────────────────────────┼─────────────────────────────┘ ││
│ │ │ ││
│ │ ┌─────────────────────────┴─────────────────────────────┐ ││
│ │ │ Propagation Models │ ││
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ ││
│ │ │ │ Okumura │ │ COST-231 │ │ ITU-R │ │ Longley │ │ ││
│ │ │ │ Hata │ │ │ │ P.1546 │ │ Rice │ │ ││
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ ││
│ │ └───────────────────────────────────────────────────────┘ ││
│ │ │ ││
│ │ ┌─────────────────────────┴─────────────────────────────┐ ││
│ │ │ Data Services │ ││
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ ││
│ │ │ │ Terrain │ │ Building │ │ Spatial │ │ Cache │ │ ││
│ │ │ │ Service │ │ Service │ │ Index │ │ Service │ │ ││
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ ││
│ │ └───────────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
📁 New Project Structure
rfcp/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI app + WebSocket
│ │ ├── config.py # Configuration management
│ │ │
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── routes/
│ │ │ │ ├── coverage.py # REST endpoints
│ │ │ │ ├── terrain.py # Terrain/elevation API
│ │ │ │ ├── system.py # Health, info, shutdown
│ │ │ │ └── sites.py # Site management
│ │ │ └── websocket.py # WebSocket handlers
│ │ │
│ │ ├── core/ # Core business logic
│ │ │ ├── __init__.py
│ │ │ ├── engine.py # CoverageEngine orchestrator
│ │ │ ├── grid.py # Grid generation
│ │ │ ├── calculator.py # Point calculation coordinator
│ │ │ └── result.py # Result aggregation
│ │ │
│ │ ├── models/ # Propagation models
│ │ │ ├── __init__.py
│ │ │ ├── base.py # Abstract PropagationModel
│ │ │ ├── free_space.py # Free space path loss
│ │ │ ├── okumura_hata.py # Okumura-Hata (150-1500 MHz)
│ │ │ ├── cost231_hata.py # COST-231 Hata (1500-2000 MHz)
│ │ │ ├── cost231_wi.py # COST-231 Walfisch-Ikegami
│ │ │ ├── itu_r_p1546.py # ITU-R P.1546 (30-3000 MHz)
│ │ │ ├── itu_r_p526.py # Diffraction (knife-edge)
│ │ │ ├── longley_rice.py # Irregular Terrain Model
│ │ │ └── uhf_vhf.py # UHF/VHF specific models
│ │ │
│ │ ├── services/ # Data services
│ │ │ ├── __init__.py
│ │ │ ├── terrain.py # SRTM terrain data
│ │ │ ├── buildings.py # OSM buildings
│ │ │ ├── spatial_index.py # R-tree spatial queries
│ │ │ ├── cache.py # Unified cache management
│ │ │ └── osm_client.py # OSM API client
│ │ │
│ │ ├── parallel/ # Parallel processing
│ │ │ ├── __init__.py
│ │ │ ├── manager.py # Shared memory manager
│ │ │ ├── worker.py # Worker process logic
│ │ │ └── pool.py # Process pool with cleanup
│ │ │
│ │ ├── geometry/ # Geometry operations
│ │ │ ├── __init__.py
│ │ │ ├── haversine.py # Distance calculations
│ │ │ ├── los.py # Line of sight checks
│ │ │ ├── intersection.py # Line-polygon intersection
│ │ │ ├── diffraction.py # Knife-edge diffraction
│ │ │ └── reflection.py # Reflection calculations
│ │ │
│ │ └── utils/
│ │ ├── __init__.py
│ │ ├── logging.py # Structured logging
│ │ ├── progress.py # Progress reporting
│ │ └── units.py # Unit conversions
│ │
│ ├── tests/ # Unit tests
│ │ ├── test_models/
│ │ ├── test_services/
│ │ ├── test_geometry/
│ │ └── test_integration/
│ │
│ └── requirements.txt
│
├── frontend/
│ ├── src/
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ ├── map/
│ │ │ │ ├── Map.tsx
│ │ │ │ ├── CoverageLayer.tsx
│ │ │ │ ├── ElevationLayer.tsx
│ │ │ │ └── SiteMarker.tsx
│ │ │ ├── panels/
│ │ │ │ ├── SitePanel.tsx
│ │ │ │ ├── SettingsPanel.tsx
│ │ │ │ ├── LTE_Panel.tsx # LTE band settings
│ │ │ │ └── UHF_VHF_Panel.tsx # NEW: UHF/VHF settings
│ │ │ └── common/
│ │ │ ├── LoadingOverlay.tsx
│ │ │ └── ProgressBar.tsx
│ │ │
│ │ ├── hooks/
│ │ │ ├── useWebSocket.ts # WebSocket connection
│ │ │ ├── useCoverage.ts # Coverage state
│ │ │ └── useProgress.ts # Progress tracking
│ │ │
│ │ ├── services/
│ │ │ ├── api.ts # REST API client
│ │ │ └── websocket.ts # WebSocket client
│ │ │
│ │ └── store/
│ │ ├── sites.ts
│ │ ├── settings.ts
│ │ └── coverage.ts
│ │
│ └── package.json
│
├── desktop/
│ ├── main.js # Electron main process
│ ├── preload.js # Preload scripts
│ └── package.json
│
└── installer/
├── build.bat
├── rfcp-server.spec
└── scripts/
🔧 Core Components Specification
1. CoverageEngine (Orchestrator)
# backend/app/core/engine.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional, AsyncIterator
from enum import Enum
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" # Terrain only
STANDARD = "standard" # + Buildings
DETAILED = "detailed" # + Reflections/Diffraction
@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
azimuth: float = 0 # degrees
beamwidth: float = 360
tilt: float = 0 # electrical downtilt
@dataclass
class CoverageSettings:
radius: float # meters
resolution: float # meters
min_signal: float # dBm
preset: PresetType
band_type: BandType
# Model-specific
environment: str = "urban" # urban, suburban, rural, open
terrain_enabled: bool = True
buildings_enabled: bool = True
diffraction_enabled: bool = True
reflection_enabled: bool = False # OFF by default now!
@dataclass
class PointResult:
lat: float
lon: float
rsrp: float # dBm
distance: float # meters
path_loss: float # dB
terrain_loss: float # dB
building_loss: float # dB
diffraction_loss: float # dB
has_los: bool
model_used: str
@dataclass
class CoverageResult:
points: List[PointResult]
stats: dict
computation_time: float
models_used: List[str]
class CoverageEngine:
"""
Main orchestrator for coverage calculations.
Responsibilities:
- Coordinate data loading
- Select appropriate propagation model
- Manage parallel computation
- Aggregate results
Does NOT:
- Implement propagation physics (delegated to models)
- Handle HTTP/WebSocket (delegated to API layer)
- Manage caching (delegated to services)
"""
def __init__(
self,
terrain_service: TerrainService,
building_service: BuildingService,
cache_service: CacheService,
):
self.terrain = terrain_service
self.buildings = building_service
self.cache = cache_service
self._models = self._init_models()
def _init_models(self) -> dict:
"""Initialize available propagation models."""
return {
# LTE bands (700-2600 MHz)
(BandType.LTE, "urban"): Cost231HataModel(),
(BandType.LTE, "suburban"): OkumuraHataModel(),
(BandType.LTE, "rural"): OkumuraHataModel(),
# UHF (400-520 MHz)
(BandType.UHF, "urban"): OkumuraHataModel(),
(BandType.UHF, "rural"): LongleyRiceModel(),
# VHF (136-174 MHz)
(BandType.VHF, "urban"): ITUR_P1546Model(),
(BandType.VHF, "rural"): LongleyRiceModel(),
}
def select_model(self, band: BandType, environment: str) -> PropagationModel:
"""Select best propagation model for given band and environment."""
key = (band, environment)
if key in self._models:
return self._models[key]
# Fallback
return OkumuraHataModel()
async def calculate(
self,
sites: List[Site],
settings: CoverageSettings,
progress_callback: Optional[callable] = None
) -> CoverageResult:
"""
Main calculation entry point.
Steps:
1. Generate grid
2. Load terrain data
3. Load building data (if needed)
4. Select propagation model
5. Calculate points (parallel)
6. Aggregate results
"""
start_time = time.time()
# Step 1: Generate grid
grid = GridService.generate(
sites=sites,
radius=settings.radius,
resolution=settings.resolution
)
if progress_callback:
await progress_callback(phase="grid", progress=0.05)
# Step 2: Load terrain
bbox = grid.bounding_box
terrain_data = await self.terrain.load_region(bbox)
if progress_callback:
await progress_callback(phase="terrain", progress=0.15)
# Step 3: Load buildings (if needed)
building_data = None
if settings.buildings_enabled and settings.preset != PresetType.FAST:
building_data = await self.buildings.load_region(bbox)
if progress_callback:
await progress_callback(phase="buildings", progress=0.25)
# Step 4: Select model
model = self.select_model(settings.band_type, settings.environment)
# Step 5: Calculate (parallel)
calculator = PointCalculator(
model=model,
terrain=terrain_data,
buildings=building_data,
settings=settings
)
results = await calculator.calculate_parallel(
sites=sites,
points=grid.points,
progress_callback=progress_callback
)
# Step 6: Aggregate
return CoverageResult(
points=results,
stats=self._compute_stats(results),
computation_time=time.time() - start_time,
models_used=[model.name]
)
def _compute_stats(self, results: List[PointResult]) -> dict:
"""Compute coverage statistics."""
rsrp_values = [r.rsrp for r in results]
return {
"min_rsrp": min(rsrp_values),
"max_rsrp": max(rsrp_values),
"avg_rsrp": sum(rsrp_values) / len(rsrp_values),
"los_percentage": sum(1 for r in results if r.has_los) / len(results) * 100,
"total_points": len(results)
}
2. Propagation Models (Clean Interface)
# backend/app/models/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
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, 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 # Detailed loss components
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
)
# backend/app/models/okumura_hata.py
class OkumuraHataModel(PropagationModel):
"""
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"
"""
@property
def name(self) -> str:
return "Okumura-Hata"
@property
def frequency_range(self) -> tuple:
return (150, 1500)
@property
def distance_range(self) -> tuple:
return (1000, 20000) # 1-20 km
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = input.distance_m / 1000 # Convert to km
hb = input.tx_height_m
hm = input.rx_height_m
# 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) - 40.94
elif input.environment == "open":
L = L_urban - 4.78 * (math.log10(f) ** 2) + 18.33 * math.log10(f) - 35.94
else:
L = L_urban
return PropagationOutput(
path_loss_db=L,
model_name=self.name,
is_los=False, # Okumura-Hata is for NLOS
breakdown={
"basic_loss": L_urban,
"environment_correction": L - L_urban,
"antenna_correction": a_hm
}
)
# backend/app/models/free_space.py
class FreeSpaceModel(PropagationModel):
"""
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
"""
@property
def name(self) -> str:
return "Free-Space"
@property
def frequency_range(self) -> tuple:
return (1, 100000) # Practically unlimited
@property
def distance_range(self) -> tuple:
return (1, 1000000) # 1m to 1000km
def calculate(self, input: PropagationInput) -> PropagationOutput:
d_km = input.distance_m / 1000
f = input.frequency_mhz
# Avoid log(0)
d_km = max(d_km, 0.001)
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
}
)
# backend/app/models/cost231_hata.py
class Cost231HataModel(PropagationModel):
"""
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.
"""
@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 (1000, 20000)
def calculate(self, input: PropagationInput) -> PropagationOutput:
f = input.frequency_mhz
d = input.distance_m / 1000
hb = input.tx_height_m
hm = input.rx_height_m
# 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
}
)
3. UHF/VHF Specific Models
# backend/app/models/uhf_vhf.py
"""
UHF/VHF Propagation Models
UHF (Ultra High Frequency): 300-3000 MHz, typically 400-520 MHz for radio
VHF (Very High Frequency): 30-300 MHz, typically 136-174 MHz for radio
Key differences from LTE:
1. Lower frequency = better penetration, longer range
2. Terrain diffraction more important than building reflection
3. Tropospheric effects at longer ranges
4. Different antenna characteristics
"""
class ITUR_P1546Model(PropagationModel):
"""
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)
"""
@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:
"""
Simplified P.1546 implementation.
Full implementation would include:
- Terrain clearance angle
- Mixed path (land/sea)
- Time variability
"""
f = input.frequency_mhz
d = input.distance_m / 1000 # km
h1 = input.tx_height_m
h2 = input.rx_height_m
# 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: using regression fit
E_ref = 106.9 - 20 * math.log10(d) # dBμV/m at 1kW
# Height gain for transmitter
if h1 > 10:
delta_h1 = 20 * math.log10(h1 / 10)
else:
delta_h1 = 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Ω)
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, # Assume LOS for very short distances
breakdown={
"reference_field": E_ref,
"height_gain": delta_h1,
"frequency_correction": delta_f,
"path_loss": L
}
)
class LongleyRiceModel(PropagationModel):
"""
Longley-Rice Irregular Terrain Model (ITM).
Best for:
- VHF/UHF over irregular terrain
- Point-to-point links
- Distances 1-2000 km
Note: Full implementation requires terrain profile.
This is a simplified version.
Reference: NTIA Report 82-100
"""
@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! (open source)
- NTIA ITM reference implementation
"""
f = input.frequency_mhz
d = input.distance_m / 1000
h1 = input.tx_height_m
h2 = input.rx_height_m
# 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
if h_eff > 20:
height_gain = 10 * math.log10(h_eff / 20)
else:
height_gain = 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
}
)
class KnifeEdgeDiffractionModel:
"""
Single knife-edge diffraction model.
Used for calculating loss when terrain blocks LOS.
Reference: ITU-R P.526
"""
@staticmethod
def calculate_loss(
d1_m: float, # Distance TX to obstacle
d2_m: float, # Distance obstacle to RX
h_m: float, # Obstacle height above LOS
wavelength_m: float
) -> float:
"""
Calculate diffraction loss over single knife edge.
Returns:
Loss in dB (always positive or zero)
"""
# 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:
# Well below LOS
L = 0
elif v < 0:
# Below LOS
L = 6.02 + 9.11 * v - 1.27 * v**2
elif v < 2.4:
# Above LOS
L = 6.02 + 9.11 * v + 1.65 * v**2
else:
# Deep shadow
L = 12.95 + 20 * math.log10(v)
return max(0, L)
4. Shared Memory Architecture
# backend/app/parallel/manager.py
"""
Shared Memory Manager for parallel processing.
Key insight: Instead of copying 350k buildings to each worker,
we store data in shared memory that all workers can READ.
Memory layout:
┌─────────────────────────────────────────────┐
│ Shared Memory Block │
├─────────────────────────────────────────────┤
│ Terrain Data (SRTM tiles) │
│ - Heights as flat array │
│ - Tile metadata (bounds, resolution) │
├─────────────────────────────────────────────┤
│ Building Data │
│ - Centroids (lat, lon) as arrays │
│ - Heights as array │
│ - Polygon vertices (flattened) │
│ - Polygon offsets (to reconstruct) │
├─────────────────────────────────────────────┤
│ Spatial Index │
│ - Grid cell assignments │
│ - Building IDs per cell │
└─────────────────────────────────────────────┘
import multiprocessing.shared_memory as shm
import numpy as np
from dataclasses import dataclass
from typing import Optional
import struct
@dataclass
class SharedTerrainData:
"""Terrain data in shared memory."""
shm_name: str
shape: tuple # (rows, cols)
bounds: tuple # (min_lat, min_lon, max_lat, max_lon)
resolution: float # arc-seconds
def get_array(self) -> np.ndarray:
"""Attach to shared memory and return numpy array."""
existing_shm = shm.SharedMemory(name=self.shm_name)
return np.ndarray(self.shape, dtype=np.int16, buffer=existing_shm.buf)
@dataclass
class SharedBuildingData:
"""Building data in shared memory."""
shm_centroids_name: str # (N, 2) float64 - lat, lon
shm_heights_name: str # (N,) float32 - heights
shm_vertices_name: str # (total_verts, 2) float64 - all vertices
shm_offsets_name: str # (N+1,) int32 - where each polygon starts
count: int
def get_centroids(self) -> np.ndarray:
existing = shm.SharedMemory(name=self.shm_centroids_name)
return np.ndarray((self.count, 2), dtype=np.float64, buffer=existing.buf)
def get_polygon(self, idx: int) -> np.ndarray:
"""Get polygon vertices for building idx."""
offsets = self._get_offsets()
vertices = self._get_vertices()
start, end = offsets[idx], offsets[idx + 1]
return vertices[start:end]
class SharedMemoryManager:
"""
Manages shared memory blocks for parallel processing.
Usage:
# In main process
manager = SharedMemoryManager()
terrain_ref = manager.store_terrain(terrain_data)
buildings_ref = manager.store_buildings(buildings)
# Pass references to workers
pool.map(worker_func, points, terrain_ref, buildings_ref)
# Workers attach to shared memory
terrain = terrain_ref.get_array() # No copy!
# Cleanup
manager.cleanup()
"""
def __init__(self):
self._shm_blocks: list = []
def store_terrain(self, heights: np.ndarray, bounds: tuple, resolution: float) -> SharedTerrainData:
"""Store terrain heights in shared memory."""
# Create shared memory block
shm_block = shm.SharedMemory(create=True, size=heights.nbytes)
self._shm_blocks.append(shm_block)
# Copy data to shared memory
shm_array = np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_block.buf)
shm_array[:] = heights[:]
return SharedTerrainData(
shm_name=shm_block.name,
shape=heights.shape,
bounds=bounds,
resolution=resolution
)
def store_buildings(self, buildings: list) -> SharedBuildingData:
"""Store building data in shared memory."""
n = len(buildings)
# Extract centroids
centroids = np.array([
[b['centroid_lat'], b['centroid_lon']]
for b in buildings
], dtype=np.float64)
# Extract heights
heights = np.array([
b.get('height', 10.0) for b in buildings
], dtype=np.float32)
# Flatten all polygon vertices
all_vertices = []
offsets = [0]
for b in buildings:
coords = b.get('geometry', {}).get('coordinates', [[]])[0]
for lon, lat in coords:
all_vertices.append([lat, lon])
offsets.append(len(all_vertices))
vertices = np.array(all_vertices, dtype=np.float64)
offsets = np.array(offsets, dtype=np.int32)
# Create shared memory blocks
shm_centroids = shm.SharedMemory(create=True, size=centroids.nbytes)
shm_heights = shm.SharedMemory(create=True, size=heights.nbytes)
shm_vertices = shm.SharedMemory(create=True, size=vertices.nbytes)
shm_offsets = shm.SharedMemory(create=True, size=offsets.nbytes)
self._shm_blocks.extend([shm_centroids, shm_heights, shm_vertices, shm_offsets])
# Copy data
np.ndarray(centroids.shape, dtype=centroids.dtype, buffer=shm_centroids.buf)[:] = centroids
np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_heights.buf)[:] = heights
np.ndarray(vertices.shape, dtype=vertices.dtype, buffer=shm_vertices.buf)[:] = vertices
np.ndarray(offsets.shape, dtype=offsets.dtype, buffer=shm_offsets.buf)[:] = offsets
return SharedBuildingData(
shm_centroids_name=shm_centroids.name,
shm_heights_name=shm_heights.name,
shm_vertices_name=shm_vertices.name,
shm_offsets_name=shm_offsets.name,
count=n
)
def cleanup(self):
"""Release all shared memory blocks."""
for block in self._shm_blocks:
try:
block.close()
block.unlink()
except Exception:
pass
self._shm_blocks.clear()
5. WebSocket Communication
# backend/app/api/websocket.py
from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, Set
import asyncio
import json
class ConnectionManager:
"""Manage WebSocket connections."""
def __init__(self):
self.active_connections: Set[WebSocket] = set()
self._calculation_tasks: Dict[str, asyncio.Task] = {}
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
pass
async def send_progress(self, calc_id: str, phase: str, progress: float, eta: float = None):
"""Send calculation progress update."""
await self.broadcast({
"type": "progress",
"calculation_id": calc_id,
"phase": phase,
"progress": progress,
"eta_seconds": eta
})
async def send_result(self, calc_id: str, result: dict):
"""Send calculation result."""
await self.broadcast({
"type": "result",
"calculation_id": calc_id,
"data": result
})
async def send_error(self, calc_id: str, error: str):
"""Send error message."""
await self.broadcast({
"type": "error",
"calculation_id": calc_id,
"message": error
})
manager = ConnectionManager()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_json()
if data["type"] == "calculate":
# Start calculation
calc_id = data.get("id", str(uuid.uuid4()))
async def progress_callback(phase: str, progress: float, eta: float = None):
await manager.send_progress(calc_id, phase, progress, eta)
try:
result = await engine.calculate(
sites=data["sites"],
settings=CoverageSettings(**data["settings"]),
progress_callback=progress_callback
)
await manager.send_result(calc_id, result.to_dict())
except Exception as e:
await manager.send_error(calc_id, str(e))
elif data["type"] == "cancel":
# Cancel calculation
calc_id = data.get("id")
if calc_id in manager._calculation_tasks:
manager._calculation_tasks[calc_id].cancel()
elif data["type"] == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
manager.disconnect(websocket)
// frontend/src/hooks/useWebSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface ProgressUpdate {
type: 'progress';
calculation_id: string;
phase: string;
progress: number;
eta_seconds?: number;
}
interface ResultUpdate {
type: 'result';
calculation_id: string;
data: CoverageResult;
}
interface ErrorUpdate {
type: 'error';
calculation_id: string;
message: string;
}
type WSMessage = ProgressUpdate | ResultUpdate | ErrorUpdate;
export function useWebSocket() {
const ws = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const [progress, setProgress] = useState<ProgressUpdate | null>(null);
const callbacks = useRef<{
onResult?: (result: CoverageResult) => void;
onError?: (error: string) => void;
}>({});
useEffect(() => {
const connect = () => {
ws.current = new WebSocket('ws://127.0.0.1:8888/ws');
ws.current.onopen = () => {
setConnected(true);
console.log('[WS] Connected');
};
ws.current.onclose = () => {
setConnected(false);
console.log('[WS] Disconnected, reconnecting...');
setTimeout(connect, 2000);
};
ws.current.onmessage = (event) => {
const msg: WSMessage = JSON.parse(event.data);
switch (msg.type) {
case 'progress':
setProgress(msg);
break;
case 'result':
callbacks.current.onResult?.(msg.data);
setProgress(null);
break;
case 'error':
callbacks.current.onError?.(msg.message);
setProgress(null);
break;
}
};
};
connect();
return () => {
ws.current?.close();
};
}, []);
const calculate = useCallback((
sites: Site[],
settings: CoverageSettings,
onResult: (result: CoverageResult) => void,
onError: (error: string) => void
) => {
if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
onError('WebSocket not connected');
return;
}
const calcId = crypto.randomUUID();
callbacks.current = { onResult, onError };
ws.current.send(JSON.stringify({
type: 'calculate',
id: calcId,
sites,
settings
}));
return calcId;
}, []);
const cancel = useCallback((calcId: string) => {
ws.current?.send(JSON.stringify({
type: 'cancel',
id: calcId
}));
}, []);
return { connected, progress, calculate, cancel };
}
6. Clean Electron Lifecycle
// desktop/main.js
const { app, BrowserWindow } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const http = require('http');
let mainWindow = null;
let backendProcess = null;
let isQuitting = false;
// Configuration
const CONFIG = {
backendHost: '127.0.0.1',
backendPort: 8888,
healthCheckInterval: 1000,
shutdownTimeout: 5000,
};
// ==================== Backend Management ====================
function getBackendPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'rfcp-server.exe');
}
return path.join(__dirname, '..', 'installer', 'dist', 'rfcp-server.exe');
}
async function startBackend() {
const exePath = getBackendPath();
console.log('[Backend] Starting:', exePath);
backendProcess = spawn(exePath, [], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
RFCP_HOST: CONFIG.backendHost,
RFCP_PORT: String(CONFIG.backendPort),
},
windowsHide: true,
});
backendProcess.stdout.on('data', (data) => {
console.log('[Backend]', data.toString().trim());
});
backendProcess.stderr.on('data', (data) => {
console.error('[Backend Error]', data.toString().trim());
});
backendProcess.on('exit', (code) => {
console.log('[Backend] Exited with code:', code);
backendProcess = null;
if (!isQuitting) {
console.log('[Backend] Unexpected exit, restarting...');
setTimeout(startBackend, 1000);
}
});
// Wait for backend to be ready
await waitForBackend();
}
async function waitForBackend(maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
try {
await checkHealth();
console.log('[Backend] Ready!');
return;
} catch {
await sleep(500);
}
}
throw new Error('Backend failed to start');
}
function checkHealth() {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: CONFIG.backendHost,
port: CONFIG.backendPort,
path: '/api/health',
method: 'GET',
timeout: 1000,
}, (res) => {
if (res.statusCode === 200) resolve();
else reject(new Error(`Health check failed: ${res.statusCode}`));
});
req.on('error', reject);
req.on('timeout', () => reject(new Error('Timeout')));
req.end();
});
}
async function stopBackend() {
if (!backendProcess) return;
console.log('[Backend] Stopping...');
// Step 1: Request graceful shutdown
try {
await fetch(`http://${CONFIG.backendHost}:${CONFIG.backendPort}/api/system/shutdown`, {
method: 'POST',
signal: AbortSignal.timeout(2000),
});
console.log('[Backend] Shutdown requested');
} catch {
console.log('[Backend] Shutdown request failed, force killing');
}
// Step 2: Wait for process to exit
const exitPromise = new Promise((resolve) => {
if (!backendProcess) {
resolve();
return;
}
backendProcess.once('exit', resolve);
setTimeout(resolve, CONFIG.shutdownTimeout);
});
// Step 3: Force kill if still running
if (backendProcess) {
backendProcess.kill('SIGTERM');
await exitPromise;
if (backendProcess) {
backendProcess.kill('SIGKILL');
}
}
console.log('[Backend] Stopped');
}
// ==================== Window Management ====================
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
icon: path.join(__dirname, 'icon.png'),
title: 'RFCP - RF Coverage Planner',
});
// Load frontend
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'frontend', 'dist', 'index.html'));
}
// Handle close
mainWindow.on('close', async (event) => {
if (!isQuitting) {
event.preventDefault();
await quit();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ==================== App Lifecycle ====================
async function quit() {
if (isQuitting) return;
isQuitting = true;
console.log('[App] Quitting...');
await stopBackend();
if (mainWindow) {
mainWindow.destroy();
}
app.quit();
}
// App ready
app.whenReady().then(async () => {
try {
await startBackend();
createWindow();
} catch (error) {
console.error('[App] Failed to start:', error);
app.quit();
}
});
// All windows closed
app.on('window-all-closed', () => {
quit();
});
// Before quit
app.on('before-quit', (event) => {
if (!isQuitting) {
event.preventDefault();
quit();
}
});
// macOS activate
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Signals
process.on('SIGINT', quit);
process.on('SIGTERM', quit);
// ==================== Utilities ====================
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
📊 Performance Targets
| Metric | Current | Target | Improvement |
|---|---|---|---|
| Fast preset (2km) | 0.03s | 0.03s | Same |
| Standard preset (5km) | 38s | 20s | 2x |
| Detailed preset (5km) | 300s (timeout) | 45s | 6x |
| Memory peak | 8 GB | 1.5 GB | 5x |
| App close | Broken | Works | ∞ |
| UHF/VHF support | No | Yes | New |
🗓️ Implementation Phases
Phase 3.1: Core Infrastructure (Week 1)
- New project structure
- PropagationModel interface + FreeSpace, OkumuraHata
- TerrainService (port existing SRTM code)
- Basic CoverageEngine (single-threaded)
- Unit tests for models
Phase 3.2: Data Services (Week 2)
- BuildingService (port existing OSM code)
- SpatialIndex service
- CacheService (unified caching)
- SharedMemoryManager
- Integration tests
Phase 3.3: Parallel Processing (Week 3)
- PointCalculator with shared memory
- Worker pool with clean lifecycle
- Progress reporting
- Cancellation support
- Performance benchmarks
Phase 3.4: API + Frontend (Week 4)
- WebSocket API
- useWebSocket hook
- Progress UI
- UHF/VHF tab
- Electron cleanup
Phase 3.5: Polish + Testing (Week 5)
- End-to-end tests
- Documentation
- PyInstaller build
- Performance validation
- Release
🧪 Testing Strategy
tests/
├── unit/
│ ├── test_models/
│ │ ├── test_free_space.py
│ │ ├── test_okumura_hata.py
│ │ └── test_cost231.py
│ ├── test_geometry/
│ │ ├── test_haversine.py
│ │ └── test_intersection.py
│ └── test_services/
│ ├── test_terrain.py
│ └── test_buildings.py
│
├── integration/
│ ├── test_engine.py
│ ├── test_parallel.py
│ └── test_websocket.py
│
└── e2e/
├── test_coverage_calculation.py
└── test_app_lifecycle.py
Test coverage targets:
- Models: 100% (pure functions, easy to test)
- Services: 90%
- Engine: 85%
- API: 80%
📝 Migration Guide
What to Keep (copy directly):
- SRTM terrain loading logic
- OSM building parsing
- R-tree spatial index
- Frontend React components
- Elevation layer visualization
What to Rewrite:
- coverage_service.py → core/engine.py + core/calculator.py
- dominant_path_service.py → models/ + geometry/
- parallel_coverage_service.py → parallel/
- main.js → Clean lifecycle management
Data Compatibility:
- Existing terrain cache: Compatible ✅
- Existing OSM cache: Compatible ✅
- Site JSON format: Compatible ✅
- Coverage result format: Compatible ✅
🎯 Success Criteria
-
Performance:
- Detailed preset completes in < 60 seconds
- Memory usage < 2 GB peak
-
Reliability:
- App close works first time, every time
- No zombie processes
- Clean error handling
-
Maintainability:
- Each model in separate file
- Unit tests for all models
- Clear separation of concerns
-
Features:
- UHF/VHF tab functional
- Multiple propagation models selectable
- Progress visible via WebSocket
🚀 Ready to Start?
This architecture document is the blueprint.
Next step: Give Claude Code Phase 3.1 tasks to create:
- New project structure
- Base propagation model interface
- FreeSpace and OkumuraHata models
- Basic engine skeleton
Shall I create the Phase 3.1 task file?