Files
rfcp/docs/devlog/gpu_supp/RFCP-Iteration-3.5.1-Bugfixes-Polish.md

18 KiB

RFCP Iteration 3.5.1 — Bugfixes & Polish

Overview

Focused bugfix and polish release addressing UI issues, coverage boundary accuracy, history improvements, and GPU indicator fixes discovered during 3.5.0 testing.


1. GPU — Detection Not Working + UI Overlap

1A. GPU Not Detected Despite Being Available

Problem: User has a laptop with DUAL GPUs (Intel integrated + NVIDIA discrete) but the app only shows "CPU (NumPy)". GPU acceleration is not working at all — no GPU option available in the device selector.

Root cause investigation needed:

  1. Check if CuPy is actually installed in the Python environment
  2. Check if CUDA toolkit is accessible from the app's runtime
  3. Check if PyOpenCL is installed (fallback for Intel GPU)
  4. The backend GPU detection may be failing silently

Debug steps to add:

# backend/app/services/gpu_backend.py — improve detection with logging

import logging
logger = logging.getLogger(__name__)

@classmethod
def detect_backends(cls) -> list:
    backends = []
    
    # Check NVIDIA CUDA
    try:
        import cupy as cp
        count = cp.cuda.runtime.getDeviceCount()
        logger.info(f"CUDA detected: {count} device(s)")
        for i in range(count):
            device = cp.cuda.Device(i)
            backends.append({...})
    except ImportError:
        logger.warning("CuPy not installed — run: pip install cupy-cuda12x")
    except Exception as e:
        logger.warning(f"CUDA detection failed: {e}")
    
    # Check OpenCL (works with Intel, AMD, AND NVIDIA)
    try:
        import pyopencl as cl
        platforms = cl.get_platforms()
        logger.info(f"OpenCL detected: {len(platforms)} platform(s)")
        for platform in platforms:
            for device in platform.get_devices():
                logger.info(f"  OpenCL device: {device.name}")
                backends.append({...})
    except ImportError:
        logger.warning("PyOpenCL not installed — run: pip install pyopencl")
    except Exception as e:
        logger.warning(f"OpenCL detection failed: {e}")
    
    # Always log what was found
    logger.info(f"Total compute backends: {len(backends)} "
                f"({sum(1 for b in backends if b['type'] == 'cuda')} CUDA, "
                f"{sum(1 for b in backends if b['type'] == 'opencl')} OpenCL)")
    
    # CPU always available
    backends.append({...cpu...})
    return backends

Installation check endpoint:

# backend/app/api/routes/gpu.py — add diagnostic endpoint

@router.get("/diagnostics")
async def gpu_diagnostics():
    """Full GPU diagnostic info for troubleshooting."""
    diag = {
        "python_version": sys.version,
        "platform": platform.platform(),
        "cuda": {},
        "opencl": {},
        "numpy": {}
    }
    
    # Check CuPy/CUDA
    try:
        import cupy
        diag["cuda"]["cupy_version"] = cupy.__version__
        diag["cuda"]["cuda_version"] = cupy.cuda.runtime.runtimeGetVersion()
        diag["cuda"]["device_count"] = cupy.cuda.runtime.getDeviceCount()
        for i in range(diag["cuda"]["device_count"]):
            d = cupy.cuda.Device(i)
            diag["cuda"][f"device_{i}"] = {
                "name": d.name,
                "compute_capability": d.compute_capability,
                "total_memory_mb": d.mem_info[1] // 1024 // 1024
            }
    except ImportError:
        diag["cuda"]["error"] = "CuPy not installed"
        diag["cuda"]["install_hint"] = "pip install cupy-cuda12x --break-system-packages"
    except Exception as e:
        diag["cuda"]["error"] = str(e)
    
    # Check PyOpenCL
    try:
        import pyopencl as cl
        diag["opencl"]["pyopencl_version"] = cl.VERSION_TEXT
        for p in cl.get_platforms():
            platform_info = {"name": p.name, "devices": []}
            for d in p.get_devices():
                platform_info["devices"].append({
                    "name": d.name,
                    "type": cl.device_type.to_string(d.type),
                    "memory_mb": d.global_mem_size // 1024 // 1024,
                    "compute_units": d.max_compute_units
                })
            diag["opencl"][p.name] = platform_info
    except ImportError:
        diag["opencl"]["error"] = "PyOpenCL not installed"
        diag["opencl"]["install_hint"] = "pip install pyopencl"
    except Exception as e:
        diag["opencl"]["error"] = str(e)
    
    # Check NumPy
    import numpy as np
    diag["numpy"]["version"] = np.__version__
    
    return diag

Frontend — show diagnostic info:

// In GPUIndicator.tsx — when only CPU detected, show help

{devices.length === 1 && devices[0].type === 'cpu' && (
  <div className="text-xs text-yellow-400 mt-2 p-2 bg-yellow-900/20 rounded">
     No GPU detected. 
    <button 
      onClick={() => fetchDiagnostics()}
      className="underline ml-1"
    >
      Run diagnostics
    </button>
  </div>
)}

Auto-install hint in UI:

⚠ No GPU detected
  
For NVIDIA GPU: pip install cupy-cuda12x
For Intel/AMD GPU: pip install pyopencl
  
[Run Diagnostics] [Install Guide]

Dual GPU handling (Intel + NVIDIA laptop):

# When both Intel (OpenCL) and NVIDIA (CUDA) found:
# - List both in device selector
# - Default to NVIDIA CUDA (faster)
# - Allow user to switch
# - Intel iGPU via OpenCL is still ~3-5x faster than CPU

# Example device list for dual GPU laptop:
# 1. ⚡ NVIDIA GeForce RTX 4060 (CUDA) — 8 GB  [DEFAULT]
# 2. ⚡ Intel UHD Graphics 770 (OpenCL) — shared memory
# 3. 💻 CPU (16 cores)

1B. GPU Indicator UI — Fix Overlap with Fit Button

Problem: GPU device dropdown overlaps with the "Fit" button in top-right corner.

Solution:

  • Keep compact " CPU" badge in header
  • Dropdown opens to the LEFT or DOWNWARD, not overlapping map controls
  • Proper z-index and positioning
  • Shorter labels: "CPU" not "CPU (NumPy)"

Files:

  • frontend/src/components/ui/GPUIndicator.tsx
  • backend/app/services/gpu_backend.py
  • backend/app/api/routes/gpu.py

2. Coverage Boundary — Improve Accuracy

Problem: Current boundary shows a rough circle/ellipse shape that doesn't follow actual coverage contour.

Current behavior: Boundary seems to be based on simple distance radius rather than actual RSRP threshold contour.

Expected behavior: Boundary should follow the actual -100 dBm (or configured threshold) contour line — an irregular shape that follows terrain, buildings, vegetation shadows.

Solution:

# Backend approach: Generate contour from actual RSRP grid

import numpy as np
from scipy.ndimage import binary_dilation, binary_erosion
from shapely.geometry import MultiPoint
from shapely.ops import unary_union

def calculate_coverage_boundary(points: list, threshold_dbm: float = -100) -> list:
    """
    Calculate coverage boundary as convex hull of points above threshold.
    Returns list of [lat, lon] coordinates forming the boundary polygon.
    """
    # Filter points above threshold
    valid_points = [(p['lat'], p['lon']) for p in points if p['rsrp'] >= threshold_dbm]
    
    if len(valid_points) < 3:
        return []
    
    # Create concave hull (alpha shape) for realistic boundary
    # Concave hull follows the actual shape better than convex hull
    from shapely.geometry import MultiPoint
    multi_point = MultiPoint(valid_points)
    
    # Alpha shape — adjust alpha for detail level
    # Higher alpha = more detailed (but slower)
    boundary = concave_hull(multi_point, ratio=0.3)
    
    if boundary.is_empty:
        return []
    
    # Simplify to reduce points (tolerance in degrees ≈ 100m)
    simplified = boundary.simplify(0.001)
    
    # Return as coordinate list
    coords = list(simplified.exterior.coords)
    return [[lat, lon] for lat, lon in coords]
# Alternative: Grid-based contour approach

def calculate_boundary_from_grid(
    grid_points: list,
    threshold_dbm: float,
    grid_resolution_m: float
) -> list:
    """
    Create boundary by finding edge cells of coverage area.
    More accurate than hull — follows actual coverage gaps.
    """
    import numpy as np
    
    # Build 2D RSRP grid
    lats = sorted(set(p['lat'] for p in grid_points))
    lons = sorted(set(p['lon'] for p in grid_points))
    
    grid = np.full((len(lats), len(lons)), np.nan)
    lat_idx = {lat: i for i, lat in enumerate(lats)}
    lon_idx = {lon: i for i, lon in enumerate(lons)}
    
    for p in grid_points:
        i = lat_idx[p['lat']]
        j = lon_idx[p['lon']]
        grid[i, j] = p['rsrp']
    
    # Binary mask: above threshold
    mask = grid >= threshold_dbm
    
    # Find boundary: dilate - original = edge cells
    dilated = binary_dilation(mask)
    boundary_mask = dilated & ~mask
    
    # Extract boundary coordinates
    boundary_coords = []
    for i in range(len(lats)):
        for j in range(len(lons)):
            if boundary_mask[i, j]:
                boundary_coords.append([lats[i], lons[j]])
    
    # Order points for polygon (traveling salesman approximate)
    if len(boundary_coords) > 2:
        ordered = order_boundary_points(boundary_coords)
        return ordered
    
    return boundary_coords

Frontend changes:

  • Receive boundary polygon from backend (already calculated with results)
  • Or calculate client-side from grid points
  • Render as Leaflet polygon with dashed white stroke
  • Should follow actual coverage shape, not circular approximation

Files:

  • backend/app/services/coverage_service.py — add boundary calculation
  • frontend/src/components/map/CoverageBoundary.tsx — render real contour

3. Session History — Show Propagation Parameters

Problem: History entries only show preset, points, radius, resolution. Missing propagation settings used.

Solution: Save full propagation config snapshot with each history entry.

// frontend/src/store/calcHistory.ts

interface HistoryEntry {
  id: string;
  timestamp: Date;
  computationTime: number;
  preset: string;
  radius: number;
  resolution: number;
  totalPoints: number;
  
  // Coverage results
  coverage: {
    excellent: number;  // percentage
    good: number;
    fair: number;
    weak: number;
  };
  avgRsrp: number;
  rangeMin: number;
  rangeMax: number;
  
  // NEW: Propagation parameters snapshot
  propagation: {
    modelsUsed: string[];        // ["Free-Space", "terrain_los", ...]
    modelCount: number;          // 12
    frequency: number;           // 2100 MHz
    txPower: number;             // 46 dBm
    antennaGain: number;         // 15 dBi
    antennaHeight: number;       // 10 m
    
    // Environment
    season: string;              // "Winter (30%)"
    temperature: string;         // "15°C (mild)"
    humidity: string;            // "50% (normal)"
    rainConditions: string;      // "Light Rain"
    indoorCoverage: string;      // "Medium Building (brick)"
    
    // Margins
    fadingMargin: number;        // 0 dB
    
    // Atmospheric
    atmosphericAbsorption: boolean;
  };
  
  // Site config
  sites: number;                 // 2
  sectors: number;               // total sectors
}

Display in History panel:

// Expanded history entry shows propagation details

<div className="history-entry-expanded">
  {/* Existing: time, points, coverage bars */}
  
  {/* NEW: Propagation summary (collapsed by default) */}
  <details className="mt-2">
    <summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300">
       Propagation: {entry.propagation.modelCount} models, {entry.propagation.frequency} MHz
    </summary>
    <div className="mt-1 pl-3 text-xs text-gray-500 space-y-0.5">
      <div>TX: {entry.propagation.txPower} dBm, Gain: {entry.propagation.antennaGain} dBi</div>
      <div>Height: {entry.propagation.antennaHeight}m</div>
      <div>Environment: {entry.propagation.season}, {entry.propagation.rainConditions}</div>
      <div>Indoor: {entry.propagation.indoorCoverage}</div>
      {entry.propagation.fadingMargin > 0 && (
        <div>Fading margin: {entry.propagation.fadingMargin} dB</div>
      )}
      <div className="flex flex-wrap gap-1 mt-1">
        {entry.propagation.modelsUsed.map(model => (
          <span key={model} className="px-1 py-0.5 bg-slate-700 rounded text-[10px]">
            {model}
          </span>
        ))}
      </div>
    </div>
  </details>
</div>

Files:

  • frontend/src/store/calcHistory.ts — extend HistoryEntry type, save propagation
  • frontend/src/components/panels/HistoryPanel.tsx — show expandable propagation details
  • backend/app/api/websocket.py — include propagation config in result message
  • backend/app/services/coverage_service.py — return config snapshot with results

4. Results Popup — Show Propagation Summary

Problem: Calculation Complete popup shows time, points, coverage bars — but not which models were used.

Solution: Add compact propagation info to results popup.

// frontend/src/components/ui/ResultsPopup.tsx

// Add below coverage bars:
<div className="mt-2 text-xs text-gray-400">
  <span>{result.modelsUsed?.length || 0} models</span>
  <span className="mx-1"></span>
  <span>{result.frequency} MHz</span>
  {result.fadingMargin > 0 && (
    <>
      <span className="mx-1"></span>
      <span>FM: {result.fadingMargin} dB</span>
    </>
  )}
  {result.indoorCoverage && result.indoorCoverage !== 'none' && (
    <>
      <span className="mx-1"></span>
      <span>Indoor: {result.indoorCoverage}</span>
    </>
  )}
</div>

Files:

  • frontend/src/components/ui/ResultsPopup.tsx

5. Batch Frequency Change (from 3.5.0 backlog)

Problem: To compare coverage at different frequencies, user must edit each sector manually.

Solution: Quick-change buttons in toolbar or Coverage Settings.

// frontend/src/components/panels/BatchOperations.tsx (NEW)

const QUICK_BANDS = [
  { freq: 700, label: '700', band: 'B28', color: 'text-red-400' },
  { freq: 800, label: '800', band: 'B20', color: 'text-orange-400' },
  { freq: 900, label: '900', band: 'B8', color: 'text-yellow-400' },
  { freq: 1800, label: '1800', band: 'B3', color: 'text-green-400' },
  { freq: 2100, label: '2100', band: 'B1', color: 'text-blue-400' },
  { freq: 2600, label: '2600', band: 'B7', color: 'text-purple-400' },
  { freq: 3500, label: '3500', band: 'n78', color: 'text-pink-400' },
];

export function BatchFrequencyChange() {
  return (
    <div className="p-3 border-t border-slate-700">
      <h4 className="text-xs font-semibold text-gray-400 mb-2">
        SET ALL SECTORS
      </h4>
      <div className="flex flex-wrap gap-1">
        {QUICK_BANDS.map(b => (
          <button
            key={b.freq}
            onClick={() => setAllSectorsFrequency(b.freq)}
            className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded"
            title={`${b.band}${b.freq} MHz`}
          >
            <span className={b.color}>{b.label}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

Location: Below site list, above Coverage Settings.

Files:

  • frontend/src/components/panels/BatchOperations.tsx (NEW)
  • frontend/src/store/sites.ts — add setAllSectorsFrequency() action

6. Minor UI Fixes

6.1 Terrain Profile — Click Propagation (verify fix)

  • Verify that clicking "Terrain Profile" button no longer adds ruler point
  • If still broken: ensure e.stopPropagation() AND e.preventDefault() on button

6.2 GPU Indicator — Shorter Label

  • Current: "CPU (NumPy)" — too long
  • Should be: "CPU" or " CPU"
  • When GPU active: " RTX 4060" (short device name)

6.3 Coordinate Display — Show Elevation WORKS

  • Elevation loads on hover with delay — NOT a bug
  • Shows "Elev: 380m ASL" after holding cursor on map
  • No fix needed

Implementation Order

Priority 1 — Quick Fixes (30 min)

  • GPU indicator positioning (no overlap with Fit)
  • GPU detection — install CuPy/PyOpenCL, diagnostics endpoint
  • Terrain Profile click fix (verify)

Priority 2 — History Enhancement (1 hour)

  • Extend HistoryEntry with propagation params
  • Save propagation snapshot on calculation complete
  • Expandable propagation details in History panel
  • Results popup — show model count + frequency

Priority 3 — Coverage Boundary (1-2 hours)

  • Implement contour-based boundary from actual RSRP grid
  • Replace circular approximation with real coverage shape
  • Test with multi-site calculations
  • Smooth boundary line (simplify polygon)

Priority 4 — Batch Frequency (30 min)

  • BatchOperations component
  • setAllSectorsFrequency store action
  • Wire into sidebar panel

Success Criteria

  • GPU indicator does not overlap with any map controls
  • Coverage boundary follows actual coverage shape (not circular)
  • History entries show expandable propagation parameters
  • Results popup shows model count and frequency
  • Batch frequency change updates all sectors at once
  • Terrain Profile button click doesn't add ruler point
  • Elevation displays correctly in bottom-left

Files Summary

New Files

  • frontend/src/components/panels/BatchOperations.tsx

Modified Files

  • frontend/src/components/ui/GPUIndicator.tsx — fix position/overlap
  • frontend/src/components/map/CoverageBoundary.tsx — real contour
  • frontend/src/components/ui/ResultsPopup.tsx — propagation info
  • frontend/src/store/calcHistory.ts — extended HistoryEntry
  • frontend/src/components/panels/HistoryPanel.tsx — expandable details
  • frontend/src/store/sites.ts — batch frequency action
  • backend/app/services/coverage_service.py — boundary calculation, config snapshot
  • backend/app/api/websocket.py — include config in results

"Polish makes the difference between a tool and a product"