Files
rfcp/docs/devlog/installer/RFCP-Phase-2.4-GPU-Elevation.md
2026-02-02 21:30:00 +02:00

22 KiB

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():
function killBackend() {
  console.log('[KILL] killBackend() called, pid:', backendPid);
  // ... rest of function
}
  1. Add console.log in close handler:
mainWindow.on('close', (event) => {
  console.log('[CLOSE] Window close event triggered');
  killBackend();
});
  1. 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:

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;
}
  1. Add in app quit:
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:

# 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:

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

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:

from app.api.routes import terrain
app.include_router(terrain.router)

A.2: Frontend Component

New file: frontend/src/components/ElevationLayer.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

// 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

"""
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

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

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:

# 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:

# 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