Speed up address and road diffs from hours to seconds, gzip GeoJSON downloads, and render map points on canvas with viewport-only OSM/county layers

This commit is contained in:
zyphlar
2026-10-21 19:46:02 -07:00
parent 28550984be
commit 4759424e85
4 changed files with 296 additions and 288 deletions
+65 -76
View File
@@ -99,9 +99,12 @@ class AddressComparator:
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]
tree = STRtree(geoms)
radius_deg = radius_meters / 111000.0
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:
@@ -111,13 +114,13 @@ class AddressComparator:
if not num or not street:
continue
best, best_dist = None, float('inf')
for j in tree.query(addr['geometry'].buffer(radius_deg)):
for j in by_num.get(num, []):
if j in paired:
continue
osm = removed_addresses[j]
if self._normalize_multi(osm.get('addr:housenumber')) != num:
continue
osm_street = self._normalize_street_name(str(osm.get('addr:street', '')))
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'))
@@ -162,97 +165,83 @@ class AddressComparator:
if local_gdf.crs != osm_gdf.crs:
osm_gdf = osm_gdf.to_crs(local_gdf.crs)
osm_index = STRtree(osm_gdf.geometry.tolist())
# 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 _, local_row in local_gdf.iterrows():
local_point = local_row.geometry
nearby = osm_index.query(local_point.buffer(self.tolerance_deg))
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]
best_match = None
min_distance = float('inf')
# 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
for osm_idx in nearby:
osm_row = osm_gdf.iloc[osm_idx]
distance = local_point.distance(osm_row.geometry)
# 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)
local_num = self._normalize_multi(local_row.get('addr:housenumber', ''))
osm_num = self._normalize_multi(osm_row.get('addr:housenumber', ''))
local_street = self._normalize_street_name(str(local_row.get('addr:street', '')))
osm_street = self._normalize_street_name(str(osm_row.get('addr:street', '')))
street_match = local_street == osm_street and local_street != ''
local_unit = self._normalize_multi(local_row.get('addr:unit'))
osm_unit = self._normalize_multi(osm_row.get('addr:unit'))
if local_unit and osm_unit:
unit_match = local_unit == osm_unit
elif local_unit or osm_unit:
unit_match = False
else:
unit_match = True
if local_num == osm_num and street_match and unit_match and distance < min_distance:
min_distance = distance
best_match = osm_idx
distance_m = min_distance * 111000.0
if best_match is not None and distance_m <= self.tolerance_meters:
if best_match is not None:
existing_addresses.append({
'geometry': local_point,
'osm_geometry': osm_gdf.iloc[best_match].geometry,
'local_data': dict(local_row.drop('geometry')),
'osm_data': dict(osm_gdf.iloc[best_match].drop('geometry')),
'distance_meters': distance_m,
'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_row.drop('geometry'))
props = dict(local_props[i])
props['status'] = 'new'
new_addresses.append({'geometry': local_point, **props})
# Second pass: find OSM addresses with no local match (potentially removed)
matched_osm = set()
for _, local_row in local_gdf.iterrows():
local_point = local_row.geometry
nearby = osm_index.query(local_point.buffer(self.tolerance_deg))
local_num = self._normalize_multi(local_row.get('addr:housenumber', ''))
local_street = self._normalize_street_name(str(local_row.get('addr:street', '')))
local_unit = self._normalize_multi(local_row.get('addr:unit'))
for osm_idx in nearby:
osm_row = osm_gdf.iloc[osm_idx]
dist_m = local_point.distance(osm_row.geometry) * 111000.0
osm_num = self._normalize_multi(osm_row.get('addr:housenumber', ''))
osm_st = self._normalize_street_name(str(osm_row.get('addr:street', '')))
osm_unit = self._normalize_multi(osm_row.get('addr:unit'))
unit_match = True
if local_unit and osm_unit:
unit_match = local_unit == osm_unit
if (dist_m <= self.tolerance_meters and
local_num == osm_num and
local_street == osm_st and local_street != '' and
unit_match):
matched_osm.add(osm_idx)
break
removed_addresses = []
for idx, osm_row in osm_gdf.iterrows():
if idx not in matched_osm:
props = dict(osm_row.drop('geometry'))
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_row.geometry, **props})
removed_addresses.append({'geometry': osm_point, **props})
self._flag_close_conflicts(new_addresses)
renames = self._flag_possible_renames(new_addresses, removed_addresses)