rashdf 0.8.4__py3-none-any.whl → 0.9.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.
cli.py CHANGED
@@ -26,6 +26,7 @@ COMMANDS = [
26
26
  "reference_lines",
27
27
  "reference_points",
28
28
  "structures",
29
+ "generate_bridge_xs_lines",
29
30
  ]
30
31
 
31
32
 
rashdf/geom.py CHANGED
@@ -27,6 +27,8 @@ from .utils import (
27
27
  convert_ras_hdf_value,
28
28
  get_first_hdf_group,
29
29
  hdf5_attrs_to_dict,
30
+ copy_lines_parallel,
31
+ experimental,
30
32
  )
31
33
 
32
34
 
@@ -474,6 +476,67 @@ class RasGeomHdf(RasHdf):
474
476
  def connections(self) -> GeoDataFrame: # noqa D102
475
477
  raise NotImplementedError
476
478
 
479
+ @experimental
480
+ def generate_bridge_xs_lines(self, datetime_to_str: bool = False) -> GeoDataFrame:
481
+ """[EXPERIMENTAL] Attempt to return the 2D bridge cross-section lines.
482
+
483
+ This method attempts to generate the cross-section lines for bridges modeled
484
+ within 2D mesh areas. It should be noted that these lines are not explicitly
485
+ stored within the HEC-RAS Geometry HDF file, and are instead generated based
486
+ on the bridge attributes and centerline geometry. As such, the accuracy of
487
+ these lines may vary depending on the complexity of the bridge geometry and
488
+ output from this method should be reviewed for accuracy.
489
+
490
+ Parameters
491
+ ----------
492
+ datetime_to_str : bool, optional
493
+ If True, convert datetime values to string format (default: False).
494
+
495
+ Returns
496
+ -------
497
+ GeoDataFrame
498
+ A GeoDataFrame containing the 2D bridge cross-section lines if they exist.
499
+ """
500
+ profile_info = self.get(self.GEOM_STRUCTURES_PATH + "/Table Info")
501
+ structs = self.structures().merge(
502
+ pd.DataFrame(profile_info[()] if profile_info is not None else None),
503
+ left_index=True,
504
+ right_index=True,
505
+ )
506
+ if structs.empty:
507
+ return GeoDataFrame()
508
+
509
+ bridges = structs[structs["Mode"] == "Bridge Opening"].copy()
510
+ if bridges.empty:
511
+ return GeoDataFrame()
512
+
513
+ inside_buffer_widths = bridges["Weir Width"] / 2
514
+ inside_bridge_xs = copy_lines_parallel(
515
+ bridges, inside_buffer_widths.values, "struct_id"
516
+ )
517
+ inside_bridge_xs["level"] = "inside"
518
+
519
+ outside_buffer_widths = inside_buffer_widths + bridges["Upstream Distance"]
520
+ outside_bridge_xs = copy_lines_parallel(
521
+ bridges, outside_buffer_widths.values, "struct_id"
522
+ )
523
+ outside_bridge_xs["level"] = "outside"
524
+
525
+ br_xs = GeoDataFrame(
526
+ pd.concat([inside_bridge_xs, outside_bridge_xs], ignore_index=True),
527
+ geometry="geometry",
528
+ )
529
+ br_xs.side = br_xs.side.apply(
530
+ lambda x: "upstream" if x == "right" else "downstream"
531
+ )
532
+
533
+ if datetime_to_str:
534
+ br_xs[self.LAST_EDITED_COLUMN] = br_xs[self.LAST_EDITED_COLUMN].apply(
535
+ lambda x: pd.Timestamp.isoformat(x)
536
+ )
537
+
538
+ return br_xs
539
+
477
540
  def ic_points(self) -> GeoDataFrame: # noqa D102
478
541
  """Return initial conditions points.
479
542
 
rashdf/plan.py CHANGED
@@ -1178,7 +1178,7 @@ class RasPlanHdf(RasGeomHdf):
1178
1178
  xr.Dataset
1179
1179
  An xarray Dataset with timeseries output data for boundary conditions lines.
1180
1180
  """
1181
- df_bc_lines = self.bc_lines()
1181
+ df_bc_lines = super().bc_lines()
1182
1182
  bc_lines_names = df_bc_lines["name"]
1183
1183
  datasets = []
1184
1184
  for bc_line_name in bc_lines_names:
@@ -1197,6 +1197,57 @@ class RasPlanHdf(RasGeomHdf):
1197
1197
  )
1198
1198
  return ds
1199
1199
 
1200
+ def bc_lines(
1201
+ self, include_output: bool = True, datetime_to_str: bool = False
1202
+ ) -> GeoDataFrame:
1203
+ """Return the boundary condition lines from a HEC-RAS HDF plan file.
1204
+
1205
+ Optionally include summary output data for each boundary condition line.
1206
+
1207
+ Parameters
1208
+ ----------
1209
+ include_output : bool, optional
1210
+ If True, include summary output data in the GeoDataFrame. (default: True)
1211
+ datetime_to_str : bool, optional
1212
+ If True, convert datetime columns to strings. (default: False)
1213
+
1214
+ Returns
1215
+ -------
1216
+ GeoDataFrame
1217
+ A GeoDataFrame with boundary condition line geometry and summary output data.
1218
+ """
1219
+ gdf = super().bc_lines()
1220
+ if include_output is False:
1221
+ return gdf
1222
+
1223
+ ds = self.bc_lines_timeseries_output()
1224
+ summary = {
1225
+ "bc_line_id": ds.coords["bc_line_id"].values,
1226
+ "name": ds.coords["bc_line_name"].values,
1227
+ "mesh_name": ds.coords["mesh_name"].values,
1228
+ "type": ds.coords["bc_line_type"].values,
1229
+ }
1230
+
1231
+ for var in ds.data_vars:
1232
+ abbrev = "q" if var.lower() == "flow" else "ws"
1233
+ summary[f"max_{abbrev}"] = ds[var].max(dim="time").values
1234
+ summary[f"max_{abbrev}_time"] = (
1235
+ ds[var].time[ds[var].argmax(dim="time")].values
1236
+ )
1237
+ summary[f"min_{abbrev}"] = ds[var].min(dim="time").values
1238
+ summary[f"min_{abbrev}_time"] = (
1239
+ ds[var].time[ds[var].argmin(dim="time")].values
1240
+ )
1241
+
1242
+ gdf_with_output = gdf.merge(
1243
+ pd.DataFrame(summary),
1244
+ on=["bc_line_id", "name", "mesh_name", "type"],
1245
+ how="left",
1246
+ )
1247
+ return (
1248
+ df_datetimes_to_str(gdf_with_output) if datetime_to_str else gdf_with_output
1249
+ )
1250
+
1200
1251
  def observed_timeseries_input(self, vartype: str = "Flow") -> xr.DataArray:
1201
1252
  """Return observed timeseries input data for reference lines and points from a HEC-RAS HDF plan file.
1202
1253
 
rashdf/utils.py CHANGED
@@ -8,6 +8,38 @@ from datetime import datetime, timedelta
8
8
  import re
9
9
  from typing import Any, Callable, List, Tuple, Union, Optional
10
10
  import warnings
11
+ from shapely import LineString, MultiLineString
12
+ import geopandas as gpd
13
+
14
+
15
+ def experimental(func) -> Callable:
16
+ """
17
+ Declare a function to be experimental.
18
+
19
+ This is a decorator which can be used to mark functions as experimental.
20
+ It will result in a warning being emitted when the function is used.
21
+
22
+ Parameters
23
+ ----------
24
+ func: The function to be declared experimental.
25
+
26
+ Returns
27
+ -------
28
+ The decorated function.
29
+ """
30
+
31
+ def new_func(*args, **kwargs):
32
+ warnings.warn(
33
+ f"{func.__name__} is experimental and could change in the future. Please review output carefully.",
34
+ category=UserWarning,
35
+ stacklevel=2,
36
+ )
37
+ return func(*args, **kwargs)
38
+
39
+ new_func.__name__ = func.__name__
40
+ new_func.__doc__ = func.__doc__
41
+ new_func.__dict__.update(func.__dict__)
42
+ return new_func
11
43
 
12
44
 
13
45
  def deprecated(func) -> Callable:
@@ -354,3 +386,131 @@ def ras_timesteps_to_datetimes(
354
386
  start_time + pd.Timedelta(timestep, unit=time_unit).round(round_to)
355
387
  for timestep in timesteps.astype(np.float64)
356
388
  ]
389
+
390
+
391
+ def remove_line_ends(
392
+ geom: Union[LineString, MultiLineString],
393
+ ) -> Union[LineString, MultiLineString]:
394
+ """
395
+ Remove endpoints from a LineString or each LineString in a MultiLineString if longer than 3 points.
396
+
397
+ Parameters
398
+ ----------
399
+ geom : LineString or MultiLineString
400
+ The geometry to trim.
401
+
402
+ Returns
403
+ -------
404
+ LineString or MultiLineString
405
+ The trimmed geometry, or original if not enough points to trim.
406
+ """
407
+ if isinstance(geom, LineString):
408
+ coords = list(geom.coords)
409
+ if len(coords) > 3:
410
+ return LineString(coords[1:-1])
411
+ return geom
412
+ elif isinstance(geom, MultiLineString):
413
+ trimmed = []
414
+ for line in geom.geoms:
415
+ coords = list(line.coords)
416
+ if len(coords) > 3:
417
+ trimmed.append(LineString(coords[1:-1]))
418
+ else:
419
+ trimmed.append(line)
420
+ return MultiLineString(trimmed)
421
+ return geom
422
+
423
+
424
+ def reverse_line(
425
+ line: Union[LineString, MultiLineString],
426
+ ) -> Union[LineString, MultiLineString]:
427
+ """
428
+ Reverse the order of coordinates in a LineString or each LineString in a MultiLineString.
429
+
430
+ Parameters
431
+ ----------
432
+ line : LineString or MultiLineString
433
+ The geometry to reverse.
434
+
435
+ Returns
436
+ -------
437
+ LineString or MultiLineString
438
+ The reversed geometry.
439
+ """
440
+ return (
441
+ MultiLineString([LineString(list(line.coords)[::-1]) for line in line.geoms])
442
+ if isinstance(line, MultiLineString)
443
+ else LineString(list(line.coords)[::-1])
444
+ )
445
+
446
+
447
+ def copy_lines_parallel(
448
+ lines: gpd.GeoDataFrame,
449
+ offset_ft: Union[np.ndarray, float],
450
+ id_col: str = "id",
451
+ ) -> gpd.GeoDataFrame:
452
+ """
453
+ Create parallel copies of line geometries offset to the left and right, then trim and erase overlaps.
454
+
455
+ Parameters
456
+ ----------
457
+ lines : gpd.GeoDataFrame
458
+ GeoDataFrame containing line geometries.
459
+ offset_ft : float or np.ndarray
460
+ Offset distance (in feet) for parallel lines.
461
+ id_col : str
462
+ Name of the column containing unique structure IDs. Default is "id".
463
+
464
+ Returns
465
+ -------
466
+ gpd.GeoDataFrame
467
+ GeoDataFrame with trimmed, parallel left and right offset lines.
468
+ """
469
+ # Offset lines to the left
470
+ left = lines.copy()
471
+ offset_ft = offset_ft.astype(float)
472
+ left.geometry = lines.buffer(
473
+ offset_ft, cap_style="flat", single_sided=True, resolution=3
474
+ ).boundary
475
+ left["side"] = "left"
476
+
477
+ # Offset lines to the right (reverse direction first)
478
+ reversed_lines = lines.copy()
479
+ reversed_lines.geometry = reversed_lines.geometry.apply(reverse_line)
480
+ right = lines.copy()
481
+ right.geometry = reversed_lines.buffer(
482
+ offset_ft, cap_style="flat", single_sided=True, resolution=3
483
+ ).boundary.apply(reverse_line)
484
+ right["side"] = "right"
485
+
486
+ # Combine left and right boundaries
487
+ boundaries = pd.concat([left, right], ignore_index=True)
488
+ boundaries_gdf = gpd.GeoDataFrame(boundaries, crs=lines.crs, geometry="geometry")
489
+
490
+ # Erase buffer caps
491
+ erase_buffer = 0.1
492
+ cleaned_list = []
493
+ eraser = gpd.GeoDataFrame(
494
+ {
495
+ id_col: lines[id_col],
496
+ "geometry": lines.buffer(
497
+ offset_ft - erase_buffer, cap_style="square", resolution=3
498
+ ),
499
+ },
500
+ crs=lines.crs,
501
+ )
502
+ for id in lines[id_col].unique():
503
+ cleaned_list.append(
504
+ gpd.overlay(
505
+ boundaries_gdf[boundaries_gdf[id_col] == id],
506
+ eraser[eraser[id_col] == id],
507
+ how="difference",
508
+ )
509
+ )
510
+ cleaned = gpd.GeoDataFrame(
511
+ pd.concat(cleaned_list, ignore_index=True), crs=lines.crs, geometry="geometry"
512
+ )
513
+
514
+ # trim ends
515
+ cleaned["geometry"] = cleaned["geometry"].apply(remove_line_ends)
516
+ return cleaned
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rashdf
3
- Version: 0.8.4
3
+ Version: 0.9.0
4
4
  Summary: Read data from HEC-RAS HDF files.
5
5
  Project-URL: repository, https://github.com/fema-ffrd/rashdf
6
6
  Classifier: Development Status :: 4 - Beta
7
7
  Classifier: Intended Audience :: Developers
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: h5py
@@ -0,0 +1,12 @@
1
+ cli.py,sha256=SlMHkoosQKO2BDsF34CeYSHjD221AzGYGaKd0T2sOig,6866
2
+ rashdf/__init__.py,sha256=XXFtJDgLPCimqAhfsFz_pTWYECJiRT0i-Kb1uflXmVU,156
3
+ rashdf/base.py,sha256=cAQJX1aeBJKb3MJ06ltpbRTUaZX5NkuxpR1J4f7FyTU,2507
4
+ rashdf/geom.py,sha256=qieDgMxKrPcUqyVQUbCpRfrDo8VoOqh9Rx7OXOC_nRI,31192
5
+ rashdf/plan.py,sha256=r5_rXqBmJKQ0obm-iEBYpB28eper9jw4PwBcXkwEvSo,65483
6
+ rashdf/utils.py,sha256=-p8nQzotN5jakUCzpmKSYJOvYned8cD13WFjQhX3q1M,15845
7
+ rashdf-0.9.0.dist-info/licenses/LICENSE,sha256=L_0QaLpQVHPcglVjiaJPnOocwzP8uXevDRjUPr9DL1Y,1065
8
+ rashdf-0.9.0.dist-info/METADATA,sha256=yLj9yYCZdDUJC5_ZyluCMVxVKhzAzp6uWADXPi82c_s,6073
9
+ rashdf-0.9.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ rashdf-0.9.0.dist-info/entry_points.txt,sha256=LHHMR1lLy4wRyscMuW1RlYDXemtPgqQhNcILz0DtStY,36
11
+ rashdf-0.9.0.dist-info/top_level.txt,sha256=SrmLb6FFTJtM_t6O1v0M0JePshiQJMHr0yYVkHL7ztk,11
12
+ rashdf-0.9.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- cli.py,sha256=NtO1bHaiM8GBW5Gv9eSF8Iu-ekwTIQAfV_hd7DBXyLQ,6834
2
- rashdf/__init__.py,sha256=XXFtJDgLPCimqAhfsFz_pTWYECJiRT0i-Kb1uflXmVU,156
3
- rashdf/base.py,sha256=cAQJX1aeBJKb3MJ06ltpbRTUaZX5NkuxpR1J4f7FyTU,2507
4
- rashdf/geom.py,sha256=O2PMYY7w7fdW2U4u0rsbWeEDKAmsUh4-49ro-xUMc4A,28755
5
- rashdf/plan.py,sha256=ctkfLBqocF2TpU6wYygXkxE2voCJa8WyVGYlimsyxS4,63612
6
- rashdf/utils.py,sha256=I23Zij1EFi9v99UXFIJVGaZ2JN48-q5TK1aNsKwL4vE,11113
7
- rashdf-0.8.4.dist-info/licenses/LICENSE,sha256=L_0QaLpQVHPcglVjiaJPnOocwzP8uXevDRjUPr9DL1Y,1065
8
- rashdf-0.8.4.dist-info/METADATA,sha256=ZHbiO37k11MIami2y_JyuRzlLNk_qsgiLCx2Gi6TDKs,6072
9
- rashdf-0.8.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- rashdf-0.8.4.dist-info/entry_points.txt,sha256=LHHMR1lLy4wRyscMuW1RlYDXemtPgqQhNcILz0DtStY,36
11
- rashdf-0.8.4.dist-info/top_level.txt,sha256=SrmLb6FFTJtM_t6O1v0M0JePshiQJMHr0yYVkHL7ztk,11
12
- rashdf-0.8.4.dist-info/RECORD,,
File without changes