diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 58e66b4..8425587 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -43,7 +43,8 @@ "Bash(kill:*)", "Bash(sort:*)", "Bash(journalctl:*)", - "Bash(pkill:*)" + "Bash(pkill:*)", + "Bash(pip3 list:*)" ] } } diff --git a/RFCP-Dependencies-Installer.md b/RFCP-Dependencies-Installer.md new file mode 100644 index 0000000..3c35037 --- /dev/null +++ b/RFCP-Dependencies-Installer.md @@ -0,0 +1,656 @@ +# RFCP Dependencies & Installer Specification + +## Overview + +All dependencies needed for RFCP to work out of the box, including GPU acceleration. +The installer must handle everything — user should NOT need to run pip manually. + +--- + +## Python Dependencies + +### Core (MUST have) + +```txt +# requirements.txt + +# Web framework +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +websockets>=12.0 + +# Scientific computing +numpy>=1.24.0 +scipy>=1.11.0 + +# Geospatial +pyproj>=3.6.0 # coordinate transformations +shapely>=2.0.0 # geometry operations (boundary contours) + +# Terrain data +rasterio>=1.3.0 # GeoTIFF reading (optional, for custom terrain) +# Note: SRTM .hgt files read with numpy directly + +# OSM data +requests>=2.31.0 # HTTP client for OSM Overpass API +geopy>=2.4.0 # distance calculations + +# Database +# sqlite3 is built-in Python — no install needed + +# Utilities +orjson>=3.9.0 # fast JSON (optional, faster API responses) +pydantic>=2.0.0 # data validation (FastAPI dependency) +``` + +### GPU Acceleration (OPTIONAL — auto-detected) + +```txt +# requirements-gpu-nvidia.txt +cupy-cuda12x>=12.0.0 # For CUDA 12.x (RTX 30xx, 40xx) +# OR +cupy-cuda11x>=11.0.0 # For CUDA 11.x (older cards) + +# requirements-gpu-opencl.txt +pyopencl>=2023.1 # For ANY GPU (Intel, AMD, NVIDIA) +``` + +### Development / Testing + +```txt +# requirements-dev.txt +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +httpx>=0.25.0 # async test client +``` + +--- + +## System Dependencies + +### NVIDIA GPU Support + +``` +REQUIRED: NVIDIA Driver (comes with GPU) +REQUIRED: CUDA Toolkit 12.x (for CuPy) + +Check if installed: + nvidia-smi → shows driver version + nvcc --version → shows CUDA toolkit version + +If missing CUDA toolkit: + Download from: https://developer.nvidia.com/cuda-downloads + Select: Windows > x86_64 > 11/10 > exe (local) + Size: ~3 GB + +Alternative: cupy auto-installs CUDA runtime! + pip install cupy-cuda12x + This bundles CUDA runtime (~700 MB) — no separate install needed +``` + +### Intel GPU Support (OpenCL) + +``` +REQUIRED: Intel GPU Driver (usually pre-installed) +REQUIRED: Intel OpenCL Runtime + +Check if installed: + Open Device Manager → Display Adapters → Intel UHD/Iris + +For OpenCL: + Download Intel GPU Computing Runtime: + https://github.com/intel/compute-runtime/releases + + Or: Intel oneAPI Base Toolkit (includes OpenCL) + https://www.intel.com/content/www/us/en/developer/tools/oneapi/base-toolkit-download.html +``` + +### AMD GPU Support (OpenCL) + +``` +REQUIRED: AMD Adrenalin Driver (includes OpenCL) +Download from: https://www.amd.com/en/support +``` + +--- + +## Node.js / Frontend Dependencies + +### System Requirements + +``` +Node.js >= 18.0.0 (LTS recommended) +npm >= 9.0.0 + +Check: + node --version + npm --version +``` + +### Frontend packages (managed by npm) + +```json +// package.json — key dependencies +{ + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "leaflet": "^1.9.4", + "react-leaflet": "^4.2.0", + "recharts": "^2.8.0", + "zustand": "^4.4.0", + "lucide-react": "^0.294.0" + }, + "devDependencies": { + "vite": "^5.0.0", + "typescript": "^5.3.0", + "tailwindcss": "^3.4.0", + "@types/leaflet": "^1.9.0" + } +} +``` + +--- + +## Installer Script + +### Windows Installer (NSIS or Electron-Builder) + +```python +# install_rfcp.py — Python-based installer/setup script + +import subprocess +import sys +import platform +import os +import shutil +import json + +def check_python(): + """Verify Python 3.10+ is available.""" + version = sys.version_info + if version.major < 3 or version.minor < 10: + print(f"❌ Python 3.10+ required, found {version.major}.{version.minor}") + return False + print(f"✅ Python {version.major}.{version.minor}.{version.micro}") + return True + +def check_node(): + """Verify Node.js 18+ is available.""" + try: + result = subprocess.run(["node", "--version"], capture_output=True, text=True) + version = result.stdout.strip().lstrip('v') + major = int(version.split('.')[0]) + if major < 18: + print(f"❌ Node.js 18+ required, found {version}") + return False + print(f"✅ Node.js {version}") + return True + except FileNotFoundError: + print("❌ Node.js not found") + return False + +def detect_gpu(): + """Detect available GPU hardware.""" + gpus = { + "nvidia": False, + "nvidia_name": "", + "intel": False, + "intel_name": "", + "amd": False, + "amd_name": "" + } + + # Check NVIDIA + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,driver_version,memory.total", + "--format=csv,noheader"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + info = result.stdout.strip() + gpus["nvidia"] = True + gpus["nvidia_name"] = info.split(",")[0].strip() + print(f"✅ NVIDIA GPU: {info}") + except (FileNotFoundError, subprocess.TimeoutExpired): + print("ℹ️ No NVIDIA GPU detected") + + # Check Intel/AMD via WMI (Windows) + if platform.system() == "Windows": + try: + result = subprocess.run( + ["wmic", "path", "win32_videocontroller", "get", + "name,adapterram,driverversion", "/format:csv"], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().split('\n'): + if 'Intel' in line: + gpus["intel"] = True + gpus["intel_name"] = [x for x in line.split(',') if 'Intel' in x][0] + print(f"✅ Intel GPU: {gpus['intel_name']}") + elif 'AMD' in line or 'Radeon' in line: + gpus["amd"] = True + gpus["amd_name"] = [x for x in line.split(',') if 'AMD' in x or 'Radeon' in x][0] + print(f"✅ AMD GPU: {gpus['amd_name']}") + except Exception: + pass + + return gpus + +def install_core_dependencies(): + """Install core Python dependencies.""" + print("\n📦 Installing core dependencies...") + subprocess.run([ + sys.executable, "-m", "pip", "install", "-r", "requirements.txt", + "--quiet", "--no-warn-script-location" + ], check=True) + print("✅ Core dependencies installed") + +def install_gpu_dependencies(gpus: dict): + """Install GPU-specific dependencies based on detected hardware.""" + print("\n🎮 Setting up GPU acceleration...") + + gpu_installed = False + + # NVIDIA — install CuPy (includes CUDA runtime) + if gpus["nvidia"]: + print(f" Installing CuPy for {gpus['nvidia_name']}...") + try: + # Try CUDA 12 first (newer cards) + subprocess.run([ + sys.executable, "-m", "pip", "install", "cupy-cuda12x", + "--quiet", "--no-warn-script-location" + ], check=True, timeout=300) + print(f" ✅ CuPy (CUDA 12) installed for {gpus['nvidia_name']}") + gpu_installed = True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + try: + # Fallback to CUDA 11 + subprocess.run([ + sys.executable, "-m", "pip", "install", "cupy-cuda11x", + "--quiet", "--no-warn-script-location" + ], check=True, timeout=300) + print(f" ✅ CuPy (CUDA 11) installed for {gpus['nvidia_name']}") + gpu_installed = True + except Exception as e: + print(f" ⚠️ CuPy installation failed: {e}") + print(f" 💡 Manual install: pip install cupy-cuda12x") + + # Intel/AMD — install PyOpenCL + if gpus["intel"] or gpus["amd"]: + gpu_name = gpus["intel_name"] or gpus["amd_name"] + print(f" Installing PyOpenCL for {gpu_name}...") + try: + subprocess.run([ + sys.executable, "-m", "pip", "install", "pyopencl", + "--quiet", "--no-warn-script-location" + ], check=True, timeout=120) + print(f" ✅ PyOpenCL installed for {gpu_name}") + gpu_installed = True + except Exception as e: + print(f" ⚠️ PyOpenCL installation failed: {e}") + print(f" 💡 Manual install: pip install pyopencl") + + if not gpu_installed: + print(" ℹ️ No GPU acceleration available — using CPU (NumPy)") + print(" 💡 This is fine! GPU just makes large calculations faster.") + + return gpu_installed + +def install_frontend(): + """Install frontend dependencies and build.""" + print("\n🌐 Setting up frontend...") + frontend_dir = os.path.join(os.path.dirname(__file__), "frontend") + + if os.path.exists(os.path.join(frontend_dir, "package.json")): + subprocess.run(["npm", "install"], cwd=frontend_dir, check=True) + subprocess.run(["npm", "run", "build"], cwd=frontend_dir, check=True) + print("✅ Frontend built") + else: + print("⚠️ Frontend directory not found") + +def download_terrain_data(): + """Pre-download SRTM terrain tiles for Ukraine.""" + print("\n🏔️ Checking terrain data...") + cache_dir = os.path.expanduser("~/.rfcp/terrain") + os.makedirs(cache_dir, exist_ok=True) + + # Ukraine bounding box: lat 44-53, lon 22-41 + # SRTM tiles needed for typical use + required_tiles = [ + # Lviv oblast area (common test area) + "N49E025", "N49E024", "N49E026", + "N50E025", "N50E024", "N50E026", + # Dnipro area + "N48E034", "N48E035", + "N49E034", "N49E035", + ] + + existing = [f.replace(".hgt", "") for f in os.listdir(cache_dir) if f.endswith(".hgt")] + missing = [t for t in required_tiles if t not in existing] + + if missing: + print(f" {len(missing)} terrain tiles needed (auto-download on first use)") + else: + print(f" ✅ {len(existing)} terrain tiles cached") + +def create_launcher(): + """Create desktop shortcut / launcher script.""" + print("\n🚀 Creating launcher...") + + if platform.system() == "Windows": + # Create .bat launcher + launcher = os.path.join(os.path.dirname(__file__), "RFCP.bat") + with open(launcher, 'w') as f: + f.write('@echo off\n') + f.write('title RFCP - RF Coverage Planner\n') + f.write('echo Starting RFCP...\n') + f.write(f'cd /d "{os.path.dirname(__file__)}"\n') + f.write(f'"{sys.executable}" -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888\n') + print(f" ✅ Launcher created: {launcher}") + + return True + +def verify_installation(): + """Run quick verification tests.""" + print("\n🔍 Verifying installation...") + + checks = [] + + # Check core imports + try: + import numpy as np + checks.append(f"✅ NumPy {np.__version__}") + except ImportError: + checks.append("❌ NumPy missing") + + try: + import scipy + checks.append(f"✅ SciPy {scipy.__version__}") + except ImportError: + checks.append("❌ SciPy missing") + + try: + import fastapi + checks.append(f"✅ FastAPI {fastapi.__version__}") + except ImportError: + checks.append("❌ FastAPI missing") + + try: + import shapely + checks.append(f"✅ Shapely {shapely.__version__}") + except ImportError: + checks.append("⚠️ Shapely missing (boundary features disabled)") + + # Check GPU + try: + import cupy as cp + device = cp.cuda.Device(0) + checks.append(f"✅ CuPy → {device.name} ({device.mem_info[1]//1024//1024} MB)") + except ImportError: + checks.append("ℹ️ CuPy not available") + except Exception as e: + checks.append(f"⚠️ CuPy error: {e}") + + try: + import pyopencl as cl + devices = [] + for p in cl.get_platforms(): + for d in p.get_devices(): + devices.append(d.name) + checks.append(f"✅ PyOpenCL → {', '.join(devices)}") + except ImportError: + checks.append("ℹ️ PyOpenCL not available") + except Exception as e: + checks.append(f"⚠️ PyOpenCL error: {e}") + + for check in checks: + print(f" {check}") + + return all("❌" not in c for c in checks) + +def main(): + """Main installer entry point.""" + print("=" * 60) + print(" RFCP — RF Coverage Planner — Installer") + print("=" * 60) + print() + + # Step 1: Check prerequisites + print("📋 Checking prerequisites...") + if not check_python(): + sys.exit(1) + check_node() + + # Step 2: Detect GPU + gpus = detect_gpu() + + # Step 3: Install dependencies + install_core_dependencies() + install_gpu_dependencies(gpus) + + # Step 4: Frontend + install_frontend() + + # Step 5: Terrain data + download_terrain_data() + + # Step 6: Launcher + create_launcher() + + # Step 7: Verify + print() + success = verify_installation() + + # Summary + print() + print("=" * 60) + if success: + print(" ✅ RFCP installed successfully!") + print() + print(" To start RFCP:") + print(" python -m uvicorn backend.app.main:app --port 8888") + print(" Then open: http://localhost:8888") + print() + if gpus["nvidia"]: + print(f" 🎮 GPU: {gpus['nvidia_name']} (CUDA)") + elif gpus["intel"] or gpus["amd"]: + gpu_name = gpus["intel_name"] or gpus["amd_name"] + print(f" 🎮 GPU: {gpu_name} (OpenCL)") + else: + print(" 💻 Mode: CPU only") + else: + print(" ⚠️ Installation completed with warnings") + print(" Some features may be limited") + print("=" * 60) + +if __name__ == "__main__": + main() +``` + +--- + +## Electron-Builder / NSIS Packaging + +### For .exe Installer + +```yaml +# electron-builder.yml + +appId: com.rfcp.coverage-planner +productName: "RFCP - RF Coverage Planner" +copyright: "RFCP 2026" + +directories: + output: dist + buildResources: build + +files: + - "backend/**/*" + - "frontend/dist/**/*" + - "requirements.txt" + - "install_rfcp.py" + - "!**/*.pyc" + - "!**/node_modules/**" + - "!**/venv/**" + +extraResources: + - from: "python-embedded/" + to: "python/" + - from: "terrain-data/" + to: "terrain/" + +win: + target: + - target: nsis + arch: [x64] + icon: "build/icon.ico" + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + installerIcon: "build/icon.ico" + license: "LICENSE.md" + + # Custom NSIS script for GPU detection + include: "build/gpu-detect.nsh" + + # Install steps: + # 1. Extract files + # 2. Run install_rfcp.py (detects GPU, installs deps) + # 3. Create Start Menu shortcuts + # 4. Create Desktop shortcut +``` + +### Portable Version (.zip) + +``` +RFCP-Portable/ +├── RFCP.bat # Main launcher +├── install.bat # First-time setup +├── backend/ +│ ├── app/ +│ │ ├── main.py +│ │ ├── api/ +│ │ ├── services/ +│ │ └── models/ +│ └── requirements.txt +├── frontend/ +│ └── dist/ # Pre-built frontend +├── python/ # Embedded Python (optional) +│ ├── python.exe +│ └── Lib/ +├── terrain/ # Pre-cached .hgt files +│ ├── N49E025.hgt +│ └── ... +├── data/ +│ ├── osm_cache.db # SQLite cache (created on first run) +│ └── config.json # User settings +└── README.md +``` + +### install.bat (First-Time Setup) + +```batch +@echo off +title RFCP - First Time Setup +echo ============================================ +echo RFCP - RF Coverage Planner - Setup +echo ============================================ +echo. + +REM Check if Python exists +python --version >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python not found! + echo Please install Python 3.10+ from python.org + pause + exit /b 1 +) + +REM Run installer +python install_rfcp.py + +echo. +echo Setup complete! Run RFCP.bat to start. +pause +``` + +### RFCP.bat (Launcher) + +```batch +@echo off +title RFCP - RF Coverage Planner +cd /d "%~dp0" + +REM Check if installed +if not exist "backend\app\main.py" ( + echo ERROR: RFCP not found. Run install.bat first. + pause + exit /b 1 +) + +echo Starting RFCP... +echo Open http://localhost:8888 in your browser +echo Press Ctrl+C to stop +echo. + +python -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888 +``` + +--- + +## Dependency Size Estimates + +| Component | Size | +|-----------|------| +| Python (embedded) | ~30 MB | +| Core pip packages | ~80 MB | +| CuPy + CUDA runtime | ~700 MB | +| PyOpenCL | ~15 MB | +| Frontend (built) | ~5 MB | +| SRTM terrain (Ukraine) | ~300 MB | +| **Total (with CUDA)** | **~1.1 GB** | +| **Total (CPU only)** | **~415 MB** | + +--- + +## Runtime Requirements + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 4 GB | 8+ GB | +| Disk | 500 MB | 2 GB (with terrain cache) | +| CPU | 4 cores | 8+ cores | +| GPU | - | NVIDIA GTX 1060+ / Intel UHD 630+ | +| OS | Windows 10 | Windows 10/11 64-bit | +| Python | 3.10 | 3.11+ | +| Node.js | 18 | 20 LTS | + +--- + +## Auto-Update Mechanism (Future) + +```python +# Check for updates on startup +async def check_for_updates(): + try: + response = await httpx.get( + "https://api.github.com/repos/user/rfcp/releases/latest", + timeout=5 + ) + latest = response.json()["tag_name"] + current = get_current_version() + + if latest != current: + return { + "update_available": True, + "current": current, + "latest": latest, + "download_url": response.json()["assets"][0]["browser_download_url"] + } + except: + pass + return {"update_available": False} +``` diff --git a/RFCP-Iteration-3.5.1-Bugfixes-Polish.md b/RFCP-Iteration-3.5.1-Bugfixes-Polish.md new file mode 100644 index 0000000..964c26b --- /dev/null +++ b/RFCP-Iteration-3.5.1-Bugfixes-Polish.md @@ -0,0 +1,557 @@ +# RFCP Iteration 3.5.1 — Bugfixes & Polish + +## Overview + +Focused bugfix and polish release addressing UI issues, coverage boundary accuracy, history improvements, and GPU indicator fixes discovered during 3.5.0 testing. + +--- + +## 1. GPU — Detection Not Working + UI Overlap + +### 1A. GPU Not Detected Despite Being Available + +**Problem:** User has a laptop with DUAL GPUs (Intel integrated + NVIDIA discrete) but the app only shows "CPU (NumPy)". GPU acceleration is not working at all — no GPU option available in the device selector. + +**Root cause investigation needed:** +1. Check if CuPy is actually installed in the Python environment +2. Check if CUDA toolkit is accessible from the app's runtime +3. Check if PyOpenCL is installed (fallback for Intel GPU) +4. The backend GPU detection may be failing silently + +**Debug steps to add:** + +```python +# backend/app/services/gpu_backend.py — improve detection with logging + +import logging +logger = logging.getLogger(__name__) + +@classmethod +def detect_backends(cls) -> list: + backends = [] + + # Check NVIDIA CUDA + try: + import cupy as cp + count = cp.cuda.runtime.getDeviceCount() + logger.info(f"CUDA detected: {count} device(s)") + for i in range(count): + device = cp.cuda.Device(i) + backends.append({...}) + except ImportError: + logger.warning("CuPy not installed — run: pip install cupy-cuda12x") + except Exception as e: + logger.warning(f"CUDA detection failed: {e}") + + # Check OpenCL (works with Intel, AMD, AND NVIDIA) + try: + import pyopencl as cl + platforms = cl.get_platforms() + logger.info(f"OpenCL detected: {len(platforms)} platform(s)") + for platform in platforms: + for device in platform.get_devices(): + logger.info(f" OpenCL device: {device.name}") + backends.append({...}) + except ImportError: + logger.warning("PyOpenCL not installed — run: pip install pyopencl") + except Exception as e: + logger.warning(f"OpenCL detection failed: {e}") + + # Always log what was found + logger.info(f"Total compute backends: {len(backends)} " + f"({sum(1 for b in backends if b['type'] == 'cuda')} CUDA, " + f"{sum(1 for b in backends if b['type'] == 'opencl')} OpenCL)") + + # CPU always available + backends.append({...cpu...}) + return backends +``` + +**Installation check endpoint:** + +```python +# backend/app/api/routes/gpu.py — add diagnostic endpoint + +@router.get("/diagnostics") +async def gpu_diagnostics(): + """Full GPU diagnostic info for troubleshooting.""" + diag = { + "python_version": sys.version, + "platform": platform.platform(), + "cuda": {}, + "opencl": {}, + "numpy": {} + } + + # Check CuPy/CUDA + try: + import cupy + diag["cuda"]["cupy_version"] = cupy.__version__ + diag["cuda"]["cuda_version"] = cupy.cuda.runtime.runtimeGetVersion() + diag["cuda"]["device_count"] = cupy.cuda.runtime.getDeviceCount() + for i in range(diag["cuda"]["device_count"]): + d = cupy.cuda.Device(i) + diag["cuda"][f"device_{i}"] = { + "name": d.name, + "compute_capability": d.compute_capability, + "total_memory_mb": d.mem_info[1] // 1024 // 1024 + } + except ImportError: + diag["cuda"]["error"] = "CuPy not installed" + diag["cuda"]["install_hint"] = "pip install cupy-cuda12x --break-system-packages" + except Exception as e: + diag["cuda"]["error"] = str(e) + + # Check PyOpenCL + try: + import pyopencl as cl + diag["opencl"]["pyopencl_version"] = cl.VERSION_TEXT + for p in cl.get_platforms(): + platform_info = {"name": p.name, "devices": []} + for d in p.get_devices(): + platform_info["devices"].append({ + "name": d.name, + "type": cl.device_type.to_string(d.type), + "memory_mb": d.global_mem_size // 1024 // 1024, + "compute_units": d.max_compute_units + }) + diag["opencl"][p.name] = platform_info + except ImportError: + diag["opencl"]["error"] = "PyOpenCL not installed" + diag["opencl"]["install_hint"] = "pip install pyopencl" + except Exception as e: + diag["opencl"]["error"] = str(e) + + # Check NumPy + import numpy as np + diag["numpy"]["version"] = np.__version__ + + return diag +``` + +**Frontend — show diagnostic info:** + +```typescript +// In GPUIndicator.tsx — when only CPU detected, show help + +{devices.length === 1 && devices[0].type === 'cpu' && ( +
+ ⚠ No GPU detected. + +
+)} +``` + +**Auto-install hint in UI:** +``` +⚠ No GPU detected + +For NVIDIA GPU: pip install cupy-cuda12x +For Intel/AMD GPU: pip install pyopencl + +[Run Diagnostics] [Install Guide] +``` + +**Dual GPU handling (Intel + NVIDIA laptop):** +```python +# When both Intel (OpenCL) and NVIDIA (CUDA) found: +# - List both in device selector +# - Default to NVIDIA CUDA (faster) +# - Allow user to switch +# - Intel iGPU via OpenCL is still ~3-5x faster than CPU + +# Example device list for dual GPU laptop: +# 1. ⚡ NVIDIA GeForce RTX 4060 (CUDA) — 8 GB [DEFAULT] +# 2. ⚡ Intel UHD Graphics 770 (OpenCL) — shared memory +# 3. 💻 CPU (16 cores) +``` + +### 1B. GPU Indicator UI — Fix Overlap with Fit Button + +**Problem:** GPU device dropdown overlaps with the "Fit" button in top-right corner. + +**Solution:** +- Keep compact "⚡ CPU" badge in header +- Dropdown opens to the LEFT or DOWNWARD, not overlapping map controls +- Proper z-index and positioning +- Shorter labels: "CPU" not "CPU (NumPy)" + +**Files:** +- `frontend/src/components/ui/GPUIndicator.tsx` +- `backend/app/services/gpu_backend.py` +- `backend/app/api/routes/gpu.py` + +--- + +## 2. Coverage Boundary — Improve Accuracy + +**Problem:** Current boundary shows a rough circle/ellipse shape that doesn't follow actual coverage contour. + +**Current behavior:** Boundary seems to be based on simple distance radius rather than actual RSRP threshold contour. + +**Expected behavior:** Boundary should follow the actual -100 dBm (or configured threshold) contour line — an irregular shape that follows terrain, buildings, vegetation shadows. + +**Solution:** + +```python +# Backend approach: Generate contour from actual RSRP grid + +import numpy as np +from scipy.ndimage import binary_dilation, binary_erosion +from shapely.geometry import MultiPoint +from shapely.ops import unary_union + +def calculate_coverage_boundary(points: list, threshold_dbm: float = -100) -> list: + """ + Calculate coverage boundary as convex hull of points above threshold. + Returns list of [lat, lon] coordinates forming the boundary polygon. + """ + # Filter points above threshold + valid_points = [(p['lat'], p['lon']) for p in points if p['rsrp'] >= threshold_dbm] + + if len(valid_points) < 3: + return [] + + # Create concave hull (alpha shape) for realistic boundary + # Concave hull follows the actual shape better than convex hull + from shapely.geometry import MultiPoint + multi_point = MultiPoint(valid_points) + + # Alpha shape — adjust alpha for detail level + # Higher alpha = more detailed (but slower) + boundary = concave_hull(multi_point, ratio=0.3) + + if boundary.is_empty: + return [] + + # Simplify to reduce points (tolerance in degrees ≈ 100m) + simplified = boundary.simplify(0.001) + + # Return as coordinate list + coords = list(simplified.exterior.coords) + return [[lat, lon] for lat, lon in coords] +``` + +```python +# Alternative: Grid-based contour approach + +def calculate_boundary_from_grid( + grid_points: list, + threshold_dbm: float, + grid_resolution_m: float +) -> list: + """ + Create boundary by finding edge cells of coverage area. + More accurate than hull — follows actual coverage gaps. + """ + import numpy as np + + # Build 2D RSRP grid + lats = sorted(set(p['lat'] for p in grid_points)) + lons = sorted(set(p['lon'] for p in grid_points)) + + grid = np.full((len(lats), len(lons)), np.nan) + lat_idx = {lat: i for i, lat in enumerate(lats)} + lon_idx = {lon: i for i, lon in enumerate(lons)} + + for p in grid_points: + i = lat_idx[p['lat']] + j = lon_idx[p['lon']] + grid[i, j] = p['rsrp'] + + # Binary mask: above threshold + mask = grid >= threshold_dbm + + # Find boundary: dilate - original = edge cells + dilated = binary_dilation(mask) + boundary_mask = dilated & ~mask + + # Extract boundary coordinates + boundary_coords = [] + for i in range(len(lats)): + for j in range(len(lons)): + if boundary_mask[i, j]: + boundary_coords.append([lats[i], lons[j]]) + + # Order points for polygon (traveling salesman approximate) + if len(boundary_coords) > 2: + ordered = order_boundary_points(boundary_coords) + return ordered + + return boundary_coords +``` + +**Frontend changes:** +- Receive boundary polygon from backend (already calculated with results) +- Or calculate client-side from grid points +- Render as Leaflet polygon with dashed white stroke +- Should follow actual coverage shape, not circular approximation + +**Files:** +- `backend/app/services/coverage_service.py` — add boundary calculation +- `frontend/src/components/map/CoverageBoundary.tsx` — render real contour + +--- + +## 3. Session History — Show Propagation Parameters + +**Problem:** History entries only show preset, points, radius, resolution. Missing propagation settings used. + +**Solution:** Save full propagation config snapshot with each history entry. + +```typescript +// frontend/src/store/calcHistory.ts + +interface HistoryEntry { + id: string; + timestamp: Date; + computationTime: number; + preset: string; + radius: number; + resolution: number; + totalPoints: number; + + // Coverage results + coverage: { + excellent: number; // percentage + good: number; + fair: number; + weak: number; + }; + avgRsrp: number; + rangeMin: number; + rangeMax: number; + + // NEW: Propagation parameters snapshot + propagation: { + modelsUsed: string[]; // ["Free-Space", "terrain_los", ...] + modelCount: number; // 12 + frequency: number; // 2100 MHz + txPower: number; // 46 dBm + antennaGain: number; // 15 dBi + antennaHeight: number; // 10 m + + // Environment + season: string; // "Winter (30%)" + temperature: string; // "15°C (mild)" + humidity: string; // "50% (normal)" + rainConditions: string; // "Light Rain" + indoorCoverage: string; // "Medium Building (brick)" + + // Margins + fadingMargin: number; // 0 dB + + // Atmospheric + atmosphericAbsorption: boolean; + }; + + // Site config + sites: number; // 2 + sectors: number; // total sectors +} +``` + +**Display in History panel:** + +```typescript +// Expanded history entry shows propagation details + +
+ {/* Existing: time, points, coverage bars */} + + {/* NEW: Propagation summary (collapsed by default) */} +
+ + ▸ Propagation: {entry.propagation.modelCount} models, {entry.propagation.frequency} MHz + +
+
TX: {entry.propagation.txPower} dBm, Gain: {entry.propagation.antennaGain} dBi
+
Height: {entry.propagation.antennaHeight}m
+
Environment: {entry.propagation.season}, {entry.propagation.rainConditions}
+
Indoor: {entry.propagation.indoorCoverage}
+ {entry.propagation.fadingMargin > 0 && ( +
Fading margin: {entry.propagation.fadingMargin} dB
+ )} +
+ {entry.propagation.modelsUsed.map(model => ( + + {model} + + ))} +
+
+
+
+``` + +**Files:** +- `frontend/src/store/calcHistory.ts` — extend HistoryEntry type, save propagation +- `frontend/src/components/panels/HistoryPanel.tsx` — show expandable propagation details +- `backend/app/api/websocket.py` — include propagation config in result message +- `backend/app/services/coverage_service.py` — return config snapshot with results + +--- + +## 4. Results Popup — Show Propagation Summary + +**Problem:** Calculation Complete popup shows time, points, coverage bars — but not which models were used. + +**Solution:** Add compact propagation info to results popup. + +```typescript +// frontend/src/components/ui/ResultsPopup.tsx + +// Add below coverage bars: +
+ {result.modelsUsed?.length || 0} models + + {result.frequency} MHz + {result.fadingMargin > 0 && ( + <> + + FM: {result.fadingMargin} dB + + )} + {result.indoorCoverage && result.indoorCoverage !== 'none' && ( + <> + + Indoor: {result.indoorCoverage} + + )} +
+``` + +**Files:** +- `frontend/src/components/ui/ResultsPopup.tsx` + +--- + +## 5. Batch Frequency Change (from 3.5.0 backlog) + +**Problem:** To compare coverage at different frequencies, user must edit each sector manually. + +**Solution:** Quick-change buttons in toolbar or Coverage Settings. + +```typescript +// frontend/src/components/panels/BatchOperations.tsx (NEW) + +const QUICK_BANDS = [ + { freq: 700, label: '700', band: 'B28', color: 'text-red-400' }, + { freq: 800, label: '800', band: 'B20', color: 'text-orange-400' }, + { freq: 900, label: '900', band: 'B8', color: 'text-yellow-400' }, + { freq: 1800, label: '1800', band: 'B3', color: 'text-green-400' }, + { freq: 2100, label: '2100', band: 'B1', color: 'text-blue-400' }, + { freq: 2600, label: '2600', band: 'B7', color: 'text-purple-400' }, + { freq: 3500, label: '3500', band: 'n78', color: 'text-pink-400' }, +]; + +export function BatchFrequencyChange() { + return ( +
+

+ SET ALL SECTORS +

+
+ {QUICK_BANDS.map(b => ( + + ))} +
+
+ ); +} +``` + +**Location:** Below site list, above Coverage Settings. + +**Files:** +- `frontend/src/components/panels/BatchOperations.tsx` (NEW) +- `frontend/src/store/sites.ts` — add `setAllSectorsFrequency()` action + +--- + +## 6. Minor UI Fixes + +### 6.1 Terrain Profile — Click Propagation (verify fix) +- Verify that clicking "Terrain Profile" button no longer adds ruler point +- If still broken: ensure e.stopPropagation() AND e.preventDefault() on button + +### 6.2 GPU Indicator — Shorter Label +- Current: "CPU (NumPy)" — too long +- Should be: "CPU" or "⚡ CPU" +- When GPU active: "⚡ RTX 4060" (short device name) + +### 6.3 ~~Coordinate Display — Show Elevation~~ ✅ WORKS +- Elevation loads on hover with delay — NOT a bug +- Shows "Elev: 380m ASL" after holding cursor on map +- No fix needed + +--- + +## Implementation Order + +### Priority 1 — Quick Fixes (30 min) +- [ ] GPU indicator positioning (no overlap with Fit) +- [ ] GPU detection — install CuPy/PyOpenCL, diagnostics endpoint +- [ ] Terrain Profile click fix (verify) + +### Priority 2 — History Enhancement (1 hour) +- [ ] Extend HistoryEntry with propagation params +- [ ] Save propagation snapshot on calculation complete +- [ ] Expandable propagation details in History panel +- [ ] Results popup — show model count + frequency + +### Priority 3 — Coverage Boundary (1-2 hours) +- [ ] Implement contour-based boundary from actual RSRP grid +- [ ] Replace circular approximation with real coverage shape +- [ ] Test with multi-site calculations +- [ ] Smooth boundary line (simplify polygon) + +### Priority 4 — Batch Frequency (30 min) +- [ ] BatchOperations component +- [ ] setAllSectorsFrequency store action +- [ ] Wire into sidebar panel + +--- + +## Success Criteria + +- [ ] GPU indicator does not overlap with any map controls +- [ ] Coverage boundary follows actual coverage shape (not circular) +- [ ] History entries show expandable propagation parameters +- [ ] Results popup shows model count and frequency +- [ ] Batch frequency change updates all sectors at once +- [ ] Terrain Profile button click doesn't add ruler point +- [ ] Elevation displays correctly in bottom-left + +--- + +## Files Summary + +### New Files +- `frontend/src/components/panels/BatchOperations.tsx` + +### Modified Files +- `frontend/src/components/ui/GPUIndicator.tsx` — fix position/overlap +- `frontend/src/components/map/CoverageBoundary.tsx` — real contour +- `frontend/src/components/ui/ResultsPopup.tsx` — propagation info +- `frontend/src/store/calcHistory.ts` — extended HistoryEntry +- `frontend/src/components/panels/HistoryPanel.tsx` — expandable details +- `frontend/src/store/sites.ts` — batch frequency action +- `backend/app/services/coverage_service.py` — boundary calculation, config snapshot +- `backend/app/api/websocket.py` — include config in results + +--- + +*"Polish makes the difference between a tool and a product"* ✨ diff --git a/backend/app/api/routes/gpu.py b/backend/app/api/routes/gpu.py index 8f62e57..c1e1e0e 100644 --- a/backend/app/api/routes/gpu.py +++ b/backend/app/api/routes/gpu.py @@ -33,3 +33,9 @@ async def gpu_set_device(request: SetDeviceRequest): return {"status": "ok", **result} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/diagnostics") +async def gpu_diagnostics(): + """Full GPU diagnostic info for troubleshooting detection issues.""" + return gpu_manager.get_diagnostics() diff --git a/backend/app/services/gpu_backend.py b/backend/app/services/gpu_backend.py index 36a4c2f..23d258a 100644 --- a/backend/app/services/gpu_backend.py +++ b/backend/app/services/gpu_backend.py @@ -167,6 +167,66 @@ class GPUManager: for d in self._devices ] + def get_diagnostics(self) -> dict: + """Full diagnostic info for troubleshooting GPU detection.""" + import sys + import platform + + diag = { + "python_version": sys.version, + "platform": platform.platform(), + "numpy": {"version": np.__version__}, + "cuda": {}, + "opencl": {}, + "detected_devices": len(self._devices), + "active_backend": self._active_backend.value, + } + + # Check CuPy/CUDA + try: + import cupy as cp + diag["cuda"]["cupy_version"] = cp.__version__ + diag["cuda"]["cuda_runtime_version"] = cp.cuda.runtime.runtimeGetVersion() + diag["cuda"]["device_count"] = cp.cuda.runtime.getDeviceCount() + for i in range(diag["cuda"]["device_count"]): + props = cp.cuda.runtime.getDeviceProperties(i) + name = props["name"] + if isinstance(name, bytes): + name = name.decode() + diag["cuda"][f"device_{i}"] = { + "name": str(name), + "memory_mb": props["totalGlobalMem"] // (1024 * 1024), + "compute_capability": f"{props['major']}.{props['minor']}", + } + except ImportError: + diag["cuda"]["error"] = "CuPy not installed" + diag["cuda"]["install_hint"] = "pip install cupy-cuda12x" + except Exception as e: + diag["cuda"]["error"] = str(e) + + # Check PyOpenCL + try: + import pyopencl as cl + diag["opencl"]["pyopencl_version"] = cl.VERSION_TEXT + diag["opencl"]["platforms"] = [] + for p in cl.get_platforms(): + platform_info = {"name": p.name.strip(), "devices": []} + for d in p.get_devices(): + platform_info["devices"].append({ + "name": d.name.strip(), + "type": cl.device_type.to_string(d.type), + "memory_mb": d.global_mem_size // (1024 * 1024), + "compute_units": d.max_compute_units, + }) + diag["opencl"]["platforms"].append(platform_info) + except ImportError: + diag["opencl"]["error"] = "PyOpenCL not installed" + diag["opencl"]["install_hint"] = "pip install pyopencl" + except Exception as e: + diag["opencl"]["error"] = str(e) + + return diag + def set_device(self, backend: str, index: int = 0) -> dict: """Switch active compute device.""" target_backend = GPUBackend(backend) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96a00ad..c93790c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import ExportPanel from '@/components/panels/ExportPanel.tsx'; import ProjectPanel from '@/components/panels/ProjectPanel.tsx'; import CoverageStats from '@/components/panels/CoverageStats.tsx'; import HistoryPanel from '@/components/panels/HistoryPanel.tsx'; +import BatchFrequencyChange from '@/components/panels/BatchFrequencyChange.tsx'; import ResultsPanel from '@/components/panels/ResultsPanel.tsx'; import SiteImportExport from '@/components/panels/SiteImportExport.tsx'; import { SiteConfigModal } from '@/components/modals/index.ts'; @@ -728,6 +729,11 @@ export default function App() { {/* Site list */} + {/* Quick frequency change */} +
+ +
+ {/* Coverage settings */}

diff --git a/frontend/src/components/map/CoverageBoundary.tsx b/frontend/src/components/map/CoverageBoundary.tsx index db7b8d4..72ed5c1 100644 --- a/frontend/src/components/map/CoverageBoundary.tsx +++ b/frontend/src/components/map/CoverageBoundary.tsx @@ -107,7 +107,10 @@ export default function CoverageBoundary({ /** * Compute concave hull boundary path(s) for a set of coverage points. * - * maxEdge = resolution * 3 (in km) gives good detail without over-fitting. + * Uses adaptive maxEdge based on point count and resolution: + * - More points → smaller maxEdge for finer detail + * - Larger resolution → larger maxEdge to avoid over-fitting + * * Returns multiple paths if hull is a MultiPolygon (disjoint coverage areas). * Falls back to empty if hull computation fails (e.g., collinear points). */ @@ -121,8 +124,17 @@ function computeConcaveHulls( const features = pts.map((p) => point([p.lon, p.lat])); const fc = featureCollection(features); - // maxEdge in km — resolution * 3 balances detail vs smoothness - const maxEdge = (resolutionM * 3) / 1000; + // Adaptive maxEdge based on point density: + // - Base: resolution * 2 (tighter fit) + // - For sparse grids (<100 pts): use larger edge to avoid holes + // - For dense grids (>1000 pts): use smaller edge for detail + let multiplier = 2.0; + if (pts.length < 100) { + multiplier = 4.0; // Sparse: wider tolerance + } else if (pts.length > 1000) { + multiplier = 1.5; // Dense: finer detail + } + const maxEdge = (resolutionM * multiplier) / 1000; try { const hull = concave(fc, { maxEdge, units: 'kilometers' }); diff --git a/frontend/src/components/panels/BatchFrequencyChange.tsx b/frontend/src/components/panels/BatchFrequencyChange.tsx new file mode 100644 index 0000000..14e2529 --- /dev/null +++ b/frontend/src/components/panels/BatchFrequencyChange.tsx @@ -0,0 +1,77 @@ +/** + * Quick frequency band selector for setting all sectors at once. + * Enables rapid comparison of coverage at different frequency bands. + */ + +import { useSitesStore } from '@/store/sites.ts'; +import { COMMON_FREQUENCIES, FREQUENCY_GROUPS } from '@/constants/frequencies.ts'; + +const QUICK_BANDS = [ + { freq: 70, label: '70', color: 'text-indigo-400' }, + { freq: 225, label: '225', color: 'text-cyan-400' }, + { freq: 700, label: '700', color: 'text-red-400' }, + { freq: 800, label: '800', color: 'text-orange-400' }, + { freq: 900, label: '900', color: 'text-yellow-400' }, + { freq: 1800, label: '1.8G', color: 'text-green-400' }, + { freq: 2100, label: '2.1G', color: 'text-blue-400' }, + { freq: 2600, label: '2.6G', color: 'text-purple-400' }, + { freq: 3500, label: '3.5G', color: 'text-pink-400' }, +]; + +export default function BatchFrequencyChange() { + const sites = useSitesStore((s) => s.sites); + const setAllSitesFrequency = useSitesStore((s) => s.setAllSitesFrequency); + + if (sites.length === 0) return null; + + // Get current frequency (from first site) + const currentFreq = sites[0]?.frequency ?? 1800; + + // Check if all sites have same frequency + const allSameFreq = sites.every((s) => s.frequency === currentFreq); + + // Get band info + const getBandName = (freq: number) => { + const band = COMMON_FREQUENCIES.find((b) => b.value === freq); + return band?.name ?? `${freq} MHz`; + }; + + const handleSetFrequency = async (freq: number) => { + await setAllSitesFrequency(freq); + }; + + return ( +
+
+

+ Quick Frequency +

+ + {allSameFreq ? getBandName(currentFreq) : 'Mixed'} + +
+
+ {QUICK_BANDS.map((b) => { + const isActive = allSameFreq && currentFreq === b.freq; + return ( + + ); + })} +
+
+ Sets all {sites.length} sector{sites.length !== 1 ? 's' : ''} to selected band +
+
+ ); +} diff --git a/frontend/src/components/panels/HistoryPanel.tsx b/frontend/src/components/panels/HistoryPanel.tsx index 95f8afb..0a8ec8a 100644 --- a/frontend/src/components/panels/HistoryPanel.tsx +++ b/frontend/src/components/panels/HistoryPanel.tsx @@ -3,6 +3,8 @@ import { useCalcHistoryStore } from '@/store/calcHistory.ts'; import type { CalculationEntry } from '@/store/calcHistory.ts'; function EntryDetail({ entry }: { entry: CalculationEntry }) { + const p = entry.propagation; + return (
{/* Coverage breakdown with percentages */} @@ -38,6 +40,73 @@ function EntryDetail({ entry }: { entry: CalculationEntry }) { Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm
+ + {/* Propagation details */} + {p && ( +
+ {/* Site parameters */} +
+ {p.frequency} MHz + {p.txPower} dBm + {p.antennaGain} dBi + {p.antennaHeight} m +
+ + {/* Models used */} + {p.modelsUsed.length > 0 && ( +
+ {p.modelsUsed.map((model) => ( + + {model} + + ))} +
+ )} + + {/* Active toggles summary */} +
+ {p.use_terrain && ( + Terrain + )} + {p.use_buildings && ( + Buildings + )} + {p.use_materials && ( + Materials + )} + {p.use_dominant_path && ( + DomPath + )} + {p.use_reflections && ( + Reflections + )} + {p.use_vegetation && ( + Vegetation + )} + {p.use_atmospheric && ( + Atmospheric + )} + {p.fading_margin > 0 && ( + + -{p.fading_margin} dB fade + + )} + {p.rain_rate > 0 && ( + + Rain {p.rain_rate} mm/h + + )} + {p.indoor_loss_type !== 'none' && ( + + Indoor: {p.indoor_loss_type} + + )} +
+
+ )}

); } diff --git a/frontend/src/components/ui/GPUIndicator.tsx b/frontend/src/components/ui/GPUIndicator.tsx index 3747d4c..a98f862 100644 --- a/frontend/src/components/ui/GPUIndicator.tsx +++ b/frontend/src/components/ui/GPUIndicator.tsx @@ -1,6 +1,7 @@ /** * Small header badge showing the active compute backend (CPU or GPU). * Fetches status on mount. Clicking opens a dropdown to switch devices. + * Dropdown opens to the LEFT to avoid overlapping map controls. */ import { useState, useEffect, useRef } from 'react'; @@ -11,6 +12,8 @@ export default function GPUIndicator() { const [status, setStatus] = useState(null); const [open, setOpen] = useState(false); const [switching, setSwitching] = useState(false); + const [diagnostics, setDiagnostics] = useState | null>(null); + const [showDiag, setShowDiag] = useState(false); const ref = useRef(null); useEffect(() => { @@ -23,6 +26,7 @@ export default function GPUIndicator() { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); + setShowDiag(false); } }; document.addEventListener('mousedown', handler); @@ -32,7 +36,7 @@ export default function GPUIndicator() { if (!status) return null; const isGPU = status.active_backend !== 'cpu'; - // Short label for header badge + // Short label: just "CPU" or first word of GPU name const label = isGPU ? (status.active_device?.name?.split(' ')[0] ?? 'GPU') : 'CPU'; @@ -50,6 +54,16 @@ export default function GPUIndicator() { setOpen(false); }; + const handleRunDiagnostics = async () => { + try { + const diag = await api.getGPUDiagnostics(); + setDiagnostics(diag); + setShowDiag(true); + } catch { + // ignore + } + }; + return (
{open && ( -
+
Compute Devices
@@ -98,6 +112,37 @@ export default function GPUIndicator() { ); })} + + {/* Show help when only CPU available */} + {status.available_devices.length === 1 && status.active_backend === 'cpu' && ( +
+
+ No GPU detected. For faster calculations: +
+
+
NVIDIA: pip install cupy-cuda12x
+
Intel/AMD: pip install pyopencl
+
+ +
+ )} + + {/* Diagnostics output */} + {showDiag && diagnostics && ( +
+
+ Diagnostics +
+
+                {JSON.stringify(diagnostics, null, 2)}
+              
+
+ )}
)}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7eea18a..83bb252 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -240,6 +240,12 @@ class ApiService { return response.json(); } + async getGPUDiagnostics(): Promise> { + const response = await fetch(`${API_BASE}/api/gpu/diagnostics`); + if (!response.ok) throw new Error('Failed to get GPU diagnostics'); + return response.json(); + } + // === Terrain Profile API === async getTerrainProfile( diff --git a/frontend/src/store/calcHistory.ts b/frontend/src/store/calcHistory.ts index f5a271f..2b238ba 100644 --- a/frontend/src/store/calcHistory.ts +++ b/frontend/src/store/calcHistory.ts @@ -1,5 +1,29 @@ import { create } from 'zustand'; +export interface PropagationSnapshot { + // Models used + modelsUsed: string[]; + use_terrain: boolean; + use_buildings: boolean; + use_materials: boolean; + use_dominant_path: boolean; + use_street_canyon: boolean; + use_reflections: boolean; + use_water_reflection: boolean; + use_vegetation: boolean; + use_atmospheric: boolean; + // Site params (first site or average) + frequency: number; + txPower: number; + antennaGain: number; + antennaHeight: number; + // Environmental + season: string; + rain_rate: number; + indoor_loss_type: string; + fading_margin: number; +} + export interface CalculationEntry { id: string; timestamp: Date; @@ -12,6 +36,8 @@ export interface CalculationEntry { avgRsrp: number; rangeMin: number; rangeMax: number; + // Propagation snapshot for detailed history + propagation?: PropagationSnapshot; } interface CalcHistoryState { diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts index 7ac9498..7504bc8 100644 --- a/frontend/src/store/coverage.ts +++ b/frontend/src/store/coverage.ts @@ -5,7 +5,7 @@ import type { WSProgress } from '@/services/websocket.ts'; import { useSitesStore } from '@/store/sites.ts'; import { useToastStore } from '@/components/ui/Toast.tsx'; import { useCalcHistoryStore } from '@/store/calcHistory.ts'; -import type { CalculationEntry } from '@/store/calcHistory.ts'; +import type { CalculationEntry, PropagationSnapshot } from '@/store/calcHistory.ts'; import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts'; import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts'; @@ -119,6 +119,32 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry { const avgRsrp = result.stats?.avg_rsrp ?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0); + // Capture propagation snapshot from settings + sites + const sites = useSitesStore.getState().sites.filter((s) => s.visible); + const firstSite = sites[0]; + const settings = result.settings; + + const propagation: PropagationSnapshot = { + modelsUsed: result.modelsUsed ?? [], + use_terrain: settings.use_terrain ?? true, + use_buildings: settings.use_buildings ?? true, + use_materials: settings.use_materials ?? true, + use_dominant_path: settings.use_dominant_path ?? false, + use_street_canyon: settings.use_street_canyon ?? false, + use_reflections: settings.use_reflections ?? false, + use_water_reflection: settings.use_water_reflection ?? false, + use_vegetation: settings.use_vegetation ?? false, + use_atmospheric: settings.use_atmospheric ?? false, + frequency: firstSite?.frequency ?? 1800, + txPower: firstSite?.power ?? 43, + antennaGain: firstSite?.gain ?? 18, + antennaHeight: firstSite?.height ?? 30, + season: settings.season ?? 'summer', + rain_rate: settings.rain_rate ?? 0, + indoor_loss_type: settings.indoor_loss_type ?? 'none', + fading_margin: settings.fading_margin ?? 0, + }; + return { id: crypto.randomUUID(), timestamp: new Date(), @@ -136,6 +162,7 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry { avgRsrp, rangeMin: minRsrp === Infinity ? 0 : minRsrp, rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp, + propagation, }; } diff --git a/frontend/src/store/sites.ts b/frontend/src/store/sites.ts index 0b27962..1c4a2ce 100644 --- a/frontend/src/store/sites.ts +++ b/frontend/src/store/sites.ts @@ -64,6 +64,7 @@ interface SitesState { batchAdjustTilt: (delta: number) => Promise; batchSetTilt: (tilt: number) => Promise; batchSetFrequency: (frequency: number) => Promise; + setAllSitesFrequency: (frequency: number) => Promise; } export const useSitesStore = create((set, get) => ({ @@ -584,4 +585,30 @@ export const useSitesStore = create((set, get) => ({ set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, + + setAllSitesFrequency: async (frequency: number) => { + const { sites } = get(); + if (sites.length === 0) return; + pushSnapshot('set all sites frequency', sites); + const clamped = Math.max(100, Math.min(6000, frequency)); + const now = new Date(); + + const updatedSites = sites.map((site) => ({ + ...site, + frequency: clamped, + updatedAt: now, + })); + + for (const site of updatedSites) { + await db.sites.put({ + id: site.id, + data: JSON.stringify(site), + createdAt: site.createdAt.getTime(), + updatedAt: now.getTime(), + }); + } + + set({ sites: updatedSites }); + useCoverageStore.getState().clearCoverage(); + }, }));