Source code for openworld_radio_twin.providers.terrain

from __future__ import annotations

import asyncio
import gzip
import io
import json
import math
from dataclasses import dataclass

import httpx
import numpy as np
from PIL import Image
from pyproj.enums import TransformDirection

from openworld_radio_twin.environment import TerrainGrid, TerrainSource
from openworld_radio_twin.geodesy import LocalFrame

USGS_3DEP_URL = "https://elevation.nationalmap.gov/arcgis/rest/services/3DEPElevation/ImageServer"
MAPZEN_SKADI_URL = (
    "https://s3.amazonaws.com/elevation-tiles-prod/skadi/{latitude_band}/{tile}.hgt.gz"
)
SKADI_VOID = -32768
MIN_TERRESTRIAL_ELEVATION_M = -500.0


[docs] class TerrainProviderError(RuntimeError): pass
[docs] @dataclass(frozen=True) class TerrainQuery: longitude: float latitude: float radius_m: float spacing_m: float @property def axis(self) -> np.ndarray: count = max(2, math.ceil(2 * self.radius_m / self.spacing_m) + 1) return np.linspace(-self.radius_m, self.radius_m, count, dtype=np.float64)
def _local_coordinates( query: TerrainQuery, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: axis = query.axis east, north = np.meshgrid(axis, axis) frame = LocalFrame.at(query.longitude, query.latitude) longitude, latitude, _ = frame.transformer.transform( east, north, np.zeros_like(east), direction=TransformDirection.INVERSE, ) return axis, east, np.asarray(longitude), np.asarray(latitude) FLAT_TERRAIN_PROVIDER = "flat plane"
[docs] def flat_terrain(query: TerrainQuery) -> TerrainGrid: """Return a gridded flat ground at local z=0 so surface classes can be assigned per cell. No elevation source is queried. The grid uses the same axes as a DEM-backed terrain at the requested spacing, so semantic surface classification and the per-class terrain meshes work unchanged on a planar ground. """ source = TerrainSource( provider=FLAT_TERRAIN_PROVIDER, dataset="planar ground at local z=0", vertical_datum="local ground plane", requested_spacing_m=float(query.spacing_m), source_resolution_m=None, interpolation="none", source_url="", license="not applicable", attributes={"terrain_mode": "flat", "elevation_source_queried": False}, ) return _normalize(query, np.zeros((query.axis.size, query.axis.size), dtype=np.float32), source)
def _normalize( query: TerrainQuery, elevations: np.ndarray, source: TerrainSource, ) -> TerrainGrid: axis = query.axis model = np.asarray(elevations, dtype=np.float32) center = _sample_regular_grid(model, axis, 0.0, 0.0) # Subtract before rounding to local float32: the interpolated origin can # lie between representable absolute elevations, particularly at altitude. local = (model.astype(np.float64) - center).astype(np.float32) return TerrainGrid( east_m=axis, north_m=axis.copy(), source_elevation_m=model, model_elevation_m=model.copy(), local_elevation_m=local, source_origin_elevation_m=float(center), origin_elevation_m=float(center), source=source, ) def _sample_regular_grid( values: np.ndarray, axis: np.ndarray, east_m: float, north_m: float, ) -> float: column = int(np.clip(np.searchsorted(axis, east_m) - 1, 0, axis.size - 2)) row = int(np.clip(np.searchsorted(axis, north_m) - 1, 0, axis.size - 2)) tx = (east_m - axis[column]) / (axis[column + 1] - axis[column]) ty = (north_m - axis[row]) / (axis[row + 1] - axis[row]) south = values[row, column] * (1 - tx) + values[row, column + 1] * tx north = values[row + 1, column] * (1 - tx) + values[row + 1, column + 1] * tx return float(south * (1 - ty) + north * ty) def _bilinear_rectilinear( values_north_up: np.ndarray, west: float, south: float, east: float, north: float, longitude: np.ndarray, latitude: np.ndarray, ) -> np.ndarray: rows, columns = values_north_up.shape x = (longitude - west) / (east - west) * columns - 0.5 y = (north - latitude) / (north - south) * rows - 0.5 x = np.clip(x, 0, columns - 1) y = np.clip(y, 0, rows - 1) x0 = np.floor(x).astype(np.int64) y0 = np.floor(y).astype(np.int64) x1 = np.minimum(x0 + 1, columns - 1) y1 = np.minimum(y0 + 1, rows - 1) tx = x - x0 ty = y - y0 north_values = values_north_up[y0, x0] * (1 - tx) + values_north_up[y0, x1] * tx south_values = values_north_up[y1, x0] * (1 - tx) + values_north_up[y1, x1] * tx return north_values * (1 - ty) + south_values * ty
[docs] class USGS3DEPTerrainProvider: def __init__(self, client: httpx.AsyncClient) -> None: self.client = client
[docs] @staticmethod def covers(longitude: float, latitude: float) -> bool: mainland_and_alaska = -180 <= longitude <= -60 and 18 <= latitude <= 72 pacific_territories = 130 <= longitude <= 180 and 0 <= latitude <= 25 return mainland_and_alaska or pacific_territories
[docs] async def terrain(self, query: TerrainQuery) -> TerrainGrid: axis, _, longitudes, latitudes = _local_coordinates(query) margin = max(query.spacing_m, 2.0) / 111_000.0 west = float(np.min(longitudes) - margin) east = float(np.max(longitudes) + margin) south = float(np.min(latitudes) - margin) north = float(np.max(latitudes) + margin) size = axis.size + 2 params = { "bbox": f"{west},{south},{east},{north}", "bboxSR": 4326, "imageSR": 4326, "size": f"{size},{size}", "format": "tiff", "pixelType": "F32", "interpolation": "RSP_BilinearInterpolation", "f": "image", } # The DEM export and the catalog lookup are independent requests. response, identified = await asyncio.gather( self.client.get(f"{USGS_3DEP_URL}/exportImage", params=params), self._identify(query.longitude, query.latitude), ) response.raise_for_status() try: with Image.open(io.BytesIO(response.content)) as image: source_values = np.asarray(image, dtype=np.float32) except Exception as exc: raise TerrainProviderError("USGS 3DEP did not return a readable F32 DEM") from exc if source_values.shape != (size, size): raise TerrainProviderError(f"USGS 3DEP returned unexpected shape {source_values.shape}") if not np.all(np.isfinite(source_values)) or np.min(source_values) < -10_000: raise TerrainProviderError( "USGS 3DEP has no complete elevation coverage for this scene" ) elevations = _bilinear_rectilinear( source_values, west, south, east, north, longitudes, latitudes, ) attributes = { **identified, "owrt_metadata_sample_longitude": query.longitude, "owrt_metadata_sample_latitude": query.latitude, } source_resolution = _numeric_attribute( attributes, "Resolution", "Resolution_X", "Pixel Size", "LowPS", ) source = TerrainSource( provider="USGS 3DEP", dataset="3DEP Bare Earth DEM Dynamic", vertical_datum=str( _attribute(attributes, "VerticalDatum", "Vertical Datum") or "source metadata" ), requested_spacing_m=float(2 * query.radius_m / (axis.size - 1)), source_resolution_m=source_resolution, interpolation="USGS bilinear export followed by WGS84-to-ENU bilinear sampling", source_url=USGS_3DEP_URL, license="United States public domain", attributes=attributes, ) return _normalize(query, elevations, source)
async def _identify(self, longitude: float, latitude: float) -> dict: params = { "geometry": json.dumps( { "x": longitude, "y": latitude, "spatialReference": {"wkid": 4326}, } ), "geometryType": "esriGeometryPoint", "returnGeometry": "false", "returnCatalogItems": "true", "f": "json", } try: response = await self.client.get(f"{USGS_3DEP_URL}/identify", params=params) response.raise_for_status() except httpx.HTTPError as exc: status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None return { "owrt_catalog_metadata_status": "unavailable", "owrt_catalog_metadata_http_status": status, } payload = response.json() features = payload.get("catalogItems", {}).get("features", []) candidates = [ dict(feature.get("attributes", {})) for feature in features if feature.get("attributes", {}).get("Category") == 1 ] return { **min(candidates, key=_pixel_size, default={}), "owrt_catalog_metadata_status": "available", }
def _attribute(attributes: dict, *names: str): lowered = {str(key).lower(): value for key, value in attributes.items()} for name in names: value = lowered.get(name.lower()) if value not in (None, ""): return value return None def _numeric_attribute(attributes: dict, *names: str) -> float | None: value = _attribute(attributes, *names) if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _pixel_size(attributes: dict) -> float: value = _numeric_attribute(attributes, "LowPS", "Resolution_X", "MinPS") return value if value is not None and value > 0 else math.inf
[docs] class MapzenTerrainProvider: """Global raw HGT fallback with explicit void handling and source headers.""" def __init__(self, client: httpx.AsyncClient) -> None: self.client = client
[docs] async def terrain(self, query: TerrainQuery) -> TerrainGrid: axis, _, longitudes, latitudes = _local_coordinates(query) normalized_longitudes = (longitudes + 180.0) % 360.0 - 180.0 tile_keys = sorted( { (math.floor(float(latitude)), math.floor(float(longitude))) for latitude, longitude in zip( latitudes.reshape(-1), normalized_longitudes.reshape(-1), strict=True ) } ) fetched = await asyncio.gather(*(self._tile(*key) for key in tile_keys)) tiles = {key: values for key, values, _ in fetched} imagery_sources = sorted( {source for _, _, sources in fetched for source in sources.split(",") if source} ) elevations = np.empty_like(latitudes, dtype=np.float64) source_spacings = [] for latitude_floor, longitude_floor in tile_keys: tile = tiles[(latitude_floor, longitude_floor)] source_spacings.append(1.0 / (tile.shape[0] - 1)) mask = (np.floor(latitudes) == latitude_floor) & ( np.floor(normalized_longitudes) == longitude_floor ) elevations[mask] = _sample_hgt( tile, normalized_longitudes[mask] - longitude_floor, latitude_floor + 1.0 - latitudes[mask], ) if np.min(elevations) < -12_000 or np.max(elevations) > 9_000: raise TerrainProviderError( "Mapzen returned elevations outside the physical Earth range" ) arc_spacing = max(source_spacings) source_resolution_m = max( 111_132.0 * arc_spacing, 111_320.0 * math.cos(math.radians(query.latitude)) * arc_spacing, ) source = TerrainSource( provider="Mapzen Terrain Tiles", dataset="Skadi global raw elevation mosaic", vertical_datum="EGM96 orthometric height", requested_spacing_m=float(2 * query.radius_m / (axis.size - 1)), source_resolution_m=source_resolution_m, interpolation="big-endian int16 HGT decode followed by geographic bilinear sampling", source_url="https://registry.opendata.aws/terrain-tiles/", license="Mapzen/AWS Terrain Tiles attribution terms; source datasets vary", attributes={ "format": "Skadi HGT.gz", "tile_count": len(tile_keys), "tiles": ",".join(_skadi_name(*key) for key in tile_keys), "imagery_sources": ",".join(imagery_sources), "void_policy": "fail if any -32768 sample participates in interpolation", "source_range_policy": "fail outside -12000..9000 m", "rf_surface_policy": ( "reject modeled values below -500 m after explicit marine hydroflattening" ), "quality_note": ( "Multi-source mosaic; source accuracy and datum consistency vary by region" ), }, ) return _normalize(query, elevations, source)
async def _tile(self, latitude_floor: int, longitude_floor: int): tile_name = _skadi_name(latitude_floor, longitude_floor) url = MAPZEN_SKADI_URL.format(latitude_band=tile_name[:3], tile=tile_name) response = await self.client.get(url) response.raise_for_status() try: raw = gzip.decompress(response.content) side = math.isqrt(len(raw) // 2) if side * side * 2 != len(raw) or side < 2: raise ValueError("payload is not a square int16 grid") values = np.frombuffer(raw, dtype=">i2").reshape(side, side) except (gzip.BadGzipFile, ValueError) as exc: raise TerrainProviderError(f"Mapzen {tile_name} is not a valid HGT tile") from exc return ( (latitude_floor, longitude_floor), values, response.headers.get("x-imagery-sources", ""), )
def _skadi_name(latitude_floor: int, longitude_floor: int) -> str: latitude = f"N{latitude_floor:02d}" if latitude_floor >= 0 else f"S{-latitude_floor:02d}" longitude = f"E{longitude_floor:03d}" if longitude_floor >= 0 else f"W{-longitude_floor:03d}" return f"{latitude}{longitude}" def _sample_hgt(values: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray: scale = values.shape[0] - 1 x = np.clip(x * scale, 0, scale) y = np.clip(y * scale, 0, scale) x0 = np.floor(x).astype(np.int64) y0 = np.floor(y).astype(np.int64) x1 = np.minimum(x0 + 1, scale) y1 = np.minimum(y0 + 1, scale) samples = np.stack((values[y0, x0], values[y0, x1], values[y1, x0], values[y1, x1])) if np.any(samples == SKADI_VOID): raise TerrainProviderError("Mapzen Skadi has a void in the requested scene") tx = x - x0 ty = y - y0 top = samples[0] * (1 - tx) + samples[1] * tx bottom = samples[2] * (1 - tx) + samples[3] * tx return top * (1 - ty) + bottom * ty
[docs] class TerrainProvider: def __init__(self, client: httpx.AsyncClient) -> None: self.usgs = USGS3DEPTerrainProvider(client) self.global_fallback = MapzenTerrainProvider(client)
[docs] async def terrain( self, query: TerrainQuery, allow_bathymetry: bool = False, ) -> tuple[TerrainGrid, list[str]]: warnings = [] if self.usgs.covers(query.longitude, query.latitude): try: terrain = await self.usgs.terrain(query) if ( terrain.source.attributes.get("owrt_catalog_metadata_status") == "unavailable" ): warnings.append( "USGS catalog metadata was unavailable; DEM samples are retained and " "source resolution is reported as unknown." ) return terrain, warnings except (httpx.HTTPError, TerrainProviderError) as exc: warnings.append(f"USGS 3DEP unavailable for this scene; using global DEM: {exc}") try: terrain = await self.global_fallback.terrain(query) if not allow_bathymetry: validate_terrestrial_surface(terrain) warnings.append( "Global terrain uses a multi-source Mapzen mosaic; regional source accuracy, " "datum consistency, and attribution must be reviewed in exported metadata." ) return terrain, warnings except (httpx.HTTPError, TerrainProviderError) as exc: raise TerrainProviderError(f"Global terrain query failed: {exc}") from exc
[docs] def validate_terrestrial_surface(terrain: TerrainGrid) -> None: if np.min(terrain.model_elevation_m) < MIN_TERRESTRIAL_ELEVATION_M: raise TerrainProviderError( f"{terrain.source.provider} sampled bathymetry in the scene; enable semantic " "surface data so marine cells can be modeled at mean sea level" )