ras-commander 0.48.0__py3-none-any.whl → 0.50.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.
ras_commander/HdfUtils.py CHANGED
@@ -1,518 +1,435 @@
1
- """
2
- Class: HdfUtils
3
-
4
- Attribution: A substantial amount of code in this file is sourced or derived
5
- from the https://github.com/fema-ffrd/rashdf library,
6
- released under MIT license and Copyright (c) 2024 fema-ffrd
7
-
8
- The file has been forked and modified for use in RAS Commander.
9
- """
10
- import logging
11
- from pathlib import Path
12
- import h5py
13
- import numpy as np
14
- import pandas as pd
15
- from datetime import datetime, timedelta
16
- from typing import Union, Optional, Dict, List, Tuple, Any
17
- from scipy.spatial import KDTree
18
- import re
19
-
20
-
21
- from .Decorators import standardize_input, log_call
22
- from .LoggingConfig import setup_logging, get_logger
23
-
24
- logger = get_logger(__name__)
25
-
26
- class HdfUtils:
27
- """
28
- Utility class for working with HEC-RAS HDF files.
29
-
30
- This class provides general utility functions for HDF file operations,
31
- including attribute extraction, data conversion, and common HDF queries.
32
- It also includes spatial operations and helper methods for working with
33
- HEC-RAS specific data structures.
34
-
35
- Note:
36
- - Use this class for general HDF utility functions that are not specific to plan or geometry files.
37
- - All methods in this class are static and can be called without instantiating the class.
38
- """
39
-
40
- @staticmethod
41
- @standardize_input(file_type='plan_hdf')
42
- def get_hdf_filename(hdf_input: Union[str, Path, h5py.File], ras_object=None) -> Optional[Path]:
43
- """
44
- Get the HDF filename from various input types.
45
-
46
- Args:
47
- hdf_input (Union[str, Path, h5py.File]): The plan number, full path to the HDF file, or an open HDF file object.
48
- ras_object (RasPrj, optional): The RAS project object. If None, uses the global ras instance.
49
-
50
- Returns:
51
- Optional[Path]: Path to the HDF file, or None if not found.
52
- """
53
- if isinstance(hdf_input, h5py.File):
54
- return Path(hdf_input.filename)
55
-
56
- if isinstance(hdf_input, str):
57
- hdf_input = Path(hdf_input)
58
-
59
- if isinstance(hdf_input, Path) and hdf_input.is_file():
60
- return hdf_input
61
-
62
- if ras_object is None:
63
- logger.critical("RAS object is not provided. It is required when hdf_input is not a direct file path.")
64
- return None
65
-
66
- plan_info = ras_object.plan_df[ras_object.plan_df['plan_number'] == str(hdf_input)]
67
- if plan_info.empty:
68
- logger.critical(f"No HDF file found for plan number {hdf_input}")
69
- return None
70
-
71
- hdf_filename = plan_info.iloc[0]['HDF_Results_Path']
72
- if hdf_filename is None:
73
- logger.critical(f"HDF_Results_Path is None for plan number {hdf_input}")
74
- return None
75
-
76
- hdf_path = Path(hdf_filename)
77
- if not hdf_path.is_file():
78
- logger.critical(f"HDF file not found: {hdf_path}")
79
- return None
80
-
81
- return hdf_path
82
-
83
- @staticmethod
84
- @standardize_input(file_type='plan_hdf')
85
- def get_root_attrs(hdf_path: Path) -> dict:
86
- """
87
- Return attributes at root level of HEC-RAS HDF file.
88
-
89
- Args:
90
- hdf_path (Path): Path to the HDF file.
91
-
92
- Returns:
93
- dict: Dictionary filled with HEC-RAS HDF root attributes.
94
- """
95
- with h5py.File(hdf_path, 'r') as hdf_file:
96
- return HdfUtils.get_attrs(hdf_file, "/")
97
-
98
- @staticmethod
99
- @standardize_input(file_type='plan_hdf')
100
- def get_attrs(hdf_path: Path, attr_path: str) -> dict:
101
- """
102
- Get attributes from a HEC-RAS HDF file for a given attribute path.
103
-
104
- Args:
105
- hdf_path (Path): The path to the HDF file.
106
- attr_path (str): The path to the attributes within the HDF file.
107
-
108
- Returns:
109
- dict: A dictionary of attributes.
110
- """
111
- try:
112
- with h5py.File(hdf_path, 'r') as hdf_file:
113
- attr_object = hdf_file.get(attr_path)
114
- if attr_object is None:
115
- logger.warning(f"Attribute path '{attr_path}' not found in HDF file.")
116
- return {}
117
- return HdfUtils._hdf5_attrs_to_dict(attr_object.attrs)
118
- except Exception as e:
119
- logger.error(f"Error getting attributes from '{attr_path}': {str(e)}")
120
- return {}
121
-
122
- @staticmethod
123
- @standardize_input(file_type='plan_hdf')
124
- def get_hdf_paths_with_properties(hdf_path: Path) -> pd.DataFrame:
125
- """
126
- Get all paths in the HDF file with their properties.
127
-
128
- Args:
129
- hdf_path (Path): Path to the HDF file.
130
-
131
- Returns:
132
- pd.DataFrame: DataFrame containing paths and their properties.
133
- """
134
- def get_item_properties(item):
135
- return {
136
- 'name': item.name,
137
- 'type': type(item).__name__,
138
- 'shape': item.shape if hasattr(item, 'shape') else None,
139
- 'dtype': item.dtype if hasattr(item, 'dtype') else None
140
- }
141
-
142
- try:
143
- with h5py.File(hdf_path, 'r') as hdf_file:
144
- items = []
145
- hdf_file.visititems(lambda name, item: items.append(get_item_properties(item)))
146
-
147
- return pd.DataFrame(items)
148
- except Exception as e:
149
- logger.error(f"Error reading HDF file: {e}")
150
- return pd.DataFrame()
151
-
152
- @staticmethod
153
- @standardize_input(file_type='plan_hdf')
154
- def get_group_attributes_as_df(hdf_path: Path, group_path: str) -> pd.DataFrame:
155
- """
156
- Get attributes of a group in the HDF file as a DataFrame.
157
-
158
- Args:
159
- hdf_path (Path): Path to the HDF file.
160
- group_path (str): Path to the group within the HDF file.
161
-
162
- Returns:
163
- pd.DataFrame: DataFrame containing the group's attributes.
164
- """
165
- with h5py.File(hdf_path, 'r') as hdf_file:
166
- group = hdf_file[group_path]
167
- attributes = {key: group.attrs[key] for key in group.attrs.keys()}
168
- return pd.DataFrame([attributes])
169
-
170
-
171
- @staticmethod
172
- def convert_ras_hdf_string(value: Union[str, bytes]) -> Union[bool, datetime, List[datetime], timedelta, str]:
173
- """
174
- Convert a string value from an HEC-RAS HDF file into a Python object.
175
-
176
- Args:
177
- value (Union[str, bytes]): The value to convert.
178
-
179
- Returns:
180
- Union[bool, datetime, List[datetime], timedelta, str]: The converted value.
181
- """
182
- return HdfUtils._convert_ras_hdf_string(value)
183
-
184
- @staticmethod
185
- def df_datetimes_to_str(df: pd.DataFrame) -> pd.DataFrame:
186
- """
187
- Convert any datetime64 columns in a DataFrame to strings.
188
-
189
- Args:
190
- df (pd.DataFrame): The DataFrame to convert.
191
-
192
- Returns:
193
- pd.DataFrame: The DataFrame with datetime columns converted to strings.
194
- """
195
- for col in df.select_dtypes(include=['datetime64']).columns:
196
- df[col] = df[col].dt.strftime('%Y-%m-%d %H:%M:%S')
197
- return df
198
-
199
- @staticmethod
200
- def perform_kdtree_query(
201
- reference_points: np.ndarray,
202
- query_points: np.ndarray,
203
- max_distance: float = 2.0
204
- ) -> np.ndarray:
205
- """
206
- Performs a KDTree query between two datasets and returns indices with distances exceeding max_distance set to -1.
207
-
208
- Args:
209
- reference_points (np.ndarray): The reference dataset for KDTree.
210
- query_points (np.ndarray): The query dataset to search against KDTree of reference_points.
211
- max_distance (float, optional): The maximum distance threshold. Indices with distances greater than this are set to -1. Defaults to 2.0.
212
-
213
- Returns:
214
- np.ndarray: Array of indices from reference_points that are nearest to each point in query_points.
215
- Indices with distances > max_distance are set to -1.
216
-
217
- Example:
218
- >>> ref_points = np.array([[0, 0], [1, 1], [2, 2]])
219
- >>> query_points = np.array([[0.5, 0.5], [3, 3]])
220
- >>> result = HdfUtils.perform_kdtree_query(ref_points, query_points)
221
- >>> print(result)
222
- array([ 0, -1])
223
- """
224
- dist, snap = KDTree(reference_points).query(query_points, distance_upper_bound=max_distance)
225
- snap[dist > max_distance] = -1
226
- return snap
227
-
228
- @staticmethod
229
- def find_nearest_neighbors(points: np.ndarray, max_distance: float = 2.0) -> np.ndarray:
230
- """
231
- Creates a self KDTree for dataset points and finds nearest neighbors excluding self,
232
- with distances above max_distance set to -1.
233
-
234
- Args:
235
- points (np.ndarray): The dataset to build the KDTree from and query against itself.
236
- max_distance (float, optional): The maximum distance threshold. Indices with distances
237
- greater than max_distance are set to -1. Defaults to 2.0.
238
-
239
- Returns:
240
- np.ndarray: Array of indices representing the nearest neighbor in points for each point in points.
241
- Indices with distances > max_distance or self-matches are set to -1.
242
-
243
- Example:
244
- >>> points = np.array([[0, 0], [1, 1], [2, 2], [10, 10]])
245
- >>> result = HdfUtils.find_nearest_neighbors(points)
246
- >>> print(result)
247
- array([1, 0, 1, -1])
248
- """
249
- dist, snap = KDTree(points).query(points, k=2, distance_upper_bound=max_distance)
250
- snap[dist > max_distance] = -1
251
-
252
- snp = pd.DataFrame(snap, index=np.arange(len(snap)))
253
- snp = snp.replace(-1, np.nan)
254
- snp.loc[snp[0] == snp.index, 0] = np.nan
255
- snp.loc[snp[1] == snp.index, 1] = np.nan
256
- filled = snp[0].fillna(snp[1])
257
- snapped = filled.fillna(-1).astype(np.int64).to_numpy()
258
- return snapped
259
-
260
- @staticmethod
261
- def _convert_ras_hdf_string(value: Union[str, bytes]) -> Union[bool, datetime, List[datetime], timedelta, str]:
262
- """
263
- Private method to convert a string value from an HEC-RAS HDF file into a Python object.
264
-
265
- Args:
266
- value (Union[str, bytes]): The value to convert.
267
-
268
- Returns:
269
- Union[bool, datetime, List[datetime], timedelta, str]: The converted value.
270
- """
271
- if isinstance(value, bytes):
272
- s = value.decode("utf-8")
273
- else:
274
- s = value
275
-
276
- if s == "True":
277
- return True
278
- elif s == "False":
279
- return False
280
-
281
- ras_datetime_format1_re = r"\d{2}\w{3}\d{4} \d{2}:\d{2}:\d{2}"
282
- ras_datetime_format2_re = r"\d{2}\w{3}\d{4} \d{2}\d{2}"
283
- ras_duration_format_re = r"\d{2}:\d{2}:\d{2}"
284
-
285
- if re.match(rf"^{ras_datetime_format1_re}", s):
286
- if re.match(rf"^{ras_datetime_format1_re} to {ras_datetime_format1_re}$", s):
287
- split = s.split(" to ")
288
- return [
289
- HdfBase._parse_ras_datetime(split[0]),
290
- HdfBase._parse_ras_datetime(split[1]),
291
- ]
292
- return HdfBase._parse_ras_datetime(s)
293
- elif re.match(rf"^{ras_datetime_format2_re}", s):
294
- if re.match(rf"^{ras_datetime_format2_re} to {ras_datetime_format2_re}$", s):
295
- split = s.split(" to ")
296
- return [
297
- HdfBase._parse_ras_simulation_window_datetime(split[0]),
298
- HdfBase._parse_ras_simulation_window_datetime(split[1]),
299
- ]
300
- return HdfBase._parse_ras_simulation_window_datetime(s)
301
- elif re.match(rf"^{ras_duration_format_re}$", s):
302
- return HdfBase._parse_duration(s)
303
- return s
304
-
305
- @staticmethod
306
- def _convert_ras_hdf_value(value: Any) -> Union[None, bool, str, List[str], int, float, List[int], List[float]]:
307
- """
308
- Convert a value from a HEC-RAS HDF file into a Python object.
309
-
310
- Args:
311
- value (Any): The value to convert.
312
-
313
- Returns:
314
- Union[None, bool, str, List[str], int, float, List[int], List[float]]: The converted value.
315
- """
316
- if isinstance(value, np.floating) and np.isnan(value):
317
- return None
318
- elif isinstance(value, (bytes, np.bytes_)):
319
- return value.decode('utf-8')
320
- elif isinstance(value, np.integer):
321
- return int(value)
322
- elif isinstance(value, np.floating):
323
- return float(value)
324
- elif isinstance(value, (int, float)):
325
- return value
326
- elif isinstance(value, (list, tuple, np.ndarray)):
327
- if len(value) > 1:
328
- return [HdfUtils._convert_ras_hdf_value(v) for v in value]
329
- else:
330
- return HdfUtils._convert_ras_hdf_value(value[0])
331
- else:
332
- return str(value)
333
-
334
- @staticmethod
335
- def _parse_ras_datetime_ms(datetime_str: str) -> datetime:
336
- """
337
- Private method to parse a datetime string with milliseconds from a RAS file.
338
-
339
- Args:
340
- datetime_str (str): The datetime string to parse.
341
-
342
- Returns:
343
- datetime: The parsed datetime object.
344
- """
345
- milliseconds = int(datetime_str[-3:])
346
- microseconds = milliseconds * 1000
347
- parsed_dt = HdfBase._parse_ras_datetime(datetime_str[:-4]).replace(microsecond=microseconds)
348
- return parsed_dt
349
-
350
- @staticmethod
351
- def _ras_timesteps_to_datetimes(timesteps: np.ndarray, start_time: datetime, time_unit: str = "days", round_to: str = "100ms") -> pd.DatetimeIndex:
352
- """
353
- Convert RAS timesteps to datetime objects.
354
-
355
- Args:
356
- timesteps (np.ndarray): Array of timesteps.
357
- start_time (datetime): Start time of the simulation.
358
- time_unit (str): Unit of the timesteps. Default is "days".
359
- round_to (str): Frequency string to round the times to. Default is "100ms" (100 milliseconds).
360
-
361
- Returns:
362
- pd.DatetimeIndex: DatetimeIndex of converted and rounded datetimes.
363
- """
364
- if time_unit == "days":
365
- datetimes = start_time + pd.to_timedelta(timesteps, unit='D')
366
- elif time_unit == "hours":
367
- datetimes = start_time + pd.to_timedelta(timesteps, unit='H')
368
- else:
369
- raise ValueError(f"Unsupported time unit: {time_unit}")
370
-
371
- return pd.DatetimeIndex(datetimes).round(round_to)
372
-
373
- @staticmethod
374
- def _hdf5_attrs_to_dict(attrs: Union[h5py.AttributeManager, Dict], prefix: Optional[str] = None) -> Dict:
375
- """
376
- Private method to convert HDF5 attributes to a Python dictionary.
377
-
378
- Args:
379
- attrs (Union[h5py.AttributeManager, Dict]): The attributes to convert.
380
- prefix (Optional[str]): A prefix to add to the attribute keys.
381
-
382
- Returns:
383
- Dict: A dictionary of converted attributes.
384
- """
385
- result = {}
386
- for key, value in attrs.items():
387
- if prefix:
388
- key = f"{prefix}/{key}"
389
- if isinstance(value, (np.ndarray, list)):
390
- result[key] = [HdfUtils._convert_ras_hdf_value(v) for v in value]
391
- else:
392
- result[key] = HdfUtils._convert_ras_hdf_value(value)
393
- return result
394
-
395
- @staticmethod
396
- def parse_run_time_window(window: str) -> Tuple[datetime, datetime]:
397
- """
398
- Parse a run time window string into a tuple of datetime objects.
399
-
400
- Args:
401
- window (str): The run time window string to be parsed.
402
-
403
- Returns:
404
- Tuple[datetime, datetime]: A tuple containing two datetime objects representing the start and end of the run
405
- time window.
406
- """
407
- split = window.split(" to ")
408
- begin = HdfBase._parse_ras_datetime(split[0])
409
- end = HdfBase._parse_ras_datetime(split[1])
410
- return begin, end
411
-
412
-
413
-
414
- @staticmethod
415
- @standardize_input(file_type='plan_hdf')
416
- def get_2d_flow_area_names_and_counts(hdf_path: Path) -> List[Tuple[str, int]]:
417
- """
418
- Get the names and cell counts of 2D flow areas from the HDF file.
419
-
420
- Args:
421
- hdf_path (Path): Path to the HDF file.
422
-
423
- Returns:
424
- List[Tuple[str, int]]: A list of tuples containing the name and cell count of each 2D flow area.
425
-
426
- Raises:
427
- ValueError: If there's an error reading the HDF file or accessing the required data.
428
- """
429
- try:
430
- with h5py.File(hdf_path, 'r') as hdf_file:
431
- flow_area_2d_path = "Geometry/2D Flow Areas"
432
- if flow_area_2d_path not in hdf_file:
433
- return []
434
-
435
- attributes = hdf_file[f"{flow_area_2d_path}/Attributes"][()]
436
- names = [HdfUtils._convert_ras_hdf_string(name) for name in attributes["Name"]]
437
-
438
- cell_info = hdf_file[f"{flow_area_2d_path}/Cell Info"][()]
439
- cell_counts = [info[1] for info in cell_info]
440
-
441
- return list(zip(names, cell_counts))
442
- except Exception as e:
443
- logger.error(f"Error reading 2D flow area names and counts from {hdf_path}: {str(e)}")
444
- raise ValueError(f"Failed to get 2D flow area names and counts: {str(e)}")
445
-
446
- @staticmethod
447
- @standardize_input(file_type='plan_hdf')
448
- def projection(hdf_path: Path) -> Optional[str]:
449
- """
450
- Get the projection information from the HDF file.
451
-
452
- Args:
453
- hdf_path (Path): Path to the HDF file.
454
-
455
- Returns:
456
- Optional[str]: The projection information as a string, or None if not found.
457
- """
458
- try:
459
- with h5py.File(hdf_path, 'r') as hdf_file:
460
- proj_wkt = hdf_file.attrs.get("Projection")
461
- if proj_wkt is None:
462
- return None
463
- if isinstance(proj_wkt, bytes) or isinstance(proj_wkt, np.bytes_):
464
- proj_wkt = proj_wkt.decode("utf-8")
465
- return proj_wkt
466
- except Exception as e:
467
- logger.error(f"Error reading projection from {hdf_path}: {str(e)}")
468
- return None
469
-
470
- def print_attrs(name, obj):
471
- """
472
- Print attributes of an HDF5 object.
473
- """
474
- if obj.attrs:
475
- print("")
476
- print(f" Attributes for {name}:")
477
- for key, val in obj.attrs.items():
478
- print(f" {key}: {val}")
479
- else:
480
- print(f" No attributes for {name}.")
481
-
482
- @staticmethod
483
- @standardize_input(file_type='plan_hdf')
484
- def explore_hdf5(file_path: Path, group_path: str = '/') -> None:
485
- """
486
- Recursively explore and print the structure of an HDF5 file.
487
-
488
- :param file_path: Path to the HDF5 file
489
- :param group_path: Current group path to explore
490
- """
491
- def recurse(name, obj, indent=0):
492
- spacer = " " * indent
493
- if isinstance(obj, h5py.Group):
494
- print(f"{spacer}Group: {name}")
495
- HdfUtils.print_attrs(name, obj)
496
- for key in obj:
497
- recurse(f"{name}/{key}", obj[key], indent+1)
498
- elif isinstance(obj, h5py.Dataset):
499
- print(f"{spacer}Dataset: {name}")
500
- print(f"{spacer} Shape: {obj.shape}")
501
- print(f"{spacer} Dtype: {obj.dtype}")
502
- HdfUtils.print_attrs(name, obj)
503
- else:
504
- print(f"{spacer}Unknown object: {name}")
505
-
506
- try:
507
- with h5py.File(file_path, 'r') as hdf_file:
508
- if group_path in hdf_file:
509
- print("")
510
- print(f"Exploring group: {group_path}\n")
511
- group = hdf_file[group_path]
512
- for key in group:
513
- print("")
514
- recurse(f"{group_path}/{key}", group[key], indent=1)
515
- else:
516
- print(f"Group path '{group_path}' not found in the HDF5 file.")
517
- except Exception as e:
518
- print(f"Error exploring HDF5 file: {e}")
1
+ """
2
+ HdfUtils Class
3
+ -------------
4
+
5
+ A utility class providing static methods for working with HEC-RAS HDF files.
6
+
7
+ Attribution:
8
+ A substantial amount of code in this file is sourced or derived from the
9
+ https://github.com/fema-ffrd/rashdf library, released under MIT license
10
+ and Copyright (c) 2024 fema-ffrd. The file has been forked and modified
11
+ for use in RAS Commander.
12
+
13
+ Key Features:
14
+ - HDF file data conversion and parsing
15
+ - DateTime handling for RAS-specific formats
16
+ - Spatial operations using KDTree
17
+ - HDF attribute management
18
+
19
+ Main Method Categories:
20
+
21
+ 1. Data Conversion
22
+ - convert_ras_string: Convert RAS HDF strings to Python objects
23
+ - convert_ras_hdf_value: Convert general HDF values to Python objects
24
+ - convert_df_datetimes_to_str: Convert DataFrame datetime columns to strings
25
+ - convert_hdf5_attrs_to_dict: Convert HDF5 attributes to dictionary
26
+ - convert_timesteps_to_datetimes: Convert timesteps to datetime objects
27
+
28
+ 2. Spatial Operations
29
+ - perform_kdtree_query: KDTree search between datasets
30
+ - find_nearest_neighbors: Find nearest neighbors within dataset
31
+
32
+ 3. DateTime Parsing
33
+ - parse_ras_datetime: Parse standard RAS datetime format (ddMMMYYYY HH:MM:SS)
34
+ - parse_ras_window_datetime: Parse simulation window datetime (ddMMMYYYY HHMM)
35
+ - parse_duration: Parse duration strings (HH:MM:SS)
36
+ - parse_ras_datetime_ms: Parse datetime with milliseconds
37
+ - parse_run_time_window: Parse time window strings
38
+
39
+ Usage Notes:
40
+ - All methods are static and can be called without class instantiation
41
+ - Methods handle both raw HDF data and converted Python objects
42
+ - Includes comprehensive error handling for RAS-specific data formats
43
+ - Supports various RAS datetime formats and conversions
44
+ """
45
+ import logging
46
+ from pathlib import Path
47
+ import h5py
48
+ import numpy as np
49
+ import pandas as pd
50
+ from datetime import datetime, timedelta
51
+ from typing import Union, Optional, Dict, List, Tuple, Any
52
+ from scipy.spatial import KDTree
53
+ import re
54
+ from shapely.geometry import LineString # Import LineString to avoid NameError
55
+
56
+ from .Decorators import standardize_input, log_call
57
+ from .LoggingConfig import setup_logging, get_logger
58
+
59
+ logger = get_logger(__name__)
60
+
61
+ class HdfUtils:
62
+ """
63
+ Utility class for working with HEC-RAS HDF files.
64
+
65
+ This class provides general utility functions for HDF file operations,
66
+ including attribute extraction, data conversion, and common HDF queries.
67
+ It also includes spatial operations and helper methods for working with
68
+ HEC-RAS specific data structures.
69
+
70
+ Note:
71
+ - Use this class for general HDF utility functions that are not specific to plan or geometry files.
72
+ - All methods in this class are static and can be called without instantiating the class.
73
+ """
74
+
75
+
76
+
77
+
78
+ # RENAME TO convert_ras_string and make public
79
+
80
+ @staticmethod
81
+ def convert_ras_string(value: Union[str, bytes]) -> Union[bool, datetime, List[datetime], timedelta, str]:
82
+ """
83
+ Convert a string value from an HEC-RAS HDF file into a Python object.
84
+
85
+ Args:
86
+ value (Union[str, bytes]): The value to convert.
87
+
88
+ Returns:
89
+ Union[bool, datetime, List[datetime], timedelta, str]: The converted value.
90
+ """
91
+ if isinstance(value, bytes):
92
+ s = value.decode("utf-8")
93
+ else:
94
+ s = value
95
+
96
+ if s == "True":
97
+ return True
98
+ elif s == "False":
99
+ return False
100
+
101
+ ras_datetime_format1_re = r"\d{2}\w{3}\d{4} \d{2}:\d{2}:\d{2}"
102
+ ras_datetime_format2_re = r"\d{2}\w{3}\d{4} \d{2}\d{2}"
103
+ ras_duration_format_re = r"\d{2}:\d{2}:\d{2}"
104
+
105
+ if re.match(rf"^{ras_datetime_format1_re}", s):
106
+ if re.match(rf"^{ras_datetime_format1_re} to {ras_datetime_format1_re}$", s):
107
+ split = s.split(" to ")
108
+ return [
109
+ HdfUtils.parse_ras_datetime(split[0]),
110
+ HdfUtils.parse_ras_datetime(split[1]),
111
+ ]
112
+ return HdfUtils.parse_ras_datetime(s)
113
+ elif re.match(rf"^{ras_datetime_format2_re}", s):
114
+ if re.match(rf"^{ras_datetime_format2_re} to {ras_datetime_format2_re}$", s):
115
+ split = s.split(" to ")
116
+ return [
117
+ HdfUtils.parse_ras_window_datetime(split[0]),
118
+ HdfUtils.parse_ras_window_datetime(split[1]),
119
+ ]
120
+ return HdfUtils.parse_ras_window_datetime(s)
121
+ elif re.match(rf"^{ras_duration_format_re}$", s):
122
+ return HdfUtils.parse_ras_duration(s)
123
+ return s
124
+
125
+
126
+
127
+
128
+
129
+ @staticmethod
130
+ def convert_ras_hdf_value(value: Any) -> Union[None, bool, str, List[str], int, float, List[int], List[float]]:
131
+ """
132
+ Convert a value from a HEC-RAS HDF file into a Python object.
133
+
134
+ Args:
135
+ value (Any): The value to convert.
136
+
137
+ Returns:
138
+ Union[None, bool, str, List[str], int, float, List[int], List[float]]: The converted value.
139
+ """
140
+ if isinstance(value, np.floating) and np.isnan(value):
141
+ return None
142
+ elif isinstance(value, (bytes, np.bytes_)):
143
+ return value.decode('utf-8')
144
+ elif isinstance(value, np.integer):
145
+ return int(value)
146
+ elif isinstance(value, np.floating):
147
+ return float(value)
148
+ elif isinstance(value, (int, float)):
149
+ return value
150
+ elif isinstance(value, (list, tuple, np.ndarray)):
151
+ if len(value) > 1:
152
+ return [HdfUtils.convert_ras_hdf_value(v) for v in value]
153
+ else:
154
+ return HdfUtils.convert_ras_hdf_value(value[0])
155
+ else:
156
+ return str(value)
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+ # RENAME TO convert_df_datetimes_to_str
168
+
169
+ @staticmethod
170
+ def convert_df_datetimes_to_str(df: pd.DataFrame) -> pd.DataFrame:
171
+ """
172
+ Convert any datetime64 columns in a DataFrame to strings.
173
+
174
+ Args:
175
+ df (pd.DataFrame): The DataFrame to convert.
176
+
177
+ Returns:
178
+ pd.DataFrame: The DataFrame with datetime columns converted to strings.
179
+ """
180
+ for col in df.select_dtypes(include=['datetime64']).columns:
181
+ df[col] = df[col].dt.strftime('%Y-%m-%d %H:%M:%S')
182
+ return df
183
+
184
+
185
+ # KDTree Methods:
186
+
187
+
188
+ @staticmethod
189
+ def perform_kdtree_query(
190
+ reference_points: np.ndarray,
191
+ query_points: np.ndarray,
192
+ max_distance: float = 2.0
193
+ ) -> np.ndarray:
194
+ """
195
+ Performs a KDTree query between two datasets and returns indices with distances exceeding max_distance set to -1.
196
+
197
+ Args:
198
+ reference_points (np.ndarray): The reference dataset for KDTree.
199
+ query_points (np.ndarray): The query dataset to search against KDTree of reference_points.
200
+ max_distance (float, optional): The maximum distance threshold. Indices with distances greater than this are set to -1. Defaults to 2.0.
201
+
202
+ Returns:
203
+ np.ndarray: Array of indices from reference_points that are nearest to each point in query_points.
204
+ Indices with distances > max_distance are set to -1.
205
+
206
+ Example:
207
+ >>> ref_points = np.array([[0, 0], [1, 1], [2, 2]])
208
+ >>> query_points = np.array([[0.5, 0.5], [3, 3]])
209
+ >>> result = HdfUtils.perform_kdtree_query(ref_points, query_points)
210
+ >>> print(result)
211
+ array([ 0, -1])
212
+ """
213
+ dist, snap = KDTree(reference_points).query(query_points, distance_upper_bound=max_distance)
214
+ snap[dist > max_distance] = -1
215
+ return snap
216
+
217
+ @staticmethod
218
+ def find_nearest_neighbors(points: np.ndarray, max_distance: float = 2.0) -> np.ndarray:
219
+ """
220
+ Creates a self KDTree for dataset points and finds nearest neighbors excluding self,
221
+ with distances above max_distance set to -1.
222
+
223
+ Args:
224
+ points (np.ndarray): The dataset to build the KDTree from and query against itself.
225
+ max_distance (float, optional): The maximum distance threshold. Indices with distances
226
+ greater than max_distance are set to -1. Defaults to 2.0.
227
+
228
+ Returns:
229
+ np.ndarray: Array of indices representing the nearest neighbor in points for each point in points.
230
+ Indices with distances > max_distance or self-matches are set to -1.
231
+
232
+ Example:
233
+ >>> points = np.array([[0, 0], [1, 1], [2, 2], [10, 10]])
234
+ >>> result = HdfUtils.find_nearest_neighbors(points)
235
+ >>> print(result)
236
+ array([1, 0, 1, -1])
237
+ """
238
+ dist, snap = KDTree(points).query(points, k=2, distance_upper_bound=max_distance)
239
+ snap[dist > max_distance] = -1
240
+
241
+ snp = pd.DataFrame(snap, index=np.arange(len(snap)))
242
+ snp = snp.replace(-1, np.nan)
243
+ snp.loc[snp[0] == snp.index, 0] = np.nan
244
+ snp.loc[snp[1] == snp.index, 1] = np.nan
245
+ filled = snp[0].fillna(snp[1])
246
+ snapped = filled.fillna(-1).astype(np.int64).to_numpy()
247
+ return snapped
248
+
249
+
250
+
251
+
252
+ # Datetime Parsing Methods:
253
+
254
+ @staticmethod
255
+ @log_call
256
+ def parse_ras_datetime_ms(datetime_str: str) -> datetime:
257
+ """
258
+ Public method to parse a datetime string with milliseconds from a RAS file.
259
+
260
+ Args:
261
+ datetime_str (str): The datetime string to parse.
262
+
263
+ Returns:
264
+ datetime: The parsed datetime object.
265
+ """
266
+ milliseconds = int(datetime_str[-3:])
267
+ microseconds = milliseconds * 1000
268
+ parsed_dt = HdfUtils.parse_ras_datetime(datetime_str[:-4]).replace(microsecond=microseconds)
269
+ return parsed_dt
270
+
271
+ # Rename to convert_timesteps_to_datetimes and make public
272
+ @staticmethod
273
+ def convert_timesteps_to_datetimes(timesteps: np.ndarray, start_time: datetime, time_unit: str = "days", round_to: str = "100ms") -> pd.DatetimeIndex:
274
+ """
275
+ Convert RAS timesteps to datetime objects.
276
+
277
+ Args:
278
+ timesteps (np.ndarray): Array of timesteps.
279
+ start_time (datetime): Start time of the simulation.
280
+ time_unit (str): Unit of the timesteps. Default is "days".
281
+ round_to (str): Frequency string to round the times to. Default is "100ms" (100 milliseconds).
282
+
283
+ Returns:
284
+ pd.DatetimeIndex: DatetimeIndex of converted and rounded datetimes.
285
+ """
286
+ if time_unit == "days":
287
+ datetimes = start_time + pd.to_timedelta(timesteps, unit='D')
288
+ elif time_unit == "hours":
289
+ datetimes = start_time + pd.to_timedelta(timesteps, unit='H')
290
+ else:
291
+ raise ValueError(f"Unsupported time unit: {time_unit}")
292
+
293
+ return pd.DatetimeIndex(datetimes).round(round_to)
294
+
295
+ # rename to convert_hdf5_attrs_to_dict and make public
296
+
297
+ @staticmethod
298
+ def convert_hdf5_attrs_to_dict(attrs: Union[h5py.AttributeManager, Dict], prefix: Optional[str] = None) -> Dict:
299
+ """
300
+ Convert HDF5 attributes to a Python dictionary.
301
+
302
+ Args:
303
+ attrs (Union[h5py.AttributeManager, Dict]): The attributes to convert.
304
+ prefix (Optional[str]): A prefix to add to the attribute keys.
305
+
306
+ Returns:
307
+ Dict: A dictionary of converted attributes.
308
+ """
309
+ result = {}
310
+ for key, value in attrs.items():
311
+ if prefix:
312
+ key = f"{prefix}/{key}"
313
+ if isinstance(value, (np.ndarray, list)):
314
+ result[key] = [HdfUtils.convert_ras_hdf_value(v) for v in value]
315
+ else:
316
+ result[key] = HdfUtils.convert_ras_hdf_value(value)
317
+ return result
318
+
319
+
320
+
321
+ @staticmethod
322
+ def parse_run_time_window(window: str) -> Tuple[datetime, datetime]:
323
+ """
324
+ Parse a run time window string into a tuple of datetime objects.
325
+
326
+ Args:
327
+ window (str): The run time window string to be parsed.
328
+
329
+ Returns:
330
+ Tuple[datetime, datetime]: A tuple containing two datetime objects representing the start and end of the run
331
+ time window.
332
+ """
333
+ split = window.split(" to ")
334
+ begin = HdfUtils._parse_ras_datetime(split[0])
335
+ end = HdfUtils._parse_ras_datetime(split[1])
336
+ return begin, end
337
+
338
+
339
+
340
+
341
+
342
+
343
+
344
+
345
+
346
+
347
+
348
+
349
+
350
+
351
+
352
+
353
+ ## MOVED FROM HdfBase to HdfUtils:
354
+ # _parse_ras_datetime
355
+ # _parse_ras_simulation_window_datetime
356
+ # _parse_duration
357
+ # _parse_ras_datetime_ms
358
+ # _convert_ras_hdf_string
359
+
360
+ # Which were renamed and made public as:
361
+ # parse_ras_datetime
362
+ # parse_ras_window_datetime
363
+ # parse_ras_datetime_ms
364
+ # parse_ras_duration
365
+ # parse_ras_time_window
366
+
367
+
368
+ # Rename to parse_ras_datetime and make public
369
+
370
+ @staticmethod
371
+ def parse_ras_datetime(datetime_str: str) -> datetime:
372
+ """
373
+ Parse a RAS datetime string into a datetime object.
374
+
375
+ Args:
376
+ datetime_str (str): The datetime string in format "ddMMMYYYY HH:MM:SS"
377
+
378
+ Returns:
379
+ datetime: The parsed datetime object.
380
+ """
381
+ return datetime.strptime(datetime_str, "%d%b%Y %H:%M:%S")
382
+
383
+ # Rename to parse_ras_window_datetime and make public
384
+
385
+ @staticmethod
386
+ def parse_ras_window_datetime(datetime_str: str) -> datetime:
387
+ """
388
+ Parse a datetime string from a RAS simulation window into a datetime object.
389
+
390
+ Args:
391
+ datetime_str (str): The datetime string to parse.
392
+
393
+ Returns:
394
+ datetime: The parsed datetime object.
395
+ """
396
+ return datetime.strptime(datetime_str, "%d%b%Y %H%M")
397
+
398
+
399
+ # Rename to parse_duration and make public
400
+
401
+
402
+ @staticmethod
403
+ def parse_duration(duration_str: str) -> timedelta:
404
+ """
405
+ Parse a duration string into a timedelta object.
406
+
407
+ Args:
408
+ duration_str (str): The duration string to parse.
409
+
410
+ Returns:
411
+ timedelta: The parsed duration as a timedelta object.
412
+ """
413
+ hours, minutes, seconds = map(int, duration_str.split(':'))
414
+ return timedelta(hours=hours, minutes=minutes, seconds=seconds)
415
+
416
+
417
+ # Rename to parse_ras_datetime_ms and make public
418
+
419
+ @staticmethod
420
+ def parse_ras_datetime_ms(datetime_str: str) -> datetime:
421
+ """
422
+ Parse a datetime string with milliseconds from a RAS file.
423
+
424
+ Args:
425
+ datetime_str (str): The datetime string to parse.
426
+
427
+ Returns:
428
+ datetime: The parsed datetime object.
429
+ """
430
+ milliseconds = int(datetime_str[-3:])
431
+ microseconds = milliseconds * 1000
432
+ parsed_dt = HdfUtils.parse_ras_datetime(datetime_str[:-4]).replace(microsecond=microseconds)
433
+ return parsed_dt
434
+
435
+