ras-commander 0.78.0__py3-none-any.whl → 0.79.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.
@@ -4,17 +4,19 @@ Class: HdfFluvialPluvial
4
4
  All of the methods in this class are static and are designed to be used without instantiation.
5
5
 
6
6
  List of Functions in HdfFluvialPluvial:
7
- - calculate_fluvial_pluvial_boundary()
7
+ - calculate_fluvial_pluvial_boundary(): Returns LineStrings representing the boundary.
8
+ - generate_fluvial_pluvial_polygons(): Returns dissolved Polygons for fluvial, pluvial, and ambiguous zones.
8
9
  - _process_cell_adjacencies()
10
+ - _get_boundary_cell_pairs()
9
11
  - _identify_boundary_edges()
10
12
 
11
13
  """
12
14
 
13
- from typing import Dict, List, Tuple
15
+ from typing import Dict, List, Tuple, Set, Optional
14
16
  import pandas as pd
15
17
  import geopandas as gpd
16
18
  from collections import defaultdict
17
- from shapely.geometry import LineString, MultiLineString # Added MultiLineString import
19
+ from shapely.geometry import LineString, MultiLineString
18
20
  from tqdm import tqdm
19
21
  from .HdfMesh import HdfMesh
20
22
  from .HdfUtils import HdfUtils
@@ -37,7 +39,7 @@ class HdfFluvialPluvial:
37
39
  Key Concepts:
38
40
  - Fluvial flooding: Flooding from rivers/streams
39
41
  - Pluvial flooding: Flooding from rainfall/surface water
40
- - Delta_t: Time threshold (in hours) used to distinguish between fluvial and pluvial cells.
42
+ - delta_t: Time threshold (in hours) used to distinguish between fluvial and pluvial cells.
41
43
  Cells with max WSE time differences greater than delta_t are considered boundaries.
42
44
 
43
45
  Data Requirements:
@@ -46,509 +48,369 @@ class HdfFluvialPluvial:
46
48
  - Maximum water surface elevation times (accessed via HdfResultsMesh)
47
49
 
48
50
  Usage Example:
49
- >>> ras = init_ras_project(project_path, ras_version)
51
+ >>> from ras_commander import HdfFluvialPluvial
50
52
  >>> hdf_path = Path("path/to/plan.hdf")
51
- >>> boundary_gdf = HdfFluvialPluvial.calculate_fluvial_pluvial_boundary(
53
+
54
+ # To get just the boundary lines
55
+ >>> boundary_lines_gdf = HdfFluvialPluvial.calculate_fluvial_pluvial_boundary(
52
56
  ... hdf_path,
53
57
  ... delta_t=12
54
58
  ... )
59
+
60
+ # To get classified flood polygons
61
+ >>> flood_polygons_gdf = HdfFluvialPluvial.generate_fluvial_pluvial_polygons(
62
+ ... hdf_path,
63
+ ... delta_t=12,
64
+ ... temporal_tolerance_hours=1.0
65
+ ... )
55
66
  """
56
67
  def __init__(self):
57
68
  self.logger = get_logger(__name__) # Initialize logger with module name
58
69
 
59
70
  @staticmethod
60
71
  @standardize_input(file_type='plan_hdf')
61
- def calculate_fluvial_pluvial_boundary(hdf_path: Path, delta_t: float = 12) -> gpd.GeoDataFrame:
72
+ def calculate_fluvial_pluvial_boundary(
73
+ hdf_path: Path,
74
+ delta_t: float = 12,
75
+ min_line_length: Optional[float] = None
76
+ ) -> gpd.GeoDataFrame:
62
77
  """
63
- Calculate the fluvial-pluvial boundary based on cell polygons and maximum water surface elevation times.
78
+ Calculate the fluvial-pluvial boundary lines based on cell polygons and maximum water surface elevation times.
79
+
80
+ This function is useful for visualizing the line of transition between flooding mechanisms.
64
81
 
65
82
  Args:
66
- hdf_path (Path): Path to the HEC-RAS plan HDF file
83
+ hdf_path (Path): Path to the HEC-RAS plan HDF file.
67
84
  delta_t (float): Threshold time difference in hours. Cells with time differences
68
- greater than this value are considered boundaries. Default is 12 hours.
85
+ greater than this value are considered boundaries. Default is 12 hours.
86
+ min_line_length (float, optional): Minimum length (in CRS units) for boundary lines to be included.
87
+ Lines shorter than this will be dropped. Default is None (no filtering).
69
88
 
70
89
  Returns:
71
- gpd.GeoDataFrame: GeoDataFrame containing the fluvial-pluvial boundaries with:
72
- - geometry: LineString features representing boundaries
73
- - CRS: Coordinate reference system matching the input HDF file
74
-
75
- Raises:
76
- ValueError: If no cell polygons or maximum water surface data found in HDF file
77
- Exception: If there are errors during boundary calculation
78
-
79
- Note:
80
- The returned boundaries represent locations where the timing of maximum water surface
81
- elevation changes significantly (> delta_t), indicating potential transitions between
82
- fluvial and pluvial flooding mechanisms.
90
+ gpd.GeoDataFrame: GeoDataFrame containing the fluvial-pluvial boundary lines.
83
91
  """
84
92
  try:
85
- # Get cell polygons from HdfMesh
86
93
  logger.info("Getting cell polygons from HDF file...")
87
94
  cell_polygons_gdf = HdfMesh.get_mesh_cell_polygons(hdf_path)
88
95
  if cell_polygons_gdf.empty:
89
96
  raise ValueError("No cell polygons found in HDF file")
90
97
 
91
- # Get max water surface data from HdfResultsMesh
92
98
  logger.info("Getting maximum water surface data from HDF file...")
93
99
  max_ws_df = HdfResultsMesh.get_mesh_max_ws(hdf_path)
94
100
  if max_ws_df.empty:
95
101
  raise ValueError("No maximum water surface data found in HDF file")
96
102
 
97
- # Convert timestamps using the renamed utility function
98
103
  logger.info("Converting maximum water surface timestamps...")
99
- if 'maximum_water_surface_time' in max_ws_df.columns:
100
- max_ws_df['maximum_water_surface_time'] = max_ws_df['maximum_water_surface_time'].apply(
101
- lambda x: HdfUtils.parse_ras_datetime(x) if isinstance(x, str) else x
102
- )
104
+ max_ws_df['maximum_water_surface_time'] = max_ws_df['maximum_water_surface_time'].apply(
105
+ lambda x: HdfUtils.parse_ras_datetime(x) if isinstance(x, str) else x
106
+ )
103
107
 
104
- # Process cell adjacencies
105
108
  logger.info("Processing cell adjacencies...")
106
109
  cell_adjacency, common_edges = HdfFluvialPluvial._process_cell_adjacencies(cell_polygons_gdf)
107
110
 
108
- # Get cell times from max_ws_df
109
111
  logger.info("Extracting cell times from maximum water surface data...")
110
112
  cell_times = max_ws_df.set_index('cell_id')['maximum_water_surface_time'].to_dict()
111
113
 
112
- # Identify boundary edges
113
114
  logger.info("Identifying boundary edges...")
114
115
  boundary_edges = HdfFluvialPluvial._identify_boundary_edges(
115
- cell_adjacency, common_edges, cell_times, delta_t
116
+ cell_adjacency, common_edges, cell_times, delta_t, min_line_length=min_line_length
117
+ )
118
+
119
+ logger.info("Creating final GeoDataFrame for boundaries...")
120
+ boundary_gdf = gpd.GeoDataFrame(
121
+ geometry=boundary_edges,
122
+ crs=cell_polygons_gdf.crs
123
+ )
124
+
125
+ logger.info("Boundary line calculation completed successfully.")
126
+ return boundary_gdf
127
+
128
+ except Exception as e:
129
+ logger.error(f"Error calculating fluvial-pluvial boundary lines: {str(e)}")
130
+ return gpd.GeoDataFrame()
131
+
132
+ @staticmethod
133
+ @standardize_input(file_type='plan_hdf')
134
+ def generate_fluvial_pluvial_polygons(
135
+ hdf_path: Path,
136
+ delta_t: float = 12,
137
+ temporal_tolerance_hours: float = 1.0,
138
+ min_polygon_area_acres: Optional[float] = None
139
+ ) -> gpd.GeoDataFrame:
140
+ """
141
+ Generates dissolved polygons representing fluvial, pluvial, and ambiguous flood zones.
142
+
143
+ This function classifies each wetted cell and merges them into three distinct regions
144
+ based on the timing of maximum water surface elevation.
145
+
146
+ Optionally, for polygons classified as fluvial or pluvial, if their area is less than
147
+ min_polygon_area_acres, they are reclassified to the opposite type and merged with
148
+ adjacent polygons of that type. Ambiguous polygons are exempt from this logic.
149
+
150
+ Args:
151
+ hdf_path (Path): Path to the HEC-RAS plan HDF file.
152
+ delta_t (float): The time difference (in hours) between adjacent cells that defines
153
+ the initial boundary between fluvial and pluvial zones. Default is 12.
154
+ temporal_tolerance_hours (float): The maximum time difference (in hours) for a cell
155
+ to be considered part of an expanding region.
156
+ Default is 1.0.
157
+ min_polygon_area_acres (float, optional): Minimum polygon area (in acres). For fluvial or pluvial
158
+ polygons smaller than this, reclassify to the opposite
159
+ type and merge with adjacent polygons of that type.
160
+ Ambiguous polygons are not affected.
161
+
162
+ Returns:
163
+ gpd.GeoDataFrame: A GeoDataFrame with dissolved polygons for 'fluvial', 'pluvial',
164
+ and 'ambiguous' zones.
165
+ """
166
+ try:
167
+ # --- 1. Data Loading and Preparation ---
168
+ logger.info("Loading mesh and results data...")
169
+ cell_polygons_gdf = HdfMesh.get_mesh_cell_polygons(hdf_path)
170
+ max_ws_df = HdfResultsMesh.get_mesh_max_ws(hdf_path)
171
+ max_ws_df['maximum_water_surface_time'] = max_ws_df['maximum_water_surface_time'].apply(
172
+ lambda x: HdfUtils.parse_ras_datetime(x) if isinstance(x, str) else x
116
173
  )
174
+ cell_times = max_ws_df.set_index('cell_id')['maximum_water_surface_time'].to_dict()
175
+
176
+ logger.info("Processing cell adjacencies...")
177
+ cell_adjacency, _ = HdfFluvialPluvial._process_cell_adjacencies(cell_polygons_gdf)
117
178
 
118
- # FOCUS YOUR REVISIONS HERE:
119
- # Join adjacent LineStrings into simple LineStrings by connecting them at shared endpoints
120
- logger.info("Joining adjacent LineStrings into simple LineStrings...")
179
+ # --- 2. Seeding the Classifications ---
180
+ logger.info(f"Identifying initial boundary seeds with delta_t = {delta_t} hours...")
181
+ boundary_pairs = HdfFluvialPluvial._get_boundary_cell_pairs(cell_adjacency, cell_times, delta_t)
182
+
183
+ classifications = pd.Series('unclassified', index=cell_polygons_gdf['cell_id'], name='classification')
121
184
 
122
- def get_coords(geom):
123
- """Helper function to extract coordinates from geometry objects
124
-
125
- Args:
126
- geom: A Shapely LineString or MultiLineString geometry
127
-
128
- Returns:
129
- tuple: Tuple containing:
130
- - list of original coordinates [(x1,y1), (x2,y2),...]
131
- - list of rounded coordinates for comparison
132
- - None if invalid geometry
133
- """
134
- if isinstance(geom, LineString):
135
- orig_coords = list(geom.coords)
136
- # Round coordinates to 0.01 for comparison
137
- rounded_coords = [(round(x, 2), round(y, 2)) for x, y in orig_coords]
138
- return orig_coords, rounded_coords
139
- elif isinstance(geom, MultiLineString):
140
- orig_coords = list(geom.geoms[0].coords)
141
- rounded_coords = [(round(x, 2), round(y, 2)) for x, y in orig_coords]
142
- return orig_coords, rounded_coords
143
- return None, None
144
-
145
- def find_connecting_line(current_end, unused_lines, endpoint_counts, rounded_endpoints):
146
- """Find a line that connects to the current endpoint
147
-
148
- Args:
149
- current_end: Tuple of (x, y) coordinates
150
- unused_lines: Set of unused line indices
151
- endpoint_counts: Dict of endpoint occurrence counts
152
- rounded_endpoints: Dict of rounded endpoint coordinates
153
-
154
- Returns:
155
- tuple: (line_index, should_reverse, found) or (None, None, False)
156
- """
157
- rounded_end = (round(current_end[0], 2), round(current_end[1], 2))
158
-
159
- # Skip if current endpoint is connected to more than 2 lines
160
- if endpoint_counts.get(rounded_end, 0) > 2:
161
- return None, None, False
162
-
163
- for i in unused_lines:
164
- start, end = rounded_endpoints[i]
165
- if start == rounded_end and endpoint_counts.get(start, 0) <= 2:
166
- return i, False, True
167
- elif end == rounded_end and endpoint_counts.get(end, 0) <= 2:
168
- return i, True, True
169
- return None, None, False
170
-
171
- # Initialize data structures
172
- joined_lines = []
173
- unused_lines = set(range(len(boundary_edges)))
185
+ for cell1, cell2 in boundary_pairs:
186
+ if cell_times.get(cell1) > cell_times.get(cell2):
187
+ classifications.loc[cell1] = 'fluvial'
188
+ classifications.loc[cell2] = 'pluvial'
189
+ else:
190
+ classifications.loc[cell1] = 'pluvial'
191
+ classifications.loc[cell2] = 'fluvial'
174
192
 
175
- # Create endpoint lookup dictionaries
176
- line_endpoints = {}
177
- rounded_endpoints = {}
178
- for i, edge in enumerate(boundary_edges):
179
- coords_result = get_coords(edge)
180
- if coords_result:
181
- orig_coords, rounded_coords = coords_result
182
- line_endpoints[i] = (orig_coords[0], orig_coords[-1])
183
- rounded_endpoints[i] = (rounded_coords[0], rounded_coords[-1])
184
-
185
- # Count endpoint occurrences
186
- endpoint_counts = {}
187
- for start, end in rounded_endpoints.values():
188
- endpoint_counts[start] = endpoint_counts.get(start, 0) + 1
189
- endpoint_counts[end] = endpoint_counts.get(end, 0) + 1
190
-
191
- # Iteratively join lines
192
- while unused_lines:
193
- # Start a new line chain
194
- current_points = []
195
-
196
- # Find first unused line
197
- start_idx = unused_lines.pop()
198
- start_coords, _ = get_coords(boundary_edges[start_idx])
199
- if start_coords:
200
- current_points.extend(start_coords)
201
-
202
- # Try to extend in both directions
203
- continue_joining = True
204
- while continue_joining:
205
- continue_joining = False
193
+ # --- 3. Iterative Region Growth ---
194
+ logger.info(f"Starting iterative region growth with tolerance = {temporal_tolerance_hours} hours...")
195
+ fluvial_frontier = set(classifications[classifications == 'fluvial'].index)
196
+ pluvial_frontier = set(classifications[classifications == 'pluvial'].index)
197
+
198
+ iteration = 0
199
+ with tqdm(desc="Region Growing", unit="iter") as pbar:
200
+ while fluvial_frontier or pluvial_frontier:
201
+ iteration += 1
206
202
 
207
- # Try to extend forward
208
- next_idx, should_reverse, found = find_connecting_line(
209
- current_points[-1],
210
- unused_lines,
211
- endpoint_counts,
212
- rounded_endpoints
213
- )
203
+ next_fluvial_candidates = set()
204
+ for cell_id in fluvial_frontier:
205
+ for neighbor_id in cell_adjacency.get(cell_id, []):
206
+ if classifications.loc[neighbor_id] == 'unclassified' and pd.notna(cell_times.get(neighbor_id)):
207
+ time_diff_seconds = abs((cell_times[cell_id] - cell_times[neighbor_id]).total_seconds())
208
+ if time_diff_seconds <= temporal_tolerance_hours * 3600:
209
+ next_fluvial_candidates.add(neighbor_id)
214
210
 
215
- if found:
216
- unused_lines.remove(next_idx)
217
- next_coords, _ = get_coords(boundary_edges[next_idx])
218
- if next_coords:
219
- if should_reverse:
220
- current_points.extend(reversed(next_coords[:-1]))
221
- else:
222
- current_points.extend(next_coords[1:])
223
- continue_joining = True
224
- continue
211
+ next_pluvial_candidates = set()
212
+ for cell_id in pluvial_frontier:
213
+ for neighbor_id in cell_adjacency.get(cell_id, []):
214
+ if classifications.loc[neighbor_id] == 'unclassified' and pd.notna(cell_times.get(neighbor_id)):
215
+ time_diff_seconds = abs((cell_times[cell_id] - cell_times[neighbor_id]).total_seconds())
216
+ if time_diff_seconds <= temporal_tolerance_hours * 3600:
217
+ next_pluvial_candidates.add(neighbor_id)
225
218
 
226
- # Try to extend backward
227
- prev_idx, should_reverse, found = find_connecting_line(
228
- current_points[0],
229
- unused_lines,
230
- endpoint_counts,
231
- rounded_endpoints
232
- )
219
+ # Resolve conflicts
220
+ ambiguous_cells = next_fluvial_candidates.intersection(next_pluvial_candidates)
221
+ if ambiguous_cells:
222
+ classifications.loc[list(ambiguous_cells)] = 'ambiguous'
223
+
224
+ # Classify non-conflicted cells
225
+ newly_fluvial = next_fluvial_candidates - ambiguous_cells
226
+ if newly_fluvial:
227
+ classifications.loc[list(newly_fluvial)] = 'fluvial'
228
+
229
+ newly_pluvial = next_pluvial_candidates - ambiguous_cells
230
+ if newly_pluvial:
231
+ classifications.loc[list(newly_pluvial)] = 'pluvial'
233
232
 
234
- if found:
235
- unused_lines.remove(prev_idx)
236
- prev_coords, _ = get_coords(boundary_edges[prev_idx])
237
- if prev_coords:
238
- if should_reverse:
239
- current_points[0:0] = reversed(prev_coords[:-1])
240
- else:
241
- current_points[0:0] = prev_coords[:-1]
242
- continue_joining = True
243
-
244
- # Create final LineString from collected points
245
- if current_points:
246
- joined_lines.append(LineString(current_points))
247
-
248
- # FILL GAPS BETWEEN JOINED LINES
249
- logger.info(f"Starting gap analysis for {len(joined_lines)} line segments...")
250
-
251
- def find_endpoints(lines):
252
- """Get all endpoints of the lines with their indices"""
253
- endpoints = []
254
- for i, line in enumerate(lines):
255
- coords = list(line.coords)
256
- endpoints.append((coords[0], i, 'start'))
257
- endpoints.append((coords[-1], i, 'end'))
258
- return endpoints
259
-
260
- def find_nearby_points(point1, point2, tolerance=0.01):
261
- """Check if two points are within tolerance distance"""
262
- return (abs(point1[0] - point2[0]) <= tolerance and
263
- abs(point1[1] - point2[1]) <= tolerance)
264
-
265
- def find_gaps(lines, tolerance=0.01):
266
- """Find gaps between line endpoints"""
267
- logger.info("Analyzing line endpoints to identify gaps...")
268
- endpoints = []
269
- for i, line in enumerate(lines):
270
- coords = list(line.coords)
271
- start = coords[0]
272
- end = coords[-1]
273
- endpoints.append({
274
- 'point': start,
275
- 'line_idx': i,
276
- 'position': 'start',
277
- 'coords': coords
278
- })
279
- endpoints.append({
280
- 'point': end,
281
- 'line_idx': i,
282
- 'position': 'end',
283
- 'coords': coords
233
+ # Update frontiers for the next iteration
234
+ fluvial_frontier = newly_fluvial
235
+ pluvial_frontier = newly_pluvial
236
+
237
+ pbar.update(1)
238
+ pbar.set_postfix({
239
+ "Fluvial": len(fluvial_frontier),
240
+ "Pluvial": len(pluvial_frontier),
241
+ "Ambiguous": len(ambiguous_cells)
284
242
  })
285
-
286
- logger.info(f"Found {len(endpoints)} endpoints to analyze")
287
- gaps = []
288
-
289
- # Compare each endpoint with all others
290
- for i, ep1 in enumerate(endpoints):
291
- for ep2 in endpoints[i+1:]:
292
- # Skip if endpoints are from same line
293
- if ep1['line_idx'] == ep2['line_idx']:
294
- continue
295
-
296
- point1 = ep1['point']
297
- point2 = ep2['point']
298
-
299
- # Skip if points are too close (already connected)
300
- if find_nearby_points(point1, point2):
301
- continue
302
-
303
- # Check if this could be a gap
304
- dist = LineString([point1, point2]).length
305
- if dist < 10.0: # Maximum gap distance threshold
306
- gaps.append({
307
- 'start': ep1,
308
- 'end': ep2,
309
- 'distance': dist
310
- })
311
-
312
- logger.info(f"Identified {len(gaps)} potential gaps to fill")
313
- return sorted(gaps, key=lambda x: x['distance'])
314
-
315
- def join_lines_with_gap(line1_coords, line2_coords, gap_start_pos, gap_end_pos):
316
- """Join two lines maintaining correct point order based on gap positions"""
317
- if gap_start_pos == 'end' and gap_end_pos == 'start':
318
- # line1 end connects to line2 start
319
- return line1_coords + line2_coords
320
- elif gap_start_pos == 'start' and gap_end_pos == 'end':
321
- # line1 start connects to line2 end
322
- return list(reversed(line2_coords)) + line1_coords
323
- elif gap_start_pos == 'end' and gap_end_pos == 'end':
324
- # line1 end connects to line2 end
325
- return line1_coords + list(reversed(line2_coords))
326
- else: # start to start
327
- # line1 start connects to line2 start
328
- return list(reversed(line1_coords)) + line2_coords
329
-
330
- # Process gaps and join lines
331
- processed_lines = joined_lines.copy()
332
- line_groups = [[i] for i in range(len(processed_lines))]
333
- gaps = find_gaps(processed_lines)
334
243
 
335
- filled_gap_count = 0
336
- for gap_idx, gap in enumerate(gaps, 1):
337
- logger.info(f"Processing gap {gap_idx}/{len(gaps)} (distance: {gap['distance']:.3f})")
338
-
339
- line1_idx = gap['start']['line_idx']
340
- line2_idx = gap['end']['line_idx']
341
-
342
- # Find the groups containing these lines
343
- group1 = next(g for g in line_groups if line1_idx in g)
344
- group2 = next(g for g in line_groups if line2_idx in g)
345
-
346
- # Skip if lines are already in the same group
347
- if group1 == group2:
348
- continue
349
-
350
- # Get the coordinates for both lines
351
- line1_coords = gap['start']['coords']
352
- line2_coords = gap['end']['coords']
353
-
354
- # Join the lines in correct order
355
- joined_coords = join_lines_with_gap(
356
- line1_coords,
357
- line2_coords,
358
- gap['start']['position'],
359
- gap['end']['position']
360
- )
361
-
362
- # Create new joined line
363
- new_line = LineString(joined_coords)
364
-
365
- # Update processed_lines and line_groups
366
- new_idx = len(processed_lines)
367
- processed_lines.append(new_line)
368
-
369
- # Merge groups and remove old ones
370
- new_group = group1 + group2
371
- line_groups.remove(group1)
372
- line_groups.remove(group2)
373
- line_groups.append(new_group + [new_idx])
374
-
375
- filled_gap_count += 1
376
- logger.info(f"Successfully joined lines {line1_idx} and {line2_idx}")
244
+ logger.info(f"Region growing completed in {iteration} iterations.")
377
245
 
378
- logger.info(f"Gap filling complete. Filled {filled_gap_count} out of {len(gaps)} gaps")
246
+ # --- 4. Finalization and Dissolving ---
247
+ # Classify any remaining unclassified (likely isolated) cells as ambiguous
248
+ classifications[classifications == 'unclassified'] = 'ambiguous'
249
+
250
+ logger.info("Merging classifications with cell polygons...")
251
+ classified_gdf = cell_polygons_gdf.merge(classifications.to_frame(), left_on='cell_id', right_index=True)
379
252
 
380
- # Get final lines (take the last line from each group)
381
- final_lines = [processed_lines[group[-1]] for group in line_groups]
253
+ logger.info("Dissolving polygons by classification...")
254
+ final_regions_gdf = classified_gdf.dissolve(by='classification', aggfunc='first').reset_index()
255
+
256
+ # --- 5. Minimum Polygon Area Filtering and Merging (if requested) ---
257
+ if min_polygon_area_acres is not None:
258
+ logger.info(f"Applying minimum polygon area filter: {min_polygon_area_acres} acres")
259
+ # Calculate area in acres (1 acre = 4046.8564224 m^2)
260
+ # If CRS is not projected, warn and skip area filtering
261
+ if not final_regions_gdf.crs or not final_regions_gdf.crs.is_projected:
262
+ logger.warning("CRS is not projected. Area-based filtering skipped.")
263
+ else:
264
+ # Explode to individual polygons for area filtering
265
+ exploded = final_regions_gdf.explode(index_parts=False, ignore_index=True)
266
+ exploded['area_acres'] = exploded.geometry.area / 4046.8564224
267
+
268
+ # Only consider fluvial and pluvial polygons for area filtering
269
+ mask_fluvial = (exploded['classification'] == 'fluvial') & (exploded['area_acres'] < min_polygon_area_acres)
270
+ mask_pluvial = (exploded['classification'] == 'pluvial') & (exploded['area_acres'] < min_polygon_area_acres)
271
+
272
+ n_fluvial = mask_fluvial.sum()
273
+ n_pluvial = mask_pluvial.sum()
274
+ logger.info(f"Found {n_fluvial} small fluvial and {n_pluvial} small pluvial polygons to reclassify.")
275
+
276
+ # Reclassify small fluvial polygons as pluvial, and small pluvial polygons as fluvial
277
+ exploded.loc[mask_fluvial, 'classification'] = 'pluvial'
278
+ exploded.loc[mask_pluvial, 'classification'] = 'fluvial'
279
+ # Ambiguous polygons are not changed
280
+
281
+ # Redissolve by classification to merge with adjacent polygons of the same type
282
+ final_regions_gdf = exploded.dissolve(by='classification', aggfunc='first').reset_index()
283
+ logger.info("Redissolved polygons after reclassification of small areas.")
284
+
285
+ logger.info("Polygon generation completed successfully.")
286
+ return final_regions_gdf
382
287
 
383
- logger.info(f"Final cleanup complete. Resulting in {len(final_lines)} line segments")
384
- joined_lines = final_lines
385
-
386
- # Create final GeoDataFrame with CRS from cell_polygons_gdf
387
- logger.info("Creating final GeoDataFrame for boundaries...")
388
- boundary_gdf = gpd.GeoDataFrame(
389
- geometry=joined_lines,
390
- crs=cell_polygons_gdf.crs
391
- )
392
-
393
- # Clean up intermediate dataframes
394
- logger.info("Cleaning up intermediate dataframes...")
395
- del cell_polygons_gdf
396
- del max_ws_df
397
-
398
- logger.info("Fluvial-pluvial boundary calculation completed successfully.")
399
- return boundary_gdf
400
-
401
288
  except Exception as e:
402
- self.logger.error(f"Error calculating fluvial-pluvial boundary: {str(e)}")
403
- return None
289
+ logger.error(f"Error generating fluvial-pluvial polygons: {str(e)}", exc_info=True)
290
+ return gpd.GeoDataFrame()
404
291
 
405
292
 
406
293
  @staticmethod
407
294
  def _process_cell_adjacencies(cell_polygons_gdf: gpd.GeoDataFrame) -> Tuple[Dict[int, List[int]], Dict[int, Dict[int, LineString]]]:
408
295
  """
409
296
  Optimized method to process cell adjacencies by extracting shared edges directly.
410
-
411
- Args:
412
- cell_polygons_gdf (gpd.GeoDataFrame): GeoDataFrame containing 2D mesh cell polygons
413
- with 'cell_id' and 'geometry' columns.
414
-
415
- Returns:
416
- Tuple containing:
417
- - Dict[int, List[int]]: Dictionary mapping cell IDs to lists of adjacent cell IDs.
418
- - Dict[int, Dict[int, LineString]]: Nested dictionary storing common edges between cells,
419
- where common_edges[cell1][cell2] gives the shared boundary.
420
297
  """
421
298
  cell_adjacency = defaultdict(list)
422
299
  common_edges = defaultdict(dict)
423
-
424
- # Build an edge to cells mapping
425
300
  edge_to_cells = defaultdict(set)
426
301
 
427
- # Function to generate edge keys
428
302
  def edge_key(coords1, coords2, precision=8):
429
- # Round coordinates
430
303
  coords1 = tuple(round(coord, precision) for coord in coords1)
431
304
  coords2 = tuple(round(coord, precision) for coord in coords2)
432
- # Create sorted key to handle edge direction
433
305
  return tuple(sorted([coords1, coords2]))
434
306
 
435
- # For each polygon, extract edges
436
- for idx, row in cell_polygons_gdf.iterrows():
307
+ for _, row in cell_polygons_gdf.iterrows():
437
308
  cell_id = row['cell_id']
438
309
  geom = row['geometry']
439
310
  if geom.is_empty or not geom.is_valid:
440
311
  continue
441
- # Get exterior coordinates
442
312
  coords = list(geom.exterior.coords)
443
- num_coords = len(coords)
444
- for i in range(num_coords - 1):
445
- coord1 = coords[i]
446
- coord2 = coords[i + 1]
447
- key = edge_key(coord1, coord2)
313
+ for i in range(len(coords) - 1):
314
+ key = edge_key(coords[i], coords[i + 1])
448
315
  edge_to_cells[key].add(cell_id)
449
316
 
450
- # Now, process edge_to_cells to build adjacency
451
317
  for edge, cells in edge_to_cells.items():
452
- cells = list(cells)
453
- if len(cells) >= 2:
454
- # For all pairs of cells sharing this edge
455
- for i in range(len(cells)):
456
- for j in range(i + 1, len(cells)):
457
- cell1 = cells[i]
458
- cell2 = cells[j]
459
- # Update adjacency
460
- if cell2 not in cell_adjacency[cell1]:
461
- cell_adjacency[cell1].append(cell2)
462
- if cell1 not in cell_adjacency[cell2]:
463
- cell_adjacency[cell2].append(cell1)
464
- # Store common edge
318
+ cell_list = list(cells)
319
+ if len(cell_list) >= 2:
320
+ for i in range(len(cell_list)):
321
+ for j in range(i + 1, len(cell_list)):
322
+ cell1, cell2 = cell_list[i], cell_list[j]
323
+ cell_adjacency[cell1].append(cell2)
324
+ cell_adjacency[cell2].append(cell1)
465
325
  common_edge = LineString([edge[0], edge[1]])
466
326
  common_edges[cell1][cell2] = common_edge
467
327
  common_edges[cell2][cell1] = common_edge
468
328
 
469
- logger.info("Cell adjacencies processed successfully.")
470
329
  return cell_adjacency, common_edges
471
-
330
+
472
331
  @staticmethod
473
- def _identify_boundary_edges(cell_adjacency: Dict[int, List[int]],
474
- common_edges: Dict[int, Dict[int, LineString]],
475
- cell_times: Dict[int, pd.Timestamp],
476
- delta_t: float) -> List[LineString]:
332
+ def _get_boundary_cell_pairs(
333
+ cell_adjacency: Dict[int, List[int]],
334
+ cell_times: Dict[int, pd.Timestamp],
335
+ delta_t: float
336
+ ) -> List[Tuple[int, int]]:
477
337
  """
478
- Identify boundary edges between cells with significant time differences.
338
+ Identifies pairs of adjacent cell IDs that form a boundary.
479
339
 
340
+ A boundary is defined where the difference in max water surface time
341
+ between two adjacent cells is greater than delta_t.
342
+
480
343
  Args:
481
- cell_adjacency (Dict[int, List[int]]): Dictionary of cell adjacencies
482
- common_edges (Dict[int, Dict[int, LineString]]): Dictionary of shared edges between cells
483
- cell_times (Dict[int, pd.Timestamp]): Dictionary mapping cell IDs to their max WSE times
484
- delta_t (float): Time threshold in hours
344
+ cell_adjacency (Dict[int, List[int]]): Dictionary of cell adjacencies.
345
+ cell_times (Dict[int, pd.Timestamp]): Dictionary mapping cell IDs to their max WSE times.
346
+ delta_t (float): Time threshold in hours.
485
347
 
486
348
  Returns:
487
- List[LineString]: List of LineString geometries representing boundaries
349
+ List[Tuple[int, int]]: A list of tuples, where each tuple contains a pair of
350
+ cell IDs forming a boundary.
488
351
  """
489
- # Validate cell_times data
490
- valid_times = {k: v for k, v in cell_times.items() if pd.notna(v)}
491
- if len(valid_times) < len(cell_times):
492
- logger.warning(f"Found {len(cell_times) - len(valid_times)} cells with invalid timestamps")
493
- cell_times = valid_times
494
-
495
- # Use a set to store processed cell pairs and avoid duplicates
352
+ boundary_cell_pairs = []
496
353
  processed_pairs = set()
497
- boundary_edges = []
498
-
499
- # Track time differences for debugging
500
- time_diffs = []
354
+ delta_t_seconds = delta_t * 3600
501
355
 
502
- with tqdm(total=len(cell_adjacency), desc="Processing cell adjacencies") as pbar:
503
- for cell_id, neighbors in cell_adjacency.items():
504
- if cell_id not in cell_times:
505
- logger.debug(f"Skipping cell {cell_id} - no timestamp data")
506
- pbar.update(1)
356
+ for cell_id, neighbors in cell_adjacency.items():
357
+ time1 = cell_times.get(cell_id)
358
+ if not pd.notna(time1):
359
+ continue
360
+
361
+ for neighbor_id in neighbors:
362
+ pair = tuple(sorted((cell_id, neighbor_id)))
363
+ if pair in processed_pairs:
507
364
  continue
508
-
509
- cell_time = cell_times[cell_id]
510
365
 
511
- for neighbor_id in neighbors:
512
- if neighbor_id not in cell_times:
513
- logger.debug(f"Skipping neighbor {neighbor_id} of cell {cell_id} - no timestamp data")
514
- continue
515
-
516
- # Create a sorted tuple of the cell pair to ensure uniqueness
517
- cell_pair = tuple(sorted([cell_id, neighbor_id]))
518
-
519
- # Skip if we've already processed this pair
520
- if cell_pair in processed_pairs:
521
- continue
522
-
523
- neighbor_time = cell_times[neighbor_id]
524
-
525
- # Ensure both timestamps are valid
526
- if pd.isna(cell_time) or pd.isna(neighbor_time):
527
- continue
528
-
529
- # Calculate time difference in hours
530
- time_diff = abs((cell_time - neighbor_time).total_seconds() / 3600)
531
- time_diffs.append(time_diff)
532
-
533
- logger.debug(f"Time difference between cells {cell_id} and {neighbor_id}: {time_diff:.2f} hours")
366
+ time2 = cell_times.get(neighbor_id)
367
+ if not pd.notna(time2):
368
+ continue
369
+
370
+ time_diff = abs((time1 - time2).total_seconds())
534
371
 
535
- if time_diff >= delta_t:
536
- logger.debug(f"Found boundary edge between cells {cell_id} and {neighbor_id} "
537
- f"(time diff: {time_diff:.2f} hours)")
538
- boundary_edges.append(common_edges[cell_id][neighbor_id])
539
-
540
- # Mark this pair as processed
541
- processed_pairs.add(cell_pair)
372
+ if time_diff >= delta_t_seconds:
373
+ boundary_cell_pairs.append(pair)
374
+
375
+ processed_pairs.add(pair)
376
+
377
+ return boundary_cell_pairs
378
+
379
+ @staticmethod
380
+ def _identify_boundary_edges(
381
+ cell_adjacency: Dict[int, List[int]],
382
+ common_edges: Dict[int, Dict[int, LineString]],
383
+ cell_times: Dict[int, pd.Timestamp],
384
+ delta_t: float,
385
+ min_line_length: Optional[float] = None
386
+ ) -> List[LineString]:
387
+ """
388
+ Identify boundary edges between cells with significant time differences.
389
+
390
+ This function now uses the helper `_get_boundary_cell_pairs`.
542
391
 
543
- pbar.update(1)
392
+ Args:
393
+ cell_adjacency (Dict[int, List[int]]): Dictionary of cell adjacencies.
394
+ common_edges (Dict[int, Dict[int, LineString]]): Dictionary of shared edges between cells.
395
+ cell_times (Dict[int, pd.Timestamp]): Dictionary mapping cell IDs to their max WSE times.
396
+ delta_t (float): Time threshold in hours.
397
+ min_line_length (float, optional): Minimum length (in CRS units) for boundary lines to be included.
398
+ Lines shorter than this will be dropped. Default is None (no filtering).
399
+
400
+ Returns:
401
+ List[LineString]: List of LineString geometries representing boundaries.
402
+ """
403
+ boundary_pairs = HdfFluvialPluvial._get_boundary_cell_pairs(cell_adjacency, cell_times, delta_t)
404
+
405
+ boundary_edges = [common_edges[c1][c2] for c1, c2 in boundary_pairs]
406
+
407
+ logger.info(f"Identified {len(boundary_edges)} boundary edges using delta_t of {delta_t} hours.")
544
408
 
545
- # Log summary statistics
546
- if time_diffs:
547
- logger.info(f"Time difference statistics:")
548
- logger.info(f" Min: {min(time_diffs):.2f} hours")
549
- logger.info(f" Max: {max(time_diffs):.2f} hours")
550
- logger.info(f" Mean: {sum(time_diffs)/len(time_diffs):.2f} hours")
551
- logger.info(f" Number of boundaries found: {len(boundary_edges)}")
552
- logger.info(f" Delta-t threshold: {delta_t} hours")
409
+ if min_line_length is not None:
410
+ filtered_edges = [edge for edge in boundary_edges if edge.length >= min_line_length]
411
+ num_dropped = len(boundary_edges) - len(filtered_edges)
412
+ if num_dropped > 0:
413
+ logger.info(f"{num_dropped} boundary line(s) shorter than {min_line_length} units were dropped after filtering.")
414
+ boundary_edges = filtered_edges
553
415
 
554
- return boundary_edges
416
+ return boundary_edges
@@ -42,22 +42,6 @@ HdfUtils for common operations. Methods use @log_call decorator for logging and
42
42
 
43
43
 
44
44
 
45
- REVISIONS MADE:
46
-
47
- Use get_ prefix for functions that return data.
48
- BUT, we will never set results data, so we should use get_ for results data.
49
-
50
- Renamed functions:
51
- - mesh_summary_output() to get_mesh_summary()
52
- - mesh_timeseries_output() to get_mesh_timeseries()
53
- - mesh_faces_timeseries_output() to get_mesh_faces_timeseries()
54
- - mesh_cells_timeseries_output() to get_mesh_cells_timeseries()
55
- - mesh_last_iter() to get_mesh_last_iter()
56
- - mesh_max_ws() to get_mesh_max_ws()
57
-
58
-
59
-
60
-
61
45
 
62
46
 
63
47
 
ras_commander/__init__.py CHANGED
@@ -10,7 +10,7 @@ try:
10
10
  __version__ = version("ras-commander")
11
11
  except PackageNotFoundError:
12
12
  # package is not installed
13
- __version__ = "0.77.0"
13
+ __version__ = "0.79.0"
14
14
 
15
15
  # Set up logging
16
16
  setup_logging()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ras-commander
3
- Version: 0.78.0
3
+ Version: 0.79.0
4
4
  Summary: A Python library for automating HEC-RAS 6.x operations
5
5
  Home-page: https://github.com/gpt-cmdr/ras-commander
6
6
  Author: William M. Katzenmeyer, P.E., C.F.M.
@@ -1,14 +1,14 @@
1
1
  ras_commander/Decorators.py,sha256=oJHZqwhw1H3Gy-m-mT4zyJ-LTH9b4Ick1hzgkMO-MEM,14783
2
2
  ras_commander/HdfBase.py,sha256=Jws6Y8JFkharuiM6Br5ivp6MS64X2fL6y87FOpe3FQw,14219
3
3
  ras_commander/HdfBndry.py,sha256=FBNFoTz4sXVB-MOsbHJBP8P0dMqJUfBROloKTaxmzCo,16377
4
- ras_commander/HdfFluvialPluvial.py,sha256=dlqoFX5i7uSA2BvuRNrV-Fg-z2JaeUxY86_fbZAdGqI,25933
4
+ ras_commander/HdfFluvialPluvial.py,sha256=Go-rogUjF0O9Vjbmh7g1bh9QJfIephQD-nHTiDhx9l8,20824
5
5
  ras_commander/HdfInfiltration.py,sha256=n42JY__hTLZzEnsf4NDgKXssrNU2zkYzdPAAQ19fWU4,67700
6
6
  ras_commander/HdfMesh.py,sha256=zI_4AqxDxb2_31G9RUmWibyld6KDMGhDpI3F8qwzVAw,19139
7
7
  ras_commander/HdfPipe.py,sha256=m-yvPL2GIP23NKt2tcwzOlS7khvgcDPGAshlTPMUAeI,32154
8
8
  ras_commander/HdfPlan.py,sha256=WINI3lp865cE99QXztgvKKIhVUTOqu4X41ZPBfhYJGU,12145
9
9
  ras_commander/HdfPlot.py,sha256=7MNI5T9qIz-Ava1RdlnB6O9oJElE5BEB29QVF5Y2Xuc,3401
10
10
  ras_commander/HdfPump.py,sha256=Vc2ff16kRISR7jwtnaAqxI0p-gfBSuZKzR3rQbBLQoE,12951
11
- ras_commander/HdfResultsMesh.py,sha256=MKnSJxcWVDccHaRRGgAK0szm7B-VtrKBtgL1Uz0kAiw,44662
11
+ ras_commander/HdfResultsMesh.py,sha256=FIUN_yZ6h1XrcaHuOdAQrozOBN_UKNNn3_l8lO703dk,44172
12
12
  ras_commander/HdfResultsPlan.py,sha256=gmeAceYHABubv0V8dJL2F5GN4Th7g92E_1y-Hc4JZjg,16626
13
13
  ras_commander/HdfResultsPlot.py,sha256=ylzfT78CfgoDO0XAlRwlgMNRzvNQYBMn9eyXyBfjv_w,7660
14
14
  ras_commander/HdfResultsXsec.py,sha256=-P7nXnbjOLAeUnrdSC_lJQSfzrlWKmDF9Z5gEjmxbJY,13031
@@ -24,9 +24,9 @@ ras_commander/RasPlan.py,sha256=ogIpLqawXTsjLnKRZTqzZydn_EFVJZFZZGgHvJ_t_-c,6540
24
24
  ras_commander/RasPrj.py,sha256=oXPWuNC_403mUfqyysXyBVul0Xtz_0SKDxZdj4fvNYU,63448
25
25
  ras_commander/RasUnsteady.py,sha256=TO08CT2GC4G5rcXO_Wbia2t4PhiWRu9-nC9F0IW7Gyo,37187
26
26
  ras_commander/RasUtils.py,sha256=0fm4IIs0LH1dgDj3pGd66mR82DhWLEkRKUvIo2M_5X0,35886
27
- ras_commander/__init__.py,sha256=byL-4pG2AwLeZXa1nFd3srZe9Pu96V52Y5Dujcu3mM8,2039
28
- ras_commander-0.78.0.dist-info/licenses/LICENSE,sha256=_pbd6qHnlsz1iQ-ozDW_49r86BZT6CRwO2iBtw0iN6M,457
29
- ras_commander-0.78.0.dist-info/METADATA,sha256=-AIKb7auKcOkGb6vDogfaKr4eCdhFZIrml4kPiZbpcM,27855
30
- ras_commander-0.78.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- ras_commander-0.78.0.dist-info/top_level.txt,sha256=i76S7eKLFC8doKcXDl3aiOr9RwT06G8adI6YuKbQDaA,14
32
- ras_commander-0.78.0.dist-info/RECORD,,
27
+ ras_commander/__init__.py,sha256=sd0ei6EQBUoBKiP8DQZfwhUXY1Wr4r4ubEKKqaCAIlI,2039
28
+ ras_commander-0.79.0.dist-info/licenses/LICENSE,sha256=_pbd6qHnlsz1iQ-ozDW_49r86BZT6CRwO2iBtw0iN6M,457
29
+ ras_commander-0.79.0.dist-info/METADATA,sha256=aNL6rjWX_mmG2XIBXLu6QubymBPoXRJPlbwPAP1jT_0,27855
30
+ ras_commander-0.79.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ ras_commander-0.79.0.dist-info/top_level.txt,sha256=i76S7eKLFC8doKcXDl3aiOr9RwT06G8adI6YuKbQDaA,14
32
+ ras_commander-0.79.0.dist-info/RECORD,,