@mytec: iter3.5.1 ready for testing

This commit is contained in:
2026-02-03 12:04:36 +02:00
parent 255b91f257
commit 20d19d09ae
14 changed files with 1583 additions and 8 deletions

View File

@@ -43,7 +43,8 @@
"Bash(kill:*)",
"Bash(sort:*)",
"Bash(journalctl:*)",
"Bash(pkill:*)"
"Bash(pkill:*)",
"Bash(pip3 list:*)"
]
}
}

View File

@@ -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}
```

View File

@@ -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' && (
<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):**
```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
<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.
```typescript
// 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.
```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 (
<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"*

View File

@@ -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()

View File

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

View File

@@ -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 */}
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
{/* Quick frequency change */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
<BatchFrequencyChange />
</div>
{/* Coverage settings */}
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">

View File

@@ -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' });

View File

@@ -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 (
<div className="p-3 border-t border-gray-200 dark:border-dark-border">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase">
Quick Frequency
</h4>
<span className="text-[10px] text-gray-400 dark:text-dark-muted">
{allSameFreq ? getBandName(currentFreq) : 'Mixed'}
</span>
</div>
<div className="flex flex-wrap gap-1">
{QUICK_BANDS.map((b) => {
const isActive = allSameFreq && currentFreq === b.freq;
return (
<button
key={b.freq}
onClick={() => 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)})`}
>
<span className={isActive ? '' : b.color}>{b.label}</span>
</button>
);
})}
</div>
<div className="mt-1.5 text-[10px] text-gray-400 dark:text-dark-muted">
Sets all {sites.length} sector{sites.length !== 1 ? 's' : ''} to selected band
</div>
</div>
);
}

View File

@@ -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 (
<div className="mt-1.5 pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1.5 text-[10px]">
{/* Coverage breakdown with percentages */}
@@ -38,6 +40,73 @@ function EntryDetail({ entry }: { entry: CalculationEntry }) {
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
</div>
{/* Propagation details */}
{p && (
<div className="pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1">
{/* Site parameters */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-gray-500 dark:text-dark-muted">
<span>{p.frequency} MHz</span>
<span>{p.txPower} dBm</span>
<span>{p.antennaGain} dBi</span>
<span>{p.antennaHeight} m</span>
</div>
{/* Models used */}
{p.modelsUsed.length > 0 && (
<div className="flex flex-wrap gap-1">
{p.modelsUsed.map((model) => (
<span
key={model}
className="px-1 py-0.5 bg-gray-100 dark:bg-dark-border text-gray-600 dark:text-dark-muted rounded"
>
{model}
</span>
))}
</div>
)}
{/* Active toggles summary */}
<div className="flex flex-wrap gap-1">
{p.use_terrain && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Terrain</span>
)}
{p.use_buildings && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Buildings</span>
)}
{p.use_materials && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Materials</span>
)}
{p.use_dominant_path && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">DomPath</span>
)}
{p.use_reflections && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Reflections</span>
)}
{p.use_vegetation && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Vegetation</span>
)}
{p.use_atmospheric && (
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Atmospheric</span>
)}
{p.fading_margin > 0 && (
<span className="px-1 py-0.5 bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 rounded">
-{p.fading_margin} dB fade
</span>
)}
{p.rain_rate > 0 && (
<span className="px-1 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded">
Rain {p.rain_rate} mm/h
</span>
)}
{p.indoor_loss_type !== 'none' && (
<span className="px-1 py-0.5 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded">
Indoor: {p.indoor_loss_type}
</span>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<GPUStatus | null>(null);
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const [diagnostics, setDiagnostics] = useState<Record<string, unknown> | null>(null);
const [showDiag, setShowDiag] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
@@ -59,13 +73,13 @@ export default function GPUIndicator() {
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-muted dark:hover:bg-dark-muted'
}`}
title={`Compute: ${label}`}
title={`Compute: ${status.active_device?.name ?? label}`}
>
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 w-56 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-50 py-1">
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-[9999] py-1">
<div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase">
Compute Devices
</div>
@@ -98,6 +112,37 @@ export default function GPUIndicator() {
</button>
);
})}
{/* Show help when only CPU available */}
{status.available_devices.length === 1 && status.active_backend === 'cpu' && (
<div className="border-t border-gray-100 dark:border-dark-border mt-1 pt-2 px-3 pb-2">
<div className="text-[10px] text-yellow-600 dark:text-yellow-400 mb-2">
No GPU detected. For faster calculations:
</div>
<div className="text-[10px] text-gray-500 dark:text-dark-muted space-y-0.5">
<div>NVIDIA: <code className="bg-gray-100 dark:bg-dark-border px-1 rounded">pip install cupy-cuda12x</code></div>
<div>Intel/AMD: <code className="bg-gray-100 dark:bg-dark-border px-1 rounded">pip install pyopencl</code></div>
</div>
<button
onClick={handleRunDiagnostics}
className="mt-2 w-full text-[10px] text-blue-600 dark:text-blue-400 hover:underline text-left"
>
Run Diagnostics
</button>
</div>
)}
{/* Diagnostics output */}
{showDiag && diagnostics && (
<div className="border-t border-gray-100 dark:border-dark-border mt-1 pt-2 px-3 pb-2 max-h-48 overflow-y-auto">
<div className="text-[10px] font-semibold text-gray-500 dark:text-dark-muted mb-1">
Diagnostics
</div>
<pre className="text-[9px] text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">
{JSON.stringify(diagnostics, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>

View File

@@ -240,6 +240,12 @@ class ApiService {
return response.json();
}
async getGPUDiagnostics(): Promise<Record<string, unknown>> {
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(

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -64,6 +64,7 @@ interface SitesState {
batchAdjustTilt: (delta: number) => Promise<void>;
batchSetTilt: (tilt: number) => Promise<void>;
batchSetFrequency: (frequency: number) => Promise<void>;
setAllSitesFrequency: (frequency: number) => Promise<void>;
}
export const useSitesStore = create<SitesState>((set, get) => ({
@@ -584,4 +585,30 @@ export const useSitesStore = create<SitesState>((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();
},
}));