open-navigator / scripts /discovery /fold_organization_location_into_c1.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
12.4 kB
#!/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/<UUIDv5 from data_source+source_id+name+city+state>
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())