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:
1 parent
68d8745534
commit
63aa0adf1c
10 files changed
+476
-3
No files matched your search
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user