Source code for openworld_radio_twin.providers.boston

"""Boston BPDA OBJ collections with explicit archive, status and coordinate identity.

The official source is tiled and version-pinned. A local, hash-pinned catalog
can select downloaded/historical archives without changing the Scene API.
It is never an implicit fallback from a failed current-source request.
"""

from __future__ import annotations

import asyncio
import csv
import hashlib
import io
import json
import math
import re
import zipfile
from pathlib import Path

import httpx
import numpy as np
from pyproj import Transformer
from shapely.geometry import Polygon, box, shape
from shapely.ops import transform

from openworld_radio_twin.geodesy import LocalFrame
from openworld_radio_twin.providers.base import BuildingProviderError, BuildingQueryResult
from openworld_radio_twin.providers.cityjson import triangulate_surface
from openworld_radio_twin.providers.native_mesh import mesh_feature

ATTRIBUTION = "Boston Planning and Development Agency / Boston Planning Department"
LICENSE_URL = "https://www.bostonplans.org/3d-data-maps/3d-smart-model/3d-data-download"
RELEASE = "20250128"
TILE_QUERY = (
    "https://services.arcgis.com/sFnw0xNflSi8J0uh/ArcGIS/rest/services/"
    "Bos3D_TileGrid_PopUps/FeatureServer/70/query"
)
DOWNLOAD_ROOT = f"https://maps.bostonplans.org/3d/Bos3d_BldgModels_{RELEASE}_OBJ"
FOOT_M = 1200 / 3937  # EPSG:9003 US survey foot, including NAVD88 height.
MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
EXISTING_STATUSES = {"Current", "Construction Complete", "Approved Demo", "Permitted Demo"}
TO_STATE_PLANE = Transformer.from_crs(4326, 2249, always_xy=True)


[docs] def configuration_identity(settings): path = settings.boston_source_catalog if path is None: return {"release": RELEASE} return {"catalog_sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
[docs] def json_document(content: bytes): """Read JSON or a single JS variable assignment, never execute JavaScript.""" text = content.decode("utf-8-sig").strip() text = re.sub(r"^(?:var|let|const)\s+\w+\s*=\s*", "", text) return json.loads(text.rstrip(";\r\n "))
def _checked_archive(content): archive = zipfile.ZipFile(io.BytesIO(content)) if sum(member.file_size for member in archive.infolist()) > MAX_EXPANDED_BYTES: archive.close() raise ValueError("Boston archive exceeds the expanded size limit") return archive def _model_obj(archive, members, identifier): package_name = members.get(f"{identifier}_OBJ.zip") if package_name: with _checked_archive(archive.read(package_name)) as package: objs = [n for n in package.namelist() if n.lower().endswith(".obj")] if len(objs) != 1: raise ValueError("Building package must contain exactly one OBJ") return package.read(objs[0]) return archive.read(members[f"{identifier}.obj"])
[docs] def catalog_offset(archive, catalog, members): """Resolve the common translation from paired source footprint/OBJ bounds. Historical Boston tile-frame metadata can contradict the model coordinates. Require agreement of three independent complete model extents within 0.25 ft; do not move buildings independently or infer scale/rotation from a picture. """ candidates = [] identifiers = [] for feature in catalog["features"]: attributes = feature["properties"] if attributes.get("Model_LOD") not in {1, "1"}: continue identifier = str(attributes["Model_ID"]) try: obj = _model_obj(archive, members, identifier) points = np.asarray( [ list(map(float, line.split()[1:4])) for line in obj.decode("utf-8-sig").splitlines() if line.startswith("v ") ] ) footprint = transform(TO_STATE_PLANE.transform, shape(feature["geometry"])) target = np.asarray(footprint.bounds) original = np.r_[points[:, :2].min(axis=0), points[:, :2].max(axis=0)] delta = target - original candidate = np.round((delta[:2] + delta[2:]) / 2) if np.max(np.abs(delta - np.tile(candidate, 2))) > 0.25: continue candidates.append(candidate) identifiers.append(identifier) except (KeyError, ValueError, zipfile.BadZipFile): continue if len(candidates) == 3: break if len(candidates) < 3 or not np.allclose(candidates, candidates[0], atol=0.01, rtol=0): raise ValueError("Boston catalog cannot establish a consistent model-coordinate origin") return candidates[0].tolist(), identifiers
[docs] def decode_obj(content: bytes, metadata: dict, offset_xy_ft, *, source_url: str): """Preserve OBJ faces; record normal-derived proxies, not source semantics.""" points, polygons = [], [] for line in content.decode("utf-8-sig").splitlines(): words = line.split("#", 1)[0].split() if not words: continue if words[0] == "v": if len(words) != 4: raise ValueError("Boston OBJ requires three-coordinate vertices") points.append(tuple(map(float, words[1:]))) elif words[0] == "f": face = [] for word in words[1:]: value = int(word.split("/", 1)[0]) if value == 0: raise ValueError("OBJ vertex indices cannot be zero") face.append(value - 1 if value > 0 else len(points) + value) polygons.append(face) vertices = np.asarray(points, dtype=float) if vertices.ndim != 2 or vertices.shape[1] != 3 or not np.isfinite(vertices).all(): raise ValueError("Boston OBJ has invalid coordinates") if len(offset_xy_ft) != 2 or not np.isfinite(offset_xy_ft).all(): raise ValueError("Boston coordinate offset must contain two finite values") # XY stays in ftUS (EPSG:2249); BuildingGeometry Z is always metric. vertices[:, :2] += offset_xy_ft metric = vertices * FOOT_M vertices[:, 2] *= FOOT_M triangles, kinds, omitted = [], [], 0 for polygon in polygons: faces = triangulate_surface(metric, [polygon]) omitted += not faces for face in faces: a, b, c = metric[list(face)] normal = np.cross(b - a, c - a) slope = normal[2] / np.linalg.norm(normal) kinds.append( "WallSurface" if abs(slope) < 0.1 else "RoofSurface" if slope > 0 else "GroundSurface" ) triangles.append(face) identifier = str(metadata["Model_ID"]) building = mesh_feature( identifier=identifier, source="boston", parent_id=identifier, vertices=vertices, triangles=triangles, surface_types=kinds, crs="EPSG:2249", vertical_datum="NAVD88", lod=str(metadata.get("Model_LOD", "unspecified")), attributes={ **metadata, "attribution": ATTRIBUTION, "license_url": LICENSE_URL, "source_url": source_url, "source_offset_xy_ft": list(offset_xy_ft), "source_vertical_unit": "US survey foot", "surface_semantics": "derived_from_source_face_normals_nz_threshold_0.1", "omitted_zero_area_surface_count": omitted, }, ) ground = metadata.get("Gnd_El_Ft") if ground is not None and str(ground).strip(): ground = float(ground) * FOOT_M if not math.isfinite(ground): raise ValueError("Boston source ground elevation must be finite") building.geometry.ground_elevation_m = ground building.min_height_m = max(0.0, vertices[:, 2].min() - ground) building.source_attributes["ground_reference"] = "source_Gnd_El_Ft" else: building.source_attributes["ground_reference"] = "lowest_downward_facing_source_surface" building.name = metadata.get("Name") return building
[docs] def decode_collection(content: bytes, *, domain, offset_xy_ft, source_url): """Filter existing buildings, then decode their OBJ packages without extracting ZIPs.""" records, warnings = [], [] with _checked_archive(content) as archive: catalogs = [ name for name in archive.namelist() if Path(name).name in {"ModelCatalog_geojson.js", "ModelCatalog.geojson"} ] members = {Path(name).name: name for name in archive.namelist() if not name.endswith("/")} if catalogs: catalog = json_document(archive.read(catalogs[0])) entries = [(f["properties"], shape(f["geometry"])) for f in catalog["features"]] # Validate rather than silently trusting legacy frame offsets. if len(entries) >= 3: actual_offset, anchors = catalog_offset(archive, catalog, members) if not np.allclose(actual_offset, offset_xy_ft, atol=0.01, rtol=0): raise ValueError( f"Boston supplied origin {offset_xy_ft} conflicts with model catalog " f"origin {actual_offset}; regenerate the explicit source catalog" ) else: anchors = [] else: csv_names = [name for name in archive.namelist() if name.lower().endswith(".csv")] if len(csv_names) != 1: raise ValueError("Boston archive requires a model GeoJSON or CSV catalog") rows = csv.DictReader(io.StringIO(archive.read(csv_names[0]).decode("utf-8-sig"))) entries = [(dict(row), None) for row in rows] for attributes, footprint in entries: if attributes.get("Status") not in EXISTING_STATUSES: continue if str(attributes.get("StructType", "Building")).lower() not in {"building", "bldg"}: continue if footprint is not None and not footprint.intersects(domain): continue identifier = str(attributes.get("Model_ID", "")) try: obj = _model_obj(archive, members, identifier) building = decode_obj(obj, attributes, offset_xy_ft, source_url=source_url) if catalogs: building.source_attributes["origin_validation_models"] = anchors if Polygon(building.coordinates, building.holes).intersects(domain): records.append(building) except (ValueError, KeyError, TypeError, zipfile.BadZipFile) as exc: warnings.append(f"Skipped Boston model {identifier}: {exc}") return records, warnings
[docs] class BostonProvider: def __init__(self, settings, client: httpx.AsyncClient): self.client = client self.catalog_path = settings.boston_source_catalog self._lock = asyncio.Lock() async def _download(self, url, maximum=MAX_ARCHIVE_BYTES): chunks, size = [], 0 async with self.client.stream("GET", url, timeout=120) as response: response.raise_for_status() async for chunk in response.aiter_bytes(): size += len(chunk) if size > maximum: raise ValueError("Boston download exceeds the size limit") chunks.append(chunk) return b"".join(chunks) async def _tiles(self, domain): if self.catalog_path is not None: document = json.loads(self.catalog_path.read_text()) if document.get("format") != "owrt-boston-archives-v1": raise ValueError("Unsupported Boston source catalog format") tiles = [tile for tile in document["tiles"] if box(*tile["bbox"]).intersects(domain)] return tiles, str(document["release"]) response = await self.client.get( TILE_QUERY, params={ "f": "geojson", "where": "1=1", "outFields": "*", "outSR": "4326", "geometry": ",".join(map(str, domain.bounds)), "geometryType": "esriGeometryEnvelope", "inSR": "4326", "spatialRel": "esriSpatialRelIntersects", "resultRecordCount": 1000, }, timeout=60, ) response.raise_for_status() data = response.json() if "error" in data or data.get("exceededTransferLimit"): raise ValueError("Boston tile index query failed or was truncated") tiles = [] for feature in data["features"]: attributes = feature["properties"] tile_id = attributes["tile_id"] if not re.fullmatch(r"BOS_[A-Z]+_\d+", tile_id): raise ValueError("Invalid Boston tile identifier") tiles.append( { "id": tile_id, "url": f"{DOWNLOAD_ROOT}/{tile_id}_BldgModels_OBJ.zip", "offset_xy_ft": [ attributes["MASP_X"] - attributes["BosShift_X"], attributes["MASP_Y"] - attributes["BosShift_Y"], ], } ) return tiles, RELEASE
[docs] async def buildings(self, latitude, longitude, radius_m, limit): if limit < 1 or not math.isfinite(radius_m) or not 0 < radius_m <= 5000: raise ValueError("Boston queries require a positive limit and radius at most 5000 m") if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): raise ValueError("Invalid latitude/longitude") domain = Polygon( LocalFrame.at(longitude, latitude).corners(-radius_m, -radius_m, radius_m, radius_m) ) buildings, warnings, seen = [], [], set() async with self._lock: try: tiles, release = await self._tiles(domain) if not tiles: raise ValueError("Query does not intersect the selected Boston source coverage") if len(tiles) > 64: raise ValueError("Boston query exceeds 64 tiles") for tile in tiles: if "path" in tile: path = self.catalog_path.parent / tile["path"] if path.stat().st_size > MAX_ARCHIVE_BYTES: raise ValueError("Boston local archive exceeds size limit") content = await asyncio.to_thread(path.read_bytes) else: content = await self._download(tile["url"]) digest = hashlib.sha256(content).hexdigest() if self.catalog_path is not None and digest != tile.get("sha256"): raise ValueError("Boston archive SHA-256 differs from its source catalog") records, rejected = await asyncio.to_thread( decode_collection, content, domain=domain, offset_xy_ft=tile["offset_xy_ft"], source_url=tile.get("url", tile.get("path")), ) warnings.extend(rejected) for record in records: if record.feature_id in seen: continue seen.add(record.feature_id) record.source_release = release record.source_attributes.update(source_sha256=digest, tile_id=tile["id"]) buildings.append(record) if len(buildings) >= limit: warnings.append(f"Boston building limit ({limit}) reached") break if len(buildings) >= limit: break except ( httpx.HTTPError, ValueError, KeyError, TypeError, OSError, zipfile.BadZipFile, ) as exc: raise BuildingProviderError(f"Boston building acquisition failed: {exc}") from exc return BuildingQueryResult( buildings=buildings, provider="Boston BPDA OBJ", release=release, warnings=warnings )