Source code for openworld_radio_twin.providers.osm

import asyncio
import math
import time
from dataclasses import dataclass

import httpx

from openworld_radio_twin.config import Settings
from openworld_radio_twin.geodesy import LocalFrame
from openworld_radio_twin.models import BuildingFeature, SearchResult


[docs] @dataclass class CacheEntry: expires_at: float value: object
[docs] class OsmProvider: """Fetches only the active area from public OSM services and caches it in memory.""" def __init__(self, settings: Settings, client: httpx.AsyncClient) -> None: self.settings = settings self.client = client self._cache: dict[str, CacheEntry] = {} self._search_lock = asyncio.Lock() self._last_search_at = 0.0 def _cached(self, key: str) -> object | None: entry = self._cache.get(key) if entry and entry.expires_at > time.monotonic(): return entry.value self._cache.pop(key, None) return None def _store(self, key: str, value: object) -> object: self._cache[key] = CacheEntry( expires_at=time.monotonic() + self.settings.cache_ttl_seconds, value=value, ) return value
[docs] async def search(self, query: str, limit: int = 5) -> list[SearchResult]: key = f"search:{query.casefold()}:{limit}" cached = self._cached(key) if cached is not None: return cached # type: ignore[return-value] async with self._search_lock: wait_for = 1.0 - (time.monotonic() - self._last_search_at) if wait_for > 0: await asyncio.sleep(wait_for) response = await self.client.get( self.settings.nominatim_url, params={"q": query, "format": "jsonv2", "limit": limit}, headers={"User-Agent": self.settings.user_agent}, timeout=12, ) self._last_search_at = time.monotonic() response.raise_for_status() results = [ SearchResult( display_name=item["display_name"], latitude=float(item["lat"]), longitude=float(item["lon"]), kind=item.get("type"), ) for item in response.json() ] return self._store(key, results) # type: ignore[return-value]
[docs] async def buildings( self, latitude: float, longitude: float, radius_m: int, limit: int | None = None, ) -> list[BuildingFeature]: result_limit = limit or self.settings.max_buildings rounded = (round(latitude, 7), round(longitude, 7), radius_m, result_limit) key = f"buildings:{rounded}" cached = self._cached(key) if cached is not None: return cached # type: ignore[return-value] # The scene is the ENU square of half-width radius_m; query its geographic # bounding box so the corners are covered, as the other providers do. west, south, east, north = self._bbox(longitude, latitude, radius_m) query = f""" [out:json][timeout:20]; ( way["building"]({south},{west},{north},{east}); relation["building"]({south},{west},{north},{east}); ); out tags geom {result_limit}; """ response = await self.client.post( self.settings.overpass_url, data={"data": query}, headers={"User-Agent": self.settings.user_agent}, timeout=30, ) response.raise_for_status() features: list[BuildingFeature] = [] for element in response.json().get("elements", []): tags = element.get("tags", {}) height_m, height_source, height_uncertainty_m = self._building_height_details(tags) geometries = self._element_geometries(element) for osm_id, geometry in geometries: if len(geometry) < 3: continue coordinates = [[float(p["lon"]), float(p["lat"])] for p in geometry] if coordinates[0] != coordinates[-1]: coordinates.append(coordinates[0]) features.append( BuildingFeature( feature_id=f"osm:{osm_id}", source="OpenStreetMap", source_record_id=str(osm_id), name=tags.get("name"), height_m=height_m, height_source=height_source, height_uncertainty_m=height_uncertainty_m, coordinates=coordinates, ) ) if len(features) >= result_limit: break if len(features) >= result_limit: break return self._store(key, features) # type: ignore[return-value]
@staticmethod def _bbox( longitude: float, latitude: float, radius_m: float, ) -> tuple[float, float, float, float]: 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] return min(longitudes), min(latitudes), max(longitudes), max(latitudes) @classmethod def _element_geometries( cls, element: dict[str, object] ) -> list[tuple[int | str, list[dict[str, float]]]]: if element.get("type") != "relation": return [(element.get("id", "unknown"), element.get("geometry") or [])] # type: ignore[list-item] relation_id = element.get("id", "unknown") segments = [ member.get("geometry") or [] for member in element.get("members", []) # type: ignore[union-attr] if member.get("type") == "way" and member.get("role", "") in {"", "outer"} ] rings = cls._merge_outer_rings(segments) return [(f"{relation_id}:{index}", ring) for index, ring in enumerate(rings)] @staticmethod def _merge_outer_rings( segments: list[list[dict[str, float]]], ) -> list[list[dict[str, float]]]: remaining = [segment[:] for segment in segments if len(segment) >= 2] rings: list[list[dict[str, float]]] = [] def endpoint(point: dict[str, float]) -> tuple[float, float]: return float(point["lon"]), float(point["lat"]) while remaining: ring = remaining.pop(0) while endpoint(ring[0]) != endpoint(ring[-1]): tail = endpoint(ring[-1]) match_index = next( ( index for index, segment in enumerate(remaining) if endpoint(segment[0]) == tail or endpoint(segment[-1]) == tail ), None, ) if match_index is None: break segment = remaining.pop(match_index) if endpoint(segment[-1]) == tail: segment.reverse() ring.extend(segment[1:]) if len(ring) >= 4 and endpoint(ring[0]) == endpoint(ring[-1]): rings.append(ring) return rings @staticmethod def _building_height(tags: dict[str, str]) -> float: return OsmProvider._building_height_details(tags)[0] @staticmethod def _building_height_details(tags: dict[str, str]) -> tuple[float, str, float | None]: raw_height = tags.get("height", "").lower().strip() try: is_feet = raw_height.endswith(("ft", "feet", "foot", "'")) suffixes = ("meters", "metres", "meter", "metre", "feet", "foot", "ft", "m", "'") for suffix in suffixes: if raw_height.endswith(suffix): raw_height = raw_height[: -len(suffix)].strip() break value = float(raw_height) * (0.3048 if is_feet else 1.0) if math.isfinite(value) and 2 <= value <= 500: return value, "osm_height", None except ValueError: pass try: levels = float(tags.get("building:levels", "")) if math.isfinite(levels) and 0 < levels <= 150: return max(3.0, levels * 3.2), "estimated_from_osm_levels", 1.6 except ValueError: pass return 10.0, "default_assumption", 5.0