floodmodeller-api 0.4.4.post1__py3-none-any.whl → 0.5.0.post1__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.
Files changed (67) hide show
  1. floodmodeller_api/__init__.py +1 -0
  2. floodmodeller_api/dat.py +117 -96
  3. floodmodeller_api/hydrology_plus/__init__.py +2 -0
  4. floodmodeller_api/hydrology_plus/helper.py +23 -0
  5. floodmodeller_api/hydrology_plus/hydrology_plus_export.py +333 -0
  6. floodmodeller_api/ied.py +93 -90
  7. floodmodeller_api/ief.py +233 -50
  8. floodmodeller_api/ief_flags.py +1 -0
  9. floodmodeller_api/logs/lf.py +5 -1
  10. floodmodeller_api/mapping.py +2 -0
  11. floodmodeller_api/test/test_conveyance.py +23 -32
  12. floodmodeller_api/test/test_data/7082.ief +28 -0
  13. floodmodeller_api/test/test_data/BaseModel_2D_Q100.ief +28 -0
  14. floodmodeller_api/test/test_data/Baseline_unchecked.csv +77 -0
  15. floodmodeller_api/test/test_data/Constant QT.ief +19 -0
  16. floodmodeller_api/test/test_data/Domain1_Q_xml_expected.json +7 -7
  17. floodmodeller_api/test/test_data/EX18_DAT_expected.json +54 -38
  18. floodmodeller_api/test/test_data/EX3_DAT_expected.json +246 -166
  19. floodmodeller_api/test/test_data/EX3_IEF_expected.json +25 -20
  20. floodmodeller_api/test/test_data/EX6_DAT_expected.json +522 -350
  21. floodmodeller_api/test/test_data/FEH boundary.ief +23 -0
  22. floodmodeller_api/test/test_data/Linked1D2D_xml_expected.json +7 -7
  23. floodmodeller_api/test/test_data/P3Panels_UNsteady.ief +25 -0
  24. floodmodeller_api/test/test_data/QT in dat file.ief +20 -0
  25. floodmodeller_api/test/test_data/T10.ief +25 -0
  26. floodmodeller_api/test/test_data/T2.ief +25 -0
  27. floodmodeller_api/test/test_data/T5.ief +25 -0
  28. floodmodeller_api/test/test_data/df_flows_hplus.csv +56 -0
  29. floodmodeller_api/test/test_data/event_hplus.csv +56 -0
  30. floodmodeller_api/test/test_data/ex4.ief +20 -0
  31. floodmodeller_api/test/test_data/ex6.ief +21 -0
  32. floodmodeller_api/test/test_data/example_h+_export.csv +77 -0
  33. floodmodeller_api/test/test_data/hplus_export_example_1.csv +72 -0
  34. floodmodeller_api/test/test_data/hplus_export_example_10.csv +77 -0
  35. floodmodeller_api/test/test_data/hplus_export_example_2.csv +79 -0
  36. floodmodeller_api/test/test_data/hplus_export_example_3.csv +77 -0
  37. floodmodeller_api/test/test_data/hplus_export_example_4.csv +131 -0
  38. floodmodeller_api/test/test_data/hplus_export_example_5.csv +77 -0
  39. floodmodeller_api/test/test_data/hplus_export_example_6.csv +131 -0
  40. floodmodeller_api/test/test_data/hplus_export_example_7.csv +131 -0
  41. floodmodeller_api/test/test_data/hplus_export_example_8.csv +131 -0
  42. floodmodeller_api/test/test_data/hplus_export_example_9.csv +131 -0
  43. floodmodeller_api/test/test_data/network_dat_expected.json +312 -210
  44. floodmodeller_api/test/test_data/network_ied_expected.json +6 -6
  45. floodmodeller_api/test/test_data/network_with_comments.ied +55 -0
  46. floodmodeller_api/test/test_flowtimeprofile.py +133 -0
  47. floodmodeller_api/test/test_hydrology_plus_export.py +210 -0
  48. floodmodeller_api/test/test_ied.py +12 -0
  49. floodmodeller_api/test/test_ief.py +49 -9
  50. floodmodeller_api/test/test_json.py +6 -1
  51. floodmodeller_api/test/test_read_file.py +27 -0
  52. floodmodeller_api/test/test_river.py +246 -0
  53. floodmodeller_api/to_from_json.py +7 -1
  54. floodmodeller_api/tool.py +6 -10
  55. floodmodeller_api/units/__init__.py +11 -1
  56. floodmodeller_api/units/conveyance.py +103 -212
  57. floodmodeller_api/units/sections.py +120 -39
  58. floodmodeller_api/util.py +2 -0
  59. floodmodeller_api/version.py +1 -1
  60. floodmodeller_api/xml2d.py +20 -13
  61. floodmodeller_api/xsd_backup.xml +738 -0
  62. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/METADATA +2 -1
  63. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/RECORD +67 -33
  64. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/WHEEL +1 -1
  65. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/LICENSE.txt +0 -0
  66. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/entry_points.txt +0 -0
  67. {floodmodeller_api-0.4.4.post1.dist-info → floodmodeller_api-0.5.0.post1.dist-info}/top_level.txt +0 -0
@@ -1,31 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from functools import lru_cache
4
+ from typing import TYPE_CHECKING
4
5
 
5
6
  import numpy as np
6
7
  import pandas as pd
7
- from shapely import LineString, MultiLineString, Polygon, intersection
8
+
9
+ if TYPE_CHECKING:
10
+ from numpy.typing import NDArray
8
11
 
9
12
  MINIMUM_PERIMETER_THRESHOLD = 1e-8
10
13
 
11
14
 
12
15
  def calculate_cross_section_conveyance(
13
- x: np.ndarray,
14
- y: np.ndarray,
15
- n: np.ndarray,
16
- rpl: np.ndarray,
17
- panel_markers: np.ndarray,
16
+ x: NDArray[np.float64],
17
+ y: NDArray[np.float64],
18
+ n: NDArray[np.float64],
19
+ rpl: NDArray[np.float64],
20
+ panel_markers: NDArray[np.float64],
18
21
  ) -> pd.Series:
19
22
  """
20
23
  Calculate the conveyance of a cross-section by summing the conveyance
21
24
  across all panels defined by panel markers.
22
25
 
23
26
  Args:
24
- x (np.ndarray): The x-coordinates of the cross-section.
25
- y (np.ndarray): The y-coordinates of the cross-section.
26
- n (np.ndarray): Manning's n values for each segment.
27
- rpl (np.ndarray): Relative Path Length values for each segment.
28
- panel_markers (np.ndarray): Boolean array indicating the start of each panel.
27
+ x (NDArray[np.float64]): The x-coordinates of the cross-section.
28
+ y (NDArray[np.float64]): The y-coordinates of the cross-section.
29
+ n (NDArray[np.float64]): Manning's n values for each segment.
30
+ rpl (NDArray[np.float64]): Relative Path Length values for each segment.
31
+ panel_markers (NDArray[np.float64]): Boolean array indicating the start of each panel.
29
32
 
30
33
  Returns:
31
34
  pd.Series: A pandas Series containing the conveyance values indexed by water levels.
@@ -41,164 +44,117 @@ def calculate_cross_section_conveyance(
41
44
  result = calculate_cross_section_conveyance(x, y, n, rpl, panel_markers)
42
45
  print(result)
43
46
  """
44
- # Create a set of water levels to calculate conveyance at,
45
- # currently using 50mm minimum increments plus WLs at every data point
46
- wls = insert_intermediate_wls(np.unique(y), threshold=0.05)
47
-
48
- # Panel markers are forced true to the bounds to make the process work
49
- panel_markers = np.array([True, *panel_markers[1:-1], True])
50
- panel_indices = np.where(panel_markers)[0]
51
- conveyance_by_panel = []
52
- for panel_start, panel_end in zip(panel_indices[:-1], panel_indices[1:] + 1):
53
- panel_x = x[panel_start:panel_end]
54
- panel_y = y[panel_start:panel_end]
55
- panel_n = n[panel_start:panel_end]
56
- # RPL value is only valid for the start of a panel, and set to 1 if it's zero
57
- panel_rpl = (
58
- 1.0
59
- if (panel_start == 0 and not panel_markers[0]) or rpl[panel_start] == 0
60
- else float(rpl[panel_start])
61
- )
62
- conveyance_by_panel.append(
63
- calculate_conveyance_by_panel(panel_x, panel_y, panel_n, panel_rpl, wls),
64
- )
65
-
66
- # Sum conveyance across panels
67
- conveyance_values = [sum(values) for values in zip(*conveyance_by_panel)]
68
-
69
- return pd.Series(data=conveyance_values, index=wls)
70
-
71
-
72
- def calculate_conveyance_by_panel(
73
- x: np.ndarray,
74
- y: np.ndarray,
75
- n: np.ndarray,
76
- rpl: float,
77
- wls: np.ndarray,
78
- ) -> list[float]:
79
- """
80
- Calculate the conveyance for a single panel of a cross-section at specified water levels.
81
-
82
- Args:
83
- x (np.ndarray): The x-coordinates of the panel.
84
- y (np.ndarray): The y-coordinates of the panel.
85
- n (np.ndarray): Manning's n values for each segment in the panel.
86
- rpl (float): Relative Path Length for each segment in the panel.
87
- wls (np.ndarray): The water levels at which to calculate conveyance.
88
-
89
- Returns:
90
- list[float]: A list of conveyance values for each water level.
91
- """
47
+ water_levels = insert_intermediate_wls(np.unique(y), threshold=0.05)
48
+ area, length, mannings = calculate_geometry(x, y, n, water_levels)
49
+ panel = panel_markers.cumsum()[:-1]
92
50
 
93
- max_y = np.max(wls) + 1
94
- min_y = np.min(wls) - 1
51
+ intersection = (y[:-2] < water_levels[:, np.newaxis]) & (y[1:-1] >= water_levels[:, np.newaxis])
52
+ section_markers = np.hstack([np.full((intersection.shape[0], 1), False), intersection])
53
+ section = section_markers.cumsum(axis=1)
95
54
 
96
- # insert additional start/end points to represent the glass wall sides
97
- x = np.array([x[0], *x, x[-1]])
98
- y = np.array([max_y, *y, max_y])
99
- n = np.array([0, *n, 0])
55
+ conveyance = np.zeros_like(water_levels)
100
56
 
101
- # Define a polygon for the channel including artificial sides and top
102
- channel_polygon = Polygon(zip(x, y))
103
- start, end = x[0] - 0.1, x[-1] + 0.1 # Useful points enclosing the x bounds with small buffer
104
-
105
- # Define linestring geometries representing glass walls, so they can be subtracted later
106
- glass_walls = (
107
- LineString(zip([x[0], x[1]], [y[0], y[1]])), # left
108
- LineString(zip([x[-2], x[-1]], [y[-2], y[-1]])), # right
109
- )
110
-
111
- # Remove glass wall sections from coords
112
- x, y, n = x[1:-1], y[1:-1], n[1:-1]
113
-
114
- conveyance_values = []
115
- for wl in wls:
116
- if wl <= np.min(y):
117
- # no channel capacity (essentially zero depth) so no need to calculate
118
- conveyance_values.append(0.0)
57
+ for i in range(panel.max() + 1):
58
+ in_panel = panel == i
59
+ if not in_panel.any():
119
60
  continue
120
61
 
121
- # Some geometries to represent the channel at a given water level
122
- water_surface = Polygon(zip([start, start, end, end], [wl, min_y, min_y, wl]))
123
- water_plane = intersection(channel_polygon, LineString(zip([start, end], [wl, wl])))
124
- wetted_polygon = intersection(channel_polygon, water_surface)
62
+ rpl_panel = np.sqrt(rpl[:-1][in_panel][0])
63
+ rpl_panel = 1 if rpl_panel == 0 else rpl_panel
125
64
 
126
- multiple_parts = wetted_polygon.geom_type in ["GeometryCollection", "MultiPolygon"]
127
- parts = wetted_polygon.geoms if multiple_parts else [wetted_polygon]
65
+ for j in range(section.max() + 1):
66
+ in_section = section == j
67
+ in_panel_and_section = in_panel & in_section
68
+ if not in_panel_and_section.any():
69
+ continue
128
70
 
129
- conveyance = 0.0
71
+ total_area = np.where(in_panel_and_section, area, 0).sum(axis=1)
72
+ total_length = np.where(in_panel_and_section, length, 0).sum(axis=1)
73
+ total_mannings = np.where(in_panel_and_section, mannings, 0).sum(axis=1)
130
74
 
131
- # 'parts' here refers to when a water level results in 2 separate channel sections,
132
- # e.g. where the cross section has a 'peak' part way through
133
- for part in parts:
134
- conveyance += calculate_conveyance_part(part, water_plane, glass_walls, x, n, rpl)
135
- conveyance_values.append(conveyance)
75
+ with np.errstate(invalid="ignore"):
76
+ conveyance += np.where(
77
+ total_length >= MINIMUM_PERIMETER_THRESHOLD,
78
+ total_area ** (5 / 3) * total_length ** (1 / 3) / (total_mannings * rpl_panel),
79
+ 0,
80
+ )
136
81
 
137
- return conveyance_values
82
+ return pd.Series(conveyance, index=water_levels)
138
83
 
139
84
 
140
- def calculate_conveyance_part( # noqa: PLR0913
141
- wetted_polygon: Polygon,
142
- water_plane: LineString,
143
- glass_walls: tuple[LineString, LineString],
144
- x: np.ndarray,
145
- n: np.ndarray,
146
- rpl: float,
147
- ) -> float:
85
+ def calculate_geometry(
86
+ x: NDArray[np.float64],
87
+ y: NDArray[np.float64],
88
+ n: NDArray[np.float64],
89
+ water_levels: NDArray[np.float64],
90
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
148
91
  """
149
- Calculate the conveyance for a part of the wetted area.
92
+ Calculate area, length, weighted mannings for piecewise linear curve (x, y) below water_level.
150
93
 
151
94
  Args:
152
- wetted_polygon (Polygon): The polygon representing the wetted area.
153
- water_plane (LineString): The line representing the water plane.
154
- glass_wall_left (LineString): The left boundary of the channel.
155
- glass_wall_right (LineString): The right boundary of the channel.
156
- x (np.ndarray): 1D array of channel chainage
157
- n (np.ndarray): 1D array of channel mannings
158
- rpl (float): Relative path length of panel
95
+ x (NDArray[np.float64]): 1D array of x-coordinates.
96
+ y (NDArray[np.float64]): 1D array of y-coordinates.
97
+ n (NDArray[np.float64]): 1D array to integrate over the length.
98
+ water_levels (NDArray[np.float64]): The horizontal reference line.
159
99
 
160
100
  Returns:
161
- float: The conveyance value for the wetted part.
101
+ NDArray[np.float64]: The area above the curve and under the reference line.
102
+ NDArray[np.float64]: The length of the curve under the reference line.
103
+ NDArray[np.float64]: Manning's n integrated along the curve under the reference line.
162
104
  """
163
- water_plane_clip: LineString = intersection(water_plane, wetted_polygon)
164
- glass_wall_left_clip: LineString = intersection(glass_walls[0], wetted_polygon)
165
- glass_wall_right_clip: LineString = intersection(glass_walls[1], wetted_polygon)
166
-
167
- # wetted perimeter should only account for actual section of channel, so we need to remove any
168
- # length related to the water surface and any glass walls due to panel
169
- perimeter_loss = (
170
- water_plane_clip.length + glass_wall_left_clip.length + glass_wall_right_clip.length
105
+ h = water_levels[:, np.newaxis] - y
106
+
107
+ x1 = x[:-1]
108
+ x2 = x[1:]
109
+ h1 = h[:, :-1]
110
+ h2 = h[:, 1:]
111
+ n1 = n[:-1]
112
+
113
+ dx = x2 - x1
114
+
115
+ is_submerged = (h1 > 0) & (h2 > 0)
116
+ is_submerged_on_left = (h1 > 0) & (h2 <= 0)
117
+ is_submerged_on_right = (h1 <= 0) & (h2 > 0)
118
+ conditions = [is_submerged, is_submerged_on_left, is_submerged_on_right]
119
+
120
+ with np.errstate(divide="ignore", invalid="ignore"):
121
+ # needed for partially submerged sections
122
+ dx_left = dx * h1 / (h1 - h2)
123
+ dx_right = dx * h2 / (h2 - h1)
124
+
125
+ area = np.select(
126
+ conditions,
127
+ [
128
+ 0.5 * dx * (h1 + h2),
129
+ 0.5 * dx_left * h1,
130
+ 0.5 * dx_right * h2,
131
+ ],
132
+ default=0,
171
133
  )
172
-
173
- wetted_perimeter = wetted_polygon.boundary.length - perimeter_loss
174
- if wetted_perimeter < MINIMUM_PERIMETER_THRESHOLD:
175
- # Would occur if water level is above lowest point on section, but intersects a near-zero
176
- # perimeter, e.g. touching the bottom of an elevated side channel
177
- return 0.0
178
-
179
- area = wetted_polygon.area
180
-
181
- wetted_polyline: LineString = (
182
- wetted_polygon.exterior.difference(water_plane_clip)
183
- .difference(glass_wall_left_clip)
184
- .difference(glass_wall_right_clip)
134
+ length = np.select(
135
+ conditions,
136
+ [
137
+ np.sqrt((h2 - h1) ** 2 + dx**2),
138
+ np.sqrt(h1**2 + dx_left**2),
139
+ np.sqrt(h2**2 + dx_right**2),
140
+ ],
141
+ default=0,
185
142
  )
186
- weighted_mannings = calculate_weighted_mannings(x, n, rpl, wetted_polyline)
143
+ weighted_mannings = n1 * length
187
144
 
188
- # apply conveyance equation
189
- return (area ** (5 / 3) / wetted_perimeter ** (2 / 3)) * (wetted_perimeter / weighted_mannings)
145
+ return area, length, weighted_mannings
190
146
 
191
147
 
192
- def insert_intermediate_wls(arr: np.ndarray, threshold: float):
148
+ def insert_intermediate_wls(arr: NDArray[np.float64], threshold: float) -> NDArray[np.float64]:
193
149
  """
194
150
  Insert intermediate water levels into an array based on a threshold.
195
151
 
196
152
  Args:
197
- arr (np.ndarray): The array of original water levels.
153
+ arr (NDArray[np.float64]): The array of original water levels.
198
154
  threshold (float): The maximum allowed gap between water levels.
199
155
 
200
156
  Returns:
201
- np.ndarray: The array with intermediate water levels inserted.
157
+ NDArray[np.float64]: The array with intermediate water levels inserted.
202
158
  """
203
159
  # Calculate gaps between consecutive elements
204
160
  gaps = np.diff(arr)
@@ -207,81 +163,16 @@ def insert_intermediate_wls(arr: np.ndarray, threshold: float):
207
163
  num_points = (gaps // threshold).astype(int)
208
164
 
209
165
  # Prepare lists to hold the new points and results
210
- new_points = []
211
-
212
- for i, start in enumerate(arr[:-1]):
213
- end = arr[i + 1]
214
- if num_points[i] > 0:
215
- points = np.linspace(start, end, num_points[i] + 2)[1:-1]
216
- new_points.extend(points)
217
- new_points.append(end)
218
-
219
- # Combine the original starting point with the new points
220
- return np.array([arr[0]] + new_points)
221
-
222
-
223
- def calculate_weighted_mannings(
224
- x: np.ndarray,
225
- n: np.ndarray,
226
- rpl: float,
227
- wetted_polyline: LineString,
228
- ) -> float:
229
- """Calculate the weighted Manning's n value for a wetted polyline."""
230
-
231
- # We want the polyline to be split into each individual segment
232
- segments = line_to_segments(wetted_polyline)
233
- weighted_mannings = 0
234
- for segment in segments:
235
- mannings_value = get_mannings_by_segment_x_coords(
236
- x,
237
- n,
238
- segment.coords[0][0],
239
- segment.coords[1][0],
240
- )
241
- weighted_mannings += mannings_value * segment.length * np.sqrt(rpl)
242
-
243
- return weighted_mannings
244
-
245
-
246
- def line_to_segments(line: LineString | MultiLineString) -> list[LineString]:
247
- """Convert a LineString or MultiLineString into a list of LineString segments."""
248
- if isinstance(line, LineString):
249
- segments = []
250
- for start, end in zip(line.coords[:-1], line.coords[1:]):
251
- points = sorted([start, end], key=lambda x: x[0])
252
- segments.append(LineString(points))
253
- return segments
254
- if isinstance(line, MultiLineString):
255
- segments = []
256
- for linestring in line.geoms:
257
- segments.extend(line_to_segments(linestring))
258
- return segments
259
- raise TypeError("Input must be a LineString or MultiLineString")
260
-
261
-
262
- def get_mannings_by_segment_x_coords(
263
- x: np.ndarray,
264
- n: np.ndarray,
265
- start_x: float,
266
- end_x: float,
267
- ) -> float:
268
- """Get the Manning's n or RPL value for a segment based on its start x-coordinate."""
269
-
270
- # This method doesn't handle cases where we have multiple manning's values at a vertical section
271
- # and will always just take the first at any verticle, but it is probably quite rare for this
272
- # not to be the case
273
- if start_x == end_x:
274
- # Vertical segment take first x match
275
- index = np.searchsorted(x, start_x) - (start_x not in x)
276
- else:
277
- # Otherwise non-vertical segment, take last match
278
- index = np.searchsorted(x, start_x, side="right") - 1
279
-
280
- return n[index]
166
+ new_points = [
167
+ np.linspace(start, end, num + 2, endpoint=False)
168
+ for start, end, num in zip(arr[:-1], arr[1:], num_points)
169
+ ]
170
+ end = np.array([arr[-1]])
171
+ return np.concatenate([*new_points, end])
281
172
 
282
173
 
283
174
  @lru_cache
284
- def calculate_cross_section_conveyance_chached(
175
+ def calculate_cross_section_conveyance_cached(
285
176
  x: tuple[float],
286
177
  y: tuple[float],
287
178
  n: tuple[float],
@@ -19,7 +19,7 @@ import pandas as pd
19
19
  from floodmodeller_api.validation import _validate_unit
20
20
 
21
21
  from ._base import Unit
22
- from .conveyance import calculate_cross_section_conveyance_chached
22
+ from .conveyance import calculate_cross_section_conveyance_cached
23
23
  from .helpers import (
24
24
  _to_float,
25
25
  _to_int,
@@ -51,12 +51,21 @@ class RIVER(Unit):
51
51
 
52
52
  Returns:
53
53
  RIVER: Flood Modeller RIVER Unit class object
54
-
55
- Methods:
56
- convert_to_muskingham: Not currently supported but planned for future release
57
54
  """
58
55
 
59
56
  _unit = "RIVER"
57
+ _required_columns = [
58
+ "X",
59
+ "Y",
60
+ "Mannings n",
61
+ "Panel",
62
+ "RPL",
63
+ "Marker",
64
+ "Easting",
65
+ "Northing",
66
+ "Deactivation",
67
+ "SP. Marker",
68
+ ]
60
69
 
61
70
  def _create_from_blank( # noqa: PLR0913
62
71
  self,
@@ -88,29 +97,18 @@ class RIVER(Unit):
88
97
  "dist_to_next": dist_to_next,
89
98
  "slope": slope,
90
99
  "density": density,
91
- "data": data,
92
100
  }.items():
93
101
  setattr(self, param, val)
94
102
 
95
- self.data = (
103
+ self._data = (
96
104
  data
97
105
  if isinstance(data, pd.DataFrame)
98
106
  else pd.DataFrame(
99
107
  [],
100
- columns=[
101
- "X",
102
- "Y",
103
- "Mannings n",
104
- "Panel",
105
- "RPL",
106
- "Marker",
107
- "Easting",
108
- "Northing",
109
- "Deactivation",
110
- "SP. Marker",
111
- ],
108
+ columns=self._required_columns,
112
109
  )
113
110
  )
111
+ self._active_data = None
114
112
 
115
113
  def _read(self, riv_block):
116
114
  """Function to read a given RIVER block and store data as class attributes."""
@@ -171,20 +169,9 @@ class RIVER(Unit):
171
169
  sp_marker,
172
170
  ],
173
171
  )
174
- self.data = pd.DataFrame(
172
+ self._data = pd.DataFrame(
175
173
  data_list,
176
- columns=[
177
- "X",
178
- "Y",
179
- "Mannings n",
180
- "Panel",
181
- "RPL",
182
- "Marker",
183
- "Easting",
184
- "Northing",
185
- "Deactivation",
186
- "SP. Marker",
187
- ],
174
+ columns=self._required_columns,
188
175
  )
189
176
 
190
177
  else:
@@ -195,6 +182,8 @@ class RIVER(Unit):
195
182
  self._raw_block = riv_block
196
183
  self.name = riv_block[2][: self._label_len].strip()
197
184
 
185
+ self._active_data = None
186
+
198
187
  def _write(self):
199
188
  """Function to write a valid RIVER block"""
200
189
 
@@ -214,7 +203,7 @@ class RIVER(Unit):
214
203
  )
215
204
  # Manual so slope can have more sf
216
205
  params = f'{self.dist_to_next:>10.3f}{"":>10}{self.slope:>10.6f}{self.density:>10.3f}'
217
- self.nrows = len(self.data)
206
+ self.nrows = len(self._data)
218
207
  riv_block = [header, self.subtype, labels, params, f"{str(self.nrows):>10}"]
219
208
 
220
209
  riv_data = []
@@ -230,7 +219,7 @@ class RIVER(Unit):
230
219
  northing,
231
220
  deactivation,
232
221
  sp_marker,
233
- ) in self.data.itertuples():
222
+ ) in self._data.itertuples():
234
223
  row = join_10_char(x, y, n)
235
224
  if panel:
236
225
  row += "*"
@@ -245,6 +234,36 @@ class RIVER(Unit):
245
234
 
246
235
  return self._raw_block
247
236
 
237
+ @property
238
+ def data(self) -> pd.DataFrame:
239
+ """Data table for the river cross section.
240
+
241
+ Returns:
242
+ pd.DataFrame: Pandas dataframe for the cross section data with columns: 'X', 'Y',
243
+ 'Mannings n', 'Panel', 'RPL', 'Marker', 'Easting', 'Northing', 'Deactivation',
244
+ 'SP. Marker'
245
+ """
246
+ if self._active_data is None:
247
+ return self._data
248
+
249
+ # Replace the active section with the self._active_data df
250
+ left_bank_idx, right_bank_idx = self._get_left_right_active_index()
251
+ self._data = pd.concat(
252
+ [self._data[:left_bank_idx], self._active_data, self._data[right_bank_idx + 1 :]],
253
+ ).reset_index(drop=True)
254
+ self._active_data = None
255
+ return self._data
256
+
257
+ @data.setter
258
+ def data(self, new_df: pd.DataFrame) -> None:
259
+ if not isinstance(new_df, pd.DataFrame):
260
+ raise ValueError(
261
+ "The updated data table for a cross section must be a pandas DataFrame.",
262
+ )
263
+ if list(map(str.lower, new_df.columns)) != list(map(str.lower, self._required_columns)):
264
+ raise ValueError(f"The DataFrame must only contain columns: {self._required_columns}")
265
+ self._data = new_df
266
+
248
267
  @property
249
268
  def conveyance(self) -> pd.Series:
250
269
  """Calculate and return the conveyance curve of the cross-section.
@@ -257,14 +276,76 @@ class RIVER(Unit):
257
276
  Returns:
258
277
  pd.Series: A pandas Series containing the conveyance values indexed by water levels.
259
278
  """
260
- return calculate_cross_section_conveyance_chached(
261
- x=tuple(self.data.X.values),
262
- y=tuple(self.data.Y.values),
263
- n=tuple(self.data["Mannings n"].values),
264
- rpl=tuple(self.data.RPL.values),
265
- panel_markers=tuple(self.data.Panel.values),
279
+ return calculate_cross_section_conveyance_cached(
280
+ x=tuple(self._data.X.values),
281
+ y=tuple(self._data.Y.values),
282
+ n=tuple(self._data["Mannings n"].values),
283
+ rpl=tuple(self._data.RPL.values),
284
+ panel_markers=tuple(self._data.Panel.values),
266
285
  )
267
286
 
287
+ @property
288
+ def active_data(self) -> pd.DataFrame:
289
+ """Data table for active subset of the river cross section, defined by deactivation markers.
290
+
291
+ Returns:
292
+ pd.DataFrame: Pandas dataframe for the active cross section data with columns: 'X', 'Y',
293
+ 'Mannings n', 'Panel', 'RPL', 'Marker', 'Easting', 'Northing', 'Deactivation',
294
+ 'SP. Marker'
295
+
296
+ Example:
297
+ In this example we read in a river section that has deactivation markers
298
+
299
+ .. ipython:: python
300
+
301
+ from floodmodeller_api.units import RIVER
302
+ river_unit = RIVER(
303
+ [
304
+ "RIVER normal case",
305
+ "SECTION",
306
+ "SomeUnit",
307
+ " 0.000 0.000100 1000.000",
308
+ " 5",
309
+ " 0.000 10 0.030 0.000 0.0 0.0 ",
310
+ " 1.000 9 0.030 0.000 0.0 0.0 LEFT",
311
+ " 2.000 5 0.030 0.000 0.0 0.0 ",
312
+ " 3.000 6 0.030 0.000 0.0 0.0 RIGHT",
313
+ " 4.000 10 0.030 0.000 0.0 0.0 ",
314
+ ]
315
+ )
316
+ river_unit.data
317
+ river_unit.active_data
318
+ """
319
+ if self._active_data is not None:
320
+ return self._active_data
321
+ left_bank_idx, right_bank_idx = self._get_left_right_active_index()
322
+ self._active_data = self._data.iloc[left_bank_idx : right_bank_idx + 1].copy()
323
+ return self._active_data
324
+
325
+ @active_data.setter
326
+ def active_data(self, new_df: pd.DataFrame) -> None:
327
+ if not isinstance(new_df, pd.DataFrame):
328
+ raise ValueError(
329
+ "The updated data table for a cross section must be a pandas DataFrame.",
330
+ )
331
+ if new_df.columns.to_list() != self._required_columns:
332
+ raise ValueError(f"The DataFrame must only contain columns: {self._required_columns}")
333
+
334
+ # Ensure activation markers are present
335
+ new_df = new_df.copy()
336
+ new_df.iloc[0, 8] = "LEFT"
337
+ new_df.iloc[-1, 8] = "RIGHT"
338
+ self._active_data = new_df
339
+
340
+ def _get_left_right_active_index(self) -> tuple[int, int]:
341
+ bank_data = self._data.Deactivation.to_list()
342
+ lb_flag = "LEFT" in bank_data
343
+ rb_flag = "RIGHT" in bank_data
344
+
345
+ left_bank_idx = (len(bank_data) - 1) - bank_data[::-1].index("LEFT") if lb_flag else 0
346
+ right_bank_idx = bank_data.index("RIGHT") if rb_flag else len(bank_data) - 1
347
+ return left_bank_idx, right_bank_idx
348
+
268
349
 
269
350
  class INTERPOLATE(Unit):
270
351
  """Class to hold and process INTERPOLATE unit type
floodmodeller_api/util.py CHANGED
@@ -59,6 +59,7 @@ def read_file(filepath: str | Path) -> FMFile:
59
59
 
60
60
  """
61
61
  from . import DAT, IED, IEF, INP, LF1, LF2, XML2D, ZZN
62
+ from .hydrology_plus import HydrologyPlusExport
62
63
 
63
64
  suffix_to_class = {
64
65
  ".ief": IEF,
@@ -69,6 +70,7 @@ def read_file(filepath: str | Path) -> FMFile:
69
70
  ".inp": INP,
70
71
  ".lf1": LF1,
71
72
  ".lf2": LF2,
73
+ ".csv": HydrologyPlusExport,
72
74
  }
73
75
  filepath = Path(filepath)
74
76
  api_class = suffix_to_class.get(filepath.suffix.lower())
@@ -1 +1 @@
1
- __version__ = "0.4.4.post1"
1
+ __version__ = "0.5.0.post1"