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.
This commit is contained in:
zwitschi committed 2026-11-08 16:31:29 +01:00
1 parent 68d8745534
commit 63aa0adf1c
10 files changed
+476 -3

No files matched your search

+5
View File
@@ -42,6 +42,11 @@ Thumbs.db
.env.* .env.*
*.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) # Local, private reference material — never committed (see Docs/reference-notes-hollywood.md)
/Art/reference/ /Art/reference/
/Docs/data-inventory.md /Docs/data-inventory.md
@@ -62,6 +62,7 @@ Python scripts in `Tools/` drive the content pipeline:
| `judge_screenshot.py` | Vision-model pre-check (optional) | | `judge_screenshot.py` | Vision-model pre-check (optional) |
| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification | | `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`) | | `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). 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).
+1
View File
@@ -201,6 +201,7 @@ Python scripts in `Tools/` drive the content pipeline:
| `judge_screenshot.py` | Vision-model pre-check (optional) | | `judge_screenshot.py` | Vision-model pre-check (optional) |
| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification | | `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`) | | `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). Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs).
+1
View File
@@ -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 | | `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 | | `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 `<asset>.geo.json` sidecars: position, provenance, conversion | | `asset_metadata.py` | Writes and audits `<asset>.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 ## Unit boundary
+7 -2
View File
@@ -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 rule and the "height tag beats levels beats guess" order are the parts that
actually decide what the blockout should render. actually decide what the blockout should render.
""" """
if tags.get("building") == "roof" or tags.get("amenity") == "shelter": # Not building volumes: elevated roof/canopy structures and transit shelters.
kind = "roof" if tags.get("building") == "roof" else "shelter" # 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})" return 0.0, f"not a building volume ({kind})"
if tags.get("height"): if tags.get("height"):
return float(tags["height"]), "height tag" return float(tags["height"]), "height tag"
+317
View File
@@ -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())
+20
View File
@@ -359,3 +359,23 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
45 cm MaxStepHeight, so not blocking. 45 cm MaxStepHeight, so not blocking.
- Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM - Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM
and photos can't resolve finer). 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.
+1 -1
View File
@@ -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`) ### 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. - [ ] 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. - [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. - [ ] 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.
+6
View File
@@ -37,6 +37,12 @@ class ClassifyHeightTest(unittest.TestCase):
self.assertEqual(height, 0.0) self.assertEqual(height, 0.0)
self.assertIn("not a building volume (roof)", source) 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): def test_transit_shelter_is_not_a_building_volume(self):
height, source = bhr.classify_height( height, source = bhr.classify_height(
{"building": "yes", "amenity": "shelter", "shelter_type": "public_transport"}) {"building": "yes", "amenity": "shelter", "shelter_type": "public_transport"})
+117
View File
@@ -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()