Source code for openworld_radio_twin.models

import math
from enum import Enum
from typing import Literal

import numpy as np
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator

from openworld_radio_twin.geodesy import LocalFrame


[docs] class EngineName(str, Enum): preview = "preview" sionna = "sionna"
MAX_PREVIEW_GRID_CELLS = 20_000 MAX_SIONNA_GRID_CELLS = 2_250_000 MAX_TERRAIN_GRID_CELLS = 250_000
[docs] class Coordinate(BaseModel): latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) altitude_m: float = Field( default=25.0, ge=0, le=1000, description="Transmitter height above the local ground plane in meters (AGL)", )
[docs] class TransmitterConfig(BaseModel): position: Coordinate frequency_ghz: float = Field(default=3.5, ge=0.1, le=100) power_dbm: float = Field(default=30.0, ge=-20, le=80) azimuth_deg: float = Field(default=0.0, ge=0, lt=360) downtilt_deg: float = Field(default=6.0, ge=-30, le=90) antenna_pattern: str = Field(default="sector", pattern="^(sector|isotropic)$")
SIONNA_PROPAGATION_PARAMETERS = ( "los", "specular_reflection", "diffuse_reflection", "refraction", "diffraction", "edge_diffraction", "diffraction_lit_region", )
[docs] class SionnaEngineConfig(BaseModel): """Runtime and geometry controls for the built-in Sionna RT adapter.""" model_config = ConfigDict(extra="forbid") device: Literal["auto", "cuda", "cpu"] = "auto" include_ground: bool = True include_vegetation: bool = True include_paved: bool = True include_water: bool = True include_building_roofs: bool = True include_building_walls: bool = True # Names and defaults follow Sionna RT 2.0.1 RadioMapSolver.__call__. los: bool = True specular_reflection: bool = True diffuse_reflection: bool = False refraction: bool = True diffraction: bool = False edge_diffraction: bool = False diffraction_lit_region: bool = True
[docs] class SceneRequest(BaseModel): """Geometry inputs of a compiled scene, keyed like ``openworld_radio_twin.rt.load_scene``.""" latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) radius_m: float = Field(default=750, ge=100, le=5000) include_buildings: bool = True building_source: str = Field(default="auto", pattern=r"^(?:[a-z][a-z0-9_-]*|3dbag)$") include_terrain: bool = False material_profile: Literal["itu", "uniform"] = "itu" terrain_resolution_m: float = Field( default=5.0, ge=1.0, le=100.0, description="ENU terrain mesh spacing; distinct from source DEM resolution", )
[docs] @model_validator(mode="after") def limit_terrain_grid(self) -> "SceneRequest": terrain_side = math.ceil(2 * self.radius_m / self.terrain_resolution_m) if self.include_terrain and terrain_side**2 > MAX_TERRAIN_GRID_CELLS: raise ValueError( f"Requested {terrain_side}x{terrain_side} terrain grid exceeds the " f"{MAX_TERRAIN_GRID_CELLS:,}-cell viewer and scene limit; " "increase terrain resolution" ) return self
@property def buildings_mode(self) -> Literal["auto", "none"]: return "auto" if self.include_buildings else "none" @property def terrain_mode(self) -> Literal["elevation", "flat"]: return "elevation" if self.include_terrain else "flat"
[docs] class SceneCenter(BaseModel): """Explicit scene origin; the compiled square is centred here rather than on the first TX.""" latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180)
[docs] class SimulationRequest(BaseModel): transmitters: list[TransmitterConfig] = Field(min_length=1, max_length=8) scene_center: SceneCenter | None = Field( default=None, description=( "Centre of the compiled scene square. Defaults to the first transmitter, " "which is what dataset generation always uses." ), ) engine: str = Field(default=EngineName.preview.value, pattern=r"^[a-z][a-z0-9-]*$") engine_config: dict[str, str | int | float | bool] = Field(default_factory=dict) radius_m: float = Field(default=750, ge=100, le=5000) resolution_m: float = Field(default=30, ge=0.1, le=200) receiver_height_m: float = Field( default=1.5, ge=0.1, le=100, description="Receiver height above the local ground plane in meters (AGL)", ) max_depth: int = Field(default=3, ge=0, le=8) samples_per_tx: int = Field(default=1_000_000, ge=10_000, le=100_000_000) seed: int = Field(default=42, ge=0, le=4_294_967_295) association_metric: str = Field(default="sinr", pattern="^(path_gain|rss|sinr)$") include_buildings: bool = True building_source: str = Field(default="auto", pattern=r"^(?:[a-z][a-z0-9_-]*|3dbag)$") include_terrain: bool = False material_profile: Literal["itu", "uniform"] = "itu" terrain_resolution_m: float = Field( default=5.0, ge=1.0, le=100.0, description="ENU terrain mesh spacing; distinct from source DEM resolution", )
[docs] @model_validator(mode="after") def limit_grid_size(self) -> "SimulationRequest": terrain_side = math.ceil(2 * self.radius_m / self.terrain_resolution_m) if self.include_terrain and terrain_side**2 > MAX_TERRAIN_GRID_CELLS: raise ValueError( f"Requested {terrain_side}x{terrain_side} terrain grid exceeds the " f"{MAX_TERRAIN_GRID_CELLS:,}-cell viewer and scene limit; " "increase terrain resolution" ) side = math.ceil(2 * self.radius_m / self.resolution_m) maximum = ( MAX_PREVIEW_GRID_CELLS if self.engine == EngineName.preview.value else MAX_SIONNA_GRID_CELLS ) if side**2 > maximum: raise ValueError( f"Requested {side}x{side} coverage grid exceeds the " f"{maximum:,}-cell {self.engine} limit; increase resolution" ) origin = self.scene_origin frame = LocalFrame.at(origin.longitude, origin.latitude) for index, transmitter in enumerate(self.transmitters, start=1): east, north, _ = frame.to_local( transmitter.position.longitude, transmitter.position.latitude ) if max(abs(east), abs(north)) > self.radius_m + 1e-6: raise ValueError( f"Transmitter {index} lies outside the {2 * self.radius_m:g} m scene square" ) if self.engine == EngineName.sionna.value: self.engine_config = SionnaEngineConfig.model_validate(self.engine_config).model_dump() frequencies = {transmitter.frequency_ghz for transmitter in self.transmitters} patterns = {transmitter.antenna_pattern for transmitter in self.transmitters} if any(not 1 <= frequency <= 10 for frequency in frequencies): raise ValueError("The default Sionna ITU ground model is valid from 1 to 10 GHz") if len(frequencies) != 1: raise ValueError("Sionna transmitters must use one shared scene frequency") if len(patterns) != 1: raise ValueError("Sionna transmitters must use one shared antenna pattern") return self
@property def include_surface_materials(self) -> bool: """Return whether semantic surface classes are required by this request. Classes are assigned on the ground grid whether that grid follows the DEM or is a flat plane; only the uniform profile omits them. """ return self.material_profile == "itu" @property def include_environment_grid(self) -> bool: """Return whether a gridded ground (DEM or flat) must be acquired for this request. The planar preview engine ignores materials, so a flat scene only needs the environment grid when Sionna evaluates it. """ if self.include_terrain: return True return self.include_surface_materials and self.engine == EngineName.sionna.value @property def reference_transmitter(self) -> TransmitterConfig: """First TX; it sets the shared antenna preset and, without ``scene_center``, the origin.""" return self.transmitters[0] @property def scene_origin(self) -> SceneCenter: """Local ENU origin and coverage-grid centre of the compiled scene.""" if self.scene_center is not None: return self.scene_center position = self.reference_transmitter.position return SceneCenter(latitude=position.latitude, longitude=position.longitude) @property def scene_request(self) -> SceneRequest: """Geometry of the compiled scene this simulation runs in.""" origin = self.scene_origin return SceneRequest( latitude=origin.latitude, longitude=origin.longitude, radius_m=self.radius_m, include_buildings=self.include_buildings, building_source=self.building_source, include_terrain=self.include_terrain, material_profile=self.material_profile, terrain_resolution_m=self.terrain_resolution_m, )
[docs] class BuildingGeometry(BaseModel): """Source-resolved triangle shell, before scene placement. Horizontal coordinates use ``crs``; vertex Z and ``ground_elevation_m`` use the explicitly named vertical datum, never implicit WGS84 heights. """ crs: str vertical_datum: str lod: str ground_elevation_m: float = Field(allow_inf_nan=False) vertices: list[tuple[float, float, float]] = Field(min_length=3) triangles: list[tuple[int, int, int]] = Field(min_length=1) surface_types: list[str]
[docs] @model_validator(mode="after") def validate_mesh(self) -> "BuildingGeometry": vertices = np.asarray(self.vertices, dtype=np.float64).reshape(-1, 3) if not np.isfinite(vertices).all(): raise ValueError("Building geometry coordinates must be finite") if len(self.surface_types) != len(self.triangles): raise ValueError("Each building triangle needs a surface type") triangles = np.asarray(self.triangles, dtype=np.int64).reshape(-1, 3) if ( (triangles < 0).any() or (triangles >= len(self.vertices)).any() or (triangles[:, 0] == triangles[:, 1]).any() or (triangles[:, 1] == triangles[:, 2]).any() or (triangles[:, 0] == triangles[:, 2]).any() ): raise ValueError("Building geometry has an invalid triangle index") return self
[docs] class BuildingFeature(BaseModel): feature_id: str source: str feature_type: str = Field(default="building", pattern="^(building|building_part)$") source_dataset_id: str | None = None parent_building_id: str | None = None source_record_id: str | None = None source_release: str | None = None name: str | None = None height_m: float = Field( gt=0, le=1000, description="Vertical extent from the feature's lowest to highest point in meters", ) min_height_m: float = Field( default=0, ge=0, le=1000, description="Height AGL at which the feature begins", ) height_source: str = "unspecified" height_uncertainty_m: float | None = None coordinates: list[list[float]] holes: list[list[list[float]]] = Field(default_factory=list) geometry: BuildingGeometry | None = None source_attributes: dict[str, object] = Field(default_factory=dict) @computed_field @property def roof_height_agl_m(self) -> float: return self.min_height_m + self.height_m
[docs] class GridMapping(BaseModel): horizontal_datum: str = "WGS84" local_axes: str = "east,north,up" origin_longitude: float origin_latitude: float first_sample_east_m: float first_sample_north_m: float column_step_m: float row_step_m: float sample_layout: str corner_coordinates: list[list[float]]
[docs] class RadioLayer(BaseModel): label: str unit: str values: list[float | None] | None = None minimum: float maximum: float display_minimum: float display_maximum: float reduction: str = "best_transmitter_per_cell"
[docs] class CoverageGrid(BaseModel): crs: str = "EPSG:4326" no_data_value: None = None receiver_height_agl_m: float grid_mapping: GridMapping west: float south: float east: float north: float width: int height: int layers: dict[str, RadioLayer] association: list[int] | None = None building_mask: list[bool] | None = None building_heights_m: list[float | None] | None = None building_cell_count: int = 0
[docs] @model_validator(mode="after") def validate_aligned_grids(self) -> "CoverageGrid": arrays = [self.association, self.building_mask, self.building_heights_m] arrays.extend(layer.values for layer in self.layers.values()) if all(value is None for value in arrays): return self if any(value is None for value in arrays): raise ValueError("Coverage cell arrays must be either all present or all omitted") assert self.association is not None assert self.building_mask is not None assert self.building_heights_m is not None expected = self.width * self.height aligned = { "association": len(self.association), "building_mask": len(self.building_mask), "building_heights_m": len(self.building_heights_m), } for name, layer in self.layers.items(): assert layer.values is not None aligned[f"layers.{name}"] = len(layer.values) for name, actual in aligned.items(): if actual != expected: raise ValueError(f"{name} has {actual} cells; expected {expected}") if any( mask != (height is not None) for mask, height in zip(self.building_mask, self.building_heights_m, strict=True) ): raise ValueError("building_mask and building_heights_m disagree") return self
[docs] class NativeBuildingMesh(BaseModel): feature_id: str vertices: list[tuple[float, float, float]] triangles: list[tuple[int, int, int]] surface_types: list[str] lod: str placement_rule: str
[docs] class BuildingGeometryContext(BaseModel): """Solver-coordinate native shells, with no visual offset or vertical scaling.""" origin_longitude: float origin_latitude: float coordinate_system: str = "WGS84 topocentric ENU" meshes: list[NativeBuildingMesh]
[docs] class SimulationResponse(BaseModel): simulation_id: str engine: str elapsed_ms: int coverage: CoverageGrid buildings: list[BuildingFeature] building_geometry: BuildingGeometryContext | None = None warnings: list[str] = Field(default_factory=list) engine_details: dict[str, str | int | float | bool] = Field(default_factory=dict) data_provenance: dict[str, str | int | float | bool] = Field(default_factory=dict) environment: "EnvironmentContext | None" = None scene: "SceneResponse | None" = None
[docs] class SceneBounds(BaseModel): origin_longitude: float origin_latitude: float radius_m: float wgs84_corners: list[list[float]]
[docs] class SceneResponse(BaseModel): """A compiled, cached scene as the browser displays it. ``scene_id`` is the cache directory name shared with ``openworld_radio_twin.rt.load_scene`` for the same geometry inputs; the buildings and environment are read from that directory. """ scene_id: str cached: bool specification: SceneRequest bounds: SceneBounds buildings: list[BuildingFeature] building_geometry: BuildingGeometryContext | None = None environment: "EnvironmentContext | None" = None warnings: list[str] = Field(default_factory=list) data_provenance: dict[str, str | int | float | bool] = Field(default_factory=dict)
[docs] class TerrainContext(BaseModel): width: int height: int east_m: list[float] north_m: list[float] local_elevation_m: list[float] origin_elevation_m: float minimum_local_elevation_m: float maximum_local_elevation_m: float model_operations: list[dict[str, object]] source: dict[str, object]
[docs] class SurfaceContext(BaseModel): width: int height: int classes: list[int] legend: dict[int, str] material_profile_version: int material_profiles: dict[str, dict[str, str]] provenance: dict[str, object]
[docs] class EnvironmentContext(BaseModel): terrain: TerrainContext surfaces: SurfaceContext
[docs] class BuildingContextResponse(BaseModel): buildings: list[BuildingFeature] building_geometry: BuildingGeometryContext | None = None warnings: list[str] = Field(default_factory=list) data_provenance: dict[str, str | int | float | bool] = Field(default_factory=dict)
[docs] class SearchResult(BaseModel): display_name: str latitude: float longitude: float kind: str | None = None