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,700 @@
1
+ """Utility functions for tile coordinate conversions, elevation/PNG encoding, and interpolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import math
7
+ import os
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+ from PIL import Image
12
+ from scipy.interpolate import RegularGridInterpolator
13
+
14
+
15
+ def get_zoom_level_for_resolution(dx: float) -> int:
16
+ """Determine the tile zoom level that matches a given spatial resolution.
17
+
18
+ Parameters
19
+ ----------
20
+ dx : float
21
+ Desired pixel size in metres.
22
+
23
+ Returns
24
+ -------
25
+ int
26
+ Zoom level (0-23) whose native pixel size is just below *dx*.
27
+ """
28
+ dxy = 156543.03 / 2 ** np.arange(24)
29
+ izoom = np.where(dxy < dx)[0]
30
+ if len(izoom) == 0:
31
+ izoom = 23
32
+ else:
33
+ izoom = int(izoom[0])
34
+ return izoom
35
+
36
+
37
+ def get_zoom_level(npixels: int, lat_range: list[float], max_zoom: int) -> int:
38
+ """Determine the zoom level needed to cover a latitude range with a given pixel count.
39
+
40
+ Parameters
41
+ ----------
42
+ npixels : int
43
+ Number of pixels available in the latitude direction.
44
+ lat_range : list[float]
45
+ Two-element list ``[lat_min, lat_max]`` in degrees.
46
+ max_zoom : int
47
+ Maximum allowed zoom level.
48
+
49
+ Returns
50
+ -------
51
+ int
52
+ Appropriate zoom level.
53
+ """
54
+ dxr = (lat_range[1] - lat_range[0]) * 111111 / npixels
55
+ dxy = 156543.03 / 2 ** np.arange(max_zoom + 1)
56
+ izoom = np.where(dxy < dxr)[0]
57
+ if len(izoom) == 0:
58
+ izoom = max_zoom
59
+ else:
60
+ izoom = izoom[0]
61
+ return izoom
62
+
63
+
64
+ def webmercator_to_lat_lon(easting: float, northing: float) -> tuple[float, float]:
65
+ """Convert Web Mercator coordinates to latitude and longitude.
66
+
67
+ Parameters
68
+ ----------
69
+ easting : float
70
+ Web Mercator easting in metres.
71
+ northing : float
72
+ Web Mercator northing in metres.
73
+
74
+ Returns
75
+ -------
76
+ tuple[float, float]
77
+ ``(latitude, longitude)`` in degrees.
78
+ """
79
+ lon = (easting / 20037508.34) * 180
80
+ lat = (180 / math.pi) * (
81
+ 2 * math.atan(math.exp(northing / 20037508.34 * math.pi)) - (math.pi / 2)
82
+ )
83
+ return lat, lon
84
+
85
+
86
+ def lat_lon_to_webmercator(lat: float, lon: float) -> tuple[float, float]:
87
+ """Convert latitude and longitude to Web Mercator coordinates.
88
+
89
+ Parameters
90
+ ----------
91
+ lat : float
92
+ Latitude in degrees.
93
+ lon : float
94
+ Longitude in degrees.
95
+
96
+ Returns
97
+ -------
98
+ tuple[float, float]
99
+ ``(x, y)`` in Web Mercator metres.
100
+ """
101
+ x = lon * 20037508.34 / 180
102
+ y = (math.log(math.tan((90 + lat) * math.pi / 360)) / math.pi) * 20037508.34
103
+ return x, y
104
+
105
+
106
+ def lat_lon_to_tile_indices(lat: float, lon: float, zoom: int) -> tuple[int, int]:
107
+ """Convert latitude/longitude to slippy-map tile indices.
108
+
109
+ Parameters
110
+ ----------
111
+ lat : float
112
+ Latitude in degrees.
113
+ lon : float
114
+ Longitude in degrees.
115
+ zoom : int
116
+ Zoom level.
117
+
118
+ Returns
119
+ -------
120
+ tuple[int, int]
121
+ ``(tile_x, tile_y)`` column and row indices.
122
+ """
123
+ tile_x = int((lon + 180) / 360 * (2**zoom))
124
+ tile_y = int(
125
+ (
126
+ 1
127
+ - (
128
+ math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat)))
129
+ / math.pi
130
+ )
131
+ )
132
+ / 2
133
+ * (2**zoom)
134
+ )
135
+ return tile_x, tile_y
136
+
137
+
138
+ def xy2num(easting: float, northing: float, zoom: int) -> tuple[int, int]:
139
+ """Convert Web Mercator coordinates to tile indices.
140
+
141
+ Parameters
142
+ ----------
143
+ easting : float
144
+ Web Mercator easting in metres.
145
+ northing : float
146
+ Web Mercator northing in metres.
147
+ zoom : int
148
+ Zoom level.
149
+
150
+ Returns
151
+ -------
152
+ tuple[int, int]
153
+ ``(tile_x, tile_y)`` column and row indices.
154
+ """
155
+ lat, lon = webmercator_to_lat_lon(easting, northing)
156
+ ix, it = lat_lon_to_tile_indices(lat, lon, zoom)
157
+ return ix, it
158
+
159
+
160
+ def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> tuple[int, int]:
161
+ """Return the column and row index of a slippy tile for a given lat/lon.
162
+
163
+ Parameters
164
+ ----------
165
+ lat_deg : float
166
+ Latitude in degrees.
167
+ lon_deg : float
168
+ Longitude in degrees.
169
+ zoom : int
170
+ Zoom level.
171
+
172
+ Returns
173
+ -------
174
+ tuple[int, int]
175
+ ``(xtile, ytile)`` column and row indices.
176
+ """
177
+ lat_rad = math.radians(lat_deg)
178
+ n = 2**zoom
179
+ xtile = int((lon_deg + 180.0) / 360.0 * n)
180
+ ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
181
+ return (xtile, ytile)
182
+
183
+
184
+ def num2deg(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
185
+ """Return the upper-left latitude and longitude of a slippy tile.
186
+
187
+ Parameters
188
+ ----------
189
+ xtile : int
190
+ Tile column index.
191
+ ytile : int
192
+ Tile row index.
193
+ zoom : int
194
+ Zoom level.
195
+
196
+ Returns
197
+ -------
198
+ tuple[float, float]
199
+ ``(latitude, longitude)`` of the upper-left corner in degrees.
200
+ """
201
+ n = 2**zoom
202
+ lon_deg = xtile / n * 360.0 - 180.0
203
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
204
+ lat_deg = math.degrees(lat_rad)
205
+ return (lat_deg, lon_deg)
206
+
207
+
208
+ def num2xy(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
209
+ """Return the upper-left Web Mercator x/y of a slippy tile.
210
+
211
+ Parameters
212
+ ----------
213
+ xtile : int
214
+ Tile column index.
215
+ ytile : int
216
+ Tile row index.
217
+ zoom : int
218
+ Zoom level.
219
+
220
+ Returns
221
+ -------
222
+ tuple[float, float]
223
+ ``(x, y)`` in Web Mercator metres.
224
+ """
225
+ n = 2**zoom
226
+ lon_deg = xtile / n * 360.0 - 180.0
227
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
228
+ lat_deg = math.degrees(lat_rad)
229
+ x, y = lat_lon_to_webmercator(lat_deg, lon_deg)
230
+ return x, y
231
+
232
+
233
+ def num2deg_ll(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
234
+ """Return the lower-left latitude and longitude of a slippy tile (old format).
235
+
236
+ Parameters
237
+ ----------
238
+ xtile : int
239
+ Tile column index.
240
+ ytile : int
241
+ Tile row index.
242
+ zoom : int
243
+ Zoom level.
244
+
245
+ Returns
246
+ -------
247
+ tuple[float, float]
248
+ ``(latitude, longitude)`` of the lower-left corner in degrees.
249
+ """
250
+ n = 2**zoom
251
+ lon_deg = xtile / n * 360.0 - 180.0
252
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
253
+ lat_deg = math.degrees(-lat_rad)
254
+ return (lat_deg, lon_deg)
255
+
256
+
257
+ def num2deg_ur(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
258
+ """Return the upper-right latitude and longitude of a slippy tile (old format).
259
+
260
+ Parameters
261
+ ----------
262
+ xtile : int
263
+ Tile column index.
264
+ ytile : int
265
+ Tile row index.
266
+ zoom : int
267
+ Zoom level.
268
+
269
+ Returns
270
+ -------
271
+ tuple[float, float]
272
+ ``(latitude, longitude)`` of the upper-right corner in degrees.
273
+ """
274
+ n = 2**zoom
275
+ lon_deg = (xtile + 1) / n * 360.0 - 180.0
276
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (ytile + 1) / n)))
277
+ lat_deg = math.degrees(-lat_rad)
278
+ return (lat_deg, lon_deg)
279
+
280
+
281
+ def get_lower_left_corner(tile_x: int, tile_y: int, zoom: int) -> tuple[float, float]:
282
+ """Return the lower-left Web Mercator coordinates of a tile.
283
+
284
+ Parameters
285
+ ----------
286
+ tile_x : int
287
+ Tile column index.
288
+ tile_y : int
289
+ Tile row index.
290
+ zoom : int
291
+ Zoom level.
292
+
293
+ Returns
294
+ -------
295
+ tuple[float, float]
296
+ ``(ll_x, ll_y)`` lower-left corner in Web Mercator metres.
297
+ """
298
+ total_size = 20037508.34 * 2
299
+ tile_size = total_size / (2**zoom)
300
+ ll_x = tile_x * tile_size - 20037508.34
301
+ ll_y = 20037508.34 - (tile_y + 1) * tile_size
302
+ return ll_x, ll_y
303
+
304
+
305
+ def elevation2png(
306
+ val: NDArray[np.floating],
307
+ png_file: str,
308
+ encoder: str = "terrarium",
309
+ encoder_vmin: float = 0.0,
310
+ encoder_vmax: float = 1.0,
311
+ compress_level: int = 6,
312
+ ) -> None:
313
+ """Convert a 256x256 NumPy array to a PNG tile using a specified encoder.
314
+
315
+ Parameters
316
+ ----------
317
+ val : NDArray[np.floating]
318
+ 256x256 array of elevation or data values.
319
+ png_file : str
320
+ Output PNG file path.
321
+ encoder : str
322
+ Encoding scheme. One of ``"terrarium"``, ``"terrarium16"``, ``"uint8"``,
323
+ ``"uint16"``, ``"uint24"``, ``"uint32"``, ``"float8"``, ``"float16"``,
324
+ ``"float24"``, ``"float32"``.
325
+ encoder_vmin : float
326
+ Minimum value for float encoders.
327
+ encoder_vmax : float
328
+ Maximum value for float encoders.
329
+ compress_level : int
330
+ PNG compression level (0-9).
331
+
332
+ Raises
333
+ ------
334
+ ValueError
335
+ If values exceed the range supported by the chosen encoder.
336
+ """
337
+ if encoder == "terrarium":
338
+ rgb = np.zeros((256, 256, 3), "uint8")
339
+ val += 32768.0
340
+ rgb[:, :, 0] = np.floor(val / 256).astype(int)
341
+ rgb[:, :, 1] = np.floor(val % 256)
342
+ rgb[:, :, 2] = np.floor((val - np.floor(val)) * 256).astype(int)
343
+ elif encoder == "terrarium16":
344
+ rgb = np.zeros((256, 256, 3), "uint8")
345
+ val += 32768.0
346
+ rgb[:, :, 0] = np.floor(val / 256).astype(int)
347
+ rgb[:, :, 1] = np.floor(val % 256).astype(int)
348
+ elif encoder == "uint8":
349
+ if np.any(val >= 255):
350
+ raise ValueError(
351
+ "Some values in are equal to or larger than 255. This is not allowed for encoder 'uint8'."
352
+ )
353
+ rgb = np.zeros((256, 256, 3), "uint8") + 255
354
+ r = val + 0
355
+ r[np.where(val < 0)] = 255
356
+ rgb[:, :, 0] = r
357
+ elif encoder == "uint16":
358
+ if np.any(val >= 65535):
359
+ raise ValueError(
360
+ "Some values are equal to or larger than 65535. This is not allowed for encoder 'uint16'."
361
+ )
362
+ rgb = np.zeros((256, 256, 3), "uint8") + 255
363
+ r = (val // 256) % 256
364
+ g = val % 256
365
+ r[np.where(val < 0)] = 255
366
+ g[np.where(val < 0)] = 255
367
+ rgb[:, :, 0] = r
368
+ rgb[:, :, 1] = g
369
+ elif encoder == "uint24":
370
+ if np.any(val >= 16777215):
371
+ raise ValueError(
372
+ "Some values are equal to or larger than 16777215. This is not allowed for encoder 'uint24'."
373
+ )
374
+ rgb = np.zeros((256, 256, 3), "uint8") + 255
375
+ r = (val // 256**2) % 256
376
+ g = (val // 256) % 256
377
+ b = val % 256
378
+ r[np.where(val < 0)] = 255
379
+ g[np.where(val < 0)] = 255
380
+ b[np.where(val < 0)] = 255
381
+ rgb[:, :, 0] = r
382
+ rgb[:, :, 1] = g
383
+ rgb[:, :, 2] = b
384
+ elif encoder == "uint32":
385
+ if np.any(val >= 4294967295):
386
+ raise ValueError(
387
+ "Some values are equal to or larger than 4294967295. This is not allowed for encoder 'uint32'."
388
+ )
389
+ rgb = np.zeros((256, 256, 4), "uint8") + 255
390
+ r = (val // 256**3) % 256
391
+ g = (val // 256**2) % 256
392
+ b = (val // 256) % 256
393
+ a = val % 256
394
+ r[np.where(val < 0)] = 255
395
+ g[np.where(val < 0)] = 255
396
+ b[np.where(val < 0)] = 255
397
+ a[np.where(val < 0)] = 255
398
+ rgb[:, :, 0] = r
399
+ rgb[:, :, 1] = g
400
+ rgb[:, :, 2] = b
401
+ rgb[:, :, 3] = a
402
+ elif encoder == "float8":
403
+ val = np.maximum(val, encoder_vmin)
404
+ val = np.minimum(val, encoder_vmax)
405
+ val = val - encoder_vmin
406
+ i = np.floor(val * 254 / (encoder_vmax - encoder_vmin)).astype(int) + 1
407
+ i[np.isnan(val)] = 0
408
+ rgb = np.zeros((256, 256, 3), "uint8")
409
+ rgb[:, :, 0] = i
410
+ elif encoder == "float16":
411
+ val = np.maximum(val, encoder_vmin)
412
+ val = np.minimum(val, encoder_vmax)
413
+ val = val - encoder_vmin
414
+ i = np.floor(val * 65534 / (encoder_vmax - encoder_vmin)).astype(int) + 1
415
+ i[np.isnan(val)] = 0
416
+ rgb = np.zeros((256, 256, 3), "uint8")
417
+ rgb[:, :, 0] = (i // 256) % 256
418
+ rgb[:, :, 1] = i % 256
419
+ elif encoder == "float24":
420
+ val = np.maximum(val, encoder_vmin)
421
+ val = np.minimum(val, encoder_vmax)
422
+ val = val - encoder_vmin
423
+ i = np.floor(val * 16777214 / (encoder_vmax - encoder_vmin)).astype(int) + 1
424
+ i[np.isnan(val)] = 0
425
+ rgb = np.zeros((256, 256, 3), "uint8")
426
+ rgb[:, :, 0] = (i // 256**2) % 256
427
+ rgb[:, :, 1] = (i // 256) % 256
428
+ rgb[:, :, 2] = i % 256
429
+ elif encoder == "float32":
430
+ val = np.maximum(val, encoder_vmin)
431
+ val = np.minimum(val, encoder_vmax)
432
+ val = val - encoder_vmin
433
+ i = np.floor(val * 4294967294 / (encoder_vmax - encoder_vmin)).astype(int) + 1
434
+ i[np.isnan(val)] = 0
435
+ rgb = np.zeros((256, 256, 4), "uint8")
436
+ rgb[:, :, 0] = (i // 256**3) % 256
437
+ rgb[:, :, 1] = (i // 256**2) % 256
438
+ rgb[:, :, 2] = (i // 256) % 256
439
+ rgb[:, :, 3] = i % 256
440
+
441
+ img = Image.fromarray(rgb)
442
+ img.save(png_file, compress_level=compress_level)
443
+
444
+
445
+ def png2elevation(
446
+ png_file: str,
447
+ encoder: str = "terrarium",
448
+ encoder_vmin: float = 0.0,
449
+ encoder_vmax: float = 1.0,
450
+ ) -> NDArray[np.floating]:
451
+ """Convert a PNG tile back to an elevation array using a specified encoder.
452
+
453
+ Parameters
454
+ ----------
455
+ png_file : str
456
+ Input PNG file path.
457
+ encoder : str
458
+ Encoding scheme used when the tile was created.
459
+ encoder_vmin : float
460
+ Minimum value for float encoders.
461
+ encoder_vmax : float
462
+ Maximum value for float encoders.
463
+
464
+ Returns
465
+ -------
466
+ NDArray[np.floating]
467
+ 256x256 array of decoded elevation or data values.
468
+ """
469
+ img = Image.open(png_file)
470
+ if encoder == "terrarium":
471
+ rgb = np.array(img.convert("RGB")).astype(float)
472
+ elevation = (rgb[:, :, 0] * 256 + rgb[:, :, 1] + rgb[:, :, 2] / 256) - 32768.0
473
+ elevation[np.where(elevation < -32767.0)] = np.nan
474
+ elif encoder == "terrarium16":
475
+ rgb = np.array(img.convert("RGB")).astype(float)
476
+ elevation = (rgb[:, :, 0] * 256 + rgb[:, :, 1]) - 32768.0
477
+ elevation[np.where(elevation < -32767.0)] = np.nan
478
+ elif encoder == "uint8":
479
+ rgb = np.array(img.convert("RGB")).astype(int)
480
+ elevation = rgb[:, :, 0]
481
+ elevation[np.where(elevation == 255)] = -1
482
+ elif encoder == "uint16":
483
+ rgb = np.array(img.convert("RGB")).astype(int)
484
+ elevation = rgb[:, :, 0] * 256 + rgb[:, :, 1]
485
+ elevation[np.where(elevation == 65535)] = -1
486
+ elif encoder == "uint24":
487
+ rgb = np.array(img.convert("RGB")).astype(int)
488
+ elevation = rgb[:, :, 0] * 65536 + rgb[:, :, 1] * 256 + rgb[:, :, 2]
489
+ elevation[np.where(elevation == 16777215)] = -1
490
+ elif encoder == "uint32":
491
+ rgb = np.array(img.convert("RGBA")).astype(int)
492
+ elevation = (
493
+ rgb[:, :, 0] * 16777216
494
+ + rgb[:, :, 1] * 65536
495
+ + rgb[:, :, 2] * 256
496
+ + rgb[:, :, 3]
497
+ )
498
+ elevation[np.where(elevation == 4294967295)] = -1
499
+ elif encoder == "float8":
500
+ rgb = np.array(img.convert("RGB")).astype(float)
501
+ i = rgb[:, :, 0]
502
+ elevation = encoder_vmin + (encoder_vmax - encoder_vmin) * i / 254
503
+ elevation[np.where(i == 0)] = np.nan
504
+ elif encoder == "float16":
505
+ rgb = np.array(img.convert("RGB")).astype(float)
506
+ i = rgb[:, :, 0] * 256 + rgb[:, :, 1]
507
+ elevation = encoder_vmin + (encoder_vmax - encoder_vmin) * i / 65534
508
+ elevation[np.where(i == 0)] = np.nan
509
+ elif encoder == "float24":
510
+ rgb = np.array(img.convert("RGB")).astype(float)
511
+ i = rgb[:, :, 0] * 65536 + rgb[:, :, 1] * 256 + rgb[:, :, 2]
512
+ elevation = encoder_vmin + (encoder_vmax - encoder_vmin) * i / 16777214
513
+ elevation[np.where(i == 0)] = np.nan
514
+ elif encoder == "float32":
515
+ rgb = np.array(img.convert("RGBA")).astype(float)
516
+ i = (
517
+ rgb[:, :, 0] * 16777216
518
+ + rgb[:, :, 1] * 65536
519
+ + rgb[:, :, 2] * 256
520
+ + rgb[:, :, 3]
521
+ )
522
+ elevation = encoder_vmin + (encoder_vmax - encoder_vmin) * i / 4294967294
523
+ elevation[np.where(i == 0)] = np.nan
524
+ return elevation
525
+
526
+
527
+ def png2int(png_file: str, idummy: int) -> NDArray[np.signedinteger]:
528
+ """Convert a PNG tile to an integer index array.
529
+
530
+ Parameters
531
+ ----------
532
+ png_file : str
533
+ Input PNG file path.
534
+ idummy : int
535
+ Value to assign to pixels that decode as the maximum RGBA integer (no-data).
536
+
537
+ Returns
538
+ -------
539
+ NDArray[np.signedinteger]
540
+ 256x256 integer index array.
541
+ """
542
+ image = Image.open(png_file)
543
+ rgba = np.array(image.convert("RGBA")).astype(int)
544
+ ind = (
545
+ (rgba[:, :, 0] * 256**3)
546
+ + (rgba[:, :, 1] * 256**2)
547
+ + (rgba[:, :, 2] * 256)
548
+ + rgba[:, :, 3]
549
+ )
550
+ ind[np.where(ind == 4294967295)] = idummy
551
+ return ind
552
+
553
+
554
+ def int2png(val: NDArray[np.signedinteger], png_file: str) -> None:
555
+ """Convert an integer index array to a PNG tile.
556
+
557
+ Parameters
558
+ ----------
559
+ val : NDArray[np.signedinteger]
560
+ 256x256 integer array. Negative values are encoded as no-data (all 255).
561
+ png_file : str
562
+ Output PNG file path.
563
+ """
564
+ rgba = np.zeros((256, 256, 4), "uint8") + 255
565
+ r = (val // 256**3) % 256
566
+ g = (val // 256**2) % 256
567
+ b = (val // 256) % 256
568
+ a = val % 256
569
+ r[np.where(val < 0)] = 255
570
+ g[np.where(val < 0)] = 255
571
+ b[np.where(val < 0)] = 255
572
+ a[np.where(val < 0)] = 255
573
+ rgba[:, :, 0] = r
574
+ rgba[:, :, 1] = g
575
+ rgba[:, :, 2] = b
576
+ rgba[:, :, 3] = a
577
+ img = Image.fromarray(rgba)
578
+ img.save(png_file)
579
+
580
+
581
+ def makedir(path: str) -> None:
582
+ """Create a directory (and parents) if it does not already exist.
583
+
584
+ Parameters
585
+ ----------
586
+ path : str
587
+ Directory path to create.
588
+ """
589
+ if not os.path.exists(path):
590
+ os.makedirs(path)
591
+
592
+
593
+ def list_files(src: str) -> list[str]:
594
+ """List files matching a glob pattern.
595
+
596
+ Parameters
597
+ ----------
598
+ src : str
599
+ Glob pattern (e.g. ``"/data/*.png"``).
600
+
601
+ Returns
602
+ -------
603
+ list[str]
604
+ List of matching file paths.
605
+ """
606
+ file_list = []
607
+ full_list = glob.glob(src)
608
+ for item in full_list:
609
+ if os.path.isfile(item):
610
+ file_list.append(item)
611
+ return file_list
612
+
613
+
614
+ def list_folders(src: str, basename: bool = False) -> list[str]:
615
+ """List directories matching a glob pattern.
616
+
617
+ Parameters
618
+ ----------
619
+ src : str
620
+ Glob pattern.
621
+ basename : bool
622
+ If True, return only the directory base names instead of full paths.
623
+
624
+ Returns
625
+ -------
626
+ list[str]
627
+ List of matching directory paths (or base names).
628
+ """
629
+ folder_list = []
630
+ full_list = glob.glob(src)
631
+ for item in full_list:
632
+ if os.path.isdir(item):
633
+ if basename:
634
+ folder_list.append(os.path.basename(item))
635
+ else:
636
+ folder_list.append(item)
637
+
638
+ return folder_list
639
+
640
+
641
+ def interp2(
642
+ x0: NDArray[np.floating],
643
+ y0: NDArray[np.floating],
644
+ z0: NDArray[np.floating],
645
+ x1: NDArray[np.floating],
646
+ y1: NDArray[np.floating],
647
+ ) -> NDArray[np.floating]:
648
+ """Bilinear interpolation from a regular grid onto scattered target points.
649
+
650
+ Parameters
651
+ ----------
652
+ x0 : NDArray[np.floating]
653
+ 1-D x-coordinates of the source grid.
654
+ y0 : NDArray[np.floating]
655
+ 1-D y-coordinates of the source grid.
656
+ z0 : NDArray[np.floating]
657
+ 2-D source values, shape ``(len(y0), len(x0))``.
658
+ x1 : NDArray[np.floating]
659
+ 2-D target x-coordinates.
660
+ y1 : NDArray[np.floating]
661
+ 2-D target y-coordinates, same shape as *x1*.
662
+
663
+ Returns
664
+ -------
665
+ NDArray[np.floating]
666
+ Interpolated values at target locations, same shape as *x1*.
667
+ """
668
+ f = RegularGridInterpolator((y0, x0), z0, bounds_error=False, fill_value=np.nan)
669
+ sz = x1.shape
670
+ x1 = x1.reshape(sz[0] * sz[1])
671
+ y1 = y1.reshape(sz[0] * sz[1])
672
+ z1 = f((y1, x1)).reshape(sz)
673
+ return z1
674
+
675
+
676
+ def binary_search(
677
+ val_array: NDArray[np.number], vals: NDArray[np.number]
678
+ ) -> NDArray[np.signedinteger]:
679
+ """Find indices of *vals* within a sorted *val_array* using binary search.
680
+
681
+ Parameters
682
+ ----------
683
+ val_array : NDArray[np.number]
684
+ Sorted 1-D array of reference values.
685
+ vals : NDArray[np.number]
686
+ Values to look up.
687
+
688
+ Returns
689
+ -------
690
+ NDArray[np.signedinteger]
691
+ Index into *val_array* for each element of *vals*, or -1 if not found.
692
+ """
693
+ indx = np.searchsorted(val_array, vals)
694
+ not_ok = np.where(indx == len(val_array))[0]
695
+ indx[np.where(indx == len(val_array))[0]] = 0
696
+ is_ok = np.where(val_array[indx] == vals)[0]
697
+ indices = np.zeros(len(vals), dtype=int) - 1
698
+ indices[is_ok] = indx[is_ok]
699
+ indices[not_ok] = -1
700
+ return indices
@@ -0,0 +1,8 @@
1
+ """Coastal and wave physics utilities."""
2
+
3
+ from cht_utils.physics.deshoal import deshoal as deshoal
4
+ from cht_utils.physics.deshoal import wavecelerity as wavecelerity
5
+ from cht_utils.physics.disper import disper as disper
6
+ from cht_utils.physics.disper import disper_fentonmckee as disper_fentonmckee
7
+ from cht_utils.physics.runup_vo21 import runup_vo21 as runup_vo21
8
+ from cht_utils.physics.waves import split_waves_guza as split_waves_guza