@mytec: iter3.5.0 ready for testing

This commit is contained in:
2026-02-03 10:32:38 +02:00
parent f46bf16428
commit 3b36535d4e
17 changed files with 860 additions and 68 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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)

View File

@@ -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() {
</span>
</div>
<div className="flex items-center gap-3 mr-4">
<GPUIndicator />
<ThemeToggle />
{/* Undo / Redo buttons */}
<div className="hidden sm:flex items-center gap-1">
@@ -658,7 +663,11 @@ export default function App() {
<div className="flex-1 flex overflow-hidden relative">
{/* Map */}
<div className="flex-1 relative">
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
<MapView
onMapClick={handleMapClick}
onEditSite={handleEditSite}
onProfileRequest={(start, end) => 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 && (
<CoverageBoundary
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
visible={heatmapVisible}
visible={showBoundary}
resolution={settings.resolution}
/>
)}
@@ -681,6 +690,13 @@ export default function App() {
</MapView>
<HeatmapLegend />
<ResultsPanel />
{profileEndpoints && (
<TerrainProfile
start={profileEndpoints.start}
end={profileEndpoints.end}
onClose={() => setProfileEndpoints(null)}
/>
)}
</div>
{/* Side panel */}
@@ -1023,6 +1039,20 @@ export default function App() {
<option value="vehicle">Inside Vehicle</option>
</select>
</div>
{/* Fading margin */}
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-dark-border">
<NumberInput
label="Fading Margin"
value={settings.fading_margin ?? 0}
onChange={(v) => useCoverageStore.getState().updateSettings({ fading_margin: v })}
min={0}
max={20}
step={1}
unit="dB"
hint="Safety margin subtracted from signal"
/>
</div>
</div>
)}
</div>

View File

@@ -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() {
</button>
</div>
{/* Boundary toggle */}
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-gray-500 dark:text-dark-muted">
Boundary
</span>
<button
onClick={() => setShowBoundary(!showBoundary)}
className={`w-8 h-4 rounded-full transition-colors relative
${showBoundary ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'}`}
>
<span
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
${showBoundary ? 'left-4' : 'left-0.5'}`}
/>
</button>
</div>
{/* Gradient bar + labels */}
<div className="flex gap-2">
{/* Continuous gradient bar */}

View File

@@ -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<LeafletMap |
return null;
}
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
export default function MapView({ onMapClick, onEditSite, onProfileRequest, children }: MapViewProps) {
const sites = useSitesStore((s) => 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)

View File

@@ -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: '<div style="width:10px;height:10px;background:white;border:2px solid #333;border-radius:50%;"></div>',
});
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 && (
<button
onClick={(e) => {
e.stopPropagation();
onProfileRequest(points[0], points[points.length - 1]);
}}
style={{
marginLeft: 10,
background: 'rgba(255,255,255,0.15)',
border: '1px solid rgba(255,255,255,0.3)',
color: 'white',
padding: '2px 8px',
borderRadius: 4,
cursor: 'pointer',
fontSize: 11,
pointerEvents: 'auto',
}}
>
Terrain Profile
</button>
)}
</div>
)}
</>

View File

@@ -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<HTMLCanvasElement>(null);
const [profile, setProfile] = useState<TerrainProfilePoint[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<HTMLCanvasElement>) => {
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 (
<div
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[1500]
bg-white dark:bg-dark-surface rounded-lg shadow-xl border border-gray-200 dark:border-dark-border
overflow-hidden"
style={{ width: CANVAS_W + 16 }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-dark-border">
<span className="text-xs font-semibold text-gray-700 dark:text-dark-text">
Terrain Profile
</span>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-white text-sm w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-dark-border"
>
{'\u2715'}
</button>
</div>
{/* Canvas */}
<div className="px-2 py-1">
{loading && (
<div className="flex items-center justify-center h-[200px] text-sm text-gray-400">
Loading profile...
</div>
)}
{error && (
<div className="flex items-center justify-center h-[200px] text-sm text-red-400">
{error}
</div>
)}
{!loading && !error && profile && (
<canvas
ref={canvasRef}
style={{ width: CANVAS_W, height: CANVAS_H, cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
)}
</div>
{/* Stats bar */}
{profile && profile.length > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 dark:bg-dark-bg text-[10px] text-gray-500 dark:text-dark-muted border-t border-gray-100 dark:border-dark-border">
<span>Distance: {(totalDist / 1000).toFixed(2)} km</span>
<span>Min: {Math.round(minElev)} m</span>
<span>Max: {Math.round(maxElev)} m</span>
<span>
LOS: {profile[0].elevation <= profile[profile.length - 1].elevation ? 'Uphill' : 'Downhill'}
</span>
</div>
)}
</div>
);
}

View File

@@ -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';

View File

@@ -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<GPUStatus | null>(null);
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className={`px-2 py-1 rounded text-[11px] font-medium transition-colors
${isGPU
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-muted dark:hover:bg-dark-muted'
}`}
title={`Compute: ${label}`}
>
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 w-56 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-50 py-1">
<div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase">
Compute Devices
</div>
{status.available_devices.map((d) => {
const isActive =
status.active_device?.backend === d.backend &&
status.active_device?.index === d.index;
return (
<button
key={`${d.backend}-${d.index}`}
onClick={() => !isActive && handleSwitch(d)}
disabled={isActive || switching}
className={`w-full text-left px-3 py-2 text-xs transition-colors
${isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-dark-text dark:hover:bg-dark-border'
}
disabled:opacity-60`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{d.name}</span>
{isActive && (
<span className="text-[10px] text-blue-500 dark:text-blue-400">Active</span>
)}
</div>
<div className="text-[10px] text-gray-400 dark:text-dark-muted mt-0.5">
{d.backend.toUpperCase()}
{d.memory_mb > 0 && ` \u2022 ${d.memory_mb} MB`}
</div>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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;

View File

@@ -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<GPUStatus> {
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<TerrainProfilePoint[]> {
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();

View File

@@ -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<CoverageState>((set, get) => ({
use_atmospheric: false,
temperature_c: 15,
humidity_percent: 50,
// Fading margin
fading_margin: 0,
},
heatmapVisible: true,
error: null,

View File

@@ -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<SettingsState>()(
showGrid: false,
measurementMode: false,
showElevationInfo: false,
showBoundary: false,
showElevationOverlay: false,
elevationOpacity: 0.5,
setTheme: (theme: Theme) => {
@@ -53,6 +56,7 @@ export const useSettingsStore = create<SettingsState>()(
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 }),
}),

View File

@@ -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 {