fimeval 0.1.56__py3-none-any.whl → 0.1.58__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fimeval/BenchFIMQuery/__init__.py +5 -0
- fimeval/BenchFIMQuery/access_benchfim.py +824 -0
- fimeval/BenchFIMQuery/utilis.py +334 -0
- fimeval/BuildingFootprint/__init__.py +2 -1
- fimeval/BuildingFootprint/arcgis_API.py +195 -0
- fimeval/BuildingFootprint/evaluationwithBF.py +21 -63
- fimeval/ContingencyMap/__init__.py +2 -2
- fimeval/ContingencyMap/evaluationFIM.py +123 -62
- fimeval/ContingencyMap/printcontingency.py +3 -1
- fimeval/ContingencyMap/water_bodies.py +175 -0
- fimeval/__init__.py +10 -1
- fimeval/setup_benchFIM.py +41 -0
- fimeval/utilis.py +47 -0
- {fimeval-0.1.56.dist-info → fimeval-0.1.58.dist-info}/METADATA +47 -23
- fimeval-0.1.58.dist-info/RECORD +21 -0
- {fimeval-0.1.56.dist-info → fimeval-0.1.58.dist-info}/WHEEL +1 -1
- fimeval/BuildingFootprint/microsoftBF.py +0 -132
- fimeval/ContingencyMap/PWBs3.py +0 -42
- fimeval-0.1.56.dist-info/RECORD +0 -17
- {fimeval-0.1.56.dist-info → fimeval-0.1.58.dist-info}/licenses/LICENSE.txt +0 -0
- {fimeval-0.1.56.dist-info → fimeval-0.1.58.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level benchmark FIM access and query service.
|
|
3
|
+
Author: Supath Dhital, sdhital@crimson.ua.edu
|
|
4
|
+
Updated: 25 Nov, 2025
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import rasterio
|
|
14
|
+
from rasterio.warp import transform_bounds
|
|
15
|
+
import geopandas as gpd
|
|
16
|
+
from shapely.geometry import box, Polygon
|
|
17
|
+
from shapely.ops import unary_union
|
|
18
|
+
|
|
19
|
+
from .utilis import (
|
|
20
|
+
load_catalog_core,
|
|
21
|
+
download_fim_assets,
|
|
22
|
+
format_records_for_print,
|
|
23
|
+
_to_date,
|
|
24
|
+
_to_hour_or_none,
|
|
25
|
+
_record_day,
|
|
26
|
+
_record_hour_or_none,
|
|
27
|
+
_pretty_date_for_print,
|
|
28
|
+
_ensure_local_gpkg,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Preferred area CRSs for area calculations
|
|
32
|
+
AREA_CRS_US = "EPSG:5070" # for CONUS
|
|
33
|
+
AREA_CRS_GLOBAL = "EPSG:6933" # WGS 84 / NSIDC EASE-Grid 2.0 Global (equal-area) for rest of world: in future if the data is added into the catalog.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Helper: pretty-print container so that print(response) shows the structured text.
|
|
37
|
+
# If "printable" is empty (e.g., download=True), nothing is printed.
|
|
38
|
+
class PrettyDict(dict):
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
txt = self.get("printable", "")
|
|
41
|
+
if isinstance(txt, str) and txt.strip():
|
|
42
|
+
return txt
|
|
43
|
+
# Empty string when we do not want anything printed (e.g., download=True)
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
__repr__ = __str__
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Helper functions for catalog / geometry
|
|
50
|
+
def _get_record_bbox_xy(rec: Dict[str, Any]) -> Tuple[float, float, float, float]:
|
|
51
|
+
"""
|
|
52
|
+
Return WGS84 bbox from the catalog_core
|
|
53
|
+
"""
|
|
54
|
+
cand = None
|
|
55
|
+
|
|
56
|
+
if "bbox" in rec:
|
|
57
|
+
cand = rec["bbox"]
|
|
58
|
+
elif "bbox_wgs84" in rec:
|
|
59
|
+
cand = rec["bbox_wgs84"]
|
|
60
|
+
else:
|
|
61
|
+
# try explicit keys
|
|
62
|
+
keys_variants = [
|
|
63
|
+
("xmin", "ymin", "xmax", "ymax"),
|
|
64
|
+
("minx", "miny", "maxx", "maxy"),
|
|
65
|
+
]
|
|
66
|
+
for kx0, ky0, kx1, ky1 in keys_variants:
|
|
67
|
+
if all(k in rec for k in (kx0, ky0, kx1, ky1)):
|
|
68
|
+
return (
|
|
69
|
+
float(rec[kx0]),
|
|
70
|
+
float(rec[ky0]),
|
|
71
|
+
float(rec[kx1]),
|
|
72
|
+
float(rec[ky1]),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if cand is None:
|
|
76
|
+
raise KeyError(
|
|
77
|
+
"Record does not contain bbox information (bbox_wgs84 / bbox / xmin..ymax)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# dict form
|
|
81
|
+
if isinstance(cand, dict):
|
|
82
|
+
for kx0, ky0, kx1, ky1 in [
|
|
83
|
+
("xmin", "ymin", "xmax", "ymax"),
|
|
84
|
+
("minx", "miny", "maxx", "maxy"),
|
|
85
|
+
]:
|
|
86
|
+
if all(k in cand for k in (kx0, ky0, kx1, ky1)):
|
|
87
|
+
return (
|
|
88
|
+
float(cand[kx0]),
|
|
89
|
+
float(cand[ky0]),
|
|
90
|
+
float(cand[kx1]),
|
|
91
|
+
float(cand[ky1]),
|
|
92
|
+
)
|
|
93
|
+
cand = list(cand.values())
|
|
94
|
+
|
|
95
|
+
# string form
|
|
96
|
+
if isinstance(cand, str):
|
|
97
|
+
parts = [p.strip() for p in cand.split(",") if p.strip()]
|
|
98
|
+
if len(parts) != 4:
|
|
99
|
+
raise ValueError(f"Could not parse record bbox string: {cand!r}")
|
|
100
|
+
return tuple(float(p) for p in parts)
|
|
101
|
+
|
|
102
|
+
# list / tuple
|
|
103
|
+
if isinstance(cand, (list, tuple)) and len(cand) == 4:
|
|
104
|
+
return tuple(float(v) for v in cand)
|
|
105
|
+
raise ValueError(f"Unrecognized bbox structure on record: {type(cand)!r}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _record_bbox_polygon(rec: Dict[str, Any]) -> Polygon:
|
|
109
|
+
minx, miny, maxx, maxy = _get_record_bbox_xy(rec)
|
|
110
|
+
return box(minx, miny, maxx, maxy)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Return AOI polygon in WGS84 from a raster file --> this will be useful when user have model predicted raster and looking for the benchmrk FIM into the database
|
|
114
|
+
def _raster_aoi_polygon_wgs84(path: str) -> Polygon:
|
|
115
|
+
with rasterio.open(path) as ds:
|
|
116
|
+
if ds.crs is None:
|
|
117
|
+
raise ValueError(f"Raster {path} has no CRS; cannot derive WGS84 bbox.")
|
|
118
|
+
left, bottom, right, top = ds.bounds
|
|
119
|
+
minx, miny, maxx, maxy = transform_bounds(
|
|
120
|
+
ds.crs, "EPSG:4326", left, bottom, right, top, densify_pts=21
|
|
121
|
+
)
|
|
122
|
+
return box(minx, miny, maxx, maxy)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Return AOI polygon in WGS84 from a vector file --> this will be useful when user have model predicted vector and looking for the benchmrk FIM into the database
|
|
126
|
+
def _vector_aoi_polygon_wgs84(path: str) -> Polygon:
|
|
127
|
+
gdf = gpd.read_file(path)
|
|
128
|
+
if gdf.empty:
|
|
129
|
+
raise ValueError(f"Vector file {path} contains no features.")
|
|
130
|
+
if gdf.crs is None:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Vector file {path} has no CRS; cannot derive WGS84 geometry."
|
|
133
|
+
)
|
|
134
|
+
gdf = gdf.to_crs("EPSG:4326")
|
|
135
|
+
geom = unary_union(gdf.geometry)
|
|
136
|
+
if geom.is_empty:
|
|
137
|
+
raise ValueError(f"Vector file {path} has empty geometry after union.")
|
|
138
|
+
return geom
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ensure_dir(path: str | Path) -> Path:
|
|
142
|
+
p = Path(path)
|
|
143
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
return p
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# benchmark FIM filtering by date
|
|
148
|
+
def _filter_by_date_exact(
|
|
149
|
+
records: List[Dict[str, Any]],
|
|
150
|
+
date_input: Optional[str],
|
|
151
|
+
) -> List[Dict[str, Any]]:
|
|
152
|
+
"""
|
|
153
|
+
Filter records by exact date and optional hour (if provided with date) using catalog date fields.
|
|
154
|
+
|
|
155
|
+
- If ``date_input`` is None, returns the input list unchanged.
|
|
156
|
+
- If date-only: keep records whose record-day matches and that have *no* hour info.
|
|
157
|
+
- If date + hour: keep records whose day and hour both match.
|
|
158
|
+
"""
|
|
159
|
+
if date_input is None:
|
|
160
|
+
return records
|
|
161
|
+
|
|
162
|
+
target_day = _to_date(date_input)
|
|
163
|
+
target_hour = _to_hour_or_none(date_input)
|
|
164
|
+
out: List[Dict[str, Any]] = []
|
|
165
|
+
|
|
166
|
+
for r in records:
|
|
167
|
+
r_day = _record_day(r)
|
|
168
|
+
if r_day != target_day:
|
|
169
|
+
continue
|
|
170
|
+
r_hour = _record_hour_or_none(r)
|
|
171
|
+
if target_hour is None:
|
|
172
|
+
if r_hour is None:
|
|
173
|
+
out.append(r)
|
|
174
|
+
else:
|
|
175
|
+
if r_hour is not None and r_hour == target_hour:
|
|
176
|
+
out.append(r)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Filter available benchmark FIMs by date range
|
|
181
|
+
def _filter_by_date_range(
|
|
182
|
+
records: List[Dict[str, Any]],
|
|
183
|
+
start_date: Optional[str],
|
|
184
|
+
end_date: Optional[str],
|
|
185
|
+
) -> List[Dict[str, Any]]:
|
|
186
|
+
if not start_date and not end_date:
|
|
187
|
+
return records
|
|
188
|
+
|
|
189
|
+
d0 = _to_date(start_date) if start_date else None
|
|
190
|
+
d1 = _to_date(end_date) if end_date else None
|
|
191
|
+
|
|
192
|
+
out: List[Dict[str, Any]] = []
|
|
193
|
+
for r in records:
|
|
194
|
+
r_day = _record_day(r)
|
|
195
|
+
if not r_day:
|
|
196
|
+
continue
|
|
197
|
+
if d0 and r_day < d0:
|
|
198
|
+
continue
|
|
199
|
+
if d1 and r_day > d1:
|
|
200
|
+
continue
|
|
201
|
+
out.append(r)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Dynamic area CRS selection and overlap stats
|
|
206
|
+
def _pick_area_crs_for_bounds(bounds: Tuple[float, float, float, float]) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Choose an appropriate projected CRS based on bbox in WGS84 to calculate the approximate area of overlap between user passed AOI and benchmark AOI
|
|
209
|
+
|
|
210
|
+
Logic:
|
|
211
|
+
- If the bbox centroid is roughly over CONUS, use EPSG:5070.
|
|
212
|
+
- Otherwise, fall back to global equal-area EPSG:6933.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
bounds:
|
|
217
|
+
(minx, miny, maxx, maxy) in EPSG:4326.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
str
|
|
222
|
+
CRS string suitable for GeoPandas .to_crs().
|
|
223
|
+
"""
|
|
224
|
+
minx, miny, maxx, maxy = bounds
|
|
225
|
+
cx = (minx + maxx) / 2.0
|
|
226
|
+
cy = (miny + maxy) / 2.0
|
|
227
|
+
|
|
228
|
+
# Rough check whether it is over CONUS
|
|
229
|
+
if -130.0 <= cx <= -60.0 and 20.0 <= cy <= 55.0:
|
|
230
|
+
return AREA_CRS_US
|
|
231
|
+
return AREA_CRS_GLOBAL
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Compute the area overlap statistics between user passed AOI/raster and benchmark AOI
|
|
235
|
+
def _compute_area_overlap_stats(
|
|
236
|
+
aoi_geom: Polygon,
|
|
237
|
+
benchmark_geom: Polygon,
|
|
238
|
+
) -> Tuple[float, float]:
|
|
239
|
+
"""
|
|
240
|
+
Compute intersection area statistics between AOI and benchmark AOI.
|
|
241
|
+
|
|
242
|
+
Both geometries are assumed to be in WGS84 (EPSG:4326). They are
|
|
243
|
+
reprojected to a dynamically chosen projected CRS before area
|
|
244
|
+
computation.
|
|
245
|
+
|
|
246
|
+
The CRS is chosen as:
|
|
247
|
+
- EPSG:5070 (NAD83 / Conus Albers) if the combined bbox centroid
|
|
248
|
+
is roughly over CONUS.
|
|
249
|
+
- EPSG:6933 (WGS84 global equal-area) otherwise.
|
|
250
|
+
"""
|
|
251
|
+
# Use union bbox to decide which CRS to use
|
|
252
|
+
union_geom = unary_union([aoi_geom, benchmark_geom])
|
|
253
|
+
area_crs = _pick_area_crs_for_bounds(union_geom.bounds)
|
|
254
|
+
|
|
255
|
+
aoi_gdf = gpd.GeoDataFrame(geometry=[aoi_geom], crs="EPSG:4326").to_crs(area_crs)
|
|
256
|
+
bench_gdf = gpd.GeoDataFrame(geometry=[benchmark_geom], crs="EPSG:4326").to_crs(
|
|
257
|
+
area_crs
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
aoi_proj = aoi_gdf.geometry.iloc[0]
|
|
261
|
+
bench_proj = bench_gdf.geometry.iloc[0]
|
|
262
|
+
inter = aoi_proj.intersection(bench_proj)
|
|
263
|
+
|
|
264
|
+
bench_area_m2 = float(bench_proj.area)
|
|
265
|
+
if bench_area_m2 <= 0 or inter.is_empty:
|
|
266
|
+
return 0.0, 0.0
|
|
267
|
+
|
|
268
|
+
inter_area_m2 = float(inter.area)
|
|
269
|
+
pct = inter_area_m2 / bench_area_m2 * 100.0
|
|
270
|
+
area_km2 = inter_area_m2 / 1_000_000.0 # m² → km²
|
|
271
|
+
|
|
272
|
+
return pct, area_km2
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# For generating context string for user AOI query during display
|
|
276
|
+
def _aoi_context_str(
|
|
277
|
+
has_aoi: bool,
|
|
278
|
+
huc8: Optional[str] = None,
|
|
279
|
+
date_input: Optional[str] = None,
|
|
280
|
+
start_date: Optional[str] = None,
|
|
281
|
+
end_date: Optional[str] = None,
|
|
282
|
+
file_name: Optional[str] = None,
|
|
283
|
+
) -> str:
|
|
284
|
+
if has_aoi:
|
|
285
|
+
parts = ["your given location"]
|
|
286
|
+
if date_input:
|
|
287
|
+
parts.append(f"date '{date_input}'")
|
|
288
|
+
elif start_date or end_date:
|
|
289
|
+
parts.append(f"range {start_date or '-∞'} to {end_date or '∞'}")
|
|
290
|
+
if file_name:
|
|
291
|
+
parts.append(f"file '{file_name}'")
|
|
292
|
+
return ", ".join(parts)
|
|
293
|
+
# fall back to the non-AOI context from utils
|
|
294
|
+
from .utilis import _context_str as _ctx
|
|
295
|
+
|
|
296
|
+
return _ctx(
|
|
297
|
+
huc8=huc8,
|
|
298
|
+
date_input=date_input,
|
|
299
|
+
file_name=file_name,
|
|
300
|
+
start_date=start_date,
|
|
301
|
+
end_date=end_date,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _display_raster_name(rec: Dict[str, Any]) -> str:
|
|
305
|
+
tif_url = rec.get("tif_url")
|
|
306
|
+
if isinstance(tif_url, str) and tif_url.strip():
|
|
307
|
+
# drop querystring if any
|
|
308
|
+
tif_url = tif_url.split("?", 1)[0]
|
|
309
|
+
return os.path.basename(tif_url)
|
|
310
|
+
|
|
311
|
+
# fallback: last path token of id
|
|
312
|
+
rid = rec.get("id")
|
|
313
|
+
if isinstance(rid, str) and rid.strip():
|
|
314
|
+
return rid.strip().split("/")[-1] + ".tif"
|
|
315
|
+
|
|
316
|
+
return "NA"
|
|
317
|
+
|
|
318
|
+
# Build a single printable block, optionally with overlap appended
|
|
319
|
+
def _format_block_with_overlap(
|
|
320
|
+
rec: Dict[str, Any], pct: Optional[float], km2: Optional[float]
|
|
321
|
+
) -> str:
|
|
322
|
+
tier = rec.get("tier") or rec.get("HWM") or rec.get("quality") or "Unknown"
|
|
323
|
+
res = rec.get("resolution_m")
|
|
324
|
+
res_txt = f"{res}m" if res is not None else "NA"
|
|
325
|
+
fname = _display_raster_name(rec)
|
|
326
|
+
|
|
327
|
+
lines = [f"Data Tier: {tier}"]
|
|
328
|
+
|
|
329
|
+
# For synthetic (Tier-4), show return period instead of date.
|
|
330
|
+
if _is_synthetic_tier(rec):
|
|
331
|
+
lines.append(f"Return Period: {_return_period_text(rec)}")
|
|
332
|
+
else:
|
|
333
|
+
date_str = _pretty_date_for_print(rec)
|
|
334
|
+
lines.append(f"Benchmark FIM date: {date_str}")
|
|
335
|
+
|
|
336
|
+
lines.extend(
|
|
337
|
+
[
|
|
338
|
+
f"Spatial Resolution: {res_txt}",
|
|
339
|
+
f"Raster Filename in DB: {fname}",
|
|
340
|
+
]
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if pct is not None and km2 is not None:
|
|
344
|
+
lines.append(
|
|
345
|
+
f"Overlap with respect to benchmark FIM: {pct:.1f}% / {km2:.2f} km²"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return "\n".join(lines)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# For Tier-4- adding synthetic event year while reflecting the outcomes
|
|
352
|
+
def _is_synthetic_tier(rec: Dict[str, Any]) -> bool:
|
|
353
|
+
"""Return True when the record is a synthetic (Tier_4) event."""
|
|
354
|
+
tier = str(rec.get("tier") or rec.get("quality") or "").lower()
|
|
355
|
+
return "tier_4" in tier or tier.strip() == "4"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _return_period_text(rec: Dict[str, Any]) -> str:
|
|
359
|
+
"""
|
|
360
|
+
Build a friendly return-period string like '100-year synthetic flow'.
|
|
361
|
+
Looks in common fields and falls back if missing.
|
|
362
|
+
"""
|
|
363
|
+
rp = (
|
|
364
|
+
rec.get("return_period")
|
|
365
|
+
or rec.get("return_period_yr")
|
|
366
|
+
or rec.get("rp")
|
|
367
|
+
or rec.get("rp_years")
|
|
368
|
+
)
|
|
369
|
+
if rp is None:
|
|
370
|
+
return "synthetic flow (return period unknown)"
|
|
371
|
+
# normalize to int when possible
|
|
372
|
+
try:
|
|
373
|
+
rp_int = int(float(str(rp).strip().replace("yr", "").replace("-year", "")))
|
|
374
|
+
return f"{rp_int}-year synthetic flow"
|
|
375
|
+
except Exception:
|
|
376
|
+
return f"{rp} synthetic flow"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# helpers to read AOI GPKG geometry directly
|
|
380
|
+
def _storage_options_for_uri(uri: str) -> Optional[Dict[str, Any]]:
|
|
381
|
+
if isinstance(uri, str) and uri.startswith("s3://"):
|
|
382
|
+
anon = str(os.environ.get("AWS_NO_SIGN_REQUEST", "")).upper() in {
|
|
383
|
+
"YES",
|
|
384
|
+
"TRUE",
|
|
385
|
+
"1",
|
|
386
|
+
}
|
|
387
|
+
return {"anon": anon}
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _gpkg_urls_from_record(rec: Dict[str, Any]) -> List[str]:
|
|
392
|
+
urls: List[str] = []
|
|
393
|
+
for key in ("aoi_gpkg", "aoi_gpkg_url", "gpkg_url"):
|
|
394
|
+
v = rec.get(key)
|
|
395
|
+
if isinstance(v, str) and v.strip():
|
|
396
|
+
urls.append(v.strip())
|
|
397
|
+
for key in ("aoi_gpkgs", "gpkg_urls", "aoi_paths"):
|
|
398
|
+
v = rec.get(key)
|
|
399
|
+
if isinstance(v, (list, tuple)):
|
|
400
|
+
for item in v:
|
|
401
|
+
if isinstance(item, str) and item.strip():
|
|
402
|
+
urls.append(item.strip())
|
|
403
|
+
assets = rec.get("assets") or {}
|
|
404
|
+
if isinstance(assets, dict):
|
|
405
|
+
for _, meta in assets.items():
|
|
406
|
+
if not isinstance(meta, dict):
|
|
407
|
+
continue
|
|
408
|
+
href = meta.get("href") or meta.get("url") or meta.get("path")
|
|
409
|
+
role = str(meta.get("role", "")).lower()
|
|
410
|
+
if isinstance(href, str) and href.strip():
|
|
411
|
+
h = href.strip()
|
|
412
|
+
if h.lower().endswith(".gpkg") or role in {"aoi", "footprint"}:
|
|
413
|
+
urls.append(h)
|
|
414
|
+
seen, out = set(), []
|
|
415
|
+
for u in urls:
|
|
416
|
+
if u not in seen:
|
|
417
|
+
seen.add(u)
|
|
418
|
+
out.append(u)
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
def _read_benchmark_aoi_union_geom(rec: Dict[str, Any]) -> Optional[Polygon]:
|
|
422
|
+
# Read and union AOI geometries referenced by the record
|
|
423
|
+
urls = _gpkg_urls_from_record(rec)
|
|
424
|
+
if not urls:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
geoms: List[Polygon] = []
|
|
428
|
+
for uri in urls:
|
|
429
|
+
try:
|
|
430
|
+
storage_opts = _storage_options_for_uri(uri)
|
|
431
|
+
|
|
432
|
+
uri_to_read = _ensure_local_gpkg(uri)
|
|
433
|
+
|
|
434
|
+
gdf = (
|
|
435
|
+
gpd.read_file(uri_to_read, storage_options=storage_opts)
|
|
436
|
+
if storage_opts
|
|
437
|
+
else gpd.read_file(uri_to_read)
|
|
438
|
+
)
|
|
439
|
+
if gdf.empty:
|
|
440
|
+
continue
|
|
441
|
+
gdf = (
|
|
442
|
+
gdf.to_crs("EPSG:4326")
|
|
443
|
+
if gdf.crs
|
|
444
|
+
else gdf.set_crs("EPSG:4326", allow_override=True)
|
|
445
|
+
)
|
|
446
|
+
u = unary_union(gdf.geometry)
|
|
447
|
+
if not u.is_empty:
|
|
448
|
+
geoms.append(u)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
if not geoms:
|
|
453
|
+
return None
|
|
454
|
+
uall = unary_union(geoms)
|
|
455
|
+
return None if uall.is_empty else uall
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# Main service class
|
|
459
|
+
class benchFIMquery:
|
|
460
|
+
"""
|
|
461
|
+
High-level query helper for benchmark FIMs in S3.
|
|
462
|
+
|
|
463
|
+
This class provides a single entry point for all common user workflows:
|
|
464
|
+
- intersect a user raster or boundary with benchmark FIM footprints
|
|
465
|
+
- optionally restrict to a specific date or a date range
|
|
466
|
+
- optionally compute area-of-intersection percentages using the benchmark AOI
|
|
467
|
+
geopackages stored next to the FIM rasters
|
|
468
|
+
- optionally download the matched benchmark FIMs (and AOI geopackages)
|
|
469
|
+
into a local directory
|
|
470
|
+
- or, as a special-case, fetch a specific benchmark by its filename.
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
def __init__(self, catalog: Optional[Dict[str, Any]] = None) -> None:
|
|
474
|
+
"""
|
|
475
|
+
Create a new service.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
catalog:
|
|
480
|
+
Optional pre-loaded catalog dictionary as returned by
|
|
481
|
+
:func:`load_catalog_core`. If omitted, the catalog is lazily
|
|
482
|
+
loaded from S3 on first use.
|
|
483
|
+
"""
|
|
484
|
+
self._catalog = catalog
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def records(self) -> List[Dict[str, Any]]:
|
|
488
|
+
"""Return the list of catalog records (lazy-loaded)."""
|
|
489
|
+
if self._catalog is None:
|
|
490
|
+
self._catalog = load_catalog_core()
|
|
491
|
+
recs = self._catalog.get("records", [])
|
|
492
|
+
return list(recs)
|
|
493
|
+
|
|
494
|
+
# Public query API
|
|
495
|
+
def query(
|
|
496
|
+
self,
|
|
497
|
+
*,
|
|
498
|
+
raster_path: Optional[str] = None,
|
|
499
|
+
boundary_path: Optional[str] = None,
|
|
500
|
+
huc8: Optional[str] = None,
|
|
501
|
+
event_date: Optional[str] = None,
|
|
502
|
+
start_date: Optional[str] = None,
|
|
503
|
+
end_date: Optional[str] = None,
|
|
504
|
+
file_name: Optional[str] = None,
|
|
505
|
+
area: bool = False,
|
|
506
|
+
download: bool = False,
|
|
507
|
+
out_dir: Optional[str] = None,
|
|
508
|
+
) -> Dict[str, Any]:
|
|
509
|
+
"""
|
|
510
|
+
Query benchmark FIMs based on user instructions.
|
|
511
|
+
This method centralizes all possible combinations of user inputs into
|
|
512
|
+
|
|
513
|
+
1. **Direct filename download** (no AOI, no dates)
|
|
514
|
+
If ``file_name`` and ``download=True`` are provided without any raster/boundary/date inputs, the method will locate the matching
|
|
515
|
+
catalog record by filename, download the FIM and any associated geopackages into ``out_dir``, and return metadata without any formatted listing.
|
|
516
|
+
|
|
517
|
+
2. **AOI-only search (raster or boundary)**
|
|
518
|
+
If ``raster_path`` or ``boundary_path`` is given without date filters, the method computes the AOI’s WGS84 footprint, intersects
|
|
519
|
+
it with catalog record bounding boxes, and returns the intersecting benchmark records. When ``area=True``, it additionally downloads
|
|
520
|
+
the benchmark AOI geopackage(s) and reports the intersection area percentage and area in km² relative to the benchmark AOI.
|
|
521
|
+
|
|
522
|
+
3. **AOI + specific date**
|
|
523
|
+
Combine (2) with ``date_input`` for an exact date (and optional hour) filter before computing intersection (and area metrics if requested).
|
|
524
|
+
|
|
525
|
+
4. **AOI + date range**
|
|
526
|
+
As in (2) but filtered to benchmarks whose event dates fall within ``[start_date, end_date]`` (inclusive). When ``download=True`` and
|
|
527
|
+
``out_dir`` is given, the method downloads all intersecting benchmarks into that directory.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
raster_path:
|
|
532
|
+
Optional path to a user raster. This can be model predicted FIM user want to evaluate against benchmark.
|
|
533
|
+
boundary_path:
|
|
534
|
+
Instead of raster/ user can also pass vector AOI file
|
|
535
|
+
huc8:
|
|
536
|
+
Optional HUC8 string used to limit searches to a specific basin/ This is for within US only..
|
|
537
|
+
date_input:
|
|
538
|
+
Exact (possibly hourly) date filter. See :func:`_to_date` docs for accepted formats.
|
|
539
|
+
start_date, end_date:
|
|
540
|
+
Inclusive date range filter. Either may be ``None`` for open-ended ranges.
|
|
541
|
+
file_name:
|
|
542
|
+
Exact benchmark FIM filename as listed in the catalog (e.g.``"PSS_3_0m_20170830T162251_BM.tif"``). When provided together
|
|
543
|
+
with ``download=True`` and *no AOI or date filters*, triggers the direct-filename workflow.
|
|
544
|
+
area:
|
|
545
|
+
If ``True``, and an AOI is supplied, compute the intersection area percentage and area in km² relative to each benchmark AOI geopackage (if present).
|
|
546
|
+
download:
|
|
547
|
+
If ``True``, download matched benchmark FIM rasters and any geopackages into ``out_dir``. When ``False`` (the default), the method performs a read-only query.
|
|
548
|
+
out_dir:
|
|
549
|
+
Target directory for downloads. Required when ``download=True``; ignored otherwise.
|
|
550
|
+
"""
|
|
551
|
+
# Validate args and AOI geometry
|
|
552
|
+
aoi_geom: Optional[Polygon] = None
|
|
553
|
+
if raster_path:
|
|
554
|
+
aoi_geom = _raster_aoi_polygon_wgs84(raster_path)
|
|
555
|
+
if boundary_path:
|
|
556
|
+
boundary_geom = _vector_aoi_polygon_wgs84(boundary_path)
|
|
557
|
+
aoi_geom = (
|
|
558
|
+
boundary_geom
|
|
559
|
+
if aoi_geom is None
|
|
560
|
+
else aoi_geom.intersection(boundary_geom)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if download and not out_dir:
|
|
564
|
+
return PrettyDict(
|
|
565
|
+
{
|
|
566
|
+
"status": "error",
|
|
567
|
+
"message": "When download=True, you must provide out_dir.",
|
|
568
|
+
"matches": [],
|
|
569
|
+
"printable": "",
|
|
570
|
+
}
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Direct filename-only workflow (no AOI, no dates)
|
|
574
|
+
if (
|
|
575
|
+
file_name
|
|
576
|
+
and download
|
|
577
|
+
and aoi_geom is None
|
|
578
|
+
and not any([event_date, start_date, end_date])
|
|
579
|
+
):
|
|
580
|
+
fname = file_name.strip()
|
|
581
|
+
recs = self.records
|
|
582
|
+
if huc8:
|
|
583
|
+
candidates = [
|
|
584
|
+
r
|
|
585
|
+
for r in recs
|
|
586
|
+
if str(r.get("file_name", "")).strip() == fname
|
|
587
|
+
and str(r.get("huc8", "")).strip() == str(huc8).strip()
|
|
588
|
+
]
|
|
589
|
+
if not candidates:
|
|
590
|
+
candidates = [
|
|
591
|
+
r for r in recs if str(r.get("file_name", "")).strip() == fname
|
|
592
|
+
]
|
|
593
|
+
else:
|
|
594
|
+
candidates = [
|
|
595
|
+
r for r in recs if str(r.get("file_name", "")).strip() == fname
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
if not candidates:
|
|
599
|
+
return PrettyDict(
|
|
600
|
+
{
|
|
601
|
+
"status": "not_found",
|
|
602
|
+
"message": f"File name {fname!r} not found in catalog.",
|
|
603
|
+
"matches": [],
|
|
604
|
+
"printable": "",
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
target = candidates[0]
|
|
609
|
+
out_dir_path = _ensure_dir(out_dir)
|
|
610
|
+
dl = download_fim_assets(target, str(out_dir_path))
|
|
611
|
+
|
|
612
|
+
return PrettyDict(
|
|
613
|
+
{
|
|
614
|
+
"status": "ok",
|
|
615
|
+
"message": f"Downloaded benchmark FIM '{fname}' to '{out_dir_path}'.",
|
|
616
|
+
"matches": [
|
|
617
|
+
{
|
|
618
|
+
"record": target,
|
|
619
|
+
"bbox_intersects": False,
|
|
620
|
+
"intersection_area_pct": None,
|
|
621
|
+
"intersection_area_km2": None,
|
|
622
|
+
"downloads": dl,
|
|
623
|
+
}
|
|
624
|
+
],
|
|
625
|
+
"printable": "",
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# AOI-based workflows
|
|
630
|
+
records = self.records
|
|
631
|
+
if huc8:
|
|
632
|
+
huc8_str = str(huc8).strip()
|
|
633
|
+
records = [r for r in records if str(r.get("huc8", "")).strip() == huc8_str]
|
|
634
|
+
|
|
635
|
+
# Date filters
|
|
636
|
+
if event_date:
|
|
637
|
+
records = _filter_by_date_exact(records, event_date)
|
|
638
|
+
elif start_date or end_date:
|
|
639
|
+
records = _filter_by_date_range(records, start_date, end_date)
|
|
640
|
+
|
|
641
|
+
if not records:
|
|
642
|
+
return PrettyDict(
|
|
643
|
+
{
|
|
644
|
+
"status": "not_found",
|
|
645
|
+
"message": "No catalog records match the provided filters.",
|
|
646
|
+
"matches": [],
|
|
647
|
+
"printable": "",
|
|
648
|
+
}
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# If no AOI is provided at all
|
|
652
|
+
if aoi_geom is None:
|
|
653
|
+
matches = []
|
|
654
|
+
for r in records:
|
|
655
|
+
matches.append(
|
|
656
|
+
{
|
|
657
|
+
"record": r,
|
|
658
|
+
"bbox_intersects": False,
|
|
659
|
+
"intersection_area_pct": None,
|
|
660
|
+
"intersection_area_km2": None,
|
|
661
|
+
"downloads": None,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
ctx = _aoi_context_str(
|
|
666
|
+
has_aoi=False,
|
|
667
|
+
huc8=huc8,
|
|
668
|
+
date_input=event_date,
|
|
669
|
+
start_date=start_date,
|
|
670
|
+
end_date=end_date,
|
|
671
|
+
file_name=file_name,
|
|
672
|
+
)
|
|
673
|
+
printable = format_records_for_print(
|
|
674
|
+
[m["record"] for m in matches], context=ctx
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
if download:
|
|
678
|
+
out_dir_path = _ensure_dir(out_dir)
|
|
679
|
+
for m in matches:
|
|
680
|
+
m["downloads"] = download_fim_assets(m["record"], str(out_dir_path))
|
|
681
|
+
msg = (
|
|
682
|
+
f"Downloaded {len(matches)} benchmark record(s) "
|
|
683
|
+
f"to '{out_dir_path}'."
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
msg = (
|
|
687
|
+
f"Found {len(matches)} benchmark record(s) "
|
|
688
|
+
f"for the provided filters."
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return PrettyDict(
|
|
692
|
+
{
|
|
693
|
+
"status": "ok",
|
|
694
|
+
"message": msg,
|
|
695
|
+
"matches": matches,
|
|
696
|
+
"printable": "" if download else printable,
|
|
697
|
+
}
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# AOI is present: intersect with bbox
|
|
701
|
+
intersecting: List[Dict[str, Any]] = []
|
|
702
|
+
for r in records:
|
|
703
|
+
try:
|
|
704
|
+
rec_poly = _record_bbox_polygon(r)
|
|
705
|
+
except Exception:
|
|
706
|
+
continue
|
|
707
|
+
if not rec_poly.intersects(aoi_geom):
|
|
708
|
+
continue
|
|
709
|
+
intersecting.append(r)
|
|
710
|
+
|
|
711
|
+
if not intersecting:
|
|
712
|
+
return PrettyDict(
|
|
713
|
+
{
|
|
714
|
+
"status": "not_found",
|
|
715
|
+
"message": "No benchmark FIM bbox intersects the provided AOI.",
|
|
716
|
+
"matches": [],
|
|
717
|
+
"printable": "",
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
out_matches: List[Dict[str, Any]] = []
|
|
722
|
+
out_dir_path = _ensure_dir(out_dir) if (download and out_dir) else None
|
|
723
|
+
|
|
724
|
+
for rec in intersecting:
|
|
725
|
+
intersection_area_pct: Optional[float] = None
|
|
726
|
+
intersection_area_km2: Optional[float] = None
|
|
727
|
+
downloads = None
|
|
728
|
+
|
|
729
|
+
if area:
|
|
730
|
+
# Read AOI geopackages directly (no download) and compute overlap
|
|
731
|
+
bench_union = _read_benchmark_aoi_union_geom(rec)
|
|
732
|
+
if bench_union is not None and not bench_union.is_empty:
|
|
733
|
+
pct, km2 = _compute_area_overlap_stats(aoi_geom, bench_union)
|
|
734
|
+
intersection_area_pct = pct
|
|
735
|
+
intersection_area_km2 = km2
|
|
736
|
+
# If user also requested downloads, do it separately (not needed for area calc)
|
|
737
|
+
if download and out_dir_path:
|
|
738
|
+
downloads = download_fim_assets(rec, str(out_dir_path))
|
|
739
|
+
|
|
740
|
+
if download and not area and out_dir_path:
|
|
741
|
+
downloads = download_fim_assets(rec, str(out_dir_path))
|
|
742
|
+
|
|
743
|
+
out_matches.append(
|
|
744
|
+
{
|
|
745
|
+
"record": rec,
|
|
746
|
+
"bbox_intersects": True,
|
|
747
|
+
"intersection_area_pct": intersection_area_pct,
|
|
748
|
+
"intersection_area_km2": intersection_area_km2,
|
|
749
|
+
"downloads": downloads,
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
if download and out_dir_path:
|
|
754
|
+
msg = (
|
|
755
|
+
f"Downloaded {len(out_matches)} intersecting benchmark "
|
|
756
|
+
f"record(s) to '{out_dir_path}'."
|
|
757
|
+
)
|
|
758
|
+
printable = ""
|
|
759
|
+
else:
|
|
760
|
+
msg = (
|
|
761
|
+
f"Found {len(out_matches)} benchmark record(s) "
|
|
762
|
+
f"intersecting the AOI."
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Build per-record blocks; if area=True, append overlap line inside each block
|
|
766
|
+
ctx = _aoi_context_str(
|
|
767
|
+
has_aoi=True,
|
|
768
|
+
huc8=huc8,
|
|
769
|
+
date_input=event_date,
|
|
770
|
+
start_date=start_date,
|
|
771
|
+
end_date=end_date,
|
|
772
|
+
file_name=file_name,
|
|
773
|
+
)
|
|
774
|
+
header = f"Following are the available benchmark data for {ctx}:\n"
|
|
775
|
+
blocks: List[str] = []
|
|
776
|
+
for m in out_matches:
|
|
777
|
+
rec = m["record"]
|
|
778
|
+
pct = m.get("intersection_area_pct")
|
|
779
|
+
km2 = m.get("intersection_area_km2")
|
|
780
|
+
if area:
|
|
781
|
+
blocks.append(_format_block_with_overlap(rec, pct, km2))
|
|
782
|
+
else:
|
|
783
|
+
# reuse original block style without overlap
|
|
784
|
+
blocks.append(_format_block_with_overlap(rec, None, None))
|
|
785
|
+
printable = header + "\n\n".join(blocks)
|
|
786
|
+
|
|
787
|
+
return PrettyDict(
|
|
788
|
+
{
|
|
789
|
+
"status": "ok",
|
|
790
|
+
"message": msg,
|
|
791
|
+
"matches": out_matches,
|
|
792
|
+
"printable": printable,
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
def __call__(
|
|
797
|
+
self,
|
|
798
|
+
*,
|
|
799
|
+
raster_path: Optional[str] = None,
|
|
800
|
+
boundary_path: Optional[str] = None,
|
|
801
|
+
huc8: Optional[str] = None,
|
|
802
|
+
event_date: Optional[str] = None,
|
|
803
|
+
start_date: Optional[str] = None,
|
|
804
|
+
end_date: Optional[str] = None,
|
|
805
|
+
file_name: Optional[str] = None,
|
|
806
|
+
area: bool = False,
|
|
807
|
+
download: bool = False,
|
|
808
|
+
out_dir: Optional[str] = None,
|
|
809
|
+
) -> Dict[str, Any]:
|
|
810
|
+
return self.query(
|
|
811
|
+
raster_path=raster_path,
|
|
812
|
+
boundary_path=boundary_path,
|
|
813
|
+
huc8=huc8,
|
|
814
|
+
event_date=event_date,
|
|
815
|
+
start_date=start_date,
|
|
816
|
+
end_date=end_date,
|
|
817
|
+
file_name=file_name,
|
|
818
|
+
area=area,
|
|
819
|
+
download=download,
|
|
820
|
+
out_dir=out_dir,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
benchFIMquery = benchFIMquery()
|