210 lines
7.5 KiB
Python
210 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
map-checker: compare hand-recorded observations from thevillages.com/map
|
|
(see reference.yml) against our county/OSM address data.
|
|
|
|
This is a deliberately manual workflow. thevillages.com's Terms of Use
|
|
prohibit automated/bot access to their site, so nothing here fetches from
|
|
them - reference.yml is filled in BY HAND after a human looks at the
|
|
official map in their own browser. This script only reads OUR OWN data
|
|
(county-addresses.geojson / city-comparison.geojson, already produced by
|
|
convert-addresses.py and generate-city-report.py) and checks it against
|
|
those manually recorded entries.
|
|
|
|
Usage:
|
|
python check.py [--reference reference.yml] [--data-dir /data] [--radius 200]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
DEFAULT_RADIUS_METERS = 200.0
|
|
|
|
|
|
def normalize_city(value):
|
|
if not value:
|
|
return ''
|
|
s = str(value).strip()
|
|
return '' if s in ('', 'nan', 'None') else s
|
|
|
|
|
|
def normalize_street(value):
|
|
if not value:
|
|
return ''
|
|
s = str(value).strip().lower()
|
|
s = re.sub(r'\bstate road\b', 'sr', s)
|
|
s = re.sub(r'\bstate route\b', 'sr', s)
|
|
s = re.sub(r'\bcounty road\b', 'cr', s)
|
|
s = re.sub(r'\s+', ' ', s).strip()
|
|
return s
|
|
|
|
|
|
def haversine_meters(lat1, lon1, lat2, lon2):
|
|
r = 6371000.0
|
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
dp = math.radians(lat2 - lat1)
|
|
dl = math.radians(lon2 - lon1)
|
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
return 2 * r * math.asin(math.sqrt(a))
|
|
|
|
|
|
def load_geojson(path):
|
|
if not path.exists():
|
|
return None
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def find_matches(entry, county_features, comparison_features, radius_m):
|
|
"""Return a list of matched feature-property dicts for one reference entry.
|
|
|
|
Prefers city-comparison.geojson (has county_postal_community, county_city,
|
|
AND osm_city together) when available; falls back to the raw
|
|
county-addresses.geojson (county-only, no OSM side) otherwise.
|
|
"""
|
|
source = comparison_features if comparison_features is not None else county_features
|
|
if source is None:
|
|
return []
|
|
|
|
street = entry.get('street')
|
|
housenumber = entry.get('housenumber')
|
|
lat, lon = entry.get('lat'), entry.get('lon')
|
|
|
|
matches = []
|
|
for feature in source['features']:
|
|
props = feature.get('properties', {})
|
|
|
|
if lat is not None and lon is not None:
|
|
coords = feature['geometry']['coordinates']
|
|
dist = haversine_meters(lat, lon, coords[1], coords[0])
|
|
if dist > radius_m:
|
|
continue
|
|
elif street:
|
|
if normalize_street(props.get('addr:street')) != normalize_street(street):
|
|
continue
|
|
if housenumber is not None:
|
|
try:
|
|
if int(props.get('addr:housenumber')) != int(housenumber):
|
|
continue
|
|
except (TypeError, ValueError):
|
|
continue
|
|
else:
|
|
continue
|
|
|
|
matches.append(props)
|
|
|
|
return matches
|
|
|
|
|
|
def check_entry(entry, index, county_features, comparison_features, radius_m):
|
|
label = entry.get('street') or f"({entry.get('lat')}, {entry.get('lon')})"
|
|
official_city = normalize_city(entry.get('official_city'))
|
|
matches = find_matches(entry, county_features, comparison_features, radius_m)
|
|
|
|
print(f"\n[{index}] {label}"
|
|
+ (f" #{entry['housenumber']}" if entry.get('housenumber') else '')
|
|
+ f" ({entry.get('county', '?')})")
|
|
if entry.get('official_village'):
|
|
print(f" Official village: {entry['official_village']}")
|
|
print(f" Official city (recorded): {official_city or '(none recorded)'}")
|
|
|
|
if not matches:
|
|
print(" No matching addresses found in our data - can't check.")
|
|
return {'checked': True, 'matched': False, 'mismatches': 0}
|
|
|
|
# A single street address (e.g. a shopping plaza or apartment building)
|
|
# can match many unit rows that all carry identical city values - group
|
|
# those together instead of printing one line per unit.
|
|
groups = {} # (postal_community, city, osm_city) -> {'count', 'addr'}
|
|
for props in matches:
|
|
addr = ' '.join(str(v) for v in
|
|
[props.get('addr:housenumber'), props.get('addr:street')] if v)
|
|
key = (normalize_city(props.get('county_postal_community')),
|
|
normalize_city(props.get('county_city')),
|
|
normalize_city(props.get('osm_city')))
|
|
if key not in groups:
|
|
groups[key] = {'count': 0, 'addr': addr}
|
|
groups[key]['count'] += 1
|
|
|
|
mismatches = 0
|
|
for (postal, city, osm), g in groups.items():
|
|
fields = [('postal community', postal), ('city', city), ('OSM city', osm)]
|
|
parts = []
|
|
row_mismatch = False
|
|
for field_label, value in fields:
|
|
if not value:
|
|
continue
|
|
agrees = (not official_city) or value.lower() == official_city.lower()
|
|
if not agrees:
|
|
row_mismatch = True
|
|
flag = '' if agrees else ' <-- MISMATCH'
|
|
parts.append(f"{field_label}={value}{flag}")
|
|
count_note = f" (x{g['count']})" if g['count'] > 1 else ''
|
|
if row_mismatch:
|
|
mismatches += g['count']
|
|
print(f" {g['addr']}{count_note}: " + ', '.join(parts))
|
|
|
|
return {'checked': True, 'matched': True, 'mismatches': mismatches}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--reference', default=str(Path(__file__).parent / 'reference.yml'))
|
|
parser.add_argument('--data-dir', default='/data')
|
|
parser.add_argument('--radius', type=float, default=DEFAULT_RADIUS_METERS,
|
|
help=f'Match radius in meters for lat/lon entries (default {DEFAULT_RADIUS_METERS})')
|
|
args = parser.parse_args()
|
|
|
|
ref_path = Path(args.reference)
|
|
if not ref_path.exists():
|
|
print(f"Error: {ref_path} not found.", file=sys.stderr)
|
|
return 1
|
|
|
|
with open(ref_path) as f:
|
|
data = yaml.safe_load(f) or {}
|
|
checks = data.get('checks') or []
|
|
|
|
if not checks:
|
|
print(f"No entries in {ref_path} yet.")
|
|
print("Open https://www.thevillages.com/map/ in your own browser, look up an address,")
|
|
print("and add an entry to reference.yml by hand - see the comments at the top of that file.")
|
|
return 0
|
|
|
|
data_dir = Path(args.data_dir)
|
|
cache = {}
|
|
total_mismatches = 0
|
|
total_unmatched = 0
|
|
|
|
for i, entry in enumerate(checks, 1):
|
|
county = entry.get('county')
|
|
if not county:
|
|
print(f"\n[{i}] Skipped - missing 'county' field.")
|
|
continue
|
|
if county not in cache:
|
|
cache[county] = (
|
|
load_geojson(data_dir / county / 'county-addresses.geojson'),
|
|
load_geojson(data_dir / county / 'city-comparison.geojson'),
|
|
)
|
|
county_features, comparison_features = cache[county]
|
|
result = check_entry(entry, i, county_features, comparison_features, args.radius)
|
|
if not result['matched']:
|
|
total_unmatched += 1
|
|
total_mismatches += result['mismatches']
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Checked {len(checks)} entries: {total_mismatches} address(es) with a "
|
|
f"disagreeing city field, {total_unmatched} entries with no match in our data.")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|