Compare commits

...

2 Commits

Author SHA1 Message Date
3b36535d4e @mytec: iter3.5.0 ready for testing 2026-02-03 10:32:38 +02:00
f46bf16428 @mytec: 3.5.0 cont 2026-02-03 02:53:46 +02:00
21 changed files with 2103 additions and 103 deletions

View File

@@ -30,7 +30,20 @@
"Bash(pip3 install numpy)", "Bash(pip3 install numpy)",
"Bash(echo:*)", "Bash(echo:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(node -c:*)" "Bash(node -c:*)",
"Bash(curl:*)",
"Bash(head -3 python3 -c \"import numpy; print\\(numpy.__file__\\)\")",
"Bash(pip3 install:*)",
"Bash(apt list:*)",
"Bash(dpkg:*)",
"Bash(sudo apt-get install:*)",
"Bash(docker:*)",
"Bash(~/.local/bin/pip install:*)",
"Bash(pgrep:*)",
"Bash(kill:*)",
"Bash(sort:*)",
"Bash(journalctl:*)",
"Bash(pkill:*)"
] ]
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -69,8 +69,16 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
start_time = time.time() start_time = time.time()
cancel_token = CancellationToken() cancel_token = CancellationToken()
# Dynamic timeout based on radius (large radius needs more time for tiled processing)
radius_m = request.settings.radius
if radius_m > 30_000:
calc_timeout = 600.0 # 10 min for 30-50km
elif radius_m > 10_000:
calc_timeout = 480.0 # 8 min for 10-30km
else:
calc_timeout = 300.0 # 5 min for ≤10km
try: try:
# Calculate with 5-minute timeout
if len(request.sites) == 1: if len(request.sites) == 1:
points = await asyncio.wait_for( points = await asyncio.wait_for(
coverage_service.calculate_coverage( coverage_service.calculate_coverage(
@@ -78,7 +86,7 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
request.settings, request.settings,
cancel_token, cancel_token,
), ),
timeout=300.0 timeout=calc_timeout,
) )
else: else:
points = await asyncio.wait_for( points = await asyncio.wait_for(
@@ -87,14 +95,15 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
request.settings, request.settings,
cancel_token, cancel_token,
), ),
timeout=300.0 timeout=calc_timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
cancel_token.cancel() cancel_token.cancel()
# Force cleanup orphaned worker processes # Force cleanup orphaned worker processes
from app.services.parallel_coverage_service import _kill_worker_processes from app.services.parallel_coverage_service import _kill_worker_processes
killed = _kill_worker_processes() killed = _kill_worker_processes()
detail = f"Calculation timeout (5 min). Cleaned up {killed} workers." if killed else "Calculation timeout (5 min) — try smaller radius or lower resolution" timeout_min = int(calc_timeout / 60)
detail = f"Calculation timeout ({timeout_min} min). Cleaned up {killed} workers." if killed else f"Calculation timeout ({timeout_min} min) — try smaller radius or lower resolution"
raise HTTPException(408, detail) raise HTTPException(408, detail)
except asyncio.CancelledError: except asyncio.CancelledError:
cancel_token.cancel() cancel_token.cancel()

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

@@ -180,6 +180,15 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
poller_task = asyncio.create_task(progress_poller()) poller_task = asyncio.create_task(progress_poller())
# Dynamic timeout based on radius
radius_m = settings.radius
if radius_m > 30_000:
calc_timeout = 600.0 # 10 min for 30-50km
elif radius_m > 10_000:
calc_timeout = 480.0 # 8 min for 10-30km
else:
calc_timeout = 300.0 # 5 min for ≤10km
# Run calculation with timeout # Run calculation with timeout
start_time = time.time() start_time = time.time()
try: try:
@@ -190,7 +199,7 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
progress_fn=sync_progress_fn, progress_fn=sync_progress_fn,
tile_callback=_tile_callback, tile_callback=_tile_callback,
), ),
timeout=300.0, timeout=calc_timeout,
) )
else: else:
points = await asyncio.wait_for( points = await asyncio.wait_for(
@@ -199,7 +208,7 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
progress_fn=sync_progress_fn, progress_fn=sync_progress_fn,
tile_callback=_tile_callback, tile_callback=_tile_callback,
), ),
timeout=300.0, timeout=calc_timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
cancel_token.cancel() cancel_token.cancel()
@@ -207,7 +216,8 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
await poller_task await poller_task
from app.services.parallel_coverage_service import _kill_worker_processes from app.services.parallel_coverage_service import _kill_worker_processes
_kill_worker_processes() _kill_worker_processes()
await ws_manager.send_error(ws, calc_id, "Calculation timeout (5 min)") timeout_min = int(calc_timeout / 60)
await ws_manager.send_error(ws, calc_id, f"Calculation timeout ({timeout_min} min)")
return return
except asyncio.CancelledError: except asyncio.CancelledError:
cancel_token.cancel() cancel_token.cancel()

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI, WebSocket
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.database import connect_to_mongo, close_mongo_connection 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 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(coverage.router, prefix="/api/coverage", tags=["coverage"])
app.include_router(regions.router, prefix="/api/regions", tags=["regions"]) app.include_router(regions.router, prefix="/api/regions", tags=["regions"])
app.include_router(system.router, prefix="/api/system", tags=["system"]) 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 # WebSocket endpoint for real-time coverage with progress
app.websocket("/ws")(websocket_endpoint) app.websocket("/ws")(websocket_endpoint)

View File

@@ -123,13 +123,14 @@ def _filter_buildings_to_bbox(
max_lat: float, max_lon: float, max_lat: float, max_lon: float,
site_lat: float, site_lon: float, site_lat: float, site_lon: float,
log_fn=None, log_fn=None,
max_buildings: int = MAX_BUILDINGS_FOR_WORKERS,
) -> list: ) -> list:
"""Filter buildings to coverage bbox and cap at MAX_BUILDINGS_FOR_WORKERS. """Filter buildings to coverage bbox and cap at max_buildings.
Returns buildings sorted by distance to site (nearest first) so the Returns buildings sorted by distance to site (nearest first) so the
cap preserves buildings most likely to affect coverage. cap preserves buildings most likely to affect coverage.
""" """
if not buildings or len(buildings) <= MAX_BUILDINGS_FOR_WORKERS: if not buildings or len(buildings) <= max_buildings:
return buildings return buildings
original = len(buildings) original = len(buildings)
@@ -149,7 +150,7 @@ def _filter_buildings_to_bbox(
log_fn(f"Building bbox filter: {original} -> {len(filtered)}") log_fn(f"Building bbox filter: {original} -> {len(filtered)}")
# If still too many, sort by centroid distance and cap # If still too many, sort by centroid distance and cap
if len(filtered) > MAX_BUILDINGS_FOR_WORKERS: if len(filtered) > max_buildings:
def _centroid_dist(b): def _centroid_dist(b):
lats = [p[1] for p in b.geometry] lats = [p[1] for p in b.geometry]
lons = [p[0] for p in b.geometry] lons = [p[0] for p in b.geometry]
@@ -158,7 +159,7 @@ def _filter_buildings_to_bbox(
return (clat - site_lat) ** 2 + (clon - site_lon) ** 2 return (clat - site_lat) ** 2 + (clon - site_lon) ** 2
filtered.sort(key=_centroid_dist) filtered.sort(key=_centroid_dist)
filtered = filtered[:MAX_BUILDINGS_FOR_WORKERS] filtered = filtered[:max_buildings]
if log_fn: if log_fn:
log_fn(f"Building distance cap: -> {len(filtered)} (nearest to site)") log_fn(f"Building distance cap: -> {len(filtered)} (nearest to site)")
@@ -246,6 +247,9 @@ class CoverageSettings(BaseModel):
temperature_c: float = 15.0 temperature_c: float = 15.0
humidity_percent: float = 50.0 humidity_percent: float = 50.0
# Fading margin (dB) — additional safety loss subtracted from RSRP
fading_margin: float = 0.0
# Preset # Preset
preset: Optional[str] = None # fast, standard, detailed, full preset: Optional[str] = None # fast, standard, detailed, full
@@ -762,9 +766,57 @@ class CoverageService:
# Free full grid reference # Free full grid reference
del grid del grid
# ── Pre-fetch buildings for inner zone (≤20km) ──
# This avoids re-reading the disk JSON cache (7-8s) per tile.
inner_radius_m = min(settings.radius, 20_000)
needs_osm = (settings.use_buildings
or getattr(settings, 'use_street_canyon', False)
or getattr(settings, 'use_water_reflection', False)
or getattr(settings, 'use_vegetation', False))
prefetched_buildings: List[Building] = []
prefetched_streets: list = []
prefetched_water: list = []
prefetched_vegetation: list = []
if needs_osm:
lat_delta = inner_radius_m / 111_320.0
lon_delta = inner_radius_m / (111_320.0 * max(math.cos(math.radians(site.lat)), 0.01))
inner_bbox = (
site.lat - lat_delta, site.lon - lon_delta,
site.lat + lat_delta, site.lon + lon_delta,
)
if progress_fn:
progress_fn("Pre-fetching map data", 0.02)
_clog(f"Pre-fetching OSM for inner zone ({inner_radius_m/1000:.0f}km)")
osm_prefetch = await self._fetch_osm_grid_aligned(
inner_bbox[0], inner_bbox[1], inner_bbox[2], inner_bbox[3],
settings,
)
prefetched_buildings = osm_prefetch.get("buildings", [])
prefetched_streets = osm_prefetch.get("streets", [])
prefetched_water = osm_prefetch.get("water_bodies", [])
prefetched_vegetation = osm_prefetch.get("vegetation_areas", [])
del osm_prefetch
_clog(f"Pre-fetched: {len(prefetched_buildings)} buildings, "
f"{len(prefetched_streets)} streets, "
f"{len(prefetched_water)} water, "
f"{len(prefetched_vegetation)} veg")
# Clear singleton memory cache — we hold our own reference
self.buildings._memory_cache.clear()
gc.collect()
site_elevation: Optional[float] = None site_elevation: Optional[float] = None
all_points: List[CoveragePoint] = [] all_points: List[CoveragePoint] = []
# FSPL pre-check: compute minimum distance to each tile and estimate
# free-space signal. Skip tiles where even best-case FSPL < min_signal.
eirp_dbm = site.power + site.gain
min_signal = getattr(settings, 'min_signal', -130)
tiles_skipped_fspl = 0
for tile_idx, tile in enumerate(tiles): for tile_idx, tile in enumerate(tiles):
if cancel_token and cancel_token.is_cancelled: if cancel_token and cancel_token.is_cancelled:
_clog("Tiled calculation cancelled") _clog("Tiled calculation cancelled")
@@ -776,6 +828,20 @@ class CoverageService:
tile_start = time.time() tile_start = time.time()
min_lat, min_lon, max_lat, max_lon = tile.bbox min_lat, min_lon, max_lat, max_lon = tile.bbox
# Quick FSPL check: closest edge of tile to site
clamp_lat = max(min_lat, min(site.lat, max_lat))
clamp_lon = max(min_lon, min(site.lon, max_lon))
closest_dist = TerrainService.haversine_distance(
site.lat, site.lon, clamp_lat, clamp_lon,
)
if closest_dist > 500: # Skip check for tiles containing the site
fspl_db = 20 * math.log10(closest_dist) + 20 * math.log10(site.frequency * 1e6) - 147.55
best_rsrp = eirp_dbm - fspl_db
if best_rsrp < min_signal:
tiles_skipped_fspl += 1
continue
_clog(f"━━━ Tile {tile_idx + 1}/{total_tiles}: " _clog(f"━━━ Tile {tile_idx + 1}/{total_tiles}: "
f"{len(tile_grid)} points ━━━") f"{len(tile_grid)} points ━━━")
@@ -787,28 +853,39 @@ class CoverageService:
f"Tile {_idx + 1}/{total_tiles}: {phase}", overall, f"Tile {_idx + 1}/{total_tiles}: {phase}", overall,
) )
# ── 1. Fetch OSM data for this tile ── # ── 1. Filter pre-fetched OSM data for this tile ──
_tile_progress("Fetching map data", 0.10) tile_center_lat = (min_lat + max_lat) / 2
tile_center_lon = (min_lon + max_lon) / 2
tile_dist_m = TerrainService.haversine_distance(
site.lat, site.lon, tile_center_lat, tile_center_lon,
)
skip_buildings = tile_dist_m > 20_000
_tile_progress("Filtering map data", 0.10)
await asyncio.sleep(0) await asyncio.sleep(0)
osm_data = await self._fetch_osm_grid_aligned( if skip_buildings:
min_lat, min_lon, max_lat, max_lon, settings, buildings: list = []
) streets: list = []
water_bodies: list = []
buildings = _filter_buildings_to_bbox( vegetation_areas: list = []
osm_data["buildings"], min_lat, min_lon, max_lat, max_lon, else:
site.lat, site.lon, _clog, # Fast in-memory filter from pre-fetched data (no disk I/O)
) buildings = _filter_buildings_to_bbox(
streets = _filter_osm_list_to_bbox( prefetched_buildings, min_lat, min_lon, max_lat, max_lon,
osm_data["streets"], min_lat, min_lon, max_lat, max_lon, site.lat, site.lon, _clog,
) max_buildings=5000,
water_bodies = _filter_osm_list_to_bbox( )
osm_data["water_bodies"], min_lat, min_lon, max_lat, max_lon, streets = _filter_osm_list_to_bbox(
) prefetched_streets, min_lat, min_lon, max_lat, max_lon,
vegetation_areas = _filter_osm_list_to_bbox( )
osm_data["vegetation_areas"], min_lat, min_lon, max_lat, max_lon, water_bodies = _filter_osm_list_to_bbox(
max_count=5000, prefetched_water, min_lat, min_lon, max_lat, max_lon,
) )
vegetation_areas = _filter_osm_list_to_bbox(
prefetched_vegetation, min_lat, min_lon, max_lat, max_lon,
max_count=5000,
)
spatial_idx: Optional[SpatialIndex] = None spatial_idx: Optional[SpatialIndex] = None
if buildings: if buildings:
@@ -907,15 +984,21 @@ class CoverageService:
_clog(f"Tile {tile_idx + 1}/{total_tiles} done: " _clog(f"Tile {tile_idx + 1}/{total_tiles} done: "
f"{len(tile_points)} points in {tile_time:.1f}s") f"{len(tile_points)} points in {tile_time:.1f}s")
# ── 5. Free memory ── # ── 5. Free per-tile memory ──
del buildings, streets, water_bodies, vegetation_areas del buildings, streets, water_bodies, vegetation_areas
del osm_data, spatial_idx, point_elevations, precomputed del spatial_idx, point_elevations, precomputed
del pre_distances, pre_path_loss, grid_lats, grid_lons del pre_distances, pre_path_loss, grid_lats, grid_lons
gc.collect() gc.collect()
# Free pre-fetched OSM data
del prefetched_buildings, prefetched_streets
del prefetched_water, prefetched_vegetation
gc.collect()
total_time = time.time() - calc_start total_time = time.time() - calc_start
_clog(f"━━━ Tiled calculation complete: " _clog(f"━━━ Tiled calculation complete: "
f"{len(all_points)} points in {total_time:.1f}s ━━━") f"{len(all_points)} points in {total_time:.1f}s "
f"({tiles_skipped_fspl} tiles skipped by FSPL pre-check) ━━━")
if progress_fn: if progress_fn:
progress_fn("Finalizing", 0.95) progress_fn("Finalizing", 0.95)
@@ -1282,7 +1365,8 @@ class CoverageService:
rsrp = (site.power + site.gain - path_loss - antenna_loss rsrp = (site.power + site.gain - path_loss - antenna_loss
- terrain_loss - building_loss - veg_loss - terrain_loss - building_loss - veg_loss
- rain_loss - indoor_loss - atmo_loss - rain_loss - indoor_loss - atmo_loss
+ reflection_gain) + reflection_gain
- settings.fading_margin)
return CoveragePoint( return CoveragePoint(
lat=lat, lon=lon, rsrp=rsrp, distance=distance, lat=lat, lon=lon, rsrp=rsrp, distance=distance,
@@ -1428,7 +1512,8 @@ class CoverageService:
) )
rsrp = (site.power + site.gain - path_loss rsrp = (site.power + site.gain - path_loss
- antenna_loss - terrain_loss) - antenna_loss - terrain_loss
- settings.fading_margin)
if rsrp >= settings.min_signal: if rsrp >= settings.min_signal:
points.append(CoveragePoint( 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. Falls back to NumPy when CuPy/CUDA is not available.
Provides vectorized batch operations for coverage calculation: 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) - Okumura-Hata path loss (all distances at once)
Usage: Usage:
@@ -11,48 +11,29 @@ Usage:
""" """
import numpy as np import numpy as np
from typing import Dict, Any, Optional from typing import Dict, Any
# ── Try CuPy import ── from app.services.gpu_backend import gpu_manager
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)
# 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 # 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): def _to_cpu(arr):
"""Transfer array to CPU numpy if on GPU.""" """Transfer array to CPU numpy if on GPU."""
if GPU_AVAILABLE and hasattr(arr, 'get'): return gpu_manager.to_cpu(arr)
return arr.get()
return np.asarray(arr)
class GPUService: class GPUService:
@@ -60,13 +41,13 @@ class GPUService:
@property @property
def available(self) -> bool: def available(self) -> bool:
return GPU_AVAILABLE return gpu_manager.gpu_available
def get_info(self) -> Dict[str, Any]: def get_info(self) -> Dict[str, Any]:
"""Return GPU info dict for system endpoint.""" """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": False, "name": None, "memory_mb": None}
return {"available": True, **GPU_INFO} return {"available": True, **(GPU_INFO or {})}
def precompute_distances( def precompute_distances(
self, self,
@@ -79,16 +60,17 @@ class GPUService:
Returns distances in meters as a CPU numpy array. Returns distances in meters as a CPU numpy array.
""" """
lat1 = xp.radians(xp.asarray(grid_lats, dtype=xp.float64)) _xp = gpu_manager.get_array_module()
lon1 = xp.radians(xp.asarray(grid_lons, dtype=xp.float64)) lat1 = _xp.radians(_xp.asarray(grid_lats, dtype=_xp.float64))
lat2 = xp.radians(xp.float64(site_lat)) lon1 = _xp.radians(_xp.asarray(grid_lons, dtype=_xp.float64))
lon2 = xp.radians(xp.float64(site_lon)) lat2 = _xp.radians(_xp.float64(site_lat))
lon2 = _xp.radians(_xp.float64(site_lon))
dlat = lat2 - lat1 dlat = lat2 - lat1
dlon = lon2 - lon1 dlon = lon2 - lon1
a = xp.sin(dlat / 2) ** 2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon / 2) ** 2 a = _xp.sin(dlat / 2) ** 2 + _xp.cos(lat1) * _xp.cos(lat2) * _xp.sin(dlon / 2) ** 2
c = 2 * xp.arcsin(xp.sqrt(a)) c = 2 * _xp.arcsin(_xp.sqrt(a))
distances = 6371000.0 * c distances = 6371000.0 * c
return _to_cpu(distances) return _to_cpu(distances)
@@ -108,40 +90,41 @@ class GPUService:
Returns path loss in dB as a CPU numpy array. Returns path loss in dB as a CPU numpy array.
""" """
d_arr = xp.asarray(distances, dtype=xp.float64) _xp = gpu_manager.get_array_module()
d_km = xp.maximum(d_arr / 1000.0, 0.1) d_arr = _xp.asarray(distances, dtype=_xp.float64)
d_km = _xp.maximum(d_arr / 1000.0, 0.1)
freq = float(frequency_mhz) freq = float(frequency_mhz)
h_tx = max(float(tx_height), 1.0) h_tx = max(float(tx_height), 1.0)
h_rx = max(float(rx_height), 1.0) h_rx = max(float(rx_height), 1.0)
log_f = xp.log10(xp.float64(freq)) log_f = _xp.log10(_xp.float64(freq))
log_hb = xp.log10(xp.float64(max(h_tx, 1.0))) log_hb = _xp.log10(_xp.float64(max(h_tx, 1.0)))
if freq > 2000: if freq > 2000:
# Free-Space Path Loss: FSPL = 20*log10(d_km) + 20*log10(f) + 32.45 # 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: elif freq > 1500:
# COST-231 Hata: extends Okumura-Hata to 1500-2000 MHz # 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) 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 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": if environment == "urban":
L += 3.0 # Metropolitan center correction L += 3.0 # Metropolitan center correction
elif freq >= 150: elif freq >= 150:
# Okumura-Hata: 150-1500 MHz # Okumura-Hata: 150-1500 MHz
if environment == "urban" and freq >= 400: 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: else:
a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8) 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 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": 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": elif environment == "rural":
L = L_urban - 4.78 * (log_f ** 2) + 18.33 * log_f - 35.94 L = L_urban - 4.78 * (log_f ** 2) + 18.33 * log_f - 35.94
elif environment == "open": elif environment == "open":
@@ -152,7 +135,7 @@ class GPUService:
else: else:
# Very low frequency — Longley-Rice simplified (area mode) # Very low frequency — Longley-Rice simplified (area mode)
# Use FSPL as baseline with terrain roughness correction # 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) 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 type { SiteFormValues } from '@/components/modals/index.ts';
import ToastContainer from '@/components/ui/Toast.tsx'; import ToastContainer from '@/components/ui/Toast.tsx';
import ThemeToggle from '@/components/ui/ThemeToggle.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 Button from '@/components/ui/Button.tsx';
import NumberInput from '@/components/ui/NumberInput.tsx'; import NumberInput from '@/components/ui/NumberInput.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx'; import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
@@ -111,6 +113,7 @@ export default function App() {
const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode); const setMeasurementMode = useSettingsStore((s) => s.setMeasurementMode);
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo); const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo); const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo);
const showBoundary = useSettingsStore((s) => s.showBoundary);
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay); const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay); const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity); const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
@@ -132,6 +135,7 @@ export default function App() {
const [panelCollapsed, setPanelCollapsed] = useState(false); const [panelCollapsed, setPanelCollapsed] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false);
const [kbDeleteTarget, setKbDeleteTarget] = useState<{ id: string; name: string } | null>(null); 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) // Region wizard for first-run (desktop mode only)
const [showWizard, setShowWizard] = useState(false); const [showWizard, setShowWizard] = useState(false);
@@ -484,6 +488,7 @@ export default function App() {
</span> </span>
</div> </div>
<div className="flex items-center gap-3 mr-4"> <div className="flex items-center gap-3 mr-4">
<GPUIndicator />
<ThemeToggle /> <ThemeToggle />
{/* Undo / Redo buttons */} {/* Undo / Redo buttons */}
<div className="hidden sm:flex items-center gap-1"> <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"> <div className="flex-1 flex overflow-hidden relative">
{/* Map */} {/* Map */}
<div className="flex-1 relative"> <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 */} {/* Show partial results during tiled calculation, or final result */}
{(coverageResult || (isCalculating && partialPoints.length > 0)) && ( {(coverageResult || (isCalculating && partialPoints.length > 0)) && (
<> <>
@@ -672,7 +681,7 @@ export default function App() {
{coverageResult && ( {coverageResult && (
<CoverageBoundary <CoverageBoundary
points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)} points={coverageResult.points.filter(p => p.rsrp >= settings.rsrpThreshold)}
visible={heatmapVisible} visible={showBoundary}
resolution={settings.resolution} resolution={settings.resolution}
/> />
)} )}
@@ -681,6 +690,13 @@ export default function App() {
</MapView> </MapView>
<HeatmapLegend /> <HeatmapLegend />
<ResultsPanel /> <ResultsPanel />
{profileEndpoints && (
<TerrainProfile
start={profileEndpoints.start}
end={profileEndpoints.end}
onClose={() => setProfileEndpoints(null)}
/>
)}
</div> </div>
{/* Side panel */} {/* Side panel */}
@@ -1023,6 +1039,20 @@ export default function App() {
<option value="vehicle">Inside Vehicle</option> <option value="vehicle">Inside Vehicle</option>
</select> </select>
</div> </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>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts'; import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
import { useCoverageStore } from '@/store/coverage.ts'; import { useCoverageStore } from '@/store/coverage.ts';
import { useSitesStore } from '@/store/sites.ts'; import { useSitesStore } from '@/store/sites.ts';
import { useSettingsStore } from '@/store/settings.ts';
const LEGEND_STEPS = [ const LEGEND_STEPS = [
{ rsrp: -130, label: 'No Service' }, { rsrp: -130, label: 'No Service' },
@@ -41,6 +42,8 @@ export default function HeatmapLegend() {
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap); const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
const settings = useCoverageStore((s) => s.settings); const settings = useCoverageStore((s) => s.settings);
const sites = useSitesStore((s) => s.sites); const sites = useSitesStore((s) => s.sites);
const showBoundary = useSettingsStore((s) => s.showBoundary);
const setShowBoundary = useSettingsStore((s) => s.setShowBoundary);
if (!result) return null; if (!result) return null;
@@ -72,6 +75,23 @@ export default function HeatmapLegend() {
</button> </button>
</div> </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 */} {/* Gradient bar + labels */}
<div className="flex gap-2"> <div className="flex gap-2">
{/* Continuous gradient bar */} {/* Continuous gradient bar */}

View File

@@ -16,6 +16,7 @@ import ElevationLayer from './ElevationLayer.tsx';
interface MapViewProps { interface MapViewProps {
onMapClick: (lat: number, lon: number) => void; onMapClick: (lat: number, lon: number) => void;
onEditSite: (site: Site) => void; onEditSite: (site: Site) => void;
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -48,7 +49,7 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
return null; 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 sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode); const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const showTerrain = useSettingsStore((s) => s.showTerrain); 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'); addToast(`Distance: ${distKm.toFixed(2)} km (${(distKm * 1000).toFixed(0)} m)`, 'info');
setMeasurementMode(false); setMeasurementMode(false);
}} }}
onProfileRequest={onProfileRequest}
/> />
{sites {sites
.filter((s) => s.visible) .filter((s) => s.visible)

View File

@@ -5,6 +5,7 @@ import L from 'leaflet';
interface MeasurementToolProps { interface MeasurementToolProps {
enabled: boolean; enabled: boolean;
onComplete?: (distanceKm: number) => void; onComplete?: (distanceKm: number) => void;
onProfileRequest?: (start: [number, number], end: [number, number]) => void;
} }
function haversineKm( 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>', 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 map = useMap();
const [points, setPoints] = useState<[number, number][]>([]); const [points, setPoints] = useState<[number, number][]>([]);
const pointsRef = useRef(points); 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) 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> </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) { if (form.gain < 0 || form.gain > 30) {
newErrors.gain = 'Gain must be 0-30 dBi'; newErrors.gain = 'Gain must be 0-30 dBi';
} }
if (form.frequency < 100 || form.frequency > 6000) { if (form.frequency < 30 || form.frequency > 6000) {
newErrors.frequency = 'Frequency must be 100-6000 MHz'; newErrors.frequency = 'Frequency must be 30-6000 MHz';
} }
if (form.height < 1 || form.height > 100) { if (form.height < 1 || form.height > 100) {
newErrors.height = 'Height must be 1-100m'; 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'; import type { FrequencyBand } from '@/types/index.ts';
export const COMMON_FREQUENCIES: FrequencyBand[] = [ 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, value: 800,
name: 'Band 20', name: 'Band 20',
@@ -12,6 +45,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
typical: 'Rural coverage, deep building penetration', 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, value: 1800,
name: 'Band 3', 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 // 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 // All quick frequencies grouped by band type
export const FREQUENCY_GROUPS = { export const FREQUENCY_GROUPS = {
LTE: [800, 1800, 1900, 2100, 2600], VHF: [70, 150],
UHF: [450], UHF: [225, 450],
VHF: [150], LTE: [700, 800, 900, 1800, 1900, 2100, 2600],
'5G': [3500], '5G': [3500],
} as const; } as const;

View File

@@ -212,6 +212,53 @@ class ApiService {
if (!response.ok) throw new Error('Failed to get cache stats'); if (!response.ok) throw new Error('Failed to get cache stats');
return response.json(); 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 === // === Region types ===
@@ -244,4 +291,29 @@ export interface CacheStats {
vegetation_mb: number; 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(); export const api = new ApiService();

View File

@@ -73,6 +73,7 @@ function buildApiSettings(settings: CoverageSettings) {
use_atmospheric: settings.use_atmospheric, use_atmospheric: settings.use_atmospheric,
temperature_c: settings.temperature_c, temperature_c: settings.temperature_c,
humidity_percent: settings.humidity_percent, humidity_percent: settings.humidity_percent,
fading_margin: settings.fading_margin,
}; };
} }
@@ -166,6 +167,8 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
use_atmospheric: false, use_atmospheric: false,
temperature_c: 15, temperature_c: 15,
humidity_percent: 50, humidity_percent: 50,
// Fading margin
fading_margin: 0,
}, },
heatmapVisible: true, heatmapVisible: true,
error: null, error: null,

View File

@@ -10,9 +10,11 @@ interface SettingsState {
showGrid: boolean; showGrid: boolean;
measurementMode: boolean; measurementMode: boolean;
showElevationInfo: boolean; showElevationInfo: boolean;
showBoundary: boolean;
showElevationOverlay: boolean; showElevationOverlay: boolean;
elevationOpacity: number; elevationOpacity: number;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
setShowBoundary: (show: boolean) => void;
setShowTerrain: (show: boolean) => void; setShowTerrain: (show: boolean) => void;
setTerrainOpacity: (opacity: number) => void; setTerrainOpacity: (opacity: number) => void;
setShowGrid: (show: boolean) => void; setShowGrid: (show: boolean) => void;
@@ -42,6 +44,7 @@ export const useSettingsStore = create<SettingsState>()(
showGrid: false, showGrid: false,
measurementMode: false, measurementMode: false,
showElevationInfo: false, showElevationInfo: false,
showBoundary: false,
showElevationOverlay: false, showElevationOverlay: false,
elevationOpacity: 0.5, elevationOpacity: 0.5,
setTheme: (theme: Theme) => { setTheme: (theme: Theme) => {
@@ -53,6 +56,7 @@ export const useSettingsStore = create<SettingsState>()(
setShowGrid: (show: boolean) => set({ showGrid: show }), setShowGrid: (show: boolean) => set({ showGrid: show }),
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }), setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }), setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }),
setShowBoundary: (show: boolean) => set({ showBoundary: show }),
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }), setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }), setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
}), }),

View File

@@ -64,6 +64,8 @@ export interface CoverageSettings {
use_atmospheric?: boolean; use_atmospheric?: boolean;
temperature_c?: number; temperature_c?: number;
humidity_percent?: number; humidity_percent?: number;
// Fading margin
fading_margin?: number; // dB additional safety loss
} }
export interface GridPoint { export interface GridPoint {