#!/usr/bin/env python3 """ Point-in-polygon check: for a hand-traced boundary (see the Boundary Tracer tool at /map-checker, or draw manually and save as boundary.geojson), flag every county/OSM address that disagrees with it - either inside the boundary but not labeled with the expected city, or outside it but labeled with the expected city anyway. The boundary itself is drawn by a human looking at a reference map in their own browser (e.g. thevillages.com/map) - nothing here fetches from any third-party site, only our own /data/{county}/*.geojson files. Usage: python check-boundary.py --county lake --county sumter python check-boundary.py --county lake --expect-city "The Villages" --output flagged.geojson """ import argparse import json import sys from pathlib import Path from shapely.geometry import shape, Point from shapely.ops import unary_union import warnings warnings.filterwarnings('ignore') def normalize_city(value): if value is None: return '' s = str(value).strip() return '' if s in ('', 'nan', 'None') else s def load_boundary(path): with open(path) as f: data = json.load(f) polys = [] for feat in data.get('features', []): name = (feat.get('properties') or {}).get('name', 'boundary') polys.append((name, shape(feat['geometry']))) return polys def load_addresses(data_dir, county): county_dir = Path(data_dir) / county # Prefer city-comparison.geojson: has postal community, city, AND osm_city # together for matched addresses. Falls back to county-addresses.geojson # (county-only, addr:city, no OSM side) if the diff step hasn't run yet. for filename in ('city-comparison.geojson', 'county-addresses.geojson'): path = county_dir / filename if path.exists(): with open(path) as f: return json.load(f), filename return None, None def city_values(props): values = [ normalize_city(props.get('county_postal_community')), normalize_city(props.get('county_city')), normalize_city(props.get('osm_city')), normalize_city(props.get('addr:city')), ] return [v for v in values if v] def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--boundary', default='/data/map-checker/boundary.geojson', help='Path to the traced boundary GeoJSON (default: the file saved by ' 'the Boundary Tracer web tool at /map-checker)') parser.add_argument('--data-dir', default='/data') parser.add_argument('--county', action='append', help='County id to check (repeatable). Default: lake, sumter') parser.add_argument('--expect-city', default='The Villages', help='City name addresses inside the boundary should have (default: "The Villages")') parser.add_argument('--output', help='Optional path to save flagged addresses as GeoJSON') args = parser.parse_args() boundary_path = Path(args.boundary) if not boundary_path.exists(): print(f"Error: {boundary_path} not found.", file=sys.stderr) print("Trace a boundary first via the Boundary Tracer tool (/map-checker), " "or save one to that path by hand.", file=sys.stderr) return 1 polys = load_boundary(boundary_path) if not polys: print("Error: boundary file has no polygons.", file=sys.stderr) return 1 combined = unary_union([g for _, g in polys]) counties = args.county or ['lake', 'sumter'] expect = args.expect_city.strip().lower() total = inside_count = inside_flagged = outside_flagged = 0 flagged_features = [] print(f"Boundary: {', '.join(sorted(set(name for name, _ in polys)))} " f"({len(polys)} polygon(s))") for county in counties: fc, source_file = load_addresses(args.data_dir, county) if fc is None: print(f" {county}: no data found, skipping.") continue county_total = county_inside = county_inside_flagged = county_outside_flagged = 0 for feat in fc['features']: coords = feat['geometry']['coordinates'] pt = Point(coords[0], coords[1]) is_inside = combined.contains(pt) props = feat.get('properties', {}) values = city_values(props) says_expected = any(v.lower() == expect for v in values) county_total += 1 if is_inside: county_inside += 1 if values and not says_expected: county_inside_flagged += 1 flagged_features.append({ **feat, 'properties': {**props, 'county': county, 'flag': 'inside_but_not_labeled'}, }) elif says_expected: county_outside_flagged += 1 flagged_features.append({ **feat, 'properties': {**props, 'county': county, 'flag': 'outside_but_labeled'}, }) print(f" {county} ({source_file}): {county_total} addresses, " f"{county_inside} inside boundary " f"({county_inside_flagged} not labeled '{args.expect_city}'), " f"{county_outside_flagged} outside boundary but labeled '{args.expect_city}'") total += county_total inside_count += county_inside inside_flagged += county_inside_flagged outside_flagged += county_outside_flagged print(f"\n{'=' * 60}") print(f"Total: {total} addresses checked, {inside_count} inside the boundary.") print(f" Inside but not labeled '{args.expect_city}': {inside_flagged}") print(f" Outside but labeled '{args.expect_city}': {outside_flagged}") if args.output: out = {'type': 'FeatureCollection', 'features': flagged_features} with open(args.output, 'w') as f: json.dump(out, f) print(f"\nSaved {len(flagged_features)} flagged addresses to {args.output}") return 0 if __name__ == '__main__': sys.exit(main())