822 lines
22 KiB
Markdown
822 lines
22 KiB
Markdown
# 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<HTMLCanvasElement | null>(null);
|
|
const overlayRef = useRef<L.ImageOverlay | null>(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)
|
|
<div className="layer-controls">
|
|
<h4>Map Layers</h4>
|
|
|
|
<label className="layer-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={showElevation}
|
|
onChange={(e) => setShowElevation(e.target.checked)}
|
|
/>
|
|
Show Elevation
|
|
</label>
|
|
|
|
{showElevation && (
|
|
<div className="elevation-opacity">
|
|
<label>Opacity: {Math.round(elevationOpacity * 100)}%</label>
|
|
<input
|
|
type="range"
|
|
min="0.2"
|
|
max="1"
|
|
step="0.1"
|
|
value={elevationOpacity}
|
|
onChange={(e) => setElevationOpacity(parseFloat(e.target.value))}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Elevation legend */}
|
|
{showElevation && (
|
|
<div className="elevation-legend">
|
|
<div className="legend-item">
|
|
<span className="color-box" style={{background: '#2166ac'}}></span>
|
|
<100m
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color-box" style={{background: '#91cf60'}}></span>
|
|
150-200m
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color-box" style={{background: '#fee08b'}}></span>
|
|
200-250m
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color-box" style={{background: '#d73027'}}></span>
|
|
>300m
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
// In Map component
|
|
<ElevationLayer
|
|
enabled={showElevation}
|
|
opacity={elevationOpacity}
|
|
bbox={mapBounds} // Current map view bounds
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
## ⚡ 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
|