mytec: after methods

This commit is contained in:
2026-02-02 01:55:09 +02:00
parent b5b2fd90d2
commit aa07fb5f02
10 changed files with 495 additions and 101 deletions

View File

@@ -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")
async def get_presets():
"""Get available propagation model presets"""

View File

@@ -1,10 +1,16 @@
import os
import json
import asyncio
import multiprocessing as mp
from pathlib import Path
from fastapi import 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")
async def get_system_info():
@@ -72,3 +78,108 @@ async def shutdown():
loop.call_later(3.0, lambda: os._exit(0))
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,
}

View File

@@ -7,6 +7,7 @@ progress updates during computation phases.
import time
import asyncio
import logging
import threading
from typing import Optional
@@ -18,6 +19,8 @@ from app.services.coverage_service import (
)
from app.services.parallel_coverage_service import CancellationToken
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Track cancellation tokens per calculation."""
@@ -37,8 +40,8 @@ class ConnectionManager:
"progress": min(progress, 1.0),
"eta_seconds": eta,
})
except Exception:
pass
except Exception as e:
logger.debug(f"[WS] send_progress failed: {e}")
async def send_result(self, ws: WebSocket, calc_id: str, result: dict):
try:
@@ -47,8 +50,8 @@ class ConnectionManager:
"calculation_id": calc_id,
"data": result,
})
except Exception:
pass
except Exception as e:
logger.debug(f"[WS] send_result failed: {e}")
async def send_error(self, ws: WebSocket, calc_id: str, error: str):
try:
@@ -57,8 +60,8 @@ class ConnectionManager:
"calculation_id": calc_id,
"message": error,
})
except Exception:
pass
except Exception as e:
logger.debug(f"[WS] send_error failed: {e}")
ws_manager = ConnectionManager()
@@ -69,6 +72,17 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
cancel_token = CancellationToken()
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:
sites_data = data.get("sites", [])
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)
# ── Bridge sync progress_fn → async WS sends ──
# progress_fn is called from two contexts:
# 1. Event loop thread (phases 1-2.5, directly in calculate_coverage)
# 2. Worker threads (phase 3, from ProcessPool/sequential executors)
# We detect which thread we're on and use the appropriate method.
loop = asyncio.get_running_loop()
event_loop_thread_id = threading.current_thread().ident
progress_queue: asyncio.Queue = asyncio.Queue()
# ── Progress poller: reads shared dict and sends WS updates ──
async def progress_poller():
last_sent_seq = 0
last_sent_pct = 0.0
while not _done:
await asyncio.sleep(0.3)
seq = _progress["seq"]
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):
"""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())
poller_task = asyncio.create_task(progress_poller())
# Run calculation with timeout
start_time = time.time()
@@ -164,25 +154,23 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
)
except asyncio.TimeoutError:
cancel_token.cancel()
_sender_done = True
progress_queue.put_nowait(("done", 1.0))
await progress_task
_done = True
await poller_task
from app.services.parallel_coverage_service import _kill_worker_processes
_kill_worker_processes()
await ws_manager.send_error(ws, calc_id, "Calculation timeout (5 min)")
return
except asyncio.CancelledError:
cancel_token.cancel()
_sender_done = True
progress_queue.put_nowait(("done", 1.0))
await progress_task
_done = True
await poller_task
await ws_manager.send_error(ws, calc_id, "Calculation cancelled")
return
# Stop progress sender
_sender_done = True
progress_queue.put_nowait(("done", 1.0))
await progress_task
# Stop poller and send final progress
_done = True
await poller_task
await ws_manager.send_progress(ws, calc_id, "Finalizing", 0.98)
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)
except Exception as e:
# Stop progress sender on unhandled exception
_sender_done = True
logger.error(f"[WS] Calculation error: {e}", exc_info=True)
_done = True
try:
progress_queue.put_nowait(("done", 1.0))
except Exception:
pass
try:
await progress_task
await poller_task
except Exception:
pass
await ws_manager.send_error(ws, calc_id, str(e))