This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/geo-service/api/terrain.py
Benjamin Admin bfdaf63ba9 fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

231 lines
8.2 KiB
Python

"""
Terrain/DEM API Endpoints
Serves heightmap tiles from Copernicus DEM data
"""
from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
import structlog
from config import settings
from services.dem_service import DEMService
logger = structlog.get_logger(__name__)
router = APIRouter()
# Initialize DEM service
dem_service = DEMService()
@router.get("/{z}/{x}/{y}.png", response_class=Response)
async def get_heightmap_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
):
"""
Get a heightmap tile as 16-bit PNG (Mapbox Terrain-RGB encoding).
Heightmaps are generated from Copernicus DEM GLO-30 (30m resolution).
The encoding allows for ~0.1m precision: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)
"""
try:
tile_data = await dem_service.get_heightmap_tile(z, x, y)
if tile_data is None:
return Response(status_code=204) # No terrain data for this tile
return Response(
content=tile_data,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=604800", # 7 days cache
"X-Tile-Source": "copernicus-dem",
},
)
except FileNotFoundError:
logger.warning("DEM data not found", z=z, x=x, y=y)
raise HTTPException(
status_code=503,
detail="DEM data not available. Please download Copernicus DEM first.",
)
except Exception as e:
logger.error("Error serving heightmap tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving heightmap tile")
@router.get("/hillshade/{z}/{x}/{y}.png", response_class=Response)
async def get_hillshade_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
azimuth: float = Query(315, ge=0, le=360, description="Light azimuth in degrees"),
altitude: float = Query(45, ge=0, le=90, description="Light altitude in degrees"),
):
"""
Get a hillshade tile for terrain visualization.
Hillshade is rendered from DEM with configurable light direction.
Default light comes from northwest (azimuth=315) at 45° altitude.
"""
try:
tile_data = await dem_service.get_hillshade_tile(z, x, y, azimuth, altitude)
if tile_data is None:
return Response(status_code=204)
return Response(
content=tile_data,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=604800",
"X-Hillshade-Azimuth": str(azimuth),
"X-Hillshade-Altitude": str(altitude),
},
)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error serving hillshade tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving hillshade tile")
@router.get("/contours/{z}/{x}/{y}.pbf", response_class=Response)
async def get_contour_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
interval: int = Query(20, ge=5, le=100, description="Contour interval in meters"),
):
"""
Get contour lines as vector tile.
Contours are generated from DEM at the specified interval.
Useful for topographic map overlays.
"""
try:
tile_data = await dem_service.get_contour_tile(z, x, y, interval)
if tile_data is None:
return Response(status_code=204)
return Response(
content=tile_data,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "gzip",
"Cache-Control": "public, max-age=604800",
"X-Contour-Interval": str(interval),
},
)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error serving contour tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving contour tile")
@router.get("/elevation")
async def get_elevation_at_point(
lat: float = Query(..., ge=47.27, le=55.06, description="Latitude"),
lon: float = Query(..., ge=5.87, le=15.04, description="Longitude"),
):
"""
Get elevation at a specific point.
Returns elevation in meters from Copernicus DEM.
Only works within Germany bounds.
"""
try:
elevation = await dem_service.get_elevation(lat, lon)
if elevation is None:
raise HTTPException(
status_code=404,
detail="No elevation data available for this location",
)
return {
"latitude": lat,
"longitude": lon,
"elevation_m": round(elevation, 1),
"source": "Copernicus DEM GLO-30",
"resolution_m": 30,
}
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error getting elevation", lat=lat, lon=lon, error=str(e))
raise HTTPException(status_code=500, detail="Error getting elevation")
@router.post("/elevation/profile")
async def get_elevation_profile(
coordinates: list[list[float]],
samples: int = Query(100, ge=10, le=1000, description="Number of sample points"),
):
"""
Get elevation profile along a path.
Takes a list of [lon, lat] coordinates and returns elevations sampled along the path.
Useful for hiking/cycling route profiles.
"""
if len(coordinates) < 2:
raise HTTPException(status_code=400, detail="At least 2 coordinates required")
if len(coordinates) > 100:
raise HTTPException(status_code=400, detail="Maximum 100 coordinates allowed")
try:
profile = await dem_service.get_elevation_profile(coordinates, samples)
return {
"profile": profile,
"statistics": {
"min_elevation_m": min(p["elevation_m"] for p in profile if p["elevation_m"]),
"max_elevation_m": max(p["elevation_m"] for p in profile if p["elevation_m"]),
"total_ascent_m": sum(
max(0, profile[i + 1]["elevation_m"] - profile[i]["elevation_m"])
for i in range(len(profile) - 1)
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
),
"total_descent_m": sum(
max(0, profile[i]["elevation_m"] - profile[i + 1]["elevation_m"])
for i in range(len(profile) - 1)
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
),
},
"source": "Copernicus DEM GLO-30",
}
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error getting elevation profile", error=str(e))
raise HTTPException(status_code=500, detail="Error getting elevation profile")
@router.get("/metadata")
async def get_dem_metadata():
"""
Get metadata about available DEM data.
"""
metadata = await dem_service.get_metadata()
return {
"name": "Copernicus DEM GLO-30",
"description": "Global 30m Digital Elevation Model",
"resolution_m": 30,
"coverage": "Germany (Deutschland)",
"bounds": [5.87, 47.27, 15.04, 55.06],
"vertical_datum": "EGM2008",
"horizontal_datum": "WGS84",
"license": "Copernicus Data (free, attribution required)",
"attribution": "© Copernicus Service Information 2024",
"data_available": metadata.get("data_available", False),
"tiles_generated": metadata.get("tiles_generated", 0),
}