import time import asyncio from fastapi import APIRouter, HTTPException, BackgroundTasks from typing import List, Optional from pydantic import BaseModel from app.services.coverage_service import ( coverage_service, CoverageSettings, SiteParams, CoveragePoint, apply_preset, PRESETS, ) from app.services.parallel_coverage_service import CancellationToken router = APIRouter() class CoverageRequest(BaseModel): """Request body for coverage calculation""" sites: List[SiteParams] settings: CoverageSettings = CoverageSettings() class CoverageResponse(BaseModel): """Coverage calculation response""" points: List[CoveragePoint] count: int settings: CoverageSettings stats: dict computation_time: float # seconds models_used: List[str] # which models were active @router.post("/calculate") async def calculate_coverage(request: CoverageRequest) -> CoverageResponse: """ Calculate RF coverage for one or more sites Returns grid of RSRP values with terrain and building effects. Supports propagation model presets: fast, standard, detailed, full. """ if not request.sites: raise HTTPException(400, "At least one site required") if len(request.sites) > 10: raise HTTPException(400, "Maximum 10 sites per request") # Validate settings if request.settings.radius > 50000: raise HTTPException(400, "Maximum radius 50km") if request.settings.resolution < 50: raise HTTPException(400, "Minimum resolution 50m") # Apply preset and determine active models effective_settings = apply_preset(request.settings.model_copy()) models_used = _get_active_models(effective_settings) # Time the calculation start_time = time.time() cancel_token = CancellationToken() try: # Calculate with 5-minute timeout if len(request.sites) == 1: points = await asyncio.wait_for( coverage_service.calculate_coverage( request.sites[0], request.settings, cancel_token, ), timeout=300.0 ) else: points = await asyncio.wait_for( coverage_service.calculate_multi_site_coverage( request.sites, request.settings, cancel_token, ), timeout=300.0 ) except asyncio.TimeoutError: cancel_token.cancel() raise HTTPException(408, "Calculation timeout (5 min) — try smaller radius or lower resolution") except asyncio.CancelledError: cancel_token.cancel() raise HTTPException(499, "Client disconnected") computation_time = time.time() - start_time # Calculate stats 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, "points_with_buildings": sum(1 for p in points if p.building_loss > 0), "points_with_terrain_loss": sum(1 for p in points if p.terrain_loss > 0), "points_with_reflection_gain": sum(1 for p in points if p.reflection_gain > 0), "points_with_vegetation_loss": sum(1 for p in points if p.vegetation_loss > 0), "points_with_rain_loss": sum(1 for p in points if p.rain_loss > 0), "points_with_indoor_loss": sum(1 for p in points if p.indoor_loss > 0), "points_with_atmospheric_loss": sum(1 for p in points if p.atmospheric_loss > 0), } 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""" return { "presets": { "fast": { "description": "Quick calculation - terrain only", **PRESETS["fast"], "estimated_speed": "~5 seconds for 5km radius" }, "standard": { "description": "Balanced - terrain + buildings with materials", **PRESETS["standard"], "estimated_speed": "~30 seconds for 5km radius" }, "detailed": { "description": "Accurate - adds dominant path + vegetation", **PRESETS["detailed"], "estimated_speed": "~2 minutes for 5km radius" }, "full": { "description": "Maximum realism - all models + water + vegetation", **PRESETS["full"], "estimated_speed": "~5 minutes for 5km radius" } } } @router.get("/buildings") async def get_buildings( min_lat: float, min_lon: float, max_lat: float, max_lon: float ): """ Get buildings in bounding box (for debugging/visualization) """ from app.services.buildings_service import buildings_service # Limit bbox size if (max_lat - min_lat) > 0.1 or (max_lon - min_lon) > 0.1: raise HTTPException(400, "Bbox too large (max 0.1 degrees)") buildings = await buildings_service.fetch_buildings( min_lat, min_lon, max_lat, max_lon ) return { "count": len(buildings), "buildings": [b.model_dump() for b in buildings] } def _get_active_models(settings: CoverageSettings) -> List[str]: """Determine which propagation models are active""" models = ["okumura_hata"] # Always active as base model if settings.use_terrain: models.append("terrain_los") if settings.use_buildings: models.append("buildings") if settings.use_materials: models.append("materials") if settings.use_dominant_path: models.append("dominant_path") if settings.use_street_canyon: models.append("street_canyon") if settings.use_reflections: models.append("reflections") if settings.use_water_reflection: models.append("water_reflection") if settings.use_vegetation: models.append("vegetation") if settings.rain_rate > 0: models.append("rain_attenuation") if settings.indoor_loss_type != "none": models.append("indoor_penetration") if settings.use_atmospheric: models.append("atmospheric") return models