273 lines
9.1 KiB
Python
273 lines
9.1 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")
|
|
|
|
|
|
@router.get("/status")
|
|
async def terrain_status():
|
|
"""Return terrain data availability info."""
|
|
cached_tiles = terrain_service.get_cached_tiles()
|
|
cache_size = terrain_service.get_cache_size_mb()
|
|
|
|
# Categorize by resolution based on file size
|
|
srtm1_tiles = []
|
|
srtm3_tiles = []
|
|
for t in cached_tiles:
|
|
tile_path = terrain_service.terrain_path / f"{t}.hgt"
|
|
try:
|
|
if tile_path.stat().st_size == 3601 * 3601 * 2:
|
|
srtm1_tiles.append(t)
|
|
else:
|
|
srtm3_tiles.append(t)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"total_tiles": len(cached_tiles),
|
|
"srtm1": {
|
|
"count": len(srtm1_tiles),
|
|
"resolution_m": 30,
|
|
"tiles": sorted(srtm1_tiles),
|
|
},
|
|
"srtm3": {
|
|
"count": len(srtm3_tiles),
|
|
"resolution_m": 90,
|
|
"tiles": sorted(srtm3_tiles),
|
|
},
|
|
"cache_size_mb": round(cache_size, 1),
|
|
"memory_cached": len(terrain_service._tile_cache),
|
|
"terra_server": "https://terra.eliah.one",
|
|
}
|
|
|
|
|
|
@router.post("/download")
|
|
async def terrain_download(request: dict):
|
|
"""Pre-download tiles for a region.
|
|
|
|
Body: {"center_lat": 48.46, "center_lon": 35.04, "radius_km": 50}
|
|
Or: {"tiles": ["N48E034", "N48E035", "N47E034", "N47E035"]}
|
|
"""
|
|
if "tiles" in request:
|
|
tile_list = request["tiles"]
|
|
else:
|
|
center_lat = request.get("center_lat", 48.46)
|
|
center_lon = request.get("center_lon", 35.04)
|
|
radius_km = request.get("radius_km", 50)
|
|
tile_list = terrain_service.get_required_tiles(center_lat, center_lon, radius_km)
|
|
|
|
missing = [t for t in tile_list if not terrain_service.get_tile_path(t).exists()]
|
|
|
|
if not missing:
|
|
return {"status": "ok", "message": "All tiles already cached", "count": len(tile_list)}
|
|
|
|
# Download missing tiles
|
|
downloaded = []
|
|
failed = []
|
|
for tile_name in missing:
|
|
success = await terrain_service.download_tile(tile_name)
|
|
if success:
|
|
downloaded.append(tile_name)
|
|
else:
|
|
failed.append(tile_name)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"required": len(tile_list),
|
|
"already_cached": len(tile_list) - len(missing),
|
|
"downloaded": downloaded,
|
|
"failed": failed,
|
|
}
|
|
|
|
|
|
@router.get("/index")
|
|
async def terrain_index():
|
|
"""Fetch tile index from terra server."""
|
|
import httpx
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get("https://terra.eliah.one/api/index")
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception:
|
|
pass
|
|
return {"error": "Could not reach terra.eliah.one", "offline": True}
|