fimbench 0.1.0__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.
Files changed (36) hide show
  1. fimbench/__init__.py +66 -0
  2. fimbench/_log.py +15 -0
  3. fimbench/processing_floodmap/README.md +41 -0
  4. fimbench/processing_floodmap/__init__.py +23 -0
  5. fimbench/processing_floodmap/fema_ble.py +398 -0
  6. fimbench/processing_floodmap/hwm.py +447 -0
  7. fimbench/processing_floodmap/tier1.py +507 -0
  8. fimbench/processing_floodmap/tier2.py +472 -0
  9. fimbench/processing_floodmap/tier3.py +463 -0
  10. fimbench/processing_floodmap/utils.py +114 -0
  11. fimbench/publish/README.md +92 -0
  12. fimbench/publish/__init__.py +26 -0
  13. fimbench/publish/arcgis_online.py +323 -0
  14. fimbench/publish/s3/__init__.py +10 -0
  15. fimbench/publish/s3/s3_client.py +98 -0
  16. fimbench/publish/s3/s3_io.py +19 -0
  17. fimbench/publish/upload_benchmarkfloodmap.py +68 -0
  18. fimbench/publish/upload_catalogntilesintos3.py +16 -0
  19. fimbench/query/README.md +104 -0
  20. fimbench/query/__init__.py +15 -0
  21. fimbench/query/access_benchfim.py +899 -0
  22. fimbench/query/utilis.py +720 -0
  23. fimbench/webcontent_utils/README.md +83 -0
  24. fimbench/webcontent_utils/__init__.py +17 -0
  25. fimbench/webcontent_utils/build_catalog.py +346 -0
  26. fimbench/webcontent_utils/smoothen_fimextent.py +117 -0
  27. fimbench/webcontent_utils/tiling.py +587 -0
  28. fimbench/webcontent_utils/viewtile_locally/README.md +74 -0
  29. fimbench/webcontent_utils/viewtile_locally/__init__.py +9 -0
  30. fimbench/webcontent_utils/viewtile_locally/serve_tiles.py +49 -0
  31. fimbench/webcontent_utils/viewtile_locally/view.html +85 -0
  32. fimbench-0.1.0.dist-info/METADATA +256 -0
  33. fimbench-0.1.0.dist-info/RECORD +36 -0
  34. fimbench-0.1.0.dist-info/WHEEL +5 -0
  35. fimbench-0.1.0.dist-info/licenses/LICENSE +61 -0
  36. fimbench-0.1.0.dist-info/top_level.txt +1 -0
fimbench/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ fimbench
3
+
4
+ FIM benchmarking utilities. Pipeline: processing_floodmap (standardize) ->
5
+ webcontent_utils (catalog + tiles) -> query (availability) -> publish (S3 /
6
+ ArcGIS Online). Public classes are re-exported at the top level
7
+ (e.g. fimbench.Tier1Processor) and resolved lazily so `import fimbench` stays
8
+ light.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ # Lazy top-level names (see __getattr__); avoids importing rasterio/geopandas/
16
+ # arcgis until a name is actually used.
17
+ __all__: list[str] = [
18
+ # subpackages
19
+ "processing_floodmap",
20
+ "webcontent_utils",
21
+ "query",
22
+ "publish",
23
+ # processing_floodmap classes -> fimbench.Tier1Processor, etc.
24
+ "Tier1Processor",
25
+ "Tier2Processor",
26
+ "Tier3Processor",
27
+ "FemaBleProcessor",
28
+ "HwmProcessor",
29
+ # webcontent_utils classes
30
+ "FIMCatalogBuilder",
31
+ "CatalogandTileManager",
32
+ # query
33
+ "benchFIMquery",
34
+ # publish
35
+ "PublishFIMExtent2ArcGISOnline",
36
+ "upload_benchmarkfloodmap",
37
+ ]
38
+
39
+ # name -> (submodule, attribute) for lazy top-level re-export.
40
+ _LAZY = {
41
+ "Tier1Processor": ("processing_floodmap", "Tier1Processor"),
42
+ "Tier2Processor": ("processing_floodmap", "Tier2Processor"),
43
+ "Tier3Processor": ("processing_floodmap", "Tier3Processor"),
44
+ "FemaBleProcessor": ("processing_floodmap", "FemaBleProcessor"),
45
+ "HwmProcessor": ("processing_floodmap", "HwmProcessor"),
46
+ "FIMCatalogBuilder": ("webcontent_utils", "FIMCatalogBuilder"),
47
+ "CatalogandTileManager": ("webcontent_utils", "CatalogandTileManager"),
48
+ "benchFIMquery": ("query", "benchFIMquery"),
49
+ "PublishFIMExtent2ArcGISOnline": ("publish", "PublishFIMExtent2ArcGISOnline"),
50
+ "upload_benchmarkfloodmap": ("publish", "upload_benchmarkfloodmap"),
51
+ }
52
+
53
+
54
+ def __getattr__(name: str):
55
+ import importlib
56
+
57
+ target = _LAZY.get(name)
58
+ if target is not None:
59
+ submod, attr = target
60
+ module = importlib.import_module(f"{__name__}.{submod}")
61
+ return getattr(module, attr)
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
63
+
64
+
65
+ def __dir__():
66
+ return sorted(__all__)
fimbench/_log.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ fimbench._log
3
+
4
+ Shared status logger so every subpackage emits output the same way:
5
+ [HH:MM:SS] message
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+
13
+ def log(msg):
14
+ """Print a timestamped status line."""
15
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
@@ -0,0 +1,41 @@
1
+ # fimbench.processing_floodmap
2
+
3
+ This standalone module helps to standardize a raw benchmark flood map into the FIMbench database format. It is now structured as one class per flooding map source;
4
+ each writes the renamed GeoTIFF, metadata.json, and AOI.gpkg into a per-map
5
+ folder under the destination. Logic is kept faithful to the source notebooks,
6
+ so the output matches.
7
+
8
+ Intersected HUC8 watersheds informations for metadata within US are resolved on the fly from the ArcGIS REST service
9
+ (`utils.get_intersected_huc8`)
10
+
11
+ ## Classes
12
+
13
+ - `Tier1Processor` — Aerial Imagery (AI)
14
+ - `Tier2Processor` — Planet Scope Scene (PSS)
15
+ - `Tier3Processor` — Sentinel 1A (S1A)
16
+ - `FemaBleProcessor` — FEMA Base Level Engineering (BLE), uses a return-period event
17
+ - `HwmProcessor` — High Water Mark (HWM), single TIF + start/end date
18
+
19
+ Defaults (`SENSOR_CODE`, `SOURCE`, `NODATA_VAL`, `SIMPLIFY_TOL`, ...) are class
20
+ attributes and can be overridden via keyword args.
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ from fimbench.processing_floodmap import Tier1Processor
26
+
27
+ Tier1Processor().process("in/rasters", "out/")
28
+ # override a default:
29
+ Tier1Processor(source="My source").process("in/rasters", "out/")
30
+ ```
31
+
32
+ Each module also exposes a `process(...)` shortcut:
33
+
34
+ ```python
35
+ from fimbench.processing_floodmap import tier1, fema_ble, hwm
36
+
37
+ tier1.process("in/", "out/")
38
+ fema_ble.process("in/", "out/", event="100")
39
+ hwm.process("map.tif", "out/", start_date="160928", end_date="161009")
40
+ ```
41
+ **For more usage notes refer to the [tests](../../../tests/) or [docs](../../../docs/) for the fimbench python package**
@@ -0,0 +1,23 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ from . import tier1, tier2, tier3, fema_ble, hwm, utils
5
+ from .tier1 import Tier1Processor
6
+ from .tier2 import Tier2Processor
7
+ from .tier3 import Tier3Processor
8
+ from .fema_ble import FemaBleProcessor
9
+ from .hwm import HwmProcessor
10
+
11
+ __all__ = [
12
+ "Tier1Processor",
13
+ "Tier2Processor",
14
+ "Tier3Processor",
15
+ "FemaBleProcessor",
16
+ "HwmProcessor",
17
+ "tier1",
18
+ "tier2",
19
+ "tier3",
20
+ "fema_ble",
21
+ "hwm",
22
+ "utils",
23
+ ]
@@ -0,0 +1,398 @@
1
+ """
2
+ Authors: Supath Dhital (sdhital@ua.edu), Dipsikha Devi (ddevi@ua.edu)
3
+ Updated: June 2026
4
+
5
+ FEMA Base Level Engineering (BLE) flood map processing.
6
+
7
+ Standardizes FEMA BLE synthetic (return-period) flood rasters into the FIM
8
+ database format: renamed GeoTIFF, metadata.json, and AOI.gpkg per flood map.
9
+ """
10
+
11
+ import json
12
+ import shutil
13
+ import tempfile
14
+ from pathlib import Path
15
+ import numpy as np
16
+ import rasterio
17
+ from rasterio.features import shapes
18
+ from rasterio.warp import calculate_default_transform, reproject, Resampling
19
+ from shapely.geometry import shape
20
+ from shapely.ops import unary_union
21
+ from shapely.validation import make_valid
22
+ import geopandas as gpd
23
+
24
+ from .._log import log as _log
25
+ from .utils import get_intersected_huc8, list_input_tifs
26
+
27
+
28
+ class FemaBleProcessor:
29
+ """Standardize FEMA Base Level Engineering flood maps for the FIM database."""
30
+
31
+ SENSOR_CODE = "BLE"
32
+ Full_Form = "Base Level Engineering"
33
+ SOURCE = "NOAA/NWS Office of Water Prediction (OWP)"
34
+
35
+ NODATA_VAL = -9999
36
+ BLOCK_SIZE = 512
37
+ SIMPLIFY_TOL = 0.0001 # ~11 m at the equator
38
+
39
+ def __init__(self, *, sensor_code=None, full_form=None, source=None,
40
+ nodata_val=None, block_size=None, simplify_tol=None):
41
+ # Class attributes are the defaults.
42
+ if sensor_code is not None:
43
+ self.SENSOR_CODE = sensor_code
44
+ if full_form is not None:
45
+ self.Full_Form = full_form
46
+ if source is not None:
47
+ self.SOURCE = source
48
+ if nodata_val is not None:
49
+ self.NODATA_VAL = nodata_val
50
+ if block_size is not None:
51
+ self.BLOCK_SIZE = block_size
52
+ if simplify_tol is not None:
53
+ self.SIMPLIFY_TOL = simplify_tol
54
+
55
+ # Folder for intermediate rasters; set per run in process().
56
+ self._work_dir = None
57
+
58
+ def log(self, msg):
59
+ _log(msg)
60
+
61
+ def _work_path(self, name):
62
+ # Place an intermediate file in the work dir (else next to the input).
63
+ if self._work_dir is not None:
64
+ return Path(self._work_dir) / name
65
+ return Path(name)
66
+
67
+ # Assign nodata, datatype and tile size
68
+ def process_tif(self, input_tif, nodata_val=None, block_size=None):
69
+ nodata_val = self.NODATA_VAL if nodata_val is None else nodata_val
70
+ block_size = self.BLOCK_SIZE if block_size is None else block_size
71
+ input_tif = Path(input_tif)
72
+ output_tif = self._work_path(input_tif.stem + "_nodata.tif")
73
+ output_tif.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ with rasterio.open(input_tif) as src:
76
+ m = src.read(masked=True)
77
+ src_dtype = np.dtype(src.dtypes[0])
78
+ if np.issubdtype(src_dtype, np.floating):
79
+ out_dtype = "float32"
80
+ else:
81
+ out_dtype = "int32"
82
+
83
+ out = np.full(m.shape, nodata_val, dtype=out_dtype)
84
+ valid = ~m.mask
85
+ out[valid] = m.data[valid].astype(out_dtype, copy=False)
86
+
87
+ profile = src.profile.copy()
88
+ profile.update(
89
+ dtype=out_dtype,
90
+ nodata=nodata_val,
91
+ tiled=True,
92
+ blockxsize=block_size,
93
+ blockysize=block_size,
94
+ compress="lzw"
95
+ )
96
+
97
+ with rasterio.open(output_tif, "w", **profile) as dst:
98
+ dst.write(out)
99
+ try:
100
+ dst.update_tags(**src.tags())
101
+ except Exception:
102
+ pass
103
+
104
+ return output_tif
105
+
106
+ # Reproject to the target coordinate system (default EPSG:5070)
107
+ def reproject_raster(self, input_file, target_epsg):
108
+ input_file = Path(input_file)
109
+ output_file = self._work_path(input_file.stem + f"_reprojected_{target_epsg}.tif")
110
+ output_file.parent.mkdir(parents=True, exist_ok=True)
111
+
112
+ with rasterio.open(input_file) as src:
113
+ transform, width, height = calculate_default_transform(
114
+ src.crs, f"EPSG:{target_epsg}", src.width, src.height, *src.bounds)
115
+
116
+ metadata = src.meta.copy()
117
+ metadata.update({
118
+ "crs": f"EPSG:{target_epsg}",
119
+ "transform": transform,
120
+ "width": width,
121
+ "height": height,
122
+ "compress": "lzw"
123
+ })
124
+
125
+ with rasterio.open(output_file, "w", **metadata) as dst:
126
+ for i in range(1, src.count + 1):
127
+ reproject(
128
+ source=rasterio.band(src, i),
129
+ destination=rasterio.band(dst, i),
130
+ src_transform=src.transform,
131
+ src_crs=src.crs,
132
+ dst_transform=transform,
133
+ dst_crs=f"EPSG:{target_epsg}",
134
+ resampling=Resampling.nearest
135
+ )
136
+ return output_file
137
+
138
+ # Extract the resolution from the projected file
139
+ def get_resolution_string(self, geo_tif_5070):
140
+ with rasterio.open(geo_tif_5070) as src:
141
+ res_x, res_y = src.res
142
+ avg_res = (res_x + res_y) / 2.0
143
+ rounded_res = round(avg_res, 1)
144
+ res_str = f"{str(rounded_res).replace('.', '_')}m" # e.g., 10.0 -> "10_0m"
145
+ res_txt = f"{rounded_res:.2f} m"
146
+ return rounded_res, res_str, res_txt
147
+
148
+ def get_raster_centroid(self, out_4326):
149
+ with rasterio.open(out_4326) as src:
150
+ b = src.bounds
151
+ centroid_x = (b.left + b.right) / 2
152
+ centroid_y = (b.top + b.bottom) / 2
153
+ crs = src.crs
154
+
155
+ return centroid_x, centroid_y, crs, b
156
+
157
+ # Decimal degrees to DMS, e.g. 95 31'44" W and 31 04'36" N -> 953144W310436N
158
+ def decimal_to_dms_str(self, dec, is_lat=True):
159
+ direction = ''
160
+ if is_lat:
161
+ direction = 'N' if dec >= 0 else 'S'
162
+ else:
163
+ direction = 'E' if dec >= 0 else 'W'
164
+
165
+ dec = abs(dec)
166
+ degrees = int(dec)
167
+ minutes = int((dec - degrees) * 60)
168
+ seconds = int(((dec - degrees) * 60 - minutes) * 60)
169
+
170
+ return f"{degrees:02d}{minutes:02d}{seconds:02d}{direction}"
171
+
172
+ def build_metadata_dict(
173
+ self, src_4326, src_5070, new_filename, res_m_float, res_txt, dms_code, x, y, state_list, huc8_list, name_list, EVENT
174
+ ):
175
+ with rasterio.open(src_4326) as src:
176
+ meta = {
177
+ "File_Name": new_filename,
178
+ "Full form of the sensor code": self.Full_Form,
179
+ "HUC8": huc8_list,
180
+ "Format": src.driver,
181
+ "Nodata_Value": src.nodata,
182
+ "Resolution in meter": res_m_float,
183
+ "Datatype": src.dtypes[0],
184
+ "Compression Type": src.tags(ns="IMAGE_STRUCTURE"),
185
+ "Extent": {
186
+ "xmin": src.bounds.left,
187
+ "ymin": src.bounds.bottom,
188
+ "xmax": src.bounds.right,
189
+ "ymax": src.bounds.top
190
+ },
191
+ "DMS_Code_centroid": dms_code,
192
+ "Projection": src.crs.to_string() if src.crs else "Unknown",
193
+ "Bands": src.count,
194
+ "Rows": src.height,
195
+ "Columns": src.width,
196
+ "State": f"{state_list}, USA" if state_list else "USA",
197
+ "Description": f"",
198
+ "River Basin Name": name_list,
199
+ "Source": self.SOURCE,
200
+ "Location of the centroid of the flood map": (x, y),
201
+ "Synthetic Flooding Event (return period (years))": EVENT,
202
+ "Keywords": ["flood", "hazard", "GIS"],
203
+ "Access_Rights": "Public",
204
+ "References": [
205
+ "1. Cohen, S., Baruah, A., Nikrou, P., Tian, D., & Liu, H. (2025). Toward robust evaluations of flood inundation predictions using remote sensing derived benchmark maps. Water Resources Research, 61(8), e2024WR039574.",
206
+ "2. Munasinghe, D., Cohen, S., Tian, D., Liu, H., Baruah, A., and Devi, D. (2025). A Database of Flood Maps using high-resolution Airborne Imagery and Machine Learning Models. In: CIROH Developers' Conference, May 28-30, 2025.",
207
+ "3. Devi, D., Dhital, S., Munasinghe, D., Cohen, S., Baruah, A., Chen, Y., ... & Pruitt, C. (2025). A Framework for the Evaluation of Flood Inundation Predictions Over Extensive Benchmark Databases.",
208
+ "4. Tian.D, Liu.H, Wang.L, Cohen.S, Thapa.P (2024).Enhancing satellite image-derived flood maps with hydrologically guided region growing method and high-resolution DEMs.Chapman Conference on Remote Sensing of the Water Cycle"
209
+ ]
210
+ }
211
+
212
+ with rasterio.open(src_5070) as src_geom:
213
+ arr = src_geom.read(1, masked=True)
214
+ geom_crs = src_geom.crs
215
+ mask = arr > 0 # 1 is flood, 0 is dry
216
+
217
+ results = (
218
+ {'properties': {'raster_val': v}, 'geometry': s}
219
+ for i, (s, v) in enumerate(
220
+ shapes(arr, mask=mask, transform=src_geom.transform)
221
+ )
222
+ )
223
+
224
+ geoms = list(results)
225
+ if not geoms:
226
+ self.log("No flooded areas found in 5070 raster.")
227
+ return meta, None
228
+
229
+ gdf1 = gpd.GeoDataFrame.from_features(geoms)
230
+ gdf1.set_crs(geom_crs, inplace=True)
231
+
232
+ if self.SIMPLIFY_TOL:
233
+ gdf1['geometry'] = gdf1.simplify(self.SIMPLIFY_TOL, preserve_topology=True)
234
+ gdf1['geometry'] = gdf1.make_valid()
235
+
236
+ unified_geom = unary_union(gdf1.geometry)
237
+
238
+ return meta, unified_geom
239
+
240
+ def raster_to_polygon(self, in_raster, gpkg_path, metadata_row, valid_classes=(0, 1, 2), nodata=None, connectivity=8):
241
+ nodata = self.NODATA_VAL if nodata is None else nodata
242
+ in_raster = Path(in_raster)
243
+ with rasterio.open(in_raster) as src:
244
+ arr = src.read(1)
245
+ tfm = src.transform
246
+ crs = src.crs
247
+ mask = arr != nodata
248
+ polygons = []
249
+ for geom, val in shapes(arr, mask=mask, transform=tfm, connectivity=connectivity):
250
+ if val in valid_classes:
251
+ poly = shape(geom)
252
+ if not poly.is_valid:
253
+ poly = poly.buffer(0)
254
+ if not poly.is_empty:
255
+ polygons.append(poly)
256
+
257
+ if not polygons:
258
+ raise ValueError("No polygons found to build AOI.")
259
+
260
+ unified = unary_union(polygons)
261
+ gdf = gpd.GeoDataFrame([metadata_row], geometry=[unified], crs=crs)
262
+ gdf.to_file(gpkg_path, layer="AOI", driver="GPKG")
263
+
264
+ def raster_data_bbox(self, in_raster, gpkg_path, metadata_row, nodata=None):
265
+ nodata = self.NODATA_VAL if nodata is None else nodata
266
+ with rasterio.open(in_raster) as src:
267
+ arr = src.read(1)
268
+ crs = src.crs
269
+ valid = arr != nodata
270
+ if np.issubdtype(arr.dtype, np.floating):
271
+ valid &= ~np.isnan(arr)
272
+ valid_mask = valid.astype(np.uint8)
273
+ geoms = [
274
+ shape(geom)
275
+ for geom, _ in shapes(valid_mask, mask=valid_mask, transform=src.transform)
276
+ ]
277
+
278
+ if not geoms:
279
+ raise ValueError("No valid data found to build AOI.")
280
+
281
+ unified = unary_union(geoms)
282
+ if self.SIMPLIFY_TOL:
283
+ unified = unified.simplify(self.SIMPLIFY_TOL, preserve_topology=True)
284
+ unified = make_valid(unified)
285
+
286
+ gdf = gpd.GeoDataFrame([metadata_row], geometry=[unified], crs=crs)
287
+ gdf.to_file(gpkg_path, layer="AOI", driver="GPKG")
288
+
289
+ def process(self, input_path, base_dest, event, intermediate_folder=None):
290
+ """
291
+ Process a folder of rasters or a single .tif.
292
+
293
+ event: the synthetic return-period event (e.g. "100"), applied to all files.
294
+ intermediate_folder: where temporary rasters are written; defaults to a
295
+ fresh temp dir. Removed (best-effort) when processing finishes.
296
+ """
297
+ BASE_DEST = Path(base_dest)
298
+ EVENT = event
299
+
300
+ tif_list = list_input_tifs(input_path)
301
+ if not tif_list:
302
+ self.log(f"No .tif files found under {input_path}")
303
+ return
304
+
305
+ self.log(f"Found {len(tif_list)} .tif file(s) under {input_path}")
306
+
307
+ # Set up the intermediate work dir (temp if not provided).
308
+ if intermediate_folder is None:
309
+ self._work_dir = Path(tempfile.mkdtemp(prefix="fimbench_"))
310
+ else:
311
+ self._work_dir = Path(intermediate_folder)
312
+ self._work_dir.mkdir(parents=True, exist_ok=True)
313
+
314
+ try:
315
+ for tif in tif_list:
316
+ try:
317
+ self.log(f"Processing: {tif}")
318
+
319
+ # Pre-processing steps
320
+ nodata_tif = self.process_tif(tif)
321
+ out_5070 = self.reproject_raster(nodata_tif, 5070)
322
+ out_4326 = self.reproject_raster(nodata_tif, 4326)
323
+ res_m_float, res_str, res_txt = self.get_resolution_string(out_5070)
324
+
325
+ # Centroid (4326) + DMS
326
+ x, y, raster_crs, b = self.get_raster_centroid(out_4326)
327
+ lon_str = self.decimal_to_dms_str(x, is_lat=False)
328
+ lat_str = self.decimal_to_dms_str(y, is_lat=True)
329
+ dms_code = lon_str + lat_str
330
+
331
+ # Metadata and geometry generation (return period comes from EVENT)
332
+ temp_filename = f"{self.SENSOR_CODE}_{res_str}_{EVENT}_{dms_code}_BM.tif"
333
+
334
+ meta_dict, unified_geom = self.build_metadata_dict(
335
+ out_4326, out_5070, temp_filename, res_m_float, res_txt, dms_code,
336
+ x, y, "", [], [], EVENT
337
+ )
338
+
339
+ # HUC overlay via the ArcGIS REST service (unified_geom is in EPSG:5070)
340
+ huc8_list, name_list, state_list = get_intersected_huc8(unified_geom, 5070)
341
+
342
+ # Update the metadata dictionary with the actual HUC results
343
+ meta_dict["HUC8"] = huc8_list
344
+ meta_dict["River Basin Name"] = name_list
345
+ meta_dict["State"] = f"{state_list}, USA" if state_list else "USA"
346
+ meta_dict["Description"] = (
347
+ f"The Flood Inundation Map (FIM) corresponds to FEMA Base Level Engineering "
348
+ f"for the {EVENT} flood with a spatial resolution of {res_txt}. "
349
+ f"The corresponding HUC IDs are {huc8_list}"
350
+ )
351
+
352
+ # File management
353
+ folder_name = f"{self.SENSOR_CODE}_{EVENT}_{dms_code}"
354
+ destination_folder = BASE_DEST / folder_name
355
+ destination_folder.mkdir(parents=True, exist_ok=True)
356
+
357
+ new_filename = f"{self.SENSOR_CODE}_{res_str}_{EVENT}_{dms_code}_BM.tif"
358
+ new_path = destination_folder / new_filename
359
+
360
+ shutil.copy2(out_4326, new_path)
361
+ self.log(f"Saved GeoTIFF: {new_path.name}")
362
+
363
+ # Save JSON
364
+ metadata_filename = new_path.name.replace("BM.tif", "metadata.json")
365
+ metadata_path = new_path.parent / metadata_filename
366
+ with open(metadata_path, "w") as jf:
367
+ json.dump(meta_dict, jf, indent=4)
368
+ self.log(f"Wrote metadata: {metadata_path.name}")
369
+
370
+ # AOI.gpkg
371
+ gpkg_path = new_path.parent / new_path.name.replace("BM.tif", "AOI.gpkg")
372
+ self.raster_data_bbox(out_4326, gpkg_path, meta_dict, nodata=self.NODATA_VAL)
373
+ self.log(f"Wrote AOI: {gpkg_path.name}")
374
+
375
+ except Exception as e:
376
+ self.log(f"Skipped {tif.name} due to error: {e}")
377
+ import traceback
378
+ traceback.print_exc()
379
+
380
+ self.log("Done.")
381
+ finally:
382
+ # Remove the intermediate work dir so the input folder stays clean
383
+ # (best-effort; ignore if it cannot be removed).
384
+ shutil.rmtree(self._work_dir, ignore_errors=True)
385
+ self._work_dir = None
386
+
387
+
388
+ def process(input_path, base_dest, event, intermediate_folder=None, **overrides):
389
+ """
390
+ Convenience wrapper: run FemaBleProcessor over a folder or a single .tif.
391
+
392
+ event: synthetic return-period event (e.g. "100").
393
+ intermediate_folder: temp raster dir (defaults to a temp dir, removed after).
394
+ Config overrides (e.g. source=...) are passed to the processor.
395
+ """
396
+ FemaBleProcessor(**overrides).process(
397
+ input_path, base_dest, event, intermediate_folder=intermediate_folder
398
+ )