From 3b36535d4ece1d33b6374452fd3314d0d59915e1 Mon Sep 17 00:00:00 2001 From: mytec Date: Tue, 3 Feb 2026 10:32:38 +0200 Subject: [PATCH] @mytec: iter3.5.0 ready for testing --- backend/app/api/routes/gpu.py | 35 +++ backend/app/main.py | 3 +- backend/app/services/coverage_service.py | 9 +- backend/app/services/gpu_backend.py | 192 +++++++++++++ backend/app/services/gpu_service.py | 91 +++--- frontend/src/App.tsx | 34 ++- frontend/src/components/map/HeatmapLegend.tsx | 20 ++ frontend/src/components/map/Map.tsx | 4 +- .../src/components/map/MeasurementTool.tsx | 24 +- .../src/components/map/TerrainProfile.tsx | 272 ++++++++++++++++++ .../src/components/modals/SiteConfigModal.tsx | 4 +- frontend/src/components/ui/GPUIndicator.tsx | 105 +++++++ frontend/src/constants/frequencies.ts | 54 +++- frontend/src/services/api.ts | 72 +++++ frontend/src/store/coverage.ts | 3 + frontend/src/store/settings.ts | 4 + frontend/src/types/coverage.ts | 2 + 17 files changed, 860 insertions(+), 68 deletions(-) create mode 100644 backend/app/api/routes/gpu.py create mode 100644 backend/app/services/gpu_backend.py create mode 100644 frontend/src/components/map/TerrainProfile.tsx create mode 100644 frontend/src/components/ui/GPUIndicator.tsx diff --git a/backend/app/api/routes/gpu.py b/backend/app/api/routes/gpu.py new file mode 100644 index 0000000..8f62e57 --- /dev/null +++ b/backend/app/api/routes/gpu.py @@ -0,0 +1,35 @@ +"""GPU management API endpoints.""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.services.gpu_backend import gpu_manager + +router = APIRouter() + + +class SetDeviceRequest(BaseModel): + backend: str + index: int = 0 + + +@router.get("/status") +async def gpu_status(): + """Return GPU manager status: active backend, device, available devices.""" + return gpu_manager.get_status() + + +@router.get("/devices") +async def gpu_devices(): + """Return list of available compute devices.""" + return {"devices": gpu_manager.get_devices()} + + +@router.post("/set") +async def gpu_set_device(request: SetDeviceRequest): + """Switch active compute device.""" + try: + result = gpu_manager.set_device(request.backend, request.index) + return {"status": "ok", **result} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/main.py b/backend/app/main.py index ed0451c..a4f3a82 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI, WebSocket from fastapi.middleware.cors import CORSMiddleware from app.core.database import connect_to_mongo, close_mongo_connection -from app.api.routes import health, projects, terrain, coverage, regions, system +from app.api.routes import health, projects, terrain, coverage, regions, system, gpu from app.api.websocket import websocket_endpoint @@ -38,6 +38,7 @@ app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"]) app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"]) app.include_router(regions.router, prefix="/api/regions", tags=["regions"]) app.include_router(system.router, prefix="/api/system", tags=["system"]) +app.include_router(gpu.router, prefix="/api/gpu", tags=["gpu"]) # WebSocket endpoint for real-time coverage with progress app.websocket("/ws")(websocket_endpoint) diff --git a/backend/app/services/coverage_service.py b/backend/app/services/coverage_service.py index b35cccf..4035dc3 100644 --- a/backend/app/services/coverage_service.py +++ b/backend/app/services/coverage_service.py @@ -247,6 +247,9 @@ class CoverageSettings(BaseModel): temperature_c: float = 15.0 humidity_percent: float = 50.0 + # Fading margin (dB) — additional safety loss subtracted from RSRP + fading_margin: float = 0.0 + # Preset preset: Optional[str] = None # fast, standard, detailed, full @@ -1362,7 +1365,8 @@ class CoverageService: rsrp = (site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss - veg_loss - rain_loss - indoor_loss - atmo_loss - + reflection_gain) + + reflection_gain + - settings.fading_margin) return CoveragePoint( lat=lat, lon=lon, rsrp=rsrp, distance=distance, @@ -1508,7 +1512,8 @@ class CoverageService: ) rsrp = (site.power + site.gain - path_loss - - antenna_loss - terrain_loss) + - antenna_loss - terrain_loss + - settings.fading_margin) if rsrp >= settings.min_signal: points.append(CoveragePoint( diff --git a/backend/app/services/gpu_backend.py b/backend/app/services/gpu_backend.py new file mode 100644 index 0000000..36a4c2f --- /dev/null +++ b/backend/app/services/gpu_backend.py @@ -0,0 +1,192 @@ +""" +GPU Backend Manager — detects and manages compute backends. + +Supports: + - CUDA via CuPy + - OpenCL via PyOpenCL (future) + - CPU via NumPy (always available) + +Usage: + from app.services.gpu_backend import gpu_manager + xp = gpu_manager.get_array_module() # cupy or numpy + status = gpu_manager.get_status() +""" + +import logging +from enum import Enum +from dataclasses import dataclass, field +from typing import Any, Optional + +import numpy as np + +logger = logging.getLogger(__name__) + + +class GPUBackend(str, Enum): + CUDA = "cuda" + OPENCL = "opencl" + CPU = "cpu" + + +@dataclass +class GPUDevice: + backend: GPUBackend + index: int + name: str + memory_mb: int + extra: dict = field(default_factory=dict) + + +class GPUManager: + """Singleton GPU manager with device detection and selection.""" + + def __init__(self): + self._devices: list[GPUDevice] = [] + self._active_backend: GPUBackend = GPUBackend.CPU + self._active_device: Optional[GPUDevice] = None + self._cupy = None + self._detect_devices() + + def _detect_devices(self): + """Probe available GPU backends.""" + # Always add CPU + cpu_device = GPUDevice( + backend=GPUBackend.CPU, + index=0, + name="CPU (NumPy)", + memory_mb=0, + ) + self._devices.append(cpu_device) + + # Try CuPy / CUDA + try: + import cupy as cp + device_count = cp.cuda.runtime.getDeviceCount() + for i in range(device_count): + props = cp.cuda.runtime.getDeviceProperties(i) + name = props["name"] + if isinstance(name, bytes): + name = name.decode() + mem_mb = props["totalGlobalMem"] // (1024 * 1024) + cuda_ver = cp.cuda.runtime.runtimeGetVersion() + device = GPUDevice( + backend=GPUBackend.CUDA, + index=i, + name=str(name), + memory_mb=mem_mb, + extra={"cuda_version": cuda_ver}, + ) + self._devices.append(device) + logger.info(f"[GPU] CUDA device {i}: {name} ({mem_mb} MB)") + if device_count > 0: + self._cupy = cp + except ImportError: + logger.info("[GPU] CuPy not installed — CUDA unavailable") + except Exception as e: + logger.warning(f"[GPU] CuPy probe error: {e}") + + # Try PyOpenCL (future — stub for detection only) + try: + import pyopencl as cl + platforms = cl.get_platforms() + for plat in platforms: + for dev in plat.get_devices(): + mem_mb = dev.global_mem_size // (1024 * 1024) + device = GPUDevice( + backend=GPUBackend.OPENCL, + index=len([d for d in self._devices if d.backend == GPUBackend.OPENCL]), + name=dev.name.strip(), + memory_mb=mem_mb, + extra={"platform": plat.name.strip()}, + ) + self._devices.append(device) + logger.info(f"[GPU] OpenCL device: {device.name} ({mem_mb} MB)") + except ImportError: + pass + except Exception as e: + logger.debug(f"[GPU] OpenCL probe error: {e}") + + # Auto-select best: prefer CUDA > OpenCL > CPU + cuda_devices = [d for d in self._devices if d.backend == GPUBackend.CUDA] + if cuda_devices: + self._active_backend = GPUBackend.CUDA + self._active_device = cuda_devices[0] + logger.info(f"[GPU] Active backend: CUDA — {self._active_device.name}") + else: + self._active_backend = GPUBackend.CPU + self._active_device = cpu_device + logger.info("[GPU] Active backend: CPU (NumPy)") + + @property + def gpu_available(self) -> bool: + return self._active_backend != GPUBackend.CPU + + def get_array_module(self) -> Any: + """Return cupy (if CUDA active) or numpy.""" + if self._active_backend == GPUBackend.CUDA and self._cupy is not None: + return self._cupy + return np + + def to_cpu(self, arr: Any) -> np.ndarray: + """Transfer array to CPU numpy.""" + if hasattr(arr, 'get'): + return arr.get() + return np.asarray(arr) + + def get_status(self) -> dict: + """Full status dict for API.""" + return { + "active_backend": self._active_backend.value, + "active_device": { + "backend": self._active_device.backend.value, + "index": self._active_device.index, + "name": self._active_device.name, + "memory_mb": self._active_device.memory_mb, + } if self._active_device else None, + "gpu_available": self.gpu_available, + "available_devices": [ + { + "backend": d.backend.value, + "index": d.index, + "name": d.name, + "memory_mb": d.memory_mb, + } + for d in self._devices + ], + } + + def get_devices(self) -> list[dict]: + """Device list for API.""" + return [ + { + "backend": d.backend.value, + "index": d.index, + "name": d.name, + "memory_mb": d.memory_mb, + } + for d in self._devices + ] + + def set_device(self, backend: str, index: int = 0) -> dict: + """Switch active compute device.""" + target_backend = GPUBackend(backend) + candidates = [d for d in self._devices + if d.backend == target_backend and d.index == index] + if not candidates: + raise ValueError(f"No device found: backend={backend}, index={index}") + + self._active_device = candidates[0] + self._active_backend = target_backend + + if target_backend == GPUBackend.CUDA and self._cupy is not None: + self._cupy.cuda.Device(index).use() + + logger.info(f"[GPU] Switched to: {self._active_device.name} ({target_backend.value})") + return { + "backend": self._active_backend.value, + "device": self._active_device.name, + } + + +# Singleton +gpu_manager = GPUManager() diff --git a/backend/app/services/gpu_service.py b/backend/app/services/gpu_service.py index 04a7639..135c544 100644 --- a/backend/app/services/gpu_service.py +++ b/backend/app/services/gpu_service.py @@ -3,7 +3,7 @@ GPU-accelerated computation service using CuPy. Falls back to NumPy when CuPy/CUDA is not available. Provides vectorized batch operations for coverage calculation: - - Haversine distance (site → all grid points) + - Haversine distance (site -> all grid points) - Okumura-Hata path loss (all distances at once) Usage: @@ -11,48 +11,29 @@ Usage: """ import numpy as np -from typing import Dict, Any, Optional +from typing import Dict, Any -# ── Try CuPy import ── - -GPU_AVAILABLE = False -GPU_INFO: Optional[Dict[str, Any]] = None -cp = None - -try: - import cupy as _cp - device_count = _cp.cuda.runtime.getDeviceCount() - if device_count > 0: - cp = _cp - GPU_AVAILABLE = True - props = _cp.cuda.runtime.getDeviceProperties(0) - GPU_INFO = { - "name": props["name"].decode() if isinstance(props["name"], bytes) else str(props["name"]), - "memory_mb": props["totalGlobalMem"] // (1024 * 1024), - "cuda_version": _cp.cuda.runtime.runtimeGetVersion(), - } - print(f"[GPU] CUDA available: {GPU_INFO['name']} ({GPU_INFO['memory_mb']} MB)", flush=True) - else: - print("[GPU] No CUDA devices found", flush=True) -except ImportError: - print("[GPU] CuPy not installed — using CPU/NumPy", flush=True) - print("[GPU] To enable GPU acceleration, install CuPy:", flush=True) - print("[GPU] For CUDA 12.x: pip install cupy-cuda12x", flush=True) - print("[GPU] For CUDA 11.x: pip install cupy-cuda11x", flush=True) - print("[GPU] Check CUDA version: nvidia-smi", flush=True) -except Exception as e: - print(f"[GPU] CuPy error: {e} — GPU acceleration disabled", flush=True) +from app.services.gpu_backend import gpu_manager +# Backward-compatible exports +GPU_AVAILABLE = gpu_manager.gpu_available +GPU_INFO: Dict[str, Any] | None = ( + { + "name": gpu_manager._active_device.name, + "memory_mb": gpu_manager._active_device.memory_mb, + **gpu_manager._active_device.extra, + } + if gpu_manager.gpu_available and gpu_manager._active_device + else None +) # Array module: cupy on GPU, numpy on CPU -xp = cp if GPU_AVAILABLE else np +xp = gpu_manager.get_array_module() def _to_cpu(arr): """Transfer array to CPU numpy if on GPU.""" - if GPU_AVAILABLE and hasattr(arr, 'get'): - return arr.get() - return np.asarray(arr) + return gpu_manager.to_cpu(arr) class GPUService: @@ -60,13 +41,13 @@ class GPUService: @property def available(self) -> bool: - return GPU_AVAILABLE + return gpu_manager.gpu_available def get_info(self) -> Dict[str, Any]: """Return GPU info dict for system endpoint.""" - if not GPU_AVAILABLE: + if not gpu_manager.gpu_available: return {"available": False, "name": None, "memory_mb": None} - return {"available": True, **GPU_INFO} + return {"available": True, **(GPU_INFO or {})} def precompute_distances( self, @@ -79,16 +60,17 @@ class GPUService: Returns distances in meters as a CPU numpy array. """ - lat1 = xp.radians(xp.asarray(grid_lats, dtype=xp.float64)) - lon1 = xp.radians(xp.asarray(grid_lons, dtype=xp.float64)) - lat2 = xp.radians(xp.float64(site_lat)) - lon2 = xp.radians(xp.float64(site_lon)) + _xp = gpu_manager.get_array_module() + lat1 = _xp.radians(_xp.asarray(grid_lats, dtype=_xp.float64)) + lon1 = _xp.radians(_xp.asarray(grid_lons, dtype=_xp.float64)) + lat2 = _xp.radians(_xp.float64(site_lat)) + lon2 = _xp.radians(_xp.float64(site_lon)) dlat = lat2 - lat1 dlon = lon2 - lon1 - a = xp.sin(dlat / 2) ** 2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon / 2) ** 2 - c = 2 * xp.arcsin(xp.sqrt(a)) + a = _xp.sin(dlat / 2) ** 2 + _xp.cos(lat1) * _xp.cos(lat2) * _xp.sin(dlon / 2) ** 2 + c = 2 * _xp.arcsin(_xp.sqrt(a)) distances = 6371000.0 * c return _to_cpu(distances) @@ -108,40 +90,41 @@ class GPUService: Returns path loss in dB as a CPU numpy array. """ - d_arr = xp.asarray(distances, dtype=xp.float64) - d_km = xp.maximum(d_arr / 1000.0, 0.1) + _xp = gpu_manager.get_array_module() + d_arr = _xp.asarray(distances, dtype=_xp.float64) + d_km = _xp.maximum(d_arr / 1000.0, 0.1) freq = float(frequency_mhz) h_tx = max(float(tx_height), 1.0) h_rx = max(float(rx_height), 1.0) - log_f = xp.log10(xp.float64(freq)) - log_hb = xp.log10(xp.float64(max(h_tx, 1.0))) + log_f = _xp.log10(_xp.float64(freq)) + log_hb = _xp.log10(_xp.float64(max(h_tx, 1.0))) if freq > 2000: # Free-Space Path Loss: FSPL = 20*log10(d_km) + 20*log10(f) + 32.45 - L = 20.0 * xp.log10(d_km) + 20.0 * log_f + 32.45 + L = 20.0 * _xp.log10(d_km) + 20.0 * log_f + 32.45 elif freq > 1500: # COST-231 Hata: extends Okumura-Hata to 1500-2000 MHz a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8) L = (46.3 + 33.9 * log_f - 13.82 * log_hb - a_hm - + (44.9 - 6.55 * log_hb) * xp.log10(d_km)) + + (44.9 - 6.55 * log_hb) * _xp.log10(d_km)) if environment == "urban": L += 3.0 # Metropolitan center correction elif freq >= 150: # Okumura-Hata: 150-1500 MHz if environment == "urban" and freq >= 400: - a_hm = 3.2 * (xp.log10(11.75 * h_rx) ** 2) - 4.97 + a_hm = 3.2 * (_xp.log10(11.75 * h_rx) ** 2) - 4.97 else: a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8) L_urban = (69.55 + 26.16 * log_f - 13.82 * log_hb - a_hm - + (44.9 - 6.55 * log_hb) * xp.log10(d_km)) + + (44.9 - 6.55 * log_hb) * _xp.log10(d_km)) if environment == "suburban": - L = L_urban - 2 * (xp.log10(freq / 28) ** 2) - 5.4 + L = L_urban - 2 * (_xp.log10(freq / 28) ** 2) - 5.4 elif environment == "rural": L = L_urban - 4.78 * (log_f ** 2) + 18.33 * log_f - 35.94 elif environment == "open": @@ -152,7 +135,7 @@ class GPUService: else: # Very low frequency — Longley-Rice simplified (area mode) # Use FSPL as baseline with terrain roughness correction - L = 20.0 * xp.log10(d_km) + 20.0 * log_f + 32.45 + 10.0 + L = 20.0 * _xp.log10(d_km) + 20.0 * log_f + 32.45 + 10.0 return _to_cpu(L) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 315410a..96a00ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,8 @@ import { SiteConfigModal } from '@/components/modals/index.ts'; import type { SiteFormValues } from '@/components/modals/index.ts'; import ToastContainer from '@/components/ui/Toast.tsx'; import ThemeToggle from '@/components/ui/ThemeToggle.tsx'; +import GPUIndicator from '@/components/ui/GPUIndicator.tsx'; +import TerrainProfile from '@/components/map/TerrainProfile.tsx'; import Button from '@/components/ui/Button.tsx'; import NumberInput from '@/components/ui/NumberInput.tsx'; import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx'; @@ -111,6 +113,7 @@ export default function App() { const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode); const showElevationInfo = useSettingsStore((s) => s.showElevationInfo); const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo); + const showBoundary = useSettingsStore((s) => s.showBoundary); const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay); const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay); const elevationOpacity = useSettingsStore((s) => s.elevationOpacity); @@ -132,6 +135,7 @@ export default function App() { const [panelCollapsed, setPanelCollapsed] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null); + const [profileEndpoints, setProfileEndpoints] = useState<{ start: [number, number]; end: [number, number] } | null>(null); // Region wizard for first-run (desktop mode only) const [showWizard, setShowWizard] = useState(false); @@ -484,6 +488,7 @@ export default function App() {
+ {/* Undo / Redo buttons */}
@@ -658,7 +663,11 @@ export default function App() {
{/* Map */}
- + setProfileEndpoints({ start, end })} + > {/* Show partial results during tiled calculation, or final result */} {(coverageResult || (isCalculating && partialPoints.length > 0)) && ( <> @@ -672,7 +681,7 @@ export default function App() { {coverageResult && ( p.rsrp >= settings.rsrpThreshold)} - visible={heatmapVisible} + visible={showBoundary} resolution={settings.resolution} /> )} @@ -681,6 +690,13 @@ export default function App() { + {profileEndpoints && ( + setProfileEndpoints(null)} + /> + )}
{/* Side panel */} @@ -1023,6 +1039,20 @@ export default function App() {
+ + {/* Fading margin */} +
+ useCoverageStore.getState().updateSettings({ fading_margin: v })} + min={0} + max={20} + step={1} + unit="dB" + hint="Safety margin subtracted from signal" + /> +
)}
diff --git a/frontend/src/components/map/HeatmapLegend.tsx b/frontend/src/components/map/HeatmapLegend.tsx index 6c96f47..c635622 100644 --- a/frontend/src/components/map/HeatmapLegend.tsx +++ b/frontend/src/components/map/HeatmapLegend.tsx @@ -10,6 +10,7 @@ import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts'; import { useCoverageStore } from '@/store/coverage.ts'; import { useSitesStore } from '@/store/sites.ts'; +import { useSettingsStore } from '@/store/settings.ts'; const LEGEND_STEPS = [ { rsrp: -130, label: 'No Service' }, @@ -41,6 +42,8 @@ export default function HeatmapLegend() { const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap); const settings = useCoverageStore((s) => s.settings); const sites = useSitesStore((s) => s.sites); + const showBoundary = useSettingsStore((s) => s.showBoundary); + const setShowBoundary = useSettingsStore((s) => s.setShowBoundary); if (!result) return null; @@ -72,6 +75,23 @@ export default function HeatmapLegend() { + {/* Boundary toggle */} +
+ + Boundary + + +
+ {/* Gradient bar + labels */}
{/* Continuous gradient bar */} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 9cb94e6..a541b5e 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -16,6 +16,7 @@ import ElevationLayer from './ElevationLayer.tsx'; interface MapViewProps { onMapClick: (lat: number, lon: number) => void; onEditSite: (site: Site) => void; + onProfileRequest?: (start: [number, number], end: [number, number]) => void; children?: React.ReactNode; } @@ -48,7 +49,7 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject s.sites); const isPlacingMode = useSitesStore((s) => s.isPlacingMode); const showTerrain = useSettingsStore((s) => s.showTerrain); @@ -109,6 +110,7 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info'); setMeasurementMode(false); }} + onProfileRequest={onProfileRequest} /> {sites .filter((s) => s.visible) diff --git a/frontend/src/components/map/MeasurementTool.tsx b/frontend/src/components/map/MeasurementTool.tsx index a3c6a52..bba3c57 100644 --- a/frontend/src/components/map/MeasurementTool.tsx +++ b/frontend/src/components/map/MeasurementTool.tsx @@ -5,6 +5,7 @@ import L from 'leaflet'; interface MeasurementToolProps { enabled: boolean; onComplete?: (distanceKm: number) => void; + onProfileRequest?: (start: [number, number], end: [number, number]) => void; } function haversineKm( @@ -39,7 +40,7 @@ const dotIcon = L.divIcon({ html: '
', }); -export default function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) { +export default function MeasurementTool({ enabled, onComplete, onProfileRequest }: MeasurementToolProps) { const map = useMap(); const [points, setPoints] = useState<[number, number][]>([]); const pointsRef = useRef(points); @@ -116,6 +117,27 @@ export default function MeasurementTool({ enabled, onComplete }: MeasurementTool }} > Distance: {dist.toFixed(2)} km ({(dist * 1000).toFixed(0)} m) + {points.length >= 2 && onProfileRequest && ( + + )}
)} diff --git a/frontend/src/components/map/TerrainProfile.tsx b/frontend/src/components/map/TerrainProfile.tsx new file mode 100644 index 0000000..6bf87c6 --- /dev/null +++ b/frontend/src/components/map/TerrainProfile.tsx @@ -0,0 +1,272 @@ +/** + * Canvas-based terrain elevation profile viewer. + * + * Shows elevation cross-section between two geographic points with: + * - Green filled terrain area + * - Dashed red LOS line from start to end + * - Hover tooltip with elevation/distance at cursor + * - Stats bar: total distance, min/max elevation + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { api } from '@/services/api.ts'; +import type { TerrainProfilePoint } from '@/services/api.ts'; + +interface TerrainProfileProps { + start: [number, number]; // [lat, lon] + end: [number, number]; // [lat, lon] + onClose: () => void; +} + +const CANVAS_W = 600; +const CANVAS_H = 200; +const PAD = { top: 20, right: 20, bottom: 30, left: 50 }; +const PLOT_W = CANVAS_W - PAD.left - PAD.right; +const PLOT_H = CANVAS_H - PAD.top - PAD.bottom; + +export default function TerrainProfile({ start, end, onClose }: TerrainProfileProps) { + const canvasRef = useRef(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hover, setHover] = useState<{ x: number; idx: number } | null>(null); + + // Fetch profile data + useEffect(() => { + setLoading(true); + setError(null); + api + .getTerrainProfile(start[0], start[1], end[0], end[1], 200) + .then((data) => { + setProfile(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, [start, end]); + + // Draw chart + const draw = useCallback( + (hoverIdx: number | null) => { + const canvas = canvasRef.current; + if (!canvas || !profile || profile.length === 0) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = CANVAS_W * dpr; + canvas.height = CANVAS_H * dpr; + ctx.scale(dpr, dpr); + + // Clear + ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); + + const elevations = profile.map((p) => p.elevation); + const distances = profile.map((p) => p.distance); + const minElev = Math.min(...elevations); + const maxElev = Math.max(...elevations); + const maxDist = distances[distances.length - 1] || 1; + + // Add 10% padding to elevation range + const elevRange = maxElev - minElev || 1; + const eMin = minElev - elevRange * 0.1; + const eMax = maxElev + elevRange * 0.1; + + const xScale = (d: number) => PAD.left + (d / maxDist) * PLOT_W; + const yScale = (e: number) => PAD.top + PLOT_H - ((e - eMin) / (eMax - eMin)) * PLOT_H; + + // Grid lines + ctx.strokeStyle = '#e5e7eb'; + ctx.lineWidth = 0.5; + const nGridY = 5; + for (let i = 0; i <= nGridY; i++) { + const y = PAD.top + (i / nGridY) * PLOT_H; + ctx.beginPath(); + ctx.moveTo(PAD.left, y); + ctx.lineTo(PAD.left + PLOT_W, y); + ctx.stroke(); + } + + // Terrain fill + ctx.beginPath(); + ctx.moveTo(xScale(distances[0]), yScale(elevations[0])); + for (let i = 1; i < profile.length; i++) { + ctx.lineTo(xScale(distances[i]), yScale(elevations[i])); + } + ctx.lineTo(xScale(distances[distances.length - 1]), PAD.top + PLOT_H); + ctx.lineTo(xScale(distances[0]), PAD.top + PLOT_H); + ctx.closePath(); + ctx.fillStyle = 'rgba(34, 197, 94, 0.3)'; + ctx.fill(); + + // Terrain line + ctx.beginPath(); + ctx.moveTo(xScale(distances[0]), yScale(elevations[0])); + for (let i = 1; i < profile.length; i++) { + ctx.lineTo(xScale(distances[i]), yScale(elevations[i])); + } + ctx.strokeStyle = '#16a34a'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // LOS dashed line (start elevation to end elevation) + ctx.beginPath(); + ctx.setLineDash([6, 4]); + ctx.moveTo(xScale(distances[0]), yScale(elevations[0])); + ctx.lineTo(xScale(distances[distances.length - 1]), yScale(elevations[elevations.length - 1])); + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + + // Y axis labels + ctx.fillStyle = '#6b7280'; + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let i = 0; i <= nGridY; i++) { + const elev = eMax - (i / nGridY) * (eMax - eMin); + const y = PAD.top + (i / nGridY) * PLOT_H; + ctx.fillText(`${Math.round(elev)}m`, PAD.left - 4, y); + } + + // X axis labels + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + const nGridX = 5; + for (let i = 0; i <= nGridX; i++) { + const d = (i / nGridX) * maxDist; + const x = xScale(d); + ctx.fillText(`${(d / 1000).toFixed(1)}km`, x, PAD.top + PLOT_H + 4); + } + + // Hover crosshair + tooltip + if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < profile.length) { + const p = profile[hoverIdx]; + const hx = xScale(p.distance); + const hy = yScale(p.elevation); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(hx, PAD.top); + ctx.lineTo(hx, PAD.top + PLOT_H); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Dot + ctx.beginPath(); + ctx.arc(hx, hy, 4, 0, Math.PI * 2); + ctx.fillStyle = '#2563eb'; + ctx.fill(); + + // Tooltip + const text = `${Math.round(p.elevation)}m @ ${(p.distance / 1000).toFixed(2)}km`; + ctx.font = 'bold 11px monospace'; + const tw = ctx.measureText(text).width + 10; + const tx = Math.min(hx + 8, CANVAS_W - tw - 4); + const ty = Math.max(hy - 22, PAD.top); + ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + ctx.beginPath(); + ctx.roundRect(tx, ty, tw, 18, 3); + ctx.fill(); + ctx.fillStyle = 'white'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, tx + 5, ty + 9); + } + }, + [profile] + ); + + // Re-draw on profile load or hover change + useEffect(() => { + draw(hover?.idx ?? null); + }, [draw, hover]); + + // Mouse move handler + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!profile || profile.length === 0) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const relX = (mx - PAD.left) / PLOT_W; + if (relX < 0 || relX > 1) { + setHover(null); + return; + } + const idx = Math.round(relX * (profile.length - 1)); + setHover({ x: mx, idx }); + }, + [profile] + ); + + const handleMouseLeave = useCallback(() => setHover(null), []); + + // Stats + const minElev = profile ? Math.min(...profile.map((p) => p.elevation)) : 0; + const maxElev = profile ? Math.max(...profile.map((p) => p.elevation)) : 0; + const totalDist = profile && profile.length > 0 ? profile[profile.length - 1].distance : 0; + + return ( +
+ {/* Header */} +
+ + Terrain Profile + + +
+ + {/* Canvas */} +
+ {loading && ( +
+ Loading profile... +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && profile && ( + + )} +
+ + {/* Stats bar */} + {profile && profile.length > 0 && ( +
+ Distance: {(totalDist / 1000).toFixed(2)} km + Min: {Math.round(minElev)} m + Max: {Math.round(maxElev)} m + + LOS: {profile[0].elevation <= profile[profile.length - 1].elevation ? 'Uphill' : 'Downhill'} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/modals/SiteConfigModal.tsx b/frontend/src/components/modals/SiteConfigModal.tsx index a571a08..3435b28 100644 --- a/frontend/src/components/modals/SiteConfigModal.tsx +++ b/frontend/src/components/modals/SiteConfigModal.tsx @@ -214,8 +214,8 @@ export default function SiteConfigModal({ if (form.gain < 0 || form.gain > 30) { newErrors.gain = 'Gain must be 0-30 dBi'; } - if (form.frequency < 100 || form.frequency > 6000) { - newErrors.frequency = 'Frequency must be 100-6000 MHz'; + if (form.frequency < 30 || form.frequency > 6000) { + newErrors.frequency = 'Frequency must be 30-6000 MHz'; } if (form.height < 1 || form.height > 100) { newErrors.height = 'Height must be 1-100m'; diff --git a/frontend/src/components/ui/GPUIndicator.tsx b/frontend/src/components/ui/GPUIndicator.tsx new file mode 100644 index 0000000..3747d4c --- /dev/null +++ b/frontend/src/components/ui/GPUIndicator.tsx @@ -0,0 +1,105 @@ +/** + * Small header badge showing the active compute backend (CPU or GPU). + * Fetches status on mount. Clicking opens a dropdown to switch devices. + */ + +import { useState, useEffect, useRef } from 'react'; +import { api } from '@/services/api.ts'; +import type { GPUStatus, GPUDevice } from '@/services/api.ts'; + +export default function GPUIndicator() { + const [status, setStatus] = useState(null); + const [open, setOpen] = useState(false); + const [switching, setSwitching] = useState(false); + const ref = useRef(null); + + useEffect(() => { + api.getGPUStatus().then(setStatus).catch(() => {}); + }, []); + + // Close dropdown on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + if (!status) return null; + + const isGPU = status.active_backend !== 'cpu'; + // Short label for header badge + const label = isGPU + ? (status.active_device?.name?.split(' ')[0] ?? 'GPU') + : 'CPU'; + + const handleSwitch = async (device: GPUDevice) => { + setSwitching(true); + try { + await api.setGPUDevice(device.backend, device.index); + const updated = await api.getGPUStatus(); + setStatus(updated); + } catch { + // ignore + } + setSwitching(false); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ Compute Devices +
+ {status.available_devices.map((d) => { + const isActive = + status.active_device?.backend === d.backend && + status.active_device?.index === d.index; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/constants/frequencies.ts b/frontend/src/constants/frequencies.ts index afefefd..0fb02a9 100644 --- a/frontend/src/constants/frequencies.ts +++ b/frontend/src/constants/frequencies.ts @@ -1,6 +1,39 @@ import type { FrequencyBand } from '@/types/index.ts'; export const COMMON_FREQUENCIES: FrequencyBand[] = [ + { + value: 70, + name: 'VHF Low', + range: '30-88 MHz', + type: 'VHF', + characteristics: { + range: 'long', + penetration: 'excellent', + typical: 'Military tactical, long-range ground wave', + }, + }, + { + value: 225, + name: 'Military UHF', + range: '225-400 MHz', + type: 'UHF', + characteristics: { + range: 'long', + penetration: 'good', + typical: 'NATO MILCOM, SINCGARS, air-ground', + }, + }, + { + value: 700, + name: 'Band 28', + range: '703-803 MHz', + type: 'LTE', + characteristics: { + range: 'long', + penetration: 'excellent', + typical: 'Extended range LTE, first responder (FirstNet)', + }, + }, { value: 800, name: 'Band 20', @@ -12,6 +45,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [ typical: 'Rural coverage, deep building penetration', }, }, + { + value: 900, + name: 'Band 8', + range: '880-960 MHz', + type: 'LTE', + characteristics: { + range: 'long', + penetration: 'excellent', + typical: 'GSM refarming, IoT, rural coverage', + }, + }, { value: 1800, name: 'Band 3', @@ -91,16 +135,16 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [ }, ]; -export const QUICK_FREQUENCIES = [800, 1800, 1900, 2100, 2600]; +export const QUICK_FREQUENCIES = [700, 800, 900, 1800, 1900, 2100, 2600]; // Tactical radio presets for UHF/VHF -export const TACTICAL_FREQUENCIES = [150, 450]; +export const TACTICAL_FREQUENCIES = [70, 150, 225, 450]; // All quick frequencies grouped by band type export const FREQUENCY_GROUPS = { - LTE: [800, 1800, 1900, 2100, 2600], - UHF: [450], - VHF: [150], + VHF: [70, 150], + UHF: [225, 450], + LTE: [700, 800, 900, 1800, 1900, 2100, 2600], '5G': [3500], } as const; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 11f0392..7eea18a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -212,6 +212,53 @@ class ApiService { if (!response.ok) throw new Error('Failed to get cache stats'); return response.json(); } + + // === GPU API === + + async getGPUStatus(): Promise { + const response = await fetch(`${API_BASE}/api/gpu/status`); + if (!response.ok) throw new Error('Failed to get GPU status'); + return response.json(); + } + + async getGPUDevices(): Promise<{ devices: GPUDevice[] }> { + const response = await fetch(`${API_BASE}/api/gpu/devices`); + if (!response.ok) throw new Error('Failed to get GPU devices'); + return response.json(); + } + + async setGPUDevice(backend: string, index: number = 0): Promise<{ status: string; backend: string; device: string }> { + const response = await fetch(`${API_BASE}/api/gpu/set`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backend, index }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({ detail: 'Failed to set GPU device' })); + throw new Error(err.detail || 'Failed to set GPU device'); + } + return response.json(); + } + + // === Terrain Profile API === + + async getTerrainProfile( + lat1: number, lon1: number, + lat2: number, lon2: number, + points: number = 100, + ): Promise { + const params = new URLSearchParams({ + lat1: lat1.toString(), + lon1: lon1.toString(), + lat2: lat2.toString(), + lon2: lon2.toString(), + points: points.toString(), + }); + const response = await fetch(`${API_BASE}/api/terrain/profile?${params}`); + if (!response.ok) throw new Error('Failed to get terrain profile'); + const data = await response.json(); + return data.profile ?? data; + } } // === Region types === @@ -244,4 +291,29 @@ export interface CacheStats { vegetation_mb: number; } +// === GPU types === + +export interface GPUDevice { + backend: string; + index: number; + name: string; + memory_mb: number; +} + +export interface GPUStatus { + active_backend: string; + active_device: GPUDevice | null; + gpu_available: boolean; + available_devices: GPUDevice[]; +} + +// === Terrain Profile types === + +export interface TerrainProfilePoint { + lat: number; + lon: number; + elevation: number; + distance: number; +} + export const api = new ApiService(); diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts index d99c800..7ac9498 100644 --- a/frontend/src/store/coverage.ts +++ b/frontend/src/store/coverage.ts @@ -73,6 +73,7 @@ function buildApiSettings(settings: CoverageSettings) { use_atmospheric: settings.use_atmospheric, temperature_c: settings.temperature_c, humidity_percent: settings.humidity_percent, + fading_margin: settings.fading_margin, }; } @@ -166,6 +167,8 @@ export const useCoverageStore = create((set, get) => ({ use_atmospheric: false, temperature_c: 15, humidity_percent: 50, + // Fading margin + fading_margin: 0, }, heatmapVisible: true, error: null, diff --git a/frontend/src/store/settings.ts b/frontend/src/store/settings.ts index 77dff4a..a62a600 100644 --- a/frontend/src/store/settings.ts +++ b/frontend/src/store/settings.ts @@ -10,9 +10,11 @@ interface SettingsState { showGrid: boolean; measurementMode: boolean; showElevationInfo: boolean; + showBoundary: boolean; showElevationOverlay: boolean; elevationOpacity: number; setTheme: (theme: Theme) => void; + setShowBoundary: (show: boolean) => void; setShowTerrain: (show: boolean) => void; setTerrainOpacity: (opacity: number) => void; setShowGrid: (show: boolean) => void; @@ -42,6 +44,7 @@ export const useSettingsStore = create()( showGrid: false, measurementMode: false, showElevationInfo: false, + showBoundary: false, showElevationOverlay: false, elevationOpacity: 0.5, setTheme: (theme: Theme) => { @@ -53,6 +56,7 @@ export const useSettingsStore = create()( setShowGrid: (show: boolean) => set({ showGrid: show }), setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }), setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }), + setShowBoundary: (show: boolean) => set({ showBoundary: show }), setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }), setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }), }), diff --git a/frontend/src/types/coverage.ts b/frontend/src/types/coverage.ts index ff5782d..69fdde9 100644 --- a/frontend/src/types/coverage.ts +++ b/frontend/src/types/coverage.ts @@ -64,6 +64,8 @@ export interface CoverageSettings { use_atmospheric?: boolean; temperature_c?: number; humidity_percent?: number; + // Fading margin + fading_margin?: number; // dB additional safety loss } export interface GridPoint {