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.
@@ -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()