"""Geo helpers: ZIP geocoding, haversine distance, bounding-box radius filter. Portable across MySQL and SQLite (no spatial extension needed). For large-scale deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade. """ import math from app.extensions import db from app.models.geo import ZipGeo EARTH_MI = 3958.7613 # mean earth radius, miles def geocode_zip(zip_code): """Return (lat, lng, city, state, metro) or None.""" row = db.session.get(ZipGeo, (zip_code or "").strip()) if row is None: return None return row.lat, row.lng, row.city, row.state, row.metro def haversine_mi(lat1, lng1, lat2, lng2): rlat1, rlat2 = math.radians(lat1), math.radians(lat2) dlat = math.radians(lat2 - lat1) dlng = math.radians(lng2 - lng1) a = (math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlng / 2) ** 2) return EARTH_MI * 2 * math.asin(math.sqrt(a)) def bounding_box(lat, lng, radius_mi): """Lat/lng min/max box enclosing the radius. Cheap indexed prefilter.""" lat_delta = radius_mi / 69.0 # guard against poles; cos(lat) shrinks lng degrees-per-mile cos_lat = max(math.cos(math.radians(lat)), 0.01) lng_delta = radius_mi / (69.172 * cos_lat) return (lat - lat_delta, lat + lat_delta, lng - lng_delta, lng + lng_delta)