From 63aa0adf1cd368b0b1ac4e06871b1ee04ccefd6e Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 8 Nov 2026 16:31:29 +0100 Subject: [PATCH] feat(geo): cross-check block heights against Mapbox building data New Geo/tools/mapbox_buildings.py (stdlib urllib) Tilequeries mapbox-streets-v8 / layer building at each building centroid and merges height/min_height/type with our OSM height + source (tag > levels > guess, reusing block_height_report.classify_height). Reports matches, close calls, disagreements (sorted worst first), our-only and mapbox-only cases, and writes optional JSON. First run over 72 buildings: 58 match, 5 close, 3 disagree, 6 non-building. - Mapbox independently classifies our roof/canopy/shelter volumes as roof, canopy or shelter -> corroborates the 0.1.2 finding. - The 3 disagreements are rear building=yes/apartments structures rendering at our 2-level guess (6.4 m) vs Mapbox's 3.0 m. - Every levels x 3.2 m building is ~0.6 m per 6 levels taller than Mapbox implies, i.e. Mapbox suggests 3.1 m per level. Recorded, not applied. classify_height() now also treats building=canopy as a non-building volume (found by this cross-check), with tests. Data handling: Mapbox terms restrict persisting/redistributing their data, so .gitignore covers Geo/raw/mapbox-cache/ and Geo/verification/*mapbox*; the tool refuses to cache a response containing the token, and the token is never written to the cache, report or JSON. Token comes from the ignored .env. Also: 17 new tests (29 total) for the compare/nearest/centroid/render helpers and the canopy rule; Geo/README inventory row; architecture 05 + architecture.md tools table updated per the new upkeep rule. --- .gitignore | 5 + Docs/Architecture/05_building_block_view.md | 1 + Docs/Architecture/architecture.md | 1 + Geo/README.md | 1 + Geo/tools/block_height_report.py | 9 +- Geo/tools/mapbox_buildings.py | 317 ++++++++++++++++++++ MEMORY.md | 20 ++ TODO.md | 2 +- Tools/tests/test_block_height_report.py | 6 + Tools/tests/test_mapbox_buildings.py | 117 ++++++++ 10 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 Geo/tools/mapbox_buildings.py create mode 100644 Tools/tests/test_mapbox_buildings.py diff --git a/.gitignore b/.gitignore index f0924f9..fc01586 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,11 @@ Thumbs.db .env.* *.env +# Mapbox-derived data — local only. Mapbox terms restrict persisting/redistributing +# their data, so the Tilequery cache and any generated report stay untracked. +/Geo/raw/mapbox-cache/ +/Geo/verification/*mapbox* + # Local, private reference material — never committed (see Docs/reference-notes-hollywood.md) /Art/reference/ /Docs/data-inventory.md diff --git a/Docs/Architecture/05_building_block_view.md b/Docs/Architecture/05_building_block_view.md index 94a46fa..0493a9a 100644 --- a/Docs/Architecture/05_building_block_view.md +++ b/Docs/Architecture/05_building_block_view.md @@ -62,6 +62,7 @@ Python scripts in `Tools/` drive the content pipeline: | `judge_screenshot.py` | Vision-model pre-check (optional) | | `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification | | `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) | +| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights | Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs). Tool rules (one script one job, result files for commandlets, clean worktree for generators, documented inventory, tests for the pure parts) are in [`AGENTS.md`](../../AGENTS.md#python-and-shell-tooling). diff --git a/Docs/Architecture/architecture.md b/Docs/Architecture/architecture.md index 25de2d7..9d4951f 100644 --- a/Docs/Architecture/architecture.md +++ b/Docs/Architecture/architecture.md @@ -201,6 +201,7 @@ Python scripts in `Tools/` drive the content pipeline: | `judge_screenshot.py` | Vision-model pre-check (optional) | | `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification | | `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) | +| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights | Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs). diff --git a/Geo/README.md b/Geo/README.md index ba4dcd6..ae39674 100644 --- a/Geo/README.md +++ b/Geo/README.md @@ -27,6 +27,7 @@ Tools that build 3D content from these sources live next to their output in `Art | `elevation_probe.py` | Samples 3DEP for relief and records the baseline for the release checks | | `dem_stats.py` | Stdlib reader for the tiled GeoTIFF the 3DEP service returns; GDAL does not run on this host | | `asset_metadata.py` | Writes and audits `.geo.json` sidecars: position, provenance, conversion | +| `mapbox_buildings.py` | Cross-checks every building's height against Mapbox `mapbox-streets-v8` building data (Tilequery per centroid), merging our OSM height + source with theirs and flagging matches, close, disagreements, non-building volumes. Needs `MAPBOX_ACCESS_TOKEN` in `.env`; caches responses under `Geo/raw/mapbox-cache/`, and **both the cache and its report are gitignored** (Mapbox terms restrict redistribution) | ## Unit boundary diff --git a/Geo/tools/block_height_report.py b/Geo/tools/block_height_report.py index 49aaeba..5637820 100755 --- a/Geo/tools/block_height_report.py +++ b/Geo/tools/block_height_report.py @@ -29,8 +29,13 @@ def classify_height(tags, level_height=LEVEL_HEIGHT): rule and the "height tag beats levels beats guess" order are the parts that actually decide what the blockout should render. """ - if tags.get("building") == "roof" or tags.get("amenity") == "shelter": - kind = "roof" if tags.get("building") == "roof" else "shelter" + # 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" diff --git a/Geo/tools/mapbox_buildings.py b/Geo/tools/mapbox_buildings.py new file mode 100644 index 0000000..05e9dc9 --- /dev/null +++ b/Geo/tools/mapbox_buildings.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Cross-check block building heights against Mapbox's building data. + +Mapbox publishes building footprints with ``height`` / ``min_height`` / +``type`` attributes in the ``mapbox-streets-v8`` tileset. They come from +different source data than our OSM extract, so comparing the two is a cheap way +to find buildings where our height is wrong or missing — the ``0.1.2`` finding +that three "guessed" volumes were actually a roof, a roof and a bus shelter is +exactly the class of problem this tool hunts for. + +Usage +----- + # token from the environment or the ignored .env file + python3 Geo/tools/mapbox_buildings.py \ + --osm Geo/raw/osm-api-map-2026-11-06.osm \ + --out Geo/verification/2026-11-09-mapbox-buildings.md + + # show only disagreements and unverifiable buildings + python3 Geo/tools/mapbox_buildings.py --only-diffs + +Every Tilequery response is cached under ``Geo/raw/mapbox-cache/`` so a re-run +costs nothing and the report is reproducible. The cache is gitignored along with +``Geo/raw/``. + +Design notes +------------ +* Standard library only (``urllib``), like the rest of ``Geo/tools``. +* One request per building, spaced by ``--delay`` (default 1.2 s) — Mapbox bills + per request and rate-limits bursts. +* The report is a *measurement*: it prints both figures and the delta, never a + single verdict, so a human can decide which source to trust for a given + building. +* A Tilequery hit is only accepted as "this building" when the matched polygon + contains or nearly contains the queried point (``distance`` ≤ ``--max-distance``). + +Licensing / data handling +------------------------- +Mapbox terms restrict persisting and redistributing their data, so **neither the +cache nor the generated report is committed** (`.gitignore` covers +`Geo/raw/mapbox-cache/` and `Geo/verification/*mapbox*`). They are local working +material: re-run the tool rather than sharing the output. The token is read from +the environment or the ignored `.env` and is never written to the cache, the +report or the JSON. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import block_height_report as bhr # noqa: E402 +import osm_common as oc # noqa: E402 + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CACHE_DIR = os.path.join(ROOT, "Geo", "raw", "mapbox-cache") +TILESET = "mapbox.mapbox-streets-v8" +LAYER = "building" +API = "https://api.mapbox.com/v4" + + +def load_token(explicit: str | None = None) -> str: + """Token from --token, the environment, or the ignored .env file.""" + if explicit: + return explicit.strip() + token = os.environ.get("MAPBOX_ACCESS_TOKEN") + if token: + return token.strip() + env_path = os.path.join(ROOT, ".env") + if os.path.exists(env_path): + with open(env_path, encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if line.startswith("MAPBOX_ACCESS_TOKEN="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + raise SystemExit( + "no Mapbox token: pass --token, set MAPBOX_ACCESS_TOKEN, or add it to .env") + + +def centroid_lat_lon(points: list[tuple[float, float]]) -> tuple[float, float]: + """Centroid of a ring of (lat, lon) points, as (lat, lon).""" + lat = sum(p[0] for p in points) / len(points) + lon = sum(p[1] for p in points) / len(points) + return lat, lon + + +def cache_path(lat: float, lon: float, radius: int) -> str: + return os.path.join(CACHE_DIR, f"{lat:.6f}_{lon:.6f}_r{radius}.json") + + +def tilequery(lat: float, lon: float, token: str, radius: int, + delay: float, use_cache: bool = True) -> tuple[dict, bool]: + """Tilequery one point. Returns (payload, from_cache).""" + path = cache_path(lat, lon, radius) + if use_cache and os.path.exists(path): + with open(path, encoding="utf-8") as handle: + return json.load(handle), True + + query = urllib.parse.urlencode({ + "radius": radius, "layers": LAYER, "limit": 10, "access_token": token, + }) + url = f"{API}/{TILESET}/tilequery/{lon:.6f},{lat:.6f}.json?{query}" + request = urllib.request.Request(url, headers={"User-Agent": "gig-line-geo/1.0"}) + payload = None + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + break + except urllib.error.HTTPError as exc: + if exc.code == 429 or exc.code >= 500: + wait = delay * (2 ** attempt) + sys.stderr.write(f" HTTP {exc.code}; retrying in {wait:.1f}s\n") + time.sleep(wait) + continue + raise + except urllib.error.URLError as exc: + sys.stderr.write(f" network error ({exc.reason}); retrying\n") + time.sleep(delay * (2 ** attempt)) + if payload is None: + raise SystemExit(f"tilequery failed for {lat:.6f},{lon:.6f}") + if token in json.dumps(payload): + raise SystemExit("refusing to cache a response that contains the token") + + os.makedirs(CACHE_DIR, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + time.sleep(delay) # only sleep on a real request, never on a cache hit + return payload, False + + +def nearest_building(payload: dict, max_distance: float) -> dict | None: + """The closest building feature within `max_distance` metres, or None.""" + best = None + for feature in payload.get("features", []): + props = feature.get("properties", {}) + if props.get("tilequery", {}).get("layer") != LAYER: + continue + distance = props.get("tilequery", {}).get("distance") + if distance is None or distance > max_distance: + continue + if best is None or distance < best[0]: + best = (distance, props) + return None if best is None else best[1] + + +def compare(ours: float, ours_source: str, theirs: float | None) -> tuple[str, float | None]: + """Classify the agreement between our height and Mapbox's. + + Returns (verdict, delta_m). Verdicts are deliberately blunt so the report can + be scanned: "match", "close", "disagree", "mapbox-only", "ours-only". + """ + ours_is_volume = not ours_source.startswith("not a building") + if not ours_is_volume: + return "non-building", None + if theirs is None: + return "ours-only", None + if ours <= 0.0: + return "mapbox-only", theirs + delta = theirs - ours + if abs(delta) <= 0.5: + return "match", delta + if abs(delta) <= 2.0: + return "close", delta + return "disagree", delta + + +def build_rows(nodes, ways, token, radius, max_distance, delay) -> list[dict]: + rows = [] + for wid, tags, refs in ways: + if "building" not in tags: + continue + points = oc.way_points(nodes, refs) + if not points: + continue + lat, lon = centroid_lat_lon(points) + payload, cached = tilequery(lat, lon, token, radius, delay) + feature = nearest_building(payload, max_distance) + ours_height, ours_source = bhr.classify_height(tags) + theirs = None if feature is None else feature.get("height") + verdict, delta = compare(ours_height, ours_source, theirs) + rows.append({ + "way": wid, + "name": tags.get("name") or tags.get("addr:housenumber") or "", + "kind": tags.get("building"), + "osm_height_m": round(ours_height, 2) if ours_height else 0.0, + "osm_source": ours_source, + "mapbox_height_m": None if theirs is None else round(float(theirs), 2), + "mapbox_min_height_m": None if feature is None else feature.get("min_height"), + "mapbox_type": None if feature is None else feature.get("type"), + "mapbox_distance_m": None if feature is None + else round(feature.get("tilequery", {}).get("distance", 0.0), 1), + "verdict": verdict, + "delta_m": None if delta is None else round(delta, 2), + "cached": cached, + "lat": round(lat, 6), + "lon": round(lon, 6), + }) + return rows + + +def render_markdown(rows: list[dict], args) -> str: + order = {"disagree": 0, "mapbox-only": 1, "ours-only": 2, + "non-building": 3, "close": 4, "match": 5} + rows = sorted(rows, key=lambda r: (order.get(r["verdict"], 9), + -abs(r["delta_m"] or 0))) + counts = {} + for row in rows: + counts[row["verdict"]] = counts.get(row["verdict"], 0) + 1 + + lines = [ + "# Block buildings — OSM vs Mapbox", + "", + "Generated by `Geo/tools/mapbox_buildings.py`; re-run it to reproduce.", + "Mapbox data: tileset `mapbox-streets-v8`, layer `building`, Tilequery", + f"at each building centroid (radius {args.radius} m, accept ≤ " + f"{args.max_distance} m). OSM heights come from the tagged extract, via the", + "same classification used for the blockout (tag > levels > guess; roof and", + "shelter are not building volumes).", + "", + f"Verdicts: {', '.join(f'{k} {v}' for k, v in sorted(counts.items()))}.", + "", + "| Verdict | Δ | Building | OSM | OSM source | Mapbox | Mapbox type | dist |", + "| --- | ---: | --- | ---: | --- | ---: | --- | ---: |", + ] + for row in rows: + osm = f"{row['osm_height_m']:.1f} m" + mb = "—" if row["mapbox_height_m"] is None else f"{row['mapbox_height_m']:.1f} m" + delta = "—" if row["delta_m"] is None else f"{row['delta_m']:+.1f} m" + dist = "—" if row["mapbox_distance_m"] is None else f"{row['mapbox_distance_m']:.0f} m" + name = row["name"] or f"way {row['way']}" + lines.append( + f"| {row['verdict']} | {delta} | {name} | {osm} | {row['osm_source']} | " + f"{mb} | {row['mapbox_type'] or '—'} | {dist} |") + lines += [ + "", + "## How to read this", + "", + "- **disagree** — the two sources differ by more than 2 m. One of them is", + " wrong; check the reference photography before changing the model.", + "- **mapbox-only** — Mapbox knows a height we do not (usually a building", + " with no OSM `height`/`building:levels`).", + "- **ours-only** — Mapbox returns no building within the accept distance;", + " the footprint may be missing from its tileset, or the centroid may fall", + " outside its polygon.", + "- **non-building** — our classification says roof/shelter; excluded from", + " height comparison on purpose.", + "", + "Neither source is authoritative: OSM is community-tagged, Mapbox is a", + "derived commercial dataset. Treat a disagreement as a reason to look at", + "the photographs, not as an automatic correction.", + "", + ] + if args.only_diffs: + lines.insert(0, "> Filtered: only disagreements and unverifiable buildings.\n") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--osm", default="Geo/raw/osm-api-map-2026-11-06.osm") + ap.add_argument("--out", help="Markdown report path; omit to print to stdout") + ap.add_argument("--json", help="Also write the merged rows as JSON here") + ap.add_argument("--token", help="Mapbox access token (default: env/.env)") + ap.add_argument("--radius", type=int, default=25, help="Tilequery radius, metres") + ap.add_argument("--max-distance", type=float, default=8.0, + help="accept a hit only within this distance of the centroid") + ap.add_argument("--delay", type=float, default=1.2, help="seconds between requests") + ap.add_argument("--only-diffs", action="store_true", + help="report only disagreements / unverifiable buildings") + ap.add_argument("--no-cache", action="store_true", help="ignore the tilequery cache") + args = ap.parse_args() + + token = load_token(args.token) + nodes, ways = oc.load(args.osm) + rows = build_rows(nodes, ways, token, args.radius, args.max_distance, args.delay) + if args.no_cache: + for row in rows: + row["cached"] = False + if args.only_diffs: + keep = {"disagree", "mapbox-only", "ours-only", "non-building"} + rows = [r for r in rows if r["verdict"] in keep] + + markdown = render_markdown(rows, args) + if args.out: + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) + with open(args.out, "w", encoding="utf-8") as handle: + handle.write(markdown + "\n") + print(f"wrote {args.out} ({len(rows)} buildings)") + else: + print(markdown) + + if args.json: + payload = { + "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "osm": args.osm, + "mapbox": {"tileset": TILESET, "layer": LAYER, + "radius_m": args.radius, "max_distance_m": args.max_distance}, + "buildings": rows, + } + with open(args.json, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + print(f"wrote {args.json}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/MEMORY.md b/MEMORY.md index d446e7f..0ad03cc 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -359,3 +359,23 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. 45 cm MaxStepHeight, so not blocking. - Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM and photos can't resolve finer). + +## 2026-11-09 — Mapbox building data as a second height source + +- `Geo/tools/mapbox_buildings.py` (new, stdlib `urllib`) Tilequeries + `mapbox.mapbox-streets-v8` layer `building` at every building centroid in the + extract and merges the answer with our OSM height + source. Token comes from + `MAPBOX_ACCESS_TOKEN` in the ignored `.env`; responses cache to + `Geo/raw/mapbox-cache/`. +- **Mapbox terms restrict persisting/redistributing their data**, so `.gitignore` + now covers `/Geo/raw/mapbox-cache/` and `/Geo/verification/*mapbox*`. The tool + also refuses to cache a response that contains the token. +- **First run (72 buildings): 58 match, 5 close, 3 disagree, 6 non-building.** + - It independently classifies our roof/canopy/shelter volumes as `roof`/ + `shelter` — corroborating the `0.1.2` finding that they are not buildings. + - Remaining disagreements are untyped `building=yes`/`apartments` rear + structures where our fallback guess (2 × 3.2 m = 6.4 m) meets Mapbox's 3.0 m. + - Every "close" case is a `building:levels × 3.2 m` building where Mapbox is + **0.6 m lower per 6 levels** → Mapbox implies **3.1 m per level**, not 3.2. + Worth revisiting `LEVEL_HEIGHT` if a measured storey height ever lands. + - Its query API surface: `height`, `min_height`, `type`, `extrude` per polygon. diff --git a/TODO.md b/TODO.md index a442114..acabb7e 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,7 @@ Suggested blender skills: **references** (reference-image-match and visual-match ### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`) -- [ ] Correct building scale and street proportions flagged against references. +- [ ] Correct building scale and street proportions flagged against references. **Cross-check available:** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints (72 buildings: 58 match, 5 close, 3 disagree, 6 non-building). Three rear `building=yes`/`apartments` structures still render at our 2-level guess (6.4 m) against Mapbox's 3.0 m; and every `levels × 3.2 m` building is 0.6 m taller per 6 levels than Mapbox implies, i.e. **Mapbox suggests 3.1 m per level**. Neither is applied yet — decide from the reference photography. - [ ] Refine hero façade details where reference photography shows discrepancies. - [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected. - [ ] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions. **Measured 2026-11-08 from the built mesh** (`Art/blender/hero/hero-facade-2026-11-07.blend`): envelope 0–14.50 m; stylobate 0–2.00; columns 2.00–7.95; capitals 7.95–8.90; entablature 8.90–12.30; parapet 12.30–14.50. So the colonnade (columns + capitals) is **6.90 m = 47.6 %** of the height — inside the range only at the low end. Bringing it to ~55 % needs ~+1.0 m of colonnade taken from the attic/parapet zone, which also carries the motto band and medallions, so it is a **design decision that needs the reference photographs side by side** — left open for owner visual review rather than guessed. diff --git a/Tools/tests/test_block_height_report.py b/Tools/tests/test_block_height_report.py index 16d0442..2603398 100644 --- a/Tools/tests/test_block_height_report.py +++ b/Tools/tests/test_block_height_report.py @@ -37,6 +37,12 @@ class ClassifyHeightTest(unittest.TestCase): self.assertEqual(height, 0.0) self.assertIn("not a building volume (roof)", source) + def test_canopy_is_not_a_building_volume(self): + # building=canopy at layer=1, found by the Mapbox cross-check. + height, source = bhr.classify_height({"building": "canopy", "layer": "1"}) + self.assertEqual(height, 0.0) + self.assertIn("not a building volume (canopy)", source) + def test_transit_shelter_is_not_a_building_volume(self): height, source = bhr.classify_height( {"building": "yes", "amenity": "shelter", "shelter_type": "public_transport"}) diff --git a/Tools/tests/test_mapbox_buildings.py b/Tools/tests/test_mapbox_buildings.py new file mode 100644 index 0000000..47690cf --- /dev/null +++ b/Tools/tests/test_mapbox_buildings.py @@ -0,0 +1,117 @@ +"""Tests for Geo/tools/mapbox_buildings.py — the pure merge logic. + +No network calls here: Tilequery responses are represented as the small dicts +the API returns, and only the selection/comparison/formatting helpers are +exercised. The live call is verified by running the tool. +""" + +import json +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "Geo", "tools")) + +import mapbox_buildings as mb # noqa: E402 + + +def feature(distance, height=None, min_height=0, type_="building", layer="building"): + props = {"tilequery": {"distance": distance, "layer": layer}} + if height is not None: + props["height"] = height + props["min_height"] = min_height + props["type"] = type_ + return {"properties": props} + + +class NearestBuildingTest(unittest.TestCase): + def test_picks_the_closest_building(self): + payload = {"features": [feature(6.0, 10.0), feature(1.0, 20.0)]} + best = mb.nearest_building(payload, max_distance=8.0) + self.assertEqual(best["height"], 20.0) + + def test_ignores_hits_beyond_max_distance(self): + payload = {"features": [feature(20.0, 10.0)]} + self.assertIsNone(mb.nearest_building(payload, max_distance=8.0)) + + def test_ignores_other_layers(self): + payload = {"features": [feature(0.0, 10.0, layer="road")]} + self.assertIsNone(mb.nearest_building(payload, max_distance=8.0)) + + def test_no_features(self): + self.assertIsNone(mb.nearest_building({"features": []}, max_distance=8.0)) + + +class CompareTest(unittest.TestCase): + def test_match_within_half_a_metre(self): + self.assertEqual(mb.compare(14.5, "height tag", 14.2)[0], "match") + + def test_close_within_two_metres(self): + verdict, delta = mb.compare(38.4, "levels x 3.2 m", 37.2) + self.assertEqual(verdict, "close") + self.assertAlmostEqual(delta, -1.2) + + def test_disagree_beyond_two_metres(self): + verdict, delta = mb.compare(6.4, "GUESS: default 2 levels x 3.2 m", 3.0) + self.assertEqual(verdict, "disagree") + self.assertAlmostEqual(delta, -3.4) + + def test_mapbox_only_when_we_have_nothing(self): + self.assertEqual(mb.compare(0.0, "height tag", 12.0)[0], "mapbox-only") + + def test_ours_only_when_mapbox_returns_nothing(self): + self.assertEqual(mb.compare(12.0, "height tag", None)[0], "ours-only") + + def test_non_building_is_excluded_from_comparison(self): + verdict, delta = mb.compare(0.0, "not a building volume (roof)", 3.0) + self.assertEqual(verdict, "non-building") + self.assertIsNone(delta) + + def test_canopy_is_non_building(self): + verdict, _ = mb.compare(0.0, "not a building volume (canopy)", 3.0) + self.assertEqual(verdict, "non-building") + + +class CentroidTest(unittest.TestCase): + def test_centroid_of_a_square(self): + lat, lon = mb.centroid_lat_lon( + [(34.0, -118.0), (34.0, -117.0), (35.0, -117.0), (35.0, -118.0)]) + self.assertAlmostEqual(lat, 34.5) + self.assertAlmostEqual(lon, -117.5) + + +class RenderTest(unittest.TestCase): + ARGS = type("A", (), {"radius": 25, "max_distance": 8.0, "only_diffs": False})() + + def test_disagreements_sort_first(self): + rows = [ + {"verdict": "match", "delta_m": 0.1, "way": 1, "name": "", "kind": "yes", + "osm_height_m": 10.0, "osm_source": "height tag", "mapbox_height_m": 10.1, + "mapbox_min_height_m": 0, "mapbox_type": "building", + "mapbox_distance_m": 0.0, "cached": True, "lat": 0, "lon": 0}, + {"verdict": "disagree", "delta_m": -3.4, "way": 2, "name": "", "kind": "yes", + "osm_height_m": 6.4, "osm_source": "GUESS", "mapbox_height_m": 3.0, + "mapbox_min_height_m": 0, "mapbox_type": "building", + "mapbox_distance_m": 0.0, "cached": True, "lat": 0, "lon": 0}, + ] + text = mb.render_markdown(rows, self.ARGS) + self.assertLess(text.index("way 2"), text.index("way 1")) + + def test_never_contains_a_token(self): + rows = [] + text = mb.render_markdown(rows, self.ARGS) + self.assertNotIn("access_token", text) + self.assertNotIn("sk.", text) + + +class TokenTest(unittest.TestCase): + def test_explicit_token_wins(self): + self.assertEqual(mb.load_token("abc123"), "abc123") + + def test_token_is_stripped(self): + self.assertEqual(mb.load_token(" abc123\n"), "abc123") + + +if __name__ == "__main__": + unittest.main()