ras-commander 0.80.3__py3-none-any.whl → 0.82.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/RasControl.py +774 -0
- ras_commander/RasPlan.py +98 -31
- ras_commander/RasPrj.py +170 -61
- ras_commander/__init__.py +3 -2
- {ras_commander-0.80.3.dist-info → ras_commander-0.82.0.dist-info}/METADATA +15 -2
- {ras_commander-0.80.3.dist-info → ras_commander-0.82.0.dist-info}/RECORD +9 -8
- {ras_commander-0.80.3.dist-info → ras_commander-0.82.0.dist-info}/WHEEL +0 -0
- {ras_commander-0.80.3.dist-info → ras_commander-0.82.0.dist-info}/licenses/LICENSE +0 -0
- {ras_commander-0.80.3.dist-info → ras_commander-0.82.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RasControl - HECRASController API Wrapper (ras-commander style)
|
|
3
|
+
|
|
4
|
+
Provides ras-commander style API for legacy HEC-RAS versions (3.x-4.x)
|
|
5
|
+
that use HECRASController COM interface instead of HDF files.
|
|
6
|
+
|
|
7
|
+
Public functions:
|
|
8
|
+
- RasControl.run_plan(plan: Union[str, Path], ras_object: Optional[Any] = None) -> Tuple[bool, List[str]]
|
|
9
|
+
- RasControl.get_steady_results(plan: Union[str, Path], ras_object: Optional[Any] = None) -> pandas.DataFrame
|
|
10
|
+
- RasControl.get_unsteady_results(plan: Union[str, Path], max_times: Optional[int] = None, ras_object: Optional[Any] = None) -> pandas.DataFrame
|
|
11
|
+
- RasControl.get_output_times(plan: Union[str, Path], ras_object: Optional[Any] = None) -> List[str]
|
|
12
|
+
- RasControl.get_plans(plan: Union[str, Path], ras_object: Optional[Any] = None) -> List[dict]
|
|
13
|
+
- RasControl.set_current_plan(plan: Union[str, Path], ras_object: Optional[Any] = None) -> bool
|
|
14
|
+
|
|
15
|
+
Private functions:
|
|
16
|
+
- _terminate_ras_process() -> None
|
|
17
|
+
- _is_ras_running() -> bool
|
|
18
|
+
- RasControl._normalize_version(version: str) -> str
|
|
19
|
+
- RasControl._get_project_info(plan: Union[str, Path], ras_object: Optional[Any] = None) -> Tuple[Path, str, Optional[str], Optional[str]]
|
|
20
|
+
- RasControl._com_open_close(project_path: Path, version: str, operation_func: Callable[[Any], Any]) -> Any
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import win32com.client
|
|
25
|
+
import psutil
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional, List, Tuple, Callable, Any, Union
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Import ras-commander components
|
|
35
|
+
from .RasPrj import ras
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _terminate_ras_process() -> None:
|
|
39
|
+
"""Force terminate any running ras.exe processes."""
|
|
40
|
+
for proc in psutil.process_iter(['name']):
|
|
41
|
+
try:
|
|
42
|
+
if proc.info['name'] and proc.info['name'].lower() == 'ras.exe':
|
|
43
|
+
proc.terminate()
|
|
44
|
+
proc.wait(timeout=3)
|
|
45
|
+
logger.info("Terminated ras.exe process")
|
|
46
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_ras_running() -> bool:
|
|
51
|
+
"""Check if HEC-RAS is currently running"""
|
|
52
|
+
for proc in psutil.process_iter(['name']):
|
|
53
|
+
try:
|
|
54
|
+
if proc.info['name'] and proc.info['name'].lower() == 'ras.exe':
|
|
55
|
+
return True
|
|
56
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
57
|
+
pass
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RasControl:
|
|
62
|
+
"""
|
|
63
|
+
HECRASController API wrapper with ras-commander style interface.
|
|
64
|
+
|
|
65
|
+
Works with legacy HEC-RAS versions (3.x-4.x) that use COM interface
|
|
66
|
+
instead of HDF files. Integrates with ras-commander project management.
|
|
67
|
+
|
|
68
|
+
Usage (ras-commander style):
|
|
69
|
+
>>> from ras_commander import init_ras_project, RasControl
|
|
70
|
+
>>>
|
|
71
|
+
>>> # Initialize with version (with or without periods)
|
|
72
|
+
>>> init_ras_project(path, "4.1") # or "41"
|
|
73
|
+
>>>
|
|
74
|
+
>>> # Use plan numbers like HDF methods
|
|
75
|
+
>>> RasControl.run_plan("02")
|
|
76
|
+
>>> df = RasControl.get_steady_results("02")
|
|
77
|
+
|
|
78
|
+
Supported Versions:
|
|
79
|
+
All installed versions: 3.x, 4.x, 5.0.x, 6.0-6.7+
|
|
80
|
+
Accepts formats: "4.1", "41", "5.0.6", "506", "6.6", "66", etc.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Version mapping based on ACTUAL COM interfaces registered on system
|
|
84
|
+
# Only these COM interfaces exist: RAS41, RAS503, RAS505, RAS506, RAS507,
|
|
85
|
+
# RAS60, RAS631, RAS641, RAS65, RAS66, RAS67
|
|
86
|
+
# Other versions use nearest available fallback
|
|
87
|
+
VERSION_MAP = {
|
|
88
|
+
# HEC-RAS 3.x → Use 4.1 (3.x COM not registered)
|
|
89
|
+
'3.0': 'RAS41.HECRASController',
|
|
90
|
+
'30': 'RAS41.HECRASController',
|
|
91
|
+
'3.1': 'RAS41.HECRASController',
|
|
92
|
+
'31': 'RAS41.HECRASController',
|
|
93
|
+
'3.1.1': 'RAS41.HECRASController',
|
|
94
|
+
'311': 'RAS41.HECRASController',
|
|
95
|
+
'3.1.2': 'RAS41.HECRASController',
|
|
96
|
+
'312': 'RAS41.HECRASController',
|
|
97
|
+
'3.1.3': 'RAS41.HECRASController',
|
|
98
|
+
'313': 'RAS41.HECRASController',
|
|
99
|
+
|
|
100
|
+
# HEC-RAS 4.x
|
|
101
|
+
'4.0': 'RAS41.HECRASController', # Use 4.1 (4.0 COM not registered)
|
|
102
|
+
'40': 'RAS41.HECRASController',
|
|
103
|
+
'4.1': 'RAS41.HECRASController', # ✓ EXISTS
|
|
104
|
+
'41': 'RAS41.HECRASController',
|
|
105
|
+
'4.1.0': 'RAS41.HECRASController',
|
|
106
|
+
'410': 'RAS41.HECRASController',
|
|
107
|
+
|
|
108
|
+
# HEC-RAS 5.0.x
|
|
109
|
+
'5.0': 'RAS503.HECRASController', # Use 5.0.3 (RAS50 COM not registered)
|
|
110
|
+
'50': 'RAS503.HECRASController',
|
|
111
|
+
'5.0.1': 'RAS501.HECRASController', # ✓ EXISTS
|
|
112
|
+
'501': 'RAS501.HECRASController',
|
|
113
|
+
'5.0.3': 'RAS503.HECRASController', # ✓ EXISTS
|
|
114
|
+
'503': 'RAS503.HECRASController',
|
|
115
|
+
'5.0.4': 'RAS504.HECRASController', # ✓ EXISTS (newly installed)
|
|
116
|
+
'504': 'RAS504.HECRASController',
|
|
117
|
+
'5.0.5': 'RAS505.HECRASController', # ✓ EXISTS
|
|
118
|
+
'505': 'RAS505.HECRASController',
|
|
119
|
+
'5.0.6': 'RAS506.HECRASController', # ✓ EXISTS
|
|
120
|
+
'506': 'RAS506.HECRASController',
|
|
121
|
+
'5.0.7': 'RAS507.HECRASController', # ✓ EXISTS
|
|
122
|
+
'507': 'RAS507.HECRASController',
|
|
123
|
+
|
|
124
|
+
# HEC-RAS 6.x
|
|
125
|
+
'6.0': 'RAS60.HECRASController', # ✓ EXISTS
|
|
126
|
+
'60': 'RAS60.HECRASController',
|
|
127
|
+
'6.1': 'RAS60.HECRASController', # Use 6.0 (6.1 COM not registered)
|
|
128
|
+
'61': 'RAS60.HECRASController',
|
|
129
|
+
'6.2': 'RAS60.HECRASController', # Use 6.0 (6.2 COM not registered)
|
|
130
|
+
'62': 'RAS60.HECRASController',
|
|
131
|
+
'6.3': 'RAS631.HECRASController', # Use 6.3.1 (6.3 COM not registered)
|
|
132
|
+
'63': 'RAS631.HECRASController',
|
|
133
|
+
'6.3.1': 'RAS631.HECRASController', # ✓ EXISTS
|
|
134
|
+
'631': 'RAS631.HECRASController',
|
|
135
|
+
'6.4': 'RAS641.HECRASController', # Use 6.4.1 (6.4 COM not registered)
|
|
136
|
+
'64': 'RAS641.HECRASController',
|
|
137
|
+
'6.4.1': 'RAS641.HECRASController', # ✓ EXISTS
|
|
138
|
+
'641': 'RAS641.HECRASController',
|
|
139
|
+
'6.5': 'RAS65.HECRASController', # ✓ EXISTS
|
|
140
|
+
'65': 'RAS65.HECRASController',
|
|
141
|
+
'6.6': 'RAS66.HECRASController', # ✓ EXISTS
|
|
142
|
+
'66': 'RAS66.HECRASController',
|
|
143
|
+
'6.7': 'RAS67.HECRASController', # ✓ EXISTS
|
|
144
|
+
'67': 'RAS67.HECRASController',
|
|
145
|
+
'6.7 Beta 4': 'RAS67.HECRASController',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Legacy reference (kept for backwards compatibility)
|
|
149
|
+
SUPPORTED_VERSIONS = VERSION_MAP
|
|
150
|
+
|
|
151
|
+
# Output variable codes
|
|
152
|
+
WSEL = 2
|
|
153
|
+
ENERGY = 3
|
|
154
|
+
MAX_CHL_DPTH = 4
|
|
155
|
+
MIN_CH_EL = 5
|
|
156
|
+
ENERGY_SLOPE = 6
|
|
157
|
+
FLOW_TOTAL = 24
|
|
158
|
+
VEL_TOTAL = 25
|
|
159
|
+
STA_WS_LFT = 36
|
|
160
|
+
STA_WS_RGT = 37
|
|
161
|
+
FROUDE_CHL = 48
|
|
162
|
+
FROUDE_XS = 49
|
|
163
|
+
Q_WEIR = 94
|
|
164
|
+
Q_CULVERT_TOT = 242
|
|
165
|
+
|
|
166
|
+
# ========== PRIVATE METHODS (HECRASController COM API) ==========
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _normalize_version(version: str) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Normalize version string to match VERSION_MAP keys.
|
|
172
|
+
|
|
173
|
+
Handles formats like:
|
|
174
|
+
"6.6", "66" → "6.6"
|
|
175
|
+
"4.1", "41" → "4.1"
|
|
176
|
+
"5.0.6", "506" → "5.0.6"
|
|
177
|
+
"6.7 Beta 4" → "6.7 Beta 4"
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Normalized version string that exists in VERSION_MAP
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If version cannot be normalized or is not supported
|
|
184
|
+
"""
|
|
185
|
+
version_str = str(version).strip()
|
|
186
|
+
|
|
187
|
+
# Direct match
|
|
188
|
+
if version_str in RasControl.VERSION_MAP:
|
|
189
|
+
return version_str
|
|
190
|
+
|
|
191
|
+
# Try common normalizations
|
|
192
|
+
normalized_candidates = [
|
|
193
|
+
version_str,
|
|
194
|
+
version_str.replace('.', ''), # "6.6" → "66"
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
# Try adding periods for compact formats
|
|
198
|
+
if len(version_str) == 2: # "66" → "6.6"
|
|
199
|
+
normalized_candidates.append(f"{version_str[0]}.{version_str[1]}")
|
|
200
|
+
elif len(version_str) == 3 and version_str.startswith('5'): # "506" → "5.0.6"
|
|
201
|
+
normalized_candidates.append(f"5.0.{version_str[2]}")
|
|
202
|
+
elif len(version_str) == 3: # "631" → "6.3.1"
|
|
203
|
+
normalized_candidates.append(f"{version_str[0]}.{version_str[1]}.{version_str[2]}")
|
|
204
|
+
|
|
205
|
+
# Check all candidates
|
|
206
|
+
for candidate in normalized_candidates:
|
|
207
|
+
if candidate in RasControl.VERSION_MAP:
|
|
208
|
+
logger.debug(f"Normalized version '{version}' → '{candidate}'")
|
|
209
|
+
return candidate
|
|
210
|
+
|
|
211
|
+
# Not found
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Version '{version}' not supported. Supported versions:\n"
|
|
214
|
+
f" 3.x: 3.0, 3.1 (3.1.1, 3.1.2, 3.1.3)\n"
|
|
215
|
+
f" 4.x: 4.0, 4.1\n"
|
|
216
|
+
f" 5.0.x: 5.0, 5.0.1, 5.0.3, 5.0.4, 5.0.5, 5.0.6, 5.0.7\n"
|
|
217
|
+
f" 6.x: 6.0, 6.1, 6.2, 6.3, 6.3.1, 6.4, 6.4.1, 6.5, 6.6, 6.7\n"
|
|
218
|
+
f" Formats: Can use '6.6' or '66', '5.0.6' or '506', etc."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _get_project_info(plan: Union[str, Path], ras_object=None):
|
|
223
|
+
"""
|
|
224
|
+
Resolve plan number/path to project path, version, and plan details.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Tuple[Path, str, str, str]: (project_path, version, plan_number, plan_name)
|
|
228
|
+
plan_number and plan_name are None if using direct .prj path
|
|
229
|
+
"""
|
|
230
|
+
if ras_object is None:
|
|
231
|
+
ras_object = ras
|
|
232
|
+
|
|
233
|
+
# If it's a path to .prj file
|
|
234
|
+
plan_path = Path(plan) if isinstance(plan, str) else plan
|
|
235
|
+
if plan_path.exists() and plan_path.suffix == '.prj':
|
|
236
|
+
# Direct path - need version from ras_object
|
|
237
|
+
if not hasattr(ras_object, 'ras_version') or not ras_object.ras_version:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"When using direct .prj paths, project must be initialized with version.\n"
|
|
240
|
+
"Use: init_ras_project(path, '4.1') or similar"
|
|
241
|
+
)
|
|
242
|
+
return plan_path, ras_object.ras_version, None, None
|
|
243
|
+
|
|
244
|
+
# Otherwise treat as plan number
|
|
245
|
+
plan_num = str(plan).zfill(2)
|
|
246
|
+
|
|
247
|
+
# Get project path from ras_object
|
|
248
|
+
if not hasattr(ras_object, 'prj_file') or not ras_object.prj_file:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
"No project initialized. Use init_ras_project() first.\n"
|
|
251
|
+
"Example: init_ras_project(path, '4.1')"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
project_path = Path(ras_object.prj_file)
|
|
255
|
+
|
|
256
|
+
# Get version
|
|
257
|
+
if not hasattr(ras_object, 'ras_version') or not ras_object.ras_version:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"Project initialized without version. Re-initialize with:\n"
|
|
260
|
+
"init_ras_project(path, '4.1') # or '41', '501', etc."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
version = ras_object.ras_version
|
|
264
|
+
|
|
265
|
+
# Get plan name from plan_df
|
|
266
|
+
plan_row = ras_object.plan_df[ras_object.plan_df['plan_number'] == plan_num]
|
|
267
|
+
if plan_row.empty:
|
|
268
|
+
raise ValueError(f"Plan '{plan_num}' not found in project")
|
|
269
|
+
|
|
270
|
+
plan_name = plan_row['Plan Title'].iloc[0]
|
|
271
|
+
|
|
272
|
+
return project_path, version, plan_num, plan_name
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _com_open_close(project_path: Path, version: str, operation_func: Callable[[Any], Any]) -> Any:
|
|
276
|
+
"""
|
|
277
|
+
PRIVATE: Open HEC-RAS via COM, run operation, close HEC-RAS.
|
|
278
|
+
|
|
279
|
+
This is the core COM interface handler. All public methods use this.
|
|
280
|
+
"""
|
|
281
|
+
# Normalize version (handles "6.6" → "6.6", "66" → "6.6", etc.)
|
|
282
|
+
normalized_version = RasControl._normalize_version(version)
|
|
283
|
+
|
|
284
|
+
if not project_path.exists():
|
|
285
|
+
raise FileNotFoundError(f"Project file not found: {project_path}")
|
|
286
|
+
|
|
287
|
+
com_rc = None
|
|
288
|
+
result = None
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
# Open HEC-RAS COM interface
|
|
292
|
+
com_string = RasControl.VERSION_MAP[normalized_version]
|
|
293
|
+
logger.info(f"Opening HEC-RAS: {com_string} (version: {version})")
|
|
294
|
+
com_rc = win32com.client.Dispatch(com_string)
|
|
295
|
+
|
|
296
|
+
# Open project
|
|
297
|
+
logger.info(f"Opening project: {project_path}")
|
|
298
|
+
com_rc.Project_Open(str(project_path))
|
|
299
|
+
|
|
300
|
+
# Perform operation
|
|
301
|
+
logger.info("Executing operation...")
|
|
302
|
+
result = operation_func(com_rc)
|
|
303
|
+
logger.info("Operation completed successfully")
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error(f"Operation failed: {e}")
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
finally:
|
|
312
|
+
# ALWAYS close
|
|
313
|
+
logger.info("Closing HEC-RAS...")
|
|
314
|
+
|
|
315
|
+
if com_rc is not None:
|
|
316
|
+
try:
|
|
317
|
+
com_rc.QuitRas()
|
|
318
|
+
logger.info("HEC-RAS closed via QuitRas()")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.warning(f"QuitRas() failed: {e}")
|
|
321
|
+
|
|
322
|
+
_terminate_ras_process()
|
|
323
|
+
|
|
324
|
+
if _is_ras_running():
|
|
325
|
+
logger.warning("HEC-RAS may still be running!")
|
|
326
|
+
else:
|
|
327
|
+
logger.info("HEC-RAS fully closed")
|
|
328
|
+
|
|
329
|
+
# ========== PUBLIC API (ras-commander style) ==========
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def run_plan(plan: Union[str, Path], ras_object=None) -> Tuple[bool, List[str]]:
|
|
333
|
+
"""
|
|
334
|
+
Run a plan (steady or unsteady) and wait for completion.
|
|
335
|
+
|
|
336
|
+
This method starts the computation and polls Compute_Complete()
|
|
337
|
+
until the run finishes. It will block until completion.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
plan: Plan number ("01", "02") or path to .prj file
|
|
341
|
+
ras_object: Optional RasPrj instance (uses global ras if None)
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Tuple of (success: bool, messages: List[str])
|
|
345
|
+
|
|
346
|
+
Example:
|
|
347
|
+
>>> from ras_commander import init_ras_project, RasControl
|
|
348
|
+
>>> init_ras_project(path, "4.1")
|
|
349
|
+
>>> success, msgs = RasControl.run_plan("02")
|
|
350
|
+
>>> # Blocks until plan finishes running
|
|
351
|
+
|
|
352
|
+
Note:
|
|
353
|
+
Can take several minutes for large models or unsteady runs.
|
|
354
|
+
Progress is logged every 30 seconds.
|
|
355
|
+
"""
|
|
356
|
+
project_path, version, plan_num, plan_name = RasControl._get_project_info(plan, ras_object)
|
|
357
|
+
|
|
358
|
+
def _run_operation(com_rc):
|
|
359
|
+
# Set current plan if we have plan_name (using plan number)
|
|
360
|
+
if plan_name:
|
|
361
|
+
logger.info(f"Setting current plan to: {plan_name}")
|
|
362
|
+
com_rc.Plan_SetCurrent(plan_name)
|
|
363
|
+
|
|
364
|
+
# Version-specific behavior (normalize for checking)
|
|
365
|
+
norm_version = RasControl._normalize_version(version)
|
|
366
|
+
|
|
367
|
+
# Start computation (returns immediately - ASYNCHRONOUS!)
|
|
368
|
+
logger.info("Starting computation...")
|
|
369
|
+
if norm_version.startswith('4') or norm_version.startswith('3'):
|
|
370
|
+
status, _, messages = com_rc.Compute_CurrentPlan(None, None)
|
|
371
|
+
else:
|
|
372
|
+
status, _, messages, _ = com_rc.Compute_CurrentPlan(None, None)
|
|
373
|
+
|
|
374
|
+
# CRITICAL: Wait for computation to complete
|
|
375
|
+
# Compute_CurrentPlan is ASYNCHRONOUS - it returns before computation finishes
|
|
376
|
+
logger.info("Waiting for computation to complete...")
|
|
377
|
+
poll_count = 0
|
|
378
|
+
while True:
|
|
379
|
+
try:
|
|
380
|
+
# Check if computation is complete
|
|
381
|
+
is_complete = com_rc.Compute_Complete()
|
|
382
|
+
|
|
383
|
+
if is_complete:
|
|
384
|
+
logger.info(f"Computation completed (polled {poll_count} times)")
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
# Still computing - wait and poll again
|
|
388
|
+
time.sleep(1) # Poll every second
|
|
389
|
+
poll_count += 1
|
|
390
|
+
|
|
391
|
+
# Log progress every 30 seconds
|
|
392
|
+
if poll_count % 30 == 0:
|
|
393
|
+
logger.info(f"Still computing... ({poll_count} seconds elapsed)")
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
logger.error(f"Error checking completion status: {e}")
|
|
397
|
+
# If we can't check status, break and hope for the best
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
return status, list(messages) if messages else []
|
|
401
|
+
|
|
402
|
+
return RasControl._com_open_close(project_path, version, _run_operation)
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def get_steady_results(plan: Union[str, Path], ras_object=None) -> pd.DataFrame:
|
|
406
|
+
"""
|
|
407
|
+
Extract steady state profile results.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
plan: Plan number ("01", "02") or path to .prj file
|
|
411
|
+
ras_object: Optional RasPrj instance (uses global ras if None)
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
DataFrame with columns: river, reach, node_id, profile, wsel,
|
|
415
|
+
velocity, flow, froude, energy, max_depth, min_ch_el
|
|
416
|
+
|
|
417
|
+
Example:
|
|
418
|
+
>>> from ras_commander import init_ras_project, RasControl
|
|
419
|
+
>>> init_ras_project(path, "4.1")
|
|
420
|
+
>>> df = RasControl.get_steady_results("02")
|
|
421
|
+
>>> df.to_csv('results.csv', index=False)
|
|
422
|
+
"""
|
|
423
|
+
project_path, version, plan_num, plan_name = RasControl._get_project_info(plan, ras_object)
|
|
424
|
+
|
|
425
|
+
def _extract_operation(com_rc):
|
|
426
|
+
# Set current plan if we have plan_name (using plan number)
|
|
427
|
+
if plan_name:
|
|
428
|
+
logger.info(f"Setting current plan to: {plan_name}")
|
|
429
|
+
com_rc.Plan_SetCurrent(plan_name)
|
|
430
|
+
|
|
431
|
+
results = []
|
|
432
|
+
|
|
433
|
+
# Get profiles
|
|
434
|
+
_, profile_names = com_rc.Output_GetProfiles(2, None)
|
|
435
|
+
|
|
436
|
+
if profile_names is None:
|
|
437
|
+
raise RuntimeError(
|
|
438
|
+
"No steady state results found. Please ensure:\n"
|
|
439
|
+
" 1. The model has been run (use RasControl.run_plan() first)\n"
|
|
440
|
+
" 2. The current plan is a steady state plan\n"
|
|
441
|
+
" 3. Results were successfully computed"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
profiles = [{'name': name, 'code': i+1} for i, name in enumerate(profile_names)]
|
|
445
|
+
logger.info(f"Found {len(profiles)} profiles")
|
|
446
|
+
|
|
447
|
+
# Get rivers
|
|
448
|
+
_, river_names = com_rc.Output_GetRivers(0, None)
|
|
449
|
+
|
|
450
|
+
if river_names is None:
|
|
451
|
+
raise RuntimeError("No river geometry found in model.")
|
|
452
|
+
|
|
453
|
+
logger.info(f"Found {len(river_names)} rivers")
|
|
454
|
+
|
|
455
|
+
# Extract data
|
|
456
|
+
for riv_code, riv_name in enumerate(river_names, start=1):
|
|
457
|
+
_, _, reach_names = com_rc.Geometry_GetReaches(riv_code, None, None)
|
|
458
|
+
|
|
459
|
+
for rch_code, rch_name in enumerate(reach_names, start=1):
|
|
460
|
+
_, _, _, node_ids, node_types = com_rc.Geometry_GetNodes(
|
|
461
|
+
riv_code, rch_code, None, None, None
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
for node_code, (node_id, node_type) in enumerate(
|
|
465
|
+
zip(node_ids, node_types), start=1
|
|
466
|
+
):
|
|
467
|
+
if node_type == '': # Cross sections only
|
|
468
|
+
for profile in profiles:
|
|
469
|
+
try:
|
|
470
|
+
row = {
|
|
471
|
+
'river': riv_name.strip(),
|
|
472
|
+
'reach': rch_name.strip(),
|
|
473
|
+
'node_id': node_id.strip(),
|
|
474
|
+
'profile': profile['name'].strip(),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
# Extract output variables
|
|
478
|
+
row['wsel'] = com_rc.Output_NodeOutput(
|
|
479
|
+
riv_code, rch_code, node_code, 0,
|
|
480
|
+
profile['code'], RasControl.WSEL
|
|
481
|
+
)[0]
|
|
482
|
+
|
|
483
|
+
row['min_ch_el'] = com_rc.Output_NodeOutput(
|
|
484
|
+
riv_code, rch_code, node_code, 0,
|
|
485
|
+
profile['code'], RasControl.MIN_CH_EL
|
|
486
|
+
)[0]
|
|
487
|
+
|
|
488
|
+
row['velocity'] = com_rc.Output_NodeOutput(
|
|
489
|
+
riv_code, rch_code, node_code, 0,
|
|
490
|
+
profile['code'], RasControl.VEL_TOTAL
|
|
491
|
+
)[0]
|
|
492
|
+
|
|
493
|
+
row['flow'] = com_rc.Output_NodeOutput(
|
|
494
|
+
riv_code, rch_code, node_code, 0,
|
|
495
|
+
profile['code'], RasControl.FLOW_TOTAL
|
|
496
|
+
)[0]
|
|
497
|
+
|
|
498
|
+
row['froude'] = com_rc.Output_NodeOutput(
|
|
499
|
+
riv_code, rch_code, node_code, 0,
|
|
500
|
+
profile['code'], RasControl.FROUDE_CHL
|
|
501
|
+
)[0]
|
|
502
|
+
|
|
503
|
+
row['energy'] = com_rc.Output_NodeOutput(
|
|
504
|
+
riv_code, rch_code, node_code, 0,
|
|
505
|
+
profile['code'], RasControl.ENERGY
|
|
506
|
+
)[0]
|
|
507
|
+
|
|
508
|
+
row['max_depth'] = com_rc.Output_NodeOutput(
|
|
509
|
+
riv_code, rch_code, node_code, 0,
|
|
510
|
+
profile['code'], RasControl.MAX_CHL_DPTH
|
|
511
|
+
)[0]
|
|
512
|
+
|
|
513
|
+
results.append(row)
|
|
514
|
+
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.warning(
|
|
517
|
+
f"Failed to extract {riv_name}/{rch_name}/"
|
|
518
|
+
f"{node_id} profile {profile['name']}: {e}"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
logger.info(f"Extracted {len(results)} result rows")
|
|
522
|
+
return pd.DataFrame(results)
|
|
523
|
+
|
|
524
|
+
return RasControl._com_open_close(project_path, version, _extract_operation)
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def get_unsteady_results(plan: Union[str, Path], max_times: int = None,
|
|
528
|
+
ras_object=None) -> pd.DataFrame:
|
|
529
|
+
"""
|
|
530
|
+
Extract unsteady flow time series results.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
plan: Plan number ("01", "02") or path to .prj file
|
|
534
|
+
max_times: Optional limit on timesteps (for testing/large datasets)
|
|
535
|
+
ras_object: Optional RasPrj instance (uses global ras if None)
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
DataFrame with columns: river, reach, node_id, time_index, time_string,
|
|
539
|
+
wsel, velocity, flow, froude, energy, max_depth, min_ch_el
|
|
540
|
+
|
|
541
|
+
Important - Understanding "Max WS":
|
|
542
|
+
The first row (time_index=1, time_string="Max WS") contains the MAXIMUM
|
|
543
|
+
values that occurred at ANY computational timestep during the simulation,
|
|
544
|
+
not just at output intervals. This is critical data for identifying peaks.
|
|
545
|
+
|
|
546
|
+
For time series plotting, filter to actual timesteps:
|
|
547
|
+
df_timeseries = df[df['time_string'] != 'Max WS']
|
|
548
|
+
|
|
549
|
+
For showing maximum as reference line:
|
|
550
|
+
max_wse = df[df['time_string'] == 'Max WS']['wsel'].iloc[0]
|
|
551
|
+
plt.axhline(max_wse, color='r', linestyle='--', label='Max WS')
|
|
552
|
+
|
|
553
|
+
Example:
|
|
554
|
+
>>> from ras_commander import init_ras_project, RasControl
|
|
555
|
+
>>> init_ras_project(path, "4.1")
|
|
556
|
+
>>> df = RasControl.get_unsteady_results("01", max_times=10)
|
|
557
|
+
>>> # Includes "Max WS" as first row
|
|
558
|
+
"""
|
|
559
|
+
project_path, version, plan_num, plan_name = RasControl._get_project_info(plan, ras_object)
|
|
560
|
+
|
|
561
|
+
def _extract_operation(com_rc):
|
|
562
|
+
# Set current plan if we have plan_name (using plan number)
|
|
563
|
+
if plan_name:
|
|
564
|
+
logger.info(f"Setting current plan to: {plan_name}")
|
|
565
|
+
com_rc.Plan_SetCurrent(plan_name)
|
|
566
|
+
|
|
567
|
+
results = []
|
|
568
|
+
|
|
569
|
+
# Get output times
|
|
570
|
+
_, time_strings = com_rc.Output_GetProfiles(0, None)
|
|
571
|
+
|
|
572
|
+
if time_strings is None:
|
|
573
|
+
raise RuntimeError(
|
|
574
|
+
"No unsteady results found. Please ensure:\n"
|
|
575
|
+
" 1. The model has been run (use RasControl.run_plan() first)\n"
|
|
576
|
+
" 2. The current plan is an unsteady flow plan\n"
|
|
577
|
+
" 3. Results were successfully computed"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
times = list(time_strings)
|
|
581
|
+
if max_times:
|
|
582
|
+
times = times[:max_times]
|
|
583
|
+
|
|
584
|
+
logger.info(f"Extracting {len(times)} time steps")
|
|
585
|
+
|
|
586
|
+
# Get rivers
|
|
587
|
+
_, river_names = com_rc.Output_GetRivers(0, None)
|
|
588
|
+
|
|
589
|
+
if river_names is None:
|
|
590
|
+
raise RuntimeError("No river geometry found in model.")
|
|
591
|
+
|
|
592
|
+
logger.info(f"Found {len(river_names)} rivers")
|
|
593
|
+
|
|
594
|
+
# Extract data
|
|
595
|
+
for riv_code, riv_name in enumerate(river_names, start=1):
|
|
596
|
+
_, _, reach_names = com_rc.Geometry_GetReaches(riv_code, None, None)
|
|
597
|
+
|
|
598
|
+
for rch_code, rch_name in enumerate(reach_names, start=1):
|
|
599
|
+
_, _, _, node_ids, node_types = com_rc.Geometry_GetNodes(
|
|
600
|
+
riv_code, rch_code, None, None, None
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
for node_code, (node_id, node_type) in enumerate(
|
|
604
|
+
zip(node_ids, node_types), start=1
|
|
605
|
+
):
|
|
606
|
+
if node_type == '': # Cross sections only
|
|
607
|
+
for time_idx, time_str in enumerate(times, start=1):
|
|
608
|
+
try:
|
|
609
|
+
row = {
|
|
610
|
+
'river': riv_name.strip(),
|
|
611
|
+
'reach': rch_name.strip(),
|
|
612
|
+
'node_id': node_id.strip(),
|
|
613
|
+
'time_index': time_idx,
|
|
614
|
+
'time_string': time_str.strip(),
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
# Extract output variables (time_idx is profile code for unsteady)
|
|
618
|
+
row['wsel'] = com_rc.Output_NodeOutput(
|
|
619
|
+
riv_code, rch_code, node_code, 0,
|
|
620
|
+
time_idx, RasControl.WSEL
|
|
621
|
+
)[0]
|
|
622
|
+
|
|
623
|
+
row['min_ch_el'] = com_rc.Output_NodeOutput(
|
|
624
|
+
riv_code, rch_code, node_code, 0,
|
|
625
|
+
time_idx, RasControl.MIN_CH_EL
|
|
626
|
+
)[0]
|
|
627
|
+
|
|
628
|
+
row['velocity'] = com_rc.Output_NodeOutput(
|
|
629
|
+
riv_code, rch_code, node_code, 0,
|
|
630
|
+
time_idx, RasControl.VEL_TOTAL
|
|
631
|
+
)[0]
|
|
632
|
+
|
|
633
|
+
row['flow'] = com_rc.Output_NodeOutput(
|
|
634
|
+
riv_code, rch_code, node_code, 0,
|
|
635
|
+
time_idx, RasControl.FLOW_TOTAL
|
|
636
|
+
)[0]
|
|
637
|
+
|
|
638
|
+
row['froude'] = com_rc.Output_NodeOutput(
|
|
639
|
+
riv_code, rch_code, node_code, 0,
|
|
640
|
+
time_idx, RasControl.FROUDE_CHL
|
|
641
|
+
)[0]
|
|
642
|
+
|
|
643
|
+
row['energy'] = com_rc.Output_NodeOutput(
|
|
644
|
+
riv_code, rch_code, node_code, 0,
|
|
645
|
+
time_idx, RasControl.ENERGY
|
|
646
|
+
)[0]
|
|
647
|
+
|
|
648
|
+
row['max_depth'] = com_rc.Output_NodeOutput(
|
|
649
|
+
riv_code, rch_code, node_code, 0,
|
|
650
|
+
time_idx, RasControl.MAX_CHL_DPTH
|
|
651
|
+
)[0]
|
|
652
|
+
|
|
653
|
+
results.append(row)
|
|
654
|
+
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.warning(
|
|
657
|
+
f"Failed to extract {riv_name}/{rch_name}/"
|
|
658
|
+
f"{node_id} time {time_str}: {e}"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
logger.info(f"Extracted {len(results)} result rows")
|
|
662
|
+
return pd.DataFrame(results)
|
|
663
|
+
|
|
664
|
+
return RasControl._com_open_close(project_path, version, _extract_operation)
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def get_output_times(plan: Union[str, Path], ras_object=None) -> List[str]:
|
|
668
|
+
"""
|
|
669
|
+
Get list of output times for unsteady run.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
plan: Plan number ("01", "02") or path to .prj file
|
|
673
|
+
ras_object: Optional RasPrj instance (uses global ras if None)
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
List of time strings (e.g., ["01JAN2000 0000", ...])
|
|
677
|
+
|
|
678
|
+
Example:
|
|
679
|
+
>>> times = RasControl.get_output_times("01")
|
|
680
|
+
>>> print(f"Found {len(times)} output times")
|
|
681
|
+
"""
|
|
682
|
+
project_path, version, plan_num, plan_name = RasControl._get_project_info(plan, ras_object)
|
|
683
|
+
|
|
684
|
+
def _get_times(com_rc):
|
|
685
|
+
# Set current plan if we have plan_name (using plan number)
|
|
686
|
+
if plan_name:
|
|
687
|
+
logger.info(f"Setting current plan to: {plan_name}")
|
|
688
|
+
com_rc.Plan_SetCurrent(plan_name)
|
|
689
|
+
|
|
690
|
+
_, time_strings = com_rc.Output_GetProfiles(0, None)
|
|
691
|
+
|
|
692
|
+
if time_strings is None:
|
|
693
|
+
raise RuntimeError(
|
|
694
|
+
"No unsteady output times found. Ensure plan has been run."
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
times = list(time_strings)
|
|
698
|
+
logger.info(f"Found {len(times)} output times")
|
|
699
|
+
return times
|
|
700
|
+
|
|
701
|
+
return RasControl._com_open_close(project_path, version, _get_times)
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def get_plans(plan: Union[str, Path], ras_object=None) -> List[dict]:
|
|
705
|
+
"""
|
|
706
|
+
Get list of plans in project.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
plan: Plan number or path to .prj file
|
|
710
|
+
ras_object: Optional RasPrj instance
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
List of dicts with 'name' and 'filename' keys
|
|
714
|
+
"""
|
|
715
|
+
project_path, version, _, _ = RasControl._get_project_info(plan, ras_object)
|
|
716
|
+
|
|
717
|
+
def _get_plans(com_rc):
|
|
718
|
+
# Don't set current plan - just getting list
|
|
719
|
+
_, plan_names, _ = com_rc.Plan_Names(None, None, None)
|
|
720
|
+
|
|
721
|
+
plans = []
|
|
722
|
+
for name in plan_names:
|
|
723
|
+
filename, _ = com_rc.Plan_GetFilename(name)
|
|
724
|
+
plans.append({'name': name, 'filename': filename})
|
|
725
|
+
|
|
726
|
+
logger.info(f"Found {len(plans)} plans")
|
|
727
|
+
return plans
|
|
728
|
+
|
|
729
|
+
return RasControl._com_open_close(project_path, version, _get_plans)
|
|
730
|
+
|
|
731
|
+
@staticmethod
|
|
732
|
+
def set_current_plan(plan: Union[str, Path], ras_object=None) -> bool:
|
|
733
|
+
"""
|
|
734
|
+
Set the current/active plan by plan number.
|
|
735
|
+
|
|
736
|
+
Note: This is rarely needed - run_plan() and get_*_results()
|
|
737
|
+
automatically set the correct plan. This is provided for
|
|
738
|
+
advanced use cases.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
plan: Plan number ("01", "02") or path to .prj file
|
|
742
|
+
ras_object: Optional RasPrj instance
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
True if successful
|
|
746
|
+
|
|
747
|
+
Example:
|
|
748
|
+
>>> RasControl.set_current_plan("02") # Set to Plan 02
|
|
749
|
+
"""
|
|
750
|
+
project_path, version, plan_num, plan_name = RasControl._get_project_info(plan, ras_object)
|
|
751
|
+
|
|
752
|
+
if not plan_name:
|
|
753
|
+
raise ValueError("Cannot set current plan - plan name could not be determined")
|
|
754
|
+
|
|
755
|
+
def _set_plan(com_rc):
|
|
756
|
+
com_rc.Plan_SetCurrent(plan_name)
|
|
757
|
+
logger.info(f"Set current plan to Plan {plan_num}: {plan_name}")
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
return RasControl._com_open_close(project_path, version, _set_plan)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
if __name__ == '__main__':
|
|
764
|
+
logging.basicConfig(
|
|
765
|
+
level=logging.INFO,
|
|
766
|
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
print("RasControl (ras-commander API) loaded successfully")
|
|
770
|
+
print(f"Supported versions: {list(RasControl.SUPPORTED_VERSIONS.keys())}")
|
|
771
|
+
print("\nUsage example:")
|
|
772
|
+
print(" from ras_commander import init_ras_project, RasControl")
|
|
773
|
+
print(" init_ras_project(path, '4.1')")
|
|
774
|
+
print(" df = RasControl.get_steady_results('02')")
|