#!/usr/bin/env python3 """Report which block buildings have real height data and which are guesses. Usage: Geo/tools/block_height_report.py Geo/raw/osm-api-map-2026-11-06.osm Height evidence, in priority order: 1. the OSM `height` tag (a direct measurement); 2. an explicit override in `HEIGHT_OVERRIDES` (currently Mapbox-derived — see below); 3. `building:levels` x `LEVEL_HEIGHT`; 4. a flagged placeholder guess. `LEVEL_HEIGHT` is 3.1 m, not the 3.2 m first assumed: cross-checking every block building against Mapbox's building data (`Geo/tools/mapbox_buildings.py`) showed our `levels x 3.2 m` figure running 0.6 m high per six levels, i.e. 3.1 m per level. That calibration was adopted on 2026-11-09. `HEIGHT_OVERRIDES` carries the few buildings where Mapbox disagrees with a guess; each entry records why, so the number is not orphaned from its evidence. This tool lists every building in the demo bounds with its height source, so the substitutions are visible and measurable rather than buried in the model. Exit code is 0 either way; the count of guessed buildings is the deliverable. """ import argparse import math import sys import osm_common as oc LEVEL_HEIGHT = 3.1 # Per-way height overrides, applied after the OSM height tag and before the # levels/guess derivation. Keep `reason` short but specific: a height without its # evidence is a guess wearing a costume. HEIGHT_OVERRIDES = { # Rear outbuildings tagged only `building=yes`/`apartments`; the two-level # fallback rendered them as 6.4 m blocks. Mapbox's building layer gives 3.0 m # for the same footprints (single storey plus parapet), adopted 2026-11-09. 1490910971: (3.0, "Mapbox building layer: 3.0 m (single storey)"), 1492058875: (3.0, "Mapbox building layer: 3.0 m (single storey)"), 1492747608: (3.0, "Mapbox building layer: 3.0 m (single storey)"), } def classify_height(tags, level_height=LEVEL_HEIGHT, way_id=None): """Return (height_m, source) for one OSM way's tags. Extracted from `main()` so the classification is testable: the roof/shelter rule and the "height tag beats levels beats guess" order are the parts that actually decide what the blockout should render. """ # Not building volumes: elevated roof/canopy structures and transit shelters. # Found by cross-checking against Mapbox's building data # (Geo/tools/mapbox_buildings.py), which returns roof/canopy/shelter for the # same footprints instead of a storey count. building = tags.get("building") if building in ("roof", "canopy") or tags.get("amenity") == "shelter": kind = building if building in ("roof", "canopy") else "shelter" return 0.0, f"not a building volume ({kind})" if tags.get("height"): return float(tags["height"]), "height tag" if way_id is not None and int(way_id) in HEIGHT_OVERRIDES: height, reason = HEIGHT_OVERRIDES[int(way_id)] return height, f"override: {reason}" if tags.get("building:levels"): return (float(tags["building:levels"]) * level_height, f"levels x {level_height} m") return 2 * level_height, f"GUESS: default 2 levels x {level_height} m" def main(): ap = argparse.ArgumentParser() ap.add_argument("osm") args = ap.parse_args() nodes, ways = oc.load(args.osm) line, lat0, lon0, lengths, block_len = oc.block_extent( nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue") def in_block(pts): if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]: return False return True rows = [] for wid, tags, refs in ways: if "building" not in tags: continue pts = oc.way_points(nodes, refs) if not pts or not in_block(pts): continue height, source = classify_height(tags, way_id=wid) nearest = min(oc.project(p, line, lat0, lon0, lengths)[0] for p in pts) side = "S" if oc.project(pts[0], line, lat0, lon0, lengths)[2] < 0 else "N" rows.append((side, nearest, wid, tags.get("name", "") or tags.get("addr:housenumber", ""), height, source)) rows.sort(key=lambda r: (r[0], r[1])) guessed = [r for r in rows if r[5].startswith("GUESS")] print(f"{len(rows)} buildings in the block, {len(guessed)} with a guessed height\n") for side, setback, wid, name, height, source in rows: flag = (" <-- guessed" if source.startswith("GUESS") else (" <-- not a building volume" if source.startswith("not a building") else "")) print(f"{side} way {wid:>10} setback {setback:5.1f} m height {height:5.1f} m " f"({source:26}) {name[:34]}{flag}") if guessed: print("\nGuessed heights to replace from the reference photography:") for side, setback, wid, name, height, source in guessed: print(f" {side} way {wid} {name}") return 0 if __name__ == "__main__": sys.exit(main())