import os import asyncio import math from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from typing import Optional from app.core.config import settings from app.services.terrain_service import terrain_service from app.services.los_service import los_service router = APIRouter() def _build_elevation_grid(min_lat, max_lat, min_lon, max_lon, resolution): """Build a 2D elevation grid. Runs in thread executor (CPU-bound).""" import numpy as np rows = min(resolution, 200) cols = min(resolution, 200) lats = np.linspace(max_lat, min_lat, rows) # north to south lons = np.linspace(min_lon, max_lon, cols) grid = [] min_elev = float('inf') max_elev = float('-inf') for lat in lats: row = [] for lon in lons: elev = terrain_service.get_elevation_sync(float(lat), float(lon)) row.append(elev) if elev < min_elev: min_elev = elev if elev > max_elev: max_elev = elev grid.append(row) return { "grid": grid, "rows": rows, "cols": cols, "min_elevation": min_elev if min_elev != float('inf') else 0, "max_elevation": max_elev if max_elev != float('-inf') else 0, "bbox": { "min_lat": min_lat, "max_lat": max_lat, "min_lon": min_lon, "max_lon": max_lon, }, } @router.get("/elevation") async def get_elevation( lat: float = Query(..., ge=-90, le=90, description="Latitude"), lon: float = Query(..., ge=-180, le=180, description="Longitude") ): """Get elevation at a specific point""" elevation = await terrain_service.get_elevation(lat, lon) return { "lat": lat, "lon": lon, "elevation": elevation, "unit": "meters" } @router.get("/elevation-grid") async def get_elevation_grid( min_lat: float = Query(..., ge=-90, le=90, description="South boundary"), max_lat: float = Query(..., ge=-90, le=90, description="North boundary"), min_lon: float = Query(..., ge=-180, le=180, description="West boundary"), max_lon: float = Query(..., ge=-180, le=180, description="East boundary"), resolution: int = Query(100, ge=10, le=200, description="Grid size (rows/cols)"), ): """Get elevation grid for a bounding box. Returns a 2D array for terrain visualization.""" if max_lat <= min_lat or max_lon <= min_lon: raise HTTPException(400, "Invalid bbox: max must be greater than min") if (max_lat - min_lat) > 2.0 or (max_lon - min_lon) > 2.0: raise HTTPException(400, "Bbox too large (max 2 degrees per axis)") # Ensure terrain tiles are loaded for this area await terrain_service.ensure_tiles_for_bbox(min_lat, min_lon, max_lat, max_lon) # Pre-load all tiles that cover the bbox lat_start = int(math.floor(min_lat)) lat_end = int(math.floor(max_lat)) lon_start = int(math.floor(min_lon)) lon_end = int(math.floor(max_lon)) for lat_i in range(lat_start, lat_end + 1): for lon_i in range(lon_start, lon_end + 1): tile_name = terrain_service.get_tile_name(lat_i + 0.5, lon_i + 0.5) terrain_service._load_tile(tile_name) # Build grid in thread executor (CPU-bound sync calls) loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, _build_elevation_grid, min_lat, max_lat, min_lon, max_lon, resolution, ) return result @router.get("/profile") async def get_elevation_profile( lat1: float = Query(..., description="Start latitude"), lon1: float = Query(..., description="Start longitude"), lat2: float = Query(..., description="End latitude"), lon2: float = Query(..., description="End longitude"), points: int = Query(100, ge=10, le=500, description="Number of sample points") ): """Get elevation profile between two points""" profile = await terrain_service.get_elevation_profile(lat1, lon1, lat2, lon2, points) return { "start": {"lat": lat1, "lon": lon1}, "end": {"lat": lat2, "lon": lon2}, "num_points": len(profile), "profile": profile } @router.get("/los") async def check_line_of_sight( tx_lat: float = Query(..., description="Transmitter latitude"), tx_lon: float = Query(..., description="Transmitter longitude"), tx_height: float = Query(..., ge=0, description="Transmitter height above ground (m)"), rx_lat: float = Query(..., description="Receiver latitude"), rx_lon: float = Query(..., description="Receiver longitude"), rx_height: float = Query(1.5, ge=0, description="Receiver height above ground (m)") ): """Check line-of-sight between transmitter and receiver""" result = await los_service.check_line_of_sight( tx_lat, tx_lon, tx_height, rx_lat, rx_lon, rx_height ) return result @router.get("/fresnel") async def check_fresnel_clearance( tx_lat: float = Query(..., description="Transmitter latitude"), tx_lon: float = Query(..., description="Transmitter longitude"), tx_height: float = Query(..., ge=0, description="Transmitter height (m)"), rx_lat: float = Query(..., description="Receiver latitude"), rx_lon: float = Query(..., description="Receiver longitude"), rx_height: float = Query(1.5, ge=0, description="Receiver height (m)"), frequency: float = Query(1800, ge=100, le=6000, description="Frequency (MHz)") ): """Calculate Fresnel zone clearance""" try: result = await los_service.calculate_fresnel_clearance( tx_lat, tx_lon, tx_height, rx_lat, rx_lon, rx_height, frequency ) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Fresnel calculation error: {str(e)}") @router.get("/tiles") async def list_cached_tiles(): """List cached SRTM tiles""" tiles = list(terrain_service.terrain_path.glob("*.hgt")) return { "cache_dir": str(terrain_service.terrain_path), "tiles": [t.stem for t in tiles], "count": len(tiles) } @router.get("/file/{region}") async def get_terrain_file(region: str): """Serve raw SRTM terrain .hgt files (legacy compatibility).""" terrain_path = os.path.join(settings.TERRAIN_DATA_DIR, f"{region}.hgt") if os.path.exists(terrain_path): return FileResponse(terrain_path) raise HTTPException(status_code=404, detail=f"Region '{region}' not found")