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.
- cht_utils/__init__.py +28 -0
- cht_utils/cog/__init__.py +6 -0
- cht_utils/cog/geotiff_to_cog.py +79 -0
- cht_utils/cog/netcdf_to_cog.py +85 -0
- cht_utils/cog/xyz_to_cog.py +86 -0
- cht_utils/colors/__init__.py +6 -0
- cht_utils/colors/colors.py +117 -0
- cht_utils/fileio/__init__.py +21 -0
- cht_utils/fileio/deltares_ini.py +326 -0
- cht_utils/fileio/json_js.py +72 -0
- cht_utils/fileio/pli_file.py +233 -0
- cht_utils/fileio/tekal.py +234 -0
- cht_utils/fileio/xml.py +184 -0
- cht_utils/fileio/yaml.py +39 -0
- cht_utils/fileops/__init__.py +25 -0
- cht_utils/fileops/fileops.py +344 -0
- cht_utils/interpolation/__init__.py +5 -0
- cht_utils/interpolation/interpolation.py +152 -0
- cht_utils/maps/__init__.py +2 -0
- cht_utils/maps/fileops.py +191 -0
- cht_utils/maps/flood_map.py +1231 -0
- cht_utils/maps/topobathy_map.py +463 -0
- cht_utils/maps/utils.py +700 -0
- cht_utils/physics/__init__.py +8 -0
- cht_utils/physics/deshoal.py +63 -0
- cht_utils/physics/disper.py +91 -0
- cht_utils/physics/runup_vo21.py +229 -0
- cht_utils/physics/waves.py +59 -0
- cht_utils/probabilistic/__init__.py +5 -0
- cht_utils/probabilistic/prob_maps.py +263 -0
- cht_utils/remote/__init__.py +4 -0
- cht_utils/remote/s3.py +380 -0
- cht_utils/remote/sftp.py +192 -0
- cht_utils-2.0.0.dist-info/METADATA +30 -0
- cht_utils-2.0.0.dist-info/RECORD +39 -0
- cht_utils-2.0.0.dist-info/WHEEL +5 -0
- cht_utils-2.0.0.dist-info/licenses/LICENSE +21 -0
- cht_utils-2.0.0.dist-info/top_level.txt +1 -0
- cht_utils-2.0.0.dist-info/zip-safe +1 -0
|
@@ -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
|