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