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.
- ras_commander/HdfFluvialPluvial.py +276 -414
- ras_commander/HdfResultsMesh.py +0 -16
- ras_commander/__init__.py +1 -1
- {ras_commander-0.78.0.dist-info → ras_commander-0.79.0.dist-info}/METADATA +1 -1
- {ras_commander-0.78.0.dist-info → ras_commander-0.79.0.dist-info}/RECORD +8 -8
- {ras_commander-0.78.0.dist-info → ras_commander-0.79.0.dist-info}/WHEEL +0 -0
- {ras_commander-0.78.0.dist-info → ras_commander-0.79.0.dist-info}/licenses/LICENSE +0 -0
- {ras_commander-0.78.0.dist-info → ras_commander-0.79.0.dist-info}/top_level.txt +0 -0
@@ -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
|
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
|
-
-
|
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
|
-
>>>
|
51
|
+
>>> from ras_commander import HdfFluvialPluvial
|
50
52
|
>>> hdf_path = Path("path/to/plan.hdf")
|
51
|
-
|
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(
|
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
|
-
|
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
|
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
|
-
|
100
|
-
|
101
|
-
|
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
|
-
#
|
119
|
-
|
120
|
-
|
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
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
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
|
-
#
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
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
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
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
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
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
|
-
#
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
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
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
381
|
-
|
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
|
-
|
403
|
-
return
|
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
|
-
|
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
|
-
|
444
|
-
|
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
|
-
|
453
|
-
if len(
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
cell1
|
458
|
-
cell2
|
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
|
474
|
-
|
475
|
-
|
476
|
-
|
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
|
-
|
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
|
-
|
483
|
-
|
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[
|
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
|
-
|
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
|
-
|
498
|
-
|
499
|
-
# Track time differences for debugging
|
500
|
-
time_diffs = []
|
354
|
+
delta_t_seconds = delta_t * 3600
|
501
355
|
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
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
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
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
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
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
|
-
|
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
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
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
|
ras_commander/HdfResultsMesh.py
CHANGED
@@ -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
@@ -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=
|
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=
|
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=
|
28
|
-
ras_commander-0.
|
29
|
-
ras_commander-0.
|
30
|
-
ras_commander-0.
|
31
|
-
ras_commander-0.
|
32
|
-
ras_commander-0.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|