Create new project design plans

This commit is contained in:
zyphlar
2026-08-28 14:23:26 -07:00
parent a7d6db8177
commit 7ce73f19c5
22 changed files with 2682 additions and 263 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/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())