Source code for openworld_radio_twin.simulation.preview

import math

import numpy as np

from openworld_radio_twin.geodesy import LocalFrame
from openworld_radio_twin.models import BuildingFeature, SimulationRequest
from openworld_radio_twin.simulation.radio_map import RadioMapData

BOLTZMANN_J_PER_K = 1.380649e-23
PREVIEW_BANDWIDTH_HZ = 1_000_000.0
PREVIEW_TEMPERATURE_K = 293.0


def _point_in_polygon(x: float, y: float, polygon: list[tuple[float, float]]) -> bool:
    inside = False
    j = len(polygon) - 1
    for i, (xi, yi) in enumerate(polygon):
        xj, yj = polygon[j]
        if (yi > y) != (yj > y):
            crossing_x = (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi
            if x < crossing_x:
                inside = not inside
        j = i
    return inside


def _segment_intersects(
    a: tuple[float, float],
    b: tuple[float, float],
    c: tuple[float, float],
    d: tuple[float, float],
) -> bool:
    def orientation(
        p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]
    ) -> float:
        return (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1])

    return (
        orientation(a, b, c) * orientation(a, b, d) < 0
        and orientation(c, d, a) * orientation(c, d, b) < 0
    )


def _blocked_by_buildings(
    tx_x: float,
    tx_y: float,
    x: float,
    y: float,
    tx_height: float,
    rx_height: float,
    polygons: list[tuple[float, float, list[tuple[float, float]]]],
) -> int:
    blocked = 0
    distance = math.hypot(x - tx_x, y - tx_y)
    if distance < 1:
        return 0
    for minimum_height, maximum_height, polygon in polygons:
        if _point_in_polygon(x, y, polygon):
            continue
        for index, start in enumerate(polygon[:-1]):
            end = polygon[index + 1]
            if not _segment_intersects((tx_x, tx_y), (x, y), start, end):
                continue
            wall_distance = min(distance, math.hypot(start[0] - tx_x, start[1] - tx_y))
            ray_height = tx_height + (rx_height - tx_height) * (wall_distance / distance)
            if minimum_height < ray_height < maximum_height:
                blocked += 1
                break
        if blocked >= 3:
            break
    return blocked


[docs] def simulate_preview(request: SimulationRequest, buildings: list[BuildingFeature]) -> RadioMapData: """Return an analytical preview in the same numerical contract as Sionna RT.""" center = request.scene_origin diameter = request.radius_m * 2 width = max(1, math.ceil(diameter / request.resolution_m)) height = width step_x = diameter / width step_y = diameter / height local_frame = LocalFrame.at(center.longitude, center.latitude) polygons = [ ( building.min_height_m, building.roof_height_agl_m, [local_frame.to_local(lon, lat)[:2] for lon, lat in building.coordinates], ) for building in buildings ] transmitters = [ ( transmitter, local_frame.to_local( transmitter.position.longitude, transmitter.position.latitude, transmitter.position.altitude_m, ), ) for transmitter in request.transmitters ] path_gain = np.empty((len(transmitters), height, width), dtype=np.float32) rss = np.empty_like(path_gain) centers = np.empty((height, width, 3), dtype=np.float64) for row in range(height): north = -request.radius_m + (row + 0.5) * step_y for column in range(width): east = -request.radius_m + (column + 0.5) * step_x centers[row, column] = (east, north, request.receiver_height_m) for tx_index, (tx, (tx_east, tx_north, tx_up)) in enumerate(transmitters): delta_east = east - tx_east delta_north = north - tx_north distance_2d = max(1.0, math.hypot(delta_east, delta_north)) distance_3d = math.hypot(distance_2d, tx_up - request.receiver_height_m) fspl = ( 32.44 + 20 * math.log10(tx.frequency_ghz * 1000) + 20 * math.log10(distance_3d / 1000) ) obstruction_count = _blocked_by_buildings( tx_east, tx_north, east, north, tx_up, request.receiver_height_m, polygons, ) directional_penalty = 0.0 if tx.antenna_pattern == "sector": bearing = (math.degrees(math.atan2(delta_east, delta_north)) + 360) % 360 delta = abs((bearing - tx.azimuth_deg + 180) % 360 - 180) directional_penalty = min(28.0, (delta / 65.0) ** 2 * 12.0) loss_db = fspl + obstruction_count * 13.0 + directional_penalty path_gain[tx_index, row, column] = 10.0 ** (-loss_db / 10.0) transmit_power_w = 10.0 ** ((tx.power_dbm - 30.0) / 10.0) rss[tx_index, row, column] = transmit_power_w * path_gain[ tx_index, row, column ] noise_w = BOLTZMANN_J_PER_K * PREVIEW_TEMPERATURE_K * PREVIEW_BANDWIDTH_HZ total_rss = np.sum(rss.astype(np.float64), axis=0, keepdims=True) interference = np.maximum(0.0, total_rss - rss) sinr = (rss / (interference + noise_w)).astype(np.float32) association_values = { "path_gain": path_gain, "rss": rss, "sinr": sinr, }[request.association_metric] association = np.argmax(association_values, axis=0).astype(np.int32) association[np.max(association_values, axis=0) <= 0] = -1 return RadioMapData(path_gain, rss, sinr, association, centers)