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 {