mytec: after methods
This commit is contained in:
@@ -132,6 +132,59 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def calculate_preview(request: CoverageRequest) -> CoverageResponse:
|
||||||
|
"""
|
||||||
|
Fast radial preview using terrain-only along 360 spokes.
|
||||||
|
|
||||||
|
Returns coverage points much faster than full calculation
|
||||||
|
by skipping building/OSM data and using radial spokes instead of grid.
|
||||||
|
"""
|
||||||
|
if not request.sites:
|
||||||
|
raise HTTPException(400, "At least one site required")
|
||||||
|
|
||||||
|
site = request.sites[0]
|
||||||
|
effective_settings = apply_preset(request.settings.model_copy())
|
||||||
|
|
||||||
|
env = getattr(effective_settings, 'environment', 'urban')
|
||||||
|
primary_model = select_propagation_model(site.frequency, env)
|
||||||
|
models_used = ["terrain_los", primary_model.name]
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
points = await asyncio.wait_for(
|
||||||
|
coverage_service.calculate_radial_preview(
|
||||||
|
site, request.settings,
|
||||||
|
),
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(408, "Preview timeout (30s)")
|
||||||
|
|
||||||
|
computation_time = time.time() - start_time
|
||||||
|
|
||||||
|
rsrp_values = [p.rsrp for p in points]
|
||||||
|
los_count = sum(1 for p in points if p.has_los)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"min_rsrp": min(rsrp_values) if rsrp_values else 0,
|
||||||
|
"max_rsrp": max(rsrp_values) if rsrp_values else 0,
|
||||||
|
"avg_rsrp": sum(rsrp_values) / len(rsrp_values) if rsrp_values else 0,
|
||||||
|
"los_percentage": (los_count / len(points) * 100) if points else 0,
|
||||||
|
"mode": "radial_preview",
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoverageResponse(
|
||||||
|
points=points,
|
||||||
|
count=len(points),
|
||||||
|
settings=effective_settings,
|
||||||
|
stats=stats,
|
||||||
|
computation_time=round(computation_time, 2),
|
||||||
|
models_used=models_used,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/presets")
|
@router.get("/presets")
|
||||||
async def get_presets():
|
async def get_presets():
|
||||||
"""Get available propagation model presets"""
|
"""Get available propagation model presets"""
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Valid SRTM tile sizes (bytes)
|
||||||
|
_SRTM1_SIZE = 3601 * 3601 * 2 # 25,934,402
|
||||||
|
_SRTM3_SIZE = 1201 * 1201 * 2 # 2,884,802
|
||||||
|
|
||||||
|
|
||||||
@router.get("/info")
|
@router.get("/info")
|
||||||
async def get_system_info():
|
async def get_system_info():
|
||||||
@@ -72,3 +78,108 @@ async def shutdown():
|
|||||||
loop.call_later(3.0, lambda: os._exit(0))
|
loop.call_later(3.0, lambda: os._exit(0))
|
||||||
|
|
||||||
return {"status": "shutting down", "workers_killed": killed}
|
return {"status": "shutting down", "workers_killed": killed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics")
|
||||||
|
async def get_diagnostics():
|
||||||
|
"""Validate terrain tiles and OSM cache files.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Terrain .hgt files: must be exactly SRTM1 or SRTM3 size
|
||||||
|
- OSM cache .json files: must be valid JSON with expected structure
|
||||||
|
- Cache manager stats (memory + disk)
|
||||||
|
"""
|
||||||
|
data_path = Path(os.environ.get('RFCP_DATA_PATH', './data'))
|
||||||
|
terrain_path = data_path / 'terrain'
|
||||||
|
osm_dirs = [
|
||||||
|
data_path / 'osm' / 'buildings',
|
||||||
|
data_path / 'osm' / 'streets',
|
||||||
|
data_path / 'osm' / 'vegetation',
|
||||||
|
data_path / 'osm' / 'water',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Terrain tiles ---
|
||||||
|
terrain_tiles = []
|
||||||
|
terrain_errors = []
|
||||||
|
total_terrain_bytes = 0
|
||||||
|
|
||||||
|
if terrain_path.exists():
|
||||||
|
for hgt in sorted(terrain_path.glob("*.hgt")):
|
||||||
|
size = hgt.stat().st_size
|
||||||
|
total_terrain_bytes += size
|
||||||
|
if size == _SRTM1_SIZE:
|
||||||
|
terrain_tiles.append({"name": hgt.name, "type": "SRTM1", "size": size})
|
||||||
|
elif size == _SRTM3_SIZE:
|
||||||
|
terrain_tiles.append({"name": hgt.name, "type": "SRTM3", "size": size})
|
||||||
|
else:
|
||||||
|
terrain_errors.append({
|
||||||
|
"name": hgt.name,
|
||||||
|
"size": size,
|
||||||
|
"error": f"Invalid size (expected {_SRTM1_SIZE} or {_SRTM3_SIZE})",
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- OSM cache ---
|
||||||
|
osm_files = []
|
||||||
|
osm_errors = []
|
||||||
|
total_osm_bytes = 0
|
||||||
|
|
||||||
|
for osm_dir in osm_dirs:
|
||||||
|
if not osm_dir.exists():
|
||||||
|
continue
|
||||||
|
category = osm_dir.name
|
||||||
|
for jf in sorted(osm_dir.glob("*.json")):
|
||||||
|
fsize = jf.stat().st_size
|
||||||
|
total_osm_bytes += fsize
|
||||||
|
try:
|
||||||
|
data = json.loads(jf.read_text())
|
||||||
|
has_timestamp = '_cached_at' in data or '_ts' in data
|
||||||
|
has_data = 'data' in data or 'v' in data
|
||||||
|
if has_timestamp and has_data:
|
||||||
|
osm_files.append({
|
||||||
|
"name": jf.name,
|
||||||
|
"category": category,
|
||||||
|
"size": fsize,
|
||||||
|
"valid": True,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
osm_errors.append({
|
||||||
|
"name": jf.name,
|
||||||
|
"category": category,
|
||||||
|
"size": fsize,
|
||||||
|
"error": "Missing expected keys (_cached_at/data or _ts/v)",
|
||||||
|
})
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
osm_errors.append({
|
||||||
|
"name": jf.name,
|
||||||
|
"category": category,
|
||||||
|
"size": fsize,
|
||||||
|
"error": f"Invalid JSON: {e}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Cache manager stats ---
|
||||||
|
try:
|
||||||
|
from app.services.cache import cache_manager
|
||||||
|
cache_stats = cache_manager.stats()
|
||||||
|
except Exception:
|
||||||
|
cache_stats = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data_path": str(data_path),
|
||||||
|
"terrain": {
|
||||||
|
"path": str(terrain_path),
|
||||||
|
"exists": terrain_path.exists(),
|
||||||
|
"tile_count": len(terrain_tiles),
|
||||||
|
"error_count": len(terrain_errors),
|
||||||
|
"total_mb": round(total_terrain_bytes / (1024 * 1024), 1),
|
||||||
|
"tiles": terrain_tiles,
|
||||||
|
"errors": terrain_errors,
|
||||||
|
},
|
||||||
|
"osm_cache": {
|
||||||
|
"valid_count": len(osm_files),
|
||||||
|
"error_count": len(osm_errors),
|
||||||
|
"total_mb": round(total_osm_bytes / (1024 * 1024), 1),
|
||||||
|
"files": osm_files,
|
||||||
|
"errors": osm_errors,
|
||||||
|
},
|
||||||
|
"cache_manager": cache_stats,
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ progress updates during computation phases.
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ from app.services.coverage_service import (
|
|||||||
)
|
)
|
||||||
from app.services.parallel_coverage_service import CancellationToken
|
from app.services.parallel_coverage_service import CancellationToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
"""Track cancellation tokens per calculation."""
|
"""Track cancellation tokens per calculation."""
|
||||||
@@ -37,8 +40,8 @@ class ConnectionManager:
|
|||||||
"progress": min(progress, 1.0),
|
"progress": min(progress, 1.0),
|
||||||
"eta_seconds": eta,
|
"eta_seconds": eta,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"[WS] send_progress failed: {e}")
|
||||||
|
|
||||||
async def send_result(self, ws: WebSocket, calc_id: str, result: dict):
|
async def send_result(self, ws: WebSocket, calc_id: str, result: dict):
|
||||||
try:
|
try:
|
||||||
@@ -47,8 +50,8 @@ class ConnectionManager:
|
|||||||
"calculation_id": calc_id,
|
"calculation_id": calc_id,
|
||||||
"data": result,
|
"data": result,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"[WS] send_result failed: {e}")
|
||||||
|
|
||||||
async def send_error(self, ws: WebSocket, calc_id: str, error: str):
|
async def send_error(self, ws: WebSocket, calc_id: str, error: str):
|
||||||
try:
|
try:
|
||||||
@@ -57,8 +60,8 @@ class ConnectionManager:
|
|||||||
"calculation_id": calc_id,
|
"calculation_id": calc_id,
|
||||||
"message": error,
|
"message": error,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.debug(f"[WS] send_error failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
ws_manager = ConnectionManager()
|
ws_manager = ConnectionManager()
|
||||||
@@ -69,6 +72,17 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
|||||||
cancel_token = CancellationToken()
|
cancel_token = CancellationToken()
|
||||||
ws_manager._cancel_tokens[calc_id] = cancel_token
|
ws_manager._cancel_tokens[calc_id] = cancel_token
|
||||||
|
|
||||||
|
# Shared progress state — written by worker threads, polled by event loop.
|
||||||
|
# Python GIL makes dict value assignment atomic for simple types.
|
||||||
|
_progress = {"phase": "Initializing", "pct": 0.05, "seq": 0}
|
||||||
|
_done = False
|
||||||
|
|
||||||
|
def sync_progress_fn(phase: str, pct: float, _eta: Optional[float] = None):
|
||||||
|
"""Thread-safe progress callback — just updates a shared dict."""
|
||||||
|
_progress["phase"] = phase
|
||||||
|
_progress["pct"] = pct
|
||||||
|
_progress["seq"] += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sites_data = data.get("sites", [])
|
sites_data = data.get("sites", [])
|
||||||
settings_data = data.get("settings", {})
|
settings_data = data.get("settings", {})
|
||||||
@@ -104,45 +118,21 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
|||||||
|
|
||||||
await ws_manager.send_progress(ws, calc_id, "Initializing", 0.05)
|
await ws_manager.send_progress(ws, calc_id, "Initializing", 0.05)
|
||||||
|
|
||||||
# ── Bridge sync progress_fn → async WS sends ──
|
# ── Progress poller: reads shared dict and sends WS updates ──
|
||||||
# progress_fn is called from two contexts:
|
async def progress_poller():
|
||||||
# 1. Event loop thread (phases 1-2.5, directly in calculate_coverage)
|
last_sent_seq = 0
|
||||||
# 2. Worker threads (phase 3, from ProcessPool/sequential executors)
|
last_sent_pct = 0.0
|
||||||
# We detect which thread we're on and use the appropriate method.
|
while not _done:
|
||||||
loop = asyncio.get_running_loop()
|
await asyncio.sleep(0.3)
|
||||||
event_loop_thread_id = threading.current_thread().ident
|
seq = _progress["seq"]
|
||||||
progress_queue: asyncio.Queue = asyncio.Queue()
|
pct = _progress["pct"]
|
||||||
|
phase = _progress["phase"]
|
||||||
|
if seq != last_sent_seq and (pct - last_sent_pct >= 0.01 or phase != "Calculating coverage"):
|
||||||
|
await ws_manager.send_progress(ws, calc_id, phase, pct)
|
||||||
|
last_sent_seq = seq
|
||||||
|
last_sent_pct = pct
|
||||||
|
|
||||||
def sync_progress_fn(phase: str, pct: float, _eta: Optional[float] = None):
|
poller_task = asyncio.create_task(progress_poller())
|
||||||
"""Thread-safe progress callback for coverage_service."""
|
|
||||||
if threading.current_thread().ident == event_loop_thread_id:
|
|
||||||
# From event loop thread: put directly to queue
|
|
||||||
progress_queue.put_nowait((phase, pct))
|
|
||||||
else:
|
|
||||||
# From worker thread: use thread-safe bridge to wake event loop
|
|
||||||
loop.call_soon_threadsafe(progress_queue.put_nowait, (phase, pct))
|
|
||||||
|
|
||||||
# Background task: drain queue and send WS progress messages
|
|
||||||
_sender_done = False
|
|
||||||
|
|
||||||
async def progress_sender():
|
|
||||||
nonlocal _sender_done
|
|
||||||
last_pct = 0.0
|
|
||||||
while not _sender_done:
|
|
||||||
try:
|
|
||||||
phase, pct = await asyncio.wait_for(progress_queue.get(), timeout=0.5)
|
|
||||||
if pct >= 1.0:
|
|
||||||
break
|
|
||||||
# Throttle: only send if progress changed meaningfully
|
|
||||||
if pct - last_pct >= 0.02 or phase != "Calculating coverage":
|
|
||||||
await ws_manager.send_progress(ws, calc_id, phase, pct)
|
|
||||||
last_pct = pct
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
|
|
||||||
progress_task = asyncio.create_task(progress_sender())
|
|
||||||
|
|
||||||
# Run calculation with timeout
|
# Run calculation with timeout
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -164,25 +154,23 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
cancel_token.cancel()
|
cancel_token.cancel()
|
||||||
_sender_done = True
|
_done = True
|
||||||
progress_queue.put_nowait(("done", 1.0))
|
await poller_task
|
||||||
await progress_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)")
|
await ws_manager.send_error(ws, calc_id, "Calculation timeout (5 min)")
|
||||||
return
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
cancel_token.cancel()
|
cancel_token.cancel()
|
||||||
_sender_done = True
|
_done = True
|
||||||
progress_queue.put_nowait(("done", 1.0))
|
await poller_task
|
||||||
await progress_task
|
|
||||||
await ws_manager.send_error(ws, calc_id, "Calculation cancelled")
|
await ws_manager.send_error(ws, calc_id, "Calculation cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Stop progress sender
|
# Stop poller and send final progress
|
||||||
_sender_done = True
|
_done = True
|
||||||
progress_queue.put_nowait(("done", 1.0))
|
await poller_task
|
||||||
await progress_task
|
await ws_manager.send_progress(ws, calc_id, "Finalizing", 0.98)
|
||||||
|
|
||||||
computation_time = time.time() - start_time
|
computation_time = time.time() - start_time
|
||||||
|
|
||||||
@@ -216,14 +204,10 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
|||||||
await ws_manager.send_result(ws, calc_id, result)
|
await ws_manager.send_result(ws, calc_id, result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Stop progress sender on unhandled exception
|
logger.error(f"[WS] Calculation error: {e}", exc_info=True)
|
||||||
_sender_done = True
|
_done = True
|
||||||
try:
|
try:
|
||||||
progress_queue.put_nowait(("done", 1.0))
|
await poller_task
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
await progress_task
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await ws_manager.send_error(ws, calc_id, str(e))
|
await ws_manager.send_error(ws, calc_id, str(e))
|
||||||
|
|||||||
@@ -678,33 +678,65 @@ class CoverageService:
|
|||||||
|
|
||||||
return list(point_map.values())
|
return list(point_map.values())
|
||||||
|
|
||||||
|
# Adaptive resolution zone boundaries (meters)
|
||||||
|
_ADAPTIVE_ZONES = [
|
||||||
|
(0, 2000), # Inner: full user resolution
|
||||||
|
(2000, 5000), # Middle: at least 300m
|
||||||
|
(5000, float('inf')), # Outer: at least 500m
|
||||||
|
]
|
||||||
|
_ADAPTIVE_MIN_RES = [None, 300, 500] # Minimum resolution per zone
|
||||||
|
|
||||||
def _generate_grid(
|
def _generate_grid(
|
||||||
self,
|
self,
|
||||||
center_lat: float, center_lon: float,
|
center_lat: float, center_lon: float,
|
||||||
radius: float, resolution: float
|
radius: float, resolution: float
|
||||||
) -> List[Tuple[float, float]]:
|
) -> List[Tuple[float, float]]:
|
||||||
"""Generate coverage grid points"""
|
"""Generate coverage grid with adaptive resolution.
|
||||||
|
|
||||||
|
Close to TX (<2km): user's chosen resolution (details matter).
|
||||||
|
Mid-range (2-5km): at least 300m resolution.
|
||||||
|
Far (>5km): at least 500m resolution.
|
||||||
|
|
||||||
|
For small radii or coarse base resolution, this degenerates to a
|
||||||
|
uniform grid (no zones exceed their minimum).
|
||||||
|
"""
|
||||||
|
cos_lat = np.cos(np.radians(center_lat))
|
||||||
|
seen = set()
|
||||||
points = []
|
points = []
|
||||||
|
|
||||||
# Convert resolution to degrees
|
for zone_idx, (zone_min_m, zone_max_m) in enumerate(self._ADAPTIVE_ZONES):
|
||||||
lat_step = resolution / 111000
|
if zone_min_m >= radius:
|
||||||
lon_step = resolution / (111000 * np.cos(np.radians(center_lat)))
|
break # No points in this zone
|
||||||
|
|
||||||
# Calculate grid bounds
|
zone_max_m = min(zone_max_m, radius)
|
||||||
lat_delta = radius / 111000
|
min_res = self._ADAPTIVE_MIN_RES[zone_idx]
|
||||||
lon_delta = radius / (111000 * np.cos(np.radians(center_lat)))
|
zone_res = max(resolution, min_res) if min_res else resolution
|
||||||
|
|
||||||
lat = center_lat - lat_delta
|
lat_step = zone_res / 111000
|
||||||
while lat <= center_lat + lat_delta:
|
lon_step = zone_res / (111000 * cos_lat)
|
||||||
lon = center_lon - lon_delta
|
|
||||||
while lon <= center_lon + lon_delta:
|
|
||||||
# Check if within radius (circular, not square)
|
|
||||||
dist = TerrainService.haversine_distance(center_lat, center_lon, lat, lon)
|
|
||||||
if dist <= radius:
|
|
||||||
points.append((lat, lon))
|
|
||||||
lon += lon_step
|
|
||||||
lat += lat_step
|
|
||||||
|
|
||||||
|
# Grid bounds for this annular ring (with small overlap at boundaries)
|
||||||
|
lat_delta = zone_max_m / 111000
|
||||||
|
lon_delta = zone_max_m / (111000 * cos_lat)
|
||||||
|
|
||||||
|
lat = center_lat - lat_delta
|
||||||
|
while lat <= center_lat + lat_delta:
|
||||||
|
lon = center_lon - lon_delta
|
||||||
|
while lon <= center_lon + lon_delta:
|
||||||
|
dist = TerrainService.haversine_distance(
|
||||||
|
center_lat, center_lon, lat, lon
|
||||||
|
)
|
||||||
|
if zone_min_m <= dist <= zone_max_m:
|
||||||
|
# Round to avoid floating-point duplicates at zone boundaries
|
||||||
|
key = (round(lat, 7), round(lon, 7))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
points.append(key)
|
||||||
|
lon += lon_step
|
||||||
|
lat += lat_step
|
||||||
|
|
||||||
|
_clog(f"Adaptive grid: {len(points)} points "
|
||||||
|
f"(radius={radius:.0f}m, base_res={resolution:.0f}m)")
|
||||||
return points
|
return points
|
||||||
|
|
||||||
def _run_point_loop(
|
def _run_point_loop(
|
||||||
@@ -1051,6 +1083,112 @@ class CoverageService:
|
|||||||
"""Knife-edge diffraction loss using ITU-R P.526 model."""
|
"""Knife-edge diffraction loss using ITU-R P.526 model."""
|
||||||
return _DIFFRACTION_MODEL.calculate_clearance_loss(clearance, frequency)
|
return _DIFFRACTION_MODEL.calculate_clearance_loss(clearance, frequency)
|
||||||
|
|
||||||
|
async def calculate_radial_preview(
|
||||||
|
self,
|
||||||
|
site: SiteParams,
|
||||||
|
settings: CoverageSettings,
|
||||||
|
num_spokes: int = 360,
|
||||||
|
points_per_spoke: int = 50,
|
||||||
|
) -> List[CoveragePoint]:
|
||||||
|
"""Fast radial preview using terrain-only along 360 spokes.
|
||||||
|
|
||||||
|
Much faster than full grid because:
|
||||||
|
- No OSM data fetch (no buildings/vegetation/water)
|
||||||
|
- Terrain profile reused per spoke
|
||||||
|
- Fewer total points at long range
|
||||||
|
"""
|
||||||
|
calc_start = time.time()
|
||||||
|
settings = apply_preset(settings)
|
||||||
|
|
||||||
|
# Pre-load terrain tiles for bbox
|
||||||
|
lat_delta = settings.radius / 111000
|
||||||
|
cos_lat = np.cos(np.radians(site.lat))
|
||||||
|
lon_delta = settings.radius / (111000 * cos_lat)
|
||||||
|
min_lat = site.lat - lat_delta
|
||||||
|
max_lat = site.lat + lat_delta
|
||||||
|
min_lon = site.lon - lon_delta
|
||||||
|
max_lon = site.lon + lon_delta
|
||||||
|
|
||||||
|
tile_names = await self.terrain.ensure_tiles_for_bbox(
|
||||||
|
min_lat, min_lon, max_lat, max_lon
|
||||||
|
)
|
||||||
|
for tn in tile_names:
|
||||||
|
self.terrain._load_tile(tn)
|
||||||
|
|
||||||
|
site_elevation = self.terrain.get_elevation_sync(site.lat, site.lon)
|
||||||
|
|
||||||
|
# Select propagation model
|
||||||
|
env = getattr(settings, 'environment', 'urban')
|
||||||
|
model = select_propagation_model(site.frequency, env)
|
||||||
|
|
||||||
|
points: List[CoveragePoint] = []
|
||||||
|
|
||||||
|
for angle_deg in range(num_spokes):
|
||||||
|
angle_rad = math.radians(angle_deg)
|
||||||
|
cos_a = math.cos(angle_rad)
|
||||||
|
sin_a = math.sin(angle_rad)
|
||||||
|
|
||||||
|
# Antenna pattern loss for this spoke direction
|
||||||
|
antenna_loss = 0.0
|
||||||
|
if site.azimuth is not None and site.beamwidth:
|
||||||
|
angle_diff = abs(angle_deg - site.azimuth)
|
||||||
|
if angle_diff > 180:
|
||||||
|
angle_diff = 360 - angle_diff
|
||||||
|
half_bw = site.beamwidth / 2
|
||||||
|
if angle_diff <= half_bw:
|
||||||
|
antenna_loss = 3 * (angle_diff / half_bw) ** 2
|
||||||
|
else:
|
||||||
|
antenna_loss = 3 + 12 * ((angle_diff - half_bw) / half_bw) ** 2
|
||||||
|
antenna_loss = min(antenna_loss, 25)
|
||||||
|
|
||||||
|
for i in range(1, points_per_spoke + 1):
|
||||||
|
distance = i * (settings.radius / points_per_spoke)
|
||||||
|
|
||||||
|
# Move point along bearing
|
||||||
|
lat_offset = (distance / 111000) * cos_a
|
||||||
|
lon_offset = (distance / (111000 * cos_lat)) * sin_a
|
||||||
|
rx_lat = site.lat + lat_offset
|
||||||
|
rx_lon = site.lon + lon_offset
|
||||||
|
|
||||||
|
# Path loss
|
||||||
|
prop_input = PropagationInput(
|
||||||
|
frequency_mhz=site.frequency,
|
||||||
|
distance_m=distance,
|
||||||
|
tx_height_m=site.height,
|
||||||
|
rx_height_m=1.5,
|
||||||
|
environment=env,
|
||||||
|
)
|
||||||
|
path_loss = model.calculate(prop_input).path_loss_db
|
||||||
|
|
||||||
|
# Terrain LOS check
|
||||||
|
terrain_loss = 0.0
|
||||||
|
has_los = True
|
||||||
|
if settings.use_terrain:
|
||||||
|
los_result = self.los.check_line_of_sight_sync(
|
||||||
|
site.lat, site.lon, site.height,
|
||||||
|
rx_lat, rx_lon, 1.5,
|
||||||
|
)
|
||||||
|
has_los = los_result['has_los']
|
||||||
|
if not has_los:
|
||||||
|
terrain_loss = self._diffraction_loss(
|
||||||
|
los_result['clearance'], site.frequency
|
||||||
|
)
|
||||||
|
|
||||||
|
rsrp = (site.power + site.gain - path_loss
|
||||||
|
- antenna_loss - terrain_loss)
|
||||||
|
|
||||||
|
if rsrp >= settings.min_signal:
|
||||||
|
points.append(CoveragePoint(
|
||||||
|
lat=rx_lat, lon=rx_lon, rsrp=rsrp,
|
||||||
|
distance=distance, has_los=has_los,
|
||||||
|
terrain_loss=terrain_loss, building_loss=0.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
total_time = time.time() - calc_start
|
||||||
|
_clog(f"Radial preview: {len(points)} points, {num_spokes} spokes × "
|
||||||
|
f"{points_per_spoke} pts/spoke, {total_time:.1f}s")
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
# Singleton
|
||||||
coverage_service = CoverageService()
|
coverage_service = CoverageService()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Usage:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import gc
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -450,6 +451,9 @@ def _calculate_with_ray(
|
|||||||
log_fn(f"Ray done: {calc_time:.1f}s, {len(all_results)} results "
|
log_fn(f"Ray done: {calc_time:.1f}s, {len(all_results)} results "
|
||||||
f"({calc_time / max(1, total_points) * 1000:.1f}ms/point)")
|
f"({calc_time / max(1, total_points) * 1000:.1f}ms/point)")
|
||||||
|
|
||||||
|
# Force garbage collection after Ray computation
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
timing = {
|
timing = {
|
||||||
"parallel_total": calc_time,
|
"parallel_total": calc_time,
|
||||||
"ray_put": put_time,
|
"ray_put": put_time,
|
||||||
@@ -744,6 +748,8 @@ def _calculate_with_process_pool(
|
|||||||
block.unlink()
|
block.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Force garbage collection to release memory from workers
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
calc_time = time.time() - t_calc
|
calc_time = time.time() - t_calc
|
||||||
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
|
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
|
||||||
@@ -820,6 +826,9 @@ def _calculate_sequential(
|
|||||||
log_fn(f"Sequential done: {calc_time:.1f}s, {len(results)} results "
|
log_fn(f"Sequential done: {calc_time:.1f}s, {len(results)} results "
|
||||||
f"({calc_time / max(1, total) * 1000:.1f}ms/point)")
|
f"({calc_time / max(1, total) * 1000:.1f}ms/point)")
|
||||||
|
|
||||||
|
# Force garbage collection after sequential computation
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
timing["sequential_total"] = calc_time
|
timing["sequential_total"] = calc_time
|
||||||
timing["backend"] = "sequential"
|
timing["backend"] = "sequential"
|
||||||
return results, timing
|
return results, timing
|
||||||
|
|||||||
@@ -279,11 +279,21 @@ function createMainWindow() {
|
|||||||
if (!isQuitting) {
|
if (!isQuitting) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
gracefulShutdown().then(() => {
|
|
||||||
app.quit();
|
// Hard timeout: force exit after 5 seconds no matter what
|
||||||
}).catch(() => {
|
const forceExitTimer = setTimeout(() => {
|
||||||
|
log('[CLOSE] Force exit after 5s timeout');
|
||||||
killAllRfcpProcesses();
|
killAllRfcpProcesses();
|
||||||
app.quit();
|
process.exit(0);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
gracefulShutdown().then(() => {
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
|
app.exit(0);
|
||||||
|
}).catch(() => {
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
|
killAllRfcpProcesses();
|
||||||
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -534,11 +544,20 @@ app.on('before-quit', (event) => {
|
|||||||
if (!isQuitting) {
|
if (!isQuitting) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
gracefulShutdown().then(() => {
|
|
||||||
app.quit();
|
const forceExitTimer = setTimeout(() => {
|
||||||
}).catch(() => {
|
log('[CLOSE] Force exit from before-quit after 5s');
|
||||||
killAllRfcpProcesses();
|
killAllRfcpProcesses();
|
||||||
app.quit();
|
process.exit(0);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
gracefulShutdown().then(() => {
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
|
app.exit(0);
|
||||||
|
}).catch(() => {
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
|
killAllRfcpProcesses();
|
||||||
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ export default function App() {
|
|||||||
RF Coverage Planner
|
RF Coverage Planner
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 mr-4">
|
||||||
<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">
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }
|
|||||||
export default memo(function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
export default memo(function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
||||||
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
|
||||||
const selectSite = useSitesStore((s) => s.selectSite);
|
const selectSite = useSitesStore((s) => s.selectSite);
|
||||||
const updateSite = useSitesStore((s) => s.updateSite);
|
const moveSiteWithColocated = useSitesStore((s) => s.moveSiteWithColocated);
|
||||||
|
|
||||||
const isSelected = selectedSiteId === site.id;
|
const isSelected = selectedSiteId === site.id;
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default memo(function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
|||||||
dragend: (e) => {
|
dragend: (e) => {
|
||||||
const marker = e.target as L.Marker;
|
const marker = e.target as L.Marker;
|
||||||
const pos = marker.getLatLng();
|
const pos = marker.getLatLng();
|
||||||
updateSite(site.id, { lat: pos.lat, lon: pos.lng });
|
moveSiteWithColocated(site.id, pos.lat, pos.lng);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -89,18 +89,29 @@ class WebSocketService {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'progress':
|
case 'progress':
|
||||||
pending?.onProgress?.({
|
if (pending?.onProgress) {
|
||||||
phase: msg.phase,
|
pending.onProgress({
|
||||||
progress: msg.progress,
|
phase: msg.phase,
|
||||||
eta_seconds: msg.eta_seconds,
|
progress: msg.progress,
|
||||||
});
|
eta_seconds: msg.eta_seconds,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[WS] progress msg but no pending calc:', calcId, msg.phase, msg.progress);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
pending?.onResult(msg.data);
|
if (pending) {
|
||||||
|
pending.onResult(msg.data);
|
||||||
|
} else {
|
||||||
|
console.warn('[WS] result msg but no pending calc:', calcId);
|
||||||
|
}
|
||||||
if (calcId) this._pendingCalcs.delete(calcId);
|
if (calcId) this._pendingCalcs.delete(calcId);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
pending?.onError(msg.message);
|
console.error('[WS] error:', msg.message);
|
||||||
|
if (pending) {
|
||||||
|
pending.onError(msg.message);
|
||||||
|
}
|
||||||
if (calcId) this._pendingCalcs.delete(calcId);
|
if (calcId) this._pendingCalcs.delete(calcId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const SITE_COLORS = [
|
|||||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16',
|
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Proximity threshold for co-located sector grouping (~11 meters). */
|
||||||
|
const COLOCATION_THRESHOLD = 0.0001;
|
||||||
|
|
||||||
interface SitesState {
|
interface SitesState {
|
||||||
sites: Site[];
|
sites: Site[];
|
||||||
selectedSiteId: string | null;
|
selectedSiteId: string | null;
|
||||||
@@ -34,6 +37,7 @@ interface SitesState {
|
|||||||
loadSites: () => Promise<void>;
|
loadSites: () => Promise<void>;
|
||||||
addSite: (data: SiteFormData) => Promise<Site>;
|
addSite: (data: SiteFormData) => Promise<Site>;
|
||||||
updateSite: (id: string, data: Partial<Site>) => Promise<void>;
|
updateSite: (id: string, data: Partial<Site>) => Promise<void>;
|
||||||
|
moveSiteWithColocated: (id: string, newLat: number, newLon: number) => Promise<void>;
|
||||||
deleteSite: (id: string) => Promise<void>;
|
deleteSite: (id: string) => Promise<void>;
|
||||||
selectSite: (id: string | null) => void;
|
selectSite: (id: string | null) => void;
|
||||||
setEditingSite: (id: string | null) => void;
|
setEditingSite: (id: string | null) => void;
|
||||||
@@ -124,13 +128,78 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveSiteWithColocated: async (id: string, newLat: number, newLon: number) => {
|
||||||
|
const sites = get().sites;
|
||||||
|
const site = sites.find((s) => s.id === id);
|
||||||
|
if (!site) return;
|
||||||
|
|
||||||
|
const deltaLat = newLat - site.lat;
|
||||||
|
const deltaLon = newLon - site.lon;
|
||||||
|
|
||||||
|
// Find co-located sector siblings
|
||||||
|
const colocated = sites.filter(
|
||||||
|
(s) =>
|
||||||
|
s.id !== id &&
|
||||||
|
Math.abs(s.lat - site.lat) < COLOCATION_THRESHOLD &&
|
||||||
|
Math.abs(s.lon - site.lon) < COLOCATION_THRESHOLD,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (colocated.length === 0) {
|
||||||
|
// No siblings — plain update
|
||||||
|
get().updateSite(id, { lat: newLat, lon: newLon });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSnapshot('move site group', sites);
|
||||||
|
const now = new Date();
|
||||||
|
const idsToMove = new Set([id, ...colocated.map((s) => s.id)]);
|
||||||
|
|
||||||
|
const updatedSites = sites.map((s) => {
|
||||||
|
if (!idsToMove.has(s.id)) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
lat: s.id === id ? newLat : s.lat + deltaLat,
|
||||||
|
lon: s.id === id ? newLon : s.lon + deltaLon,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const s of updatedSites) {
|
||||||
|
if (!idsToMove.has(s.id)) continue;
|
||||||
|
await db.sites.put({
|
||||||
|
id: s.id,
|
||||||
|
data: JSON.stringify(s),
|
||||||
|
createdAt: s.createdAt.getTime(),
|
||||||
|
updatedAt: now.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ sites: updatedSites });
|
||||||
|
},
|
||||||
|
|
||||||
deleteSite: async (id: string) => {
|
deleteSite: async (id: string) => {
|
||||||
pushSnapshot('delete site', get().sites);
|
const sites = get().sites;
|
||||||
await db.sites.delete(id);
|
const site = sites.find((s) => s.id === id);
|
||||||
|
if (!site) return;
|
||||||
|
|
||||||
|
// Find co-located sector siblings to delete together
|
||||||
|
const colocated = sites.filter(
|
||||||
|
(s) =>
|
||||||
|
Math.abs(s.lat - site.lat) < COLOCATION_THRESHOLD &&
|
||||||
|
Math.abs(s.lon - site.lon) < COLOCATION_THRESHOLD,
|
||||||
|
);
|
||||||
|
const idsToDelete = new Set(colocated.map((s) => s.id));
|
||||||
|
|
||||||
|
pushSnapshot('delete site', sites);
|
||||||
|
|
||||||
|
for (const delId of idsToDelete) {
|
||||||
|
await db.sites.delete(delId);
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sites: state.sites.filter((s) => s.id !== id),
|
sites: state.sites.filter((s) => !idsToDelete.has(s.id)),
|
||||||
selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId,
|
selectedSiteId: idsToDelete.has(state.selectedSiteId ?? '') ? null : state.selectedSiteId,
|
||||||
editingSiteId: state.editingSiteId === id ? null : state.editingSiteId,
|
editingSiteId: idsToDelete.has(state.editingSiteId ?? '') ? null : state.editingSiteId,
|
||||||
}));
|
}));
|
||||||
// Clear stale coverage data
|
// Clear stale coverage data
|
||||||
useCoverageStore.getState().clearCoverage();
|
useCoverageStore.getState().clearCoverage();
|
||||||
|
|||||||
Reference in New Issue
Block a user