Files
rfcp/docs/devlog/installer/RFCP-Phase-3.0-Architecture-Refactor.md
2026-02-02 21:30:00 +02:00

1719 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
1. Clean, modular architecture
2. 10x performance improvement for Detailed preset
3. Proper memory management (< 2GB peak)
4. Reliable app lifecycle (close actually works!)
5. UHF/VHF band support readiness
6. 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
```python
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)
```python
# 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)
```python
# 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
```python
# 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
```python
# 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 │
└─────────────────────────────────────────────┘
```
```python
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
```python
# 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)
```
```typescript
// 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
```javascript
// 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):
1. SRTM terrain loading logic
2. OSM building parsing
3. R-tree spatial index
4. Frontend React components
5. Elevation layer visualization
### What to Rewrite:
1. coverage_service.py → core/engine.py + core/calculator.py
2. dominant_path_service.py → models/ + geometry/
3. parallel_coverage_service.py → parallel/
4. 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
1. **Performance:**
- Detailed preset completes in < 60 seconds
- Memory usage < 2 GB peak
2. **Reliability:**
- App close works first time, every time
- No zombie processes
- Clean error handling
3. **Maintainability:**
- Each model in separate file
- Unit tests for all models
- Clear separation of concerns
4. **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:
1. New project structure
2. Base propagation model interface
3. FreeSpace and OkumuraHata models
4. Basic engine skeleton
Shall I create the Phase 3.1 task file?