197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
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,
|
|
)
|
|
|
|
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()
|
|
|
|
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
|
|
),
|
|
timeout=300.0
|
|
)
|
|
else:
|
|
points = await asyncio.wait_for(
|
|
coverage_service.calculate_multi_site_coverage(
|
|
request.sites,
|
|
request.settings
|
|
),
|
|
timeout=300.0
|
|
)
|
|
except asyncio.TimeoutError:
|
|
raise HTTPException(408, "Calculation timeout (5 min) — try smaller radius or lower resolution")
|
|
|
|
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
|