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

227 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
Convert county address shapefile (ZIP) to OSM-formatted GeoJSON.
Applies LIFECYCLE filtering, CRS conversion, field mapping via qgis-functions,
and street name exceptions from /data/exceptions.yml.
Usage:
python convert-addresses.py /data/sumter/addresses.shp.zip /data/sumter/county-addresses.geojson
python convert-addresses.py /data/lake/addresses.shp.zip /data/lake/county-addresses.geojson
"""
import argparse
import shutil
import sys
import zipfile
from pathlib import Path
import geopandas as gpd
import pandas as pd
import importlib
import warnings
warnings.filterwarnings('ignore')
qgis_functions = importlib.import_module("qgis-functions")
def load_exceptions(exceptions_path='/data/exceptions.yml'):
"""Load street name corrections. Each correction has 'from'/'to', and
optionally a 'city' to restrict it to addresses in that city only -
otherwise it applies everywhere that street name occurs."""
path = Path(exceptions_path)
if not path.exists():
return []
import yaml
with open(path) as f:
data = yaml.safe_load(f) or {}
return [
item for item in data.get('corrections', [])
if 'from' in item and 'to' in item
]
def apply_street_exceptions(street_names, city_names, exceptions):
if not exceptions:
return street_names
result = []
for name, city in zip(street_names, city_names):
corrected = name
if name is not None:
for item in exceptions:
if item['from'] != name:
continue
scope_city = item.get('city')
if scope_city and (city is None or city.upper() != scope_city.upper()):
continue
corrected = item['to']
break
result.append(corrected)
return result
def process_address_fields(gdf, exceptions):
"""Map county shapefile fields to OSM address schema."""
processed = gdf.copy()
mapping = {}
# House number
for field in ['ADD_NUM', 'AddressNum', 'ADDRESS_NUM', 'HOUSE_NUM']:
if field in processed.columns:
series = pd.to_numeric(processed[field], errors='coerce')
mapping['addr:housenumber'] = series.round().astype('Int64')
break
# Unit (a dedicated column takes priority; otherwise fall back to a unit
# designator embedded in a combined street+unit address string below)
unit_from_column = [None] * len(processed)
for field in ['UNIT', 'UnitNumber', 'UNIT_NUM', 'APT']:
if field in processed.columns:
series = processed[field].copy().replace(['nan', 'None', '', None], None)
unit_from_column = list(series.where(series.notna(), None))
break
# Street name
unit_from_address = [None] * len(processed)
if 'SADD' in processed.columns:
# Sumter: full address string in SADD
mapping['addr:street'] = [
qgis_functions.title(qgis_functions.getstreetfromaddress(str(v), None, None))
if pd.notna(v) else None
for v in processed['SADD']
]
unit_from_address = [
qgis_functions.title(qgis_functions.getunitfromaddress(str(v), None, None) or '') or None
if pd.notna(v) else None
for v in processed['SADD']
]
elif 'FullAddres' in processed.columns:
# Lake: full address string in FullAddres
mapping['addr:street'] = [
qgis_functions.title(qgis_functions.getstreetfromaddress(str(v), None, None))
if pd.notna(v) else None
for v in processed['FullAddres']
]
unit_from_address = [
qgis_functions.title(qgis_functions.getunitfromaddress(str(v), None, None) or '') or None
if pd.notna(v) else None
for v in processed['FullAddres']
]
elif 'BaseStreet' in processed.columns:
# Lake alternative: assemble from components
street_names = []
for _, row in processed.iterrows():
parts = []
for col in ['PrefixDire', 'PrefixType', 'BaseStreet', 'SuffixType']:
if col in row and pd.notna(row[col]):
parts.append(str(row[col]).strip())
street_names.append(qgis_functions.title(' '.join(parts)) if parts else None)
mapping['addr:street'] = street_names
mapping['addr:unit'] = [
col if col is not None else addr
for col, addr in zip(unit_from_column, unit_from_address)
]
# City
for field in ['POST_COMM', 'PostalCity', 'CITY', 'Jurisdicti']:
if field in processed.columns:
mapping['addr:city'] = [
qgis_functions.title(str(v)) if pd.notna(v) else None
for v in processed[field]
]
break
# Apply street name exceptions (some are scoped to a specific city)
if 'addr:street' in mapping and exceptions:
mapping['addr:street'] = apply_street_exceptions(
mapping['addr:street'],
mapping.get('addr:city', [None] * len(processed)),
exceptions,
)
# Postcode
for field in ['POST_CODE', 'ZipCode', 'ZIP', 'POSTAL_CODE']:
if field in processed.columns:
series = pd.to_numeric(processed[field], errors='coerce')
mapping['addr:postcode'] = series.round().astype('Int64')
break
mapping['addr:state'] = 'FL'
for key, value in mapping.items():
processed[key] = value
return processed
def convert(zip_path, output_path):
zip_path = Path(zip_path)
output_path = Path(output_path)
print(f"Converting {zip_path} ...")
exceptions = load_exceptions()
temp_dir = zip_path.parent / "temp_extract"
temp_dir.mkdir(exist_ok=True)
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(temp_dir)
shp_files = list(temp_dir.glob("*.shp"))
if not shp_files:
print("Error: no .shp file found in ZIP", file=sys.stderr)
sys.exit(1)
gdf = gpd.read_file(shp_files[0])
# LIFECYCLE filter (Sumter addresses use 'Current'; Lake has no such field)
LIFECYCLE_FIELD = 'LIFECYCLE'
ACTIVE_VALUE = 'Current'
if LIFECYCLE_FIELD in gdf.columns:
before = len(gdf)
gdf = gdf[gdf[LIFECYCLE_FIELD] == ACTIVE_VALUE].copy().reset_index(drop=True)
filtered = before - len(gdf)
if filtered:
print(f"Filtered out {filtered} non-active addresses (LIFECYCLE != '{ACTIVE_VALUE}')")
else:
print(f"All {len(gdf)} addresses are active")
# CRS conversion
if gdf.crs and gdf.crs != 'EPSG:4326':
print(f"Converting CRS from {gdf.crs} to EPSG:4326")
gdf = gdf.to_crs('EPSG:4326')
gdf = process_address_fields(gdf, exceptions)
# Points only, must have a house number
gdf = gdf[gdf.geometry.type == 'Point'].copy()
gdf = gdf[gdf['addr:housenumber'].notna()].copy()
osm_fields = ['addr:housenumber', 'addr:unit', 'addr:street',
'addr:city', 'addr:postcode', 'addr:state']
keep = [f for f in osm_fields if f in gdf.columns]
gdf = gdf[keep + ['geometry']]
output_path.parent.mkdir(parents=True, exist_ok=True)
gdf.to_file(output_path, driver='GeoJSON')
print(f"Saved {len(gdf)} addresses to {output_path}")
finally:
if temp_dir.exists():
shutil.rmtree(temp_dir)
def main():
parser = argparse.ArgumentParser(description="Convert county address shapefile to OSM-formatted GeoJSON")
parser.add_argument('input_zip', help='Path to county address ZIP (containing .shp)')
parser.add_argument('output_geojson', help='Output GeoJSON path')
args = parser.parse_args()
convert(args.input_zip, args.output_geojson)
if __name__ == '__main__':
main()