iscc-app / scripts /geocode_redcert.py
Couscouskabyle's picture
Squash historical storage before approved Sinetti release ea8677e4
2b8ea89
Raw History Blame
4.67 kB
"""DEV-ONLY offline geocoder for REDcert's 'Map by location' view.
Mirrors scripts/geocode_holders.py (the ISCC geocoder) but sources REDcert's clean, already-split
`city` / `postcode` / `country` fields β€” so no address-string parsing is needed. Writes
`data/redcert/holder_coords.json` as {holder_norm: {CC: [lat, lng]}} (per-country, so a holder with
plants in several countries is placed in each, never collapsed to one point). The app loads that file
at runtime and plots stored points β€” NO live geocoding, so the map stays Streamlit-in-Snowflake safe.
Strategy (best-effort, honest coverage): postcode via pgeocode (great for EU), then city via
geonamescache, else skip (never fake a country-centroid point).
Run: APP_BACKEND=local python scripts/geocode_redcert.py
Deps (dev only): pip install pgeocode geonamescache
"""
from __future__ import annotations
import json
import math
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import redcert # noqa: E402
def _normalise_postcode(country: str, postcode: str) -> str:
"""Return the postcode shape expected by the country geocoder."""
pc = str(postcode or "").strip()
if (country or "").upper() == "DE" and pc.isdigit() and len(pc) < 5:
return pc.zfill(5)
return pc
def main() -> None:
import geonamescache
import pgeocode
certs = redcert.load_redcert_certs()
# Group the (postcode, city) addresses per (holder_norm, COUNTRY) β€” NOT per holder β€” so a holder
# registered in several countries is geocoded once per country and never lands abroad.
groups: dict = {} # (hn, cc) -> [(postcode, city)]
for c in certs:
hn = c.get("holder_norm")
cc = (c.get("country") or "").upper()
if not hn or not cc:
continue
addr = (str(c.get("postcode", "") or "").strip(), (c.get("city", "") or "").strip())
if not any(addr):
continue
addrs = groups.setdefault((hn, cc), [])
if addr not in addrs:
addrs.append(addr)
gc = geonamescache.GeonamesCache()
city_idx, pop = {}, {}
for cd in gc.get_cities().values():
key = (cd["countrycode"], cd["name"].lower())
if key not in city_idx or cd["population"] > pop.get(key, 0):
city_idx[key] = (round(cd["latitude"], 4), round(cd["longitude"], 4))
pop[key] = cd["population"]
nomi_cache: dict = {}
def nomi(cc: str):
if cc not in nomi_cache:
try:
nomi_cache[cc] = pgeocode.Nominatim(cc)
except Exception:
nomi_cache[cc] = None
return nomi_cache[cc]
def geocode_one(cc: str, addrs: list):
"""Resolve the first address that yields a point, constrained to country `cc`: postcode via
pgeocode(cc), then city via the (cc, city) index. Returns (latlng, how)."""
for pc, city in addrs:
pc = _normalise_postcode(cc, pc)
if pc:
n = nomi(cc)
if n is not None:
try:
r = n.query_postal_code(pc)
lat = float(r["latitude"])
if not math.isnan(lat):
return (round(lat, 4), round(float(r["longitude"]), 4)), "postcode"
except Exception:
pass
if city:
hit = city_idx.get((cc, city.lower()))
if hit:
return hit, "city"
return None, None
coords: dict = {} # hn -> {CC: [lat, lng]} (per-country)
src = Counter()
pairs_done = 0
for (hn, cc), addrs in groups.items():
latlng, how = geocode_one(cc, addrs)
if latlng:
coords.setdefault(hn, {})[cc] = list(latlng)
src[how] += 1
pairs_done += 1
out = Path(__file__).resolve().parent.parent / "data" / "redcert" / "holder_coords.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(coords), encoding="utf-8")
multi = sum(1 for v in coords.values() if len(v) > 1)
n_holders = len({hn for hn, _ in groups})
print(f"geocoded {pairs_done} of {len(groups)} (holder,country) pairs "
f"across {len(coords)} of {n_holders} holders β€” {dict(src)}")
print(f" {multi} holders placed in >1 country (per-country shape)")
print(f"wrote {out}")
if __name__ == "__main__":
main()