Source code for openworld_radio_twin.artifacts

import io
import json
import math
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path

import numpy as np
from PIL import Image

from openworld_radio_twin.environment import EnvironmentData
from openworld_radio_twin.models import SimulationRequest, SimulationResponse
from openworld_radio_twin.rendering import rgba_lookup
from openworld_radio_twin.scene_format import (
    SCENE_ENVIRONMENT_ARRAY_PATHS,
    SCENE_HEIGHT_MAP_PATH,
    SCENE_MESH_DIRECTORY,
    SCENE_PROVENANCE_PATH,
    SCENE_TERRAIN_INFO_PATH,
    SCENE_VOXEL_DEPTH_DIRECTORY,
    SCENE_VOXEL_SLICES_DIRECTORY,
    SCENE_XML_PATH,
)
from openworld_radio_twin.simulation.radio_map import RadioMapData
from openworld_radio_twin.simulation.scene_builder import (
    SceneAssets,
    build_scene_assets,
    measurement_surface_path,
    transmitter_scene_position,
    write_measurement_surface,
    write_voxel_assets,
)

REQUIRED_SINGLE_SCENE_PATHS = {
    SCENE_HEIGHT_MAP_PATH.as_posix(),
    "arrays/path_gain/path_gain.npz",
    "arrays/rss/rss.npz",
    "arrays/sinr/sinr.npz",
    "images/association.png",
    "images/path_gain_db.png",
    "images/path_gain_db_masked.png",
    "images/rss_db.png",
    "images/rss_db_masked.png",
    "images/sinr.png",
    "images/sinr_masked.png",
    (SCENE_MESH_DIRECTORY / "ground.ply").as_posix(),
    SCENE_PROVENANCE_PATH.as_posix(),
    SCENE_XML_PATH.as_posix(),
    "tx_info.json",
    (SCENE_VOXEL_SLICES_DIRECTORY / "scene_info.json").as_posix(),
    (SCENE_VOXEL_DEPTH_DIRECTORY / "scene_depth_max_z_pitch3.0.npy").as_posix(),
    (SCENE_VOXEL_DEPTH_DIRECTORY / "scene_depth_max_z_pitch3.0.png").as_posix(),
    (SCENE_VOXEL_DEPTH_DIRECTORY / "scene_depth_max_z_pitch3.0_preview.png").as_posix(),
    (SCENE_VOXEL_DEPTH_DIRECTORY / "scene_info.json").as_posix(),
}
MIN_RENDERED_IMAGE_SIDE = 1800
ASSOCIATION_COLORS = np.asarray(
    [
        [40, 199, 183],
        [244, 162, 97],
        [77, 121, 255],
        [230, 92, 118],
        [162, 112, 214],
        [118, 164, 75],
        [235, 198, 68],
        [73, 172, 220],
    ],
    dtype=np.uint8,
)


def _display_range(values: np.ndarray) -> tuple[float, float]:
    finite = values[np.isfinite(values)]
    if not finite.size:
        return 0.0, 1.0
    if finite.size >= 20:
        minimum, maximum = np.percentile(finite, [2, 98]).tolist()
    else:
        minimum, maximum = float(np.min(finite)), float(np.max(finite))
    if maximum <= minimum:
        maximum = minimum + 1.0
    return float(minimum), float(maximum)


def _save_clean_png(
    values: np.ndarray,
    path: Path,
    *,
    cmap: str = "viridis",
    building_mask: np.ndarray | None = None,
) -> tuple[float, float]:
    """Render exact solver cells by integer pixel replication, without interpolation."""
    if values.ndim != 2:
        raise ValueError("PNG source must be a two-dimensional solver grid")
    if building_mask is not None and building_mask.shape != values.shape:
        raise ValueError("Building mask and PNG source grid must have the same shape")
    minimum, maximum = _display_range(values)
    normalized = np.clip((values - minimum) / max(1e-30, maximum - minimum), 0.0, 1.0)
    lookup = rgba_lookup(cmap)
    indices = np.nan_to_num(normalized, nan=0.0)
    rgba = lookup[np.rint(indices * 255).astype(np.uint8)]
    invalid = ~np.isfinite(values)
    rgba[invalid] = (0, 0, 0, 0)
    if building_mask is not None:
        rgba[building_mask] = (0, 0, 0, 255)

    rows, columns = values.shape
    pixels_per_cell = max(1, math.ceil(MIN_RENDERED_IMAGE_SIDE / max(rows, columns)))
    north_up = np.flipud(rgba)
    rendered = np.repeat(
        np.repeat(north_up, pixels_per_cell, axis=0),
        pixels_per_cell,
        axis=1,
    )
    Image.fromarray(rendered, mode="RGBA").save(path)
    return minimum, maximum


[docs] def render_radio_map_png( data: RadioMapData, metric: str, building_mask: np.ndarray, *, mask_buildings: bool, feather_edges: bool, ) -> bytes: """Render one pixel per solver cell without spatial resampling.""" if metric == "association": values = data.association valid = values >= 0 rgba = np.zeros((*values.shape, 4), dtype=np.uint8) rgba[valid, :3] = ASSOCIATION_COLORS[values[valid] % len(ASSOCIATION_COLORS)] else: values = data.best_db(metric) valid = np.isfinite(values) minimum, maximum = _display_range(values) normalized = np.clip((values - minimum) / (maximum - minimum), 0.0, 1.0) lookup = rgba_lookup({"path_gain": "viridis", "rss": "inferno", "sinr": "jet"}[metric]) indices = np.rint(np.nan_to_num(normalized, nan=0.0) * 255).astype(np.uint8) rgba = lookup[indices] rgba[~valid] = (0, 0, 0, 0) rgba[valid, 3] = 188 if feather_edges: rows, columns = values.shape x = np.linspace(-1.0, 1.0, columns) if columns > 1 else np.zeros(1) y = np.linspace(-1.0, 1.0, rows) if rows > 1 else np.zeros(1) edge = np.clip((1.0 - np.hypot(y[:, None], x[None, :])) / 0.2, 0.0, 1.0) feather = edge * edge * (3.0 - 2.0 * edge) rgba[:, :, 3] = np.rint(rgba[:, :, 3] * feather).astype(np.uint8) if mask_buildings: rgba[np.flipud(building_mask)] = (0, 0, 0, 230) output = io.BytesIO() Image.fromarray(np.flipud(rgba), mode="RGBA").save(output, format="PNG") return output.getvalue()
def _look_at( request: SimulationRequest, tx_index: int, position: tuple[float, float, float], ) -> list[float]: tx = request.transmitters[tx_index] azimuth = math.radians(tx.azimuth_deg) downtilt = math.radians(tx.downtilt_deg) horizontal = 100.0 * math.cos(downtilt) return [ position[0] + horizontal * math.sin(azimuth), position[1] + horizontal * math.cos(azimuth), position[2] - 100.0 * math.sin(downtilt), ] def _tx_info( request: SimulationRequest, result: SimulationResponse, data: RadioMapData, maximum_roof_height_m: float, slice_path: Path, slice_shape: tuple[int, int], slice_pitch_m: float, height_map_pixel_size_m: float, image_products: dict[str, object], scene: SceneAssets, ) -> dict[str, object]: tx_list = [] for index, tx in enumerate(request.transmitters): position = transmitter_scene_position( scene, tx.position.longitude, tx.position.latitude, tx.position.altitude_m, ) tx_list.append( { "name": f"tx-{index}", "position": list(position), "look_at": _look_at(request, index, position), "power_dbm": tx.power_dbm, "frequency_ghz": tx.frequency_ghz, "azimuth_deg": tx.azimuth_deg, "downtilt_deg": tx.downtilt_deg, "antenna_pattern": tx.antenna_pattern, "geographic_position": { "longitude": tx.position.longitude, "latitude": tx.position.latitude, "height_agl_m": tx.position.altitude_m, }, } ) sector_array = request.reference_transmitter.antenna_pattern == "sector" mask_applied = result.coverage.building_cell_count > 0 return { "simulation_id": result.simulation_id, "request": request.model_dump(mode="json"), "scene_name": "scene", "scene_folder": ".", "scene_xml": SCENE_XML_PATH.as_posix(), "bbox_min_xyz": [-request.radius_m, -request.radius_m, 0.0], "bbox_max_xyz": [ request.radius_m, request.radius_m, maximum_roof_height_m, ], "rm_center": [0.0, 0.0, request.receiver_height_m], "rm_size": [2.0 * request.radius_m, 2.0 * request.radius_m], "rm_orientation": [0.0, 0.0, 0.0], "params": { "max_depth": request.max_depth, "samples_per_tx": request.samples_per_tx, "cell_size": [ abs(result.coverage.grid_mapping.column_step_m), abs(result.coverage.grid_mapping.row_step_m), ], "rm_height_above_ground": request.receiver_height_m, "assoc_metric": request.association_metric, "seed": request.seed, "n_tx": len(request.transmitters), "engine": result.engine, }, "tx_array": { "num_rows": 8 if sector_array else 1, "num_cols": 2 if sector_array else 1, "pattern": "tr38901" if sector_array else "iso", "polarization": "V", }, "rx_array": { "num_rows": 1, "num_cols": 1, "pattern": "iso", "polarization": "V", }, "tx_list": tx_list, "postprocess": { "enabled": True, "rm_height_above_ground": request.receiver_height_m, "slice_png": str(slice_path), "slice_shape": list(slice_shape), "slice_pitch_m": slice_pitch_m, "slice_row_order": "north_to_south", "coverage_mask_source": "building_footprints_sampled_at_solver_cell_centers", "mask_applied": mask_applied, }, "array_contract": { "shape": [data.transmitter_count, data.rows, data.columns], "axis_order": ["transmitter", "south_to_north_row", "west_to_east_column"], "path_gain": { "path": "arrays/path_gain/path_gain.npz", "key": "path_gain", "unit": "linear ratio", }, "rss": {"path": "arrays/rss/rss.npz", "key": "rss", "unit": "W"}, "sinr": {"path": "arrays/sinr/sinr.npz", "key": "sinr", "unit": "linear ratio"}, "image_logarithm": "10*log10(linear); RSS display images use dBm (+30 dB)", "height_map": { "path": SCENE_HEIGHT_MAP_PATH.as_posix(), "dtype": "float32", "value": "roof_height_agl_m", "row_order": "north_to_south", "pixel_size_m": height_map_pixel_size_m, }, }, "image_products": image_products, "grid_mapping": result.coverage.grid_mapping.model_dump(mode="json"), "coordinate_system": { "geographic_crs": "EPSG:4326", "local_frame": "WGS84 topocentric ENU", "origin_longitude": scene.origin_longitude, "origin_latitude": scene.origin_latitude, "horizontal_unit": "m", "vertical_datum": "local ENU origin", "vertical_unit": "m", "transmitter_input_height_reference": "terrain AGL", "transmitter_scene_z": "terrain_elevation_m + height_agl_m", }, "engine_details": result.engine_details, "data_provenance": result.data_provenance, "warnings": result.warnings, }
[docs] def write_radio_products( root: Path, result: SimulationResponse, data: RadioMapData, building_mask: np.ndarray, array_metrics: list[str] | tuple[str, ...] = ("path_gain", "rss", "sinr"), image_metrics: list[str] | tuple[str, ...] = ("path_gain", "rss", "sinr"), association_image: bool = True, ) -> dict[str, object]: arrays = root / "arrays" images = root / "images" for metric in array_metrics: (arrays / metric).mkdir(parents=True, exist_ok=True) np.savez_compressed(arrays / metric / f"{metric}.npz", **{metric: data.linear(metric)}) if image_metrics or association_image: images.mkdir(exist_ok=True) building_mask = np.flipud(building_mask) metric_specs = { "path_gain": ("path_gain_db.png", "path_gain_db_masked.png", "viridis", "dB"), "rss": ("rss_db.png", "rss_db_masked.png", "inferno", "dBm"), "sinr": ("sinr.png", "sinr_masked.png", "jet", "dB"), } metric_products = {} for metric in image_metrics: plain_name, masked_name, cmap, unit = metric_specs[metric] values = data.best_db(metric) display_minimum, display_maximum = _save_clean_png( values, images / plain_name, cmap=cmap, ) _save_clean_png( values, images / masked_name, cmap=cmap, building_mask=building_mask, ) metric_products[metric] = { "unit": unit, "colormap": cmap, "display_minimum": display_minimum, "display_maximum": display_maximum, } if association_image: association = np.where( data.association >= 0, data.association.astype(np.float32), np.nan, ) _save_clean_png( association, images / "association.png", cmap="viridis", ) pixels_per_cell = max( 1, math.ceil(MIN_RENDERED_IMAGE_SIDE / max(data.rows, data.columns)), ) return { "source_grid_shape": [data.rows, data.columns], "rendered_size_px": [ data.columns * pixels_per_cell, data.rows * pixels_per_cell, ], "pixels_per_solver_cell": pixels_per_cell, "row_order": "north_to_south", "spatial_resampling": "none_integer_cell_replication", "no_data_rendering": "transparent", "masked_building_rendering": "opaque_black", "value_source": "best_transmitter_from_in_memory_linear_solver_output", "array_metrics": list(array_metrics), "image_metrics": list(image_metrics), "association_image": association_image, "metrics": metric_products, }
[docs] def validate_artifact_tree(root: Path, data: RadioMapData) -> None: """Fail export when the portable tree diverges from the declared contract.""" paths = {path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file()} missing = REQUIRED_SINGLE_SCENE_PATHS - paths if missing: raise RuntimeError(f"Artifact tree is missing: {', '.join(sorted(missing))}") expected_shape = data.shape for metric in ("path_gain", "rss", "sinr"): path = root / "arrays" / metric / f"{metric}.npz" with np.load(path) as archive: if archive.files != [metric]: raise RuntimeError(f"{path.name} must contain only the '{metric}' key") values = archive[metric] if values.shape != expected_shape or values.dtype != np.float32: raise RuntimeError( f"{path.name} has {values.dtype} {values.shape}; " f"expected float32 {expected_shape}" ) pixels_per_cell = max( 1, math.ceil(MIN_RENDERED_IMAGE_SIDE / max(data.rows, data.columns)), ) expected_image_size = ( data.columns * pixels_per_cell, data.rows * pixels_per_cell, ) for image_path in (root / "images").glob("*.png"): with Image.open(image_path) as image: if image.size != expected_image_size or image.mode != "RGBA": raise RuntimeError(f"{image_path.name} must be an {expected_image_size} RGBA image") depth = np.load(root / SCENE_VOXEL_DEPTH_DIRECTORY / "scene_depth_max_z_pitch3.0.npy") with Image.open(root / SCENE_VOXEL_DEPTH_DIRECTORY / "scene_depth_max_z_pitch3.0.png") as image: if image.size != (depth.shape[1], depth.shape[0]) or image.mode not in {"I", "I;16"}: raise RuntimeError("Voxel depth PNG must match its numeric depth grid") with Image.open(next((root / SCENE_VOXEL_SLICES_DIRECTORY).glob("scene_slice_*.png"))) as image: if image.size != (depth.shape[1], depth.shape[0]): raise RuntimeError("Voxel depth and receiver slice must use the same XY grid") for shape in ET.parse(root / SCENE_XML_PATH).getroot().findall("shape"): filename = shape.find("string[@name='filename']") if filename is None or not (root / filename.attrib["value"]).is_file(): raise RuntimeError(f"scene.xml shape {shape.attrib.get('id')} has no usable mesh") metadata = json.loads((root / "tx_info.json").read_text(encoding="utf-8")) if metadata["array_contract"]["shape"] != list(expected_shape): raise RuntimeError("tx_info.json array shape does not match the NPZ products")
[docs] def build_artifact_bundle( request: SimulationRequest, result: SimulationResponse, data: RadioMapData, building_mask: np.ndarray, environment: EnvironmentData | None = None, ) -> bytes: """Build a portable single-scene version of the reference/package layout.""" with tempfile.TemporaryDirectory(prefix="owrt-artifact-") as temp_directory: root = Path(temp_directory) reference = request.scene_origin scene = build_scene_assets( root, result.buildings, reference.longitude, reference.latitude, request.radius_m, environment, True, request.material_profile, ) measurement = None if environment is not None: measurement = write_measurement_surface( measurement_surface_path( scene, request.resolution_m, request.receiver_height_m, ), scene, request.resolution_m, request.receiver_height_m, ) voxels = write_voxel_assets(scene, request.receiver_height_m) image_products = write_radio_products(root, result, data, building_mask) maximum_roof_height = max( (building.roof_height_m for building in scene.local_buildings), default=0.0 ) info = _tx_info( request, result, data, maximum_roof_height, voxels.slice_path.relative_to(root), voxels.slice_shape, voxels.pitch_m, scene.height_map_pixel_size_m, image_products, scene, ) info["postprocess"]["voxel_depth"] = { "array": str(voxels.depth_array_path.relative_to(root)), "png": str(voxels.depth_image_path.relative_to(root)), "preview_png": str(voxels.depth_preview_path.relative_to(root)), "shape": list(voxels.slice_shape), "value": "maximum_occupied_voxel_z_agl_m", "pitch_m": voxels.pitch_m, } if environment is not None: assert measurement is not None info["measurement_surface"] = { "path": str(measurement.path.relative_to(root)), "cell_shape": list(measurement.cell_shape), "receiver_height_agl_m": request.receiver_height_m, "reduction": "two triangles per cell; surface-area-weighted linear mean", } info["environment"] = { "metadata": SCENE_TERRAIN_INFO_PATH.as_posix(), **{name: path.as_posix() for name, path in SCENE_ENVIRONMENT_ARRAY_PATHS.items()}, } (root / "tx_info.json").write_text(json.dumps(info, indent=2), encoding="utf-8") validate_artifact_tree(root, data) output = io.BytesIO() with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(root.rglob("*")): if path.is_file(): archive.write(path, path.relative_to(root).as_posix()) return output.getvalue()