PROCITY/pipeline/build_towns.py
m3ultra 483a4b75c6 Lane E R17 (v3.2): the real-map scout — 5 AU town caches + publish --verify fix
Ledger #4 (warm-up): publish.py --verify no longer false-flags non-procity_ published assets
(sit.glb, vintage-cash-register). It compares the _published.json name record against the depot's
full file list (fetch_depot_files) instead of a procity_ prefix filter; only OUR namespace flags as
unrecorded. --verify now: all 39 recorded assets live.

Ledger #6 (the scout): new pipeline/build_towns.py ports thriftgod build_city()'s Overpass route to
per-town caches in A's procity-town-cache/1 contract. 5 real compact AU towns under web/assets/towns/
(katoomba/newtown/fremantle/bendigo/castlemaine, 6-21 shops, spans <2.4km): bounded Overpass query ->
raw cached in-repo (re-runs never re-fetch) -> dedupe/suburb-fill/parody (trademarked chains only;
indie shops keep real OSM names)/type-map. Deterministic (byte-identical re-run, no RNG). ODbL:
SOURCES.md licence table + license/attribution on every cache. All 5 boot through the full
structural+gig suites (selfcheck 16596/16596 green; synthetic golden 0x3fa36874 unmoved). Overpass
fetch John-authorized (Standing #2).

-> Lane A: caches live under web/assets/towns/; pin the per-town goldens selfcheck printed (katoomba
   0x61760542 newtown 0x95978b45 fremantle 0xee9fd09a bendigo 0x5e3e76a6 castlemaine 0xf1a628a1) +
   feed through plan_osm. E->A handshake.

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

194 lines
9.2 KiB
Python

#!/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
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",
}
# 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 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})")
req = urllib.request.Request(OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=150) as r:
els = json.load(r).get("elements", [])
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 process(key, cfg, els, fetched):
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"])
return {
"schema": "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": {"raw": len(els), "shops": len(shops)},
"shops": shops,
}
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
keys = args or list(TOWNS)
os.makedirs(TOWNS_DIR, exist_ok=True)
kept, dropped = [], []
for key in keys:
cfg = TOWNS[key]
els, fetched = fetch_raw(key, bbox(cfg["center"], cfg["span_km"]), refetch)
cache = process(key, cfg, els, fetched)
n, sp = len(cache["shops"]), span_km(cache["shops"])
types = {}
for s in cache["shops"]:
types[s["type"]] = types.get(s["type"], 0) + 1
status = "OK " if n >= 6 and sp < 5.0 else "LOW"
if n >= 6 and sp < 5.0:
json.dump(cache, open(os.path.join(TOWNS_DIR, key + ".json"), "w"), indent=1)
kept.append(key)
else:
dropped.append((key, n, sp))
print(f" {status} {key:16} raw={cache['counts']['raw']:>4} shops={n:>3} span={sp:.2f}km {dict(sorted(types.items()))}")
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()