PROCITY/pipeline/build_towns.py
m3ultra 10db373e67 Lane E R18 (v4.0-alpha): the other 4 towns' road geometry (data-only) + Overpass retry
Newtown/Fremantle/Bendigo/Castlemaine now schema v2 with roads[] (1086/622/1552/397 real streets),
same fidelity cut (street tier only). Data-only: only katoomba gates the round; A hardens against these.
build_towns.py: added Overpass retry/backoff (public API 504s under load — hit one mid-run).

The poster-clearance finding is SYSTEMATIC: it reproduces on all 5 real-road towns, every seed (23
checks, -0.9 to -4.7m), confirming it's plan-level poster placement (A's pickVenues/POSTER_CLEAR), not
town data. Scoping holds: only the real/* v2 path fails; synthetic + fixtures + classic + default boots
stay green. A's one fix generalizes to all five. ODbL: raw roads committed, SOURCES.md current.

-> Lane A: all 5 towns' roads live; the systematic poster finding is yours to resolve + re-pin goldens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:32:10 +10:00

274 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""build_towns.py — the real-map scout (ROUND17 ledger #6). A port of thriftgod `build_city()`'s
Overpass route to PER-TOWN real Australian caches in the Lane-A `procity-town-cache/1` contract.
python3 pipeline/build_towns.py # build every town (cached raw reused, no re-fetch)
python3 pipeline/build_towns.py katoomba_real # one town
python3 pipeline/build_towns.py --refetch # ignore cached raw, re-hit Overpass (deliberate)
Pipeline per town: bounded Overpass query → RAW response cached in-repo (`web/assets/towns/_raw/`, so
re-runs NEVER re-fetch) → dedupe (OSM maps a shop as node AND way) → suburb fill (untagged adopt the
nearest tagged neighbour) → parody the big trademarked charity/franchise names (indie shops keep their
real OSM name) → map OSM `shop=*` to a registry SHOP_TYPE → `web/assets/towns/<key>.json`.
DATA DISCIPLINE (ledger #6): deterministic (same raw → byte-identical cache; shops sorted by OSM id,
no RNG); bounded queries (a cache is ONE compact town, span < 5 km — the mega-strip risk); ODbL
attribution ships IN every cache (`license`/`attribution`) + `SOURCES.md`. Hours are NOT cached — the
registry supplies them at plan time (contract shop = {id,name,type,lat,lon,suburb}). Overpass is an
outward-facing fetch (John-authorized scout, once per town, raw cached).
"""
import os, sys, json, re, math, time, datetime, urllib.request, urllib.parse, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOWNS_DIR = os.path.join(ROOT, "web", "assets", "towns")
RAW_DIR = os.path.join(TOWNS_DIR, "_raw")
OVERPASS = "https://overpass-api.de/api/interpreter"
UA = "PROCITY-townscout/0.1 (real-map scout; OpenStreetMap ODbL data)"
# Compact real AU towns: center (lat,lon) + query box span (km). `katoomba_real` is the fixture
# comparison (vs the hand-made `katoomba` in osm_fixture.js). Spans kept tight so each is ONE town.
TOWNS = {
"katoomba_real": {"town": "Katoomba", "center": (-33.7145, 150.3120), "span_km": 3.0},
"newtown_real": {"town": "Newtown", "center": (-33.8985, 151.1790), "span_km": 2.6},
"fremantle_real": {"town": "Fremantle", "center": (-32.0555, 115.7480), "span_km": 2.6},
"bendigo_real": {"town": "Bendigo", "center": (-36.7580, 144.2810), "span_km": 3.4},
"castlemaine_real": {"town": "Castlemaine", "center": (-37.0640, 144.2170), "span_km": 3.0},
}
# OSM shop tags we scout (on-theme: the game's secondhand/media/hobby world) → registry SHOP_TYPE.
SHOP_RE = "charity|second_hand|music|antiques|books|toys|games|video_games|pawnbroker"
TYPE_MAP = {
"books": "book", "music": "record", "toys": "toy", "games": "toy",
"video_games": "video", "video": "video", "pawnbroker": "pawn",
"charity": "opshop", "second_hand": "opshop", "antiques": "opshop",
}
# ── roads[] (schema v2, ROUND18 REAL ROADS) ─────────────────────────────────────────────────────
# We FETCH every OSM highway tier A's ROAD_KIND maps (→ raw cache, full provenance / beta-ready) but
# EMIT only the town STREET graph. Fidelity cut (charter risk #4, flagged to A who owns the knob):
# the service/track/footway/path/steps/cycleway tier is EXCLUDED — in Katoomba that's 1461 of 2506
# ways (Blue-Mountains bushwalking tracks, sidewalks, stairs, driveways, parking aisles), noise for a
# town street graph and a way-count A's lift would build 2500+ edges from. The full set stays in the
# raw response if A wants a tier back. `kind` ships as the raw OSM highway class; A maps it (ROAD_KIND).
ROAD_HW = ("motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|road"
"|service|track|pedestrian|footway|path|steps|cycleway")
STREET_KINDS = {"motorway", "trunk", "primary", "secondary", "tertiary", "unclassified",
"residential", "living_street", "road", "pedestrian"}
# The big trademarked charity/franchise chains get loving parodies (ported from thriftgod, John's call
# for the live game); independent shops keep their real OSM names (factual ODbL data).
PARODY = [
(r"salvos( stores?)?|(the )?salvation army", "Slave-ohs"),
(r"lifeline", "Tightrope"),
(r"rspca", "RSYMCA"),
(r"vinnies|st\.? ?vincent('?s| de paul)?( society)?|svdp", "Sinnies"),
(r"(australian )?red cross", "Red Floss"),
(r"save the children", "Shave the Children"),
(r"anglicare", "Tanglecare"),
(r"uniting ?care|unitingcare|uniting", "Unknitting"),
(r"good sammys?|good samaritans?", "Okay Sammys"),
(r"mission australia", "Mission Improbable"),
(r"brotherhood of st\.? ?laurence", "Brotherhood of St Lozenge"),
(r"cash ?converters?", "Cashtheists"),
]
def parodize(name):
for pat, rep in PARODY:
name = re.sub(pat, rep, name, flags=re.I)
return name
def punct_blind(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
def bbox(center, span_km):
lat, lon = center
dlat = (span_km / 2.0) / 111.0
dlon = (span_km / 2.0) / (111.0 * math.cos(math.radians(lat)))
return {"minLat": lat - dlat, "minLon": lon - dlon, "maxLat": lat + dlat, "maxLon": lon + dlon}
def haversine_m(a, b):
R = 6371000.0
p1, p2 = math.radians(a[0]), math.radians(b[0])
dp = math.radians(b[0] - a[0]); dl = math.radians(b[1] - a[1])
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(min(1.0, math.sqrt(h)))
def overpass_post(q):
"""POST an Overpass query with retry/backoff — the public API 504s / times out under load."""
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": UA})
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=180) as r:
return json.load(r).get("elements", [])
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
if attempt == 3:
raise
wait = 15 * (attempt + 1)
print(f" Overpass {getattr(e, 'code', type(e).__name__)} — retry {attempt + 1}/3 in {wait}s")
time.sleep(wait)
def fetch_raw(key, bb, refetch):
"""Return the raw Overpass elements list, cached in-repo so re-runs never re-fetch."""
os.makedirs(RAW_DIR, exist_ok=True)
path = os.path.join(RAW_DIR, key + ".overpass.json")
if os.path.exists(path) and not refetch:
w = json.load(open(path))
return w["elements"], w.get("fetchedAt")
q = (f'[out:json][timeout:120];'
f'nwr["shop"~"^({SHOP_RE})$"]["name"]'
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
f'out center;')
print(f" [{key}] fetching Overpass (bounded {bb['minLat']:.3f},{bb['minLon']:.3f}{bb['maxLat']:.3f},{bb['maxLon']:.3f})")
els = overpass_post(q)
fetched = datetime.date.today().isoformat()
json.dump({"fetchedAt": fetched, "endpoint": OVERPASS, "query": q, "bbox": bb, "elements": els},
open(path, "w"), indent=1)
time.sleep(2) # be a good Overpass citizen between towns
return els, fetched
def fetch_roads_raw(key, bb, refetch):
"""Return the raw OSM highway ways (with geometry) for the town bbox, cached in-repo like the shops."""
os.makedirs(RAW_DIR, exist_ok=True)
path = os.path.join(RAW_DIR, key + ".roads.overpass.json")
if os.path.exists(path) and not refetch:
return json.load(open(path))["elements"]
q = (f'[out:json][timeout:120];'
f'way["highway"~"^({ROAD_HW})$"]'
f'({bb["minLat"]:.5f},{bb["minLon"]:.5f},{bb["maxLat"]:.5f},{bb["maxLon"]:.5f});'
f'out geom;')
print(f" [{key}] fetching Overpass roads (highway ways, bounded)")
ways = overpass_post(q)
json.dump({"fetchedAt": datetime.date.today().isoformat(), "endpoint": OVERPASS, "query": q,
"bbox": bb, "elements": ways}, open(path, "w"), indent=1)
time.sleep(2)
return ways
def roads_from_ways(ways):
"""OSM highway ways → roads[] (contract v2): {id, kind, name?, pts:[[lat,lon],…]}. Emit only the
STREET tier; ship full geometry (A simplifies with DouglasPeucker); deterministic (id-sorted)."""
roads = []
for w in ways:
hwy = w.get("tags", {}).get("highway")
geom = w.get("geometry")
if hwy not in STREET_KINDS or not isinstance(geom, list) or len(geom) < 2:
continue
pts = [[round(g["lat"], 6), round(g["lon"], 6)] for g in geom
if isinstance(g.get("lat"), (int, float)) and isinstance(g.get("lon"), (int, float))]
if len(pts) < 2:
continue
rd = {"id": w["id"], "kind": hwy, "pts": pts}
name = w.get("tags", {}).get("name")
if name:
rd["name"] = name[:60]
roads.append(rd)
return sorted(roads, key=lambda r: r["id"])
def process(key, cfg, els, fetched, roads=None):
center = cfg["center"]
rows, seen = [], set()
for el in els:
t = el.get("tags", {})
name, osm_shop = t.get("name"), t.get("shop")
lat = el.get("lat", el.get("center", {}).get("lat"))
lon = el.get("lon", el.get("center", {}).get("lon"))
if not (name and osm_shop and isinstance(lat, (int, float)) and isinstance(lon, (int, float))):
continue
dkey = (punct_blind(name), round(lat, 3), round(lon, 3)) # node/way dup fuzz (~110 m)
if dkey in seen:
continue
seen.add(dkey)
suburb = next((t[k] for k in ("addr:suburb", "addr:city", "addr:town", "addr:place") if t.get(k)), "")
rows.append({"id": el["id"], "name": name[:60], "osm": osm_shop, "lat": lat, "lon": lon, "suburb": suburb})
# suburb fill: untagged shops adopt the nearest tagged neighbour within ~3 km (that's a suburb)
tagged = [r for r in rows if r["suburb"]]
for r in rows:
if r["suburb"] or not tagged:
continue
near = min(tagged, key=lambda s: haversine_m((r["lat"], r["lon"]), (s["lat"], s["lon"])))
if haversine_m((r["lat"], r["lon"]), (near["lat"], near["lon"])) <= 3000:
r["suburb"] = near["suburb"]
# parody + type-map + second dedupe (parodized name in same suburb = same shop); id-sorted = deterministic
shops, seen2 = [], set()
for r in sorted(rows, key=lambda r: r["id"]):
pname = parodize(r["name"])[:60]
k2 = (punct_blind(pname), r["suburb"])
if k2 in seen2:
continue
seen2.add(k2)
shops.append({"id": r["id"], "name": pname, "type": TYPE_MAP.get(r["osm"], "opshop"),
"lat": round(r["lat"], 6), "lon": round(r["lon"], 6), "suburb": r["suburb"]})
bb = bbox(center, cfg["span_km"])
counts = {"raw": len(els), "shops": len(shops)}
cache = {
"schema": "procity-town-cache/2" if roads else "procity-town-cache/1",
"key": key, "town": cfg["town"], "source": "osm",
"license": "ODbL 1.0", "attribution": "© OpenStreetMap contributors",
"generator": "pipeline/build_towns.py", "fetchedAt": fetched,
"center": {"lat": center[0], "lon": center[1]},
"bbox": bb, "counts": counts, "shops": shops,
}
if roads:
counts["roads"] = len(roads)
cache["roads"] = roads # schema v2 — A's plan_osm builds the real street graph
return cache
def span_km(shops):
if not shops:
return 0.0
lats = [s["lat"] for s in shops]; lons = [s["lon"] for s in shops]
latm = haversine_m((min(lats), lons[0]), (max(lats), lons[0]))
lonm = haversine_m((lats[0], min(lons)), (lats[0], max(lons)))
return max(latm, lonm) / 1000.0
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
refetch = "--refetch" in sys.argv
no_roads = "--no-roads" in sys.argv # build a v1 cache (marched fallback), no road fetch
keys = args or list(TOWNS)
os.makedirs(TOWNS_DIR, exist_ok=True)
kept, dropped = [], []
for key in keys:
cfg = TOWNS[key]
bb = bbox(cfg["center"], cfg["span_km"])
els, fetched = fetch_raw(key, bb, refetch)
roads = None
if not no_roads:
roads = roads_from_ways(fetch_roads_raw(key, bb, refetch))
cache = process(key, cfg, els, fetched, roads)
n, sp = len(cache["shops"]), span_km(cache["shops"])
types = {}
for s in cache["shops"]:
types[s["type"]] = types.get(s["type"], 0) + 1
ok = n >= 6 and sp < 5.0
if ok:
json.dump(cache, open(os.path.join(TOWNS_DIR, key + ".json"), "w"), indent=1)
kept.append(key)
else:
dropped.append((key, n, sp))
rstr = ""
if roads is not None:
rk = {}
for r in roads:
rk[r["kind"]] = rk.get(r["kind"], 0) + 1
rstr = f" roads={len(roads)} {dict(sorted(rk.items(), key=lambda x: -x[1]))}"
print(f" {'OK ' if ok else 'LOW'} {key:16} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}{rstr}")
print(f"\nBUILT {len(kept)} town cache(s) → web/assets/towns/: {', '.join(kept)}")
if dropped:
print(f"DROPPED (< 6 shops or span ≥ 5 km): {dropped}")
if __name__ == "__main__":
main()