Files
osm-import-tools/compare-addresses.py
T

365 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Compare OSM-formatted county address GeoJSON against OpenStreetMap addresses.
Expects both inputs to already be in OSM field format (addr:housenumber, addr:street, etc.).
Use convert-addresses.py to prepare the county file and download-overpass.py for the OSM file.
Usage:
python compare-addresses.py \
--local-file /data/sumter/county-addresses.geojson \
--osm-file /data/sumter/osm-addresses.geojson \
--output-dir /data/sumter
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Tuple
import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.strtree import STRtree
import warnings
warnings.filterwarnings('ignore')
CONFLICT_RADIUS_METERS = 5.0
RENAME_RADIUS_METERS = 100.0
class AddressComparator:
def __init__(self, tolerance_meters: float = 500.0):
self.tolerance_meters = tolerance_meters
# 1 degree latitude ≈ 111,000 metres
self.tolerance_deg = tolerance_meters / 111000.0
@staticmethod
def _normalize_multi(value) -> str:
"""Normalise a semicolon-delimited field by sorting its parts.
Treats '101;201' and '201;101' as equal so ordering differences
don't create false positives.
"""
if value is None or (isinstance(value, float) and pd.isna(value)):
return ''
s = str(value).strip()
if not s or s == 'nan':
return ''
parts = sorted(p.strip().lower() for p in s.split(';') if p.strip())
return ';'.join(parts)
def _normalize_street_name(self, street: str) -> str:
"""Normalise street names for fuzzy matching across county/OSM formatting differences."""
if not street or street == 'nan':
return ''
s = street.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'\bc\b', 'cr', s) # "C 44a" -> "cr 44a"
# normalise CR sub-segment separators: "cr 109d 1" -> "cr 109d-1"
s = re.sub(r'\b(cr\s+\d+[a-z])\s+(\d+)', r'\1-\2', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
@staticmethod
def _flag_close_conflicts(addresses: List[Dict], radius_meters: float = CONFLICT_RADIUS_METERS) -> None:
"""Mark each address dict's 'conflict' key True when another point in the
same list falls within radius_meters. Doesn't dedupe or exclude anything -
just surfaces tight clusters (e.g. ambiguous source data reusing the same
unit label across distinct points) for manual review in the map UI."""
if not addresses:
return
geoms = [a['geometry'] for a in addresses]
tree = STRtree(geoms)
radius_deg = radius_meters / 111000.0
for i, addr in enumerate(addresses):
conflict = False
for j in tree.query(geoms[i].buffer(radius_deg)):
if j == i:
continue
if geoms[i].distance(geoms[j]) * 111000.0 <= radius_meters:
conflict = True
break
addr['conflict'] = conflict
def _flag_possible_renames(self, new_addresses: List[Dict], removed_addresses: List[Dict],
radius_meters: float = RENAME_RADIUS_METERS) -> int:
"""Pair each new county address with an unmatched OSM address nearby that
has the same house number (and a compatible unit) but a different street
name, e.g. county "828 Donelin Circle" vs OSM "828 Donelin CR". Both sides
get 'possible_rename' set to the other side's street so the map can show
them as a likely rename/misnaming rather than an unrelated add + removal,
and a shared 'rename_pair' id so the map can accept both together.
Returns the number of pairs found."""
if not new_addresses or not removed_addresses:
return 0
# Index the unmatched OSM side by house number; a pair needs the same one.
geoms = [a['geometry'] for a in removed_addresses]
by_num: Dict[str, List[int]] = {}
for j, osm in enumerate(removed_addresses):
by_num.setdefault(self._normalize_multi(osm.get('addr:housenumber')), []).append(j)
street_cache: Dict[int, str] = {}
paired = set()
count = 0
for addr in new_addresses:
num = self._normalize_multi(addr.get('addr:housenumber'))
street = self._normalize_street_name(str(addr.get('addr:street', '')))
unit = self._normalize_multi(addr.get('addr:unit'))
if not num or not street:
continue
best, best_dist = None, float('inf')
for j in by_num.get(num, []):
if j in paired:
continue
osm = removed_addresses[j]
if j not in street_cache:
street_cache[j] = self._normalize_street_name(str(osm.get('addr:street', '')))
osm_street = street_cache[j]
if not osm_street or osm_street == street:
continue
osm_unit = self._normalize_multi(osm.get('addr:unit'))
if unit and osm_unit and unit != osm_unit:
continue
d = addr['geometry'].distance(geoms[j]) * 111000.0
if d <= radius_meters and d < best_dist:
best, best_dist = j, d
if best is not None:
paired.add(best)
osm = removed_addresses[best]
addr['possible_rename'] = str(osm.get('addr:street'))
osm['possible_rename'] = str(addr.get('addr:street'))
count += 1
addr['rename_pair'] = osm['rename_pair'] = count
return count
def compare_addresses(self, local_file: str, osm_file: str) -> Tuple[List[Dict], List[Dict], List[Dict]]:
"""Compare local and OSM address data.
Returns:
(new_addresses, existing_addresses, removed_addresses)
"""
print(f"Comparing addresses: {local_file} vs {osm_file}")
local_gdf = gpd.read_file(local_file)
osm_gdf = gpd.read_file(osm_file)
print(f"Loaded local addresses: {len(local_gdf)}")
print(f"Loaded OSM addresses: {len(osm_gdf)}")
if hasattr(self, 'sample_size') and self.sample_size:
if len(local_gdf) > self.sample_size:
local_gdf = local_gdf.sample(n=self.sample_size, random_state=42).reset_index(drop=True)
print(f"Sampled local addresses to: {len(local_gdf)}")
if hasattr(self, 'max_osm') and self.max_osm:
if len(osm_gdf) > self.max_osm:
osm_gdf = osm_gdf.sample(n=self.max_osm, random_state=42).reset_index(drop=True)
print(f"Sampled OSM addresses to: {len(osm_gdf)}")
if local_gdf.crs != osm_gdf.crs:
osm_gdf = osm_gdf.to_crs(local_gdf.crs)
# Normalise every address once and index OSM by (number, street):
# a county address can only ever match an OSM address with the same
# key, so there's no need to scan everything within the tolerance
# (thousands of points in dense areas like The Villages).
def keys(gdf):
nums = [self._normalize_multi(v) for v in gdf.get('addr:housenumber', pd.Series([''] * len(gdf)))]
streets = [self._normalize_street_name(str(v)) for v in gdf.get('addr:street', pd.Series([''] * len(gdf)))]
units = [self._normalize_multi(v) for v in gdf.get('addr:unit', pd.Series([None] * len(gdf)))]
return nums, streets, units
osm_nums, osm_streets, osm_units = keys(osm_gdf)
local_nums, local_streets, local_units = keys(local_gdf)
osm_geoms = osm_gdf.geometry.tolist()
local_geoms = local_gdf.geometry.tolist()
osm_props = osm_gdf.drop(columns='geometry').to_dict('records')
local_props = local_gdf.drop(columns='geometry').to_dict('records')
osm_by_key: Dict[Tuple[str, str], List[int]] = {}
for i, (num, street) in enumerate(zip(osm_nums, osm_streets)):
if street:
osm_by_key.setdefault((num, street), []).append(i)
existing_addresses = []
new_addresses = []
matched_osm = set()
for i, local_point in enumerate(local_geoms):
street = local_streets[i]
candidates = osm_by_key.get((local_nums[i], street), []) if street else []
local_unit = local_units[i]
# Existing: nearest OSM address with the same unit (or both
# without one) within the tolerance.
best_match, min_distance = None, float('inf')
# Matched (not potentially removed): the same, except an OSM
# address with no unit also covers a county address with one.
nearest_cover, cover_distance = None, float('inf')
for j in candidates:
distance = local_point.distance(osm_geoms[j])
if distance * 111000.0 > self.tolerance_meters:
continue
osm_unit = osm_units[j]
if (local_unit == osm_unit) and distance < min_distance:
best_match, min_distance = j, distance
if (not local_unit or not osm_unit or local_unit == osm_unit) and distance < cover_distance:
nearest_cover, cover_distance = j, distance
# Prefer marking the exact match, so e.g. a unit's own OSM node
# counts as matched rather than the unit-less building around it
cover = best_match if best_match is not None else nearest_cover
if cover is not None:
matched_osm.add(cover)
if best_match is not None:
existing_addresses.append({
'geometry': local_point,
'osm_geometry': osm_geoms[best_match],
'local_data': local_props[i],
'osm_data': osm_props[best_match],
'distance_meters': min_distance * 111000.0,
})
else:
props = dict(local_props[i])
props['status'] = 'new'
new_addresses.append({'geometry': local_point, **props})
removed_addresses = []
for j, osm_point in enumerate(osm_geoms):
if j not in matched_osm:
props = dict(osm_props[j])
# geopandas reads JSON int arrays (osm_nodes) as numpy arrays,
# which would be written back out as a string
for k, v in props.items():
if isinstance(v, np.ndarray):
props[k] = [int(x) for x in v.tolist()]
props['status'] = 'removed'
removed_addresses.append({'geometry': osm_point, **props})
self._flag_close_conflicts(new_addresses)
renames = self._flag_possible_renames(new_addresses, removed_addresses)
print(f"Possible street renames (same number nearby, different street): {renames}")
return new_addresses, existing_addresses, removed_addresses
def save_results(self, new_addresses, existing_addresses, removed_addresses, output_dir):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if new_addresses:
gpd.GeoDataFrame(new_addresses).to_file(
output_dir / "addresses-to-add.geojson", driver='GeoJSON')
print(f"Saved {len(new_addresses)} new addresses to {output_dir}/addresses-to-add.geojson")
if removed_addresses:
gpd.GeoDataFrame(removed_addresses).to_file(
output_dir / "addresses-potentially-removed.geojson", driver='GeoJSON')
print(f"Saved {len(removed_addresses)} potentially removed to {output_dir}/addresses-potentially-removed.geojson")
if existing_addresses:
# Keeps both county city fields (postal community and legal
# city/jurisdiction - they can differ) plus OSM's addr:city, and
# the OSM point's coordinates, so the "Generate City Boundaries
# Report" step can build its report from this file alone without
# repeating the spatial match.
existing_simple = [
{
'geometry': a.get('osm_geometry') or a['geometry'],
'distance_meters': a['distance_meters'],
'addr:housenumber': a['local_data'].get('addr:housenumber'),
'addr:street': a['local_data'].get('addr:street'),
'addr:unit': a['local_data'].get('addr:unit'),
'county_postal_community': a['local_data'].get('county_postal_community'),
'county_city': a['local_data'].get('county_city'),
'osm_city': a['osm_data'].get('addr:city'),
}
for a in existing_addresses
]
gpd.GeoDataFrame(existing_simple).to_file(
output_dir / "addresses-existing.geojson", driver='GeoJSON')
print(f"Saved {len(existing_addresses)} existing addresses to {output_dir}/addresses-existing.geojson")
def print_summary(self, new_addresses, existing_addresses, removed_addresses):
print("\n" + "=" * 60)
print("ADDRESS COMPARISON SUMMARY")
print("=" * 60)
print(f"\n New (to add to OSM): {len(new_addresses)}")
print(f" Existing (matched): {len(existing_addresses)}")
print(f" Potentially removed: {len(removed_addresses)}")
if existing_addresses:
distances = [a['distance_meters'] for a in existing_addresses]
print(f"\n Avg match distance: {sum(distances)/len(distances):.1f} m")
print(f" Max match distance: {max(distances):.1f} m")
if new_addresses:
print(f"\n Sample new addresses by street:")
streets: Dict[str, int] = {}
for addr in new_addresses[:10]:
s = addr.get('addr:street', 'Unknown')
streets[s] = streets.get(s, 0) + 1
for s, n in sorted(streets.items()):
print(f" {s}: {n}")
if len(new_addresses) > 10:
print(f" ... and {len(new_addresses) - 10} more")
def main():
parser = argparse.ArgumentParser(
description="Compare OSM-formatted county addresses against OpenStreetMap",
epilog="""
Run convert-addresses.py first to produce county-addresses.geojson,
and download-overpass.py to produce osm-addresses.geojson.
"""
)
parser.add_argument('--local-file', required=True,
help='Pre-converted county addresses GeoJSON (from convert-addresses.py)')
parser.add_argument('--osm-file', required=True,
help='Downloaded OSM addresses GeoJSON (from download-overpass.py)')
parser.add_argument('--output-dir', '-o', required=True,
help='Output directory for result GeoJSON files')
parser.add_argument('--tolerance', '-t', type=float, default=500.0,
help='Spatial tolerance in metres for matching (default: 500)')
parser.add_argument('--sample', '-s', type=int,
help='Process only N local addresses (for testing)')
parser.add_argument('--max-osm', type=int,
help='Cap OSM addresses loaded (for testing)')
args = parser.parse_args()
local_file = Path(args.local_file)
osm_file = Path(args.osm_file)
output_dir = Path(args.output_dir)
if not local_file.exists():
print(f"Error: local file not found: {local_file}", file=sys.stderr)
print("Run convert-addresses.py first.", file=sys.stderr)
return 1
if not osm_file.exists():
print(f"Error: OSM file not found: {osm_file}", file=sys.stderr)
print("Run download-overpass.py first.", file=sys.stderr)
return 1
comparator = AddressComparator(tolerance_meters=args.tolerance)
if args.sample:
comparator.sample_size = args.sample
if args.max_osm:
comparator.max_osm = args.max_osm
new, existing, removed = comparator.compare_addresses(str(local_file), str(osm_file))
comparator.save_results(new, existing, removed, output_dir)
comparator.print_summary(new, existing, removed)
return 0
if __name__ == '__main__':
sys.exit(main())