# RFCP Iteration 3.5.0 — GPU Acceleration & UI Polish ## Overview Major performance upgrade with GPU acceleration support and UI/UX improvements. **Key Goals:** - GPU acceleration for 10-50x speedup - Fix timeout for large radius calculations - Terrain Profile Viewer - UI polish and fixes --- ## Phase 1: Critical Fixes ### 1.1 Timeout Fix for Tiled Calculations **Problem:** 5 minute timeout kills calculations > 20km even though they're working correctly. **Solution:** ```python # backend/app/api/websocket.py # Per-tile timeout instead of total timeout TILE_TIMEOUT_SECONDS = 120 # 2 min per tile (generous) TOTAL_TIMEOUT_SECONDS = 1800 # 30 min max for entire calculation # For tiled calculations: if is_tiled_calculation(radius): timeout = TOTAL_TIMEOUT_SECONDS else: timeout = 300 # 5 min for small calculations ``` **Files:** - `backend/app/api/websocket.py` - `backend/app/services/coverage_service.py` --- ### 1.2 Coverage Boundary Fix **Problem:** White dashed boundary doesn't work correctly for multi-site/tiled calculations. **Solution:** 1. Fix boundary to use convex hull of ALL points > threshold 2. Add toggle button in legend: "Show Boundary" 3. Default: OFF (less visual clutter) ```typescript // frontend/src/components/map/HeatmapLegend.tsx // Add toggle const [showBoundary, setShowBoundary] = useState(false); ``` ```typescript // frontend/src/components/map/CoverageBoundary.tsx // Calculate convex hull of all points above threshold function calculateBoundary(points: CoveragePoint[], threshold: number) { const validPoints = points.filter(p => p.rsrp >= threshold); if (validPoints.length < 3) return null; // Use convex hull algorithm (Graham scan or gift wrapping) return convexHull(validPoints.map(p => [p.lat, p.lon])); } ``` **Files:** - `frontend/src/components/map/CoverageBoundary.tsx` - `frontend/src/components/map/HeatmapLegend.tsx` - `frontend/src/store/settings.ts` (add showBoundary state) --- ## Phase 2: GPU Acceleration ### 2.1 GPU Backend Detection **Support hierarchy:** 1. NVIDIA CUDA (fastest) — CuPy 2. OpenCL (any GPU) — PyOpenCL 3. CPU fallback — NumPy ```python # backend/app/services/gpu_backend.py (NEW) from typing import Tuple, Optional from enum import Enum class ComputeBackend(Enum): CUDA = "cuda" OPENCL = "opencl" CPU = "cpu" class GPUManager: _instance = None _backend: ComputeBackend = ComputeBackend.CPU _device_name: str = "CPU (NumPy)" _devices: list = [] @classmethod def detect_backends(cls) -> list: """Detect all available compute backends.""" backends = [] # Check NVIDIA CUDA try: import cupy as cp for i in range(cp.cuda.runtime.getDeviceCount()): device = cp.cuda.Device(i) backends.append({ "type": ComputeBackend.CUDA, "id": i, "name": device.name, "memory": device.mem_info[1], # total memory "priority": 1 # highest }) except Exception: pass # Check OpenCL (AMD, Intel, NVIDIA) try: import pyopencl as cl for platform in cl.get_platforms(): for device in platform.get_devices(): # Skip if already have CUDA version of same GPU if ComputeBackend.CUDA in [b["type"] for b in backends]: if "NVIDIA" in device.name: continue backends.append({ "type": ComputeBackend.OPENCL, "id": f"{platform.name}:{device.name}", "name": device.name, "memory": device.global_mem_size, "priority": 2 }) except Exception: pass # CPU always available import multiprocessing backends.append({ "type": ComputeBackend.CPU, "id": "cpu", "name": f"CPU ({multiprocessing.cpu_count()} cores)", "memory": None, "priority": 3 }) cls._devices = sorted(backends, key=lambda x: x["priority"]) return cls._devices @classmethod def get_devices(cls) -> list: """Get list of available compute devices.""" if not cls._devices: cls.detect_backends() return cls._devices @classmethod def set_backend(cls, backend_type: ComputeBackend, device_id: Optional[str] = None): """Set active compute backend.""" cls._backend = backend_type if backend_type == ComputeBackend.CUDA: import cupy as cp device_idx = int(device_id) if device_id else 0 cp.cuda.Device(device_idx).use() cls._device_name = cp.cuda.Device(device_idx).name elif backend_type == ComputeBackend.OPENCL: # Store for later use in calculations cls._opencl_device_id = device_id cls._device_name = device_id.split(":")[-1] if device_id else "OpenCL" else: cls._device_name = "CPU (NumPy)" @classmethod def get_array_module(cls): """Get numpy-compatible array module for current backend.""" if cls._backend == ComputeBackend.CUDA: import cupy as cp return cp else: import numpy as np return np @classmethod def get_status(cls) -> dict: """Get current GPU status for UI.""" return { "backend": cls._backend.value, "device_name": cls._device_name, "available_devices": cls.get_devices() } ``` ### 2.2 GPU-Accelerated Path Loss Calculation ```python # backend/app/services/propagation_gpu.py (NEW) from .gpu_backend import GPUManager, ComputeBackend def calculate_path_loss_batch_gpu( site_lat: float, site_lon: float, site_height: float, points_lat: np.ndarray, # Can be large array points_lon: np.ndarray, frequency_mhz: float, environment: str = "suburban" ) -> np.ndarray: """ Calculate path loss for ALL points at once using GPU. Returns array of path loss values in dB. """ xp = GPUManager.get_array_module() # numpy or cupy # Transfer to GPU if using CUDA if GPUManager._backend == ComputeBackend.CUDA: lats = xp.asarray(points_lat) lons = xp.asarray(points_lon) else: lats = points_lat lons = points_lon # Vectorized distance calculation (Haversine) R = 6371000 # Earth radius in meters lat1 = xp.radians(site_lat) lat2 = xp.radians(lats) dlat = lat2 - lat1 dlon = xp.radians(lons - site_lon) a = xp.sin(dlat/2)**2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon/2)**2 c = 2 * xp.arctan2(xp.sqrt(a), xp.sqrt(1-a)) distances_m = R * c distances_km = distances_m / 1000.0 # Avoid log(0) distances_km = xp.maximum(distances_km, 0.01) # Okumura-Hata model (vectorized) f = frequency_mhz hb = site_height # Base formula A = 69.55 + 26.16 * xp.log10(f) - 13.82 * xp.log10(hb) B = 44.9 - 6.55 * xp.log10(hb) path_loss = A + B * xp.log10(distances_km) # Environment corrections if environment == "urban": pass # Base formula elif environment == "suburban": path_loss = path_loss - 2 * (xp.log10(f/28))**2 - 5.4 elif environment == "rural": path_loss = path_loss - 4.78 * (xp.log10(f))**2 + 18.33 * xp.log10(f) - 40.94 # Transfer back to CPU if needed if GPUManager._backend == ComputeBackend.CUDA: return path_loss.get() # cupy → numpy return path_loss def calculate_rsrp_batch_gpu( tx_power_dbm: float, antenna_gain_dbi: float, cable_loss_db: float, path_loss_db: np.ndarray, additional_loss_db: np.ndarray = None ) -> np.ndarray: """ Calculate RSRP for all points at once. RSRP = TX Power + Antenna Gain - Cable Loss - Path Loss - Additional Loss """ xp = GPUManager.get_array_module() if GPUManager._backend == ComputeBackend.CUDA: path_loss = xp.asarray(path_loss_db) add_loss = xp.asarray(additional_loss_db) if additional_loss_db is not None else 0 else: path_loss = path_loss_db add_loss = additional_loss_db if additional_loss_db is not None else 0 eirp = tx_power_dbm + antenna_gain_dbi - cable_loss_db rsrp = eirp - path_loss - add_loss if GPUManager._backend == ComputeBackend.CUDA: return rsrp.get() return rsrp ``` ### 2.3 GPU Terrain Interpolation ```python # backend/app/services/terrain_gpu.py (NEW) def interpolate_terrain_batch_gpu( terrain_data: np.ndarray, terrain_bounds: tuple, # (min_lat, min_lon, max_lat, max_lon) points_lat: np.ndarray, points_lon: np.ndarray ) -> np.ndarray: """ Bilinear interpolation of terrain heights for all points. GPU version uses texture memory for fast 2D lookups. """ xp = GPUManager.get_array_module() min_lat, min_lon, max_lat, max_lon = terrain_bounds rows, cols = terrain_data.shape if GPUManager._backend == ComputeBackend.CUDA: # Upload terrain as texture (cached on GPU) terrain_gpu = xp.asarray(terrain_data) lats = xp.asarray(points_lat) lons = xp.asarray(points_lon) else: terrain_gpu = terrain_data lats = points_lat lons = points_lon # Normalize coordinates to [0, 1] lat_norm = (lats - min_lat) / (max_lat - min_lat) lon_norm = (lons - min_lon) / (max_lon - min_lon) # Convert to pixel coordinates y = lat_norm * (rows - 1) x = lon_norm * (cols - 1) # Bilinear interpolation indices x0 = xp.floor(x).astype(int) x1 = xp.minimum(x0 + 1, cols - 1) y0 = xp.floor(y).astype(int) y1 = xp.minimum(y0 + 1, rows - 1) # Clamp to valid range x0 = xp.clip(x0, 0, cols - 1) y0 = xp.clip(y0, 0, rows - 1) # Interpolation weights wx = x - x0 wy = y - y0 # Bilinear interpolation heights = ( terrain_gpu[y0, x0] * (1 - wx) * (1 - wy) + terrain_gpu[y0, x1] * wx * (1 - wy) + terrain_gpu[y1, x0] * (1 - wx) * wy + terrain_gpu[y1, x1] * wx * wy ) if GPUManager._backend == ComputeBackend.CUDA: return heights.get() return heights ``` ### 2.4 API Endpoint for GPU Status ```python # backend/app/api/gpu.py (NEW) from fastapi import APIRouter from ..services.gpu_backend import GPUManager, ComputeBackend router = APIRouter(prefix="/api/gpu", tags=["GPU"]) @router.get("/status") async def get_gpu_status(): """Get current GPU acceleration status.""" return GPUManager.get_status() @router.get("/devices") async def get_available_devices(): """List all available compute devices.""" return {"devices": GPUManager.get_devices()} @router.post("/set") async def set_compute_backend(backend: str, device_id: str = None): """Set active compute backend.""" backend_enum = ComputeBackend(backend) GPUManager.set_backend(backend_enum, device_id) return {"status": "ok", "backend": backend, "device": GPUManager._device_name} ``` ### 2.5 Frontend GPU Settings UI ```typescript // frontend/src/components/panels/GPUSettings.tsx (NEW) import { useState, useEffect } from 'react'; import { Gpu, Cpu, Zap } from 'lucide-react'; interface Device { type: 'cuda' | 'opencl' | 'cpu'; id: string; name: string; memory: number | null; } export function GPUSettings() { const [devices, setDevices] = useState([]); const [activeBackend, setActiveBackend] = useState('cpu'); const [activeDevice, setActiveDevice] = useState(''); const [loading, setLoading] = useState(true); useEffect(() => { fetchGPUStatus(); }, []); const fetchGPUStatus = async () => { try { const res = await fetch('/api/gpu/devices'); const data = await res.json(); setDevices(data.devices); const status = await fetch('/api/gpu/status'); const statusData = await status.json(); setActiveBackend(statusData.backend); setActiveDevice(statusData.device_name); } finally { setLoading(false); } }; const setBackend = async (type: string, deviceId?: string) => { await fetch('/api/gpu/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backend: type, device_id: deviceId }) }); fetchGPUStatus(); }; const getIcon = (type: string) => { if (type === 'cuda') return ; if (type === 'opencl') return ; return ; }; const formatMemory = (bytes: number | null) => { if (!bytes) return ''; const gb = bytes / 1024 / 1024 / 1024; return `${gb.toFixed(1)} GB`; }; return (

Compute Acceleration

{/* Current status */}
Active: {activeDevice}
{/* Device list */}
{devices.map((device) => ( ))}
{/* Info */}

GPU acceleration provides 10-50x speedup for large calculations. NVIDIA CUDA is fastest, OpenCL works with AMD/Intel GPUs.

); } ``` ### 2.6 Status Bar GPU Indicator ```typescript // frontend/src/components/StatusBar.tsx (add to existing or create) // Add GPU indicator to status bar / toolbar
{gpuStatus.backend === 'cuda' && ( <> GPU: {gpuStatus.device_name} )} {gpuStatus.backend === 'opencl' && ( <> OpenCL: {gpuStatus.device_name} )} {gpuStatus.backend === 'cpu' && ( <> CPU mode )}
``` --- ## Phase 3: Terrain Profile Viewer ### 3.1 Backend Endpoint ```python # backend/app/api/terrain.py (NEW or add to existing) @router.post("/profile") async def get_terrain_profile( start_lat: float, start_lon: float, end_lat: float, end_lon: float, samples: int = 100 ): """ Get elevation profile between two points. Returns array of {distance, elevation, lat, lon} objects. """ from ..services.terrain_service import TerrainService terrain = TerrainService() # Generate sample points along the line lats = np.linspace(start_lat, end_lat, samples) lons = np.linspace(start_lon, end_lon, samples) # Get elevations elevations = [] total_distance = 0 prev_lat, prev_lon = start_lat, start_lon for i, (lat, lon) in enumerate(zip(lats, lons)): elev = terrain.get_elevation(lat, lon) if i > 0: # Calculate distance from previous point dist = haversine_distance(prev_lat, prev_lon, lat, lon) total_distance += dist elevations.append({ "index": i, "lat": lat, "lon": lon, "elevation": elev, "distance": total_distance }) prev_lat, prev_lon = lat, lon return { "profile": elevations, "total_distance": total_distance, "min_elevation": min(p["elevation"] for p in elevations), "max_elevation": max(p["elevation"] for p in elevations) } ``` ### 3.2 Frontend Profile Component ```typescript // frontend/src/components/panels/TerrainProfile.tsx (NEW) import { useEffect, useState } from 'react'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Area, ReferenceLine } from 'recharts'; import { Mountain, Radio, Eye } from 'lucide-react'; interface ProfilePoint { distance: number; elevation: number; lat: number; lon: number; } interface TerrainProfileProps { startPoint: [number, number]; // [lat, lon] endPoint: [number, number]; antennaHeight?: number; // Height above ground at start point onClose: () => void; } export function TerrainProfile({ startPoint, endPoint, antennaHeight = 10, onClose }: TerrainProfileProps) { const [profile, setProfile] = useState([]); const [loading, setLoading] = useState(true); const [losStatus, setLosStatus] = useState<'clear' | 'obstructed' | 'partial'>('clear'); useEffect(() => { fetchProfile(); }, [startPoint, endPoint]); const fetchProfile = async () => { setLoading(true); try { const res = await fetch('/api/terrain/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_lat: startPoint[0], start_lon: startPoint[1], end_lat: endPoint[0], end_lon: endPoint[1], samples: 100 }) }); const data = await res.json(); setProfile(data.profile); calculateLOS(data.profile); } finally { setLoading(false); } }; const calculateLOS = (profileData: ProfilePoint[]) => { if (profileData.length < 2) return; const startElev = profileData[0].elevation + antennaHeight; const endElev = profileData[profileData.length - 1].elevation + 1.5; // UE height const totalDist = profileData[profileData.length - 1].distance; let obstructionCount = 0; for (const point of profileData) { // Calculate expected LOS height at this distance const ratio = point.distance / totalDist; const losHeight = startElev + (endElev - startElev) * ratio; if (point.elevation > losHeight) { obstructionCount++; } } if (obstructionCount === 0) { setLosStatus('clear'); } else if (obstructionCount > profileData.length * 0.3) { setLosStatus('obstructed'); } else { setLosStatus('partial'); } }; // Calculate LOS line data const losLine = profile.length > 0 ? [ { distance: 0, los: profile[0].elevation + antennaHeight }, { distance: profile[profile.length - 1].distance, los: profile[profile.length - 1].elevation + 1.5 } ] : []; const totalDistanceKm = profile.length > 0 ? (profile[profile.length - 1].distance / 1000).toFixed(2) : '0'; return (
{/* Header */}

Terrain Profile

{/* Stats */}
Distance: {totalDistanceKm} km
LOS: {losStatus === 'clear' ? '✓ Clear' : losStatus === 'partial' ? '⚠ Partial' : '✗ Obstructed'}
Antenna: {antennaHeight}m AGL
{/* Chart */}
{loading ? (
Loading terrain data...
) : ( {/* Terrain fill */} `${(v/1000).toFixed(1)}km`} tick={{ fontSize: 10, fill: '#9ca3af' }} /> `${v}m`} domain={['dataMin - 20', 'dataMax + 50']} /> [ `${value.toFixed(0)}m`, name === 'elevation' ? 'Elevation' : 'LOS' ]} labelFormatter={(label: number) => `Distance: ${(label/1000).toFixed(2)} km`} /> {/* Terrain area */} {/* LOS line */} {/* Antenna marker */} )}
{/* Legend */}
Terrain
Line of Sight
Antenna
); } ``` ### 3.3 Integration with Ruler Tool ```typescript // frontend/src/components/map/RulerTool.tsx (modify existing) // After ruler measurement is complete, show button: {rulerPoints.length === 2 && ( )} {showTerrainProfile && ( setShowTerrainProfile(false)} /> )} ``` --- ## Phase 4: Additional Features ### 4.1 Batch Frequency Change ```typescript // frontend/src/components/panels/BatchOperations.tsx (NEW) export function BatchFrequencyChange() { const { sites, updateAllSectors } = useSiteStore(); const frequencies = [ { value: 700, label: '700 MHz', band: 'Band 28' }, { value: 800, label: '800 MHz', band: 'Band 20' }, { value: 1800, label: '1800 MHz', band: 'Band 3' }, { value: 2100, label: '2100 MHz', band: 'Band 1' }, { value: 2600, label: '2600 MHz', band: 'Band 7' }, { value: 3500, label: '3500 MHz', band: 'n78 (5G)' }, ]; const handleBatchChange = (freq: number) => { updateAllSectors({ frequency: freq }); }; return (

BATCH FREQUENCY CHANGE

{frequencies.map(f => ( ))}
); } ``` ### 4.2 Fading Margin Setting ```typescript // Add to Coverage Settings panel
setFadingMargin(Number(e.target.value))} className="w-full" />

Safety margin for fading (8-10 dB typical for 90% coverage)

``` ### 4.3 Extended Frequency Bands ```python # backend/app/models/frequency_bands.py (NEW) FREQUENCY_BANDS = { # LTE FDD "band_28": {"name": "Band 28", "freq_dl": 758, "freq_ul": 703, "bandwidth": 10, "type": "FDD", "region": "APAC/EU"}, "band_20": {"name": "Band 20", "freq_dl": 806, "freq_ul": 847, "bandwidth": 10, "type": "FDD", "region": "EU"}, "band_8": {"name": "Band 8", "freq_dl": 935, "freq_ul": 880, "bandwidth": 10, "type": "FDD", "region": "Global"}, "band_3": {"name": "Band 3", "freq_dl": 1842.5, "freq_ul": 1747.5, "bandwidth": 20, "type": "FDD", "region": "Global"}, "band_1": {"name": "Band 1", "freq_dl": 2140, "freq_ul": 1950, "bandwidth": 20, "type": "FDD", "region": "Global"}, "band_7": {"name": "Band 7", "freq_dl": 2655, "freq_ul": 2535, "bandwidth": 20, "type": "FDD", "region": "Global"}, # LTE TDD "band_38": {"name": "Band 38", "freq": 2600, "bandwidth": 10, "type": "TDD", "region": "EU/APAC"}, "band_40": {"name": "Band 40", "freq": 2350, "bandwidth": 20, "type": "TDD", "region": "APAC"}, "band_41": {"name": "Band 41", "freq": 2593, "bandwidth": 20, "type": "TDD", "region": "Global"}, # 5G NR "n77": {"name": "n77", "freq": 3700, "bandwidth": 100, "type": "TDD", "region": "Global", "tech": "5G"}, "n78": {"name": "n78", "freq": 3500, "bandwidth": 100, "type": "TDD", "region": "Global", "tech": "5G"}, "n258": {"name": "n258", "freq": 26500, "bandwidth": 400, "type": "TDD", "region": "Global", "tech": "5G mmWave"}, } def get_band_info(frequency_mhz: int) -> dict: """Get band info for a frequency.""" for band_id, band in FREQUENCY_BANDS.items(): if "freq_dl" in band: if abs(band["freq_dl"] - frequency_mhz) < 50: return {"band_id": band_id, **band} elif abs(band["freq"] - frequency_mhz) < 50: return {"band_id": band_id, **band} return None ``` --- ## Implementation Order ### Priority 1 — Critical (Day 1) 1. Timeout fix for tiled calculations 2. GPU backend detection & CuPy integration ### Priority 2 — GPU Implementation (Day 2-3) 3. GPU path loss calculation 4. GPU terrain interpolation 5. Frontend GPU settings UI ### Priority 3 — Features (Day 4-5) 6. Coverage boundary fix + toggle 7. Terrain Profile Viewer 8. Batch frequency change ### Priority 4 — Polish (Day 6) 9. Fading margin setting 10. Extended frequency bands 11. UI/UX improvements --- ## Dependencies ### Python (backend) ``` # requirements.txt additions cupy-cuda12x>=12.0.0 # For CUDA 12 # OR cupy-cuda11x>=11.0.0 # For CUDA 11 pyopencl>=2022.1 # OpenCL fallback ``` ### Install CUDA support ```bash # Check CUDA version nvidia-smi # Install matching CuPy pip install cupy-cuda12x # For CUDA 12.x # OR pip install cupy-cuda11x # For CUDA 11.x ``` --- ## Expected Performance | Scenario | Current (CPU) | With GPU (RTX 4060) | Speedup | |----------|---------------|---------------------|---------| | 10 km, 100m resolution | 124s | ~12s | **10x** | | 20 km, 100m resolution | >5 min (timeout) | ~30s | **10x+** | | 20 km, 200m resolution | ~3 min | ~20s | **9x** | | 50 km, 500m resolution | N/A (OOM) | ~2 min | **∞** | --- ## Testing ```bash # Test GPU detection curl http://localhost:8888/api/gpu/devices # Test GPU calculation curl -X POST http://localhost:8888/api/coverage/calculate \ -H "Content-Type: application/json" \ -d '{"use_gpu": true, "radius": 20000, "resolution": 100}' # Benchmark CPU vs GPU python -c " from app.services.gpu_backend import GPUManager import time import numpy as np # Generate test data n_points = 100000 lats = np.random.uniform(49, 50, n_points) lons = np.random.uniform(24, 25, n_points) # CPU GPUManager.set_backend('cpu') t0 = time.time() # ... path loss calc ... print(f'CPU: {time.time()-t0:.2f}s') # GPU GPUManager.set_backend('cuda') t0 = time.time() # ... path loss calc ... print(f'GPU: {time.time()-t0:.2f}s') " ``` --- ## Files Summary ### New Files - `backend/app/services/gpu_backend.py` — GPU detection & management - `backend/app/services/propagation_gpu.py` — GPU path loss - `backend/app/services/terrain_gpu.py` — GPU terrain interpolation - `backend/app/api/gpu.py` — GPU API endpoints - `backend/app/models/frequency_bands.py` — Extended band definitions - `frontend/src/components/panels/GPUSettings.tsx` — GPU UI - `frontend/src/components/panels/TerrainProfile.tsx` — Profile viewer - `frontend/src/components/panels/BatchOperations.tsx` — Batch controls ### Modified Files - `backend/app/api/websocket.py` — Timeout fix - `backend/app/services/coverage_service.py` — GPU integration - `backend/requirements.txt` — CuPy dependency - `frontend/src/components/map/CoverageBoundary.tsx` — Fix + toggle - `frontend/src/components/map/HeatmapLegend.tsx` — Boundary toggle - `frontend/src/components/map/RulerTool.tsx` — Profile integration - `frontend/src/components/panels/CoverageSettings.tsx` — Fading margin --- ## Success Criteria - [ ] 20 km radius completes without timeout - [ ] GPU detected and selectable in UI - [ ] GPU provides 10x+ speedup on RTX 4060 - [ ] OpenCL fallback works for non-NVIDIA - [ ] Terrain profile shows correct elevation - [ ] Coverage boundary toggleable and accurate - [ ] Batch frequency change works - [ ] Fading margin affects RSRP calculation --- *"Make it fast, then make it faster"* 🚀