geo: one tool directory, one unit boundary, per-asset provenance

Geo/tools/ is now every geographic script, with Geo/README.md documenting each
one; the Blender-side builders moved beside their outputs in Art/blender/*/ and
import their helpers from Geo/tools/osm_common.py.

That module gains to_unreal_local() and to_blender_local(): the only place in the
repository that multiplies metres by 100, so a second silent scale cannot be
introduced. block_height_report.py adds height provenance for the block — 3 of
15 buildings have no height data and render at a placeholder 6.4 m.
asset_metadata.py writes and audits <asset>.geo.json sidecars; four now exist
(hero-facade, terrain, street, blockout) and the audit recomputes each centre from
the origin, failing anything that disagrees.
This commit is contained in:
zwitschi committed 2026-11-07 12:28:26 +01:00
1 parent 5e5e5ae9b4
commit 386d4bee78
26 files changed
+471 -57

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Probe ground elevation across the demo block with the USGS 3DEP point service.
Usage:
Geo/tools/elevation_probe.py Geo/raw/osm-api-map-2026-11-06.osm
Samples a grid along the boulevard (centreline and both sidewalk centrelines),
the hero footprint corners, and the crossing midpoints, then reports the relief
across the demo and whether a separate elevation source is needed.
The decision rule follows `AGENTS.md`: acquire separately licensed elevation data
unless the DEM shows less than 1 m of height variation across the demo bounds.
Writes the raw responses to `Geo/raw/epqs-<date>.json` so the numbers can be
re-checked without re-querying. Service: USGS Elevation Point Query Service,
3DEP 1 m seamless DEM (CONUS). Standard library only.
"""
import argparse
import concurrent.futures
import datetime
import json
import os
import time
import urllib.parse
import urllib.request
import osm_common as oc
EPQS = "https://epqs.nationalmap.gov/v1/json"
UA = "gig-line-geo/0.1 (local development, elevation check)"
def query(lat: float, lon: float, retries: int = 5) -> dict:
params = urllib.parse.urlencode(
{"x": f"{lon:.7f}", "y": f"{lat:.7f}", "wkid": "4326", "units": "Meters", "includeDate": "false"}
)
url = f"{EPQS}?{params}"
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=40) as resp:
payload = json.loads(resp.read().decode("utf-8"))
value = payload.get("value")
if value in (None, "", "No Data"):
raise ValueError(f"no elevation returned: {payload}")
return {
"lat": round(lat, 7),
"lon": round(lon, 7),
"elevation_m": float(value),
"raster_id": payload.get("rasterId"),
"resolution_m": payload.get("resolution"),
}
except Exception as exc: # noqa: BLE001
if attempt == retries - 1:
# a single dead point must not kill the whole probe
print(f" ! no elevation at {lat:.6f},{lon:.6f}: {exc}", flush=True)
return {"lat": round(lat, 7), "lon": round(lon, 7), "elevation_m": None,
"raster_id": None, "resolution_m": None}
time.sleep(3 * (attempt + 1))
return {}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("osm")
ap.add_argument("--blvd", default="Hollywood Boulevard")
ap.add_argument("--west", default="North Orange Drive")
ap.add_argument("--east", default="North Highland Avenue")
ap.add_argument("--hero", type=int, default=424024147, help="hero building OSM way id")
ap.add_argument("--workers", type=int, default=4, help="parallel requests; the service is slow")
ap.add_argument("--pause", type=float, default=0.0)
args = ap.parse_args()
nodes, ways = oc.load(args.osm)
line, lat0, lon0, lengths, block_len = oc.block_extent(nodes, ways, args.blvd, args.west, args.east)
samples = []
grid = [block_len * i / 4 for i in range(5)]
for s in grid:
for label, offset in (("south sidewalk", -12.6), ("centreline", 0.0), ("north sidewalk", 13.6)):
lat, lon = oc.point_at(line, lengths, s, offset, lat0, lon0)
samples.append({"label": f"s={s:5.1f} m {label}", "lat": lat, "lon": lon})
hero_refs = next((refs for wid, _, refs in ways if str(wid) == str(args.hero)), None)
if hero_refs:
hero_pts = oc.way_points(nodes, hero_refs)
lats = [p[0] for p in hero_pts]
lons = [p[1] for p in hero_pts]
corners = [
("near NW", min(lats), min(lons)),
("near NE", min(lats), max(lons)),
("far SW", max(lats), min(lons)),
("far SE", max(lats), max(lons)),
]
for label, lat, lon in corners:
samples.append({"label": f"hero {label}", "lat": lat, "lon": lon})
for wid, tags, refs in ways:
if tags.get("footway") != "crossing":
continue
pts = oc.way_points(nodes, refs)
if not pts:
continue
mid = (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts))
dist, s, _ = oc.project(mid, line, lat0, lon0, lengths)
# only crossings of THIS boulevard, not side-street crossings inside the window
if 0 <= s <= block_len and dist <= 4.0:
samples.append({"label": f"crossing way {wid} at s={s:.0f} m", "lat": mid[0], "lon": mid[1]})
def fetch(sample):
r = query(sample["lat"], sample["lon"])
r["label"] = sample["label"]
return r
started = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
futures = {pool.submit(fetch, s): s for s in samples}
results = []
for done, future in enumerate(concurrent.futures.as_completed(futures), start=1):
r = future.result()
results.append(r)
value = "no data" if r["elevation_m"] is None else f"{r['elevation_m']:7.2f} m"
print(f" [{done:02d}/{len(samples)}] {r['label']:28} {value:>10}", flush=True)
if args.pause:
time.sleep(args.pause)
order = {s["label"]: i for i, s in enumerate(samples)}
results.sort(key=lambda r: order.get(r["label"], 999))
print(f"query time {time.time() - started:.0f} s for {len(samples)} samples")
walkable = [r for r in results if r["elevation_m"] is not None and not r["label"].startswith("hero")]
building = [r for r in results if r["elevation_m"] is not None and r["label"].startswith("hero")]
values = [r["elevation_m"] for r in walkable]
lo, hi = min(values), max(values)
relief = hi - lo
print("\nsamples by group (walkable surfaces only below; building interiors are diagnostics):")
for key in ("crossing", "hero"):
group = [r for r in results if r["elevation_m"] is not None and key in r["label"]]
if group:
g = [r["elevation_m"] for r in group]
print(f" {key:12} n={len(group):2} min {min(g):.2f} max {max(g):.2f} spread {max(g)-min(g):.2f} m")
print("\ncross-slope check (south sidewalk minus north sidewalk):")
for s in [block_len * i / 4 for i in range(5)]:
south = next(r for r in results if r["label"] == f"s={s:5.1f} m south sidewalk")
north = next(r for r in results if r["label"] == f"s={s:5.1f} m north sidewalk")
drop = south["elevation_m"] - north["elevation_m"]
print(f" s={s:5.1f} m: south {south['elevation_m']:.2f} m, north {north['elevation_m']:.2f} m, "
f"difference {drop:+.2f} m over ~26 m")
if building:
print("\nwarning: points inside the hero footprint are not ground reference; 3DEP can return")
print(" building or void values there. They are excluded from the relief figure.")
print(f"\nelevation range on walkable surfaces: {lo:.2f} to {hi:.2f} m, relief {relief:.2f} m")
first = next(r for r in walkable)
print(f"source: USGS 3DEP point service, raster {first['raster_id']}, resolution {first['resolution_m']} m")
if relief < 1.0:
print("decision: relief under 1 m, a separate elevation source is NOT required (AGENTS.md rule)")
else:
print("decision: relief at or above 1 m, a separately licensed elevation source IS required")
print("datum: 3DEP CONUS heights are orthometric NAVD88; treat absolute values as such, not ellipsoidal")
today = datetime.date.today().isoformat()
out_path = os.path.join("Geo", "raw", f"epqs-{today}.json")
os.makedirs(os.path.dirname(out_path), exist_ok=True)
payload = {
"service": EPQS,
"processed_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"source_dem": "USGS 3DEP seamless 1 m (CONUS)",
"vertical_datum": "NAVD88 orthometric (3DEP CONUS)",
"query": {
"blvd": args.blvd,
"west": args.west,
"east": args.east,
"hero_way": args.hero,
"block_len_m": round(block_len, 2),
},
"summary": {"min_m": lo, "max_m": hi, "relief_m": round(relief, 3), "samples": len(results)},
"samples": results,
}
with open(out_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
print(f"wrote {out_path}")
if __name__ == "__main__":
main()