@mytec: iter2.4 ready for testing
This commit is contained in:
821
RFCP-Phase-2.4-GPU-Elevation.md
Normal file
821
RFCP-Phase-2.4-GPU-Elevation.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user