seriffic commited on
Commit
812f49f
·
1 Parent(s): 3f563ac

Microtopo specialist: 3DEP elevation + TWI + HAND

Browse files

USGS 3DEP 1/3 arcsec DEM (resampled to 30 m for runtime), with two
hydrology indices computed offline by whitebox-workflows:

TWI — topographic wetness, ln(a/tan β), high where flow accumulates
HAND — height above nearest drainage, low where overbank flooding
pools first

These three rasters give the briefing terrain context that the
empirical/forward-modelled flood layers can't: even a parcel outside
every published zone can be in a topographic sink that any heavy rain
will fill.

.gitattributes routes *.tif through LFS.

.gitattributes CHANGED
@@ -1,5 +1,6 @@
1
  # Riprap-specific LFS tracking
2
  *.geojson filter=lfs diff=lfs merge=lfs -text
 
3
 
4
  # Esri FileGDB internal binary files (DEP Stormwater scenario data)
5
  *.gdbtable filter=lfs diff=lfs merge=lfs -text
 
1
  # Riprap-specific LFS tracking
2
  *.geojson filter=lfs diff=lfs merge=lfs -text
3
+ *.tif filter=lfs diff=lfs merge=lfs -text
4
 
5
  # Esri FileGDB internal binary files (DEP Stormwater scenario data)
6
  *.gdbtable filter=lfs diff=lfs merge=lfs -text
app/context/microtopo.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LiDAR/DEM-derived micro-topography specialist.
2
+
3
+ Reads a window from a precomputed NYC-wide DEM (data/nyc_dem_30m.tif)
4
+ fetched from USGS 3DEP via py3dep. Computes per-address terrain numbers
5
+ that the static FEMA/DEP scenario maps don't expose.
6
+
7
+ Metrics (all derived from the same small AOI raster):
8
+
9
+ point_elev_m elevation at the address (m)
10
+ rel_elev_pct_750m percentile of point elev in a 750-m radius
11
+ rel_elev_pct_200m percentile of point elev in a 200-m radius
12
+ (block-scale "is this a bowl?")
13
+ basin_relief_m max-elev in 750-m AOI minus point elev
14
+ aoi_min_m, aoi_max_m for context
15
+ resolution_m
16
+
17
+ We deliberately stop at "shape-of-the-terrain" metrics rather than full
18
+ hydrology — depression-fill / D8 flow accumulation on a flat coastal
19
+ DEM are noisy and slow. Percentile + relief is what the reconciler
20
+ actually needs to write a useful sentence.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import warnings
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+
31
+ warnings.filterwarnings("ignore")
32
+
33
+ log = logging.getLogger("riprap.microtopo")
34
+
35
+ DOC_ID = "microtopo"
36
+ CITATION = "USGS 3DEP 30 m DEM (precomputed citywide GeoTIFF, WGS84)"
37
+
38
+ DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
39
+ DEM_PATH = DATA_DIR / "nyc_dem_30m.tif"
40
+ TWI_PATH = DATA_DIR / "twi.tif"
41
+ HAND_PATH = DATA_DIR / "hand.tif"
42
+
43
+
44
+ @dataclass
45
+ class Microtopo:
46
+ point_elev_m: float
47
+ rel_elev_pct_750m: float # 0..100
48
+ rel_elev_pct_200m: float # 0..100
49
+ basin_relief_m: float
50
+ aoi_min_m: float
51
+ aoi_max_m: float
52
+ aoi_radius_m: int
53
+ resolution_m: int
54
+ # Hydrology indices computed on the same DEM (whitebox-workflows)
55
+ twi: float | None = None # Topographic Wetness Index, ln(SCA / tan(slope))
56
+ hand_m: float | None = None # Height Above Nearest Drainage (m)
57
+
58
+
59
+ def _percentile_in_window(arr: np.ndarray, iy: int, ix: int, point_val: float,
60
+ window_radius_cells: int) -> float:
61
+ H, W = arr.shape
62
+ y0 = max(0, iy - window_radius_cells)
63
+ y1 = min(H, iy + window_radius_cells + 1)
64
+ x0 = max(0, ix - window_radius_cells)
65
+ x1 = min(W, ix + window_radius_cells + 1)
66
+ sub = arr[y0:y1, x0:x1]
67
+ finite = sub[np.isfinite(sub)]
68
+ if finite.size == 0:
69
+ return float("nan")
70
+ return float((finite < point_val).sum()) / finite.size * 100.0
71
+
72
+
73
+ _DEM_CACHE: dict = {}
74
+
75
+
76
+ def _read_full_raster(path: Path) -> tuple[np.ndarray | None, dict | None]:
77
+ import rasterio
78
+ if not path.exists():
79
+ return None, None
80
+ with rasterio.open(path) as ds:
81
+ arr = ds.read(1).astype("float32")
82
+ nodata = ds.nodata
83
+ meta = {"H": ds.height, "W": ds.width,
84
+ "transform": ds.transform, "crs": ds.crs, "nodata": nodata}
85
+ if nodata is not None:
86
+ arr = np.where(arr == nodata, np.nan, arr)
87
+ return arr, meta
88
+
89
+
90
+ def _load_dem():
91
+ """Read the precomputed NYC DEM + TWI + HAND rasters into memory.
92
+
93
+ All three are aligned (same grid, same transform). We hold them as
94
+ numpy arrays so per-query slicing is safe under threading.
95
+ """
96
+ if "arr" in _DEM_CACHE:
97
+ return _DEM_CACHE
98
+ arr, meta = _read_full_raster(DEM_PATH)
99
+ if arr is None:
100
+ log.warning("microtopo DEM not found at %s — run scripts/fetch_nyc_dem.py", DEM_PATH)
101
+ return None
102
+ twi, _ = _read_full_raster(TWI_PATH)
103
+ hand, _ = _read_full_raster(HAND_PATH)
104
+ _DEM_CACHE.update({
105
+ "arr": arr, "H": meta["H"], "W": meta["W"],
106
+ "transform": meta["transform"], "crs": meta["crs"],
107
+ "twi": twi, "hand": hand,
108
+ })
109
+ note = []
110
+ if twi is not None: note.append(f"TWI {TWI_PATH.name}")
111
+ if hand is not None: note.append(f"HAND {HAND_PATH.name}")
112
+ log.info("microtopo: loaded NYC DEM %s (%dx%d, %s); aux: %s",
113
+ DEM_PATH.name, meta["H"], meta["W"], meta["crs"],
114
+ ", ".join(note) if note else "(none — algorithmic only)")
115
+ return _DEM_CACHE
116
+
117
+
118
+ def warm():
119
+ _load_dem()
120
+
121
+
122
+ def _row_col(transform, lat: float, lon: float) -> tuple[int, int]:
123
+ """Inverse-affine: WGS84 (lon,lat) -> raster (row, col).
124
+ Mirrors rasterio.transform.rowcol but without holding a dataset handle.
125
+ """
126
+ # affine: x = a*col + b*row + c ; y = d*col + e*row + f
127
+ # invert: col = (a_inv * (x - c)) approx — we have a diagonal affine
128
+ a, b, c, d, e, f = transform.a, transform.b, transform.c, transform.d, transform.e, transform.f
129
+ # diagonal case (b=d=0, common for north-up rasters):
130
+ col = int(round((lon - c) / a))
131
+ row = int(round((lat - f) / e))
132
+ return row, col
133
+
134
+
135
+ def microtopo_at(lat: float, lon: float, radius_m: int = 750) -> Microtopo | None:
136
+ state = _load_dem()
137
+ if state is None:
138
+ return None
139
+ arr_full = state["arr"]
140
+ transform = state["transform"]
141
+
142
+ try:
143
+ row, col = _row_col(transform, lat, lon)
144
+ except Exception as e:
145
+ log.warning("microtopo index failed: %s", e)
146
+ return None
147
+
148
+ res_m = abs(transform.a) * 111_000.0 * np.cos(np.radians(lat))
149
+ cells_radius = max(2, int(np.ceil(radius_m / max(res_m, 1.0))))
150
+
151
+ H, W = state["H"], state["W"]
152
+ y0 = max(0, row - cells_radius); y1 = min(H, row + cells_radius + 1)
153
+ x0 = max(0, col - cells_radius); x1 = min(W, col + cells_radius + 1)
154
+ if y1 <= y0 or x1 <= x0:
155
+ return None
156
+
157
+ arr = arr_full[y0:y1, x0:x1].copy()
158
+
159
+ iy = row - y0
160
+ ix = col - x0
161
+ if not (0 <= iy < arr.shape[0] and 0 <= ix < arr.shape[1]):
162
+ return None
163
+
164
+ point_elev = float(arr[iy, ix])
165
+ if not np.isfinite(point_elev):
166
+ for r in range(1, 6):
167
+ ya, yb = max(0, iy - r), min(arr.shape[0], iy + r + 1)
168
+ xa, xb = max(0, ix - r), min(arr.shape[1], ix + r + 1)
169
+ sub = arr[ya:yb, xa:xb]
170
+ if np.isfinite(sub).any():
171
+ point_elev = float(np.nanmean(sub))
172
+ break
173
+ else:
174
+ return None
175
+
176
+ finite = arr[np.isfinite(arr)]
177
+ if finite.size == 0:
178
+ return None
179
+ aoi_min = float(finite.min())
180
+ aoi_max = float(finite.max())
181
+
182
+ pct_750 = float((finite < point_elev).sum()) / finite.size * 100.0
183
+ cells_200m = max(1, int(round(200 / max(res_m, 1.0))))
184
+ pct_200 = _percentile_in_window(arr, iy, ix, point_elev, cells_200m)
185
+
186
+ twi_arr = state.get("twi")
187
+ hand_arr = state.get("hand")
188
+ twi_v: float | None = None
189
+ hand_v: float | None = None
190
+ if twi_arr is not None and 0 <= row < H and 0 <= col < W:
191
+ v = float(twi_arr[row, col])
192
+ twi_v = round(v, 2) if np.isfinite(v) else None
193
+ if hand_arr is not None and 0 <= row < H and 0 <= col < W:
194
+ v = float(hand_arr[row, col])
195
+ hand_v = round(v, 2) if np.isfinite(v) else None
196
+
197
+ return Microtopo(
198
+ point_elev_m=round(point_elev, 2),
199
+ rel_elev_pct_750m=round(pct_750, 1),
200
+ rel_elev_pct_200m=round(pct_200, 1),
201
+ basin_relief_m=round(aoi_max - point_elev, 2),
202
+ aoi_min_m=round(aoi_min, 2),
203
+ aoi_max_m=round(aoi_max, 2),
204
+ aoi_radius_m=radius_m,
205
+ resolution_m=int(round(res_m)),
206
+ twi=twi_v,
207
+ hand_m=hand_v,
208
+ )
data/hand.tif ADDED

Git LFS Details

  • SHA256: d7c2b97da1de805e457b7dc8007b7bc2fc8129d179cb3b415c4bca80d47e6616
  • Pointer size: 133 Bytes
  • Size of remote file: 16.8 MB
data/nyc_dem_30m.tif ADDED

Git LFS Details

  • SHA256: 98295d9d9be7e47302a5a55ee30c69f1e1b2f69c917f6f8a13f8bd16a2fbe45d
  • Pointer size: 133 Bytes
  • Size of remote file: 15.6 MB
data/twi.tif ADDED

Git LFS Details

  • SHA256: feaa1e615827687038ef5a02a141b94c84c82694b516b35955e55099e60a3209
  • Pointer size: 133 Bytes
  • Size of remote file: 21.3 MB