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.
@@ -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')")