# 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(null); const [connected, setConnected] = useState(false); const [progress, setProgress] = useState(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?