diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 58e66b4..8425587 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -43,7 +43,8 @@
"Bash(kill:*)",
"Bash(sort:*)",
"Bash(journalctl:*)",
- "Bash(pkill:*)"
+ "Bash(pkill:*)",
+ "Bash(pip3 list:*)"
]
}
}
diff --git a/RFCP-Dependencies-Installer.md b/RFCP-Dependencies-Installer.md
new file mode 100644
index 0000000..3c35037
--- /dev/null
+++ b/RFCP-Dependencies-Installer.md
@@ -0,0 +1,656 @@
+# RFCP Dependencies & Installer Specification
+
+## Overview
+
+All dependencies needed for RFCP to work out of the box, including GPU acceleration.
+The installer must handle everything — user should NOT need to run pip manually.
+
+---
+
+## Python Dependencies
+
+### Core (MUST have)
+
+```txt
+# requirements.txt
+
+# Web framework
+fastapi>=0.104.0
+uvicorn[standard]>=0.24.0
+websockets>=12.0
+
+# Scientific computing
+numpy>=1.24.0
+scipy>=1.11.0
+
+# Geospatial
+pyproj>=3.6.0 # coordinate transformations
+shapely>=2.0.0 # geometry operations (boundary contours)
+
+# Terrain data
+rasterio>=1.3.0 # GeoTIFF reading (optional, for custom terrain)
+# Note: SRTM .hgt files read with numpy directly
+
+# OSM data
+requests>=2.31.0 # HTTP client for OSM Overpass API
+geopy>=2.4.0 # distance calculations
+
+# Database
+# sqlite3 is built-in Python — no install needed
+
+# Utilities
+orjson>=3.9.0 # fast JSON (optional, faster API responses)
+pydantic>=2.0.0 # data validation (FastAPI dependency)
+```
+
+### GPU Acceleration (OPTIONAL — auto-detected)
+
+```txt
+# requirements-gpu-nvidia.txt
+cupy-cuda12x>=12.0.0 # For CUDA 12.x (RTX 30xx, 40xx)
+# OR
+cupy-cuda11x>=11.0.0 # For CUDA 11.x (older cards)
+
+# requirements-gpu-opencl.txt
+pyopencl>=2023.1 # For ANY GPU (Intel, AMD, NVIDIA)
+```
+
+### Development / Testing
+
+```txt
+# requirements-dev.txt
+pytest>=7.0.0
+pytest-asyncio>=0.21.0
+httpx>=0.25.0 # async test client
+```
+
+---
+
+## System Dependencies
+
+### NVIDIA GPU Support
+
+```
+REQUIRED: NVIDIA Driver (comes with GPU)
+REQUIRED: CUDA Toolkit 12.x (for CuPy)
+
+Check if installed:
+ nvidia-smi → shows driver version
+ nvcc --version → shows CUDA toolkit version
+
+If missing CUDA toolkit:
+ Download from: https://developer.nvidia.com/cuda-downloads
+ Select: Windows > x86_64 > 11/10 > exe (local)
+ Size: ~3 GB
+
+Alternative: cupy auto-installs CUDA runtime!
+ pip install cupy-cuda12x
+ This bundles CUDA runtime (~700 MB) — no separate install needed
+```
+
+### Intel GPU Support (OpenCL)
+
+```
+REQUIRED: Intel GPU Driver (usually pre-installed)
+REQUIRED: Intel OpenCL Runtime
+
+Check if installed:
+ Open Device Manager → Display Adapters → Intel UHD/Iris
+
+For OpenCL:
+ Download Intel GPU Computing Runtime:
+ https://github.com/intel/compute-runtime/releases
+
+ Or: Intel oneAPI Base Toolkit (includes OpenCL)
+ https://www.intel.com/content/www/us/en/developer/tools/oneapi/base-toolkit-download.html
+```
+
+### AMD GPU Support (OpenCL)
+
+```
+REQUIRED: AMD Adrenalin Driver (includes OpenCL)
+Download from: https://www.amd.com/en/support
+```
+
+---
+
+## Node.js / Frontend Dependencies
+
+### System Requirements
+
+```
+Node.js >= 18.0.0 (LTS recommended)
+npm >= 9.0.0
+
+Check:
+ node --version
+ npm --version
+```
+
+### Frontend packages (managed by npm)
+
+```json
+// package.json — key dependencies
+{
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "leaflet": "^1.9.4",
+ "react-leaflet": "^4.2.0",
+ "recharts": "^2.8.0",
+ "zustand": "^4.4.0",
+ "lucide-react": "^0.294.0"
+ },
+ "devDependencies": {
+ "vite": "^5.0.0",
+ "typescript": "^5.3.0",
+ "tailwindcss": "^3.4.0",
+ "@types/leaflet": "^1.9.0"
+ }
+}
+```
+
+---
+
+## Installer Script
+
+### Windows Installer (NSIS or Electron-Builder)
+
+```python
+# install_rfcp.py — Python-based installer/setup script
+
+import subprocess
+import sys
+import platform
+import os
+import shutil
+import json
+
+def check_python():
+ """Verify Python 3.10+ is available."""
+ version = sys.version_info
+ if version.major < 3 or version.minor < 10:
+ print(f"❌ Python 3.10+ required, found {version.major}.{version.minor}")
+ return False
+ print(f"✅ Python {version.major}.{version.minor}.{version.micro}")
+ return True
+
+def check_node():
+ """Verify Node.js 18+ is available."""
+ try:
+ result = subprocess.run(["node", "--version"], capture_output=True, text=True)
+ version = result.stdout.strip().lstrip('v')
+ major = int(version.split('.')[0])
+ if major < 18:
+ print(f"❌ Node.js 18+ required, found {version}")
+ return False
+ print(f"✅ Node.js {version}")
+ return True
+ except FileNotFoundError:
+ print("❌ Node.js not found")
+ return False
+
+def detect_gpu():
+ """Detect available GPU hardware."""
+ gpus = {
+ "nvidia": False,
+ "nvidia_name": "",
+ "intel": False,
+ "intel_name": "",
+ "amd": False,
+ "amd_name": ""
+ }
+
+ # Check NVIDIA
+ try:
+ result = subprocess.run(
+ ["nvidia-smi", "--query-gpu=name,driver_version,memory.total",
+ "--format=csv,noheader"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ info = result.stdout.strip()
+ gpus["nvidia"] = True
+ gpus["nvidia_name"] = info.split(",")[0].strip()
+ print(f"✅ NVIDIA GPU: {info}")
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ print("ℹ️ No NVIDIA GPU detected")
+
+ # Check Intel/AMD via WMI (Windows)
+ if platform.system() == "Windows":
+ try:
+ result = subprocess.run(
+ ["wmic", "path", "win32_videocontroller", "get",
+ "name,adapterram,driverversion", "/format:csv"],
+ capture_output=True, text=True, timeout=5
+ )
+ for line in result.stdout.strip().split('\n'):
+ if 'Intel' in line:
+ gpus["intel"] = True
+ gpus["intel_name"] = [x for x in line.split(',') if 'Intel' in x][0]
+ print(f"✅ Intel GPU: {gpus['intel_name']}")
+ elif 'AMD' in line or 'Radeon' in line:
+ gpus["amd"] = True
+ gpus["amd_name"] = [x for x in line.split(',') if 'AMD' in x or 'Radeon' in x][0]
+ print(f"✅ AMD GPU: {gpus['amd_name']}")
+ except Exception:
+ pass
+
+ return gpus
+
+def install_core_dependencies():
+ """Install core Python dependencies."""
+ print("\n📦 Installing core dependencies...")
+ subprocess.run([
+ sys.executable, "-m", "pip", "install", "-r", "requirements.txt",
+ "--quiet", "--no-warn-script-location"
+ ], check=True)
+ print("✅ Core dependencies installed")
+
+def install_gpu_dependencies(gpus: dict):
+ """Install GPU-specific dependencies based on detected hardware."""
+ print("\n🎮 Setting up GPU acceleration...")
+
+ gpu_installed = False
+
+ # NVIDIA — install CuPy (includes CUDA runtime)
+ if gpus["nvidia"]:
+ print(f" Installing CuPy for {gpus['nvidia_name']}...")
+ try:
+ # Try CUDA 12 first (newer cards)
+ subprocess.run([
+ sys.executable, "-m", "pip", "install", "cupy-cuda12x",
+ "--quiet", "--no-warn-script-location"
+ ], check=True, timeout=300)
+ print(f" ✅ CuPy (CUDA 12) installed for {gpus['nvidia_name']}")
+ gpu_installed = True
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
+ try:
+ # Fallback to CUDA 11
+ subprocess.run([
+ sys.executable, "-m", "pip", "install", "cupy-cuda11x",
+ "--quiet", "--no-warn-script-location"
+ ], check=True, timeout=300)
+ print(f" ✅ CuPy (CUDA 11) installed for {gpus['nvidia_name']}")
+ gpu_installed = True
+ except Exception as e:
+ print(f" ⚠️ CuPy installation failed: {e}")
+ print(f" 💡 Manual install: pip install cupy-cuda12x")
+
+ # Intel/AMD — install PyOpenCL
+ if gpus["intel"] or gpus["amd"]:
+ gpu_name = gpus["intel_name"] or gpus["amd_name"]
+ print(f" Installing PyOpenCL for {gpu_name}...")
+ try:
+ subprocess.run([
+ sys.executable, "-m", "pip", "install", "pyopencl",
+ "--quiet", "--no-warn-script-location"
+ ], check=True, timeout=120)
+ print(f" ✅ PyOpenCL installed for {gpu_name}")
+ gpu_installed = True
+ except Exception as e:
+ print(f" ⚠️ PyOpenCL installation failed: {e}")
+ print(f" 💡 Manual install: pip install pyopencl")
+
+ if not gpu_installed:
+ print(" ℹ️ No GPU acceleration available — using CPU (NumPy)")
+ print(" 💡 This is fine! GPU just makes large calculations faster.")
+
+ return gpu_installed
+
+def install_frontend():
+ """Install frontend dependencies and build."""
+ print("\n🌐 Setting up frontend...")
+ frontend_dir = os.path.join(os.path.dirname(__file__), "frontend")
+
+ if os.path.exists(os.path.join(frontend_dir, "package.json")):
+ subprocess.run(["npm", "install"], cwd=frontend_dir, check=True)
+ subprocess.run(["npm", "run", "build"], cwd=frontend_dir, check=True)
+ print("✅ Frontend built")
+ else:
+ print("⚠️ Frontend directory not found")
+
+def download_terrain_data():
+ """Pre-download SRTM terrain tiles for Ukraine."""
+ print("\n🏔️ Checking terrain data...")
+ cache_dir = os.path.expanduser("~/.rfcp/terrain")
+ os.makedirs(cache_dir, exist_ok=True)
+
+ # Ukraine bounding box: lat 44-53, lon 22-41
+ # SRTM tiles needed for typical use
+ required_tiles = [
+ # Lviv oblast area (common test area)
+ "N49E025", "N49E024", "N49E026",
+ "N50E025", "N50E024", "N50E026",
+ # Dnipro area
+ "N48E034", "N48E035",
+ "N49E034", "N49E035",
+ ]
+
+ existing = [f.replace(".hgt", "") for f in os.listdir(cache_dir) if f.endswith(".hgt")]
+ missing = [t for t in required_tiles if t not in existing]
+
+ if missing:
+ print(f" {len(missing)} terrain tiles needed (auto-download on first use)")
+ else:
+ print(f" ✅ {len(existing)} terrain tiles cached")
+
+def create_launcher():
+ """Create desktop shortcut / launcher script."""
+ print("\n🚀 Creating launcher...")
+
+ if platform.system() == "Windows":
+ # Create .bat launcher
+ launcher = os.path.join(os.path.dirname(__file__), "RFCP.bat")
+ with open(launcher, 'w') as f:
+ f.write('@echo off\n')
+ f.write('title RFCP - RF Coverage Planner\n')
+ f.write('echo Starting RFCP...\n')
+ f.write(f'cd /d "{os.path.dirname(__file__)}"\n')
+ f.write(f'"{sys.executable}" -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888\n')
+ print(f" ✅ Launcher created: {launcher}")
+
+ return True
+
+def verify_installation():
+ """Run quick verification tests."""
+ print("\n🔍 Verifying installation...")
+
+ checks = []
+
+ # Check core imports
+ try:
+ import numpy as np
+ checks.append(f"✅ NumPy {np.__version__}")
+ except ImportError:
+ checks.append("❌ NumPy missing")
+
+ try:
+ import scipy
+ checks.append(f"✅ SciPy {scipy.__version__}")
+ except ImportError:
+ checks.append("❌ SciPy missing")
+
+ try:
+ import fastapi
+ checks.append(f"✅ FastAPI {fastapi.__version__}")
+ except ImportError:
+ checks.append("❌ FastAPI missing")
+
+ try:
+ import shapely
+ checks.append(f"✅ Shapely {shapely.__version__}")
+ except ImportError:
+ checks.append("⚠️ Shapely missing (boundary features disabled)")
+
+ # Check GPU
+ try:
+ import cupy as cp
+ device = cp.cuda.Device(0)
+ checks.append(f"✅ CuPy → {device.name} ({device.mem_info[1]//1024//1024} MB)")
+ except ImportError:
+ checks.append("ℹ️ CuPy not available")
+ except Exception as e:
+ checks.append(f"⚠️ CuPy error: {e}")
+
+ try:
+ import pyopencl as cl
+ devices = []
+ for p in cl.get_platforms():
+ for d in p.get_devices():
+ devices.append(d.name)
+ checks.append(f"✅ PyOpenCL → {', '.join(devices)}")
+ except ImportError:
+ checks.append("ℹ️ PyOpenCL not available")
+ except Exception as e:
+ checks.append(f"⚠️ PyOpenCL error: {e}")
+
+ for check in checks:
+ print(f" {check}")
+
+ return all("❌" not in c for c in checks)
+
+def main():
+ """Main installer entry point."""
+ print("=" * 60)
+ print(" RFCP — RF Coverage Planner — Installer")
+ print("=" * 60)
+ print()
+
+ # Step 1: Check prerequisites
+ print("📋 Checking prerequisites...")
+ if not check_python():
+ sys.exit(1)
+ check_node()
+
+ # Step 2: Detect GPU
+ gpus = detect_gpu()
+
+ # Step 3: Install dependencies
+ install_core_dependencies()
+ install_gpu_dependencies(gpus)
+
+ # Step 4: Frontend
+ install_frontend()
+
+ # Step 5: Terrain data
+ download_terrain_data()
+
+ # Step 6: Launcher
+ create_launcher()
+
+ # Step 7: Verify
+ print()
+ success = verify_installation()
+
+ # Summary
+ print()
+ print("=" * 60)
+ if success:
+ print(" ✅ RFCP installed successfully!")
+ print()
+ print(" To start RFCP:")
+ print(" python -m uvicorn backend.app.main:app --port 8888")
+ print(" Then open: http://localhost:8888")
+ print()
+ if gpus["nvidia"]:
+ print(f" 🎮 GPU: {gpus['nvidia_name']} (CUDA)")
+ elif gpus["intel"] or gpus["amd"]:
+ gpu_name = gpus["intel_name"] or gpus["amd_name"]
+ print(f" 🎮 GPU: {gpu_name} (OpenCL)")
+ else:
+ print(" 💻 Mode: CPU only")
+ else:
+ print(" ⚠️ Installation completed with warnings")
+ print(" Some features may be limited")
+ print("=" * 60)
+
+if __name__ == "__main__":
+ main()
+```
+
+---
+
+## Electron-Builder / NSIS Packaging
+
+### For .exe Installer
+
+```yaml
+# electron-builder.yml
+
+appId: com.rfcp.coverage-planner
+productName: "RFCP - RF Coverage Planner"
+copyright: "RFCP 2026"
+
+directories:
+ output: dist
+ buildResources: build
+
+files:
+ - "backend/**/*"
+ - "frontend/dist/**/*"
+ - "requirements.txt"
+ - "install_rfcp.py"
+ - "!**/*.pyc"
+ - "!**/node_modules/**"
+ - "!**/venv/**"
+
+extraResources:
+ - from: "python-embedded/"
+ to: "python/"
+ - from: "terrain-data/"
+ to: "terrain/"
+
+win:
+ target:
+ - target: nsis
+ arch: [x64]
+ icon: "build/icon.ico"
+
+nsis:
+ oneClick: false
+ allowToChangeInstallationDirectory: true
+ installerIcon: "build/icon.ico"
+ license: "LICENSE.md"
+
+ # Custom NSIS script for GPU detection
+ include: "build/gpu-detect.nsh"
+
+ # Install steps:
+ # 1. Extract files
+ # 2. Run install_rfcp.py (detects GPU, installs deps)
+ # 3. Create Start Menu shortcuts
+ # 4. Create Desktop shortcut
+```
+
+### Portable Version (.zip)
+
+```
+RFCP-Portable/
+├── RFCP.bat # Main launcher
+├── install.bat # First-time setup
+├── backend/
+│ ├── app/
+│ │ ├── main.py
+│ │ ├── api/
+│ │ ├── services/
+│ │ └── models/
+│ └── requirements.txt
+├── frontend/
+│ └── dist/ # Pre-built frontend
+├── python/ # Embedded Python (optional)
+│ ├── python.exe
+│ └── Lib/
+├── terrain/ # Pre-cached .hgt files
+│ ├── N49E025.hgt
+│ └── ...
+├── data/
+│ ├── osm_cache.db # SQLite cache (created on first run)
+│ └── config.json # User settings
+└── README.md
+```
+
+### install.bat (First-Time Setup)
+
+```batch
+@echo off
+title RFCP - First Time Setup
+echo ============================================
+echo RFCP - RF Coverage Planner - Setup
+echo ============================================
+echo.
+
+REM Check if Python exists
+python --version >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Python not found!
+ echo Please install Python 3.10+ from python.org
+ pause
+ exit /b 1
+)
+
+REM Run installer
+python install_rfcp.py
+
+echo.
+echo Setup complete! Run RFCP.bat to start.
+pause
+```
+
+### RFCP.bat (Launcher)
+
+```batch
+@echo off
+title RFCP - RF Coverage Planner
+cd /d "%~dp0"
+
+REM Check if installed
+if not exist "backend\app\main.py" (
+ echo ERROR: RFCP not found. Run install.bat first.
+ pause
+ exit /b 1
+)
+
+echo Starting RFCP...
+echo Open http://localhost:8888 in your browser
+echo Press Ctrl+C to stop
+echo.
+
+python -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888
+```
+
+---
+
+## Dependency Size Estimates
+
+| Component | Size |
+|-----------|------|
+| Python (embedded) | ~30 MB |
+| Core pip packages | ~80 MB |
+| CuPy + CUDA runtime | ~700 MB |
+| PyOpenCL | ~15 MB |
+| Frontend (built) | ~5 MB |
+| SRTM terrain (Ukraine) | ~300 MB |
+| **Total (with CUDA)** | **~1.1 GB** |
+| **Total (CPU only)** | **~415 MB** |
+
+---
+
+## Runtime Requirements
+
+| Resource | Minimum | Recommended |
+|----------|---------|-------------|
+| RAM | 4 GB | 8+ GB |
+| Disk | 500 MB | 2 GB (with terrain cache) |
+| CPU | 4 cores | 8+ cores |
+| GPU | - | NVIDIA GTX 1060+ / Intel UHD 630+ |
+| OS | Windows 10 | Windows 10/11 64-bit |
+| Python | 3.10 | 3.11+ |
+| Node.js | 18 | 20 LTS |
+
+---
+
+## Auto-Update Mechanism (Future)
+
+```python
+# Check for updates on startup
+async def check_for_updates():
+ try:
+ response = await httpx.get(
+ "https://api.github.com/repos/user/rfcp/releases/latest",
+ timeout=5
+ )
+ latest = response.json()["tag_name"]
+ current = get_current_version()
+
+ if latest != current:
+ return {
+ "update_available": True,
+ "current": current,
+ "latest": latest,
+ "download_url": response.json()["assets"][0]["browser_download_url"]
+ }
+ except:
+ pass
+ return {"update_available": False}
+```
diff --git a/RFCP-Iteration-3.5.1-Bugfixes-Polish.md b/RFCP-Iteration-3.5.1-Bugfixes-Polish.md
new file mode 100644
index 0000000..964c26b
--- /dev/null
+++ b/RFCP-Iteration-3.5.1-Bugfixes-Polish.md
@@ -0,0 +1,557 @@
+# 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:**
+
+```python
+# 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:**
+
+```python
+# 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:**
+
+```typescript
+// In GPUIndicator.tsx — when only CPU detected, show help
+
+{devices.length === 1 && devices[0].type === 'cpu' && (
+
+ ⚠ No GPU detected.
+ fetchDiagnostics()}
+ className="underline ml-1"
+ >
+ Run diagnostics
+
+
+)}
+```
+
+**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):**
+```python
+# 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:**
+
+```python
+# 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]
+```
+
+```python
+# 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.
+
+```typescript
+// 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:**
+
+```typescript
+// Expanded history entry shows propagation details
+
+
+ {/* Existing: time, points, coverage bars */}
+
+ {/* NEW: Propagation summary (collapsed by default) */}
+
+
+ ▸ Propagation: {entry.propagation.modelCount} models, {entry.propagation.frequency} MHz
+
+
+
TX: {entry.propagation.txPower} dBm, Gain: {entry.propagation.antennaGain} dBi
+
Height: {entry.propagation.antennaHeight}m
+
Environment: {entry.propagation.season}, {entry.propagation.rainConditions}
+
Indoor: {entry.propagation.indoorCoverage}
+ {entry.propagation.fadingMargin > 0 && (
+
Fading margin: {entry.propagation.fadingMargin} dB
+ )}
+
+ {entry.propagation.modelsUsed.map(model => (
+
+ {model}
+
+ ))}
+
+
+
+
+```
+
+**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.
+
+```typescript
+// frontend/src/components/ui/ResultsPopup.tsx
+
+// Add below coverage bars:
+
+ {result.modelsUsed?.length || 0} models
+ •
+ {result.frequency} MHz
+ {result.fadingMargin > 0 && (
+ <>
+ •
+ FM: {result.fadingMargin} dB
+ >
+ )}
+ {result.indoorCoverage && result.indoorCoverage !== 'none' && (
+ <>
+ •
+ Indoor: {result.indoorCoverage}
+ >
+ )}
+
+```
+
+**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.
+
+```typescript
+// 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 (
+
+
+ SET ALL SECTORS
+
+
+ {QUICK_BANDS.map(b => (
+ setAllSectorsFrequency(b.freq)}
+ className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded"
+ title={`${b.band} — ${b.freq} MHz`}
+ >
+ {b.label}
+
+ ))}
+
+
+ );
+}
+```
+
+**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"* ✨
diff --git a/backend/app/api/routes/gpu.py b/backend/app/api/routes/gpu.py
index 8f62e57..c1e1e0e 100644
--- a/backend/app/api/routes/gpu.py
+++ b/backend/app/api/routes/gpu.py
@@ -33,3 +33,9 @@ async def gpu_set_device(request: SetDeviceRequest):
return {"status": "ok", **result}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/diagnostics")
+async def gpu_diagnostics():
+ """Full GPU diagnostic info for troubleshooting detection issues."""
+ return gpu_manager.get_diagnostics()
diff --git a/backend/app/services/gpu_backend.py b/backend/app/services/gpu_backend.py
index 36a4c2f..23d258a 100644
--- a/backend/app/services/gpu_backend.py
+++ b/backend/app/services/gpu_backend.py
@@ -167,6 +167,66 @@ class GPUManager:
for d in self._devices
]
+ def get_diagnostics(self) -> dict:
+ """Full diagnostic info for troubleshooting GPU detection."""
+ import sys
+ import platform
+
+ diag = {
+ "python_version": sys.version,
+ "platform": platform.platform(),
+ "numpy": {"version": np.__version__},
+ "cuda": {},
+ "opencl": {},
+ "detected_devices": len(self._devices),
+ "active_backend": self._active_backend.value,
+ }
+
+ # Check CuPy/CUDA
+ try:
+ import cupy as cp
+ diag["cuda"]["cupy_version"] = cp.__version__
+ diag["cuda"]["cuda_runtime_version"] = cp.cuda.runtime.runtimeGetVersion()
+ diag["cuda"]["device_count"] = cp.cuda.runtime.getDeviceCount()
+ for i in range(diag["cuda"]["device_count"]):
+ props = cp.cuda.runtime.getDeviceProperties(i)
+ name = props["name"]
+ if isinstance(name, bytes):
+ name = name.decode()
+ diag["cuda"][f"device_{i}"] = {
+ "name": str(name),
+ "memory_mb": props["totalGlobalMem"] // (1024 * 1024),
+ "compute_capability": f"{props['major']}.{props['minor']}",
+ }
+ except ImportError:
+ diag["cuda"]["error"] = "CuPy not installed"
+ diag["cuda"]["install_hint"] = "pip install cupy-cuda12x"
+ except Exception as e:
+ diag["cuda"]["error"] = str(e)
+
+ # Check PyOpenCL
+ try:
+ import pyopencl as cl
+ diag["opencl"]["pyopencl_version"] = cl.VERSION_TEXT
+ diag["opencl"]["platforms"] = []
+ for p in cl.get_platforms():
+ platform_info = {"name": p.name.strip(), "devices": []}
+ for d in p.get_devices():
+ platform_info["devices"].append({
+ "name": d.name.strip(),
+ "type": cl.device_type.to_string(d.type),
+ "memory_mb": d.global_mem_size // (1024 * 1024),
+ "compute_units": d.max_compute_units,
+ })
+ diag["opencl"]["platforms"].append(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)
+
+ return diag
+
def set_device(self, backend: str, index: int = 0) -> dict:
"""Switch active compute device."""
target_backend = GPUBackend(backend)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 96a00ad..c93790c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -20,6 +20,7 @@ import ExportPanel from '@/components/panels/ExportPanel.tsx';
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
import CoverageStats from '@/components/panels/CoverageStats.tsx';
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
+import BatchFrequencyChange from '@/components/panels/BatchFrequencyChange.tsx';
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
import { SiteConfigModal } from '@/components/modals/index.ts';
@@ -728,6 +729,11 @@ export default function App() {
{/* Site list */}
+ {/* Quick frequency change */}
+
+
+
+
{/* Coverage settings */}
diff --git a/frontend/src/components/map/CoverageBoundary.tsx b/frontend/src/components/map/CoverageBoundary.tsx
index db7b8d4..72ed5c1 100644
--- a/frontend/src/components/map/CoverageBoundary.tsx
+++ b/frontend/src/components/map/CoverageBoundary.tsx
@@ -107,7 +107,10 @@ export default function CoverageBoundary({
/**
* Compute concave hull boundary path(s) for a set of coverage points.
*
- * maxEdge = resolution * 3 (in km) gives good detail without over-fitting.
+ * Uses adaptive maxEdge based on point count and resolution:
+ * - More points → smaller maxEdge for finer detail
+ * - Larger resolution → larger maxEdge to avoid over-fitting
+ *
* Returns multiple paths if hull is a MultiPolygon (disjoint coverage areas).
* Falls back to empty if hull computation fails (e.g., collinear points).
*/
@@ -121,8 +124,17 @@ function computeConcaveHulls(
const features = pts.map((p) => point([p.lon, p.lat]));
const fc = featureCollection(features);
- // maxEdge in km — resolution * 3 balances detail vs smoothness
- const maxEdge = (resolutionM * 3) / 1000;
+ // Adaptive maxEdge based on point density:
+ // - Base: resolution * 2 (tighter fit)
+ // - For sparse grids (<100 pts): use larger edge to avoid holes
+ // - For dense grids (>1000 pts): use smaller edge for detail
+ let multiplier = 2.0;
+ if (pts.length < 100) {
+ multiplier = 4.0; // Sparse: wider tolerance
+ } else if (pts.length > 1000) {
+ multiplier = 1.5; // Dense: finer detail
+ }
+ const maxEdge = (resolutionM * multiplier) / 1000;
try {
const hull = concave(fc, { maxEdge, units: 'kilometers' });
diff --git a/frontend/src/components/panels/BatchFrequencyChange.tsx b/frontend/src/components/panels/BatchFrequencyChange.tsx
new file mode 100644
index 0000000..14e2529
--- /dev/null
+++ b/frontend/src/components/panels/BatchFrequencyChange.tsx
@@ -0,0 +1,77 @@
+/**
+ * Quick frequency band selector for setting all sectors at once.
+ * Enables rapid comparison of coverage at different frequency bands.
+ */
+
+import { useSitesStore } from '@/store/sites.ts';
+import { COMMON_FREQUENCIES, FREQUENCY_GROUPS } from '@/constants/frequencies.ts';
+
+const QUICK_BANDS = [
+ { freq: 70, label: '70', color: 'text-indigo-400' },
+ { freq: 225, label: '225', color: 'text-cyan-400' },
+ { freq: 700, label: '700', color: 'text-red-400' },
+ { freq: 800, label: '800', color: 'text-orange-400' },
+ { freq: 900, label: '900', color: 'text-yellow-400' },
+ { freq: 1800, label: '1.8G', color: 'text-green-400' },
+ { freq: 2100, label: '2.1G', color: 'text-blue-400' },
+ { freq: 2600, label: '2.6G', color: 'text-purple-400' },
+ { freq: 3500, label: '3.5G', color: 'text-pink-400' },
+];
+
+export default function BatchFrequencyChange() {
+ const sites = useSitesStore((s) => s.sites);
+ const setAllSitesFrequency = useSitesStore((s) => s.setAllSitesFrequency);
+
+ if (sites.length === 0) return null;
+
+ // Get current frequency (from first site)
+ const currentFreq = sites[0]?.frequency ?? 1800;
+
+ // Check if all sites have same frequency
+ const allSameFreq = sites.every((s) => s.frequency === currentFreq);
+
+ // Get band info
+ const getBandName = (freq: number) => {
+ const band = COMMON_FREQUENCIES.find((b) => b.value === freq);
+ return band?.name ?? `${freq} MHz`;
+ };
+
+ const handleSetFrequency = async (freq: number) => {
+ await setAllSitesFrequency(freq);
+ };
+
+ return (
+
+
+
+ Quick Frequency
+
+
+ {allSameFreq ? getBandName(currentFreq) : 'Mixed'}
+
+
+
+ {QUICK_BANDS.map((b) => {
+ const isActive = allSameFreq && currentFreq === b.freq;
+ return (
+ handleSetFrequency(b.freq)}
+ className={`px-2 py-1 text-xs rounded transition-colors ${
+ isActive
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 ring-1 ring-blue-400'
+ : 'bg-gray-100 hover:bg-gray-200 dark:bg-dark-border dark:hover:bg-dark-muted text-gray-700 dark:text-dark-text'
+ }`}
+ title={`Set all sectors to ${b.freq} MHz (${getBandName(b.freq)})`}
+ >
+ {b.label}
+
+ );
+ })}
+
+
+ Sets all {sites.length} sector{sites.length !== 1 ? 's' : ''} to selected band
+
+
+ );
+}
diff --git a/frontend/src/components/panels/HistoryPanel.tsx b/frontend/src/components/panels/HistoryPanel.tsx
index 95f8afb..0a8ec8a 100644
--- a/frontend/src/components/panels/HistoryPanel.tsx
+++ b/frontend/src/components/panels/HistoryPanel.tsx
@@ -3,6 +3,8 @@ import { useCalcHistoryStore } from '@/store/calcHistory.ts';
import type { CalculationEntry } from '@/store/calcHistory.ts';
function EntryDetail({ entry }: { entry: CalculationEntry }) {
+ const p = entry.propagation;
+
return (
{/* Coverage breakdown with percentages */}
@@ -38,6 +40,73 @@ function EntryDetail({ entry }: { entry: CalculationEntry }) {
Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm
Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm
+
+ {/* Propagation details */}
+ {p && (
+
+ {/* Site parameters */}
+
+ {p.frequency} MHz
+ {p.txPower} dBm
+ {p.antennaGain} dBi
+ {p.antennaHeight} m
+
+
+ {/* Models used */}
+ {p.modelsUsed.length > 0 && (
+
+ {p.modelsUsed.map((model) => (
+
+ {model}
+
+ ))}
+
+ )}
+
+ {/* Active toggles summary */}
+
+ {p.use_terrain && (
+ Terrain
+ )}
+ {p.use_buildings && (
+ Buildings
+ )}
+ {p.use_materials && (
+ Materials
+ )}
+ {p.use_dominant_path && (
+ DomPath
+ )}
+ {p.use_reflections && (
+ Reflections
+ )}
+ {p.use_vegetation && (
+ Vegetation
+ )}
+ {p.use_atmospheric && (
+ Atmospheric
+ )}
+ {p.fading_margin > 0 && (
+
+ -{p.fading_margin} dB fade
+
+ )}
+ {p.rain_rate > 0 && (
+
+ Rain {p.rain_rate} mm/h
+
+ )}
+ {p.indoor_loss_type !== 'none' && (
+
+ Indoor: {p.indoor_loss_type}
+
+ )}
+
+
+ )}
);
}
diff --git a/frontend/src/components/ui/GPUIndicator.tsx b/frontend/src/components/ui/GPUIndicator.tsx
index 3747d4c..a98f862 100644
--- a/frontend/src/components/ui/GPUIndicator.tsx
+++ b/frontend/src/components/ui/GPUIndicator.tsx
@@ -1,6 +1,7 @@
/**
* Small header badge showing the active compute backend (CPU or GPU).
* Fetches status on mount. Clicking opens a dropdown to switch devices.
+ * Dropdown opens to the LEFT to avoid overlapping map controls.
*/
import { useState, useEffect, useRef } from 'react';
@@ -11,6 +12,8 @@ export default function GPUIndicator() {
const [status, setStatus] = useState(null);
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
+ const [diagnostics, setDiagnostics] = useState | null>(null);
+ const [showDiag, setShowDiag] = useState(false);
const ref = useRef(null);
useEffect(() => {
@@ -23,6 +26,7 @@ export default function GPUIndicator() {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
+ setShowDiag(false);
}
};
document.addEventListener('mousedown', handler);
@@ -32,7 +36,7 @@ export default function GPUIndicator() {
if (!status) return null;
const isGPU = status.active_backend !== 'cpu';
- // Short label for header badge
+ // Short label: just "CPU" or first word of GPU name
const label = isGPU
? (status.active_device?.name?.split(' ')[0] ?? 'GPU')
: 'CPU';
@@ -50,6 +54,16 @@ export default function GPUIndicator() {
setOpen(false);
};
+ const handleRunDiagnostics = async () => {
+ try {
+ const diag = await api.getGPUDiagnostics();
+ setDiagnostics(diag);
+ setShowDiag(true);
+ } catch {
+ // ignore
+ }
+ };
+
return (
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
{open && (
-
+
Compute Devices
@@ -98,6 +112,37 @@ export default function GPUIndicator() {
);
})}
+
+ {/* Show help when only CPU available */}
+ {status.available_devices.length === 1 && status.active_backend === 'cpu' && (
+
+
+ No GPU detected. For faster calculations:
+
+
+
NVIDIA: pip install cupy-cuda12x
+
Intel/AMD: pip install pyopencl
+
+
+ Run Diagnostics
+
+
+ )}
+
+ {/* Diagnostics output */}
+ {showDiag && diagnostics && (
+
+
+ Diagnostics
+
+
+ {JSON.stringify(diagnostics, null, 2)}
+
+
+ )}
)}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 7eea18a..83bb252 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -240,6 +240,12 @@ class ApiService {
return response.json();
}
+ async getGPUDiagnostics(): Promise
> {
+ const response = await fetch(`${API_BASE}/api/gpu/diagnostics`);
+ if (!response.ok) throw new Error('Failed to get GPU diagnostics');
+ return response.json();
+ }
+
// === Terrain Profile API ===
async getTerrainProfile(
diff --git a/frontend/src/store/calcHistory.ts b/frontend/src/store/calcHistory.ts
index f5a271f..2b238ba 100644
--- a/frontend/src/store/calcHistory.ts
+++ b/frontend/src/store/calcHistory.ts
@@ -1,5 +1,29 @@
import { create } from 'zustand';
+export interface PropagationSnapshot {
+ // Models used
+ modelsUsed: string[];
+ use_terrain: boolean;
+ use_buildings: boolean;
+ use_materials: boolean;
+ use_dominant_path: boolean;
+ use_street_canyon: boolean;
+ use_reflections: boolean;
+ use_water_reflection: boolean;
+ use_vegetation: boolean;
+ use_atmospheric: boolean;
+ // Site params (first site or average)
+ frequency: number;
+ txPower: number;
+ antennaGain: number;
+ antennaHeight: number;
+ // Environmental
+ season: string;
+ rain_rate: number;
+ indoor_loss_type: string;
+ fading_margin: number;
+}
+
export interface CalculationEntry {
id: string;
timestamp: Date;
@@ -12,6 +36,8 @@ export interface CalculationEntry {
avgRsrp: number;
rangeMin: number;
rangeMax: number;
+ // Propagation snapshot for detailed history
+ propagation?: PropagationSnapshot;
}
interface CalcHistoryState {
diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts
index 7ac9498..7504bc8 100644
--- a/frontend/src/store/coverage.ts
+++ b/frontend/src/store/coverage.ts
@@ -5,7 +5,7 @@ import type { WSProgress } from '@/services/websocket.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
-import type { CalculationEntry } from '@/store/calcHistory.ts';
+import type { CalculationEntry, PropagationSnapshot } from '@/store/calcHistory.ts';
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
@@ -119,6 +119,32 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
const avgRsrp = result.stats?.avg_rsrp
?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
+ // Capture propagation snapshot from settings + sites
+ const sites = useSitesStore.getState().sites.filter((s) => s.visible);
+ const firstSite = sites[0];
+ const settings = result.settings;
+
+ const propagation: PropagationSnapshot = {
+ modelsUsed: result.modelsUsed ?? [],
+ use_terrain: settings.use_terrain ?? true,
+ use_buildings: settings.use_buildings ?? true,
+ use_materials: settings.use_materials ?? true,
+ use_dominant_path: settings.use_dominant_path ?? false,
+ use_street_canyon: settings.use_street_canyon ?? false,
+ use_reflections: settings.use_reflections ?? false,
+ use_water_reflection: settings.use_water_reflection ?? false,
+ use_vegetation: settings.use_vegetation ?? false,
+ use_atmospheric: settings.use_atmospheric ?? false,
+ frequency: firstSite?.frequency ?? 1800,
+ txPower: firstSite?.power ?? 43,
+ antennaGain: firstSite?.gain ?? 18,
+ antennaHeight: firstSite?.height ?? 30,
+ season: settings.season ?? 'summer',
+ rain_rate: settings.rain_rate ?? 0,
+ indoor_loss_type: settings.indoor_loss_type ?? 'none',
+ fading_margin: settings.fading_margin ?? 0,
+ };
+
return {
id: crypto.randomUUID(),
timestamp: new Date(),
@@ -136,6 +162,7 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
avgRsrp,
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
+ propagation,
};
}
diff --git a/frontend/src/store/sites.ts b/frontend/src/store/sites.ts
index 0b27962..1c4a2ce 100644
--- a/frontend/src/store/sites.ts
+++ b/frontend/src/store/sites.ts
@@ -64,6 +64,7 @@ interface SitesState {
batchAdjustTilt: (delta: number) => Promise;
batchSetTilt: (tilt: number) => Promise;
batchSetFrequency: (frequency: number) => Promise;
+ setAllSitesFrequency: (frequency: number) => Promise;
}
export const useSitesStore = create((set, get) => ({
@@ -584,4 +585,30 @@ export const useSitesStore = create((set, get) => ({
set({ sites: updatedSites });
useCoverageStore.getState().clearCoverage();
},
+
+ setAllSitesFrequency: async (frequency: number) => {
+ const { sites } = get();
+ if (sites.length === 0) return;
+ pushSnapshot('set all sites frequency', sites);
+ const clamped = Math.max(100, Math.min(6000, frequency));
+ const now = new Date();
+
+ const updatedSites = sites.map((site) => ({
+ ...site,
+ frequency: clamped,
+ updatedAt: now,
+ }));
+
+ for (const site of updatedSites) {
+ await db.sites.put({
+ id: site.id,
+ data: JSON.stringify(site),
+ createdAt: site.createdAt.getTime(),
+ updatedAt: now.getTime(),
+ });
+ }
+
+ set({ sites: updatedSites });
+ useCoverageStore.getState().clearCoverage();
+ },
}));