cht_utils 2.0.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.
@@ -0,0 +1,1231 @@
1
+ """Flood map generation from water level data, topobathy, and index rasters.
2
+
3
+ Provides the FloodMap class for computing flood depth from water levels and
4
+ topography, writing output as GeoTIFF or NetCDF, creating map overlays, and
5
+ plotting. Also includes legacy tile-based flood map generation functions and
6
+ shared utilities for overview level selection, RGB conversion, and bounding
7
+ box reprojection.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import contextily as ctx
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import rasterio
18
+ import rioxarray
19
+ import xarray as xr
20
+ from matplotlib import cm
21
+ from matplotlib.colors import BoundaryNorm, ListedColormap
22
+ from matplotlib.patches import Patch
23
+ from PIL import Image
24
+ from pyproj import Transformer
25
+ from rasterio.warp import Resampling
26
+
27
+ import cht_utils.maps.fileops as fo
28
+ from cht_utils.maps.utils import deg2num, num2deg, png2elevation, png2int
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ __all__ = [
33
+ "FloodMap",
34
+ "get_appropriate_overview_level",
35
+ "get_rgb_data_array",
36
+ "reproject_bbox",
37
+ "make_flood_map_tiles",
38
+ "make_flood_map_overlay_v2",
39
+ ]
40
+
41
+
42
+ class FloodMap:
43
+ """Compute and visualise flood depth maps from water level and topobathy data.
44
+
45
+ Uses Cloud Optimized GeoTIFF (COG) files for topography and cell
46
+ indices, and combines them with water level arrays to produce flood
47
+ depth grids. Supports writing output as GeoTIFF/NetCDF, creating
48
+ PNG map overlays, and matplotlib plotting.
49
+
50
+ Parameters
51
+ ----------
52
+ topobathy_file : str | Path | None
53
+ Path to the topobathy COG file.
54
+ index_file : str | Path | None
55
+ Path to the cell-index COG file.
56
+ zbmin : float
57
+ Minimum allowable topobathy value; below this is masked.
58
+ zbmax : float
59
+ Maximum allowable topobathy value; above this is masked.
60
+ hmin : float
61
+ Minimum water depth threshold; shallower areas are masked.
62
+ max_pixel_size : float
63
+ Maximum pixel size for overview level selection.
64
+ data_array_name : str
65
+ Name of the depth variable in the output dataset.
66
+ cmap : str | None
67
+ Matplotlib colormap name.
68
+ cmin : float | None
69
+ Minimum value for colormap normalization.
70
+ cmax : float | None
71
+ Maximum value for colormap normalization.
72
+ color_values : list[dict] | None
73
+ Discrete color definitions with ``lower_value``, ``upper_value``,
74
+ and ``color``/``rgb`` keys.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ topobathy_file: str | Path | None = None,
80
+ index_file: str | Path | None = None,
81
+ zbmin: float = 0.0,
82
+ zbmax: float = 99999.9,
83
+ hmin: float = 0.1,
84
+ max_pixel_size: float = 0.0,
85
+ data_array_name: str = "water_depth",
86
+ cmap: str | None = None,
87
+ cmin: float | None = None,
88
+ cmax: float | None = None,
89
+ color_values: list[dict] | None = None,
90
+ ) -> None:
91
+ self.topobathy_file = None
92
+ self.index_file = None
93
+ self.zb = None
94
+ self.indices = None
95
+ self.zbmin = zbmin
96
+ self.zbmax = zbmax
97
+ self.hmin = hmin
98
+ self.max_pixel_size = max_pixel_size
99
+ self.data_array_name = data_array_name
100
+ self.color_values = color_values if color_values is not None else "default"
101
+ self.cmap = cmap if cmap is not None else "jet"
102
+ self.cmin = cmin if cmin is not None else 0.0
103
+ self.cmax = cmax if cmax is not None else 1.0
104
+ self.discrete_colors = color_values is not None
105
+ self.ds = xr.Dataset()
106
+
107
+ if topobathy_file is not None:
108
+ self.set_topobathy_file(topobathy_file)
109
+ if index_file is not None:
110
+ self.set_index_file(index_file)
111
+
112
+ self.legend = {}
113
+ self.legend["title"] = "Flood Depth (m)"
114
+ self.legend["contour"] = []
115
+ self.legend["contour"].append(
116
+ {"color": "#FF0000", "lower_value": 2.0, "text": "2.0+ m"}
117
+ )
118
+ self.legend["contour"].append(
119
+ {
120
+ "color": "#FFA500",
121
+ "lower_value": 1.0,
122
+ "upper_value": 2.0,
123
+ "text": "1.0--2.0 m",
124
+ }
125
+ )
126
+ self.legend["contour"].append(
127
+ {
128
+ "color": "#FFFF00",
129
+ "lower_value": 0.3,
130
+ "upper_value": 1.0,
131
+ "text": "0.3--1.0 m",
132
+ }
133
+ )
134
+ self.legend["contour"].append(
135
+ {
136
+ "color": "#00FF00",
137
+ "lower_value": 0.1,
138
+ "upper_value": 0.3,
139
+ "text": "0.1--0.3 m",
140
+ }
141
+ )
142
+
143
+ def set_topobathy_file(self, topobathy_file: str | Path) -> None:
144
+ """Set the topobathy file and open it with rasterio.
145
+
146
+ Parameters
147
+ ----------
148
+ topobathy_file : str | Path
149
+ Path to the topobathy COG file.
150
+ """
151
+ self.topobathy_file = topobathy_file
152
+ self.zb = rasterio.open(self.topobathy_file)
153
+
154
+ def set_index_file(self, index_file: str | Path) -> None:
155
+ """Set the index file and open it with rasterio.
156
+
157
+ Parameters
158
+ ----------
159
+ index_file : str | Path
160
+ Path to the cell-index COG file.
161
+ """
162
+ self.index_file = index_file
163
+ self.indices = rasterio.open(self.index_file)
164
+
165
+ def close(self) -> None:
166
+ """Close the topobathy, index, and dataset file handles."""
167
+ if self.zb is not None:
168
+ self.zb.close()
169
+ if self.indices is not None:
170
+ self.indices.close()
171
+ self.ds.close()
172
+
173
+ def read(self, tiffile: str | Path) -> None:
174
+ """Read a GeoTIFF file with pre-computed flood depth data.
175
+
176
+ Parameters
177
+ ----------
178
+ tiffile : str | Path
179
+ Path to the GeoTIFF file.
180
+ """
181
+ self.ds = xr.Dataset()
182
+ self.ds["water_depth"] = rioxarray.open_rasterio(tiffile, masked=True).squeeze()
183
+
184
+ def set_water_level(self, zs: float | np.ndarray) -> None:
185
+ """Set the water level data used for flood depth computation.
186
+
187
+ Parameters
188
+ ----------
189
+ zs : float | np.ndarray
190
+ A scalar or 1-D array of water levels indexed by cell index.
191
+ """
192
+ self.zs = zs
193
+
194
+ def make(
195
+ self,
196
+ max_pixel_size: float = 0.0,
197
+ bbox: tuple[float, float, float, float] | None = None,
198
+ ) -> xr.Dataset:
199
+ """Compute flood depth from water levels, topobathy, and cell indices.
200
+
201
+ Reads topobathy and index COGs at the appropriate overview level,
202
+ computes ``h = zs[index] - zb``, and masks areas that are too
203
+ shallow or outside elevation bounds.
204
+
205
+ Parameters
206
+ ----------
207
+ max_pixel_size : float
208
+ Maximum pixel size in metres for overview selection. If 0.0,
209
+ the native resolution is used.
210
+ bbox : tuple[float, float, float, float] | None
211
+ Bounding box ``(minx, miny, maxx, maxy)`` to clip the data.
212
+
213
+ Returns
214
+ -------
215
+ xr.Dataset
216
+ Dataset containing the computed flood depth array.
217
+ """
218
+ overview_level = 0
219
+
220
+ if max_pixel_size > 0.0:
221
+ overview_level = get_appropriate_overview_level(self.zb, max_pixel_size)
222
+
223
+ if overview_level == 0:
224
+ zb = rioxarray.open_rasterio(self.zb)
225
+ else:
226
+ zb = rioxarray.open_rasterio(self.zb, overview_level=overview_level)
227
+ if "band" in zb.dims and zb.sizes["band"] == 1:
228
+ zb = zb.squeeze(dim="band", drop=True)
229
+ if bbox is not None:
230
+ zb = zb.rio.clip_box(minx=bbox[0], miny=bbox[1], maxx=bbox[2], maxy=bbox[3])
231
+
232
+ if overview_level == 0:
233
+ indices = rioxarray.open_rasterio(self.indices)
234
+ else:
235
+ indices = rioxarray.open_rasterio(
236
+ self.indices, overview_level=overview_level
237
+ )
238
+ if "band" in indices.dims and indices.sizes["band"] == 1:
239
+ indices = indices.squeeze(dim="band", drop=True)
240
+ if bbox is not None:
241
+ indices = indices.rio.clip_box(
242
+ minx=bbox[0], miny=bbox[1], maxx=bbox[2], maxy=bbox[3]
243
+ )
244
+
245
+ nan_val_indices = indices.attrs["_FillValue"]
246
+ no_data_mask = indices == nan_val_indices
247
+ indices = np.squeeze(indices.to_numpy()[:])
248
+ indices[np.where(indices == nan_val_indices)] = 0
249
+
250
+ if isinstance(self.zs, float):
251
+ h = np.full(zb.shape, self.zs) - zb.to_numpy()[:]
252
+ else:
253
+ h = self.zs[indices] - zb.to_numpy()[:]
254
+ h[no_data_mask] = np.nan
255
+ h[h < self.hmin] = np.nan
256
+ h[zb.to_numpy()[:] < self.zbmin] = np.nan
257
+ h[zb.to_numpy()[:] > self.zbmax] = np.nan
258
+
259
+ self.ds = xr.Dataset()
260
+ self.ds[self.data_array_name] = xr.DataArray(
261
+ h, dims=["y", "x"], coords={"y": zb.y, "x": zb.x}
262
+ )
263
+ self.ds[self.data_array_name] = self.ds[self.data_array_name].rio.write_crs(
264
+ zb.rio.crs, inplace=True
265
+ )
266
+
267
+ def write(self, output_file: str | Path = "") -> None:
268
+ """Write the flood map to a GeoTIFF or NetCDF file.
269
+
270
+ Parameters
271
+ ----------
272
+ output_file : str | Path
273
+ Output file path. Extension determines format: ``".tif"`` for
274
+ COG GeoTIFF, ``".nc"`` for NetCDF.
275
+ """
276
+ if output_file.endswith(".nc"):
277
+ self.ds.to_netcdf(output_file)
278
+
279
+ elif output_file.endswith(".tif"):
280
+ if self.cmap is not None:
281
+ rgb_da = get_rgb_data_array(
282
+ self.ds[self.data_array_name],
283
+ color_values=self.color_values,
284
+ cmap=self.cmap,
285
+ cmin=self.cmin,
286
+ cmax=self.cmax,
287
+ )
288
+
289
+ rgb_da.rio.to_raster(
290
+ output_file,
291
+ driver="COG",
292
+ compress="deflate",
293
+ blocksize=512,
294
+ overview_resampling="nearest",
295
+ )
296
+
297
+ else:
298
+ self.ds[self.data_array_name].rio.to_raster(
299
+ output_file,
300
+ driver="COG",
301
+ compress="deflate",
302
+ blocksize=512,
303
+ overview_resampling="nearest",
304
+ )
305
+
306
+ def map_overlay(
307
+ self,
308
+ file_name: str,
309
+ xlim: list[float] | None = None,
310
+ ylim: list[float] | None = None,
311
+ width: int = 800,
312
+ ) -> bool:
313
+ """Create a PNG map overlay of the flood map in EPSG:3857.
314
+
315
+ Parameters
316
+ ----------
317
+ file_name : str
318
+ Output PNG file path.
319
+ xlim : list[float] | None
320
+ Longitude extent ``[lon_min, lon_max]``.
321
+ ylim : list[float] | None
322
+ Latitude extent ``[lat_min, lat_max]``.
323
+ width : int
324
+ Width in pixels for resolution calculation.
325
+
326
+ Returns
327
+ -------
328
+ bool
329
+ True on success, False on failure.
330
+ """
331
+ if self.ds is None:
332
+ logger.error(
333
+ "Dataset is not initialized. Call make() or read() before map_overlay()."
334
+ )
335
+ return False
336
+
337
+ try:
338
+ lon_min = xlim[0]
339
+ lat_min = ylim[0]
340
+ lon_max = xlim[1]
341
+ lat_max = ylim[1]
342
+
343
+ transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
344
+ x_min, y_min = transformer.transform(lon_min, lat_min)
345
+ x_max, y_max = transformer.transform(lon_max, lat_max)
346
+
347
+ dxy = (x_max - x_min) / width
348
+
349
+ bbox = reproject_bbox(
350
+ lon_min,
351
+ lat_min,
352
+ lon_max,
353
+ lat_max,
354
+ crs_src="EPSG:4326",
355
+ crs_dst=self.zb.crs,
356
+ buffer=0.05,
357
+ )
358
+
359
+ self.make(max_pixel_size=dxy, bbox=bbox)
360
+
361
+ rgb_da = get_rgb_data_array(
362
+ self.ds[self.data_array_name],
363
+ cmap=self.cmap,
364
+ cmin=self.cmin,
365
+ cmax=self.cmax,
366
+ discrete_colors=self.discrete_colors,
367
+ color_values=self.color_values,
368
+ )
369
+
370
+ rgb_3857 = rgb_da.rio.reproject(
371
+ "EPSG:3857", resampling=Resampling.bilinear, nodata=0
372
+ )
373
+
374
+ rgb_3857 = rgb_3857.rio.pad_box(
375
+ minx=x_min, miny=y_min, maxx=x_max, maxy=y_max, constant_values=0
376
+ )
377
+
378
+ rgb_crop = rgb_3857.rio.clip_box(
379
+ minx=x_min, miny=y_min, maxx=x_max, maxy=y_max
380
+ )
381
+
382
+ rgba = rgb_crop.transpose("y", "x", "band").to_numpy().astype("uint8")
383
+
384
+ plt.imsave(file_name, rgba)
385
+
386
+ if self.discrete_colors:
387
+ self.legend = {}
388
+ self.legend["title"] = "Flood Depth (m)"
389
+ self.legend["contour"] = []
390
+
391
+ if isinstance(self.color_values, str):
392
+ color_values = []
393
+ color_values.append(
394
+ {"color": "lightgreen", "lower_value": 0.1, "upper_value": 0.3}
395
+ )
396
+ color_values.append(
397
+ {"color": "yellow", "lower_value": 0.3, "upper_value": 1.0}
398
+ )
399
+ color_values.append(
400
+ {"color": "#FFA500", "lower_value": 1.0, "upper_value": 2.0}
401
+ )
402
+ color_values.append({"color": "red", "lower_value": 2.0})
403
+ else:
404
+ color_values = self.color_values
405
+
406
+ for cv in color_values:
407
+ legend_item = {}
408
+ legend_item["color"] = cv["color"]
409
+ if "upper_value" in cv and "lower_value" in cv:
410
+ legend_item["lower_value"] = cv["lower_value"]
411
+ legend_item["upper_value"] = cv["upper_value"]
412
+ legend_item["text"] = (
413
+ f"{cv['lower_value']}--{cv['upper_value']} m"
414
+ )
415
+ elif "upper_value" in cv:
416
+ legend_item["upper_value"] = cv["upper_value"]
417
+ legend_item["text"] = f"{cv['lower_value']}- m"
418
+ else:
419
+ legend_item["lower_value"] = cv["lower_value"]
420
+ legend_item["text"] = f"{cv['lower_value']}+ m"
421
+ self.legend["contour"].append(legend_item)
422
+
423
+ else:
424
+ self.legend = {}
425
+ self.legend["title"] = "Flood Depth (m)"
426
+ self.legend["cmin"] = self.cmin
427
+ self.legend["cmax"] = self.cmax
428
+ self.legend["cmap"] = self.cmap
429
+
430
+ return True
431
+
432
+ except Exception as e:
433
+ logger.exception(e)
434
+ return False
435
+
436
+ def plot(
437
+ self,
438
+ pngfile: str,
439
+ zoom: int | None = None,
440
+ title: str = "Flood Depth (m)",
441
+ color_values: list[dict] | None = None,
442
+ cmap: str = "Blues",
443
+ vmin: float = 0.0,
444
+ vmax: float = 5.0,
445
+ lon_lim: list[float] | None = None,
446
+ lat_lim: list[float] | None = None,
447
+ width: float = 10.0,
448
+ background: str = "EsriWorldImagery",
449
+ ) -> None:
450
+ """Plot the flood map with a basemap and save to PNG.
451
+
452
+ Parameters
453
+ ----------
454
+ pngfile : str
455
+ Output PNG file path.
456
+ zoom : int | None
457
+ Basemap zoom level. If None, auto-detected.
458
+ title : str
459
+ Plot title.
460
+ color_values : list[dict] | None
461
+ Discrete color definitions. If a string is passed, a default
462
+ flood depth color scheme is used.
463
+ cmap : str
464
+ Matplotlib colormap for continuous coloring.
465
+ vmin : float
466
+ Minimum value for color mapping.
467
+ vmax : float
468
+ Maximum value for color mapping.
469
+ lon_lim : list[float] | None
470
+ Longitude limits ``[lon_min, lon_max]``.
471
+ lat_lim : list[float] | None
472
+ Latitude limits ``[lat_min, lat_max]``.
473
+ width : float
474
+ Figure width in inches.
475
+ background : str
476
+ Basemap provider: ``"osm"`` or ``"EsriWorldImagery"``.
477
+ """
478
+ if lon_lim is None or lat_lim is None:
479
+ lon_min = self.ds.x.min().to_numpy()
480
+ lat_min = self.ds.y.min().to_numpy()
481
+ lon_max = self.ds.x.max().to_numpy()
482
+ lat_max = self.ds.y.max().to_numpy()
483
+ crs = self.ds[self.data_array_name].rio.crs
484
+ transformer = Transformer.from_crs(crs, "EPSG:3857", always_xy=True)
485
+ x_min, y_min = transformer.transform(lon_min, lat_min)
486
+ x_max, y_max = transformer.transform(lon_max, lat_max)
487
+ else:
488
+ transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
489
+ x_min, y_min = transformer.transform(lon_lim[0], lat_lim[0])
490
+ x_max, y_max = transformer.transform(lon_lim[1], lat_lim[1])
491
+
492
+ da_3857 = self.ds[self.data_array_name].rio.reproject("EPSG:3857")
493
+
494
+ if color_values is None:
495
+ discrete_colors = False
496
+ else:
497
+ discrete_colors = True
498
+ if isinstance(color_values, str):
499
+ color_values = []
500
+ color_values.append(
501
+ {"color": "lightgreen", "lower_value": 0.1, "upper_value": 0.3}
502
+ )
503
+ color_values.append(
504
+ {"color": "yellow", "lower_value": 0.3, "upper_value": 1.0}
505
+ )
506
+ color_values.append(
507
+ {"color": "#FFA500", "lower_value": 1.0, "upper_value": 2.0}
508
+ )
509
+ color_values.append({"color": "red", "lower_value": 2.0})
510
+
511
+ aspect_ratio = (y_max - y_min) / (x_max - x_min)
512
+ fig, ax = plt.subplots(figsize=(width, aspect_ratio * width))
513
+
514
+ if discrete_colors:
515
+ masked = da_3857.where(da_3857 >= color_values[0]["lower_value"])
516
+
517
+ classified = xr.full_like(masked, np.nan)
518
+ colors = []
519
+ labels = []
520
+ for icolor, color_value in enumerate(color_values):
521
+ if "upper_value" in color_value:
522
+ lv = color_value["lower_value"]
523
+ uv = color_value["upper_value"]
524
+ classified = classified.where(
525
+ ~((masked > lv) & (masked <= uv)), icolor + 1
526
+ )
527
+ labels.append(f"{lv}--{uv} m")
528
+ else:
529
+ lv = color_value["lower_value"]
530
+ classified = classified.where(~(masked > lv), icolor + 1)
531
+ labels.append(f">{lv} m")
532
+ colors.append(color_value["color"])
533
+
534
+ cmap = ListedColormap(colors)
535
+ bounds = list(range(1, len(colors) + 2))
536
+
537
+ norm = BoundaryNorm(bounds, cmap.N)
538
+
539
+ classified.plot(ax=ax, cmap=cmap, norm=norm, add_colorbar=False)
540
+
541
+ legend_elements = []
542
+ for i, color_value in enumerate(color_values):
543
+ legend_elements.append(
544
+ Patch(facecolor=color_value["color"], label=labels[i])
545
+ )
546
+ plt.legend(handles=legend_elements, title="Flood Depth", loc="lower right")
547
+
548
+ else:
549
+ da_3857.plot(
550
+ ax=ax,
551
+ cmap=cmap,
552
+ vmin=vmin,
553
+ vmax=vmax,
554
+ add_colorbar=True,
555
+ cbar_kwargs={"label": "Flood Depth (m)"},
556
+ alpha=0.75,
557
+ )
558
+
559
+ if background.lower() == "osm":
560
+ if zoom is None:
561
+ ctx.add_basemap(
562
+ ax, crs=da_3857.rio.crs, source=ctx.providers.OpenStreetMap.Mapnik
563
+ )
564
+ else:
565
+ ctx.add_basemap(
566
+ ax,
567
+ crs=da_3857.rio.crs,
568
+ source=ctx.providers.OpenStreetMap.Mapnik,
569
+ zoom=zoom,
570
+ )
571
+ else:
572
+ if zoom is None:
573
+ ctx.add_basemap(
574
+ ax, crs=da_3857.rio.crs, source=ctx.providers.Esri.WorldImagery
575
+ )
576
+ else:
577
+ ctx.add_basemap(
578
+ ax,
579
+ crs=da_3857.rio.crs,
580
+ source=ctx.providers.Esri.WorldImagery,
581
+ zoom=zoom,
582
+ )
583
+
584
+ ax.set_xlim(x_min, x_max)
585
+ ax.set_ylim(y_min, y_max)
586
+
587
+ ax.set_axis_off()
588
+ plt.title(title)
589
+
590
+ plt.tight_layout()
591
+ plt.savefig(pngfile, dpi=300, bbox_inches="tight", pad_inches=0.1)
592
+
593
+
594
+ def get_appropriate_overview_level(
595
+ src: rasterio.io.DatasetReader, max_pixel_size: float
596
+ ) -> int:
597
+ """Determine the appropriate rasterio overview level for a target resolution.
598
+
599
+ Parameters
600
+ ----------
601
+ src : rasterio.io.DatasetReader
602
+ An open rasterio dataset.
603
+ max_pixel_size : float
604
+ Maximum desired pixel size in metres.
605
+
606
+ Returns
607
+ -------
608
+ int
609
+ The overview level index (0 = native resolution).
610
+ """
611
+ original_resolution = src.res
612
+ if src.crs.is_geographic:
613
+ original_resolution = (
614
+ original_resolution[0] * 111000,
615
+ original_resolution[1] * 111000,
616
+ )
617
+
618
+ overview_levels = src.overviews(1)
619
+
620
+ if not overview_levels:
621
+ return 0
622
+
623
+ resolutions = [
624
+ (original_resolution[0] * factor, original_resolution[1] * factor)
625
+ for factor in overview_levels
626
+ ]
627
+
628
+ selected_overview = 0
629
+ for i, (x_res, y_res) in enumerate(resolutions):
630
+ if x_res <= max_pixel_size and y_res <= max_pixel_size:
631
+ selected_overview = i
632
+ else:
633
+ break
634
+
635
+ return selected_overview
636
+
637
+
638
+ def get_rgb_data_array(
639
+ da: xr.DataArray,
640
+ cmap: str,
641
+ cmin: float | None = None,
642
+ cmax: float | None = None,
643
+ color_values: list[dict] | None = None,
644
+ discrete_colors: bool = False,
645
+ ) -> xr.DataArray:
646
+ """Convert an xarray DataArray to an RGBA DataArray using a colormap.
647
+
648
+ Supports both continuous colormaps and discrete color value ranges.
649
+
650
+ Parameters
651
+ ----------
652
+ da : xr.DataArray
653
+ Input 2-D data array.
654
+ cmap : str
655
+ Matplotlib colormap name for continuous coloring.
656
+ cmin : float | None
657
+ Minimum value for normalization. Defaults to data minimum.
658
+ cmax : float | None
659
+ Maximum value for normalization. Defaults to data maximum.
660
+ color_values : list[dict] | None
661
+ Discrete color definitions with ``lower_value``, ``upper_value``,
662
+ and ``rgb`` keys.
663
+ discrete_colors : bool
664
+ If True and ``color_values`` is provided, use discrete coloring
665
+ via named color strings.
666
+
667
+ Returns
668
+ -------
669
+ xr.DataArray
670
+ RGBA DataArray with shape ``(4, height, width)`` and dtype uint8.
671
+ """
672
+ ny, nx = da.shape
673
+ if color_values is not None:
674
+ zz = da.to_numpy()
675
+
676
+ if discrete_colors:
677
+ if isinstance(color_values, str):
678
+ color_values = []
679
+ color_values.append(
680
+ {"color": "lightgreen", "lower_value": 0.1, "upper_value": 0.3}
681
+ )
682
+ color_values.append(
683
+ {"color": "yellow", "lower_value": 0.3, "upper_value": 1.0}
684
+ )
685
+ color_values.append(
686
+ {"color": "#FFA500", "lower_value": 1.0, "upper_value": 2.0}
687
+ )
688
+ color_values.append({"color": "red", "lower_value": 2.0})
689
+
690
+ rgba = np.zeros((ny, nx, 4), "uint8")
691
+ for color_value in color_values:
692
+ lower = color_value.get("lower_value", -np.inf)
693
+ upper = color_value.get("upper_value", np.inf)
694
+ inr = np.logical_and(zz >= lower, zz < upper)
695
+ valid = np.logical_and(inr, ~np.isnan(zz))
696
+
697
+ if "rgb" in color_value:
698
+ rgba[valid, 0] = color_value["rgb"][0]
699
+ rgba[valid, 1] = color_value["rgb"][1]
700
+ rgba[valid, 2] = color_value["rgb"][2]
701
+ elif "color" in color_value:
702
+ color_rgba = cm.colors.to_rgba(color_value["color"])
703
+ rgba[valid, 0] = int(color_rgba[0] * 255)
704
+ rgba[valid, 1] = int(color_rgba[1] * 255)
705
+ rgba[valid, 2] = int(color_rgba[2] * 255)
706
+ rgba[valid, 3] = 255
707
+
708
+ else:
709
+ if cmap is None:
710
+ raise ValueError("Either color_values or cmap must be provided")
711
+
712
+ if cmin is None:
713
+ cmin = da.min()
714
+ if cmax is None:
715
+ cmax = da.max()
716
+
717
+ if cmin == cmax:
718
+ cmin = cmax - 1.0
719
+ cmax = cmax + 1.0
720
+
721
+ normed = (da - cmin) / (cmax - cmin)
722
+
723
+ cmap_obj = plt.get_cmap(cmap)
724
+
725
+ rgba = cmap_obj(normed)
726
+
727
+ rgba = (rgba[:, :, :] * 255).astype("uint8")
728
+
729
+ rgb_da = xr.DataArray(
730
+ np.moveaxis(rgba, -1, 0),
731
+ dims=("band", "y", "x"),
732
+ coords={"band": [0, 1, 2, 3], "y": da.y, "x": da.x},
733
+ attrs=da.attrs,
734
+ )
735
+
736
+ rgb_da.rio.write_crs(da.rio.crs, inplace=True)
737
+
738
+ return rgb_da
739
+
740
+
741
+ def reproject_bbox(
742
+ xmin: float,
743
+ ymin: float,
744
+ xmax: float,
745
+ ymax: float,
746
+ crs_src: str,
747
+ crs_dst: str,
748
+ buffer: float = 0.0,
749
+ ) -> tuple[float, float, float, float]:
750
+ """Reproject a bounding box between coordinate reference systems.
751
+
752
+ Parameters
753
+ ----------
754
+ xmin : float
755
+ Minimum x (or longitude).
756
+ ymin : float
757
+ Minimum y (or latitude).
758
+ xmax : float
759
+ Maximum x (or longitude).
760
+ ymax : float
761
+ Maximum y (or latitude).
762
+ crs_src : str
763
+ Source CRS string (e.g. ``"EPSG:4326"``).
764
+ crs_dst : str
765
+ Destination CRS string.
766
+ buffer : float
767
+ Fractional buffer to expand the bounding box before reprojection.
768
+
769
+ Returns
770
+ -------
771
+ tuple[float, float, float, float]
772
+ Reprojected bounding box ``(xmin, ymin, xmax, ymax)``.
773
+ """
774
+ transformer = Transformer.from_crs(crs_src, crs_dst, always_xy=True)
775
+
776
+ dx = (xmax - xmin) * buffer
777
+ dy = (ymax - ymin) * buffer
778
+ xmin -= dx
779
+ xmax += dx
780
+ ymin -= dy
781
+ ymax += dy
782
+
783
+ x0, y0 = transformer.transform(xmin, ymin)
784
+ x1, y1 = transformer.transform(xmax, ymin)
785
+ x2, y2 = transformer.transform(xmax, ymax)
786
+ x3, y3 = transformer.transform(xmin, ymax)
787
+
788
+ xs = [x0, x1, x2, x3]
789
+ ys = [y0, y1, y2, y3]
790
+
791
+ return min(xs), min(ys), max(xs), max(ys)
792
+
793
+
794
+ def make_flood_map_tiles(
795
+ valg: np.ndarray,
796
+ index_path: str,
797
+ png_path: str,
798
+ topo_path: str,
799
+ option: str = "deterministic",
800
+ zoom_range: list[int] | None = None,
801
+ color_values: list[dict] | None = None,
802
+ caxis: list[float] | None = None,
803
+ zbmax: float = -999.0,
804
+ merge: bool = True,
805
+ depth: float | None = None,
806
+ quiet: bool = False,
807
+ ) -> None:
808
+ """Generate flood map PNG tiles from water level data and index/topo tiles.
809
+
810
+ Parameters
811
+ ----------
812
+ valg : np.ndarray
813
+ Water level values (1-D array indexed by cell index, or a list
814
+ of CDF interpolators for probabilistic mode).
815
+ index_path : str
816
+ Directory containing index tile PNG files.
817
+ png_path : str
818
+ Output directory for the generated flood map tiles.
819
+ topo_path : str
820
+ Directory containing topobathy tile PNG files.
821
+ option : str
822
+ Tile generation mode: ``"deterministic"`` or ``"probabilistic"``.
823
+ zoom_range : list[int] | None
824
+ Two-element list ``[min_zoom, max_zoom]``. Auto-detected if None.
825
+ color_values : list[dict] | None
826
+ Discrete color definitions with ``lower_value``, ``upper_value``,
827
+ and ``rgb`` keys.
828
+ caxis : list[float] | None
829
+ Color axis range ``[vmin, vmax]``. Auto-detected if None.
830
+ zbmax : float
831
+ Maximum bed level; flood in areas below this is suppressed.
832
+ merge : bool
833
+ Whether to merge new tiles with existing ones.
834
+ depth : float | None
835
+ Water depth offset for probabilistic mode.
836
+ quiet : bool
837
+ Whether to suppress progress output.
838
+ """
839
+ if isinstance(valg, list):
840
+ pass
841
+ else:
842
+ valg = valg.transpose().flatten()
843
+
844
+ if not caxis:
845
+ caxis = []
846
+ caxis.append(np.nanmin(valg))
847
+ caxis.append(np.nanmax(valg))
848
+
849
+ # First do highest zoom level, then derefine from there
850
+ if not zoom_range:
851
+ levs = fo.list_folders(os.path.join(index_path, "*"), basename=True)
852
+ zoom_range = [999, -999]
853
+ for lev in levs:
854
+ zoom_range[0] = min(zoom_range[0], int(lev))
855
+ zoom_range[1] = max(zoom_range[1], int(lev))
856
+
857
+ izoom = zoom_range[1]
858
+
859
+ if not quiet:
860
+ logger.info(f"Processing zoom level {izoom}")
861
+
862
+ index_zoom_path = os.path.join(index_path, str(izoom))
863
+
864
+ png_zoom_path = os.path.join(png_path, str(izoom))
865
+ fo.mkdir(png_zoom_path)
866
+
867
+ for ifolder in fo.list_folders(os.path.join(index_zoom_path, "*")):
868
+ path_okay = False
869
+ ifolder = os.path.basename(ifolder)
870
+ index_zoom_path_i = os.path.join(index_zoom_path, ifolder)
871
+ png_zoom_path_i = os.path.join(png_zoom_path, ifolder)
872
+
873
+ for jfile in fo.list_files(os.path.join(index_zoom_path_i, "*.png")):
874
+ jfile = os.path.basename(jfile)
875
+ j = int(jfile[:-4])
876
+
877
+ index_file = os.path.join(index_zoom_path_i, jfile)
878
+ png_file = os.path.join(png_zoom_path_i, f"{j}.png")
879
+
880
+ ind = png2int(index_file, -1)
881
+ ind = ind.flatten()
882
+
883
+ if option == "probabilistic":
884
+ bathy_file = os.path.join(topo_path, str(izoom), ifolder, f"{j}.png")
885
+ if not os.path.exists(bathy_file):
886
+ continue
887
+ zb = png2elevation(bathy_file).flatten()
888
+ zs = zb + depth
889
+
890
+ valt = valg[ind](zs)
891
+ valt[ind < 0] = np.nan
892
+
893
+ else:
894
+ bathy_file = os.path.join(topo_path, str(izoom), ifolder, f"{j}.png")
895
+ if not os.path.exists(bathy_file):
896
+ continue
897
+ zb = png2elevation(bathy_file).flatten()
898
+
899
+ noval = np.where(ind < 0)
900
+ ind[ind < 0] = 0
901
+ valt = valg[ind]
902
+
903
+ valt = valt - zb
904
+ valt[valt < 0.10] = np.nan
905
+ valt[zb < zbmax] = np.nan
906
+ valt[noval] = np.nan
907
+
908
+ if color_values:
909
+ rgb = np.zeros((256 * 256, 4), "uint8")
910
+
911
+ for color_value in color_values:
912
+ inr = np.logical_and(
913
+ valt >= color_value["lower_value"],
914
+ valt < color_value["upper_value"],
915
+ )
916
+ rgb[inr, 0] = color_value["rgb"][0]
917
+ rgb[inr, 1] = color_value["rgb"][1]
918
+ rgb[inr, 2] = color_value["rgb"][2]
919
+ rgb[inr, 3] = 255
920
+
921
+ rgb = rgb.reshape([256, 256, 4])
922
+ if not np.any(rgb > 0):
923
+ continue
924
+ im = Image.fromarray(rgb)
925
+
926
+ else:
927
+ valt = valt.reshape([256, 256])
928
+ valt = (valt - caxis[0]) / (caxis[1] - caxis[0])
929
+ valt[valt < 0.0] = 0.0
930
+ valt[valt > 1.0] = 1.0
931
+ im = Image.fromarray(cm.jet(valt, bytes=True))
932
+
933
+ if not path_okay:
934
+ if not os.path.exists(png_zoom_path_i):
935
+ fo.mkdir(png_zoom_path_i)
936
+ path_okay = True
937
+
938
+ if os.path.exists(png_file):
939
+ if merge:
940
+ im0 = Image.open(png_file)
941
+ rgb = np.array(im)
942
+ rgb0 = np.array(im0)
943
+ isum = np.sum(rgb, axis=2)
944
+ rgb[isum == 0, :] = rgb0[isum == 0, :]
945
+ im = Image.fromarray(rgb)
946
+
947
+ im.save(png_file)
948
+
949
+ # Now make tiles for lower level by merging
950
+
951
+ for izoom in range(zoom_range[1] - 1, zoom_range[0] - 1, -1):
952
+ if not quiet:
953
+ logger.info(f"Processing zoom level {izoom}")
954
+
955
+ index_zoom_path = os.path.join(index_path, str(izoom))
956
+
957
+ if not os.path.exists(index_zoom_path):
958
+ continue
959
+
960
+ png_zoom_path = os.path.join(png_path, str(izoom))
961
+ png_zoom_path_p1 = os.path.join(png_path, str(izoom + 1))
962
+ fo.mkdir(png_zoom_path)
963
+
964
+ for ifolder in fo.list_folders(os.path.join(index_zoom_path, "*")):
965
+ path_okay = False
966
+ ifolder = os.path.basename(ifolder)
967
+ i = int(ifolder)
968
+ index_zoom_path_i = os.path.join(index_zoom_path, ifolder)
969
+ png_zoom_path_i = os.path.join(png_zoom_path, ifolder)
970
+
971
+ for jfile in fo.list_files(os.path.join(index_zoom_path_i, "*.png")):
972
+ jfile = os.path.basename(jfile)
973
+ j = int(jfile[:-4])
974
+
975
+ png_file = os.path.join(png_zoom_path_i, f"{j}.png")
976
+
977
+ rgb = np.zeros((256, 256, 4), "uint8")
978
+
979
+ i0 = i * 2
980
+ i1 = i * 2 + 1
981
+ j0 = j * 2 + 1
982
+ j1 = j * 2
983
+
984
+ tile_name_00 = os.path.join(png_zoom_path_p1, str(i0), f"{j0}.png")
985
+ tile_name_10 = os.path.join(png_zoom_path_p1, str(i0), f"{j1}.png")
986
+ tile_name_01 = os.path.join(png_zoom_path_p1, str(i1), f"{j0}.png")
987
+ tile_name_11 = os.path.join(png_zoom_path_p1, str(i1), f"{j1}.png")
988
+
989
+ okay = False
990
+
991
+ # Lower-left
992
+ if os.path.exists(tile_name_00):
993
+ okay = True
994
+ rgb0 = np.array(Image.open(tile_name_00))
995
+ rgb[128:256, 0:128, :] = rgb0[0:255:2, 0:255:2, :]
996
+ # Upper-left
997
+ if os.path.exists(tile_name_10):
998
+ okay = True
999
+ rgb0 = np.array(Image.open(tile_name_10))
1000
+ rgb[0:128, 0:128, :] = rgb0[0:255:2, 0:255:2, :]
1001
+ # Lower-right
1002
+ if os.path.exists(tile_name_01):
1003
+ okay = True
1004
+ rgb0 = np.array(Image.open(tile_name_01))
1005
+ rgb[128:256, 128:256, :] = rgb0[0:255:2, 0:255:2, :]
1006
+ # Upper-right
1007
+ if os.path.exists(tile_name_11):
1008
+ okay = True
1009
+ rgb0 = np.array(Image.open(tile_name_11))
1010
+ rgb[0:128, 128:256, :] = rgb0[0:255:2, 0:255:2, :]
1011
+
1012
+ if okay:
1013
+ im = Image.fromarray(rgb)
1014
+
1015
+ if not path_okay:
1016
+ if not os.path.exists(png_zoom_path_i):
1017
+ fo.mkdir(png_zoom_path_i)
1018
+ path_okay = True
1019
+
1020
+ if os.path.exists(png_file):
1021
+ if merge:
1022
+ im0 = Image.open(png_file)
1023
+ rgb = np.array(im)
1024
+ rgb0 = np.array(im0)
1025
+ isum = np.sum(rgb, axis=2)
1026
+ rgb[isum == 0, :] = rgb0[isum == 0, :]
1027
+ im = Image.fromarray(rgb)
1028
+
1029
+ im.save(png_file)
1030
+
1031
+
1032
+ def make_flood_map_overlay_v2(
1033
+ valg: np.ndarray,
1034
+ index_path: str,
1035
+ topo_path: str,
1036
+ zmax_minus_zmin: np.ndarray | None = None,
1037
+ mean_depth: np.ndarray | None = None,
1038
+ npixels: list[int] = [1200, 800],
1039
+ hmin: float = 0.10,
1040
+ dzdx_mild: float = 0.01,
1041
+ lon_range: list[float] | None = None,
1042
+ lat_range: list[float] | None = None,
1043
+ option: str = "deterministic",
1044
+ color_values: list[dict] | None = None,
1045
+ caxis: list[float] | None = None,
1046
+ zbmax: float = -999.0,
1047
+ merge: bool = True,
1048
+ depth: float | None = None,
1049
+ quiet: bool = False,
1050
+ file_name: str | None = None,
1051
+ ) -> tuple[list[float], list[float], list[float]] | tuple[None, None]:
1052
+ """Generate a single flood map overlay PNG from tiles at an auto-selected zoom.
1053
+
1054
+ Parameters
1055
+ ----------
1056
+ valg : np.ndarray
1057
+ Water level values (1-D array or DataArray).
1058
+ index_path : str
1059
+ Directory containing index tile PNG files.
1060
+ topo_path : str
1061
+ Directory containing topobathy tile PNG files.
1062
+ zmax_minus_zmin : np.ndarray | None
1063
+ Per-cell elevation range for slope filtering.
1064
+ mean_depth : np.ndarray | None
1065
+ Per-cell mean water depth (volume / area).
1066
+ npixels : list[int]
1067
+ Target output size ``[width, height]`` in pixels.
1068
+ hmin : float
1069
+ Minimum water depth threshold.
1070
+ dzdx_mild : float
1071
+ Slope threshold below which mean_depth overrides pixel depth.
1072
+ lon_range : list[float] | None
1073
+ Longitude range ``[lon_min, lon_max]``.
1074
+ lat_range : list[float] | None
1075
+ Latitude range ``[lat_min, lat_max]``.
1076
+ option : str
1077
+ Mode: ``"deterministic"`` or ``"probabilistic"``.
1078
+ color_values : list[dict] | None
1079
+ Discrete color definitions.
1080
+ caxis : list[float] | None
1081
+ Color axis range. Auto-detected if None.
1082
+ zbmax : float
1083
+ Maximum bed level for flood masking.
1084
+ merge : bool
1085
+ Whether to merge with existing tiles.
1086
+ depth : float | None
1087
+ Water depth offset for probabilistic mode.
1088
+ quiet : bool
1089
+ Whether to suppress progress output.
1090
+ file_name : str | None
1091
+ Output PNG file path.
1092
+
1093
+ Returns
1094
+ -------
1095
+ tuple[list[float], list[float], list[float]] | tuple[None, None]
1096
+ ``([lon_min, lon_max], [lat_min, lat_max], caxis)`` on success,
1097
+ or ``(None, None)`` on failure.
1098
+ """
1099
+ try:
1100
+ if isinstance(valg, list):
1101
+ logger.info("valg is a list!")
1102
+ elif isinstance(valg, xr.DataArray):
1103
+ valg = valg.to_numpy()
1104
+ if mean_depth is not None:
1105
+ mean_depth = mean_depth.to_numpy()
1106
+ if zmax_minus_zmin is not None:
1107
+ zmax_minus_zmin = zmax_minus_zmin.to_numpy()
1108
+ else:
1109
+ valg = valg.transpose().flatten()
1110
+ if mean_depth is not None:
1111
+ mean_depth = mean_depth.transpose().flatten()
1112
+ if zmax_minus_zmin is not None:
1113
+ zmax_minus_zmin = zmax_minus_zmin.transpose().flatten()
1114
+
1115
+ if mean_depth is not None and zmax_minus_zmin is not None:
1116
+ mean_depth[(zmax_minus_zmin > dzdx_mild)] = np.nan
1117
+
1118
+ max_zoom = 0
1119
+ levs = fo.list_folders(os.path.join(index_path, "*"), basename=True)
1120
+ for lev in levs:
1121
+ max_zoom = max(max_zoom, int(lev))
1122
+
1123
+ for izoom in range(max_zoom + 1):
1124
+ ix0, it0 = deg2num(lat_range[1], lon_range[0], izoom)
1125
+ ix1, it1 = deg2num(lat_range[0], lon_range[1], izoom)
1126
+ if (ix1 - ix0 + 1) * 256 > npixels[0] and (it1 - it0 + 1) * 256 > npixels[
1127
+ 1
1128
+ ]:
1129
+ break
1130
+
1131
+ index_zoom_path = os.path.join(index_path, str(izoom))
1132
+
1133
+ nx = (ix1 - ix0 + 1) * 256
1134
+ ny = (it1 - it0 + 1) * 256
1135
+ zz = np.empty((ny, nx))
1136
+ zz[:] = np.nan
1137
+
1138
+ if not quiet:
1139
+ logger.info(f"Processing zoom level {izoom}")
1140
+
1141
+ index_zoom_path = os.path.join(index_path, str(izoom))
1142
+
1143
+ for i in range(ix0, ix1 + 1):
1144
+ ifolder = str(i)
1145
+ index_zoom_path_i = os.path.join(index_zoom_path, ifolder)
1146
+
1147
+ for j in range(it0, it1 + 1):
1148
+ index_file = os.path.join(index_zoom_path_i, f"{j}.png")
1149
+
1150
+ if not os.path.exists(index_file):
1151
+ continue
1152
+
1153
+ ind = png2int(index_file, -1)
1154
+
1155
+ if option == "probabilistic":
1156
+ bathy_file = os.path.join(
1157
+ topo_path, str(izoom), ifolder, f"{j}.png"
1158
+ )
1159
+
1160
+ if not os.path.exists(bathy_file):
1161
+ continue
1162
+
1163
+ zb = np.fromfile(bathy_file, dtype="f4")
1164
+ zs = zb + depth
1165
+
1166
+ valt = valg[ind](zs)
1167
+ valt[ind < 0] = np.nan
1168
+
1169
+ else:
1170
+ bathy_file = os.path.join(
1171
+ topo_path, str(izoom), ifolder, f"{j}.png"
1172
+ )
1173
+ if not os.path.exists(bathy_file):
1174
+ continue
1175
+
1176
+ zb = png2elevation(bathy_file)
1177
+
1178
+ valt = valg[ind]
1179
+
1180
+ valt = valt - zb
1181
+
1182
+ if mean_depth is not None:
1183
+ mean_depth_p = mean_depth[ind]
1184
+ valt[~np.isnan(mean_depth_p)] = mean_depth_p[
1185
+ ~np.isnan(mean_depth_p)
1186
+ ]
1187
+
1188
+ valt[valt < hmin] = np.nan
1189
+ valt[zb < zbmax] = np.nan
1190
+
1191
+ ii0 = (i - ix0) * 256
1192
+ ii1 = ii0 + 256
1193
+ jj0 = (j - it0) * 256
1194
+ jj1 = jj0 + 256
1195
+ zz[jj0:jj1, ii0:ii1] = valt
1196
+
1197
+ if color_values:
1198
+ zz = zz.flatten()
1199
+ rgb = np.zeros((ny * nx, 4), "uint8")
1200
+ for color_value in color_values:
1201
+ inr = np.logical_and(
1202
+ zz >= color_value["lower_value"], zz < color_value["upper_value"]
1203
+ )
1204
+ rgb[inr, 0] = color_value["rgb"][0]
1205
+ rgb[inr, 1] = color_value["rgb"][1]
1206
+ rgb[inr, 2] = color_value["rgb"][2]
1207
+ rgb[inr, 3] = 255
1208
+ im = Image.fromarray(rgb.reshape([ny, nx, 4]))
1209
+
1210
+ else:
1211
+ if not caxis:
1212
+ caxis = []
1213
+ caxis.append(np.nanmin(valg))
1214
+ caxis.append(np.nanmax(valg))
1215
+
1216
+ zz = (zz - caxis[0]) / (caxis[1] - caxis[0])
1217
+ zz[zz < 0.0] = 0.0
1218
+ zz[zz > 1.0] = 1.0
1219
+ im = Image.fromarray(cm.jet(zz, bytes=True))
1220
+
1221
+ if file_name:
1222
+ im.save(file_name)
1223
+
1224
+ lat1, lon0 = num2deg(ix0, it0, izoom)
1225
+ lat0, lon1 = num2deg(ix1 + 1, it1 + 1, izoom)
1226
+
1227
+ return [lon0, lon1], [lat0, lat1], caxis
1228
+
1229
+ except Exception as e:
1230
+ logger.exception(e)
1231
+ return None, None