#!/usr/bin/env python3 """ Generate the City Boundaries report: addr:city agreement between the county source and OpenStreetMap for already-matched addresses. Reads addresses-existing.geojson (produced by compare-addresses.py, which already carries county_postal_community/county_city/osm_city for each matched pair) and classifies each point against OSM twice - once per county field, since the postal community (USPS mailing city) and the legal city/jurisdiction can disagree with each other, not just with OSM. No spatial matching happens here, so this is fast/cheap to re-run on its own without repeating the "Diff vs OSM" step. Usage: python generate-city-report.py \ --existing-file /data/lake/addresses-existing.geojson \ --output-dir /data/lake """ import argparse import sys import warnings from pathlib import Path import geopandas as gpd import pandas as pd warnings.filterwarnings('ignore') def normalize_city(value) -> str: if value is None or (isinstance(value, float) and pd.isna(value)): return '' s = str(value).strip() return '' if s in ('', 'nan', 'None') else s def agreement_status(county_city, osm_city) -> str: c = normalize_city(county_city) o = normalize_city(osm_city) if not c and not o: return 'both_missing' if not c: return 'osm_only' if not o: return 'county_only' return 'match' if c.lower() == o.lower() else 'mismatch' def main(): parser = argparse.ArgumentParser( description="Generate the city-name agreement report from already-matched addresses", epilog="Run compare-addresses.py first to produce addresses-existing.geojson." ) parser.add_argument('--existing-file', required=True, help='addresses-existing.geojson from compare-addresses.py') parser.add_argument('--output-dir', '-o', required=True, help='Output directory for city-comparison.geojson') args = parser.parse_args() existing_file = Path(args.existing_file) output_dir = Path(args.output_dir) if not existing_file.exists(): print(f"Error: {existing_file} not found.", file=sys.stderr) print("Run 'Diff vs OSM' for addresses first.", file=sys.stderr) return 1 gdf = gpd.read_file(existing_file) required = ['county_postal_community', 'county_city', 'osm_city'] missing = [c for c in required if c not in gdf.columns] if missing: print(f"Error: {existing_file} is missing column(s): {', '.join(missing)}", file=sys.stderr) print("This file predates the postal-community/city split - rerun " "'Convert to GeoJSON' and 'Diff vs OSM' for this county " "(convert-addresses.py needs to regenerate county-addresses.geojson " "with the current field set) before generating the report.", file=sys.stderr) return 1 gdf['status_postal_community'] = [ agreement_status(c, o) for c, o in zip(gdf['county_postal_community'], gdf['osm_city']) ] gdf['status_city'] = [ agreement_status(c, o) for c, o in zip(gdf['county_city'], gdf['osm_city']) ] output_dir.mkdir(parents=True, exist_ok=True) out_path = output_dir / "city-comparison.geojson" gdf.to_file(out_path, driver='GeoJSON') print(f"Saved {len(gdf)} city comparisons to {out_path}") for label, col in [('postal community', 'status_postal_community'), ('city', 'status_city')]: print(f" vs {label}:") for status, n in gdf[col].value_counts().items(): print(f" {status}: {n}") return 0 if __name__ == '__main__': sys.exit(main())