@mytec: iter3.5.1 ready for testing
This commit is contained in:
@@ -43,7 +43,8 @@
|
|||||||
"Bash(kill:*)",
|
"Bash(kill:*)",
|
||||||
"Bash(sort:*)",
|
"Bash(sort:*)",
|
||||||
"Bash(journalctl:*)",
|
"Bash(journalctl:*)",
|
||||||
"Bash(pkill:*)"
|
"Bash(pkill:*)",
|
||||||
|
"Bash(pip3 list:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
656
RFCP-Dependencies-Installer.md
Normal file
656
RFCP-Dependencies-Installer.md
Normal 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}
|
||||||
|
```
|
||||||
557
RFCP-Iteration-3.5.1-Bugfixes-Polish.md
Normal file
557
RFCP-Iteration-3.5.1-Bugfixes-Polish.md
Normal 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"* ✨
|
||||||
@@ -33,3 +33,9 @@ async def gpu_set_device(request: SetDeviceRequest):
|
|||||||
return {"status": "ok", **result}
|
return {"status": "ok", **result}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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()
|
||||||
|
|||||||
@@ -167,6 +167,66 @@ class GPUManager:
|
|||||||
for d in self._devices
|
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:
|
def set_device(self, backend: str, index: int = 0) -> dict:
|
||||||
"""Switch active compute device."""
|
"""Switch active compute device."""
|
||||||
target_backend = GPUBackend(backend)
|
target_backend = GPUBackend(backend)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
|||||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||||
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||||
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
|
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
|
||||||
|
import BatchFrequencyChange from '@/components/panels/BatchFrequencyChange.tsx';
|
||||||
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
|
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
|
||||||
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||||
@@ -728,6 +729,11 @@ export default function App() {
|
|||||||
{/* Site list */}
|
{/* Site list */}
|
||||||
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
|
<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 */}
|
{/* 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">
|
<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">
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ export default function CoverageBoundary({
|
|||||||
/**
|
/**
|
||||||
* Compute concave hull boundary path(s) for a set of coverage points.
|
* 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).
|
* Returns multiple paths if hull is a MultiPolygon (disjoint coverage areas).
|
||||||
* Falls back to empty if hull computation fails (e.g., collinear points).
|
* 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 features = pts.map((p) => point([p.lon, p.lat]));
|
||||||
const fc = featureCollection(features);
|
const fc = featureCollection(features);
|
||||||
|
|
||||||
// maxEdge in km — resolution * 3 balances detail vs smoothness
|
// Adaptive maxEdge based on point density:
|
||||||
const maxEdge = (resolutionM * 3) / 1000;
|
// - 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 {
|
try {
|
||||||
const hull = concave(fc, { maxEdge, units: 'kilometers' });
|
const hull = concave(fc, { maxEdge, units: 'kilometers' });
|
||||||
|
|||||||
77
frontend/src/components/panels/BatchFrequencyChange.tsx
Normal file
77
frontend/src/components/panels/BatchFrequencyChange.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
|||||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||||
|
|
||||||
function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
||||||
|
const p = entry.propagation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1.5 text-[10px]">
|
<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 */}
|
{/* Coverage breakdown with percentages */}
|
||||||
@@ -38,6 +40,73 @@ function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
|||||||
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
|
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
|
||||||
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
|
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Small header badge showing the active compute backend (CPU or GPU).
|
* Small header badge showing the active compute backend (CPU or GPU).
|
||||||
* Fetches status on mount. Clicking opens a dropdown to switch devices.
|
* 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';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
@@ -11,6 +12,8 @@ export default function GPUIndicator() {
|
|||||||
const [status, setStatus] = useState<GPUStatus | null>(null);
|
const [status, setStatus] = useState<GPUStatus | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [switching, setSwitching] = 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);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,6 +26,7 @@ export default function GPUIndicator() {
|
|||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setShowDiag(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handler);
|
document.addEventListener('mousedown', handler);
|
||||||
@@ -32,7 +36,7 @@ export default function GPUIndicator() {
|
|||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
|
|
||||||
const isGPU = status.active_backend !== 'cpu';
|
const isGPU = status.active_backend !== 'cpu';
|
||||||
// Short label for header badge
|
// Short label: just "CPU" or first word of GPU name
|
||||||
const label = isGPU
|
const label = isGPU
|
||||||
? (status.active_device?.name?.split(' ')[0] ?? 'GPU')
|
? (status.active_device?.name?.split(' ')[0] ?? 'GPU')
|
||||||
: 'CPU';
|
: 'CPU';
|
||||||
@@ -50,6 +54,16 @@ export default function GPUIndicator() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRunDiagnostics = async () => {
|
||||||
|
try {
|
||||||
|
const diag = await api.getGPUDiagnostics();
|
||||||
|
setDiagnostics(diag);
|
||||||
|
setShowDiag(true);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
<button
|
<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-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'
|
: '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}
|
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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">
|
<div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase">
|
||||||
Compute Devices
|
Compute Devices
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +112,37 @@ export default function GPUIndicator() {
|
|||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ class ApiService {
|
|||||||
return response.json();
|
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 ===
|
// === Terrain Profile API ===
|
||||||
|
|
||||||
async getTerrainProfile(
|
async getTerrainProfile(
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
import { create } from 'zustand';
|
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 {
|
export interface CalculationEntry {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
@@ -12,6 +36,8 @@ export interface CalculationEntry {
|
|||||||
avgRsrp: number;
|
avgRsrp: number;
|
||||||
rangeMin: number;
|
rangeMin: number;
|
||||||
rangeMax: number;
|
rangeMax: number;
|
||||||
|
// Propagation snapshot for detailed history
|
||||||
|
propagation?: PropagationSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalcHistoryState {
|
interface CalcHistoryState {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { WSProgress } from '@/services/websocket.ts';
|
|||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
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 { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||||
|
|
||||||
@@ -119,6 +119,32 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
|||||||
const avgRsrp = result.stats?.avg_rsrp
|
const avgRsrp = result.stats?.avg_rsrp
|
||||||
?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
|
?? (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 {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -136,6 +162,7 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
|||||||
avgRsrp,
|
avgRsrp,
|
||||||
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
|
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
|
||||||
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
|
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
|
||||||
|
propagation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ interface SitesState {
|
|||||||
batchAdjustTilt: (delta: number) => Promise<void>;
|
batchAdjustTilt: (delta: number) => Promise<void>;
|
||||||
batchSetTilt: (tilt: number) => Promise<void>;
|
batchSetTilt: (tilt: number) => Promise<void>;
|
||||||
batchSetFrequency: (frequency: number) => Promise<void>;
|
batchSetFrequency: (frequency: number) => Promise<void>;
|
||||||
|
setAllSitesFrequency: (frequency: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSitesStore = create<SitesState>((set, get) => ({
|
export const useSitesStore = create<SitesState>((set, get) => ({
|
||||||
@@ -584,4 +585,30 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
set({ sites: updatedSites });
|
set({ sites: updatedSites });
|
||||||
useCoverageStore.getState().clearCoverage();
|
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();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user