diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c3582a9..58e66b4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,20 @@ "Bash(pip3 install numpy)", "Bash(echo:*)", "Bash(find:*)", - "Bash(node -c:*)" + "Bash(node -c:*)", + "Bash(curl:*)", + "Bash(head -3 python3 -c \"import numpy; print\\(numpy.__file__\\)\")", + "Bash(pip3 install:*)", + "Bash(apt list:*)", + "Bash(dpkg:*)", + "Bash(sudo apt-get install:*)", + "Bash(docker:*)", + "Bash(~/.local/bin/pip install:*)", + "Bash(pgrep:*)", + "Bash(kill:*)", + "Bash(sort:*)", + "Bash(journalctl:*)", + "Bash(pkill:*)" ] } } diff --git a/RFCP-Iteration-3.5.0-GPU-Acceleration.md b/RFCP-Iteration-3.5.0-GPU-Acceleration.md new file mode 100644 index 0000000..7d04754 --- /dev/null +++ b/RFCP-Iteration-3.5.0-GPU-Acceleration.md @@ -0,0 +1,1096 @@ +# 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"* 🚀 diff --git a/backend/app/api/routes/coverage.py b/backend/app/api/routes/coverage.py index e828980..5c1d2b5 100644 --- a/backend/app/api/routes/coverage.py +++ b/backend/app/api/routes/coverage.py @@ -69,8 +69,16 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse: start_time = time.time() cancel_token = CancellationToken() + # Dynamic timeout based on radius (large radius needs more time for tiled processing) + radius_m = request.settings.radius + if radius_m > 30_000: + calc_timeout = 600.0 # 10 min for 30-50km + elif radius_m > 10_000: + calc_timeout = 480.0 # 8 min for 10-30km + else: + calc_timeout = 300.0 # 5 min for ≤10km + try: - # Calculate with 5-minute timeout if len(request.sites) == 1: points = await asyncio.wait_for( coverage_service.calculate_coverage( @@ -78,7 +86,7 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse: request.settings, cancel_token, ), - timeout=300.0 + timeout=calc_timeout, ) else: points = await asyncio.wait_for( @@ -87,14 +95,15 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse: request.settings, cancel_token, ), - timeout=300.0 + timeout=calc_timeout, ) except asyncio.TimeoutError: cancel_token.cancel() # Force cleanup orphaned worker processes from app.services.parallel_coverage_service import _kill_worker_processes killed = _kill_worker_processes() - detail = f"Calculation timeout (5 min). Cleaned up {killed} workers." if killed else "Calculation timeout (5 min) — try smaller radius or lower resolution" + timeout_min = int(calc_timeout / 60) + detail = f"Calculation timeout ({timeout_min} min). Cleaned up {killed} workers." if killed else f"Calculation timeout ({timeout_min} min) — try smaller radius or lower resolution" raise HTTPException(408, detail) except asyncio.CancelledError: cancel_token.cancel() diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 5afc32b..faedaf5 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -180,6 +180,15 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict): poller_task = asyncio.create_task(progress_poller()) + # Dynamic timeout based on radius + radius_m = settings.radius + if radius_m > 30_000: + calc_timeout = 600.0 # 10 min for 30-50km + elif radius_m > 10_000: + calc_timeout = 480.0 # 8 min for 10-30km + else: + calc_timeout = 300.0 # 5 min for ≤10km + # Run calculation with timeout start_time = time.time() try: @@ -190,7 +199,7 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict): progress_fn=sync_progress_fn, tile_callback=_tile_callback, ), - timeout=300.0, + timeout=calc_timeout, ) else: points = await asyncio.wait_for( @@ -199,7 +208,7 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict): progress_fn=sync_progress_fn, tile_callback=_tile_callback, ), - timeout=300.0, + timeout=calc_timeout, ) except asyncio.TimeoutError: cancel_token.cancel() @@ -207,7 +216,8 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict): await poller_task from app.services.parallel_coverage_service import _kill_worker_processes _kill_worker_processes() - await ws_manager.send_error(ws, calc_id, "Calculation timeout (5 min)") + timeout_min = int(calc_timeout / 60) + await ws_manager.send_error(ws, calc_id, f"Calculation timeout ({timeout_min} min)") return except asyncio.CancelledError: cancel_token.cancel() diff --git a/backend/app/services/coverage_service.py b/backend/app/services/coverage_service.py index bebe92f..b35cccf 100644 --- a/backend/app/services/coverage_service.py +++ b/backend/app/services/coverage_service.py @@ -123,13 +123,14 @@ def _filter_buildings_to_bbox( max_lat: float, max_lon: float, site_lat: float, site_lon: float, log_fn=None, + max_buildings: int = MAX_BUILDINGS_FOR_WORKERS, ) -> list: - """Filter buildings to coverage bbox and cap at MAX_BUILDINGS_FOR_WORKERS. + """Filter buildings to coverage bbox and cap at max_buildings. Returns buildings sorted by distance to site (nearest first) so the cap preserves buildings most likely to affect coverage. """ - if not buildings or len(buildings) <= MAX_BUILDINGS_FOR_WORKERS: + if not buildings or len(buildings) <= max_buildings: return buildings original = len(buildings) @@ -149,7 +150,7 @@ def _filter_buildings_to_bbox( log_fn(f"Building bbox filter: {original} -> {len(filtered)}") # If still too many, sort by centroid distance and cap - if len(filtered) > MAX_BUILDINGS_FOR_WORKERS: + if len(filtered) > max_buildings: def _centroid_dist(b): lats = [p[1] for p in b.geometry] lons = [p[0] for p in b.geometry] @@ -158,7 +159,7 @@ def _filter_buildings_to_bbox( return (clat - site_lat) ** 2 + (clon - site_lon) ** 2 filtered.sort(key=_centroid_dist) - filtered = filtered[:MAX_BUILDINGS_FOR_WORKERS] + filtered = filtered[:max_buildings] if log_fn: log_fn(f"Building distance cap: -> {len(filtered)} (nearest to site)") @@ -762,9 +763,57 @@ class CoverageService: # Free full grid reference del grid + # ── Pre-fetch buildings for inner zone (≤20km) ── + # This avoids re-reading the disk JSON cache (7-8s) per tile. + inner_radius_m = min(settings.radius, 20_000) + needs_osm = (settings.use_buildings + or getattr(settings, 'use_street_canyon', False) + or getattr(settings, 'use_water_reflection', False) + or getattr(settings, 'use_vegetation', False)) + + prefetched_buildings: List[Building] = [] + prefetched_streets: list = [] + prefetched_water: list = [] + prefetched_vegetation: list = [] + + if needs_osm: + lat_delta = inner_radius_m / 111_320.0 + lon_delta = inner_radius_m / (111_320.0 * max(math.cos(math.radians(site.lat)), 0.01)) + inner_bbox = ( + site.lat - lat_delta, site.lon - lon_delta, + site.lat + lat_delta, site.lon + lon_delta, + ) + if progress_fn: + progress_fn("Pre-fetching map data", 0.02) + _clog(f"Pre-fetching OSM for inner zone ({inner_radius_m/1000:.0f}km)") + + osm_prefetch = await self._fetch_osm_grid_aligned( + inner_bbox[0], inner_bbox[1], inner_bbox[2], inner_bbox[3], + settings, + ) + prefetched_buildings = osm_prefetch.get("buildings", []) + prefetched_streets = osm_prefetch.get("streets", []) + prefetched_water = osm_prefetch.get("water_bodies", []) + prefetched_vegetation = osm_prefetch.get("vegetation_areas", []) + del osm_prefetch + + _clog(f"Pre-fetched: {len(prefetched_buildings)} buildings, " + f"{len(prefetched_streets)} streets, " + f"{len(prefetched_water)} water, " + f"{len(prefetched_vegetation)} veg") + # Clear singleton memory cache — we hold our own reference + self.buildings._memory_cache.clear() + gc.collect() + site_elevation: Optional[float] = None all_points: List[CoveragePoint] = [] + # FSPL pre-check: compute minimum distance to each tile and estimate + # free-space signal. Skip tiles where even best-case FSPL < min_signal. + eirp_dbm = site.power + site.gain + min_signal = getattr(settings, 'min_signal', -130) + tiles_skipped_fspl = 0 + for tile_idx, tile in enumerate(tiles): if cancel_token and cancel_token.is_cancelled: _clog("Tiled calculation cancelled") @@ -776,6 +825,20 @@ class CoverageService: tile_start = time.time() min_lat, min_lon, max_lat, max_lon = tile.bbox + + # Quick FSPL check: closest edge of tile to site + clamp_lat = max(min_lat, min(site.lat, max_lat)) + clamp_lon = max(min_lon, min(site.lon, max_lon)) + closest_dist = TerrainService.haversine_distance( + site.lat, site.lon, clamp_lat, clamp_lon, + ) + if closest_dist > 500: # Skip check for tiles containing the site + fspl_db = 20 * math.log10(closest_dist) + 20 * math.log10(site.frequency * 1e6) - 147.55 + best_rsrp = eirp_dbm - fspl_db + if best_rsrp < min_signal: + tiles_skipped_fspl += 1 + continue + _clog(f"━━━ Tile {tile_idx + 1}/{total_tiles}: " f"{len(tile_grid)} points ━━━") @@ -787,28 +850,39 @@ class CoverageService: f"Tile {_idx + 1}/{total_tiles}: {phase}", overall, ) - # ── 1. Fetch OSM data for this tile ── - _tile_progress("Fetching map data", 0.10) + # ── 1. Filter pre-fetched OSM data for this tile ── + tile_center_lat = (min_lat + max_lat) / 2 + tile_center_lon = (min_lon + max_lon) / 2 + tile_dist_m = TerrainService.haversine_distance( + site.lat, site.lon, tile_center_lat, tile_center_lon, + ) + skip_buildings = tile_dist_m > 20_000 + + _tile_progress("Filtering map data", 0.10) await asyncio.sleep(0) - osm_data = await self._fetch_osm_grid_aligned( - min_lat, min_lon, max_lat, max_lon, settings, - ) - - buildings = _filter_buildings_to_bbox( - osm_data["buildings"], min_lat, min_lon, max_lat, max_lon, - site.lat, site.lon, _clog, - ) - streets = _filter_osm_list_to_bbox( - osm_data["streets"], min_lat, min_lon, max_lat, max_lon, - ) - water_bodies = _filter_osm_list_to_bbox( - osm_data["water_bodies"], min_lat, min_lon, max_lat, max_lon, - ) - vegetation_areas = _filter_osm_list_to_bbox( - osm_data["vegetation_areas"], min_lat, min_lon, max_lat, max_lon, - max_count=5000, - ) + if skip_buildings: + buildings: list = [] + streets: list = [] + water_bodies: list = [] + vegetation_areas: list = [] + else: + # Fast in-memory filter from pre-fetched data (no disk I/O) + buildings = _filter_buildings_to_bbox( + prefetched_buildings, min_lat, min_lon, max_lat, max_lon, + site.lat, site.lon, _clog, + max_buildings=5000, + ) + streets = _filter_osm_list_to_bbox( + prefetched_streets, min_lat, min_lon, max_lat, max_lon, + ) + water_bodies = _filter_osm_list_to_bbox( + prefetched_water, min_lat, min_lon, max_lat, max_lon, + ) + vegetation_areas = _filter_osm_list_to_bbox( + prefetched_vegetation, min_lat, min_lon, max_lat, max_lon, + max_count=5000, + ) spatial_idx: Optional[SpatialIndex] = None if buildings: @@ -907,15 +981,21 @@ class CoverageService: _clog(f"Tile {tile_idx + 1}/{total_tiles} done: " f"{len(tile_points)} points in {tile_time:.1f}s") - # ── 5. Free memory ── + # ── 5. Free per-tile memory ── del buildings, streets, water_bodies, vegetation_areas - del osm_data, spatial_idx, point_elevations, precomputed + del spatial_idx, point_elevations, precomputed del pre_distances, pre_path_loss, grid_lats, grid_lons gc.collect() + # Free pre-fetched OSM data + del prefetched_buildings, prefetched_streets + del prefetched_water, prefetched_vegetation + gc.collect() + total_time = time.time() - calc_start _clog(f"━━━ Tiled calculation complete: " - f"{len(all_points)} points in {total_time:.1f}s ━━━") + f"{len(all_points)} points in {total_time:.1f}s " + f"({tiles_skipped_fspl} tiles skipped by FSPL pre-check) ━━━") if progress_fn: progress_fn("Finalizing", 0.95)