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)
1719 lines
53 KiB
Markdown
1719 lines
53 KiB
Markdown
# 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?
|