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>
194 lines
9.2 KiB
Python
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()
|