Files
rfcp/RFCP-Iteration-3.3.0-Architecture-Refactor.md
2026-02-02 13:48:30 +02:00

724 lines
22 KiB
Markdown

# RFCP - Iteration 3.3.0: Performance Architecture Refactor
## Overview
Major refactoring based on research into professional RF tools (Signal-Server, SPLAT!, CloudRF SLEIPNIR, Sionna RT).
**Root cause identified:** Pickle serialization overhead dominates computation time.
- DP_TIMING shows: 0.6-0.9ms per point (actual calculation)
- Real throughput: 258ms per point
- **99% of time is IPC overhead, not calculation!**
**Target:** Reduce 5km Detailed from timeout (300s) to <30s
---
## Part 1: Eliminate Pickle Overhead (CRITICAL)
### 1.1 Shared Memory for Buildings
Currently terrain is in shared memory, but **15,000 buildings are pickled for every chunk**.
**File:** `backend/app/services/parallel_coverage_service.py`
```python
from multiprocessing import shared_memory
import numpy as np
def buildings_to_shared_memory(buildings: list) -> tuple:
"""
Convert buildings to numpy arrays and store in shared memory.
Returns: (shm_name, shape, dtype) for reconstruction in workers
"""
# Extract building data into numpy arrays
# For each building we need: lat, lon, height, num_vertices, vertices_flat
# Simplified: store as structured array
building_data = []
all_vertices = []
vertex_offsets = [0]
for b in buildings:
coords = extract_coords(b)
height = b.get('properties', {}).get('height', 10.0)
building_data.append({
'lat': np.mean([c[1] for c in coords]),
'lon': np.mean([c[0] for c in coords]),
'height': height,
'vertex_start': len(all_vertices),
'vertex_count': len(coords)
})
all_vertices.extend(coords)
vertex_offsets.append(len(all_vertices))
# Create numpy arrays
buildings_arr = np.array([
(b['lat'], b['lon'], b['height'], b['vertex_start'], b['vertex_count'])
for b in building_data
], dtype=[
('lat', 'f8'), ('lon', 'f8'), ('height', 'f4'),
('vertex_start', 'i4'), ('vertex_count', 'i2')
])
vertices_arr = np.array(all_vertices, dtype=[('lon', 'f8'), ('lat', 'f8')])
# Store in shared memory
shm_buildings = shared_memory.SharedMemory(
create=True,
size=buildings_arr.nbytes,
name=f"rfcp_buildings_{os.getpid()}"
)
shm_vertices = shared_memory.SharedMemory(
create=True,
size=vertices_arr.nbytes,
name=f"rfcp_vertices_{os.getpid()}"
)
# Copy data
np.ndarray(buildings_arr.shape, dtype=buildings_arr.dtype,
buffer=shm_buildings.buf)[:] = buildings_arr
np.ndarray(vertices_arr.shape, dtype=vertices_arr.dtype,
buffer=shm_vertices.buf)[:] = vertices_arr
return {
'buildings': (shm_buildings.name, buildings_arr.shape, buildings_arr.dtype),
'vertices': (shm_vertices.name, vertices_arr.shape, vertices_arr.dtype)
}
def buildings_from_shared_memory(shm_info: dict) -> tuple:
"""Reconstruct buildings arrays from shared memory in worker."""
shm_b = shared_memory.SharedMemory(name=shm_info['buildings'][0])
shm_v = shared_memory.SharedMemory(name=shm_info['vertices'][0])
buildings = np.ndarray(
shm_info['buildings'][1],
dtype=shm_info['buildings'][2],
buffer=shm_b.buf
)
vertices = np.ndarray(
shm_info['vertices'][1],
dtype=shm_info['vertices'][2],
buffer=shm_v.buf
)
return buildings, vertices, shm_b, shm_v
```
### 1.2 Increase Batch Size
**Current:** 7 chunks of ~144 points = high IPC overhead per point
**Target:** 2-3 chunks of ~300-400 points = amortize IPC cost
```python
# In parallel_coverage_service.py
def calculate_optimal_chunk_size(total_points: int, num_workers: int) -> int:
"""
Calculate chunk size to minimize IPC overhead.
Rule: computation_time should be 10-100x serialization_time
For RF calculations: ~1ms compute, ~50ms serialize
So batch at least 500 points to make compute dominate.
"""
min_chunk = 300 # Minimum to amortize IPC
max_chunk = 1000 # Maximum for memory
ideal_chunks = max(2, num_workers) # At least 2 chunks per worker
ideal_size = total_points // ideal_chunks
return max(min_chunk, min(max_chunk, ideal_size))
```
### 1.3 Pre-build Spatial Index Once
Currently spatial index may be rebuilt per-chunk. Build once and share reference.
```python
class SharedSpatialIndex:
"""Spatial index that can be shared across processes via shared memory."""
def __init__(self, buildings_shm_info: dict):
self.buildings, self.vertices, _, _ = buildings_from_shared_memory(buildings_shm_info)
self._build_grid()
def _build_grid(self):
"""Build simple grid-based spatial index."""
# Grid cells of ~100m
self.cell_size = 0.001 # ~111m in degrees
self.grid = defaultdict(list)
for i, b in enumerate(self.buildings):
cell_x = int(b['lon'] / self.cell_size)
cell_y = int(b['lat'] / self.cell_size)
self.grid[(cell_x, cell_y)].append(i)
def query_radius(self, lat: float, lon: float, radius_m: float) -> list:
"""Get building indices within radius."""
radius_deg = radius_m / 111000
cells_to_check = int(radius_deg / self.cell_size) + 1
center_x = int(lon / self.cell_size)
center_y = int(lat / self.cell_size)
result = []
for dx in range(-cells_to_check, cells_to_check + 1):
for dy in range(-cells_to_check, cells_to_check + 1):
result.extend(self.grid.get((center_x + dx, center_y + dy), []))
return result
```
---
## Part 2: Radial Calculation Pattern (Signal-Server style)
Instead of grid, calculate along radial spokes for faster coverage estimation.
### 2.1 Radial Engine
**File:** `backend/app/services/radial_coverage_service.py` (NEW)
```python
"""
Radial coverage calculation engine inspired by Signal-Server/SPLAT!
Instead of calculating every grid point independently:
1. Cast rays from TX in all directions (0-360°)
2. Sample terrain along each ray (profile)
3. Apply propagation model to profile
4. Interpolate between rays for final grid
This is 10-50x faster because:
- Terrain profiles are linear (cache-friendly)
- No building geometry per-point (use clutter model)
- Embarrassingly parallel by azimuth
"""
import numpy as np
from concurrent.futures import ThreadPoolExecutor
import math
class RadialCoverageEngine:
def __init__(self, terrain_service, propagation_model):
self.terrain = terrain_service
self.model = propagation_model
def calculate_coverage(
self,
tx_lat: float, tx_lon: float, tx_height: float,
radius_m: float,
frequency_mhz: float,
tx_power_dbm: float,
num_radials: int = 360, # 1° resolution
samples_per_radial: int = 100,
num_threads: int = 8
) -> dict:
"""
Calculate coverage using radial ray-casting.
Returns dict with 'radials' (raw data) and 'grid' (interpolated).
"""
# Pre-load terrain tiles
self._preload_terrain(tx_lat, tx_lon, radius_m)
# Calculate radials in parallel (by azimuth sectors)
sector_size = num_radials // num_threads
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = []
for i in range(num_threads):
start_az = i * sector_size
end_az = (i + 1) * sector_size if i < num_threads - 1 else num_radials
futures.append(executor.submit(
self._calculate_sector,
tx_lat, tx_lon, tx_height,
radius_m, frequency_mhz, tx_power_dbm,
start_az, end_az, samples_per_radial
))
# Collect results
all_radials = []
for f in futures:
all_radials.extend(f.result())
return {
'radials': all_radials,
'center': (tx_lat, tx_lon),
'radius': radius_m,
'num_radials': num_radials
}
def _calculate_sector(
self,
tx_lat, tx_lon, tx_height,
radius_m, frequency_mhz, tx_power_dbm,
start_az, end_az, samples_per_radial
) -> list:
"""Calculate radials for one azimuth sector."""
results = []
for az in range(start_az, end_az):
radial = self._calculate_radial(
tx_lat, tx_lon, tx_height,
radius_m, frequency_mhz, tx_power_dbm,
az, samples_per_radial
)
results.append(radial)
return results
def _calculate_radial(
self,
tx_lat, tx_lon, tx_height,
radius_m, frequency_mhz, tx_power_dbm,
azimuth_deg, num_samples
) -> dict:
"""
Calculate signal strength along one radial.
Uses terrain profile + Longley-Rice style calculation.
"""
az_rad = math.radians(azimuth_deg)
cos_lat = math.cos(math.radians(tx_lat))
# Sample points along radial
distances = np.linspace(100, radius_m, num_samples)
# Calculate lat/lon for each sample
lat_offsets = (distances / 111000) * math.cos(az_rad)
lon_offsets = (distances / (111000 * cos_lat)) * math.sin(az_rad)
lats = tx_lat + lat_offsets
lons = tx_lon + lon_offsets
# Get terrain profile
elevations = np.array([
self.terrain.get_elevation_sync(lat, lon)
for lat, lon in zip(lats, lons)
])
tx_elevation = self.terrain.get_elevation_sync(tx_lat, tx_lon)
# Calculate path loss for each point
rsrp_values = []
los_flags = []
for i, (dist, elev) in enumerate(zip(distances, elevations)):
# Simple LOS check using terrain profile up to this point
profile = elevations[:i+1]
has_los = self._check_los_profile(
tx_elevation + tx_height,
elev + 1.5, # RX height
profile,
distances[:i+1]
)
# Path loss (using configured model)
path_loss = self.model.calculate_path_loss(
frequency_mhz, dist, tx_height, 1.5,
has_los=has_los
)
# Add diffraction loss if NLOS
if not has_los:
diff_loss = self._calculate_diffraction_loss(
tx_elevation + tx_height,
elev + 1.5,
profile,
distances[:i+1],
frequency_mhz
)
path_loss += diff_loss
rsrp = tx_power_dbm - path_loss
rsrp_values.append(rsrp)
los_flags.append(has_los)
return {
'azimuth': azimuth_deg,
'distances': distances.tolist(),
'lats': lats.tolist(),
'lons': lons.tolist(),
'rsrp': rsrp_values,
'has_los': los_flags
}
def _check_los_profile(self, tx_h, rx_h, profile, distances) -> bool:
"""Check LOS using terrain profile (Fresnel zone clearance)."""
if len(profile) < 2:
return True
total_dist = distances[-1]
# Line from TX to RX
for i in range(1, len(profile) - 1):
d = distances[i]
# Expected height on LOS line
expected_h = tx_h + (rx_h - tx_h) * (d / total_dist)
# Actual terrain height
actual_h = profile[i]
if actual_h > expected_h - 0.6: # Small clearance margin
return False
return True
def _calculate_diffraction_loss(self, tx_h, rx_h, profile, distances, freq_mhz) -> float:
"""Calculate diffraction loss using Deygout method."""
# Find main obstacle
max_v = -999
max_idx = -1
total_dist = distances[-1]
wavelength = 300 / freq_mhz # meters
for i in range(1, len(profile) - 1):
d1 = distances[i]
d2 = total_dist - d1
# Height of LOS line at this point
los_h = tx_h + (rx_h - tx_h) * (d1 / total_dist)
# Obstacle height above LOS
h = profile[i] - los_h
if h > 0:
# Fresnel parameter
v = h * math.sqrt(2 * (d1 + d2) / (wavelength * d1 * d2))
if v > max_v:
max_v = v
max_idx = i
if max_v < -0.78:
return 0.0
# Knife-edge diffraction loss (ITU-R P.526)
if max_v < 0:
loss = 6.02 + 9.11 * max_v - 1.27 * max_v * max_v
elif max_v < 2.4:
loss = 6.02 + 9.11 * max_v + 1.65 * max_v * max_v
else:
loss = 12.953 + 20 * math.log10(max_v)
return max(0, loss)
```
---
## Part 3: Propagation Model Updates
### 3.1 Add Longley-Rice ITM Support
**File:** `backend/app/services/propagation_models/itm_model.py` (NEW)
```python
"""
Longley-Rice Irregular Terrain Model (ITM)
Best for: VHF/UHF terrain-based propagation (20 MHz - 20 GHz)
Based on: itmlogic Python package
Key parameters:
- Earth dielectric constant (eps): 4-81 (15 typical for ground)
- Ground conductivity (sgm): 0.001-5.0 S/m
- Atmospheric refractivity (ens): 250-400 N-units (301 typical)
- Climate: 1=Equatorial, 2=Continental Subtropical, etc.
"""
try:
from itmlogic import itmlogic_p2p
HAS_ITMLOGIC = True
except ImportError:
HAS_ITMLOGIC = False
from .base_model import BasePropagationModel, PropagationInput, PropagationResult
class LongleyRiceModel(BasePropagationModel):
"""Longley-Rice ITM propagation model."""
name = "Longley-Rice-ITM"
frequency_range = (20, 20000) # MHz
distance_range = (1000, 2000000) # meters
# Default ITM parameters
DEFAULT_PARAMS = {
'eps': 15.0, # Earth dielectric constant
'sgm': 0.005, # Ground conductivity (S/m)
'ens': 301.0, # Atmospheric refractivity (N-units)
'pol': 0, # Polarization: 0=horizontal, 1=vertical
'mdvar': 12, # Mode of variability
'klim': 5, # Climate: 5=Continental Temperate
}
# Ground parameters by type
GROUND_PARAMS = {
'average': {'eps': 15.0, 'sgm': 0.005},
'poor': {'eps': 4.0, 'sgm': 0.001},
'good': {'eps': 25.0, 'sgm': 0.020},
'fresh_water': {'eps': 81.0, 'sgm': 0.010},
'sea_water': {'eps': 81.0, 'sgm': 5.0},
'forest': {'eps': 12.0, 'sgm': 0.003},
}
def __init__(self, ground_type: str = 'average', climate: int = 5):
if not HAS_ITMLOGIC:
raise ImportError("itmlogic package required: pip install itmlogic")
self.params = self.DEFAULT_PARAMS.copy()
if ground_type in self.GROUND_PARAMS:
self.params.update(self.GROUND_PARAMS[ground_type])
self.params['klim'] = climate
def calculate(self, input: PropagationInput) -> PropagationResult:
"""Calculate path loss using ITM point-to-point mode."""
# ITM requires terrain profile
if not hasattr(input, 'terrain_profile') or input.terrain_profile is None:
# Fallback to free-space if no terrain
return self._free_space_fallback(input)
result = itmlogic_p2p(
input.terrain_profile, # Elevation samples
input.frequency_mhz,
input.tx_height_m,
input.rx_height_m,
self.params['eps'],
self.params['sgm'],
self.params['ens'],
self.params['pol'],
self.params['mdvar'],
self.params['klim']
)
return PropagationResult(
path_loss_db=result['dbloss'],
model_name=self.name,
details={
'mode': result.get('propmode', 'unknown'),
'variability': result.get('var', 0),
}
)
def _free_space_fallback(self, input: PropagationInput) -> PropagationResult:
"""Free-space path loss when no terrain available."""
fspl = 20 * np.log10(input.distance_m) + 20 * np.log10(input.frequency_mhz) - 27.55
return PropagationResult(
path_loss_db=fspl,
model_name=f"{self.name} (FSPL fallback)",
details={'mode': 'free_space'}
)
```
### 3.2 Add VHF/UHF Model Selection
**File:** `backend/app/services/propagation_models/model_selector.py`
```python
"""
Automatic propagation model selection based on frequency and environment.
"""
def select_model_for_frequency(
frequency_mhz: float,
environment: str = 'urban',
has_terrain: bool = True
) -> BasePropagationModel:
"""
Select appropriate propagation model.
Frequency bands:
- VHF: 30-300 MHz (tactical radios, FM broadcast)
- UHF: 300-3000 MHz (tactical radios, TV, early cellular)
- Cellular: 700-2600 MHz (LTE bands)
- mmWave: 24-100 GHz (5G)
Decision tree:
1. VHF/UHF with terrain → Longley-Rice ITM
2. Urban cellular → COST-231 Hata
3. Suburban/rural cellular → Okumura-Hata
4. mmWave → 3GPP 38.901
"""
# VHF (30-300 MHz)
if 30 <= frequency_mhz <= 300:
if has_terrain:
return LongleyRiceModel(ground_type='average', climate=5)
else:
return FreeSpaceModel() # Fallback
# UHF (300-1000 MHz)
elif 300 < frequency_mhz <= 1000:
if has_terrain:
return LongleyRiceModel(ground_type='average', climate=5)
else:
return OkumuraHataModel(environment=environment)
# Cellular (1000-2600 MHz)
elif 1000 < frequency_mhz <= 2600:
if environment == 'urban':
return Cost231HataModel()
else:
return OkumuraHataModel(environment=environment)
# Higher frequencies
else:
return FreeSpaceModel() # Or implement 3GPP 38.901
# Frequency band constants for UI
FREQUENCY_BANDS = {
'VHF_LOW': (30, 88, "VHF Low (30-88 MHz) - Military/Public Safety"),
'VHF_HIGH': (136, 174, "VHF High (136-174 MHz) - Marine/Aviation"),
'UHF_LOW': (400, 512, "UHF (400-512 MHz) - Public Safety/Tactical"),
'UHF_TV': (470, 862, "UHF TV (470-862 MHz)"),
'LTE_700': (700, 800, "LTE Band 28/20 (700-800 MHz)"),
'LTE_900': (880, 960, "LTE Band 8 (900 MHz)"),
'LTE_1800': (1710, 1880, "LTE Band 3 (1800 MHz)"),
'LTE_2100': (1920, 2170, "LTE Band 1 (2100 MHz)"),
'LTE_2600': (2500, 2690, "LTE Band 7 (2600 MHz)"),
}
```
---
## Part 4: Progress Bar Fix (WebSocket)
### 4.1 Proper Progress Streaming
The 5% bug persists because WebSocket messages aren't reaching frontend.
**Debug approach:**
```python
# In coverage calculation, add explicit progress logging
async def calculate_with_progress(self, ...):
total_points = len(points)
for i, chunk_result in enumerate(chunk_results):
progress = int((i + 1) / total_chunks * 100)
# Log to console AND send via WebSocket
logger.info(f"[PROGRESS] {progress}% - chunk {i+1}/{total_chunks}")
if progress_callback:
await progress_callback(progress, f"Calculating... {i+1}/{total_chunks}")
await asyncio.sleep(0) # Yield to event loop
```
**Frontend fix - check WebSocket subscription:**
```typescript
// In App.tsx or CoverageStore
useEffect(() => {
const ws = new WebSocket('ws://localhost:8888/ws/coverage');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('[WS] Received:', data); // DEBUG
if (data.type === 'progress') {
setProgress(data.progress);
setProgressStatus(data.status);
}
};
ws.onerror = (e) => console.error('[WS] Error:', e);
ws.onclose = () => console.log('[WS] Closed');
return () => ws.close();
}, []);
```
---
## Part 5: Testing & Validation
### 5.1 Performance Benchmarks
After refactoring, expected performance:
| Scenario | Before | After | Speedup |
|----------|--------|-------|---------|
| 5km Standard | 5s | 3s | 1.7x |
| 5km Detailed | timeout | 25s | 12x |
| 10km Standard | 30s | 10s | 3x |
| 10km Detailed | timeout | 60s | 5x |
### 5.2 Test Commands
```powershell
# Quick test
cd D:\root\rfcp\installer
.\test-detailed-quick.bat
# Check for [PROGRESS] logs in output
# Check for [DP_TIMING] logs
# Verify shared memory cleanup
# Check Task Manager for memory after calculation
```
---
## Implementation Order
1. **Shared Memory for Buildings** (biggest impact) - Part 1.1
2. **Increase Batch Size** - Part 1.2
3. **Progress Bar Debug** - Part 4
4. **Radial Engine** (optional, for preview mode) - Part 2
5. **Longley-Rice ITM** (for VHF/UHF) - Part 3
---
## Dependencies to Add
```
# requirements.txt additions
itmlogic>=0.1.0 # Longley-Rice ITM implementation
```
---
## Commit Message
```
feat: Iteration 3.3.0 - Performance Architecture Refactor
Performance:
- Add shared memory for buildings (eliminate pickle overhead)
- Increase batch size to 300-500 points (amortize IPC)
- Add radial coverage engine (Signal-Server style)
Propagation Models:
- Add Longley-Rice ITM for VHF/UHF (20 MHz - 20 GHz)
- Add automatic model selection by frequency
- Add frequency band constants for UI
Bug Fixes:
- Debug and fix WebSocket progress (5% stuck bug)
Expected: 5km Detailed from timeout → ~25s (12x speedup)
```
---
## Notes for Claude Code
This is a significant refactoring. Approach step by step:
1. First implement shared memory for buildings
2. Test that alone - should see major speedup
3. Then increase batch size
4. Test again
5. Then tackle progress bar
6. Radial engine and ITM can be separate iterations if needed
The key insight: **99% of time is IPC overhead, not calculation**.
Fixing pickle serialization is the #1 priority.
---
*"Fast per-point means nothing if IPC eats your lunch"* 🍽️