Files
rfcp/backend/app/api/routes/terrain.py

183 lines
6.4 KiB
Python

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")