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

53 KiB
Raw Blame History

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

for point in grid:           # 868 points
  for building in buildings:   # 50 buildings
    for wall in walls:           # 300 walls
      for polygon in obstacles:    # 50 polygons
        check_intersection()         # 4 nested loops!

Impact: O(n⁴) complexity, 340ms/point.

Problem 4: Electron-Python Lifecycle

Electron starts → spawns Python
User clicks X → Electron closes → Python orphaned
Multiple kill strategies → still doesn't work reliably

Impact: Zombie processes, user frustration.

Problem 5: No Clear Separation of Concerns

Propagation physics mixed with:
  - File I/O
  - Caching logic
  - Parallel processing
  - HTTP API handling
  - Progress reporting

Impact: Can't unit test propagation models.


🎯 New Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        RFCP Desktop App                         │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐ │
│  │  Electron   │  │   React     │  │      WebSocket          │ │
│  │   Shell     │◄─┤   Frontend  │◄─┤      Connection         │ │
│  └──────┬──────┘  └─────────────┘  └───────────┬─────────────┘ │
│         │                                       │               │
│         │ spawn + lifecycle                     │ bidirectional │
│         ▼                                       ▼               │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                    Python Backend                           ││
│  │  ┌───────────────────────────────────────────────────────┐ ││
│  │  │                    FastAPI + WebSocket                 │ ││
│  │  └───────────────────────────────────────────────────────┘ ││
│  │                              │                              ││
│  │  ┌───────────────────────────┴───────────────────────────┐ ││
│  │  │                  Coverage Engine                       │ ││
│  │  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐  │ ││
│  │  │  │ GridService │ │CalcService  │ │ ResultService   │  │ ││
│  │  │  └─────────────┘ └──────┬──────┘ └─────────────────┘  │ ││
│  │  └─────────────────────────┼─────────────────────────────┘ ││
│  │                            │                                ││
│  │  ┌─────────────────────────┴─────────────────────────────┐ ││
│  │  │              Propagation Models                        │ ││
│  │  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐  │ ││
│  │  │  │ Okumura  │ │ COST-231 │ │ ITU-R    │ │ Longley  │  │ ││
│  │  │  │   Hata   │ │          │ │ P.1546   │ │   Rice   │  │ ││
│  │  │  └──────────┘ └──────────┘ └──────────┘ └──────────┘  │ ││
│  │  └───────────────────────────────────────────────────────┘ ││
│  │                            │                                ││
│  │  ┌─────────────────────────┴─────────────────────────────┐ ││
│  │  │                  Data Services                         │ ││
│  │  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐  │ ││
│  │  │  │ Terrain  │ │ Building │ │ Spatial  │ │  Cache   │  │ ││
│  │  │  │ Service  │ │ Service  │ │  Index   │ │ Service  │  │ ││
│  │  │  └──────────┘ └──────────┘ └──────────┘ └──────────┘  │ ││
│  │  └───────────────────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

📁 New Project Structure

rfcp/
├── backend/
│   ├── app/
│   │   ├── __init__.py
│   │   ├── main.py                 # FastAPI app + WebSocket
│   │   ├── config.py               # Configuration management
│   │   │
│   │   ├── api/
│   │   │   ├── __init__.py
│   │   │   ├── routes/
│   │   │   │   ├── coverage.py     # REST endpoints
│   │   │   │   ├── terrain.py      # Terrain/elevation API
│   │   │   │   ├── system.py       # Health, info, shutdown
│   │   │   │   └── sites.py        # Site management
│   │   │   └── websocket.py        # WebSocket handlers
│   │   │
│   │   ├── core/                   # Core business logic
│   │   │   ├── __init__.py
│   │   │   ├── engine.py           # CoverageEngine orchestrator
│   │   │   ├── grid.py             # Grid generation
│   │   │   ├── calculator.py       # Point calculation coordinator
│   │   │   └── result.py           # Result aggregation
│   │   │
│   │   ├── models/                 # Propagation models
│   │   │   ├── __init__.py
│   │   │   ├── base.py             # Abstract PropagationModel
│   │   │   ├── free_space.py       # Free space path loss
│   │   │   ├── okumura_hata.py     # Okumura-Hata (150-1500 MHz)
│   │   │   ├── cost231_hata.py     # COST-231 Hata (1500-2000 MHz)
│   │   │   ├── cost231_wi.py       # COST-231 Walfisch-Ikegami
│   │   │   ├── itu_r_p1546.py      # ITU-R P.1546 (30-3000 MHz)
│   │   │   ├── itu_r_p526.py       # Diffraction (knife-edge)
│   │   │   ├── longley_rice.py     # Irregular Terrain Model
│   │   │   └── uhf_vhf.py          # UHF/VHF specific models
│   │   │
│   │   ├── services/               # Data services
│   │   │   ├── __init__.py
│   │   │   ├── terrain.py          # SRTM terrain data
│   │   │   ├── buildings.py        # OSM buildings
│   │   │   ├── spatial_index.py    # R-tree spatial queries
│   │   │   ├── cache.py            # Unified cache management
│   │   │   └── osm_client.py       # OSM API client
│   │   │
│   │   ├── parallel/               # Parallel processing
│   │   │   ├── __init__.py
│   │   │   ├── manager.py          # Shared memory manager
│   │   │   ├── worker.py           # Worker process logic
│   │   │   └── pool.py             # Process pool with cleanup
│   │   │
│   │   ├── geometry/               # Geometry operations
│   │   │   ├── __init__.py
│   │   │   ├── haversine.py        # Distance calculations
│   │   │   ├── los.py              # Line of sight checks
│   │   │   ├── intersection.py     # Line-polygon intersection
│   │   │   ├── diffraction.py      # Knife-edge diffraction
│   │   │   └── reflection.py       # Reflection calculations
│   │   │
│   │   └── utils/
│   │       ├── __init__.py
│   │       ├── logging.py          # Structured logging
│   │       ├── progress.py         # Progress reporting
│   │       └── units.py            # Unit conversions
│   │
│   ├── tests/                      # Unit tests
│   │   ├── test_models/
│   │   ├── test_services/
│   │   ├── test_geometry/
│   │   └── test_integration/
│   │
│   └── requirements.txt
│
├── frontend/
│   ├── src/
│   │   ├── App.tsx
│   │   ├── components/
│   │   │   ├── map/
│   │   │   │   ├── Map.tsx
│   │   │   │   ├── CoverageLayer.tsx
│   │   │   │   ├── ElevationLayer.tsx
│   │   │   │   └── SiteMarker.tsx
│   │   │   ├── panels/
│   │   │   │   ├── SitePanel.tsx
│   │   │   │   ├── SettingsPanel.tsx
│   │   │   │   ├── LTE_Panel.tsx       # LTE band settings
│   │   │   │   └── UHF_VHF_Panel.tsx   # NEW: UHF/VHF settings
│   │   │   └── common/
│   │   │       ├── LoadingOverlay.tsx
│   │   │       └── ProgressBar.tsx
│   │   │
│   │   ├── hooks/
│   │   │   ├── useWebSocket.ts     # WebSocket connection
│   │   │   ├── useCoverage.ts      # Coverage state
│   │   │   └── useProgress.ts      # Progress tracking
│   │   │
│   │   ├── services/
│   │   │   ├── api.ts              # REST API client
│   │   │   └── websocket.ts        # WebSocket client
│   │   │
│   │   └── store/
│   │       ├── sites.ts
│   │       ├── settings.ts
│   │       └── coverage.ts
│   │
│   └── package.json
│
├── desktop/
│   ├── main.js                     # Electron main process
│   ├── preload.js                  # Preload scripts
│   └── package.json
│
└── installer/
    ├── build.bat
    ├── rfcp-server.spec
    └── scripts/

🔧 Core Components Specification

1. CoverageEngine (Orchestrator)

# backend/app/core/engine.py

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional, AsyncIterator
from enum import Enum

class BandType(Enum):
    LTE = "lte"           # 700-2600 MHz
    UHF = "uhf"           # 400-520 MHz
    VHF = "vhf"           # 136-174 MHz
    CUSTOM = "custom"     # User-defined

class PresetType(Enum):
    FAST = "fast"         # Terrain only
    STANDARD = "standard" # + Buildings
    DETAILED = "detailed" # + Reflections/Diffraction

@dataclass
class Site:
    id: str
    lat: float
    lon: float
    height: float         # meters AGL
    power: float          # dBm
    gain: float           # dBi
    frequency: float      # MHz
    band_type: BandType
    azimuth: float = 0    # degrees
    beamwidth: float = 360
    tilt: float = 0       # electrical downtilt

@dataclass  
class CoverageSettings:
    radius: float         # meters
    resolution: float     # meters
    min_signal: float     # dBm
    preset: PresetType
    band_type: BandType
    
    # Model-specific
    environment: str = "urban"  # urban, suburban, rural, open
    terrain_enabled: bool = True
    buildings_enabled: bool = True
    diffraction_enabled: bool = True
    reflection_enabled: bool = False  # OFF by default now!

@dataclass
class PointResult:
    lat: float
    lon: float
    rsrp: float           # dBm
    distance: float       # meters
    path_loss: float      # dB
    terrain_loss: float   # dB
    building_loss: float  # dB
    diffraction_loss: float  # dB
    has_los: bool
    model_used: str

@dataclass
class CoverageResult:
    points: List[PointResult]
    stats: dict
    computation_time: float
    models_used: List[str]


class CoverageEngine:
    """
    Main orchestrator for coverage calculations.
    
    Responsibilities:
    - Coordinate data loading
    - Select appropriate propagation model
    - Manage parallel computation
    - Aggregate results
    
    Does NOT:
    - Implement propagation physics (delegated to models)
    - Handle HTTP/WebSocket (delegated to API layer)
    - Manage caching (delegated to services)
    """
    
    def __init__(
        self,
        terrain_service: TerrainService,
        building_service: BuildingService,
        cache_service: CacheService,
    ):
        self.terrain = terrain_service
        self.buildings = building_service
        self.cache = cache_service
        self._models = self._init_models()
    
    def _init_models(self) -> dict:
        """Initialize available propagation models."""
        return {
            # LTE bands (700-2600 MHz)
            (BandType.LTE, "urban"): Cost231HataModel(),
            (BandType.LTE, "suburban"): OkumuraHataModel(),
            (BandType.LTE, "rural"): OkumuraHataModel(),
            
            # UHF (400-520 MHz)
            (BandType.UHF, "urban"): OkumuraHataModel(),
            (BandType.UHF, "rural"): LongleyRiceModel(),
            
            # VHF (136-174 MHz)
            (BandType.VHF, "urban"): ITUR_P1546Model(),
            (BandType.VHF, "rural"): LongleyRiceModel(),
        }
    
    def select_model(self, band: BandType, environment: str) -> PropagationModel:
        """Select best propagation model for given band and environment."""
        key = (band, environment)
        if key in self._models:
            return self._models[key]
        # Fallback
        return OkumuraHataModel()
    
    async def calculate(
        self,
        sites: List[Site],
        settings: CoverageSettings,
        progress_callback: Optional[callable] = None
    ) -> CoverageResult:
        """
        Main calculation entry point.
        
        Steps:
        1. Generate grid
        2. Load terrain data
        3. Load building data (if needed)
        4. Select propagation model
        5. Calculate points (parallel)
        6. Aggregate results
        """
        start_time = time.time()
        
        # Step 1: Generate grid
        grid = GridService.generate(
            sites=sites,
            radius=settings.radius,
            resolution=settings.resolution
        )
        
        if progress_callback:
            await progress_callback(phase="grid", progress=0.05)
        
        # Step 2: Load terrain
        bbox = grid.bounding_box
        terrain_data = await self.terrain.load_region(bbox)
        
        if progress_callback:
            await progress_callback(phase="terrain", progress=0.15)
        
        # Step 3: Load buildings (if needed)
        building_data = None
        if settings.buildings_enabled and settings.preset != PresetType.FAST:
            building_data = await self.buildings.load_region(bbox)
        
        if progress_callback:
            await progress_callback(phase="buildings", progress=0.25)
        
        # Step 4: Select model
        model = self.select_model(settings.band_type, settings.environment)
        
        # Step 5: Calculate (parallel)
        calculator = PointCalculator(
            model=model,
            terrain=terrain_data,
            buildings=building_data,
            settings=settings
        )
        
        results = await calculator.calculate_parallel(
            sites=sites,
            points=grid.points,
            progress_callback=progress_callback
        )
        
        # Step 6: Aggregate
        return CoverageResult(
            points=results,
            stats=self._compute_stats(results),
            computation_time=time.time() - start_time,
            models_used=[model.name]
        )
    
    def _compute_stats(self, results: List[PointResult]) -> dict:
        """Compute coverage statistics."""
        rsrp_values = [r.rsrp for r in results]
        return {
            "min_rsrp": min(rsrp_values),
            "max_rsrp": max(rsrp_values),
            "avg_rsrp": sum(rsrp_values) / len(rsrp_values),
            "los_percentage": sum(1 for r in results if r.has_los) / len(results) * 100,
            "total_points": len(results)
        }

2. Propagation Models (Clean Interface)

# backend/app/models/base.py

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional

@dataclass
class PropagationInput:
    """Input for propagation calculation."""
    frequency_mhz: float
    distance_m: float
    tx_height_m: float
    rx_height_m: float
    environment: str  # urban, suburban, rural, open
    
    # Optional terrain info
    terrain_clearance_m: Optional[float] = None
    terrain_roughness_m: Optional[float] = None
    
    # Optional building info
    building_height_m: Optional[float] = None
    street_width_m: Optional[float] = None
    building_separation_m: Optional[float] = None

@dataclass
class PropagationOutput:
    """Output from propagation calculation."""
    path_loss_db: float
    model_name: str
    is_los: bool
    breakdown: dict  # Detailed loss components


class PropagationModel(ABC):
    """
    Abstract base class for all propagation models.
    
    Each model implements a single, well-defined propagation algorithm.
    Models are stateless and can be called concurrently.
    """
    
    @property
    @abstractmethod
    def name(self) -> str:
        """Model name for logging/display."""
        pass
    
    @property
    @abstractmethod
    def frequency_range(self) -> tuple:
        """Valid frequency range (min_mhz, max_mhz)."""
        pass
    
    @property
    @abstractmethod
    def distance_range(self) -> tuple:
        """Valid distance range (min_m, max_m)."""
        pass
    
    @abstractmethod
    def calculate(self, input: PropagationInput) -> PropagationOutput:
        """
        Calculate path loss for given input.
        
        This method MUST be:
        - Stateless (no side effects)
        - Thread-safe (can be called concurrently)
        - Fast (no I/O, no heavy computation)
        """
        pass
    
    def is_valid_for(self, input: PropagationInput) -> bool:
        """Check if this model is valid for given input."""
        freq_min, freq_max = self.frequency_range
        dist_min, dist_max = self.distance_range
        
        return (
            freq_min <= input.frequency_mhz <= freq_max and
            dist_min <= input.distance_m <= dist_max
        )


# backend/app/models/okumura_hata.py

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


# backend/app/models/free_space.py

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


# backend/app/models/cost231_hata.py

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

3. UHF/VHF Specific Models

# backend/app/models/uhf_vhf.py

"""
UHF/VHF Propagation Models

UHF (Ultra High Frequency): 300-3000 MHz, typically 400-520 MHz for radio
VHF (Very High Frequency): 30-300 MHz, typically 136-174 MHz for radio

Key differences from LTE:
1. Lower frequency = better penetration, longer range
2. Terrain diffraction more important than building reflection
3. Tropospheric effects at longer ranges
4. Different antenna characteristics
"""

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


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


class KnifeEdgeDiffractionModel:
    """
    Single knife-edge diffraction model.
    
    Used for calculating loss when terrain blocks LOS.
    
    Reference: ITU-R P.526
    """
    
    @staticmethod
    def calculate_loss(
        d1_m: float,      # Distance TX to obstacle
        d2_m: float,      # Distance obstacle to RX
        h_m: float,       # Obstacle height above LOS
        wavelength_m: float
    ) -> float:
        """
        Calculate diffraction loss over single knife edge.
        
        Returns:
            Loss in dB (always positive or zero)
        """
        # Fresnel-Kirchhoff parameter
        v = h_m * math.sqrt(2 * (d1_m + d2_m) / (wavelength_m * d1_m * d2_m))
        
        # Diffraction loss (Lee approximation)
        if v < -0.78:
            # Well below LOS
            L = 0
        elif v < 0:
            # Below LOS
            L = 6.02 + 9.11 * v - 1.27 * v**2
        elif v < 2.4:
            # Above LOS
            L = 6.02 + 9.11 * v + 1.65 * v**2
        else:
            # Deep shadow
            L = 12.95 + 20 * math.log10(v)
        
        return max(0, L)

4. Shared Memory Architecture

# backend/app/parallel/manager.py

"""
Shared Memory Manager for parallel processing.

Key insight: Instead of copying 350k buildings to each worker,
we store data in shared memory that all workers can READ.

Memory layout:
┌─────────────────────────────────────────────┐
│           Shared Memory Block               │
├─────────────────────────────────────────────┤
│  Terrain Data (SRTM tiles)                  │
│    - Heights as flat array                  │
│    - Tile metadata (bounds, resolution)     │
├─────────────────────────────────────────────┤
│  Building Data                              │
│    - Centroids (lat, lon) as arrays         │
│    - Heights as array                       │
│    - Polygon vertices (flattened)           │
│    - Polygon offsets (to reconstruct)       │
├─────────────────────────────────────────────┤
│  Spatial Index                              │
│    - Grid cell assignments                  │
│    - Building IDs per cell                  │
└─────────────────────────────────────────────┘
import multiprocessing.shared_memory as shm
import numpy as np
from dataclasses import dataclass
from typing import Optional
import struct

@dataclass
class SharedTerrainData:
    """Terrain data in shared memory."""
    shm_name: str
    shape: tuple  # (rows, cols)
    bounds: tuple  # (min_lat, min_lon, max_lat, max_lon)
    resolution: float  # arc-seconds
    
    def get_array(self) -> np.ndarray:
        """Attach to shared memory and return numpy array."""
        existing_shm = shm.SharedMemory(name=self.shm_name)
        return np.ndarray(self.shape, dtype=np.int16, buffer=existing_shm.buf)

@dataclass 
class SharedBuildingData:
    """Building data in shared memory."""
    shm_centroids_name: str      # (N, 2) float64 - lat, lon
    shm_heights_name: str        # (N,) float32 - heights
    shm_vertices_name: str       # (total_verts, 2) float64 - all vertices
    shm_offsets_name: str        # (N+1,) int32 - where each polygon starts
    count: int
    
    def get_centroids(self) -> np.ndarray:
        existing = shm.SharedMemory(name=self.shm_centroids_name)
        return np.ndarray((self.count, 2), dtype=np.float64, buffer=existing.buf)
    
    def get_polygon(self, idx: int) -> np.ndarray:
        """Get polygon vertices for building idx."""
        offsets = self._get_offsets()
        vertices = self._get_vertices()
        start, end = offsets[idx], offsets[idx + 1]
        return vertices[start:end]


class SharedMemoryManager:
    """
    Manages shared memory blocks for parallel processing.
    
    Usage:
        # In main process
        manager = SharedMemoryManager()
        terrain_ref = manager.store_terrain(terrain_data)
        buildings_ref = manager.store_buildings(buildings)
        
        # Pass references to workers
        pool.map(worker_func, points, terrain_ref, buildings_ref)
        
        # Workers attach to shared memory
        terrain = terrain_ref.get_array()  # No copy!
        
        # Cleanup
        manager.cleanup()
    """
    
    def __init__(self):
        self._shm_blocks: list = []
    
    def store_terrain(self, heights: np.ndarray, bounds: tuple, resolution: float) -> SharedTerrainData:
        """Store terrain heights in shared memory."""
        # Create shared memory block
        shm_block = shm.SharedMemory(create=True, size=heights.nbytes)
        self._shm_blocks.append(shm_block)
        
        # Copy data to shared memory
        shm_array = np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_block.buf)
        shm_array[:] = heights[:]
        
        return SharedTerrainData(
            shm_name=shm_block.name,
            shape=heights.shape,
            bounds=bounds,
            resolution=resolution
        )
    
    def store_buildings(self, buildings: list) -> SharedBuildingData:
        """Store building data in shared memory."""
        n = len(buildings)
        
        # Extract centroids
        centroids = np.array([
            [b['centroid_lat'], b['centroid_lon']] 
            for b in buildings
        ], dtype=np.float64)
        
        # Extract heights
        heights = np.array([
            b.get('height', 10.0) for b in buildings
        ], dtype=np.float32)
        
        # Flatten all polygon vertices
        all_vertices = []
        offsets = [0]
        
        for b in buildings:
            coords = b.get('geometry', {}).get('coordinates', [[]])[0]
            for lon, lat in coords:
                all_vertices.append([lat, lon])
            offsets.append(len(all_vertices))
        
        vertices = np.array(all_vertices, dtype=np.float64)
        offsets = np.array(offsets, dtype=np.int32)
        
        # Create shared memory blocks
        shm_centroids = shm.SharedMemory(create=True, size=centroids.nbytes)
        shm_heights = shm.SharedMemory(create=True, size=heights.nbytes)
        shm_vertices = shm.SharedMemory(create=True, size=vertices.nbytes)
        shm_offsets = shm.SharedMemory(create=True, size=offsets.nbytes)
        
        self._shm_blocks.extend([shm_centroids, shm_heights, shm_vertices, shm_offsets])
        
        # Copy data
        np.ndarray(centroids.shape, dtype=centroids.dtype, buffer=shm_centroids.buf)[:] = centroids
        np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_heights.buf)[:] = heights
        np.ndarray(vertices.shape, dtype=vertices.dtype, buffer=shm_vertices.buf)[:] = vertices
        np.ndarray(offsets.shape, dtype=offsets.dtype, buffer=shm_offsets.buf)[:] = offsets
        
        return SharedBuildingData(
            shm_centroids_name=shm_centroids.name,
            shm_heights_name=shm_heights.name,
            shm_vertices_name=shm_vertices.name,
            shm_offsets_name=shm_offsets.name,
            count=n
        )
    
    def cleanup(self):
        """Release all shared memory blocks."""
        for block in self._shm_blocks:
            try:
                block.close()
                block.unlink()
            except Exception:
                pass
        self._shm_blocks.clear()

5. WebSocket Communication

# backend/app/api/websocket.py

from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, Set
import asyncio
import json

class ConnectionManager:
    """Manage WebSocket connections."""
    
    def __init__(self):
        self.active_connections: Set[WebSocket] = set()
        self._calculation_tasks: Dict[str, asyncio.Task] = {}
    
    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.add(websocket)
    
    def disconnect(self, websocket: WebSocket):
        self.active_connections.discard(websocket)
    
    async def broadcast(self, message: dict):
        """Send message to all connected clients."""
        for connection in self.active_connections:
            try:
                await connection.send_json(message)
            except Exception:
                pass
    
    async def send_progress(self, calc_id: str, phase: str, progress: float, eta: float = None):
        """Send calculation progress update."""
        await self.broadcast({
            "type": "progress",
            "calculation_id": calc_id,
            "phase": phase,
            "progress": progress,
            "eta_seconds": eta
        })
    
    async def send_result(self, calc_id: str, result: dict):
        """Send calculation result."""
        await self.broadcast({
            "type": "result",
            "calculation_id": calc_id,
            "data": result
        })
    
    async def send_error(self, calc_id: str, error: str):
        """Send error message."""
        await self.broadcast({
            "type": "error",
            "calculation_id": calc_id,
            "message": error
        })


manager = ConnectionManager()


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    
    try:
        while True:
            data = await websocket.receive_json()
            
            if data["type"] == "calculate":
                # Start calculation
                calc_id = data.get("id", str(uuid.uuid4()))
                
                async def progress_callback(phase: str, progress: float, eta: float = None):
                    await manager.send_progress(calc_id, phase, progress, eta)
                
                try:
                    result = await engine.calculate(
                        sites=data["sites"],
                        settings=CoverageSettings(**data["settings"]),
                        progress_callback=progress_callback
                    )
                    await manager.send_result(calc_id, result.to_dict())
                    
                except Exception as e:
                    await manager.send_error(calc_id, str(e))
            
            elif data["type"] == "cancel":
                # Cancel calculation
                calc_id = data.get("id")
                if calc_id in manager._calculation_tasks:
                    manager._calculation_tasks[calc_id].cancel()
            
            elif data["type"] == "ping":
                await websocket.send_json({"type": "pong"})
    
    except WebSocketDisconnect:
        manager.disconnect(websocket)
// frontend/src/hooks/useWebSocket.ts

import { useEffect, useRef, useState, useCallback } from 'react';

interface ProgressUpdate {
  type: 'progress';
  calculation_id: string;
  phase: string;
  progress: number;
  eta_seconds?: number;
}

interface ResultUpdate {
  type: 'result';
  calculation_id: string;
  data: CoverageResult;
}

interface ErrorUpdate {
  type: 'error';
  calculation_id: string;
  message: string;
}

type WSMessage = ProgressUpdate | ResultUpdate | ErrorUpdate;

export function useWebSocket() {
  const ws = useRef<WebSocket | null>(null);
  const [connected, setConnected] = useState(false);
  const [progress, setProgress] = useState<ProgressUpdate | null>(null);
  
  const callbacks = useRef<{
    onResult?: (result: CoverageResult) => void;
    onError?: (error: string) => void;
  }>({});
  
  useEffect(() => {
    const connect = () => {
      ws.current = new WebSocket('ws://127.0.0.1:8888/ws');
      
      ws.current.onopen = () => {
        setConnected(true);
        console.log('[WS] Connected');
      };
      
      ws.current.onclose = () => {
        setConnected(false);
        console.log('[WS] Disconnected, reconnecting...');
        setTimeout(connect, 2000);
      };
      
      ws.current.onmessage = (event) => {
        const msg: WSMessage = JSON.parse(event.data);
        
        switch (msg.type) {
          case 'progress':
            setProgress(msg);
            break;
          case 'result':
            callbacks.current.onResult?.(msg.data);
            setProgress(null);
            break;
          case 'error':
            callbacks.current.onError?.(msg.message);
            setProgress(null);
            break;
        }
      };
    };
    
    connect();
    
    return () => {
      ws.current?.close();
    };
  }, []);
  
  const calculate = useCallback((
    sites: Site[],
    settings: CoverageSettings,
    onResult: (result: CoverageResult) => void,
    onError: (error: string) => void
  ) => {
    if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
      onError('WebSocket not connected');
      return;
    }
    
    const calcId = crypto.randomUUID();
    callbacks.current = { onResult, onError };
    
    ws.current.send(JSON.stringify({
      type: 'calculate',
      id: calcId,
      sites,
      settings
    }));
    
    return calcId;
  }, []);
  
  const cancel = useCallback((calcId: string) => {
    ws.current?.send(JSON.stringify({
      type: 'cancel',
      id: calcId
    }));
  }, []);
  
  return { connected, progress, calculate, cancel };
}

6. Clean Electron Lifecycle

// desktop/main.js

const { app, BrowserWindow } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const http = require('http');

let mainWindow = null;
let backendProcess = null;
let isQuitting = false;

// Configuration
const CONFIG = {
  backendHost: '127.0.0.1',
  backendPort: 8888,
  healthCheckInterval: 1000,
  shutdownTimeout: 5000,
};

// ==================== Backend Management ====================

function getBackendPath() {
  if (app.isPackaged) {
    return path.join(process.resourcesPath, 'rfcp-server.exe');
  }
  return path.join(__dirname, '..', 'installer', 'dist', 'rfcp-server.exe');
}

async function startBackend() {
  const exePath = getBackendPath();
  
  console.log('[Backend] Starting:', exePath);
  
  backendProcess = spawn(exePath, [], {
    stdio: ['ignore', 'pipe', 'pipe'],
    env: {
      ...process.env,
      RFCP_HOST: CONFIG.backendHost,
      RFCP_PORT: String(CONFIG.backendPort),
    },
    windowsHide: true,
  });
  
  backendProcess.stdout.on('data', (data) => {
    console.log('[Backend]', data.toString().trim());
  });
  
  backendProcess.stderr.on('data', (data) => {
    console.error('[Backend Error]', data.toString().trim());
  });
  
  backendProcess.on('exit', (code) => {
    console.log('[Backend] Exited with code:', code);
    backendProcess = null;
    
    if (!isQuitting) {
      console.log('[Backend] Unexpected exit, restarting...');
      setTimeout(startBackend, 1000);
    }
  });
  
  // Wait for backend to be ready
  await waitForBackend();
}

async function waitForBackend(maxAttempts = 30) {
  for (let i = 0; i < maxAttempts; i++) {
    try {
      await checkHealth();
      console.log('[Backend] Ready!');
      return;
    } catch {
      await sleep(500);
    }
  }
  throw new Error('Backend failed to start');
}

function checkHealth() {
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: CONFIG.backendHost,
      port: CONFIG.backendPort,
      path: '/api/health',
      method: 'GET',
      timeout: 1000,
    }, (res) => {
      if (res.statusCode === 200) resolve();
      else reject(new Error(`Health check failed: ${res.statusCode}`));
    });
    
    req.on('error', reject);
    req.on('timeout', () => reject(new Error('Timeout')));
    req.end();
  });
}

async function stopBackend() {
  if (!backendProcess) return;
  
  console.log('[Backend] Stopping...');
  
  // Step 1: Request graceful shutdown
  try {
    await fetch(`http://${CONFIG.backendHost}:${CONFIG.backendPort}/api/system/shutdown`, {
      method: 'POST',
      signal: AbortSignal.timeout(2000),
    });
    console.log('[Backend] Shutdown requested');
  } catch {
    console.log('[Backend] Shutdown request failed, force killing');
  }
  
  // Step 2: Wait for process to exit
  const exitPromise = new Promise((resolve) => {
    if (!backendProcess) {
      resolve();
      return;
    }
    
    backendProcess.once('exit', resolve);
    setTimeout(resolve, CONFIG.shutdownTimeout);
  });
  
  // Step 3: Force kill if still running
  if (backendProcess) {
    backendProcess.kill('SIGTERM');
    await exitPromise;
    
    if (backendProcess) {
      backendProcess.kill('SIGKILL');
    }
  }
  
  console.log('[Backend] Stopped');
}

// ==================== Window Management ====================

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1400,
    height: 900,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
    },
    icon: path.join(__dirname, 'icon.png'),
    title: 'RFCP - RF Coverage Planner',
  });
  
  // Load frontend
  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:5173');
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, '..', 'frontend', 'dist', 'index.html'));
  }
  
  // Handle close
  mainWindow.on('close', async (event) => {
    if (!isQuitting) {
      event.preventDefault();
      await quit();
    }
  });
  
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}

// ==================== App Lifecycle ====================

async function quit() {
  if (isQuitting) return;
  isQuitting = true;
  
  console.log('[App] Quitting...');
  
  await stopBackend();
  
  if (mainWindow) {
    mainWindow.destroy();
  }
  
  app.quit();
}

// App ready
app.whenReady().then(async () => {
  try {
    await startBackend();
    createWindow();
  } catch (error) {
    console.error('[App] Failed to start:', error);
    app.quit();
  }
});

// All windows closed
app.on('window-all-closed', () => {
  quit();
});

// Before quit
app.on('before-quit', (event) => {
  if (!isQuitting) {
    event.preventDefault();
    quit();
  }
});

// macOS activate
app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// Signals
process.on('SIGINT', quit);
process.on('SIGTERM', quit);

// ==================== Utilities ====================

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

📊 Performance Targets

Metric Current Target Improvement
Fast preset (2km) 0.03s 0.03s Same
Standard preset (5km) 38s 20s 2x
Detailed preset (5km) 300s (timeout) 45s 6x
Memory peak 8 GB 1.5 GB 5x
App close Broken Works
UHF/VHF support No Yes New

🗓️ Implementation Phases

Phase 3.1: Core Infrastructure (Week 1)

  • New project structure
  • PropagationModel interface + FreeSpace, OkumuraHata
  • TerrainService (port existing SRTM code)
  • Basic CoverageEngine (single-threaded)
  • Unit tests for models

Phase 3.2: Data Services (Week 2)

  • BuildingService (port existing OSM code)
  • SpatialIndex service
  • CacheService (unified caching)
  • SharedMemoryManager
  • Integration tests

Phase 3.3: Parallel Processing (Week 3)

  • PointCalculator with shared memory
  • Worker pool with clean lifecycle
  • Progress reporting
  • Cancellation support
  • Performance benchmarks

Phase 3.4: API + Frontend (Week 4)

  • WebSocket API
  • useWebSocket hook
  • Progress UI
  • UHF/VHF tab
  • Electron cleanup

Phase 3.5: Polish + Testing (Week 5)

  • End-to-end tests
  • Documentation
  • PyInstaller build
  • Performance validation
  • Release

🧪 Testing Strategy

tests/
├── unit/
│   ├── test_models/
│   │   ├── test_free_space.py
│   │   ├── test_okumura_hata.py
│   │   └── test_cost231.py
│   ├── test_geometry/
│   │   ├── test_haversine.py
│   │   └── test_intersection.py
│   └── test_services/
│       ├── test_terrain.py
│       └── test_buildings.py
│
├── integration/
│   ├── test_engine.py
│   ├── test_parallel.py
│   └── test_websocket.py
│
└── e2e/
    ├── test_coverage_calculation.py
    └── test_app_lifecycle.py

Test coverage targets:

  • Models: 100% (pure functions, easy to test)
  • Services: 90%
  • Engine: 85%
  • API: 80%

📝 Migration Guide

What to Keep (copy directly):

  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?