""" DEM (Digital Elevation Model) Service Serves terrain data from Copernicus DEM GLO-30 """ import os import math from typing import Optional, Tuple import struct import structlog import numpy as np from PIL import Image from io import BytesIO from config import settings logger = structlog.get_logger(__name__) # Germany bounding box GERMANY_BOUNDS = { "west": 5.87, "south": 47.27, "east": 15.04, "north": 55.06, } def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> Tuple[int, int]: """Convert latitude/longitude to tile coordinates.""" n = 2 ** zoom x = int((lon + 180.0) / 360.0 * n) lat_rad = math.radians(lat) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y def tile_to_bounds(z: int, x: int, y: int) -> Tuple[float, float, float, float]: """Convert tile coordinates to bounding box (west, south, east, north).""" n = 2 ** z west = x / n * 360.0 - 180.0 east = (x + 1) / n * 360.0 - 180.0 north_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n))) south_rad = math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))) north = math.degrees(north_rad) south = math.degrees(south_rad) return west, south, east, north class DEMService: """ Service for handling Digital Elevation Model data. Uses Copernicus DEM GLO-30 (30m resolution) as the data source. Generates terrain tiles in Mapbox Terrain-RGB format for MapLibre/Unity. """ def __init__(self): self.dem_dir = settings.dem_data_dir self.tile_size = settings.terrain_tile_size self._dem_cache = {} # Cache loaded DEM files def _get_dem_file_path(self, lat: int, lon: int) -> str: """ Get the path to a Copernicus DEM file for the given coordinates. Copernicus DEM files are named like: Copernicus_DSM_COG_10_N47_00_E008_00_DEM.tif """ lat_prefix = "N" if lat >= 0 else "S" lon_prefix = "E" if lon >= 0 else "W" # Format: N47E008 filename = f"{lat_prefix}{abs(lat):02d}{lon_prefix}{abs(lon):03d}.tif" return os.path.join(self.dem_dir, filename) def _load_dem_tile(self, lat: int, lon: int) -> Optional[np.ndarray]: """Load a DEM tile from disk.""" cache_key = f"{lat}_{lon}" if cache_key in self._dem_cache: return self._dem_cache[cache_key] filepath = self._get_dem_file_path(lat, lon) if not os.path.exists(filepath): logger.debug("DEM file not found", path=filepath) return None try: import rasterio with rasterio.open(filepath) as src: data = src.read(1) # Read first band self._dem_cache[cache_key] = data return data except ImportError: logger.warning("rasterio not available, using fallback") return self._load_dem_fallback(filepath) except Exception as e: logger.error("Error loading DEM", path=filepath, error=str(e)) return None def _load_dem_fallback(self, filepath: str) -> Optional[np.ndarray]: """Fallback DEM loader without rasterio (for development).""" # In development, return synthetic terrain return None async def get_heightmap_tile(self, z: int, x: int, y: int) -> Optional[bytes]: """ Generate a heightmap tile in Mapbox Terrain-RGB format. The encoding stores elevation as: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) This allows for -10000m to +1677721.6m range with 0.1m precision. """ # Get tile bounds west, south, east, north = tile_to_bounds(z, x, y) # Check if tile is within Germany if east < GERMANY_BOUNDS["west"] or west > GERMANY_BOUNDS["east"]: return None if north < GERMANY_BOUNDS["south"] or south > GERMANY_BOUNDS["north"]: return None # Try to load elevation data for this tile elevations = await self._get_elevations_for_bounds(west, south, east, north) if elevations is None: # No DEM data available - return placeholder or None return self._generate_placeholder_heightmap() # Convert to Terrain-RGB format return self._encode_terrain_rgb(elevations) async def _get_elevations_for_bounds( self, west: float, south: float, east: float, north: float ) -> Optional[np.ndarray]: """Get elevation data for a bounding box.""" # Determine which DEM tiles we need lat_min = int(math.floor(south)) lat_max = int(math.ceil(north)) lon_min = int(math.floor(west)) lon_max = int(math.ceil(east)) # Load all required DEM tiles dem_tiles = [] for lat in range(lat_min, lat_max + 1): for lon in range(lon_min, lon_max + 1): tile = self._load_dem_tile(lat, lon) if tile is not None: dem_tiles.append((lat, lon, tile)) if not dem_tiles: return None # Interpolate elevations for our tile grid elevations = np.zeros((self.tile_size, self.tile_size), dtype=np.float32) for py in range(self.tile_size): for px in range(self.tile_size): # Calculate lat/lon for this pixel lon = west + (east - west) * px / self.tile_size lat = north - (north - south) * py / self.tile_size # Get elevation (simplified - would need proper interpolation) elevation = self._sample_elevation(lat, lon, dem_tiles) elevations[py, px] = elevation if elevation is not None else 0 return elevations def _sample_elevation( self, lat: float, lon: float, dem_tiles: list ) -> Optional[float]: """Sample elevation at a specific point from loaded DEM tiles.""" tile_lat = int(math.floor(lat)) tile_lon = int(math.floor(lon)) for t_lat, t_lon, data in dem_tiles: if t_lat == tile_lat and t_lon == tile_lon: # Calculate pixel position within tile # Assuming 1 degree = 3600 pixels (1 arcsecond for 30m DEM) rows, cols = data.shape px = int((lon - tile_lon) * cols) py = int((t_lat + 1 - lat) * rows) px = max(0, min(cols - 1, px)) py = max(0, min(rows - 1, py)) return float(data[py, px]) return None def _encode_terrain_rgb(self, elevations: np.ndarray) -> bytes: """ Encode elevation data as Mapbox Terrain-RGB. Format: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) """ # Convert elevation to RGB values # encoded = (elevation + 10000) / 0.1 encoded = ((elevations + 10000) / 0.1).astype(np.uint32) r = (encoded // (256 * 256)) % 256 g = (encoded // 256) % 256 b = encoded % 256 # Create RGB image rgb = np.stack([r, g, b], axis=-1).astype(np.uint8) img = Image.fromarray(rgb, mode="RGB") # Save to bytes buffer = BytesIO() img.save(buffer, format="PNG") return buffer.getvalue() def _generate_placeholder_heightmap(self) -> bytes: """Generate a flat placeholder heightmap (sea level).""" # Sea level = 0m encoded as RGB # encoded = (0 + 10000) / 0.1 = 100000 # R = 100000 // 65536 = 1 # G = (100000 // 256) % 256 = 134 # B = 100000 % 256 = 160 img = Image.new("RGB", (self.tile_size, self.tile_size), (1, 134, 160)) buffer = BytesIO() img.save(buffer, format="PNG") return buffer.getvalue() async def get_hillshade_tile( self, z: int, x: int, y: int, azimuth: float = 315, altitude: float = 45 ) -> Optional[bytes]: """ Generate a hillshade tile for terrain visualization. Args: z, x, y: Tile coordinates azimuth: Light direction in degrees (0=N, 90=E, 180=S, 270=W) altitude: Light altitude in degrees above horizon """ west, south, east, north = tile_to_bounds(z, x, y) elevations = await self._get_elevations_for_bounds(west, south, east, north) if elevations is None: return None # Calculate hillshade using numpy hillshade = self._calculate_hillshade(elevations, azimuth, altitude) # Create grayscale image img = Image.fromarray((hillshade * 255).astype(np.uint8), mode="L") buffer = BytesIO() img.save(buffer, format="PNG") return buffer.getvalue() def _calculate_hillshade( self, dem: np.ndarray, azimuth: float, altitude: float ) -> np.ndarray: """Calculate hillshade from DEM array.""" # Convert angles to radians azimuth_rad = math.radians(360 - azimuth + 90) altitude_rad = math.radians(altitude) # Calculate gradient dy, dx = np.gradient(dem) # Calculate slope and aspect slope = np.arctan(np.sqrt(dx**2 + dy**2)) aspect = np.arctan2(-dy, dx) # Calculate hillshade hillshade = ( np.sin(altitude_rad) * np.cos(slope) + np.cos(altitude_rad) * np.sin(slope) * np.cos(azimuth_rad - aspect) ) # Normalize to 0-1 hillshade = np.clip(hillshade, 0, 1) return hillshade async def get_contour_tile( self, z: int, x: int, y: int, interval: int = 20 ) -> Optional[bytes]: """Generate contour lines as vector tile (stub - requires more complex implementation).""" # This would require generating contour lines from DEM and encoding as MVT # For now, return None logger.warning("Contour tiles not yet implemented") return None async def get_elevation(self, lat: float, lon: float) -> Optional[float]: """Get elevation at a specific point.""" tile_lat = int(math.floor(lat)) tile_lon = int(math.floor(lon)) dem_data = self._load_dem_tile(tile_lat, tile_lon) if dem_data is None: return None return self._sample_elevation(lat, lon, [(tile_lat, tile_lon, dem_data)]) async def get_elevation_profile( self, coordinates: list[list[float]], samples: int = 100 ) -> list[dict]: """Get elevation profile along a path.""" from shapely.geometry import LineString from shapely.ops import substring # Create line from coordinates line = LineString(coordinates) total_length = line.length # Sample points along line profile = [] for i in range(samples): fraction = i / (samples - 1) point = line.interpolate(fraction, normalized=True) elevation = await self.get_elevation(point.y, point.x) profile.append({ "distance_m": fraction * total_length * 111320, # Approximate meters "longitude": point.x, "latitude": point.y, "elevation_m": elevation, }) return profile async def get_metadata(self) -> dict: """Get metadata about available DEM data.""" dem_files = [] if os.path.exists(self.dem_dir): dem_files = [f for f in os.listdir(self.dem_dir) if f.endswith(".tif")] return { "data_available": len(dem_files) > 0, "tiles_generated": len(dem_files), "resolution_m": 30, "source": "Copernicus DEM GLO-30", }