1097 lines
32 KiB
Markdown
1097 lines
32 KiB
Markdown
# 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);
|
||
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={showBoundary}
|
||
onChange={(e) => setShowBoundary(e.target.checked)}
|
||
/>
|
||
<span className="text-xs">Show coverage boundary</span>
|
||
</label>
|
||
```
|
||
|
||
```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<Device[]>([]);
|
||
const [activeBackend, setActiveBackend] = useState<string>('cpu');
|
||
const [activeDevice, setActiveDevice] = useState<string>('');
|
||
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 <Gpu className="w-4 h-4 text-green-500" />;
|
||
if (type === 'opencl') return <Gpu className="w-4 h-4 text-blue-500" />;
|
||
return <Cpu className="w-4 h-4 text-gray-500" />;
|
||
};
|
||
|
||
const formatMemory = (bytes: number | null) => {
|
||
if (!bytes) return '';
|
||
const gb = bytes / 1024 / 1024 / 1024;
|
||
return `${gb.toFixed(1)} GB`;
|
||
};
|
||
|
||
return (
|
||
<div className="p-4 bg-slate-800 rounded-lg">
|
||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||
<Zap className="w-4 h-4" />
|
||
Compute Acceleration
|
||
</h3>
|
||
|
||
{/* Current status */}
|
||
<div className="mb-3 p-2 bg-slate-700 rounded text-xs">
|
||
<span className="text-gray-400">Active: </span>
|
||
<span className="text-white font-medium">{activeDevice}</span>
|
||
</div>
|
||
|
||
{/* Device list */}
|
||
<div className="space-y-2">
|
||
{devices.map((device) => (
|
||
<label
|
||
key={device.id}
|
||
className={`
|
||
flex items-center gap-3 p-2 rounded cursor-pointer
|
||
${activeBackend === device.type ? 'bg-blue-600/30 border border-blue-500' : 'bg-slate-700 hover:bg-slate-600'}
|
||
`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="compute-backend"
|
||
checked={activeBackend === device.type}
|
||
onChange={() => setBackend(device.type, device.id)}
|
||
className="sr-only"
|
||
/>
|
||
{getIcon(device.type)}
|
||
<div className="flex-1">
|
||
<div className="text-sm text-white">{device.name}</div>
|
||
<div className="text-xs text-gray-400">
|
||
{device.type.toUpperCase()}
|
||
{device.memory && ` • ${formatMemory(device.memory)}`}
|
||
</div>
|
||
</div>
|
||
{activeBackend === device.type && (
|
||
<span className="text-xs text-green-400">✓ Active</span>
|
||
)}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<p className="mt-3 text-xs text-gray-500">
|
||
GPU acceleration provides 10-50x speedup for large calculations.
|
||
NVIDIA CUDA is fastest, OpenCL works with AMD/Intel GPUs.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 2.6 Status Bar GPU Indicator
|
||
|
||
```typescript
|
||
// frontend/src/components/StatusBar.tsx (add to existing or create)
|
||
|
||
// Add GPU indicator to status bar / toolbar
|
||
<div className="flex items-center gap-1 text-xs">
|
||
{gpuStatus.backend === 'cuda' && (
|
||
<>
|
||
<Gpu className="w-3 h-3 text-green-500" />
|
||
<span className="text-green-400">GPU: {gpuStatus.device_name}</span>
|
||
</>
|
||
)}
|
||
{gpuStatus.backend === 'opencl' && (
|
||
<>
|
||
<Gpu className="w-3 h-3 text-blue-500" />
|
||
<span className="text-blue-400">OpenCL: {gpuStatus.device_name}</span>
|
||
</>
|
||
)}
|
||
{gpuStatus.backend === 'cpu' && (
|
||
<>
|
||
<Cpu className="w-3 h-3 text-gray-500" />
|
||
<span className="text-gray-400">CPU mode</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## 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<ProfilePoint[]>([]);
|
||
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 (
|
||
<div className="absolute left-4 bottom-20 w-[500px] bg-slate-800 rounded-lg shadow-xl border border-slate-600 z-50">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-3 border-b border-slate-600">
|
||
<h3 className="font-semibold flex items-center gap-2">
|
||
<Mountain className="w-4 h-4" />
|
||
Terrain Profile
|
||
</h3>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-white">×</button>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="flex gap-4 p-3 text-xs border-b border-slate-700">
|
||
<div>
|
||
<span className="text-gray-400">Distance: </span>
|
||
<span className="text-white font-medium">{totalDistanceKm} km</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">LOS: </span>
|
||
<span className={`font-medium ${
|
||
losStatus === 'clear' ? 'text-green-400' :
|
||
losStatus === 'partial' ? 'text-yellow-400' : 'text-red-400'
|
||
}`}>
|
||
{losStatus === 'clear' ? '✓ Clear' :
|
||
losStatus === 'partial' ? '⚠ Partial' : '✗ Obstructed'}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">Antenna: </span>
|
||
<span className="text-white font-medium">{antennaHeight}m AGL</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart */}
|
||
<div className="p-3 h-[200px]">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-full text-gray-400">
|
||
Loading terrain data...
|
||
</div>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={profile} margin={{ top: 10, right: 10, bottom: 20, left: 40 }}>
|
||
{/* Terrain fill */}
|
||
<defs>
|
||
<linearGradient id="terrainGradient" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor="#4ade80" stopOpacity={0.3}/>
|
||
<stop offset="95%" stopColor="#4ade80" stopOpacity={0}/>
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<XAxis
|
||
dataKey="distance"
|
||
tickFormatter={(v) => `${(v/1000).toFixed(1)}km`}
|
||
tick={{ fontSize: 10, fill: '#9ca3af' }}
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 10, fill: '#9ca3af' }}
|
||
tickFormatter={(v) => `${v}m`}
|
||
domain={['dataMin - 20', 'dataMax + 50']}
|
||
/>
|
||
<Tooltip
|
||
formatter={(value: number, name: string) => [
|
||
`${value.toFixed(0)}m`,
|
||
name === 'elevation' ? 'Elevation' : 'LOS'
|
||
]}
|
||
labelFormatter={(label: number) => `Distance: ${(label/1000).toFixed(2)} km`}
|
||
/>
|
||
|
||
{/* Terrain area */}
|
||
<Area
|
||
type="monotone"
|
||
dataKey="elevation"
|
||
stroke="#4ade80"
|
||
fill="url(#terrainGradient)"
|
||
strokeWidth={2}
|
||
/>
|
||
|
||
{/* LOS line */}
|
||
<Line
|
||
data={losLine}
|
||
type="linear"
|
||
dataKey="los"
|
||
stroke="#ef4444"
|
||
strokeWidth={1}
|
||
strokeDasharray="5 5"
|
||
dot={false}
|
||
/>
|
||
|
||
{/* Antenna marker */}
|
||
<ReferenceLine x={0} stroke="#3b82f6" strokeWidth={2} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="flex gap-4 p-3 text-xs border-t border-slate-700">
|
||
<div className="flex items-center gap-1">
|
||
<div className="w-3 h-0.5 bg-green-400"></div>
|
||
<span className="text-gray-400">Terrain</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<div className="w-3 h-0.5 bg-red-400 border-dashed"></div>
|
||
<span className="text-gray-400">Line of Sight</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Radio className="w-3 h-3 text-blue-400" />
|
||
<span className="text-gray-400">Antenna</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 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 && (
|
||
<button
|
||
onClick={() => setShowTerrainProfile(true)}
|
||
className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full mb-2
|
||
px-3 py-1 bg-blue-600 text-white text-xs rounded shadow-lg
|
||
hover:bg-blue-700 flex items-center gap-1"
|
||
>
|
||
<Mountain className="w-3 h-3" />
|
||
Show Elevation Profile
|
||
</button>
|
||
)}
|
||
|
||
{showTerrainProfile && (
|
||
<TerrainProfile
|
||
startPoint={rulerPoints[0]}
|
||
endPoint={rulerPoints[1]}
|
||
antennaHeight={activeSite?.antennaHeight || 10}
|
||
onClose={() => 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 (
|
||
<div className="p-3 border-t border-slate-700">
|
||
<h4 className="text-xs font-semibold text-gray-400 mb-2">
|
||
BATCH FREQUENCY CHANGE
|
||
</h4>
|
||
<div className="flex flex-wrap gap-1">
|
||
{frequencies.map(f => (
|
||
<button
|
||
key={f.value}
|
||
onClick={() => handleBatchChange(f.value)}
|
||
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded"
|
||
title={f.band}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.2 Fading Margin Setting
|
||
|
||
```typescript
|
||
// Add to Coverage Settings panel
|
||
|
||
<div className="space-y-2">
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-sm">Fading Margin</span>
|
||
<span className="text-sm text-blue-400">{fadingMargin} dB</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="15"
|
||
value={fadingMargin}
|
||
onChange={(e) => setFadingMargin(Number(e.target.value))}
|
||
className="w-full"
|
||
/>
|
||
<p className="text-xs text-gray-500">
|
||
Safety margin for fading (8-10 dB typical for 90% coverage)
|
||
</p>
|
||
</div>
|
||
```
|
||
|
||
### 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"* 🚀
|