from __future__ import annotations
import asyncio
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
import numpy as np
from shapely import contains_xy, from_wkb, intersects_xy, union_all
from shapely.geometry import LineString, MultiPolygon, Polygon, box
from shapely.ops import substring, transform
from openworld_radio_twin.config import Settings
from openworld_radio_twin.environment import (
FeatureSource,
SurfaceClass,
SurfaceFeature,
TerrainGrid,
)
from openworld_radio_twin.geodesy import LocalFrame
from openworld_radio_twin.providers.overture_reader import (
SEGMENT_COLUMNS,
SURFACE_COLUMNS,
latest_release,
read_batches,
)
[docs]
@dataclass(frozen=True)
class SurfaceQueryResult:
features: tuple[SurfaceFeature, ...]
cells: np.ndarray
release: str
provenance: dict[str, object]
warnings: list[str]
[docs]
class SurfaceProviderError(RuntimeError):
pass
[docs]
@dataclass(frozen=True)
class SurfaceDecision:
surface_class: SurfaceClass
basis: str
precedence: int
[docs]
@dataclass(frozen=True)
class LinearRule:
start: float
stop: float
value: str | float
PAVED_SURFACES = frozenset(
{
"asphalt",
"cobblestone",
"concrete",
"concrete_plates",
"paved",
"paving_stones",
"recreation_paved",
"rubber",
"sett",
"tartan",
}
)
VEGETATION_SURFACES = frozenset({"grass", "recreation_grass"})
GROUND_SURFACES = frozenset(
{
"compacted",
"dirt",
"earth",
"fine_gravel",
"gravel",
"ground",
"pebblestone",
"recreation_sand",
"sand",
"unpaved",
"wood",
"woodchips",
}
)
VEGETATION_LAND_COVER = frozenset(
{"crop", "forest", "grass", "mangrove", "moss", "shrub", "wetland"}
)
GROUND_LAND_COVER = frozenset({"barren", "snow", "urban"})
VEGETATION_LAND_USE_SUBTYPES = frozenset(
{"agriculture", "cemetery", "golf", "grass", "horticulture", "park"}
)
VEGETATION_LAND_USE_CLASSES = frozenset(
{
"allotments",
"cemetery",
"dog_park",
"driving_range",
"fairway",
"farmland",
"flowerbed",
"forest",
"garden",
"golf_course",
"grass",
"green",
"meadow",
"national_park",
"nature_reserve",
"orchard",
"park",
"plant_nursery",
"recreation_ground",
"rough",
"state_park",
"tee",
"village_green",
"vineyard",
"wilderness_area",
}
)
PAVED_ROAD_SURFACES = frozenset({"metal", "paved", "paving_stones"})
GROUND_ROAD_SURFACES = frozenset({"dirt", "gravel", "unpaved"})
GROUND_ROAD_CLASSES = frozenset({"bridleway", "path", "track"})
MARINE_WATER_CLASSES = frozenset({"bay", "ocean", "sea", "strait"})
ROAD_WIDTH_DEFAULTS_M = {
"motorway": 14.0,
"trunk": 12.0,
"primary": 10.0,
"secondary": 9.0,
"tertiary": 8.0,
"residential": 7.0,
"living_street": 6.0,
"unclassified": 6.0,
"service": 5.0,
"pedestrian": 4.0,
"track": 3.0,
"cycleway": 3.0,
"footway": 2.0,
"path": 2.0,
"bridleway": 2.0,
"steps": 2.0,
"other": 6.0,
}
SURFACE_MAPPING_CONTRACT = {
"version": 1,
"precedence": [
"land_use_semantic",
"land_cover_physical",
"explicit_land_use_surface",
"inferred_road_surface",
"explicit_road_surface",
"water_geometry",
],
"surface_material": {
"paved": sorted(PAVED_SURFACES),
"vegetation": sorted(VEGETATION_SURFACES),
"ground": sorted(GROUND_SURFACES),
},
"land_cover": {
"vegetation": sorted(VEGETATION_LAND_COVER),
"ground": sorted(GROUND_LAND_COVER),
},
"land_use": {
"vegetation_subtypes": sorted(VEGETATION_LAND_USE_SUBTYPES),
"vegetation_classes": sorted(VEGETATION_LAND_USE_CLASSES),
},
"road_surface": {
"paved": sorted(PAVED_ROAD_SURFACES),
"ground": sorted(GROUND_ROAD_SURFACES),
"class_inferred_ground": sorted(GROUND_ROAD_CLASSES),
"otherwise_inferred": "paved",
},
}
[docs]
class OvertureSurfaceProvider:
"""Normalize Overture semantic layers into one non-overlapping surface grid."""
themes = ("land_cover", "land_use", "water", "segment")
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._release: str | None = None
self._query_lock = asyncio.Lock()
[docs]
async def surfaces(
self,
longitude: float,
latitude: float,
radius_m: float,
terrain: TerrainGrid,
) -> SurfaceQueryResult:
async with self._query_lock:
try:
return await asyncio.to_thread(
self._query,
longitude,
latitude,
radius_m,
terrain,
)
except Exception as exc:
raise SurfaceProviderError(f"Overture surface query failed: {exc}") from exc
def _query(
self,
longitude: float,
latitude: float,
radius_m: float,
terrain: TerrainGrid,
) -> SurfaceQueryResult:
if self._release is None:
self._release = latest_release()
assert self._release is not None
frame = LocalFrame.at(longitude, latitude)
corners = frame.corners(-radius_m, -radius_m, radius_m, radius_m)
longitudes = [point[0] for point in corners]
latitudes = [point[1] for point in corners]
bbox = min(longitudes), min(latitudes), max(longitudes), max(latitudes)
clip = box(-radius_m, -radius_m, radius_m, radius_m)
features: list[SurfaceFeature] = []
counts: dict[str, int] = {}
warnings = []
# The four themes are independent objects on S3; read and decode them at once
# and keep the theme order when assembling the result.
with ThreadPoolExecutor(
max_workers=len(self.themes), thread_name_prefix="owrt-surfaces"
) as pool:
futures = {
theme: pool.submit(self._query_theme, theme, bbox, longitude, latitude, clip)
for theme in self.themes
}
results = {theme: future.result() for theme, future in futures.items()}
for theme in self.themes:
features.extend(results[theme])
counts[theme] = len(results[theme])
cells = classify_surface_cells(terrain, features)
inferred_widths = sum(item.width_source == "class_default" for item in features)
inferred_surfaces = sum(
item.classification_basis == "road_class_default" for item in features
)
classification_counts: dict[str, int] = {}
for feature in features:
classification_counts[feature.classification_basis] = (
classification_counts.get(feature.classification_basis, 0) + 1
)
source_datasets = sorted(
{
(source.dataset, source.license)
for feature in features
for source in feature.sources
if source.dataset is not None
},
key=lambda item: (item[0], item[1] or ""),
)
update_times = sorted(
source.update_time
for feature in features
for source in feature.sources
if source.update_time is not None
)
provenance: dict[str, object] = {
"provider": "Overture Maps",
"release": self._release,
"classification": "cell-center point-in-polygon with evidence precedence",
"mapping_contract": SURFACE_MAPPING_CONTRACT,
"classification_basis_counts": classification_counts,
"feature_count": len(features),
"inferred_road_width_count": inferred_widths,
"inferred_road_surface_count": inferred_surfaces,
"source_datasets": [
{"dataset": dataset, "license": license_name}
for dataset, license_name in source_datasets
],
"source_update_time_range": (
[update_times[0], update_times[-1]] if update_times else None
),
"road_width_defaults_m": ROAD_WIDTH_DEFAULTS_M,
**{f"{theme}_feature_count": count for theme, count in counts.items()},
}
if inferred_widths:
warnings.append(
f"{inferred_widths} road sections lacked source width; deterministic class widths "
"are recorded as assumptions."
)
if inferred_surfaces:
warnings.append(
f"{inferred_surfaces} road sections lacked source surface; deterministic "
"road-class surfaces are recorded as assumptions."
)
return SurfaceQueryResult(
features=tuple(features),
cells=cells,
release=self._release,
provenance=provenance,
warnings=warnings,
)
def _query_theme(
self,
theme: str,
bbox: tuple[float, float, float, float],
longitude: float,
latitude: float,
clip: Polygon,
) -> list[SurfaceFeature]:
batches = read_batches(
theme,
bbox,
self._release,
SEGMENT_COLUMNS if theme == "segment" else SURFACE_COLUMNS,
connect_timeout=self.settings.overture_connect_timeout_seconds,
request_timeout=self.settings.overture_request_timeout_seconds,
)
# Transformers are not shared between threads; each theme builds its own frame.
return self._read_theme(batches, theme, LocalFrame.at(longitude, latitude), clip)
def _read_theme(
self,
reader: object,
theme: str,
frame: LocalFrame,
clip: Polygon,
) -> list[SurfaceFeature]:
features = []
for batch in reader:
for row in batch.to_pylist():
geometry = from_wkb(row["geometry"])
features.extend(self._normalize(row, theme, geometry, frame, clip))
if len(features) >= self.settings.max_surface_features_per_theme:
return features[: self.settings.max_surface_features_per_theme]
return features
@classmethod
def _normalize(
cls,
row: dict,
theme: str,
geometry,
frame: LocalFrame,
clip: Polygon,
) -> list[SurfaceFeature]:
local = transform(frame.transformer.transform, geometry)
if theme == "segment":
if not isinstance(local, LineString):
return []
return cls._normalize_road(row, local, clip)
decision = _surface_decision(theme, row)
if decision is None or not isinstance(local, (Polygon, MultiPolygon)):
return []
clipped = local.intersection(clip)
if clipped.is_empty:
return []
return [cls._feature(row, theme, clipped, decision)]
@classmethod
def _normalize_road(
cls,
row: dict,
geometry: LineString,
clip: Polygon,
) -> list[SurfaceFeature]:
if str(row.get("subtype") or "").lower() != "road":
return []
surface_rules = _linear_rules(row.get("road_surface"), "value", str)
width_rules = _linear_rules(row.get("width_rules"), "value", float)
boundaries = sorted(
{0.0, 1.0}
| {rule.start for rule in surface_rules + width_rules}
| {rule.stop for rule in surface_rules + width_rules}
)
features = []
for section_index, (start, stop) in enumerate(
zip(boundaries, boundaries[1:], strict=False)
):
midpoint = (start + stop) / 2.0
road_surface = _rule_value(surface_rules, midpoint)
decision = _road_decision(row, road_surface)
width = _rule_value(width_rules, midpoint)
width_m = float(width) if width is not None else _default_road_width(row)
width_source = "overture_width_rule" if width is not None else "class_default"
section = substring(geometry, start, stop, normalized=True)
polygon = section.buffer(width_m / 2.0, cap_style="flat", join_style="mitre")
clipped = polygon.intersection(clip)
if clipped.is_empty:
continue
features.append(
cls._feature(
row,
"segment",
clipped,
decision,
section_index=section_index,
source_surface=str(road_surface) if road_surface is not None else None,
width_m=width_m,
width_source=width_source,
)
)
return features
@staticmethod
def _feature(
row: dict,
theme: str,
geometry: Polygon | MultiPolygon,
decision: SurfaceDecision,
*,
section_index: int | None = None,
source_surface: str | None = None,
width_m: float | None = None,
width_source: str | None = None,
) -> SurfaceFeature:
sources = tuple(
FeatureSource(
property=_text(source.get("property")),
dataset=_text(source.get("dataset")),
record_id=_text(source.get("record_id")),
update_time=_text(source.get("update_time")),
license=_text(source.get("license")),
)
for source in row.get("sources") or []
)
record_id = sources[0].record_id if sources else None
feature_id = f"overture:{theme}:{row.get('id', record_id or len(sources))}"
if section_index is not None:
feature_id = f"{feature_id}:{section_index}"
return SurfaceFeature(
feature_id=feature_id,
surface_class=decision.surface_class,
geometry=geometry,
source_theme=theme,
source_subtype=_text(row.get("subtype")),
source_class=_text(row.get("class")),
source_surface=source_surface or _text(row.get("surface")),
sources=sources,
classification_basis=decision.basis,
precedence=decision.precedence,
width_m=width_m,
width_source=width_source,
)
def _text(value) -> str | None:
return str(value) if value not in (None, "") else None
def _surface_decision(theme: str, row: dict) -> SurfaceDecision | None:
subtype = str(row.get("subtype") or "").lower()
feature_class = str(row.get("class") or "").lower()
surface = str(row.get("surface") or "").lower()
if theme == "water":
return SurfaceDecision(SurfaceClass.WATER, "water_geometry", 50)
surface_class = _surface_material_class(surface)
if surface_class is not None:
return SurfaceDecision(surface_class, "land_use_surface", 30)
if theme == "land_cover":
if subtype in VEGETATION_LAND_COVER:
return SurfaceDecision(SurfaceClass.VEGETATION, "land_cover_subtype", 20)
if subtype in GROUND_LAND_COVER:
return SurfaceDecision(SurfaceClass.GROUND, "land_cover_subtype", 20)
if theme == "land_use" and (
subtype in VEGETATION_LAND_USE_SUBTYPES or feature_class in VEGETATION_LAND_USE_CLASSES
):
return SurfaceDecision(SurfaceClass.VEGETATION, "land_use_semantic", 10)
return None
def _surface_material_class(surface: str) -> SurfaceClass | None:
if surface in PAVED_SURFACES:
return SurfaceClass.PAVED
if surface in VEGETATION_SURFACES:
return SurfaceClass.VEGETATION
if surface in GROUND_SURFACES:
return SurfaceClass.GROUND
return None
def _road_decision(row: dict, road_surface: str | float | None) -> SurfaceDecision:
surface = str(road_surface or "").lower()
if surface in GROUND_ROAD_SURFACES:
return SurfaceDecision(SurfaceClass.GROUND, "road_surface", 40)
if surface in PAVED_ROAD_SURFACES:
return SurfaceDecision(SurfaceClass.PAVED, "road_surface", 40)
road_class = str(row.get("class") or "").lower()
inferred = SurfaceClass.GROUND if road_class in GROUND_ROAD_CLASSES else SurfaceClass.PAVED
return SurfaceDecision(inferred, "road_class_default", 35)
def _linear_rules(items, value_key: str, value_type: type) -> list[LinearRule]:
rules = []
for item in items or []:
value = item.get(value_key)
if value is None:
continue
between = item.get("between")
start, stop = (0.0, 1.0) if between is None else map(float, between)
rules.append(LinearRule(start, stop, value_type(value)))
return rules
def _rule_value(rules: list[LinearRule], position: float) -> str | float | None:
matches = [rule for rule in rules if rule.start <= position <= rule.stop]
if not matches:
return None
return min(matches, key=lambda rule: rule.stop - rule.start).value
def _default_road_width(row: dict) -> float:
road_class = str(row.get("class") or "").lower()
return ROAD_WIDTH_DEFAULTS_M.get(road_class, ROAD_WIDTH_DEFAULTS_M["other"])
[docs]
def classify_surface_cells(
terrain: TerrainGrid,
features: list[SurfaceFeature] | tuple[SurfaceFeature, ...],
) -> np.ndarray:
east = (terrain.east_m[:-1] + terrain.east_m[1:]) / 2.0
north = (terrain.north_m[:-1] + terrain.north_m[1:]) / 2.0
cells = np.full(terrain.cell_shape, SurfaceClass.GROUND, dtype=np.uint8)
for feature in sorted(
features,
key=lambda item: (item.precedence, int(item.surface_class), item.feature_id),
):
minimum_east, minimum_north, maximum_east, maximum_north = feature.geometry.bounds
column_start = int(np.searchsorted(east, minimum_east, side="left"))
column_stop = int(np.searchsorted(east, maximum_east, side="right"))
row_start = int(np.searchsorted(north, minimum_north, side="left"))
row_stop = int(np.searchsorted(north, maximum_north, side="right"))
if column_start >= column_stop or row_start >= row_stop:
continue
x, y = np.meshgrid(
east[column_start:column_stop],
north[row_start:row_stop],
)
mask = contains_xy(feature.geometry, x, y)
window = cells[row_start:row_stop, column_start:column_stop]
window[mask] = int(feature.surface_class)
return cells
def _connected_water_components(mask: np.ndarray) -> list[tuple[np.ndarray, np.ndarray]]:
visited = np.zeros(mask.shape, dtype=bool)
components = []
rows, columns = mask.shape
for start_row, start_column in zip(*np.nonzero(mask), strict=True):
if visited[start_row, start_column]:
continue
queue = deque([(int(start_row), int(start_column))])
visited[start_row, start_column] = True
component_rows = []
component_columns = []
while queue:
row, column = queue.popleft()
component_rows.append(row)
component_columns.append(column)
for row_offset in (-1, 0, 1):
for column_offset in (-1, 0, 1):
neighbor_row = row + row_offset
neighbor_column = column + column_offset
if (
0 <= neighbor_row < rows
and 0 <= neighbor_column < columns
and mask[neighbor_row, neighbor_column]
and not visited[neighbor_row, neighbor_column]
):
visited[neighbor_row, neighbor_column] = True
queue.append((neighbor_row, neighbor_column))
components.append(
(
np.asarray(component_rows, dtype=np.int64),
np.asarray(component_columns, dtype=np.int64),
)
)
return components
[docs]
def apply_water_surface_model(
terrain: TerrainGrid,
features: tuple[SurfaceFeature, ...] | list[SurfaceFeature],
surface_cells: np.ndarray,
) -> TerrainGrid:
"""Replace water-tagged terrain with one horizontal RF interface per water body."""
water_cells = surface_cells == int(SurfaceClass.WATER)
if not np.any(water_cells):
return terrain
marine = [
feature.geometry
for feature in features
if feature.source_theme == "water"
and (feature.source_subtype == "ocean" or feature.source_class in MARINE_WATER_CLASSES)
]
east = (terrain.east_m[:-1] + terrain.east_m[1:]) / 2.0
north = (terrain.north_m[:-1] + terrain.north_m[1:]) / 2.0
cell_east, cell_north = np.meshgrid(east, north)
marine_cells = (
intersects_xy(union_all(marine), cell_east, cell_north) & water_cells
if marine
else np.zeros(water_cells.shape, dtype=bool)
)
model = terrain.model_elevation_m.copy()
source = terrain.source_elevation_m
vertex_columns = terrain.shape[1]
component_records = []
for index, (rows, columns) in enumerate(_connected_water_components(water_cells)):
vertex_indices = np.unique(
np.concatenate(
[
rows * vertex_columns + columns,
rows * vertex_columns + columns + 1,
(rows + 1) * vertex_columns + columns,
(rows + 1) * vertex_columns + columns + 1,
]
)
)
is_marine = bool(np.any(marine_cells[rows, columns]))
level = 0.0 if is_marine else float(np.median(source.reshape(-1)[vertex_indices]))
model.reshape(-1)[vertex_indices] = level
component_records.append(
{
"index": index,
"cell_count": int(rows.size),
"vertex_count": int(vertex_indices.size),
"modeled_elevation_m": level,
"level_basis": (
"source_vertical_datum_zero" if is_marine else "source_dem_vertex_median"
),
}
)
water_vertices = np.zeros(terrain.shape, dtype=bool)
water_vertices[:-1, :-1] |= water_cells
water_vertices[:-1, 1:] |= water_cells
water_vertices[1:, :-1] |= water_cells
water_vertices[1:, 1:] |= water_cells
return terrain.with_model_elevation(
model,
{
"operation": "water_surface_hydroflattening",
"selection": (
"8-connected components of the final Overture water-class grid; all four "
"vertices of each selected cell"
),
"vertical_datum": terrain.source.vertical_datum,
"interpretation": (
"marine components use vertical-datum zero; other water components use the "
"median of their source DEM vertices; tides and local tidal-datum offsets are "
"not modeled"
),
"component_connectivity": 8,
"component_count": len(component_records),
"marine_component_count": sum(
item["level_basis"] == "source_vertical_datum_zero"
for item in component_records
),
"dem_estimated_component_count": sum(
item["level_basis"] == "source_dem_vertex_median"
for item in component_records
),
"water_cell_count": int(np.count_nonzero(water_cells)),
"marine_cell_count": int(np.count_nonzero(marine_cells)),
"vertex_count": int(np.count_nonzero(water_vertices)),
"source_minimum_m": float(np.min(source[water_vertices])),
"source_maximum_m": float(np.max(source[water_vertices])),
"components": component_records,
},
)