#!/usr/bin/env python3 """ Fold ``public.organization_location`` (571,448 rows of HIFLD-style location data: places of worship, police stations, hospitals, sheriffs, state agencies, etc.) into ``public.civic_organization`` and then drop the source table. Mapping: organization_location -> civic_organization ───────────────────────────── ────────────────────────────────────── source_id extras['source_id'] name name organization_type classification (normalized: slashes → underscores) address address city city state state state_name extras['state_name'] zip zip_code county county latitude latitude (NEW column from 054a) longitude longitude (NEW column from 054a) telephone phone website website data_source source source_dataset extras['source_dataset'] additional_info extras (merged) created_at created_at updated_at updated_at Jurisdiction binding: each row's (state, city) is looked up in the cached OCD ``country-us.csv`` (places + counties). Matches produce the canonical OCD jurisdiction URN, e.g. ``ocd-jurisdiction/country:us/state:ms/place:natchez/government``. Unmatched rows get NULL jurisdiction_id. Deterministic ``id``: ocd-organization/ so re-runs upsert cleanly. Run:: .venv/bin/python -m scripts.discovery.fold_organization_location_into_c1 --dry-run .venv/bin/python -m scripts.discovery.fold_organization_location_into_c1 .venv/bin/python -m scripts.discovery.fold_organization_location_into_c1 --drop-source ``--drop-source`` runs migration 054b after the fold (drops public.organization_location). """ from __future__ import annotations import argparse import csv import json import logging import os import re import sys import uuid from pathlib import Path import psycopg2 from dotenv import load_dotenv from psycopg2.extras import execute_values _ROOT = Path(__file__).resolve().parents[2] load_dotenv(_ROOT / ".env") logger = logging.getLogger("fold_organization_location") _OCD_NS_ORG = uuid.UUID("a8b3c4d5-e6f7-4a89-9b1c-2d3e4f5a6b7c") _OCD_CSV = _ROOT / "data" / "cache" / "opencivicdata" / "identifiers" / "country-us.csv" # -------------------------------------------------------------------------------------- # Jurisdiction-id lookup from OCD country-us.csv # -------------------------------------------------------------------------------------- def _normalize_place_name(name: str) -> str: """Strip LSAD suffix + lowercase + collapse whitespace + drop punctuation.""" s = (name or "").strip().lower() s = re.sub(r"\s+(city|town|village|borough|cdp|municipality|township)$", "", s) s = re.sub(r"[^a-z0-9]+", "_", s) return s.strip("_") def build_ocd_lookup() -> dict[tuple[str, str], str]: """ Return ``{(state_code_lower, normalized_place): ocd_jurisdiction_id}`` covering all US places + counties. The OCD ``ocd-division`` URN gets transformed to ``ocd-jurisdiction`` with ``/government`` suffix per the canonical jurisdiction form. """ if not _OCD_CSV.exists(): raise SystemExit(f"OCD CSV not found at {_OCD_CSV} — run wikidata cache warm first") lookup: dict[tuple[str, str], str] = {} with open(_OCD_CSV) as f: for row in csv.reader(f): if len(row) < 2: continue ocd_id, name = row[0], row[1] # Places m = re.match(r"^ocd-division/country:us/state:([a-z]{2})/place:([a-z0-9_]+)$", ocd_id) if m: state, slug = m.group(1), m.group(2) juris = f"ocd-jurisdiction/country:us/state:{state}/place:{slug}/government" lookup[(state, slug)] = juris # Also map the display-name normalized form lookup[(state, _normalize_place_name(name))] = juris continue # Counties m = re.match(r"^ocd-division/country:us/state:([a-z]{2})/county:([a-z0-9_]+)$", ocd_id) if m: state, slug = m.group(1), m.group(2) juris = f"ocd-jurisdiction/country:us/state:{state}/county:{slug}/government" # County lookups keyed under county slug; org rows have city not county usually, # but if city is empty and county matches, we use this. lookup[(state, f"_county:{slug}")] = juris lookup[(state, _normalize_place_name(name))] = juris return lookup def derive_jurisdiction_id( state: str | None, city: str | None, county: str | None, lookup: dict[tuple[str, str], str], ) -> str | None: if not state: return None state_lc = state.strip().lower()[:2] if not state_lc: return None # Prefer city (place); fall back to county. if city: ocd = lookup.get((state_lc, _normalize_place_name(city))) if ocd: return ocd if county: ocd = lookup.get((state_lc, _normalize_place_name(county))) if ocd: return ocd # county-prefixed lookup ocd = lookup.get((state_lc, f"_county:{_normalize_place_name(county)}")) if ocd: return ocd return None # -------------------------------------------------------------------------------------- # Row mapping # -------------------------------------------------------------------------------------- def _normalize_classification(raw: str | None) -> str | None: if not raw: return None s = raw.strip().lower() s = re.sub(r"[^a-z0-9]+", "_", s) return s.strip("_") or None def _det_org_id(data_source: str | None, source_id: str | None, name: str | None, city: str | None, state: str | None) -> str: """UUIDv5 over the most-stable identity fields so re-fold upserts cleanly.""" seed = "|".join((data_source or "", source_id or "", name or "", city or "", state or "")) u = uuid.uuid5(_OCD_NS_ORG, seed) return f"ocd-organization/{u}" # -------------------------------------------------------------------------------------- # Pipeline # -------------------------------------------------------------------------------------- def fold(*, dry_run: bool, drop_source: bool) -> None: src_db = os.getenv("NEON_DATABASE_URL_DEV", "").strip() if not src_db: raise SystemExit("NEON_DATABASE_URL_DEV not set") logger.info("Building OCD jurisdiction-id lookup from %s …", _OCD_CSV.name) lookup = build_ocd_lookup() logger.info("Lookup size: %d (state, normalized-name) keys", len(lookup)) conn = psycopg2.connect(src_db) try: with conn.cursor(name="org_loc_stream") as src: src.itersize = 5000 src.execute(""" SELECT id, source_id, name, organization_type, address, city, state, state_name, zip, county, latitude, longitude, telephone, website, data_source, source_dataset, additional_info, created_at, updated_at FROM public.organization_location """) batch: list[tuple] = [] n_streamed = 0 n_inserted = 0 n_jur_matched = 0 type_dist: dict[str, int] = {} with conn.cursor() as dst: while True: rows = src.fetchmany(5000) if not rows: break for r in rows: (_loc_id, source_id, name, org_type, address, city, state, state_name, zip_code, county, lat, lon, phone, website, data_source, source_dataset, addl_info, created_at, updated_at) = r classification = _normalize_classification(org_type) if classification: type_dist[classification] = type_dist.get(classification, 0) + 1 jurisdiction_id = derive_jurisdiction_id(state, city, county, lookup) if jurisdiction_id: n_jur_matched += 1 org_id = _det_org_id(data_source, source_id, name, city, state) extras = {} if isinstance(addl_info, dict): extras.update(addl_info) if source_id: extras["source_id"] = source_id if state_name: extras["state_name"] = state_name if source_dataset: extras["source_dataset"] = source_dataset batch.append(( org_id, name, classification, jurisdiction_id, address, city, state, county, zip_code, lat, lon, phone, website, (data_source or "organization_location"), psycopg2.extras.Json(extras), created_at, updated_at, )) n_streamed += 1 if not dry_run and batch: execute_values( dst, """ INSERT INTO public.civic_organization (id, name, classification, jurisdiction_id, address, city, state, county, zip_code, latitude, longitude, phone, website, source, extras, created_at, updated_at) VALUES %s ON CONFLICT (ein) WHERE ein IS NOT NULL DO NOTHING """, batch, ) n_inserted += len(batch) if n_streamed % 50000 == 0 or n_streamed == len(rows): logger.info(" streamed %d / inserted %d / jur_matched %d", n_streamed, n_inserted, n_jur_matched) batch.clear() if not dry_run: conn.commit() finally: conn.close() print() print(f"Rows streamed: {n_streamed:,}") print(f"Rows inserted: {n_inserted:,}{' (dry-run: 0)' if dry_run else ''}") print(f"Jurisdiction-id matched:{n_jur_matched:,} ({100*n_jur_matched/max(1,n_streamed):.1f}%)") print(f"Classification dist:") for cls, n in sorted(type_dist.items(), key=lambda kv: -kv[1]): print(f" {cls:30s} {n:,}") if drop_source and not dry_run: logger.info("Dropping public.organization_location …") conn = psycopg2.connect(src_db) try: with conn.cursor() as cur: cur.execute("DROP TABLE public.organization_location CASCADE") conn.commit() print("\npublic.organization_location dropped.") finally: conn.close() def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--dry-run", action="store_true") p.add_argument("--drop-source", action="store_true", help="Drop public.organization_location after successful fold") p.add_argument("--verbose", "-v", action="store_true") args = p.parse_args(argv) logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) fold(dry_run=args.dry_run, drop_source=args.drop_source) return 0 if __name__ == "__main__": raise SystemExit(main())