ras-commander 0.81.0__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')")
ras_commander/RasPlan.py CHANGED
@@ -652,29 +652,44 @@ class RasPlan:
652
652
 
653
653
  @staticmethod
654
654
  @log_call
655
- def clone_plan(template_plan, new_plan_shortid=None, ras_object=None):
655
+ def clone_plan(template_plan, new_shortid=None, new_title=None, ras_object=None):
656
656
  """
657
657
  Create a new plan file based on a template and update the project file.
658
-
658
+
659
659
  Parameters:
660
660
  template_plan (str): Plan number to use as template (e.g., '01')
661
- new_plan_shortid (str, optional): New short identifier for the plan file
661
+ new_shortid (str, optional): New short identifier for the plan file (max 24 chars).
662
+ If not provided, appends '_copy' to original.
663
+ new_title (str, optional): New plan title (max 32 chars, updates "Plan Title=" line).
664
+ If not provided, keeps original title.
662
665
  ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
663
-
666
+
664
667
  Returns:
665
668
  str: New plan number
666
-
669
+
667
670
  Example:
668
- >>> ras_plan = RasPlan()
669
- >>> new_plan_number = ras_plan.clone_plan('01', new_plan_shortid='New Plan')
670
- >>> print(f"New plan created with number: {new_plan_number}")
671
+ >>> # Clone with default shortid and title
672
+ >>> new_plan = RasPlan.clone_plan('01')
673
+ >>>
674
+ >>> # Clone with custom shortid and title
675
+ >>> new_plan = RasPlan.clone_plan('01',
676
+ ... new_shortid='Steady_v41',
677
+ ... new_title='Steady Flow - HEC-RAS 4.1')
671
678
 
672
679
  Note:
680
+ Both new_shortid and new_title are optional.
673
681
  This function updates the ras object's dataframes after modifying the project structure.
674
682
  """
675
683
  ras_obj = ras_object or ras
676
684
  ras_obj.check_initialized()
677
685
 
686
+ # Validate new_title length if provided
687
+ if new_title is not None and len(new_title) > 32:
688
+ raise ValueError(
689
+ f"Plan title must be 32 characters or less. "
690
+ f"Got {len(new_title)} characters: '{new_title}'"
691
+ )
692
+
678
693
  # Update plan entries without reinitializing the entire project
679
694
  ras_obj.plan_df = ras_obj.get_prj_entries('Plan')
680
695
 
@@ -682,22 +697,32 @@ class RasPlan:
682
697
  template_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{template_plan}"
683
698
  new_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{new_plan_num}"
684
699
 
685
- def update_shortid(lines):
700
+ def update_plan_metadata(lines):
701
+ """Update both Plan Title and Short Identifier"""
702
+ title_pattern = re.compile(r'^Plan Title=(.*)$', re.IGNORECASE)
686
703
  shortid_pattern = re.compile(r'^Short Identifier=(.*)$', re.IGNORECASE)
704
+
687
705
  for i, line in enumerate(lines):
688
- match = shortid_pattern.match(line.strip())
689
- if match:
690
- current_shortid = match.group(1)
691
- if new_plan_shortid is None:
692
- new_shortid = (current_shortid + "_copy")[:24]
706
+ # Update Plan Title if new_title provided
707
+ title_match = title_pattern.match(line.strip())
708
+ if title_match and new_title is not None:
709
+ lines[i] = f"Plan Title={new_title[:32]}\n"
710
+ continue
711
+
712
+ # Update Short Identifier
713
+ shortid_match = shortid_pattern.match(line.strip())
714
+ if shortid_match:
715
+ current_shortid = shortid_match.group(1)
716
+ if new_shortid is None:
717
+ new_shortid_value = (current_shortid + "_copy")[:24]
693
718
  else:
694
- new_shortid = new_plan_shortid[:24]
695
- lines[i] = f"Short Identifier={new_shortid}\n"
696
- break
719
+ new_shortid_value = new_shortid[:24]
720
+ lines[i] = f"Short Identifier={new_shortid_value}\n"
721
+
697
722
  return lines
698
723
 
699
- # Use RasUtils to clone the file and update the short identifier
700
- RasUtils.clone_file(template_plan_path, new_plan_path, update_shortid)
724
+ # Use RasUtils to clone the file and update metadata
725
+ RasUtils.clone_file(template_plan_path, new_plan_path, update_plan_metadata)
701
726
 
702
727
  # Use RasUtils to update the project file
703
728
  RasUtils.update_project_file(ras_obj.prj_file, 'Plan', new_plan_num, ras_object=ras_obj)
@@ -714,21 +739,22 @@ class RasPlan:
714
739
 
715
740
  @staticmethod
716
741
  @log_call
717
- def clone_unsteady(template_unsteady, ras_object=None):
742
+ def clone_unsteady(template_unsteady, new_title=None, ras_object=None):
718
743
  """
719
744
  Copy unsteady flow files from a template, find the next unsteady number,
720
745
  and update the project file accordingly.
721
746
 
722
747
  Parameters:
723
748
  template_unsteady (str): Unsteady flow number to be used as a template (e.g., '01')
749
+ new_title (str, optional): New flow title (max 32 chars, updates "Flow Title=" line)
724
750
  ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
725
751
 
726
752
  Returns:
727
753
  str: New unsteady flow number (e.g., '03')
728
754
 
729
755
  Example:
730
- >>> ras_plan = RasPlan()
731
- >>> new_unsteady_num = ras_plan.clone_unsteady('01')
756
+ >>> new_unsteady_num = RasPlan.clone_unsteady('01',
757
+ ... new_title='Unsteady - HEC-RAS 4.1')
732
758
  >>> print(f"New unsteady flow file created: u{new_unsteady_num}")
733
759
 
734
760
  Note:
@@ -737,6 +763,13 @@ class RasPlan:
737
763
  ras_obj = ras_object or ras
738
764
  ras_obj.check_initialized()
739
765
 
766
+ # Validate new_title length if provided
767
+ if new_title is not None and len(new_title) > 32:
768
+ raise ValueError(
769
+ f"Flow title must be 32 characters or less. "
770
+ f"Got {len(new_title)} characters: '{new_title}'"
771
+ )
772
+
740
773
  # Update unsteady entries without reinitializing the entire project
741
774
  ras_obj.unsteady_df = ras_obj.get_prj_entries('Unsteady')
742
775
 
@@ -744,8 +777,21 @@ class RasPlan:
744
777
  template_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}"
745
778
  new_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_num}"
746
779
 
747
- # Use RasUtils to clone the file
748
- RasUtils.clone_file(template_unsteady_path, new_unsteady_path)
780
+ def update_flow_title(lines):
781
+ """Update Flow Title if new_title provided"""
782
+ if new_title is None:
783
+ return lines
784
+
785
+ title_pattern = re.compile(r'^Flow Title=(.*)$', re.IGNORECASE)
786
+ for i, line in enumerate(lines):
787
+ title_match = title_pattern.match(line.strip())
788
+ if title_match:
789
+ lines[i] = f"Flow Title={new_title[:32]}\n"
790
+ break
791
+ return lines
792
+
793
+ # Use RasUtils to clone the file and update flow title
794
+ RasUtils.clone_file(template_unsteady_path, new_unsteady_path, update_flow_title)
749
795
 
750
796
  # Copy the corresponding .hdf file if it exists
751
797
  template_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}.hdf"
@@ -769,21 +815,22 @@ class RasPlan:
769
815
 
770
816
  @staticmethod
771
817
  @log_call
772
- def clone_steady(template_flow, ras_object=None):
818
+ def clone_steady(template_flow, new_title=None, ras_object=None):
773
819
  """
774
820
  Copy steady flow files from a template, find the next flow number,
775
821
  and update the project file accordingly.
776
-
822
+
777
823
  Parameters:
778
824
  template_flow (str): Flow number to be used as a template (e.g., '01')
825
+ new_title (str, optional): New flow title (max 32 chars, updates "Flow Title=" line)
779
826
  ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
780
-
827
+
781
828
  Returns:
782
829
  str: New flow number (e.g., '03')
783
830
 
784
831
  Example:
785
- >>> ras_plan = RasPlan()
786
- >>> new_flow_num = ras_plan.clone_steady('01')
832
+ >>> new_flow_num = RasPlan.clone_steady('01',
833
+ ... new_title='Steady Flow - HEC-RAS 4.1')
787
834
  >>> print(f"New steady flow file created: f{new_flow_num}")
788
835
 
789
836
  Note:
@@ -792,6 +839,13 @@ class RasPlan:
792
839
  ras_obj = ras_object or ras
793
840
  ras_obj.check_initialized()
794
841
 
842
+ # Validate new_title length if provided
843
+ if new_title is not None and len(new_title) > 32:
844
+ raise ValueError(
845
+ f"Flow title must be 32 characters or less. "
846
+ f"Got {len(new_title)} characters: '{new_title}'"
847
+ )
848
+
795
849
  # Update flow entries without reinitializing the entire project
796
850
  ras_obj.flow_df = ras_obj.get_prj_entries('Flow')
797
851
 
@@ -799,8 +853,21 @@ class RasPlan:
799
853
  template_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{template_flow}"
800
854
  new_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{new_flow_num}"
801
855
 
802
- # Use RasUtils to clone the file
803
- RasUtils.clone_file(template_flow_path, new_flow_path)
856
+ def update_flow_title(lines):
857
+ """Update Flow Title if new_title provided"""
858
+ if new_title is None:
859
+ return lines
860
+
861
+ title_pattern = re.compile(r'^Flow Title=(.*)$', re.IGNORECASE)
862
+ for i, line in enumerate(lines):
863
+ title_match = title_pattern.match(line.strip())
864
+ if title_match:
865
+ lines[i] = f"Flow Title={new_title[:32]}\n"
866
+ break
867
+ return lines
868
+
869
+ # Use RasUtils to clone the file and update flow title
870
+ RasUtils.clone_file(template_flow_path, new_flow_path, update_flow_title)
804
871
 
805
872
  # Use RasUtils to update the project file
806
873
  RasUtils.update_project_file(ras_obj.prj_file, 'Flow', new_flow_num, ras_object=ras_obj)
ras_commander/RasPrj.py CHANGED
@@ -1452,12 +1452,17 @@ def init_ras_project(ras_project_folder, ras_version=None, ras_object=None):
1452
1452
  else:
1453
1453
  ras_object.initialize(project_folder, ras_exe_path)
1454
1454
 
1455
+ # Store version for RasControl (legacy COM interface support)
1456
+ ras_object.ras_version = ras_version if ras_version else detected_version
1457
+
1455
1458
  # Always update the global ras object as well
1456
1459
  if ras_object is not ras:
1457
1460
  if specified_prj_file is not None:
1458
1461
  ras.initialize(project_folder, ras_exe_path, prj_file=specified_prj_file)
1459
1462
  else:
1460
1463
  ras.initialize(project_folder, ras_exe_path)
1464
+ # Also store version in global ras object
1465
+ ras.ras_version = ras_version if ras_version else detected_version
1461
1466
  logger.debug("Global 'ras' object also updated to match the new project.")
1462
1467
 
1463
1468
  logger.debug(f"Project initialized. Project folder: {ras_object.project_folder}")
@@ -1501,60 +1506,92 @@ def get_ras_exe(ras_version=None):
1501
1506
  logger.warning(f"HEC-RAS is not installed or version not specified. Running HEC-RAS will fail unless a valid installed version is specified.")
1502
1507
  return default_path
1503
1508
 
1504
- ras_version_numbers = [
1505
- "6.6", "6.5", "6.4.1", "6.3.1", "6.3", "6.2", "6.1", "6.0",
1509
+ # ACTUAL folder names in C:/Program Files (x86)/HEC/HEC-RAS/
1510
+ # This list matches the exact folder names on disk (verified 2025-10-30)
1511
+ ras_version_folders = [
1512
+ "6.7 Beta 4", "6.6", "6.5", "6.4.1", "6.3.1", "6.3", "6.2", "6.1", "6.0",
1506
1513
  "5.0.7", "5.0.6", "5.0.5", "5.0.4", "5.0.3", "5.0.1", "5.0",
1507
- "4.1", "4.0", "3.1.3", "3.1.2", "3.1.1", "3.0", "2.2"
1514
+ "4.1.0", "4.0"
1508
1515
  ]
1509
-
1516
+
1517
+ # User-friendly aliases (user_input → actual_folder_name)
1518
+ # Allows users to pass "4.1" and find "4.1.0" folder, or "66" to find "6.6"
1519
+ version_aliases = {
1520
+ # 4.x aliases
1521
+ "4.1": "4.1.0", # User passes "4.1" → finds "4.1.0" folder
1522
+ "41": "4.1.0", # Compact format
1523
+ "410": "4.1.0", # Full compact
1524
+ "40": "4.0", # Compact format for 4.0
1525
+
1526
+ # 5.0.x aliases
1527
+ "50": "5.0", # Compact format
1528
+ "501": "5.0.1", # Compact format for 5.0.1
1529
+ "503": "5.0.3", # Compact format
1530
+ "504": "5.0.4", # Compact format for 5.0.4
1531
+ "505": "5.0.5",
1532
+ "506": "5.0.6",
1533
+ "507": "5.0.7",
1534
+
1535
+ # 6.x aliases
1536
+ "60": "6.0",
1537
+ "61": "6.1",
1538
+ "62": "6.2",
1539
+ "63": "6.3",
1540
+ "631": "6.3.1",
1541
+ "6.4": "6.4.1", # No 6.4 folder, use 6.4.1
1542
+ "64": "6.4.1",
1543
+ "641": "6.4.1",
1544
+ "65": "6.5",
1545
+ "66": "6.6",
1546
+ "6.7": "6.7 Beta 4", # User passes "6.7" → finds "6.7 Beta 4"
1547
+ "67": "6.7 Beta 4",
1548
+ }
1549
+
1510
1550
  # Check if input is a direct path to an executable
1511
1551
  hecras_path = Path(ras_version)
1512
1552
  if hecras_path.is_file() and hecras_path.suffix.lower() == '.exe':
1513
1553
  logger.debug(f"HEC-RAS executable found at specified path: {hecras_path}")
1514
1554
  return str(hecras_path)
1515
-
1516
- # Check known version numbers
1517
- if str(ras_version) in ras_version_numbers:
1518
- default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{ras_version}/Ras.exe")
1555
+
1556
+ version_str = str(ras_version)
1557
+
1558
+ # Check if there's an alias for this version
1559
+ if version_str in version_aliases:
1560
+ actual_folder = version_aliases[version_str]
1561
+ logger.debug(f"Mapped version '{version_str}' to folder '{actual_folder}'")
1562
+ version_str = actual_folder
1563
+
1564
+ # Check if this is a known folder name
1565
+ if version_str in ras_version_folders:
1566
+ default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{version_str}/Ras.exe")
1519
1567
  if default_path.is_file():
1520
1568
  logger.debug(f"HEC-RAS executable found at default path: {default_path}")
1521
1569
  return str(default_path)
1522
1570
  else:
1523
- error_msg = f"HEC-RAS Version {ras_version} is not found at expected path. Running HEC-RAS will fail unless a valid installed version is specified."
1571
+ error_msg = f"HEC-RAS Version {version_str} folder exists but Ras.exe not found at expected path. Running HEC-RAS will fail."
1524
1572
  logger.error(error_msg)
1525
1573
  return "Ras.exe"
1526
1574
 
1527
- # Try to handle other version formats (e.g., just the number without dots)
1575
+ # Final fallback: Try to find a matching version from folder list
1528
1576
  try:
1529
- # First check if it's a direct version number
1530
- version_str = str(ras_version)
1531
-
1532
- # Check for paths like "C:/Path/To/Ras.exe"
1533
- if os.path.sep in version_str and version_str.lower().endswith('.exe'):
1534
- exe_path = Path(version_str)
1535
- if exe_path.is_file():
1536
- logger.debug(f"HEC-RAS executable found at specified path: {exe_path}")
1537
- return str(exe_path)
1538
-
1539
1577
  # Try to find a matching version from our list
1540
- for known_version in ras_version_numbers:
1541
- if version_str in known_version or known_version.replace('.', '') == version_str:
1542
- default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{known_version}/Ras.exe")
1578
+ for known_folder in ras_version_folders:
1579
+ # Check for partial matches or compact formats
1580
+ if version_str in known_folder or known_folder.replace('.', '') == version_str:
1581
+ default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{known_folder}/Ras.exe")
1543
1582
  if default_path.is_file():
1544
- logger.debug(f"HEC-RAS executable found at default path: {default_path}")
1583
+ logger.debug(f"HEC-RAS executable found via fuzzy match: {default_path}")
1545
1584
  return str(default_path)
1546
-
1547
- # Check if it's a newer version
1585
+
1586
+ # Try direct path construction for newer versions
1548
1587
  if '.' in version_str:
1549
- major_version = int(version_str.split('.')[0])
1550
- if major_version >= 6:
1551
- default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{version_str}/Ras.exe")
1552
- if default_path.is_file():
1553
- logger.debug(f"HEC-RAS executable found at path for newer version: {default_path}")
1554
- return str(default_path)
1588
+ default_path = Path(f"C:/Program Files (x86)/HEC/HEC-RAS/{version_str}/Ras.exe")
1589
+ if default_path.is_file():
1590
+ logger.debug(f"HEC-RAS executable found at path: {default_path}")
1591
+ return str(default_path)
1555
1592
  except Exception as e:
1556
1593
  logger.error(f"Error parsing version or finding path: {e}")
1557
-
1594
+
1558
1595
  error_msg = f"HEC-RAS Version {ras_version} is not recognized or installed. Running HEC-RAS will fail unless a valid installed version is specified."
1559
1596
  logger.error(error_msg)
1560
1597
  return "Ras.exe"
ras_commander/__init__.py CHANGED
@@ -10,7 +10,7 @@ try:
10
10
  __version__ = version("ras-commander")
11
11
  except PackageNotFoundError:
12
12
  # package is not installed
13
- __version__ = "0.81.0"
13
+ __version__ = "0.82.0"
14
14
 
15
15
  # Set up logging
16
16
  setup_logging()
@@ -23,6 +23,7 @@ from .RasUnsteady import RasUnsteady
23
23
  from .RasUtils import RasUtils
24
24
  from .RasExamples import RasExamples
25
25
  from .RasCmdr import RasCmdr
26
+ from .RasControl import RasControl
26
27
  from .RasMap import RasMap
27
28
  from .HdfFluvialPluvial import HdfFluvialPluvial
28
29
 
@@ -50,7 +51,7 @@ __all__ = [
50
51
  # Core functionality
51
52
  'RasPrj', 'init_ras_project', 'get_ras_exe', 'ras',
52
53
  'RasPlan', 'RasGeo', 'RasUnsteady', 'RasUtils',
53
- 'RasExamples', 'RasCmdr', 'RasMap', 'HdfFluvialPluvial',
54
+ 'RasExamples', 'RasCmdr', 'RasControl', 'RasMap', 'HdfFluvialPluvial',
54
55
 
55
56
  # HDF handling
56
57
  'HdfBase', 'HdfBndry', 'HdfMesh', 'HdfPlan',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ras-commander
3
- Version: 0.81.0
3
+ Version: 0.82.0
4
4
  Summary: A Python library for automating HEC-RAS 6.x operations
5
5
  Home-page: https://github.com/gpt-cmdr/ras-commander
6
6
  Author: William M. Katzenmeyer, P.E., C.F.M.
@@ -21,6 +21,8 @@ Requires-Dist: shapely
21
21
  Requires-Dist: pathlib
22
22
  Requires-Dist: rasterstats
23
23
  Requires-Dist: rtree
24
+ Requires-Dist: pywin32>=227
25
+ Requires-Dist: psutil>=5.6.6
24
26
  Dynamic: author
25
27
  Dynamic: author-email
26
28
  Dynamic: description
@@ -81,6 +83,15 @@ HEC-RAS Project Management & Execution
81
83
  - Progress tracking and logging
82
84
  - Execution error handling and recovery
83
85
 
86
+ Legacy Version Support (NEW in v0.81.0)
87
+ - RasControl class for HEC-RAS 3.x-4.x via COM interface
88
+ - ras-commander style API - use plan numbers, not file paths
89
+ - Extract steady state profiles AND unsteady time series
90
+ - Supports versions: 3.1, 4.1, 5.0.x, 6.0, 6.3, 6.6
91
+ - Version migration validation and comparison
92
+ - Open-operate-close pattern prevents conflicts with modern workflows
93
+ - Seamless integration with ras-commander project management
94
+
84
95
  HDF Data Access & Analysis
85
96
  - 2D mesh results processing (depths, velocities, WSE)
86
97
  - Cross-section data extraction
@@ -115,7 +126,9 @@ RAS ASCII File Operations
115
126
  - Unsteady flow file management
116
127
  - Project file updates and validation
117
128
 
118
- Note about support for Pipe Networks: As a relatively new feature, only read access to Pipe Network geometry and results data has been included. Users will need to code their own methods to modify/add pipe network data, and pull requests are always welcome to incorporate this capability. Please note that the library has not been tested with versions prior to HEC-RAS 6.2.
129
+ Note about support for Pipe Networks: As a relatively new feature, only read access to Pipe Network geometry and results data has been included. Users will need to code their own methods to modify/add pipe network data, and pull requests are always welcome to incorporate this capability.
130
+
131
+ Note about version support: The modern HDF-based features target HEC-RAS 6.2+ for optimal compatibility. For legacy versions (3.1, 4.1, 5.0.x), use the RasControl class which provides COM-based access to steady state profile extraction and plan execution (see example notebook 17).
119
132
 
120
133
  ## Installation
121
134
 
@@ -17,16 +17,17 @@ ras_commander/HdfUtils.py,sha256=VkIKAXBrLwTlk2VtXSO-W3RU-NHpfHbE1QcZUZgl-t8,152
17
17
  ras_commander/HdfXsec.py,sha256=4DuJvzTTtn4zGcf1lv_TyWyRnYRnR_SE-iWFKix5QzM,27776
18
18
  ras_commander/LoggingConfig.py,sha256=gWe5K5XTmMQpSczsTysAqpC9my24i_IyM8dvD85fxYg,2704
19
19
  ras_commander/RasCmdr.py,sha256=37GnchoQ0fIAkPnssnCr1mRUXY8gm-hIMTmuHZlnYP8,34591
20
+ ras_commander/RasControl.py,sha256=OGEgY_Zw9J3qjXjIm9P9CkSaGK2Z_N1LrXHvEJ2O1fo,32396
20
21
  ras_commander/RasExamples.py,sha256=QFWnWnxACpQzewzA3QFMp4z4iEkg5PWf9cPDdMay7MA,24556
21
22
  ras_commander/RasGeo.py,sha256=Wy5N1yP7_St3cA3ENJliojQ2sb2w2dL8Fy8L_sZsykc,22208
22
23
  ras_commander/RasMap.py,sha256=20db61KkUz2CgjfCCYY8F-IYy5doHOtdnTKChiK0ENs,20257
23
- ras_commander/RasPlan.py,sha256=ogIpLqawXTsjLnKRZTqzZydn_EFVJZFZZGgHvJ_t_-c,65408
24
- ras_commander/RasPrj.py,sha256=30y6kJ4jcfX7dE2BB5w7KHIHP1vUVlfB-YWM0xBNhbg,68031
24
+ ras_commander/RasPlan.py,sha256=_aDVD3WmncGKmMGDahTQ_KBIMV0OIpfEUABFt5DcbMs,68630
25
+ ras_commander/RasPrj.py,sha256=OkFClPPwZdFU9nTCP9ytZ1pk0H18pZXT3jEiML9R9Hk,69298
25
26
  ras_commander/RasUnsteady.py,sha256=PdQQMiY7Mz1EsOQk6ygFQtlC2sFEa96Ntg-pznWVpLQ,37187
26
27
  ras_commander/RasUtils.py,sha256=0fm4IIs0LH1dgDj3pGd66mR82DhWLEkRKUvIo2M_5X0,35886
27
- ras_commander/__init__.py,sha256=cceh-WJ7RMhQjPUH7ztMO5sWZDUx5jMsOEtrnOPMYMU,2039
28
- ras_commander-0.81.0.dist-info/licenses/LICENSE,sha256=_pbd6qHnlsz1iQ-ozDW_49r86BZT6CRwO2iBtw0iN6M,457
29
- ras_commander-0.81.0.dist-info/METADATA,sha256=pwshM3miZgeD-5tQAZaBLUb-HKzw5gaCM06OCFrkTgE,27941
30
- ras_commander-0.81.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- ras_commander-0.81.0.dist-info/top_level.txt,sha256=i76S7eKLFC8doKcXDl3aiOr9RwT06G8adI6YuKbQDaA,14
32
- ras_commander-0.81.0.dist-info/RECORD,,
28
+ ras_commander/__init__.py,sha256=cs-SaZXXgyDR0c30577Ay-IlDzi1qAgQmcIWjFnlL6c,2089
29
+ ras_commander-0.82.0.dist-info/licenses/LICENSE,sha256=_pbd6qHnlsz1iQ-ozDW_49r86BZT6CRwO2iBtw0iN6M,457
30
+ ras_commander-0.82.0.dist-info/METADATA,sha256=TuEymRIjnn9mOV80U8thii1GYucRwLt1AbsMkiQjB4c,28653
31
+ ras_commander-0.82.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
+ ras_commander-0.82.0.dist-info/top_level.txt,sha256=i76S7eKLFC8doKcXDl3aiOr9RwT06G8adI6YuKbQDaA,14
33
+ ras_commander-0.82.0.dist-info/RECORD,,