seriffic's picture
Backend evolution: Phases 1-10 specialists + agentic FSM + Mellea + LiteLLM router
6a82282
"""nycha_development_exposure — flood-exposure briefing per NYCHA development.
Same pattern as the MTA-entrance specialist, but NYCHA developments are
*polygons* not points, so the metrics shift to overlap fractions:
- % of footprint inside the 2012 Sandy Inundation Zone (empirical)
- % of footprint inside DEP Extreme-2080 / Moderate-2050 scenarios
(modeled, broken out by depth class)
- Representative-point elevation, HAND, TWI (proxy)
- Footprint area (km²)
- Distance from query point to development boundary
Joins:
- data/nycha.geojson (NYC Open Data, 218 NYCHA developments)
- data/sandy_inundation.geojson
- DEP Stormwater Flood Map polygons (3 scenarios)
- data/nyc_dem_30m.tif, data/hand.tif
Per queried (lat, lon), returns developments whose centroid is within
the radius (default 2000 m — NYCHA developments are sparser than
subway entrances, so the radius is wider).
Honest scope:
- This is exposure, not damage forecast. We say "85% of this
development's footprint is inside the 2012 Sandy zone" — not
"this development will flood next storm".
- All overlap fractions are computed in EPSG:2263 (NYC State Plane,
feet) for accurate area arithmetic in the city.
"""
from __future__ import annotations
import json
import logging
import math
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
log = logging.getLogger("riprap.nycha")
DATA = _ROOT / "data"
NYCHA = DATA / "nycha.geojson"
DEFAULT_RADIUS_M = 2000
DEFAULT_MAX_PER_QUERY = 5
@dataclass
class DevelopmentFinding:
development: str
tds_num: str
borough: str
centroid_lat: float
centroid_lon: float
distance_m: float
footprint_km2: float
rep_elevation_m: float | None
rep_hand_m: float | None
pct_inside_sandy_2012: float
pct_in_dep_extreme_2080: float # any-depth (class>=1)
pct_in_dep_extreme_2080_deep: float # class==3 only ("Deep Contiguous")
pct_in_dep_moderate_2050: float
@lru_cache(maxsize=1)
def _load_nycha():
import geopandas as gpd
gdf = gpd.read_file(NYCHA).to_crs("EPSG:2263") # feet, accurate areas
gdf["centroid_2263"] = gdf.geometry.centroid
return gdf.reset_index(drop=True)
@lru_cache(maxsize=1)
def _load_sandy_2263():
"""Load the Sandy zone in EPSG:2263 once. Already used by
app.flood_layers.sandy_inundation but we want the geometry directly
for overlap-fraction math."""
import geopandas as gpd
g = gpd.read_file(DATA / "sandy_inundation.geojson").to_crs("EPSG:2263")
# Some NYC OEM Sandy polygons have hole-orientation issues that
# blow up unary_union. buffer(0) fixes self-intersections without
# changing the footprint at sub-foot precision.
g["geometry"] = g.geometry.buffer(0)
return g.geometry.union_all()
@lru_cache(maxsize=4)
def _load_dep_2263(scenario: str):
"""DEP scenario polygons in EPSG:2263, with depth_class column."""
import geopandas as gpd
p = DATA / "dep" / f"{scenario}.geojson"
if not p.exists():
# Fallback to whatever the existing dep_stormwater module loaded.
from app.flood_layers import dep_stormwater
gdf = dep_stormwater.load(scenario)
return gdf.to_crs("EPSG:2263") if gdf.crs is not None else gdf
return gpd.read_file(p).to_crs("EPSG:2263")
def _haversine_m(lat1, lon1, lat2, lon2) -> float:
R = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1); dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _sample_raster(raster_path: Path, lat: float, lon: float) -> float | None:
if not raster_path.exists():
return None
try:
import rasterio
with rasterio.open(raster_path) as src:
v = next(src.sample([(lon, lat)]))[0]
v = float(v)
if math.isnan(v) or v == src.nodata:
return None
return v
except Exception:
log.exception("raster sample failed for %s", raster_path)
return None
def _developments_near(lat: float, lon: float, radius_m: float):
"""Return developments whose centroid is within `radius_m` of
(lat, lon). Uses haversine on centroids re-projected back to
EPSG:4326 — the bbox prefilter gets us close, then exact distance."""
import geopandas as gpd
gdf = _load_nycha()
# Re-project centroids to 4326 for haversine
cents_4326 = gpd.GeoSeries(gdf["centroid_2263"], crs="EPSG:2263").to_crs("EPSG:4326")
deg = radius_m / 90_000
cent_lat = cents_4326.y
cent_lon = cents_4326.x
mask = ((cent_lat >= lat - deg) & (cent_lat <= lat + deg)
& (cent_lon >= lon - deg) & (cent_lon <= lon + deg))
sub = gdf[mask].copy()
if sub.empty:
return sub, []
sub["clat"] = cent_lat[mask].values
sub["clon"] = cent_lon[mask].values
sub["distance_m"] = sub.apply(
lambda r: _haversine_m(lat, lon, r["clat"], r["clon"]),
axis=1,
)
sub = sub[sub["distance_m"] <= radius_m].sort_values("distance_m")
return sub, sub.index.tolist()
def _overlap_pct(geom_2263, mask_geom_2263) -> float:
"""% of geom_2263's area that intersects mask_geom_2263."""
if mask_geom_2263 is None or mask_geom_2263.is_empty:
return 0.0
inter = geom_2263.intersection(mask_geom_2263)
if inter.is_empty:
return 0.0
return round(100.0 * inter.area / max(geom_2263.area, 1e-9), 2)
def _dep_overlap(geom_2263, scenario: str) -> tuple[float, float]:
"""Return (pct_any_depth, pct_deep_contiguous) of a polygon's area
inside the DEP scenario."""
try:
gdf = _load_dep_2263(scenario)
except Exception:
log.exception("DEP load failed for %s", scenario)
return 0.0, 0.0
if gdf is None or gdf.empty:
return 0.0, 0.0
# Bbox-prefilter the DEP polygons to those near our development.
minx, miny, maxx, maxy = geom_2263.bounds
cand = gdf.cx[minx:maxx, miny:maxy]
if cand.empty:
return 0.0, 0.0
# DEP NYC stormwater FGDB uses `Flooding_Category` (int16):
# 1=nuisance, 2=shallow, 3=deep contiguous (>4 ft).
cat_col = "Flooding_Category" if "Flooding_Category" in cand.columns else None
any_geom = cand.geometry.buffer(0).union_all()
if cat_col:
deep = cand[cand[cat_col] == 3]
deep_geom = deep.geometry.buffer(0).union_all() if not deep.empty else None
else:
deep_geom = None
pct_any = _overlap_pct(geom_2263, any_geom)
pct_deep = _overlap_pct(geom_2263, deep_geom) if deep_geom is not None else 0.0
return pct_any, pct_deep
def summary_for_point(lat: float, lon: float,
radius_m: float = DEFAULT_RADIUS_M,
max_developments: int = DEFAULT_MAX_PER_QUERY) -> dict:
near, _ = _developments_near(lat, lon, radius_m)
if near.empty:
return {"available": False,
"n_developments": 0,
"radius_m": radius_m,
"developments": []}
near = near.head(max_developments)
sandy_2263 = _load_sandy_2263()
findings: list[DevelopmentFinding] = []
for _, row in near.iterrows():
geom = row.geometry
# Representative interior point gives a more meaningful elevation
# than the centroid for irregular development footprints.
rep = geom.representative_point()
# Re-project the rep point to 4326 for raster sampling
import geopandas as gpd
rep_4326 = gpd.GeoSeries([rep], crs="EPSG:2263").to_crs("EPSG:4326").iloc[0]
rep_lat, rep_lon = rep_4326.y, rep_4326.x
elev = _sample_raster(DATA / "nyc_dem_30m.tif", rep_lat, rep_lon)
hand = _sample_raster(DATA / "hand.tif", rep_lat, rep_lon)
pct_sandy = _overlap_pct(geom, sandy_2263)
pct_2080_any, pct_2080_deep = _dep_overlap(geom, "dep_extreme_2080")
pct_2050_any, _ = _dep_overlap(geom, "dep_moderate_2050")
findings.append(DevelopmentFinding(
development=str(row["developmen"]),
tds_num=str(row["tds_num"]),
borough=str(row["borough"]),
centroid_lat=round(float(row["clat"]), 5),
centroid_lon=round(float(row["clon"]), 5),
distance_m=round(float(row["distance_m"]), 1),
footprint_km2=round(geom.area / 10.7639 / 1_000_000, 4), # sq-ft -> km²
rep_elevation_m=round(elev, 2) if elev is not None else None,
rep_hand_m=round(hand, 2) if hand is not None else None,
pct_inside_sandy_2012=pct_sandy,
pct_in_dep_extreme_2080=pct_2080_any,
pct_in_dep_extreme_2080_deep=pct_2080_deep,
pct_in_dep_moderate_2050=pct_2050_any,
))
n_majority_sandy = sum(1 for f in findings if f.pct_inside_sandy_2012 >= 50)
n_any_2080 = sum(1 for f in findings if f.pct_in_dep_extreme_2080 > 0)
return {
"available": True,
"n_developments": len(findings),
"radius_m": radius_m,
"n_majority_inside_sandy_2012": n_majority_sandy,
"n_with_dep_2080_overlap": n_any_2080,
"developments": [vars(f) for f in findings],
"citation": ("NYC Open Data NYCHA Developments (phvi-damg) + "
"NYC OEM Sandy 2012 Inundation Zone (5xsi-dfpx) + "
"NYC DEP Stormwater Flood Maps + USGS 3DEP DEM"),
}
def main() -> int:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--lat", type=float, required=True)
ap.add_argument("--lon", type=float, required=True)
ap.add_argument("--radius", type=float, default=DEFAULT_RADIUS_M)
ap.add_argument("--max", type=int, default=DEFAULT_MAX_PER_QUERY)
args = ap.parse_args()
s = summary_for_point(args.lat, args.lon, args.radius, args.max)
print(json.dumps(s, indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())