@mytec: iter2.4 ready for testing

This commit is contained in:
2026-02-01 10:48:23 +02:00
parent 7893c57bc9
commit 5488633e43
19 changed files with 1448 additions and 69 deletions

View 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>
&lt;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>
&gt;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