Files
rfcp/RFCP-Iteration-3.5.0-GPU-Acceleration.md
2026-02-03 02:53:46 +02:00

32 KiB
Raw Blame History

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:

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

# 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

# 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

# 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

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

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

# 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

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

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

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

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

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

  1. GPU path loss calculation
  2. GPU terrain interpolation
  3. Frontend GPU settings UI

Priority 3 — Features (Day 4-5)

  1. Coverage boundary fix + toggle
  2. Terrain Profile Viewer
  3. Batch frequency change

Priority 4 — Polish (Day 6)

  1. Fading margin setting
  2. Extended frequency bands
  3. 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

# 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

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