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,463 @@
1
+ """Topography/bathymetry map generation from Cloud Optimized GeoTIFF (COG) files.
2
+
3
+ Provides the TopoBathyMap class for reading elevation data, applying color
4
+ maps, writing output files (GeoTIFF/NetCDF), creating map overlays, and
5
+ plotting with matplotlib.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ import contextily as ctx
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import rasterio
15
+ import rioxarray
16
+ import xarray as xr
17
+ from matplotlib.colors import BoundaryNorm, ListedColormap
18
+ from matplotlib.patches import Patch
19
+ from pyproj import Transformer
20
+ from rasterio.warp import Resampling
21
+
22
+ from cht_utils.maps.flood_map import (
23
+ get_appropriate_overview_level,
24
+ get_rgb_data_array,
25
+ reproject_bbox,
26
+ )
27
+
28
+ __all__ = ["TopoBathyMap"]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class TopoBathyMap:
34
+ """Manages reading, processing, and visualising topography/bathymetry data.
35
+
36
+ Wraps a Cloud Optimized GeoTIFF elevation file and provides methods for
37
+ reading at various overview levels, applying elevation masks, writing
38
+ output as GeoTIFF or NetCDF, creating PNG map overlays, and producing
39
+ matplotlib plots.
40
+
41
+ Parameters
42
+ ----------
43
+ topobathy_file : str | Path | None
44
+ Path to the topobathy COG file.
45
+ zbmin : float
46
+ Minimum allowable elevation value; values below are masked to NaN.
47
+ zbmax : float
48
+ Maximum allowable elevation value; values above are masked to NaN.
49
+ max_pixel_size : float
50
+ Maximum pixel size for selecting the appropriate overview level.
51
+ data_array_name : str
52
+ Name of the elevation variable in the output dataset.
53
+ cmap : str | None
54
+ Matplotlib colormap name for coloring.
55
+ cmin : float | None
56
+ Minimum value for colormap normalization (scaled by ``scale_factor``).
57
+ cmax : float | None
58
+ Maximum value for colormap normalization (scaled by ``scale_factor``).
59
+ color_values : list[dict] | None
60
+ Discrete color definitions with ``lower_value``, ``upper_value``,
61
+ and ``color`` keys.
62
+ scale_factor : float
63
+ Multiplicative factor applied to elevation values on read.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ topobathy_file: str | Path | None = None,
69
+ zbmin: float = -999999.9,
70
+ zbmax: float = 99999.9,
71
+ max_pixel_size: float = 0.0,
72
+ data_array_name: str = "elevation",
73
+ cmap: str | None = None,
74
+ cmin: float | None = None,
75
+ cmax: float | None = None,
76
+ color_values: list[dict] | None = None,
77
+ scale_factor: float = 1.0,
78
+ ) -> None:
79
+ self.topobathy_file = topobathy_file
80
+ self.zb = rasterio.open(topobathy_file) if topobathy_file else None
81
+ self.zbmin = zbmin
82
+ self.zbmax = zbmax
83
+ self.max_pixel_size = max_pixel_size
84
+ self.data_array_name = data_array_name
85
+ self.cmap = cmap
86
+ self.cmin = cmin * scale_factor
87
+ self.cmax = cmax * scale_factor
88
+ self.color_values = color_values
89
+ self.scale_factor = scale_factor
90
+ self.ds = xr.Dataset()
91
+
92
+ def set_topobathy_file(self, topobathy_file: str | Path) -> None:
93
+ """Set the topobathy file and open it with rasterio.
94
+
95
+ Parameters
96
+ ----------
97
+ topobathy_file : str | Path
98
+ Path to the topobathy COG file.
99
+ """
100
+ self.topobathy_file = topobathy_file
101
+ self.zb = rasterio.open(self.topobathy_file)
102
+
103
+ def close(self) -> None:
104
+ """Close the rasterio dataset and the xarray dataset."""
105
+ if self.zb is not None:
106
+ self.zb.close()
107
+ self.ds.close()
108
+
109
+ def read(self, tiffile: str | Path) -> None:
110
+ """Read a GeoTIFF file with elevation data into the dataset.
111
+
112
+ Parameters
113
+ ----------
114
+ tiffile : str | Path
115
+ Path to the GeoTIFF file.
116
+ """
117
+ self.ds = xr.Dataset()
118
+ self.ds[self.data_array_name] = rioxarray.open_rasterio(
119
+ tiffile, masked=True
120
+ ).squeeze()
121
+
122
+ def make(
123
+ self,
124
+ max_pixel_size: float = 0.0,
125
+ bbox: tuple[float, float, float, float] | None = None,
126
+ ) -> xr.Dataset:
127
+ """Generate a topobathy dataset from the elevation COG.
128
+
129
+ Reads the data at an appropriate overview level, applies the scale
130
+ factor, clips to the bounding box, and masks values outside
131
+ ``[zbmin, zbmax]``.
132
+
133
+ Parameters
134
+ ----------
135
+ max_pixel_size : float
136
+ Maximum pixel size in metres for overview selection. If 0.0,
137
+ the native resolution is used.
138
+ bbox : tuple[float, float, float, float] | None
139
+ Bounding box ``(minx, miny, maxx, maxy)`` to clip the data.
140
+
141
+ Returns
142
+ -------
143
+ xr.Dataset
144
+ Dataset containing the masked elevation data array.
145
+ """
146
+ overview_level = 0
147
+
148
+ if max_pixel_size > 0.0:
149
+ overview_level = get_appropriate_overview_level(self.zb, max_pixel_size)
150
+
151
+ if overview_level == 0:
152
+ zb = rioxarray.open_rasterio(self.zb)
153
+ else:
154
+ zb = rioxarray.open_rasterio(self.zb, overview_level=overview_level)
155
+
156
+ zb = zb * self.scale_factor
157
+
158
+ if "band" in zb.dims and zb.sizes["band"] == 1:
159
+ zb = zb.squeeze(dim="band", drop=True)
160
+
161
+ if bbox is not None:
162
+ zb = zb.rio.clip_box(minx=bbox[0], miny=bbox[1], maxx=bbox[2], maxy=bbox[3])
163
+
164
+ elevation = zb.to_numpy()[:]
165
+ elevation[elevation < self.zbmin] = np.nan
166
+ elevation[elevation > self.zbmax] = np.nan
167
+
168
+ self.ds = xr.Dataset()
169
+ self.ds[self.data_array_name] = xr.DataArray(
170
+ elevation, dims=["y", "x"], coords={"y": zb.y, "x": zb.x}
171
+ )
172
+ self.ds[self.data_array_name] = self.ds[self.data_array_name].rio.write_crs(
173
+ zb.rio.crs, inplace=True
174
+ )
175
+
176
+ return self.ds
177
+
178
+ def write(self, output_file: str | Path = "") -> None:
179
+ """Write the topobathy data to a GeoTIFF or NetCDF file.
180
+
181
+ Parameters
182
+ ----------
183
+ output_file : str | Path
184
+ Output file path. Extension determines the format:
185
+ ``".tif"`` for COG GeoTIFF, ``".nc"`` for NetCDF.
186
+ """
187
+ if output_file.endswith(".nc"):
188
+ self.ds.to_netcdf(output_file)
189
+
190
+ elif output_file.endswith(".tif"):
191
+ if self.cmap is not None:
192
+ rgb_da = get_rgb_data_array(
193
+ self.ds[self.data_array_name],
194
+ cmap=self.cmap,
195
+ cmin=self.cmin,
196
+ cmax=self.cmax,
197
+ color_values=self.color_values,
198
+ )
199
+
200
+ rgb_da.rio.to_raster(
201
+ output_file,
202
+ driver="COG",
203
+ compress="deflate",
204
+ blocksize=512,
205
+ overview_resampling="nearest",
206
+ )
207
+
208
+ else:
209
+ self.ds[self.data_array_name].rio.to_raster(
210
+ output_file,
211
+ driver="COG",
212
+ compress="deflate",
213
+ blocksize=512,
214
+ overview_resampling="nearest",
215
+ )
216
+
217
+ def map_overlay(
218
+ self,
219
+ file_name: str,
220
+ xlim: list[float] | None = None,
221
+ ylim: list[float] | None = None,
222
+ width: int = 800,
223
+ ) -> bool:
224
+ """Create a PNG map overlay of the topobathy data in EPSG:3857.
225
+
226
+ Parameters
227
+ ----------
228
+ file_name : str
229
+ Output PNG file path.
230
+ xlim : list[float] | None
231
+ Longitude extent ``[lon_min, lon_max]``.
232
+ ylim : list[float] | None
233
+ Latitude extent ``[lat_min, lat_max]``.
234
+ width : int
235
+ Width in pixels for resolution calculation.
236
+
237
+ Returns
238
+ -------
239
+ bool
240
+ True on success, False on failure.
241
+ """
242
+ if self.ds is None:
243
+ return False
244
+
245
+ try:
246
+ lon_min = xlim[0]
247
+ lat_min = ylim[0]
248
+ lon_max = xlim[1]
249
+ lat_max = ylim[1]
250
+
251
+ transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
252
+ x_min, y_min = transformer.transform(lon_min, lat_min)
253
+ x_max, y_max = transformer.transform(lon_max, lat_max)
254
+
255
+ dxy = (x_max - x_min) / width
256
+
257
+ bbox = reproject_bbox(
258
+ lon_min,
259
+ lat_min,
260
+ lon_max,
261
+ lat_max,
262
+ crs_src="EPSG:4326",
263
+ crs_dst=self.zb.crs,
264
+ buffer=0.05,
265
+ )
266
+
267
+ self.make(max_pixel_size=dxy, bbox=bbox)
268
+
269
+ rgb_da = get_rgb_data_array(
270
+ self.ds[self.data_array_name],
271
+ cmap=self.cmap,
272
+ cmin=self.cmin,
273
+ cmax=self.cmax,
274
+ color_values=self.color_values,
275
+ )
276
+
277
+ rgb_3857 = rgb_da.rio.reproject(
278
+ "EPSG:3857", resampling=Resampling.bilinear, nodata=0
279
+ )
280
+
281
+ rgb_3857 = rgb_3857.rio.pad_box(
282
+ minx=x_min, miny=y_min, maxx=x_max, maxy=y_max, constant_values=0
283
+ )
284
+
285
+ rgb_crop = rgb_3857.rio.clip_box(
286
+ minx=x_min, miny=y_min, maxx=x_max, maxy=y_max
287
+ )
288
+
289
+ rgba = rgb_crop.transpose("y", "x", "band").to_numpy().astype("uint8")
290
+
291
+ plt.imsave(file_name, rgba)
292
+
293
+ return True
294
+
295
+ except Exception as e:
296
+ logger.exception(f"Error in map_overlay: {e}")
297
+ return False
298
+
299
+ def plot(
300
+ self,
301
+ pngfile: str,
302
+ zoom: int | None = None,
303
+ title: str = "Elevation (m)",
304
+ color_values: list[dict] | None = None,
305
+ cmap: str = "terrain",
306
+ vmin: float | None = None,
307
+ vmax: float | None = None,
308
+ lon_lim: list[float] | None = None,
309
+ lat_lim: list[float] | None = None,
310
+ width: float = 10.0,
311
+ background: str = "EsriWorldImagery",
312
+ ) -> None:
313
+ """Plot the topobathy data with a basemap and save to PNG.
314
+
315
+ Parameters
316
+ ----------
317
+ pngfile : str
318
+ Output PNG file path.
319
+ zoom : int | None
320
+ Basemap zoom level. If None, auto-detected.
321
+ title : str
322
+ Plot title.
323
+ color_values : list[dict] | None
324
+ Discrete color definitions. If a string is passed, a default
325
+ elevation color scheme is used.
326
+ cmap : str
327
+ Matplotlib colormap for continuous coloring.
328
+ vmin : float | None
329
+ Minimum value for color mapping. Auto-detected if None.
330
+ vmax : float | None
331
+ Maximum value for color mapping. Auto-detected if None.
332
+ lon_lim : list[float] | None
333
+ Longitude limits ``[lon_min, lon_max]`` for the plot extent.
334
+ lat_lim : list[float] | None
335
+ Latitude limits ``[lat_min, lat_max]`` for the plot extent.
336
+ width : float
337
+ Figure width in inches.
338
+ background : str
339
+ Basemap provider: ``"osm"`` for OpenStreetMap or
340
+ ``"EsriWorldImagery"`` (default).
341
+ """
342
+ if lon_lim is None or lat_lim is None:
343
+ lon_min = self.ds.x.min().to_numpy()
344
+ lat_min = self.ds.y.min().to_numpy()
345
+ lon_max = self.ds.x.max().to_numpy()
346
+ lat_max = self.ds.y.max().to_numpy()
347
+ crs = self.ds[self.data_array_name].rio.crs
348
+ transformer = Transformer.from_crs(crs, "EPSG:3857", always_xy=True)
349
+ x_min, y_min = transformer.transform(lon_min, lat_min)
350
+ x_max, y_max = transformer.transform(lon_max, lat_max)
351
+ else:
352
+ transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
353
+ x_min, y_min = transformer.transform(lon_lim[0], lat_lim[0])
354
+ x_max, y_max = transformer.transform(lon_lim[1], lat_lim[1])
355
+
356
+ da_3857 = self.ds[self.data_array_name].rio.reproject("EPSG:3857")
357
+
358
+ if vmin is None:
359
+ vmin = float(da_3857.min())
360
+ if vmax is None:
361
+ vmax = float(da_3857.max())
362
+
363
+ if color_values is None:
364
+ discrete_colors = False
365
+ else:
366
+ discrete_colors = True
367
+ if isinstance(color_values, str):
368
+ color_values = []
369
+ color_values.append(
370
+ {"color": "blue", "lower_value": -100, "upper_value": 0}
371
+ )
372
+ color_values.append(
373
+ {"color": "lightblue", "lower_value": 0, "upper_value": 10}
374
+ )
375
+ color_values.append(
376
+ {"color": "green", "lower_value": 10, "upper_value": 50}
377
+ )
378
+ color_values.append(
379
+ {"color": "brown", "lower_value": 50, "upper_value": 100}
380
+ )
381
+ color_values.append({"color": "white", "lower_value": 100})
382
+
383
+ aspect_ratio = (y_max - y_min) / (x_max - x_min)
384
+ fig, ax = plt.subplots(figsize=(width, aspect_ratio * width))
385
+
386
+ if discrete_colors:
387
+ masked = da_3857.where(da_3857 >= color_values[0]["lower_value"])
388
+
389
+ classified = xr.full_like(masked, np.nan)
390
+ colors = []
391
+ labels = []
392
+ for icolor, color_value in enumerate(color_values):
393
+ if "upper_value" in color_value:
394
+ lv = color_value["lower_value"]
395
+ uv = color_value["upper_value"]
396
+ classified = classified.where(
397
+ ~((masked > lv) & (masked <= uv)), icolor + 1
398
+ )
399
+ labels.append(f"{lv}--{uv} m")
400
+ else:
401
+ lv = color_value["lower_value"]
402
+ classified = classified.where(~(masked > lv), icolor + 1)
403
+ labels.append(f">{lv} m")
404
+ colors.append(color_value["color"])
405
+
406
+ cmap_obj = ListedColormap(colors)
407
+ bounds = list(range(1, len(colors) + 2))
408
+ norm = BoundaryNorm(bounds, cmap_obj.N)
409
+
410
+ classified.plot(ax=ax, cmap=cmap_obj, norm=norm, add_colorbar=False)
411
+
412
+ legend_elements = []
413
+ for i, color_value in enumerate(color_values):
414
+ legend_elements.append(
415
+ Patch(facecolor=color_value["color"], label=labels[i])
416
+ )
417
+ plt.legend(handles=legend_elements, title="Elevation", loc="lower right")
418
+
419
+ else:
420
+ da_3857.plot(
421
+ ax=ax,
422
+ cmap=cmap,
423
+ vmin=vmin,
424
+ vmax=vmax,
425
+ add_colorbar=True,
426
+ cbar_kwargs={"label": "Elevation (m)"},
427
+ alpha=0.75,
428
+ )
429
+
430
+ if background.lower() == "osm":
431
+ if zoom is None:
432
+ ctx.add_basemap(
433
+ ax, crs=da_3857.rio.crs, source=ctx.providers.OpenStreetMap.Mapnik
434
+ )
435
+ else:
436
+ ctx.add_basemap(
437
+ ax,
438
+ crs=da_3857.rio.crs,
439
+ source=ctx.providers.OpenStreetMap.Mapnik,
440
+ zoom=zoom,
441
+ )
442
+ else:
443
+ if zoom is None:
444
+ ctx.add_basemap(
445
+ ax, crs=da_3857.rio.crs, source=ctx.providers.Esri.WorldImagery
446
+ )
447
+ else:
448
+ ctx.add_basemap(
449
+ ax,
450
+ crs=da_3857.rio.crs,
451
+ source=ctx.providers.Esri.WorldImagery,
452
+ zoom=zoom,
453
+ )
454
+
455
+ ax.set_xlim(x_min, x_max)
456
+ ax.set_ylim(y_min, y_max)
457
+
458
+ ax.set_axis_off()
459
+ plt.title(title)
460
+
461
+ plt.tight_layout()
462
+ plt.savefig(pngfile, dpi=300, bbox_inches="tight", pad_inches=0.1)
463
+ plt.close()