@mytec: iter3.5.1 ready for testing
This commit is contained in:
@@ -43,7 +43,8 @@
|
||||
"Bash(kill:*)",
|
||||
"Bash(sort:*)",
|
||||
"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}
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' });
|
||||
|
||||
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';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user