# RFCP Phase 2.4: GPU Acceleration + Elevation Layer **Date:** February 1, 2025 **Type:** Performance + UI Enhancement **Priority:** HIGH **Depends on:** Phase 2.3 (Performance fixes) --- ## 🎯 Goals 1. **Elevation Layer** — візуалізація рельєфу на карті 2. **GPU Acceleration** — прискорення розрахунків через CUDA 3. **Bug Fixes** — закриття app, timeout handling --- ## 🐛 Bug Fixes (CRITICAL — Do First!) ### Bug 2.4.0a: App Close Still Not Working **Symptoms:** - Clicking X closes window but processes stay running - rfcp-server.exe stays in Task Manager - Have to manually kill processes **File:** `desktop/main.js` **Debug steps:** 1. Add console.log at START of killBackend(): ```javascript function killBackend() { console.log('[KILL] killBackend() called, pid:', backendPid); // ... rest of function } ``` 2. Add console.log in close handler: ```javascript mainWindow.on('close', (event) => { console.log('[CLOSE] Window close event triggered'); killBackend(); }); ``` 3. Check if the issue is: - killBackend() not being called at all - taskkill not working (wrong PID?) - Process spawning children that aren't killed **Potential fix:** ```javascript function killBackend() { console.log('[KILL] killBackend() called'); if (!backendPid && !backendProcess) { console.log('[KILL] No backend to kill'); return; } const pid = backendPid || backendProcess?.pid; console.log('[KILL] Killing PID:', pid); if (process.platform === 'win32') { // Force kill entire process tree try { require('child_process').execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' }); console.log('[KILL] taskkill completed'); } catch (e) { console.log('[KILL] taskkill error:', e.message); } } backendProcess = null; backendPid = null; } ``` 4. Add in app quit: ```javascript app.on('before-quit', () => { console.log('[QUIT] before-quit event'); killBackend(); }); app.on('will-quit', () => { console.log('[QUIT] will-quit event'); killBackend(); }); ``` --- ### Bug 2.4.0b: Calculation Continues After Timeout **Symptoms:** - User gets "timeout" error in UI - But backend keeps calculating (CPU stays loaded) - Machine stays slow until manually kill process **File:** `backend/app/services/coverage_service.py` **Root cause:** asyncio.wait_for() cancels the coroutine but: - ProcessPoolExecutor workers keep running - Ray tasks keep running - No cancellation signal sent **Fix in coverage_service.py:** ```python # Add cancellation flag _calculation_cancelled = False async def calculate_coverage(sites, settings): global _calculation_cancelled _calculation_cancelled = False try: result = await asyncio.wait_for( _do_calculation(sites, settings), timeout=300 # 5 minutes ) return result except asyncio.TimeoutError: _calculation_cancelled = True _cleanup_running_tasks() # NEW raise HTTPException(408, "Calculation timeout") def _cleanup_running_tasks(): """Stop any running parallel workers.""" global _calculation_cancelled _calculation_cancelled = True # If using Ray if RAY_AVAILABLE and ray.is_initialized(): # Cancel pending tasks # Ray doesn't have great cancellation, but we can try pass # If using ProcessPoolExecutor - it will check flag _clog("Calculation cancelled, cleaning up workers") ``` **In parallel workers, check cancellation:** ```python def _process_chunk(chunk, ...): results = [] for point in chunk: # Check if cancelled if _calculation_cancelled: _clog("Worker detected cancellation, stopping") break result = _calculate_point_sync(point, ...) results.append(result) return results ``` --- ## 📊 Part A: Elevation Layer ### A.1: Backend API **New file:** `backend/app/api/routes/terrain.py` ```python from fastapi import APIRouter, Query from typing import List from app.services.terrain_service import terrain_service router = APIRouter(prefix="/api/terrain", tags=["terrain"]) @router.get("/elevation-grid") async def get_elevation_grid( min_lat: float = Query(..., description="South boundary"), max_lat: float = Query(..., description="North boundary"), min_lon: float = Query(..., description="West boundary"), max_lon: float = Query(..., description="East boundary"), resolution: int = Query(100, description="Grid resolution in meters") ) -> dict: """ Get elevation grid for a bounding box. Returns a 2D array of elevations for rendering terrain layer. """ # Calculate grid dimensions lat_range = max_lat - min_lat lon_range = max_lon - min_lon # Approximate meters per degree meters_per_lat = 111000 meters_per_lon = 111000 * cos(radians((min_lat + max_lat) / 2)) # Grid size rows = int((lat_range * meters_per_lat) / resolution) cols = int((lon_range * meters_per_lon) / resolution) # Cap to reasonable size rows = min(rows, 200) cols = min(cols, 200) # Build elevation grid elevations = [] lat_step = lat_range / rows lon_step = lon_range / cols for i in range(rows): row = [] lat = max_lat - (i + 0.5) * lat_step # Start from north for j in range(cols): lon = min_lon + (j + 0.5) * lon_step elev = terrain_service.get_elevation_sync(lat, lon) row.append(elev) elevations.append(row) # Get min/max for color scaling flat = [e for row in elevations for e in row] return { "elevations": elevations, "rows": rows, "cols": cols, "min_elevation": min(flat), "max_elevation": max(flat), "bbox": { "min_lat": min_lat, "max_lat": max_lat, "min_lon": min_lon, "max_lon": max_lon } } ``` **Register in main.py:** ```python from app.api.routes import terrain app.include_router(terrain.router) ``` --- ### A.2: Frontend Component **New file:** `frontend/src/components/ElevationLayer.tsx` ```tsx import { useEffect, useRef } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; interface ElevationLayerProps { enabled: boolean; opacity: number; bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number; } | null; } // Color scale: blue (low) → green → yellow → brown (high) const ELEVATION_COLORS = [ { threshold: 0, color: [33, 102, 172] }, // #2166ac deep blue { threshold: 100, color: [103, 169, 207] }, // #67a9cf light blue { threshold: 150, color: [145, 207, 96] }, // #91cf60 green { threshold: 200, color: [254, 224, 139] }, // #fee08b yellow { threshold: 250, color: [252, 141, 89] }, // #fc8d59 orange { threshold: 300, color: [215, 48, 39] }, // #d73027 red { threshold: 400, color: [165, 0, 38] }, // #a50026 dark red ]; function getColorForElevation(elevation: number): [number, number, number] { for (let i = ELEVATION_COLORS.length - 1; i >= 0; i--) { if (elevation >= ELEVATION_COLORS[i].threshold) { if (i === ELEVATION_COLORS.length - 1) { return ELEVATION_COLORS[i].color as [number, number, number]; } // Interpolate between this and next color const low = ELEVATION_COLORS[i]; const high = ELEVATION_COLORS[i + 1]; const t = (elevation - low.threshold) / (high.threshold - low.threshold); return [ Math.round(low.color[0] + t * (high.color[0] - low.color[0])), Math.round(low.color[1] + t * (high.color[1] - low.color[1])), Math.round(low.color[2] + t * (high.color[2] - low.color[2])), ]; } } return ELEVATION_COLORS[0].color as [number, number, number]; } export function ElevationLayer({ enabled, opacity, bbox }: ElevationLayerProps) { const map = useMap(); const canvasRef = useRef(null); const overlayRef = useRef(null); useEffect(() => { if (!enabled || !bbox) { // Remove overlay if disabled if (overlayRef.current) { map.removeLayer(overlayRef.current); overlayRef.current = null; } return; } // Fetch elevation data const fetchElevation = async () => { const params = new URLSearchParams({ min_lat: bbox.minLat.toString(), max_lat: bbox.maxLat.toString(), min_lon: bbox.minLon.toString(), max_lon: bbox.maxLon.toString(), resolution: '100', }); const response = await fetch(`/api/terrain/elevation-grid?${params}`); const data = await response.json(); // Create canvas const canvas = document.createElement('canvas'); canvas.width = data.cols; canvas.height = data.rows; const ctx = canvas.getContext('2d')!; const imageData = ctx.createImageData(data.cols, data.rows); // Fill pixel data for (let i = 0; i < data.rows; i++) { for (let j = 0; j < data.cols; j++) { const elevation = data.elevations[i][j]; const color = getColorForElevation(elevation); const idx = (i * data.cols + j) * 4; imageData.data[idx] = color[0]; // R imageData.data[idx + 1] = color[1]; // G imageData.data[idx + 2] = color[2]; // B imageData.data[idx + 3] = 255; // A } } ctx.putImageData(imageData, 0, 0); // Create overlay const bounds = L.latLngBounds( [bbox.minLat, bbox.minLon], [bbox.maxLat, bbox.maxLon] ); if (overlayRef.current) { map.removeLayer(overlayRef.current); } overlayRef.current = L.imageOverlay(canvas.toDataURL(), bounds, { opacity: opacity, interactive: false, }); overlayRef.current.addTo(map); }; fetchElevation(); return () => { if (overlayRef.current) { map.removeLayer(overlayRef.current); } }; }, [enabled, opacity, bbox, map]); return null; } ``` --- ### A.3: Layer Controls UI **Update:** `frontend/src/App.tsx` or create `LayerControls.tsx` ```tsx // Add to state const [showElevation, setShowElevation] = useState(false); const [elevationOpacity, setElevationOpacity] = useState(0.5); // Add to UI (in settings panel or toolbar)

Map Layers

{showElevation && (
setElevationOpacity(parseFloat(e.target.value))} />
)} {/* Elevation legend */} {showElevation && (
<100m
150-200m
200-250m
>300m
)}
// In Map component ``` --- ## ⚡ Part B: GPU Acceleration ### B.1: GPU Service **New file:** `backend/app/services/gpu_service.py` ```python """ GPU acceleration for coverage calculations using CuPy. Falls back to NumPy if CUDA not available. """ import numpy as np from typing import Tuple, Optional import os # Try to import CuPy GPU_AVAILABLE = False GPU_INFO = None try: import cupy as cp # Check if CUDA actually works try: cp.cuda.runtime.getDeviceCount() GPU_AVAILABLE = True # Get GPU info props = cp.cuda.runtime.getDeviceProperties(0) GPU_INFO = { 'name': props['name'].decode() if isinstance(props['name'], bytes) else props['name'], 'memory_mb': props['totalGlobalMem'] // (1024 * 1024), 'cuda_version': cp.cuda.runtime.runtimeGetVersion(), } print(f"[GPU] CUDA available: {GPU_INFO['name']} ({GPU_INFO['memory_mb']} MB)") except Exception as e: print(f"[GPU] CUDA device check failed: {e}") except ImportError: print("[GPU] CuPy not installed, using CPU only") def get_array_module(): """Get the appropriate array module (cupy or numpy).""" if GPU_AVAILABLE: return cp return np def to_gpu(array: np.ndarray) -> 'cp.ndarray | np.ndarray': """Move array to GPU if available.""" if GPU_AVAILABLE: return cp.asarray(array) return array def to_cpu(array) -> np.ndarray: """Move array back to CPU.""" if GPU_AVAILABLE and hasattr(array, 'get'): return array.get() return np.asarray(array) class GPUService: """GPU-accelerated calculations for coverage planning.""" def __init__(self): self.enabled = GPU_AVAILABLE self.info = GPU_INFO def calculate_distances_batch( self, site_lat: float, site_lon: float, point_lats: np.ndarray, point_lons: np.ndarray, ) -> np.ndarray: """ Calculate Haversine distances from site to all points. Vectorized for GPU acceleration. Args: site_lat, site_lon: Site coordinates (degrees) point_lats, point_lons: Arrays of point coordinates (degrees) Returns: Array of distances in meters """ xp = get_array_module() # Move to GPU if available lats = to_gpu(point_lats) lons = to_gpu(point_lons) # Convert to radians lat1 = xp.radians(site_lat) lon1 = xp.radians(site_lon) lat2 = xp.radians(lats) lon2 = xp.radians(lons) # Haversine formula (vectorized) dlat = lat2 - lat1 dlon = lon2 - lon1 a = xp.sin(dlat / 2) ** 2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon / 2) ** 2 c = 2 * xp.arcsin(xp.sqrt(a)) R = 6371000 # Earth radius in meters distances = R * c return to_cpu(distances) def calculate_free_space_path_loss_batch( self, distances: np.ndarray, frequency_mhz: float, ) -> np.ndarray: """ Calculate Free Space Path Loss for all distances. FSPL = 20*log10(d) + 20*log10(f) + 20*log10(4π/c) = 20*log10(d_km) + 20*log10(f_mhz) + 32.45 """ xp = get_array_module() d = to_gpu(distances) # Avoid log(0) d_km = xp.maximum(d / 1000.0, 0.001) fspl = 20 * xp.log10(d_km) + 20 * xp.log10(frequency_mhz) + 32.45 return to_cpu(fspl) def calculate_okumura_hata_batch( self, distances: np.ndarray, frequency_mhz: float, tx_height: float, rx_height: float = 1.5, environment: str = 'urban', ) -> np.ndarray: """ Calculate Okumura-Hata path loss for all distances. Vectorized for GPU acceleration. """ xp = get_array_module() d = to_gpu(distances) # Avoid log(0) d_km = xp.maximum(d / 1000.0, 0.001) f = frequency_mhz hb = tx_height hm = rx_height # Mobile antenna height correction (urban) if f <= 200: a_hm = 8.29 * (xp.log10(1.54 * hm)) ** 2 - 1.1 elif f >= 400: a_hm = 3.2 * (xp.log10(11.75 * hm)) ** 2 - 4.97 else: a_hm = (1.1 * xp.log10(f) - 0.7) * hm - (1.56 * xp.log10(f) - 0.8) # Base formula L = (69.55 + 26.16 * xp.log10(f) - 13.82 * xp.log10(hb) - a_hm + (44.9 - 6.55 * xp.log10(hb)) * xp.log10(d_km)) # Environment corrections if environment == 'suburban': L = L - 2 * (xp.log10(f / 28)) ** 2 - 5.4 elif environment == 'rural': L = L - 4.78 * (xp.log10(f)) ** 2 + 18.33 * xp.log10(f) - 40.94 return to_cpu(L) def calculate_rsrp_batch( self, distances: np.ndarray, tx_power_dbm: float, antenna_gain_dbi: float, frequency_mhz: float, tx_height: float, environment: str = 'urban', ) -> np.ndarray: """ Calculate RSRP for all points (basic, without terrain/buildings). """ path_loss = self.calculate_okumura_hata_batch( distances, frequency_mhz, tx_height, environment=environment ) rsrp = tx_power_dbm + antenna_gain_dbi - path_loss return rsrp # Singleton instance gpu_service = GPUService() ``` --- ### B.2: Integration with Coverage Service **Update:** `backend/app/services/coverage_service.py` ```python from app.services.gpu_service import gpu_service, GPU_AVAILABLE # In calculate_coverage, before point loop: async def calculate_coverage(sites, settings): # ... existing Phase 1 & 2 code ... # Phase 2.5: Pre-calculate with GPU if available if GPU_AVAILABLE and len(grid) > 100: _clog(f"Using GPU acceleration for {len(grid)} points") # Prepare arrays point_lats = np.array([p[0] for p in grid]) point_lons = np.array([p[1] for p in grid]) # Calculate all distances at once (GPU) all_distances = gpu_service.calculate_distances_batch( site.lat, site.lon, point_lats, point_lons ) # Calculate all basic path losses at once (GPU) all_path_losses = gpu_service.calculate_okumura_hata_batch( all_distances, site.frequency, site.height, environment='urban' if settings.use_buildings else 'rural' ) # Store for use in point loop precomputed = { 'distances': all_distances, 'path_losses': all_path_losses, } _clog(f"GPU pre-calculation done: {len(grid)} distances + path losses") else: precomputed = None # Phase 3: Point loop (uses precomputed if available) # ... modify _calculate_point_sync to accept precomputed values ... ``` --- ### B.3: System Info Update **Update:** `backend/app/api/routes/system.py` ```python from app.services.gpu_service import GPU_AVAILABLE, GPU_INFO @router.get("/api/system/info") async def get_system_info(): return { "cpu_cores": mp.cpu_count(), "parallel_workers": min(mp.cpu_count() - 2, 14), "parallel_backend": "ray" if RAY_AVAILABLE else "process_pool" if mp.cpu_count() > 1 else "sequential", "ray_available": RAY_AVAILABLE, "gpu": GPU_INFO, # Now includes name, memory, cuda_version "gpu_available": GPU_AVAILABLE, } ``` --- ### B.4: Requirements **Update:** `backend/requirements.txt` ``` # ... existing requirements ... # GPU acceleration (optional) # Install with: pip install cupy-cuda12x # Or for CUDA 11.x: pip install cupy-cuda11x # cupy-cuda12x>=12.0.0 ``` **Note:** CuPy is optional. Code falls back to NumPy if not installed. --- ## 📁 Files to Create/Modify **New files:** - `backend/app/api/routes/terrain.py` - `backend/app/services/gpu_service.py` - `frontend/src/components/ElevationLayer.tsx` **Modified files:** - `backend/app/main.py` — register terrain router - `backend/app/services/coverage_service.py` — GPU integration, cancellation - `backend/app/api/routes/system.py` — GPU info - `backend/requirements.txt` — cupy optional - `desktop/main.js` — fix app close (debug + fix) - `frontend/src/App.tsx` — elevation layer toggle --- ## 🧪 Testing ### Test Elevation Layer: ```bash # Start app ./rfcp-debug.bat # In browser console or via curl: curl "http://localhost:8888/api/terrain/elevation-grid?min_lat=48.5&max_lat=48.7&min_lon=36.0&max_lon=36.2&resolution=100" # Should return JSON with elevations array ``` ### Test GPU: ```bash # Check system info curl http://localhost:8888/api/system/info # Should show: # "gpu_available": true, # "gpu": {"name": "NVIDIA GeForce RTX 4060", "memory_mb": 8192, ...} ``` ### Test App Close: ``` 1. Start app via RFCP.exe (not debug bat) 2. Click X to close 3. Check Task Manager - rfcp-server.exe should NOT be running 4. If still running - check console logs for [KILL] messages ``` --- ## ✅ Success Criteria - [ ] Elevation layer toggleable on map - [ ] Elevation colors match terrain (verify with known locations) - [ ] GPU detected and shown in system info (if NVIDIA card present) - [ ] Fast preset 2x faster with GPU - [ ] App closes completely when clicking X - [ ] No orphan processes after timeout - [ ] All existing presets still work --- ## 📈 Expected Performance | Operation | CPU (NumPy) | GPU (CuPy) | Speedup | |-----------|-------------|------------|---------| | 10k distances | 5ms | 0.1ms | 50x | | 10k path losses | 10ms | 0.2ms | 50x | | Full calculation* | 10s | 3s | 3x | *Full calculation limited by CPU-bound terrain/building checks --- ## 🔜 Next Phase Phase 2.5: Advanced Visualization - LOS ray visualization (show blocked paths) - 3D terrain view - Antenna pattern visualization - Multi-site interference view