""" Pure-Python geometry for the intel store — GeoJSON Polygon/MultiPolygon only. Enough for Phase 1 (LGA centroids, which-LGA-is-this-point-in, distances) without pulling a GIS stack into the backend; PostGIS replaces this when the store moves to Postgres (ADR-0020). """ import math EARTH_RADIUS_KM = 6371.0088 def _rings(geometry: dict) -> list[list[list[list[float]]]]: """Normalises Polygon and MultiPolygon to a list of polygons, each a list of rings (outer ring first, then holes), each ring a list of [lon, lat].""" if geometry["type"] == "Polygon": return [geometry["coordinates"]] if geometry["type"] == "MultiPolygon": return geometry["coordinates"] raise ValueError(f"Unsupported geometry type: {geometry['type']}") def _ring_area_centroid(ring: list[list[float]]) -> tuple[float, float, float]: """Signed area and area-weighted centroid of one ring (shoelace formula).""" a = cx = cy = 0.0 for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]): cross = x0 * y1 - x1 * y0 a += cross cx += (x0 + x1) * cross cy += (y0 + y1) * cross a /= 2.0 if a == 0: xs, ys = [p[0] for p in ring], [p[1] for p in ring] return 0.0, sum(xs) / len(xs), sum(ys) / len(ys) return a, cx / (6.0 * a), cy / (6.0 * a) def centroid(geometry: dict) -> tuple[float, float]: """Area-weighted centroid as (lon, lat). Holes subtract their area. Planar maths on degrees is fine at LGA scale this close to the equator.""" total = sx = sy = 0.0 for polygon in _rings(geometry): for i, ring in enumerate(polygon): a, cx, cy = _ring_area_centroid([p[:2] for p in ring]) a = abs(a) if i == 0 else -abs(a) total += a sx += cx * a sy += cy * a if total == 0: raise ValueError("Degenerate geometry") return sx / total, sy / total def bbox(geometry: dict) -> tuple[float, float, float, float]: xs, ys = [], [] for polygon in _rings(geometry): for p in polygon[0]: xs.append(p[0]) ys.append(p[1]) return min(xs), min(ys), max(xs), max(ys) def _in_ring(lon: float, lat: float, ring: list[list[float]]) -> bool: inside = False for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]): if (y0 > lat) != (y1 > lat) and lon < (x1 - x0) * (lat - y0) / (y1 - y0) + x0: inside = not inside return inside def contains(geometry: dict, lon: float, lat: float) -> bool: for polygon in _rings(geometry): outer, holes = polygon[0], polygon[1:] if _in_ring(lon, lat, [p[:2] for p in outer]) and not any(_in_ring(lon, lat, [p[:2] for p in h]) for h in holes): return True return False def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float: p1, p2 = math.radians(lat1), math.radians(lat2) dp, dl = p2 - p1, math.radians(lon2 - lon1) h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(h)) class PolygonIndex: """Point-in-polygon lookup over many features, with a bbox prefilter so a lookup only runs the ray-cast against the handful of polygons whose box contains the point.""" def __init__(self, features: list[tuple[str, dict]]): self._items = [(key, geom, bbox(geom)) for key, geom in features] def lookup(self, lon: float, lat: float) -> str | None: for key, geom, (x0, y0, x1, y1) in self._items: if x0 <= lon <= x1 and y0 <= lat <= y1 and contains(geom, lon, lat): return key return None