ras-commander 0.76.0__py3-none-any.whl → 0.78.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/RasPlan.py CHANGED
@@ -1,1537 +1,1537 @@
1
- """
2
- RasPlan - Operations for handling plan files in HEC-RAS projects
3
-
4
- This module is part of the ras-commander library and uses a centralized logging configuration.
5
-
6
- Logging Configuration:
7
- - The logging is set up in the logging_config.py file.
8
- - A @log_call decorator is available to automatically log function calls.
9
- - Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
10
- - Logs are written to both console and a rotating file handler.
11
- - The default log file is 'ras_commander.log' in the 'logs' directory.
12
- - The default log level is INFO.
13
-
14
- To use logging in this module:
15
- 1. Use the @log_call decorator for automatic function call logging.
16
- 2. For additional logging, use logger.[level]() calls (e.g., logger.info(), logger.debug()).
17
- 3. Obtain the logger using: logger = logging.getLogger(__name__)
18
-
19
- Example:
20
- @log_call
21
- def my_function():
22
- logger = logging.getLogger(__name__)
23
- logger.debug("Additional debug information")
24
- # Function logic here
25
-
26
-
27
- -----
28
-
29
- All of the methods in this class are static and are designed to be used without instantiation.
30
-
31
- List of Functions in RasPlan:
32
- - set_geom(): Set the geometry for a specified plan
33
- - set_steady(): Apply a steady flow file to a plan file
34
- - set_unsteady(): Apply an unsteady flow file to a plan file
35
- - set_num_cores(): Update the maximum number of cores to use
36
- - set_geom_preprocessor(): Update geometry preprocessor settings
37
- - clone_plan(): Create a new plan file based on a template
38
- - clone_unsteady(): Copy unsteady flow files from a template
39
- - clone_steady(): Copy steady flow files from a template
40
- - clone_geom(): Copy geometry files from a template
41
- - get_next_number(): Determine the next available number from a list
42
- - get_plan_value(): Retrieve a specific value from a plan file
43
- - get_results_path(): Get the results file path for a plan
44
- - get_plan_path(): Get the full path for a plan number
45
- - get_flow_path(): Get the full path for a flow number
46
- - get_unsteady_path(): Get the full path for an unsteady number
47
- - get_geom_path(): Get the full path for a geometry number
48
- - update_run_flags(): Update various run flags in a plan file
49
- - update_plan_intervals(): Update computation and output intervals
50
- - update_plan_description(): Update the description in a plan file
51
- - read_plan_description(): Read the description from a plan file
52
- - update_simulation_date(): Update simulation start and end dates
53
- - get_shortid(): Get the Short Identifier from a plan file
54
- - set_shortid(): Set the Short Identifier in a plan file
55
- - get_plan_title(): Get the Plan Title from a plan file
56
- - set_plan_title(): Set the Plan Title in a plan file
57
-
58
-
59
-
60
- """
61
- import os
62
- import re
63
- import logging
64
- from pathlib import Path
65
- import shutil
66
- from typing import Union, Optional
67
- import pandas as pd
68
- from .RasPrj import RasPrj, ras
69
- from .RasUtils import RasUtils
70
- from pathlib import Path
71
- from typing import Union, Any
72
- from datetime import datetime
73
-
74
- import logging
75
- import re
76
- from .LoggingConfig import get_logger
77
- from .Decorators import log_call
78
-
79
- logger = get_logger(__name__)
80
-
81
- class RasPlan:
82
- """
83
- A class for operations on HEC-RAS plan files.
84
- """
85
-
86
- @staticmethod
87
- @log_call
88
- def set_geom(plan_number: Union[str, int], new_geom: Union[str, int], ras_object=None) -> pd.DataFrame:
89
- """
90
- Set the geometry for the specified plan by updating only the plan file.
91
-
92
- Parameters:
93
- plan_number (Union[str, int]): The plan number to update.
94
- new_geom (Union[str, int]): The new geometry number to set.
95
- ras_object: An optional RAS object instance.
96
-
97
- Returns:
98
- pd.DataFrame: The updated geometry DataFrame.
99
-
100
- Example:
101
- updated_geom_df = RasPlan.set_geom('02', '03')
102
-
103
- Note:
104
- This function updates the Geom File= line in the plan file and
105
- updates the ras object's dataframes without modifying the PRJ file.
106
- """
107
- ras_obj = ras_object or ras
108
- ras_obj.check_initialized()
109
-
110
- plan_number = str(plan_number).zfill(2)
111
- new_geom = str(new_geom).zfill(2)
112
-
113
- # Update all dataframes
114
- ras_obj.plan_df = ras_obj.get_plan_entries()
115
- ras_obj.geom_df = ras_obj.get_geom_entries()
116
-
117
- if new_geom not in ras_obj.geom_df['geom_number'].values:
118
- logger.error(f"Geometry {new_geom} not found in project.")
119
- raise ValueError(f"Geometry {new_geom} not found in project.")
120
-
121
- # Get the plan file path
122
- plan_file_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{plan_number}"
123
- if not plan_file_path.exists():
124
- logger.error(f"Plan file not found: {plan_file_path}")
125
- raise ValueError(f"Plan file not found: {plan_file_path}")
126
-
127
- # Read the plan file and update the Geom File line
128
- try:
129
- with open(plan_file_path, 'r') as file:
130
- lines = file.readlines()
131
-
132
- for i, line in enumerate(lines):
133
- if line.startswith("Geom File="):
134
- lines[i] = f"Geom File=g{new_geom}\n"
135
- logger.info(f"Updated Geom File in plan file to g{new_geom} for plan {plan_number}")
136
- break
137
-
138
- with open(plan_file_path, 'w') as file:
139
- file.writelines(lines)
140
- except Exception as e:
141
- logger.error(f"Error updating plan file: {e}")
142
- raise
143
- # Update the plan_df without reinitializing
144
- mask = ras_obj.plan_df['plan_number'] == plan_number
145
- ras_obj.plan_df.loc[mask, 'geom_number'] = new_geom
146
- ras_obj.plan_df.loc[mask, 'geometry_number'] = new_geom # Update geometry_number column
147
- ras_obj.plan_df.loc[mask, 'Geom File'] = f"g{new_geom}"
148
- geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom}"
149
- ras_obj.plan_df.loc[mask, 'Geom Path'] = str(geom_path)
150
-
151
- logger.info(f"Geometry for plan {plan_number} set to {new_geom}")
152
- logger.debug("Updated plan DataFrame:")
153
- logger.debug(ras_obj.plan_df)
154
-
155
- return ras_obj.plan_df
156
-
157
- @staticmethod
158
- @log_call
159
- def set_steady(plan_number: str, new_steady_flow_number: str, ras_object=None):
160
- """
161
- Apply a steady flow file to a plan file.
162
-
163
- Parameters:
164
- plan_number (str): Plan number (e.g., '02')
165
- new_steady_flow_number (str): Steady flow number to apply (e.g., '01')
166
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
167
-
168
- Returns:
169
- None
170
-
171
- Raises:
172
- ValueError: If the specified steady flow number is not found in the project file
173
- FileNotFoundError: If the specified plan file is not found
174
-
175
- Example:
176
- >>> RasPlan.set_steady('02', '01')
177
-
178
- Note:
179
- This function updates the ras object's dataframes after modifying the project structure.
180
- """
181
- ras_obj = ras_object or ras
182
- ras_obj.check_initialized()
183
-
184
- ras_obj.flow_df = ras_obj.get_flow_entries()
185
-
186
- if new_steady_flow_number not in ras_obj.flow_df['flow_number'].values:
187
- raise ValueError(f"Steady flow number {new_steady_flow_number} not found in project file.")
188
-
189
- plan_file_path = RasPlan.get_plan_path(plan_number, ras_obj)
190
- if not plan_file_path:
191
- raise FileNotFoundError(f"Plan file not found: {plan_number}")
192
-
193
- try:
194
- RasUtils.update_file(plan_file_path, RasPlan._update_steady_in_file, new_steady_flow_number)
195
-
196
- # Update all dataframes
197
- ras_obj.plan_df = ras_obj.get_plan_entries()
198
-
199
- # Update flow-related columns
200
- mask = ras_obj.plan_df['plan_number'] == plan_number
201
- flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{new_steady_flow_number}"
202
- ras_obj.plan_df.loc[mask, 'Flow File'] = f"f{new_steady_flow_number}"
203
- ras_obj.plan_df.loc[mask, 'Flow Path'] = str(flow_path)
204
- ras_obj.plan_df.loc[mask, 'unsteady_number'] = None
205
-
206
- # Update remaining dataframes
207
- ras_obj.geom_df = ras_obj.get_geom_entries()
208
- ras_obj.flow_df = ras_obj.get_flow_entries()
209
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
210
-
211
- except Exception as e:
212
- raise IOError(f"Failed to update steady flow file: {e}")
213
-
214
- @staticmethod
215
- def _update_steady_in_file(lines, new_steady_flow_number):
216
- return [f"Flow File=f{new_steady_flow_number}\n" if line.startswith("Flow File=f") else line for line in lines]
217
-
218
- @staticmethod
219
- @log_call
220
- def set_unsteady(plan_number: str, new_unsteady_flow_number: str, ras_object=None):
221
- """
222
- Apply an unsteady flow file to a plan file.
223
-
224
- Parameters:
225
- plan_number (str): Plan number (e.g., '04')
226
- new_unsteady_flow_number (str): Unsteady flow number to apply (e.g., '01')
227
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
228
-
229
- Returns:
230
- None
231
-
232
- Raises:
233
- ValueError: If the specified unsteady number is not found in the project file
234
- FileNotFoundError: If the specified plan file is not found
235
-
236
- Example:
237
- >>> RasPlan.set_unsteady('04', '01')
238
-
239
- Note:
240
- This function updates the ras object's dataframes after modifying the project structure.
241
- """
242
- ras_obj = ras_object or ras
243
- ras_obj.check_initialized()
244
-
245
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
246
-
247
- if new_unsteady_flow_number not in ras_obj.unsteady_df['unsteady_number'].values:
248
- raise ValueError(f"Unsteady number {new_unsteady_flow_number} not found in project file.")
249
-
250
- plan_file_path = RasPlan.get_plan_path(plan_number, ras_obj)
251
- if not plan_file_path:
252
- raise FileNotFoundError(f"Plan file not found: {plan_number}")
253
-
254
- try:
255
- # Read the plan file
256
- with open(plan_file_path, 'r') as f:
257
- lines = f.readlines()
258
-
259
- # Update the Flow File line
260
- for i, line in enumerate(lines):
261
- if line.startswith("Flow File="):
262
- lines[i] = f"Flow File=u{new_unsteady_flow_number}\n"
263
- break
264
-
265
- # Write back to the plan file
266
- with open(plan_file_path, 'w') as f:
267
- f.writelines(lines)
268
-
269
- # Update all dataframes
270
- ras_obj.plan_df = ras_obj.get_plan_entries()
271
-
272
- # Update flow-related columns
273
- mask = ras_obj.plan_df['plan_number'] == plan_number
274
- flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_flow_number}"
275
- ras_obj.plan_df.loc[mask, 'Flow File'] = f"u{new_unsteady_flow_number}"
276
- ras_obj.plan_df.loc[mask, 'Flow Path'] = str(flow_path)
277
- ras_obj.plan_df.loc[mask, 'unsteady_number'] = new_unsteady_flow_number
278
-
279
- # Update remaining dataframes
280
- ras_obj.geom_df = ras_obj.get_geom_entries()
281
- ras_obj.flow_df = ras_obj.get_flow_entries()
282
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
283
-
284
- except Exception as e:
285
- raise IOError(f"Failed to update unsteady flow file: {e}")
286
-
287
- @staticmethod
288
- def _update_unsteady_in_file(lines, new_unsteady_flow_number):
289
- return [f"Unsteady File=u{new_unsteady_flow_number}\n" if line.startswith("Unsteady File=u") else line for line in lines]
290
-
291
- @staticmethod
292
- @log_call
293
- def set_num_cores(plan_number, num_cores, ras_object=None):
294
- """
295
- Update the maximum number of cores to use in the HEC-RAS plan file.
296
-
297
- Parameters:
298
- plan_number (str): Plan number (e.g., '02') or full path to the plan file
299
- num_cores (int): Maximum number of cores to use
300
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
301
-
302
- Returns:
303
- None
304
-
305
- Number of cores is controlled by the following parameters in the plan file corresponding to 1D, 2D, Pipe Systems and Pump Stations:
306
- UNET D1 Cores=
307
- UNET D2 Cores=
308
- PS Cores=
309
-
310
- Where a value of "0" is used for "All Available" cores, and values of 1 or more are used to specify the number of cores to use.
311
- For complex 1D/2D models with pipe systems, a more complex approach may be needed to optimize performance. (Suggest writing a custom function based on this code).
312
- This function simply sets the "num_cores" parameter for ALL instances of the above parameters in the plan file.
313
-
314
-
315
- Notes on setting num_cores in HEC-RAS:
316
- The recommended setting for num_cores is 2 (most efficient) to 8 (most performant)
317
- More details in the HEC-Commander Repository Blog "Benchmarking is All You Need"
318
- https://github.com/billk-FM/HEC-Commander/blob/main/Blog/7._Benchmarking_Is_All_You_Need.md
319
-
320
- Microsoft Windows has a maximum of 64 cores that can be allocated to a single Ras.exe process.
321
-
322
- Example:
323
- >>> # Using plan number
324
- >>> RasPlan.set_num_cores('02', 4)
325
- >>> # Using full path to plan file
326
- >>> RasPlan.set_num_cores('/path/to/project.p02', 4)
327
-
328
- Note:
329
- This function updates the ras object's dataframes after modifying the project structure.
330
- """
331
- ras_obj = ras_object or ras
332
- ras_obj.check_initialized()
333
-
334
- plan_file_path = RasUtils.get_plan_path(plan_number, ras_obj)
335
- if not plan_file_path:
336
- raise FileNotFoundError(f"Plan file not found: {plan_number}. Please provide a valid plan number or path.")
337
-
338
- def update_num_cores(lines):
339
- updated_lines = []
340
- for line in lines:
341
- if any(param in line for param in ["UNET D1 Cores=", "UNET D2 Cores=", "PS Cores="]):
342
- param_name = line.split("=")[0]
343
- updated_lines.append(f"{param_name}= {num_cores}\n")
344
- else:
345
- updated_lines.append(line)
346
- return updated_lines
347
-
348
- try:
349
- RasUtils.update_file(plan_file_path, update_num_cores)
350
- except Exception as e:
351
- raise IOError(f"Failed to update number of cores in plan file: {e}")
352
-
353
- # Update the ras object's dataframes
354
- ras_obj.plan_df = ras_obj.get_plan_entries()
355
- ras_obj.geom_df = ras_obj.get_geom_entries()
356
- ras_obj.flow_df = ras_obj.get_flow_entries()
357
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
358
-
359
- @staticmethod
360
- @log_call
361
- def set_geom_preprocessor(file_path, run_htab, use_ib_tables, ras_object=None):
362
- """
363
- Update the simulation plan file to modify the `Run HTab` and `UNET Use Existing IB Tables` settings.
364
-
365
- Parameters:
366
- file_path (str): Path to the simulation plan file (.p06 or similar) that you want to modify.
367
- run_htab (int): Value for the `Run HTab` setting:
368
- - `0` : Do not run the geometry preprocessor, use existing geometry tables.
369
- - `-1` : Run the geometry preprocessor, forcing a recomputation of the geometry tables.
370
- use_ib_tables (int): Value for the `UNET Use Existing IB Tables` setting:
371
- - `0` : Use existing interpolation/boundary (IB) tables without recomputing them.
372
- - `-1` : Do not use existing IB tables, force a recomputation.
373
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
374
-
375
- Returns:
376
- None
377
-
378
- Raises:
379
- ValueError: If `run_htab` or `use_ib_tables` are not integers or not within the accepted values (`0` or `-1`).
380
- FileNotFoundError: If the specified file does not exist.
381
- IOError: If there is an error reading or writing the file.
382
-
383
- Example:
384
- >>> RasPlan.set_geom_preprocessor('/path/to/project.p06', run_htab=-1, use_ib_tables=0)
385
-
386
- Note:
387
- This function updates the ras object's dataframes after modifying the project structure.
388
- """
389
- ras_obj = ras_object or ras
390
- ras_obj.check_initialized()
391
-
392
- if run_htab not in [-1, 0]:
393
- raise ValueError("Invalid value for `Run HTab`. Expected `0` or `-1`.")
394
- if use_ib_tables not in [-1, 0]:
395
- raise ValueError("Invalid value for `UNET Use Existing IB Tables`. Expected `0` or `-1`.")
396
-
397
- def update_geom_preprocessor(lines, run_htab, use_ib_tables):
398
- updated_lines = []
399
- for line in lines:
400
- if line.lstrip().startswith("Run HTab="):
401
- updated_lines.append(f"Run HTab= {run_htab} \n")
402
- elif line.lstrip().startswith("UNET Use Existing IB Tables="):
403
- updated_lines.append(f"UNET Use Existing IB Tables= {use_ib_tables} \n")
404
- else:
405
- updated_lines.append(line)
406
- return updated_lines
407
-
408
- try:
409
- RasUtils.update_file(file_path, update_geom_preprocessor, run_htab, use_ib_tables)
410
- except FileNotFoundError:
411
- raise FileNotFoundError(f"The file '{file_path}' does not exist.")
412
- except IOError as e:
413
- raise IOError(f"An error occurred while reading or writing the file: {e}")
414
-
415
- # Update the ras object's dataframes
416
- ras_obj.plan_df = ras_obj.get_plan_entries()
417
- ras_obj.geom_df = ras_obj.get_geom_entries()
418
- ras_obj.flow_df = ras_obj.get_flow_entries()
419
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
420
-
421
- @staticmethod
422
- @log_call
423
- def get_results_path(plan_number: str, ras_object=None) -> Optional[str]:
424
- """
425
- Retrieve the results file path for a given HEC-RAS plan number.
426
-
427
- Args:
428
- plan_number (str): The HEC-RAS plan number for which to find the results path.
429
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
430
-
431
- Returns:
432
- Optional[str]: The full path to the results file if found and the file exists, or None if not found.
433
-
434
- Raises:
435
- RuntimeError: If the project is not initialized.
436
-
437
- Example:
438
- >>> ras_plan = RasPlan()
439
- >>> results_path = ras_plan.get_results_path('01')
440
- >>> if results_path:
441
- ... print(f"Results file found at: {results_path}")
442
- ... else:
443
- ... print("Results file not found.")
444
- """
445
- ras_obj = ras_object or ras
446
- ras_obj.check_initialized()
447
-
448
- # Update the plan dataframe in the ras instance to ensure it is current
449
- ras_obj.plan_df = ras_obj.get_plan_entries()
450
-
451
- # Ensure plan_number is a string
452
- plan_number = str(plan_number).zfill(2)
453
-
454
- plan_entry = ras_obj.plan_df[ras_obj.plan_df['plan_number'] == plan_number]
455
- if not plan_entry.empty:
456
- results_path = plan_entry['HDF_Results_Path'].iloc[0]
457
- if results_path and Path(results_path).exists():
458
- return results_path
459
- else:
460
- return None
461
- else:
462
- return None
463
-
464
- @staticmethod
465
- @log_call
466
- def get_plan_path(plan_number: str, ras_object=None) -> Optional[str]:
467
- """
468
- Return the full path for a given plan number.
469
-
470
- This method ensures that the latest plan entries are included by refreshing
471
- the plan dataframe before searching for the requested plan number.
472
-
473
- Args:
474
- plan_number (str): The plan number to search for.
475
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
476
-
477
- Returns:
478
- Optional[str]: The full path of the plan file if found, None otherwise.
479
-
480
- Raises:
481
- RuntimeError: If the project is not initialized.
482
-
483
- Example:
484
- >>> ras_plan = RasPlan()
485
- >>> plan_path = ras_plan.get_plan_path('01')
486
- >>> if plan_path:
487
- ... print(f"Plan file found at: {plan_path}")
488
- ... else:
489
- ... print("Plan file not found.")
490
- """
491
- ras_obj = ras_object or ras
492
- ras_obj.check_initialized()
493
-
494
- plan_df = ras_obj.get_plan_entries()
495
-
496
- plan_path = plan_df[plan_df['plan_number'] == plan_number]
497
-
498
- if not plan_path.empty:
499
- if 'full_path' in plan_path.columns and not pd.isna(plan_path['full_path'].iloc[0]):
500
- return plan_path['full_path'].iloc[0]
501
- else:
502
- # Fallback to constructing path
503
- return str(ras_obj.project_folder / f"{ras_obj.project_name}.p{plan_number}")
504
- return None
505
-
506
- @staticmethod
507
- @log_call
508
- def get_flow_path(flow_number: str, ras_object=None) -> Optional[str]:
509
- """
510
- Return the full path for a given flow number.
511
-
512
- Args:
513
- flow_number (str): The flow number to search for.
514
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
515
-
516
- Returns:
517
- Optional[str]: The full path of the flow file if found, None otherwise.
518
-
519
- Raises:
520
- RuntimeError: If the project is not initialized.
521
-
522
- Example:
523
- >>> ras_plan = RasPlan()
524
- >>> flow_path = ras_plan.get_flow_path('01')
525
- >>> if flow_path:
526
- ... print(f"Flow file found at: {flow_path}")
527
- ... else:
528
- ... print("Flow file not found.")
529
- """
530
- ras_obj = ras_object or ras
531
- ras_obj.check_initialized()
532
-
533
- # Use updated flow dataframe
534
- ras_obj.flow_df = ras_obj.get_prj_entries('Flow')
535
-
536
- flow_path = ras_obj.flow_df[ras_obj.flow_df['flow_number'] == flow_number]
537
- if not flow_path.empty:
538
- full_path = flow_path['full_path'].iloc[0]
539
- return full_path
540
- else:
541
- return None
542
-
543
- @staticmethod
544
- @log_call
545
- def get_unsteady_path(unsteady_number: str, ras_object=None) -> Optional[str]:
546
- """
547
- Return the full path for a given unsteady number.
548
-
549
- Args:
550
- unsteady_number (str): The unsteady number to search for.
551
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
552
-
553
- Returns:
554
- Optional[str]: The full path of the unsteady file if found, None otherwise.
555
-
556
- Raises:
557
- RuntimeError: If the project is not initialized.
558
-
559
- Example:
560
- >>> ras_plan = RasPlan()
561
- >>> unsteady_path = ras_plan.get_unsteady_path('01')
562
- >>> if unsteady_path:
563
- ... print(f"Unsteady file found at: {unsteady_path}")
564
- ... else:
565
- ... print("Unsteady file not found.")
566
- """
567
- ras_obj = ras_object or ras
568
- ras_obj.check_initialized()
569
-
570
- # Use updated unsteady dataframe
571
- ras_obj.unsteady_df = ras_obj.get_prj_entries('Unsteady')
572
-
573
- unsteady_path = ras_obj.unsteady_df[ras_obj.unsteady_df['unsteady_number'] == unsteady_number]
574
- if not unsteady_path.empty:
575
- full_path = unsteady_path['full_path'].iloc[0]
576
- return full_path
577
- else:
578
- return None
579
-
580
- @staticmethod
581
- @log_call
582
- def get_geom_path(geom_number: Union[str, int], ras_object=None) -> Optional[str]:
583
- """
584
- Return the full path for a given geometry number.
585
-
586
- Args:
587
- geom_number (Union[str, int]): The geometry number to search for.
588
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
589
-
590
- Returns:
591
- Optional[str]: The full path of the geometry file if found, None otherwise.
592
-
593
- Raises:
594
- RuntimeError: If the project is not initialized.
595
-
596
- Example:
597
- >>> ras_plan = RasPlan()
598
- >>> geom_path = ras_plan.get_geom_path('01')
599
- >>> if geom_path:
600
- ... print(f"Geometry file found at: {geom_path}")
601
- ... else:
602
- ... print("Geometry file not found.")
603
- """
604
- logger = get_logger(__name__)
605
-
606
- if geom_number is None:
607
- logger.warning("Provided geometry number is None")
608
- return None
609
-
610
- try:
611
- ras_obj = ras_object or ras
612
- ras_obj.check_initialized()
613
-
614
- # Ensure geom_number is a string with proper formatting
615
- if isinstance(geom_number, int):
616
- geom_number = f"{geom_number:02d}"
617
- elif isinstance(geom_number, str):
618
- # Strip any leading zeros and reformat
619
- stripped = geom_number.lstrip('0')
620
- if not stripped: # Handle case where input was '0' or '00'
621
- geom_number = '00'
622
- else:
623
- geom_number = f"{int(stripped):02d}"
624
- else:
625
- # Handle unexpected types
626
- logger.warning(f"Unexpected type for geom_number: {type(geom_number)}")
627
- return None
628
-
629
- # Use updated geom dataframe
630
- ras_obj.geom_df = ras_obj.get_prj_entries('Geom')
631
-
632
- # Find the geometry file path
633
- geom_path = ras_obj.geom_df[ras_obj.geom_df['geom_number'] == geom_number]
634
- if not geom_path.empty:
635
- if 'full_path' in geom_path.columns and pd.notna(geom_path['full_path'].iloc[0]):
636
- full_path = geom_path['full_path'].iloc[0]
637
- logger.info(f"Found geometry path: {full_path}")
638
- return full_path
639
- else:
640
- # Fallback to constructing path
641
- constructed_path = str(ras_obj.project_folder / f"{ras_obj.project_name}.g{geom_number}")
642
- logger.info(f"Constructed geometry path: {constructed_path}")
643
- return constructed_path
644
- else:
645
- logger.warning(f"No geometry file found with number: {geom_number}")
646
- return None
647
- except Exception as e:
648
- logger.error(f"Error in get_geom_path: {str(e)}")
649
- return None
650
-
651
- # Clone Functions to copy unsteady, flow, and geometry files from templates
652
-
653
- @staticmethod
654
- @log_call
655
- def clone_plan(template_plan, new_plan_shortid=None, ras_object=None):
656
- """
657
- Create a new plan file based on a template and update the project file.
658
-
659
- Parameters:
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
662
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
663
-
664
- Returns:
665
- str: New plan number
666
-
667
- 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
-
672
- Note:
673
- This function updates the ras object's dataframes after modifying the project structure.
674
- """
675
- ras_obj = ras_object or ras
676
- ras_obj.check_initialized()
677
-
678
- # Update plan entries without reinitializing the entire project
679
- ras_obj.plan_df = ras_obj.get_prj_entries('Plan')
680
-
681
- new_plan_num = RasPlan.get_next_number(ras_obj.plan_df['plan_number'])
682
- template_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{template_plan}"
683
- new_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{new_plan_num}"
684
-
685
- def update_shortid(lines):
686
- shortid_pattern = re.compile(r'^Short Identifier=(.*)$', re.IGNORECASE)
687
- 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]
693
- else:
694
- new_shortid = new_plan_shortid[:24]
695
- lines[i] = f"Short Identifier={new_shortid}\n"
696
- break
697
- return lines
698
-
699
- # Use RasUtils to clone the file and update the short identifier
700
- RasUtils.clone_file(template_plan_path, new_plan_path, update_shortid)
701
-
702
- # Use RasUtils to update the project file
703
- RasUtils.update_project_file(ras_obj.prj_file, 'Plan', new_plan_num, ras_object=ras_obj)
704
-
705
- # Re-initialize the ras global object
706
- ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
707
-
708
- ras_obj.plan_df = ras_obj.get_plan_entries()
709
- ras_obj.geom_df = ras_obj.get_geom_entries()
710
- ras_obj.flow_df = ras_obj.get_flow_entries()
711
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
712
-
713
- return new_plan_num
714
-
715
- @staticmethod
716
- @log_call
717
- def clone_unsteady(template_unsteady, ras_object=None):
718
- """
719
- Copy unsteady flow files from a template, find the next unsteady number,
720
- and update the project file accordingly.
721
-
722
- Parameters:
723
- template_unsteady (str): Unsteady flow number to be used as a template (e.g., '01')
724
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
725
-
726
- Returns:
727
- str: New unsteady flow number (e.g., '03')
728
-
729
- Example:
730
- >>> ras_plan = RasPlan()
731
- >>> new_unsteady_num = ras_plan.clone_unsteady('01')
732
- >>> print(f"New unsteady flow file created: u{new_unsteady_num}")
733
-
734
- Note:
735
- This function updates the ras object's dataframes after modifying the project structure.
736
- """
737
- ras_obj = ras_object or ras
738
- ras_obj.check_initialized()
739
-
740
- # Update unsteady entries without reinitializing the entire project
741
- ras_obj.unsteady_df = ras_obj.get_prj_entries('Unsteady')
742
-
743
- new_unsteady_num = RasPlan.get_next_number(ras_obj.unsteady_df['unsteady_number'])
744
- template_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}"
745
- new_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_num}"
746
-
747
- # Use RasUtils to clone the file
748
- RasUtils.clone_file(template_unsteady_path, new_unsteady_path)
749
-
750
- # Copy the corresponding .hdf file if it exists
751
- template_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}.hdf"
752
- new_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_num}.hdf"
753
- if template_hdf_path.exists():
754
- shutil.copy(template_hdf_path, new_hdf_path)
755
-
756
- # Use RasUtils to update the project file
757
- RasUtils.update_project_file(ras_obj.prj_file, 'Unsteady', new_unsteady_num, ras_object=ras_obj)
758
-
759
- # Re-initialize the ras global object
760
- ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
761
-
762
- ras_obj.plan_df = ras_obj.get_plan_entries()
763
- ras_obj.geom_df = ras_obj.get_geom_entries()
764
- ras_obj.flow_df = ras_obj.get_flow_entries()
765
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
766
-
767
- return new_unsteady_num
768
-
769
-
770
- @staticmethod
771
- @log_call
772
- def clone_steady(template_flow, ras_object=None):
773
- """
774
- Copy steady flow files from a template, find the next flow number,
775
- and update the project file accordingly.
776
-
777
- Parameters:
778
- template_flow (str): Flow number to be used as a template (e.g., '01')
779
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
780
-
781
- Returns:
782
- str: New flow number (e.g., '03')
783
-
784
- Example:
785
- >>> ras_plan = RasPlan()
786
- >>> new_flow_num = ras_plan.clone_steady('01')
787
- >>> print(f"New steady flow file created: f{new_flow_num}")
788
-
789
- Note:
790
- This function updates the ras object's dataframes after modifying the project structure.
791
- """
792
- ras_obj = ras_object or ras
793
- ras_obj.check_initialized()
794
-
795
- # Update flow entries without reinitializing the entire project
796
- ras_obj.flow_df = ras_obj.get_prj_entries('Flow')
797
-
798
- new_flow_num = RasPlan.get_next_number(ras_obj.flow_df['flow_number'])
799
- template_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{template_flow}"
800
- new_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{new_flow_num}"
801
-
802
- # Use RasUtils to clone the file
803
- RasUtils.clone_file(template_flow_path, new_flow_path)
804
-
805
- # Use RasUtils to update the project file
806
- RasUtils.update_project_file(ras_obj.prj_file, 'Flow', new_flow_num, ras_object=ras_obj)
807
-
808
- # Re-initialize the ras global object
809
- ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
810
-
811
- ras_obj.plan_df = ras_obj.get_plan_entries()
812
- ras_obj.geom_df = ras_obj.get_geom_entries()
813
- ras_obj.flow_df = ras_obj.get_flow_entries()
814
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
815
-
816
- return new_flow_num
817
-
818
- @staticmethod
819
- @log_call
820
- def clone_geom(template_geom, ras_object=None):
821
- """
822
- Copy geometry files from a template, find the next geometry number,
823
- and update the project file accordingly.
824
-
825
- Parameters:
826
- template_geom (str): Geometry number to be used as a template (e.g., '01')
827
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
828
-
829
- Returns:
830
- str: New geometry number (e.g., '03')
831
-
832
- Note:
833
- This function updates the ras object's dataframes after modifying the project structure.
834
- """
835
- ras_obj = ras_object or ras
836
- ras_obj.check_initialized()
837
-
838
- # Update geometry entries without reinitializing the entire project
839
- ras_obj.geom_df = ras_obj.get_prj_entries('Geom')
840
-
841
- new_geom_num = RasPlan.get_next_number(ras_obj.geom_df['geom_number'])
842
- template_geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{template_geom}"
843
- new_geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom_num}"
844
-
845
- # Use RasUtils to clone the file
846
- RasUtils.clone_file(template_geom_path, new_geom_path)
847
-
848
- # Handle HDF file copy
849
- template_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{template_geom}.hdf"
850
- new_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom_num}.hdf"
851
- if template_hdf_path.is_file():
852
- RasUtils.clone_file(template_hdf_path, new_hdf_path)
853
-
854
- # Use RasUtils to update the project file
855
- RasUtils.update_project_file(ras_obj.prj_file, 'Geom', new_geom_num, ras_object=ras_obj)
856
-
857
- # Update all dataframes in the ras object
858
- ras_obj.plan_df = ras_obj.get_plan_entries()
859
- ras_obj.geom_df = ras_obj.get_geom_entries()
860
- ras_obj.flow_df = ras_obj.get_flow_entries()
861
- ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
862
-
863
- return new_geom_num
864
-
865
- @staticmethod
866
- @log_call
867
- def get_next_number(existing_numbers):
868
- """
869
- Determine the next available number from a list of existing numbers.
870
-
871
- Parameters:
872
- existing_numbers (list): List of existing numbers as strings
873
-
874
- Returns:
875
- str: Next available number as a zero-padded string
876
-
877
- Example:
878
- >>> existing_numbers = ['01', '02', '04']
879
- >>> RasPlan.get_next_number(existing_numbers)
880
- '03'
881
- >>> existing_numbers = ['01', '02', '03']
882
- >>> RasPlan.get_next_number(existing_numbers)
883
- '04'
884
- """
885
- existing_numbers = sorted(int(num) for num in existing_numbers)
886
- next_number = 1
887
- for num in existing_numbers:
888
- if num == next_number:
889
- next_number += 1
890
- else:
891
- break
892
- return f"{next_number:02d}"
893
-
894
- @staticmethod
895
- @log_call
896
- def get_plan_value(
897
- plan_number_or_path: Union[str, Path],
898
- key: str,
899
- ras_object=None
900
- ) -> Any:
901
- """
902
- Retrieve a specific value from a HEC-RAS plan file.
903
-
904
- Parameters:
905
- plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
906
- key (str): The key to retrieve from the plan file
907
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
908
-
909
- Returns:
910
- Any: The value associated with the specified key
911
-
912
- Raises:
913
- ValueError: If the plan file is not found
914
- IOError: If there's an error reading the plan file
915
-
916
- Available keys and their expected types:
917
- - 'Computation Interval' (str): Time value for computational time step (e.g., '5SEC', '2MIN')
918
- - 'DSS File' (str): Name of the DSS file used
919
- - 'Flow File' (str): Name of the flow input file
920
- - 'Friction Slope Method' (int): Method selection for friction slope (e.g., 1, 2)
921
- - 'Geom File' (str): Name of the geometry input file
922
- - 'Mapping Interval' (str): Time interval for mapping output
923
- - 'Plan File' (str): Name of the plan file
924
- - 'Plan Title' (str): Title of the simulation plan
925
- - 'Program Version' (str): Version number of HEC-RAS
926
- - 'Run HTab' (int): Flag to run HTab module (-1 or 1)
927
- - 'Run Post Process' (int): Flag to run post-processing (-1 or 1)
928
- - 'Run Sediment' (int): Flag to run sediment transport module (0 or 1)
929
- - 'Run UNET' (int): Flag to run unsteady network module (-1 or 1)
930
- - 'Run WQNET' (int): Flag to run water quality module (0 or 1)
931
- - 'Short Identifier' (str): Short name or ID for the plan
932
- - 'Simulation Date' (str): Start and end dates/times for simulation
933
- - 'UNET D1 Cores' (int): Number of cores used in 1D calculations
934
- - 'UNET D2 Cores' (int): Number of cores used in 2D calculations
935
- - 'PS Cores' (int): Number of cores used in parallel simulation
936
- - 'UNET Use Existing IB Tables' (int): Flag for using existing internal boundary tables (-1, 0, or 1)
937
- - 'UNET 1D Methodology' (str): 1D calculation methodology
938
- - 'UNET D2 Solver Type' (str): 2D solver type
939
- - 'UNET D2 Name' (str): Name of the 2D area
940
- - 'Run RASMapper' (int): Flag to run RASMapper for floodplain mapping (-1 for off, 0 for on)
941
-
942
- Note:
943
- Writing Multi line keys like 'Description' are not supported by this function.
944
-
945
- Example:
946
- >>> computation_interval = RasPlan.get_plan_value("01", "Computation Interval")
947
- >>> print(f"Computation interval: {computation_interval}")
948
- """
949
- ras_obj = ras_object or ras
950
- ras_obj.check_initialized()
951
-
952
- supported_plan_keys = {
953
- 'Description', 'Computation Interval', 'DSS File', 'Flow File', 'Friction Slope Method',
954
- 'Geom File', 'Mapping Interval', 'Plan File', 'Plan Title', 'Program Version',
955
- 'Run HTab', 'Run Post Process', 'Run Sediment', 'Run UNET', 'Run WQNET',
956
- 'Short Identifier', 'Simulation Date', 'UNET D1 Cores', 'UNET D2 Cores', 'PS Cores',
957
- 'UNET Use Existing IB Tables', 'UNET 1D Methodology', 'UNET D2 Solver Type',
958
- 'UNET D2 Name', 'Run RASMapper', 'Run HTab', 'Run UNET'
959
- }
960
-
961
- if key not in supported_plan_keys:
962
- logger = logging.getLogger(__name__)
963
- logger.warning(f"Unknown key: {key}. Valid keys are: {', '.join(supported_plan_keys)}\n Add more keys and explanations in get_plan_value() as needed.")
964
-
965
- plan_file_path = Path(plan_number_or_path)
966
- if not plan_file_path.is_file():
967
- plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
968
- if plan_file_path is None or not Path(plan_file_path).exists():
969
- raise ValueError(f"Plan file not found: {plan_file_path}")
970
-
971
- try:
972
- with open(plan_file_path, 'r') as file:
973
- content = file.read()
974
- except IOError as e:
975
- logger = logging.getLogger(__name__)
976
- logger.error(f"Error reading plan file {plan_file_path}: {e}")
977
- raise
978
-
979
- # Handle core settings specially to convert to integers
980
- core_keys = {'UNET D1 Cores', 'UNET D2 Cores', 'PS Cores'}
981
- if key in core_keys:
982
- pattern = f"{key}=(.*)"
983
- match = re.search(pattern, content)
984
- if match:
985
- try:
986
- return int(match.group(1).strip())
987
- except ValueError:
988
- logger = logging.getLogger(__name__)
989
- logger.error(f"Could not convert {key} value to integer")
990
- return None
991
- else:
992
- logger = logging.getLogger(__name__)
993
- logger.error(f"Key '{key}' not found in the plan file.")
994
- return None
995
- elif key == 'Description':
996
- match = re.search(r'Begin DESCRIPTION(.*?)END DESCRIPTION', content, re.DOTALL)
997
- return match.group(1).strip() if match else None
998
- else:
999
- pattern = f"{key}=(.*)"
1000
- match = re.search(pattern, content)
1001
- if match:
1002
- return match.group(1).strip()
1003
- else:
1004
- logger = logging.getLogger(__name__)
1005
- logger.error(f"Key '{key}' not found in the plan file.")
1006
- return None
1007
-
1008
-
1009
-
1010
-
1011
-
1012
- @staticmethod
1013
- @log_call
1014
- def update_run_flags(
1015
- plan_number_or_path: Union[str, Path],
1016
- geometry_preprocessor: bool = None,
1017
- unsteady_flow_simulation: bool = None,
1018
- run_sediment: bool = None,
1019
- post_processor: bool = None,
1020
- floodplain_mapping: bool = None,
1021
- ras_object=None
1022
- ) -> None:
1023
- """
1024
- Update the run flags in a HEC-RAS plan file.
1025
-
1026
- Parameters:
1027
- plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
1028
- geometry_preprocessor (bool, optional): Flag for Geometry Preprocessor
1029
- unsteady_flow_simulation (bool, optional): Flag for Unsteady Flow Simulation
1030
- run_sediment (bool, optional): Flag for run_sediment
1031
- post_processor (bool, optional): Flag for Post Processor
1032
- floodplain_mapping (bool, optional): Flag for Floodplain Mapping
1033
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
1034
-
1035
- Raises:
1036
- ValueError: If the plan file is not found
1037
- IOError: If there's an error reading or writing the plan file
1038
-
1039
- Example:
1040
- >>> RasPlan.update_run_flags("01", geometry_preprocessor=True, unsteady_flow_simulation=True, run_sediment=False, post_processor=True, floodplain_mapping=False)
1041
- """
1042
- ras_obj = ras_object or ras
1043
- ras_obj.check_initialized()
1044
-
1045
- plan_file_path = Path(plan_number_or_path)
1046
- if not plan_file_path.is_file():
1047
- plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
1048
- if plan_file_path is None or not Path(plan_file_path).exists():
1049
- raise ValueError(f"Plan file not found: {plan_file_path}")
1050
-
1051
- flag_mapping = {
1052
- 'geometry_preprocessor': ('Run HTab', geometry_preprocessor),
1053
- 'unsteady_flow_simulation': ('Run UNet', unsteady_flow_simulation),
1054
- 'run_sediment': ('Run run_sediment', run_sediment),
1055
- 'post_processor': ('Run PostProcess', post_processor),
1056
- 'floodplain_mapping': ('Run RASMapper', floodplain_mapping)
1057
- }
1058
-
1059
- try:
1060
- with open(plan_file_path, 'r') as file:
1061
- lines = file.readlines()
1062
-
1063
- for i, line in enumerate(lines):
1064
- for key, (file_key, value) in flag_mapping.items():
1065
- if value is not None and line.strip().startswith(file_key):
1066
- lines[i] = f"{file_key}= {1 if value else 0}\n"
1067
-
1068
- with open(plan_file_path, 'w') as file:
1069
- file.writelines(lines)
1070
-
1071
- logger = logging.getLogger(__name__)
1072
- logger.info(f"Successfully updated run flags in plan file: {plan_file_path}")
1073
-
1074
- except IOError as e:
1075
- logger = logging.getLogger(__name__)
1076
- logger.error(f"Error updating run flags in plan file {plan_file_path}: {e}")
1077
- raise
1078
-
1079
-
1080
-
1081
- @staticmethod
1082
- @log_call
1083
- def update_plan_intervals(
1084
- plan_number_or_path: Union[str, Path],
1085
- computation_interval: Optional[str] = None,
1086
- output_interval: Optional[str] = None,
1087
- instantaneous_interval: Optional[str] = None,
1088
- mapping_interval: Optional[str] = None,
1089
- ras_object=None
1090
- ) -> None:
1091
- """
1092
- Update the computation and output intervals in a HEC-RAS plan file.
1093
-
1094
- Parameters:
1095
- plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
1096
- computation_interval (Optional[str]): The new computation interval. Valid entries include:
1097
- '1SEC', '2SEC', '3SEC', '4SEC', '5SEC', '6SEC', '10SEC', '15SEC', '20SEC', '30SEC',
1098
- '1MIN', '2MIN', '3MIN', '4MIN', '5MIN', '6MIN', '10MIN', '15MIN', '20MIN', '30MIN',
1099
- '1HOUR', '2HOUR', '3HOUR', '4HOUR', '6HOUR', '8HOUR', '12HOUR', '1DAY'
1100
- output_interval (Optional[str]): The new output interval. Valid entries are the same as computation_interval.
1101
- instantaneous_interval (Optional[str]): The new instantaneous interval. Valid entries are the same as computation_interval.
1102
- mapping_interval (Optional[str]): The new mapping interval. Valid entries are the same as computation_interval.
1103
- ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
1104
-
1105
- Raises:
1106
- ValueError: If the plan file is not found or if an invalid interval is provided
1107
- IOError: If there's an error reading or writing the plan file
1108
-
1109
- Note: This function does not check if the intervals are equal divisors. Ensure you use valid values from HEC-RAS.
1110
-
1111
- Example:
1112
- >>> RasPlan.update_plan_intervals("01", computation_interval="5SEC", output_interval="1MIN", instantaneous_interval="1HOUR", mapping_interval="5MIN")
1113
- >>> RasPlan.update_plan_intervals("/path/to/plan.p01", computation_interval="10SEC", output_interval="30SEC")
1114
- """
1115
- ras_obj = ras_object or ras
1116
- ras_obj.check_initialized()
1117
-
1118
- plan_file_path = Path(plan_number_or_path)
1119
- if not plan_file_path.is_file():
1120
- plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
1121
- if plan_file_path is None or not Path(plan_file_path).exists():
1122
- raise ValueError(f"Plan file not found: {plan_file_path}")
1123
-
1124
- valid_intervals = [
1125
- '1SEC', '2SEC', '3SEC', '4SEC', '5SEC', '6SEC', '10SEC', '15SEC', '20SEC', '30SEC',
1126
- '1MIN', '2MIN', '3MIN', '4MIN', '5MIN', '6MIN', '10MIN', '15MIN', '20MIN', '30MIN',
1127
- '1HOUR', '2HOUR', '3HOUR', '4HOUR', '6HOUR', '8HOUR', '12HOUR', '1DAY'
1128
- ]
1129
-
1130
- interval_mapping = {
1131
- 'Computation Interval': computation_interval,
1132
- 'Output Interval': output_interval,
1133
- 'Instantaneous Interval': instantaneous_interval,
1134
- 'Mapping Interval': mapping_interval
1135
- }
1136
-
1137
- try:
1138
- with open(plan_file_path, 'r') as file:
1139
- lines = file.readlines()
1140
-
1141
- for i, line in enumerate(lines):
1142
- for key, value in interval_mapping.items():
1143
- if value is not None:
1144
- if value.upper() not in valid_intervals:
1145
- raise ValueError(f"Invalid {key}: {value}. Must be one of {valid_intervals}")
1146
- if line.strip().startswith(key):
1147
- lines[i] = f"{key}={value.upper()}\n"
1148
-
1149
- with open(plan_file_path, 'w') as file:
1150
- file.writelines(lines)
1151
-
1152
- logger = logging.getLogger(__name__)
1153
- logger.info(f"Successfully updated intervals in plan file: {plan_file_path}")
1154
-
1155
- except IOError as e:
1156
- logger = logging.getLogger(__name__)
1157
- logger.error(f"Error updating intervals in plan file {plan_file_path}: {e}")
1158
- raise
1159
-
1160
-
1161
- @log_call
1162
- def update_plan_description(plan_number_or_path: Union[str, Path], description: str, ras_object: Optional['RasPrj'] = None) -> None:
1163
- """
1164
- Update the description block in a HEC-RAS plan file.
1165
-
1166
- Args:
1167
- plan_number_or_path (Union[str, Path]): The plan number or full path to the plan file
1168
- description (str): The new description text to set
1169
- ras_object (Optional[RasPrj]): Specific RAS object to use. If None, uses the global ras instance.
1170
-
1171
- Raises:
1172
- ValueError: If the plan file is not found
1173
- IOError: If there's an error reading or writing the plan file
1174
- """
1175
- logger = get_logger(__name__)
1176
- ras_obj = ras_object or ras
1177
- ras_obj.check_initialized()
1178
-
1179
- plan_file_path = Path(plan_number_or_path)
1180
- if not plan_file_path.is_file():
1181
- plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_object)
1182
- if not plan_file_path.exists():
1183
- logger.error(f"Plan file not found: {plan_file_path}")
1184
- raise ValueError(f"Plan file not found: {plan_file_path}")
1185
-
1186
- try:
1187
- with open(plan_file_path, 'r') as file:
1188
- content = file.read()
1189
-
1190
- # Find the description block
1191
- desc_pattern = r'Begin DESCRIPTION.*?END DESCRIPTION'
1192
- new_desc_block = f'Begin DESCRIPTION\n{description}\nEND DESCRIPTION'
1193
-
1194
- if re.search(desc_pattern, content, re.DOTALL):
1195
- # Replace existing description block
1196
- new_content = re.sub(desc_pattern, new_desc_block, content, flags=re.DOTALL)
1197
- else:
1198
- # Add new description block at the start of the file
1199
- new_content = new_desc_block + '\n' + content
1200
-
1201
- # Write the updated content back to the file
1202
- with open(plan_file_path, 'w') as file:
1203
- file.write(new_content)
1204
-
1205
- logger.info(f"Updated description in plan file: {plan_file_path}")
1206
-
1207
- # Update the dataframes in the RAS object to reflect changes
1208
- if ras_object:
1209
- ras_object.plan_df = ras_object.get_plan_entries()
1210
- ras_object.geom_df = ras_object.get_geom_entries()
1211
- ras_object.flow_df = ras_object.get_flow_entries()
1212
- ras_object.unsteady_df = ras_object.get_unsteady_entries()
1213
-
1214
- except IOError as e:
1215
- logger.error(f"Error updating plan description in {plan_file_path}: {e}")
1216
- raise
1217
- except Exception as e:
1218
- logger.error(f"Unexpected error updating plan description: {e}")
1219
- raise
1220
-
1221
- @staticmethod
1222
- @log_call
1223
- def read_plan_description(plan_number_or_path: Union[str, Path], ras_object: Optional['RasPrj'] = None) -> str:
1224
- """
1225
- Read the description from the plan file.
1226
-
1227
- Args:
1228
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1229
- ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1230
-
1231
- Returns:
1232
- str: The description from the plan file.
1233
-
1234
- Raises:
1235
- ValueError: If the plan file is not found.
1236
- IOError: If there's an error reading from the plan file.
1237
- """
1238
- logger = logging.getLogger(__name__)
1239
-
1240
- plan_file_path = Path(plan_number_or_path)
1241
- if not plan_file_path.is_file():
1242
- plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object)
1243
- if plan_file_path is None or not Path(plan_file_path).exists():
1244
- raise ValueError(f"Plan file not found: {plan_file_path}")
1245
-
1246
- try:
1247
- with open(plan_file_path, 'r') as file:
1248
- lines = file.readlines()
1249
- except IOError as e:
1250
- logger.error(f"Error reading plan file {plan_file_path}: {e}")
1251
- raise
1252
-
1253
- description_lines = []
1254
- in_description = False
1255
- description_found = False
1256
- for line in lines:
1257
- if line.strip() == "BEGIN DESCRIPTION:":
1258
- in_description = True
1259
- description_found = True
1260
- elif line.strip() == "END DESCRIPTION:":
1261
- break
1262
- elif in_description:
1263
- description_lines.append(line.strip())
1264
-
1265
- if not description_found:
1266
- logger.warning(f"No description found in plan file: {plan_file_path}")
1267
- return ""
1268
-
1269
- description = '\n'.join(description_lines)
1270
- logger.info(f"Read description from plan file: {plan_file_path}")
1271
- return description
1272
-
1273
-
1274
-
1275
-
1276
- @staticmethod
1277
- @log_call
1278
- def update_simulation_date(plan_number_or_path: Union[str, Path], start_date: datetime, end_date: datetime, ras_object: Optional['RasPrj'] = None) -> None:
1279
- """
1280
- Update the simulation date for a given plan.
1281
-
1282
- Args:
1283
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1284
- start_date (datetime): The start date and time for the simulation.
1285
- end_date (datetime): The end date and time for the simulation.
1286
- ras_object (Optional['RasPrj']): The RAS project object. Defaults to None.
1287
-
1288
- Raises:
1289
- ValueError: If the plan file is not found or if there's an error updating the file.
1290
- """
1291
-
1292
- # Get the plan file path
1293
- plan_file_path = Path(plan_number_or_path)
1294
- if not plan_file_path.is_file():
1295
- plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object)
1296
- if plan_file_path is None or not Path(plan_file_path).exists():
1297
- raise ValueError(f"Plan file not found: {plan_file_path}")
1298
-
1299
- # Format the dates
1300
- formatted_date = f"{start_date.strftime('%d%b%Y').upper()},{start_date.strftime('%H%M')},{end_date.strftime('%d%b%Y').upper()},{end_date.strftime('%H%M')}"
1301
-
1302
- try:
1303
- # Read the file
1304
- with open(plan_file_path, 'r') as file:
1305
- lines = file.readlines()
1306
-
1307
- # Update the Simulation Date line
1308
- updated = False
1309
- for i, line in enumerate(lines):
1310
- if line.startswith("Simulation Date="):
1311
- lines[i] = f"Simulation Date={formatted_date}\n"
1312
- updated = True
1313
- break
1314
-
1315
- # If Simulation Date line not found, add it at the end
1316
- if not updated:
1317
- lines.append(f"Simulation Date={formatted_date}\n")
1318
-
1319
- # Write the updated content back to the file
1320
- with open(plan_file_path, 'w') as file:
1321
- file.writelines(lines)
1322
-
1323
- logger.info(f"Updated simulation date in plan file: {plan_file_path}")
1324
-
1325
- except IOError as e:
1326
- logger.error(f"Error updating simulation date in plan file {plan_file_path}: {e}")
1327
- raise ValueError(f"Error updating simulation date: {e}")
1328
-
1329
- # Refresh RasPrj dataframes
1330
- if ras_object:
1331
- ras_object.plan_df = ras_object.get_plan_entries()
1332
- ras_object.unsteady_df = ras_object.get_unsteady_entries()
1333
-
1334
- @staticmethod
1335
- @log_call
1336
- def get_shortid(plan_number_or_path: Union[str, Path], ras_object=None) -> str:
1337
- """
1338
- Get the Short Identifier from a HEC-RAS plan file.
1339
-
1340
- Args:
1341
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1342
- ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1343
-
1344
- Returns:
1345
- str: The Short Identifier from the plan file.
1346
-
1347
- Raises:
1348
- ValueError: If the plan file is not found.
1349
- IOError: If there's an error reading from the plan file.
1350
-
1351
- Example:
1352
- >>> shortid = RasPlan.get_shortid('01')
1353
- >>> print(f"Plan's Short Identifier: {shortid}")
1354
- """
1355
- logger = get_logger(__name__)
1356
- ras_obj = ras_object or ras
1357
- ras_obj.check_initialized()
1358
-
1359
- # Get the Short Identifier using get_plan_value
1360
- shortid = RasPlan.get_plan_value(plan_number_or_path, "Short Identifier", ras_obj)
1361
-
1362
- if shortid is None:
1363
- logger.warning(f"Short Identifier not found in plan: {plan_number_or_path}")
1364
- return ""
1365
-
1366
- logger.info(f"Retrieved Short Identifier: {shortid}")
1367
- return shortid
1368
-
1369
- @staticmethod
1370
- @log_call
1371
- def set_shortid(plan_number_or_path: Union[str, Path], new_shortid: str, ras_object=None) -> None:
1372
- """
1373
- Set the Short Identifier in a HEC-RAS plan file.
1374
-
1375
- Args:
1376
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1377
- new_shortid (str): The new Short Identifier to set (max 24 characters).
1378
- ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1379
-
1380
- Raises:
1381
- ValueError: If the plan file is not found or if new_shortid is too long.
1382
- IOError: If there's an error updating the plan file.
1383
-
1384
- Example:
1385
- >>> RasPlan.set_shortid('01', 'NewShortIdentifier')
1386
- """
1387
- logger = get_logger(__name__)
1388
- ras_obj = ras_object or ras
1389
- ras_obj.check_initialized()
1390
-
1391
- # Ensure new_shortid is not too long (HEC-RAS limits short identifiers to 24 characters)
1392
- if len(new_shortid) > 24:
1393
- logger.warning(f"Short Identifier too long (24 char max). Truncating: {new_shortid}")
1394
- new_shortid = new_shortid[:24]
1395
-
1396
- # Get the plan file path
1397
- plan_file_path = Path(plan_number_or_path)
1398
- if not plan_file_path.is_file():
1399
- plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_obj)
1400
- if not plan_file_path.exists():
1401
- logger.error(f"Plan file not found: {plan_file_path}")
1402
- raise ValueError(f"Plan file not found: {plan_file_path}")
1403
-
1404
- try:
1405
- # Read the file
1406
- with open(plan_file_path, 'r') as file:
1407
- lines = file.readlines()
1408
-
1409
- # Update the Short Identifier line
1410
- updated = False
1411
- for i, line in enumerate(lines):
1412
- if line.startswith("Short Identifier="):
1413
- lines[i] = f"Short Identifier={new_shortid}\n"
1414
- updated = True
1415
- break
1416
-
1417
- # If Short Identifier line not found, add it after Plan Title
1418
- if not updated:
1419
- for i, line in enumerate(lines):
1420
- if line.startswith("Plan Title="):
1421
- lines.insert(i+1, f"Short Identifier={new_shortid}\n")
1422
- updated = True
1423
- break
1424
-
1425
- # If Plan Title not found either, add at the beginning
1426
- if not updated:
1427
- lines.insert(0, f"Short Identifier={new_shortid}\n")
1428
-
1429
- # Write the updated content back to the file
1430
- with open(plan_file_path, 'w') as file:
1431
- file.writelines(lines)
1432
-
1433
- logger.info(f"Updated Short Identifier in plan file to: {new_shortid}")
1434
-
1435
- except IOError as e:
1436
- logger.error(f"Error updating Short Identifier in plan file {plan_file_path}: {e}")
1437
- raise ValueError(f"Error updating Short Identifier: {e}")
1438
-
1439
- # Refresh RasPrj dataframes if ras_object provided
1440
- if ras_object:
1441
- ras_object.plan_df = ras_object.get_plan_entries()
1442
-
1443
- @staticmethod
1444
- @log_call
1445
- def get_plan_title(plan_number_or_path: Union[str, Path], ras_object=None) -> str:
1446
- """
1447
- Get the Plan Title from a HEC-RAS plan file.
1448
-
1449
- Args:
1450
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1451
- ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1452
-
1453
- Returns:
1454
- str: The Plan Title from the plan file.
1455
-
1456
- Raises:
1457
- ValueError: If the plan file is not found.
1458
- IOError: If there's an error reading from the plan file.
1459
-
1460
- Example:
1461
- >>> title = RasPlan.get_plan_title('01')
1462
- >>> print(f"Plan Title: {title}")
1463
- """
1464
- logger = get_logger(__name__)
1465
- ras_obj = ras_object or ras
1466
- ras_obj.check_initialized()
1467
-
1468
- # Get the Plan Title using get_plan_value
1469
- title = RasPlan.get_plan_value(plan_number_or_path, "Plan Title", ras_obj)
1470
-
1471
- if title is None:
1472
- logger.warning(f"Plan Title not found in plan: {plan_number_or_path}")
1473
- return ""
1474
-
1475
- logger.info(f"Retrieved Plan Title: {title}")
1476
- return title
1477
-
1478
- @staticmethod
1479
- @log_call
1480
- def set_plan_title(plan_number_or_path: Union[str, Path], new_title: str, ras_object=None) -> None:
1481
- """
1482
- Set the Plan Title in a HEC-RAS plan file.
1483
-
1484
- Args:
1485
- plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1486
- new_title (str): The new Plan Title to set.
1487
- ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1488
-
1489
- Raises:
1490
- ValueError: If the plan file is not found.
1491
- IOError: If there's an error updating the plan file.
1492
-
1493
- Example:
1494
- >>> RasPlan.set_plan_title('01', 'Updated Plan Scenario')
1495
- """
1496
- logger = get_logger(__name__)
1497
- ras_obj = ras_object or ras
1498
- ras_obj.check_initialized()
1499
-
1500
- # Get the plan file path
1501
- plan_file_path = Path(plan_number_or_path)
1502
- if not plan_file_path.is_file():
1503
- plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_obj)
1504
- if not plan_file_path.exists():
1505
- logger.error(f"Plan file not found: {plan_file_path}")
1506
- raise ValueError(f"Plan file not found: {plan_file_path}")
1507
-
1508
- try:
1509
- # Read the file
1510
- with open(plan_file_path, 'r') as file:
1511
- lines = file.readlines()
1512
-
1513
- # Update the Plan Title line
1514
- updated = False
1515
- for i, line in enumerate(lines):
1516
- if line.startswith("Plan Title="):
1517
- lines[i] = f"Plan Title={new_title}\n"
1518
- updated = True
1519
- break
1520
-
1521
- # If Plan Title line not found, add it at the beginning
1522
- if not updated:
1523
- lines.insert(0, f"Plan Title={new_title}\n")
1524
-
1525
- # Write the updated content back to the file
1526
- with open(plan_file_path, 'w') as file:
1527
- file.writelines(lines)
1528
-
1529
- logger.info(f"Updated Plan Title in plan file to: {new_title}")
1530
-
1531
- except IOError as e:
1532
- logger.error(f"Error updating Plan Title in plan file {plan_file_path}: {e}")
1533
- raise ValueError(f"Error updating Plan Title: {e}")
1534
-
1535
- # Refresh RasPrj dataframes if ras_object provided
1536
- if ras_object:
1
+ """
2
+ RasPlan - Operations for handling plan files in HEC-RAS projects
3
+
4
+ This module is part of the ras-commander library and uses a centralized logging configuration.
5
+
6
+ Logging Configuration:
7
+ - The logging is set up in the logging_config.py file.
8
+ - A @log_call decorator is available to automatically log function calls.
9
+ - Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
10
+ - Logs are written to both console and a rotating file handler.
11
+ - The default log file is 'ras_commander.log' in the 'logs' directory.
12
+ - The default log level is INFO.
13
+
14
+ To use logging in this module:
15
+ 1. Use the @log_call decorator for automatic function call logging.
16
+ 2. For additional logging, use logger.[level]() calls (e.g., logger.info(), logger.debug()).
17
+ 3. Obtain the logger using: logger = logging.getLogger(__name__)
18
+
19
+ Example:
20
+ @log_call
21
+ def my_function():
22
+ logger = logging.getLogger(__name__)
23
+ logger.debug("Additional debug information")
24
+ # Function logic here
25
+
26
+
27
+ -----
28
+
29
+ All of the methods in this class are static and are designed to be used without instantiation.
30
+
31
+ List of Functions in RasPlan:
32
+ - set_geom(): Set the geometry for a specified plan
33
+ - set_steady(): Apply a steady flow file to a plan file
34
+ - set_unsteady(): Apply an unsteady flow file to a plan file
35
+ - set_num_cores(): Update the maximum number of cores to use
36
+ - set_geom_preprocessor(): Update geometry preprocessor settings
37
+ - clone_plan(): Create a new plan file based on a template
38
+ - clone_unsteady(): Copy unsteady flow files from a template
39
+ - clone_steady(): Copy steady flow files from a template
40
+ - clone_geom(): Copy geometry files from a template
41
+ - get_next_number(): Determine the next available number from a list
42
+ - get_plan_value(): Retrieve a specific value from a plan file
43
+ - get_results_path(): Get the results file path for a plan
44
+ - get_plan_path(): Get the full path for a plan number
45
+ - get_flow_path(): Get the full path for a flow number
46
+ - get_unsteady_path(): Get the full path for an unsteady number
47
+ - get_geom_path(): Get the full path for a geometry number
48
+ - update_run_flags(): Update various run flags in a plan file
49
+ - update_plan_intervals(): Update computation and output intervals
50
+ - update_plan_description(): Update the description in a plan file
51
+ - read_plan_description(): Read the description from a plan file
52
+ - update_simulation_date(): Update simulation start and end dates
53
+ - get_shortid(): Get the Short Identifier from a plan file
54
+ - set_shortid(): Set the Short Identifier in a plan file
55
+ - get_plan_title(): Get the Plan Title from a plan file
56
+ - set_plan_title(): Set the Plan Title in a plan file
57
+
58
+
59
+
60
+ """
61
+ import os
62
+ import re
63
+ import logging
64
+ from pathlib import Path
65
+ import shutil
66
+ from typing import Union, Optional
67
+ import pandas as pd
68
+ from .RasPrj import RasPrj, ras
69
+ from .RasUtils import RasUtils
70
+ from pathlib import Path
71
+ from typing import Union, Any
72
+ from datetime import datetime
73
+
74
+ import logging
75
+ import re
76
+ from .LoggingConfig import get_logger
77
+ from .Decorators import log_call
78
+
79
+ logger = get_logger(__name__)
80
+
81
+ class RasPlan:
82
+ """
83
+ A class for operations on HEC-RAS plan files.
84
+ """
85
+
86
+ @staticmethod
87
+ @log_call
88
+ def set_geom(plan_number: Union[str, int], new_geom: Union[str, int], ras_object=None) -> pd.DataFrame:
89
+ """
90
+ Set the geometry for the specified plan by updating only the plan file.
91
+
92
+ Parameters:
93
+ plan_number (Union[str, int]): The plan number to update.
94
+ new_geom (Union[str, int]): The new geometry number to set.
95
+ ras_object: An optional RAS object instance.
96
+
97
+ Returns:
98
+ pd.DataFrame: The updated geometry DataFrame.
99
+
100
+ Example:
101
+ updated_geom_df = RasPlan.set_geom('02', '03')
102
+
103
+ Note:
104
+ This function updates the Geom File= line in the plan file and
105
+ updates the ras object's dataframes without modifying the PRJ file.
106
+ """
107
+ ras_obj = ras_object or ras
108
+ ras_obj.check_initialized()
109
+
110
+ plan_number = str(plan_number).zfill(2)
111
+ new_geom = str(new_geom).zfill(2)
112
+
113
+ # Update all dataframes
114
+ ras_obj.plan_df = ras_obj.get_plan_entries()
115
+ ras_obj.geom_df = ras_obj.get_geom_entries()
116
+
117
+ if new_geom not in ras_obj.geom_df['geom_number'].values:
118
+ logger.error(f"Geometry {new_geom} not found in project.")
119
+ raise ValueError(f"Geometry {new_geom} not found in project.")
120
+
121
+ # Get the plan file path
122
+ plan_file_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{plan_number}"
123
+ if not plan_file_path.exists():
124
+ logger.error(f"Plan file not found: {plan_file_path}")
125
+ raise ValueError(f"Plan file not found: {plan_file_path}")
126
+
127
+ # Read the plan file and update the Geom File line
128
+ try:
129
+ with open(plan_file_path, 'r') as file:
130
+ lines = file.readlines()
131
+
132
+ for i, line in enumerate(lines):
133
+ if line.startswith("Geom File="):
134
+ lines[i] = f"Geom File=g{new_geom}\n"
135
+ logger.info(f"Updated Geom File in plan file to g{new_geom} for plan {plan_number}")
136
+ break
137
+
138
+ with open(plan_file_path, 'w') as file:
139
+ file.writelines(lines)
140
+ except Exception as e:
141
+ logger.error(f"Error updating plan file: {e}")
142
+ raise
143
+ # Update the plan_df without reinitializing
144
+ mask = ras_obj.plan_df['plan_number'] == plan_number
145
+ ras_obj.plan_df.loc[mask, 'geom_number'] = new_geom
146
+ ras_obj.plan_df.loc[mask, 'geometry_number'] = new_geom # Update geometry_number column
147
+ ras_obj.plan_df.loc[mask, 'Geom File'] = f"g{new_geom}"
148
+ geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom}"
149
+ ras_obj.plan_df.loc[mask, 'Geom Path'] = str(geom_path)
150
+
151
+ logger.info(f"Geometry for plan {plan_number} set to {new_geom}")
152
+ logger.debug("Updated plan DataFrame:")
153
+ logger.debug(ras_obj.plan_df)
154
+
155
+ return ras_obj.plan_df
156
+
157
+ @staticmethod
158
+ @log_call
159
+ def set_steady(plan_number: str, new_steady_flow_number: str, ras_object=None):
160
+ """
161
+ Apply a steady flow file to a plan file.
162
+
163
+ Parameters:
164
+ plan_number (str): Plan number (e.g., '02')
165
+ new_steady_flow_number (str): Steady flow number to apply (e.g., '01')
166
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
167
+
168
+ Returns:
169
+ None
170
+
171
+ Raises:
172
+ ValueError: If the specified steady flow number is not found in the project file
173
+ FileNotFoundError: If the specified plan file is not found
174
+
175
+ Example:
176
+ >>> RasPlan.set_steady('02', '01')
177
+
178
+ Note:
179
+ This function updates the ras object's dataframes after modifying the project structure.
180
+ """
181
+ ras_obj = ras_object or ras
182
+ ras_obj.check_initialized()
183
+
184
+ ras_obj.flow_df = ras_obj.get_flow_entries()
185
+
186
+ if new_steady_flow_number not in ras_obj.flow_df['flow_number'].values:
187
+ raise ValueError(f"Steady flow number {new_steady_flow_number} not found in project file.")
188
+
189
+ plan_file_path = RasPlan.get_plan_path(plan_number, ras_obj)
190
+ if not plan_file_path:
191
+ raise FileNotFoundError(f"Plan file not found: {plan_number}")
192
+
193
+ try:
194
+ RasUtils.update_file(plan_file_path, RasPlan._update_steady_in_file, new_steady_flow_number)
195
+
196
+ # Update all dataframes
197
+ ras_obj.plan_df = ras_obj.get_plan_entries()
198
+
199
+ # Update flow-related columns
200
+ mask = ras_obj.plan_df['plan_number'] == plan_number
201
+ flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{new_steady_flow_number}"
202
+ ras_obj.plan_df.loc[mask, 'Flow File'] = f"f{new_steady_flow_number}"
203
+ ras_obj.plan_df.loc[mask, 'Flow Path'] = str(flow_path)
204
+ ras_obj.plan_df.loc[mask, 'unsteady_number'] = None
205
+
206
+ # Update remaining dataframes
207
+ ras_obj.geom_df = ras_obj.get_geom_entries()
208
+ ras_obj.flow_df = ras_obj.get_flow_entries()
209
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
210
+
211
+ except Exception as e:
212
+ raise IOError(f"Failed to update steady flow file: {e}")
213
+
214
+ @staticmethod
215
+ def _update_steady_in_file(lines, new_steady_flow_number):
216
+ return [f"Flow File=f{new_steady_flow_number}\n" if line.startswith("Flow File=f") else line for line in lines]
217
+
218
+ @staticmethod
219
+ @log_call
220
+ def set_unsteady(plan_number: str, new_unsteady_flow_number: str, ras_object=None):
221
+ """
222
+ Apply an unsteady flow file to a plan file.
223
+
224
+ Parameters:
225
+ plan_number (str): Plan number (e.g., '04')
226
+ new_unsteady_flow_number (str): Unsteady flow number to apply (e.g., '01')
227
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
228
+
229
+ Returns:
230
+ None
231
+
232
+ Raises:
233
+ ValueError: If the specified unsteady number is not found in the project file
234
+ FileNotFoundError: If the specified plan file is not found
235
+
236
+ Example:
237
+ >>> RasPlan.set_unsteady('04', '01')
238
+
239
+ Note:
240
+ This function updates the ras object's dataframes after modifying the project structure.
241
+ """
242
+ ras_obj = ras_object or ras
243
+ ras_obj.check_initialized()
244
+
245
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
246
+
247
+ if new_unsteady_flow_number not in ras_obj.unsteady_df['unsteady_number'].values:
248
+ raise ValueError(f"Unsteady number {new_unsteady_flow_number} not found in project file.")
249
+
250
+ plan_file_path = RasPlan.get_plan_path(plan_number, ras_obj)
251
+ if not plan_file_path:
252
+ raise FileNotFoundError(f"Plan file not found: {plan_number}")
253
+
254
+ try:
255
+ # Read the plan file
256
+ with open(plan_file_path, 'r') as f:
257
+ lines = f.readlines()
258
+
259
+ # Update the Flow File line
260
+ for i, line in enumerate(lines):
261
+ if line.startswith("Flow File="):
262
+ lines[i] = f"Flow File=u{new_unsteady_flow_number}\n"
263
+ break
264
+
265
+ # Write back to the plan file
266
+ with open(plan_file_path, 'w') as f:
267
+ f.writelines(lines)
268
+
269
+ # Update all dataframes
270
+ ras_obj.plan_df = ras_obj.get_plan_entries()
271
+
272
+ # Update flow-related columns
273
+ mask = ras_obj.plan_df['plan_number'] == plan_number
274
+ flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_flow_number}"
275
+ ras_obj.plan_df.loc[mask, 'Flow File'] = f"u{new_unsteady_flow_number}"
276
+ ras_obj.plan_df.loc[mask, 'Flow Path'] = str(flow_path)
277
+ ras_obj.plan_df.loc[mask, 'unsteady_number'] = new_unsteady_flow_number
278
+
279
+ # Update remaining dataframes
280
+ ras_obj.geom_df = ras_obj.get_geom_entries()
281
+ ras_obj.flow_df = ras_obj.get_flow_entries()
282
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
283
+
284
+ except Exception as e:
285
+ raise IOError(f"Failed to update unsteady flow file: {e}")
286
+
287
+ @staticmethod
288
+ def _update_unsteady_in_file(lines, new_unsteady_flow_number):
289
+ return [f"Unsteady File=u{new_unsteady_flow_number}\n" if line.startswith("Unsteady File=u") else line for line in lines]
290
+
291
+ @staticmethod
292
+ @log_call
293
+ def set_num_cores(plan_number, num_cores, ras_object=None):
294
+ """
295
+ Update the maximum number of cores to use in the HEC-RAS plan file.
296
+
297
+ Parameters:
298
+ plan_number (str): Plan number (e.g., '02') or full path to the plan file
299
+ num_cores (int): Maximum number of cores to use
300
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
301
+
302
+ Returns:
303
+ None
304
+
305
+ Number of cores is controlled by the following parameters in the plan file corresponding to 1D, 2D, Pipe Systems and Pump Stations:
306
+ UNET D1 Cores=
307
+ UNET D2 Cores=
308
+ PS Cores=
309
+
310
+ Where a value of "0" is used for "All Available" cores, and values of 1 or more are used to specify the number of cores to use.
311
+ For complex 1D/2D models with pipe systems, a more complex approach may be needed to optimize performance. (Suggest writing a custom function based on this code).
312
+ This function simply sets the "num_cores" parameter for ALL instances of the above parameters in the plan file.
313
+
314
+
315
+ Notes on setting num_cores in HEC-RAS:
316
+ The recommended setting for num_cores is 2 (most efficient) to 8 (most performant)
317
+ More details in the HEC-Commander Repository Blog "Benchmarking is All You Need"
318
+ https://github.com/billk-FM/HEC-Commander/blob/main/Blog/7._Benchmarking_Is_All_You_Need.md
319
+
320
+ Microsoft Windows has a maximum of 64 cores that can be allocated to a single Ras.exe process.
321
+
322
+ Example:
323
+ >>> # Using plan number
324
+ >>> RasPlan.set_num_cores('02', 4)
325
+ >>> # Using full path to plan file
326
+ >>> RasPlan.set_num_cores('/path/to/project.p02', 4)
327
+
328
+ Note:
329
+ This function updates the ras object's dataframes after modifying the project structure.
330
+ """
331
+ ras_obj = ras_object or ras
332
+ ras_obj.check_initialized()
333
+
334
+ plan_file_path = RasUtils.get_plan_path(plan_number, ras_obj)
335
+ if not plan_file_path:
336
+ raise FileNotFoundError(f"Plan file not found: {plan_number}. Please provide a valid plan number or path.")
337
+
338
+ def update_num_cores(lines):
339
+ updated_lines = []
340
+ for line in lines:
341
+ if any(param in line for param in ["UNET D1 Cores=", "UNET D2 Cores=", "PS Cores="]):
342
+ param_name = line.split("=")[0]
343
+ updated_lines.append(f"{param_name}= {num_cores}\n")
344
+ else:
345
+ updated_lines.append(line)
346
+ return updated_lines
347
+
348
+ try:
349
+ RasUtils.update_file(plan_file_path, update_num_cores)
350
+ except Exception as e:
351
+ raise IOError(f"Failed to update number of cores in plan file: {e}")
352
+
353
+ # Update the ras object's dataframes
354
+ ras_obj.plan_df = ras_obj.get_plan_entries()
355
+ ras_obj.geom_df = ras_obj.get_geom_entries()
356
+ ras_obj.flow_df = ras_obj.get_flow_entries()
357
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
358
+
359
+ @staticmethod
360
+ @log_call
361
+ def set_geom_preprocessor(file_path, run_htab, use_ib_tables, ras_object=None):
362
+ """
363
+ Update the simulation plan file to modify the `Run HTab` and `UNET Use Existing IB Tables` settings.
364
+
365
+ Parameters:
366
+ file_path (str): Path to the simulation plan file (.p06 or similar) that you want to modify.
367
+ run_htab (int): Value for the `Run HTab` setting:
368
+ - `0` : Do not run the geometry preprocessor, use existing geometry tables.
369
+ - `-1` : Run the geometry preprocessor, forcing a recomputation of the geometry tables.
370
+ use_ib_tables (int): Value for the `UNET Use Existing IB Tables` setting:
371
+ - `0` : Use existing interpolation/boundary (IB) tables without recomputing them.
372
+ - `-1` : Do not use existing IB tables, force a recomputation.
373
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
374
+
375
+ Returns:
376
+ None
377
+
378
+ Raises:
379
+ ValueError: If `run_htab` or `use_ib_tables` are not integers or not within the accepted values (`0` or `-1`).
380
+ FileNotFoundError: If the specified file does not exist.
381
+ IOError: If there is an error reading or writing the file.
382
+
383
+ Example:
384
+ >>> RasPlan.set_geom_preprocessor('/path/to/project.p06', run_htab=-1, use_ib_tables=0)
385
+
386
+ Note:
387
+ This function updates the ras object's dataframes after modifying the project structure.
388
+ """
389
+ ras_obj = ras_object or ras
390
+ ras_obj.check_initialized()
391
+
392
+ if run_htab not in [-1, 0]:
393
+ raise ValueError("Invalid value for `Run HTab`. Expected `0` or `-1`.")
394
+ if use_ib_tables not in [-1, 0]:
395
+ raise ValueError("Invalid value for `UNET Use Existing IB Tables`. Expected `0` or `-1`.")
396
+
397
+ def update_geom_preprocessor(lines, run_htab, use_ib_tables):
398
+ updated_lines = []
399
+ for line in lines:
400
+ if line.lstrip().startswith("Run HTab="):
401
+ updated_lines.append(f"Run HTab= {run_htab} \n")
402
+ elif line.lstrip().startswith("UNET Use Existing IB Tables="):
403
+ updated_lines.append(f"UNET Use Existing IB Tables= {use_ib_tables} \n")
404
+ else:
405
+ updated_lines.append(line)
406
+ return updated_lines
407
+
408
+ try:
409
+ RasUtils.update_file(file_path, update_geom_preprocessor, run_htab, use_ib_tables)
410
+ except FileNotFoundError:
411
+ raise FileNotFoundError(f"The file '{file_path}' does not exist.")
412
+ except IOError as e:
413
+ raise IOError(f"An error occurred while reading or writing the file: {e}")
414
+
415
+ # Update the ras object's dataframes
416
+ ras_obj.plan_df = ras_obj.get_plan_entries()
417
+ ras_obj.geom_df = ras_obj.get_geom_entries()
418
+ ras_obj.flow_df = ras_obj.get_flow_entries()
419
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
420
+
421
+ @staticmethod
422
+ @log_call
423
+ def get_results_path(plan_number: str, ras_object=None) -> Optional[str]:
424
+ """
425
+ Retrieve the results file path for a given HEC-RAS plan number.
426
+
427
+ Args:
428
+ plan_number (str): The HEC-RAS plan number for which to find the results path.
429
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
430
+
431
+ Returns:
432
+ Optional[str]: The full path to the results file if found and the file exists, or None if not found.
433
+
434
+ Raises:
435
+ RuntimeError: If the project is not initialized.
436
+
437
+ Example:
438
+ >>> ras_plan = RasPlan()
439
+ >>> results_path = ras_plan.get_results_path('01')
440
+ >>> if results_path:
441
+ ... print(f"Results file found at: {results_path}")
442
+ ... else:
443
+ ... print("Results file not found.")
444
+ """
445
+ ras_obj = ras_object or ras
446
+ ras_obj.check_initialized()
447
+
448
+ # Update the plan dataframe in the ras instance to ensure it is current
449
+ ras_obj.plan_df = ras_obj.get_plan_entries()
450
+
451
+ # Ensure plan_number is a string
452
+ plan_number = str(plan_number).zfill(2)
453
+
454
+ plan_entry = ras_obj.plan_df[ras_obj.plan_df['plan_number'] == plan_number]
455
+ if not plan_entry.empty:
456
+ results_path = plan_entry['HDF_Results_Path'].iloc[0]
457
+ if results_path and Path(results_path).exists():
458
+ return results_path
459
+ else:
460
+ return None
461
+ else:
462
+ return None
463
+
464
+ @staticmethod
465
+ @log_call
466
+ def get_plan_path(plan_number: str, ras_object=None) -> Optional[str]:
467
+ """
468
+ Return the full path for a given plan number.
469
+
470
+ This method ensures that the latest plan entries are included by refreshing
471
+ the plan dataframe before searching for the requested plan number.
472
+
473
+ Args:
474
+ plan_number (str): The plan number to search for.
475
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
476
+
477
+ Returns:
478
+ Optional[str]: The full path of the plan file if found, None otherwise.
479
+
480
+ Raises:
481
+ RuntimeError: If the project is not initialized.
482
+
483
+ Example:
484
+ >>> ras_plan = RasPlan()
485
+ >>> plan_path = ras_plan.get_plan_path('01')
486
+ >>> if plan_path:
487
+ ... print(f"Plan file found at: {plan_path}")
488
+ ... else:
489
+ ... print("Plan file not found.")
490
+ """
491
+ ras_obj = ras_object or ras
492
+ ras_obj.check_initialized()
493
+
494
+ plan_df = ras_obj.get_plan_entries()
495
+
496
+ plan_path = plan_df[plan_df['plan_number'] == plan_number]
497
+
498
+ if not plan_path.empty:
499
+ if 'full_path' in plan_path.columns and not pd.isna(plan_path['full_path'].iloc[0]):
500
+ return plan_path['full_path'].iloc[0]
501
+ else:
502
+ # Fallback to constructing path
503
+ return str(ras_obj.project_folder / f"{ras_obj.project_name}.p{plan_number}")
504
+ return None
505
+
506
+ @staticmethod
507
+ @log_call
508
+ def get_flow_path(flow_number: str, ras_object=None) -> Optional[str]:
509
+ """
510
+ Return the full path for a given flow number.
511
+
512
+ Args:
513
+ flow_number (str): The flow number to search for.
514
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
515
+
516
+ Returns:
517
+ Optional[str]: The full path of the flow file if found, None otherwise.
518
+
519
+ Raises:
520
+ RuntimeError: If the project is not initialized.
521
+
522
+ Example:
523
+ >>> ras_plan = RasPlan()
524
+ >>> flow_path = ras_plan.get_flow_path('01')
525
+ >>> if flow_path:
526
+ ... print(f"Flow file found at: {flow_path}")
527
+ ... else:
528
+ ... print("Flow file not found.")
529
+ """
530
+ ras_obj = ras_object or ras
531
+ ras_obj.check_initialized()
532
+
533
+ # Use updated flow dataframe
534
+ ras_obj.flow_df = ras_obj.get_prj_entries('Flow')
535
+
536
+ flow_path = ras_obj.flow_df[ras_obj.flow_df['flow_number'] == flow_number]
537
+ if not flow_path.empty:
538
+ full_path = flow_path['full_path'].iloc[0]
539
+ return full_path
540
+ else:
541
+ return None
542
+
543
+ @staticmethod
544
+ @log_call
545
+ def get_unsteady_path(unsteady_number: str, ras_object=None) -> Optional[str]:
546
+ """
547
+ Return the full path for a given unsteady number.
548
+
549
+ Args:
550
+ unsteady_number (str): The unsteady number to search for.
551
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
552
+
553
+ Returns:
554
+ Optional[str]: The full path of the unsteady file if found, None otherwise.
555
+
556
+ Raises:
557
+ RuntimeError: If the project is not initialized.
558
+
559
+ Example:
560
+ >>> ras_plan = RasPlan()
561
+ >>> unsteady_path = ras_plan.get_unsteady_path('01')
562
+ >>> if unsteady_path:
563
+ ... print(f"Unsteady file found at: {unsteady_path}")
564
+ ... else:
565
+ ... print("Unsteady file not found.")
566
+ """
567
+ ras_obj = ras_object or ras
568
+ ras_obj.check_initialized()
569
+
570
+ # Use updated unsteady dataframe
571
+ ras_obj.unsteady_df = ras_obj.get_prj_entries('Unsteady')
572
+
573
+ unsteady_path = ras_obj.unsteady_df[ras_obj.unsteady_df['unsteady_number'] == unsteady_number]
574
+ if not unsteady_path.empty:
575
+ full_path = unsteady_path['full_path'].iloc[0]
576
+ return full_path
577
+ else:
578
+ return None
579
+
580
+ @staticmethod
581
+ @log_call
582
+ def get_geom_path(geom_number: Union[str, int], ras_object=None) -> Optional[str]:
583
+ """
584
+ Return the full path for a given geometry number.
585
+
586
+ Args:
587
+ geom_number (Union[str, int]): The geometry number to search for.
588
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
589
+
590
+ Returns:
591
+ Optional[str]: The full path of the geometry file if found, None otherwise.
592
+
593
+ Raises:
594
+ RuntimeError: If the project is not initialized.
595
+
596
+ Example:
597
+ >>> ras_plan = RasPlan()
598
+ >>> geom_path = ras_plan.get_geom_path('01')
599
+ >>> if geom_path:
600
+ ... print(f"Geometry file found at: {geom_path}")
601
+ ... else:
602
+ ... print("Geometry file not found.")
603
+ """
604
+ logger = get_logger(__name__)
605
+
606
+ if geom_number is None:
607
+ logger.warning("Provided geometry number is None")
608
+ return None
609
+
610
+ try:
611
+ ras_obj = ras_object or ras
612
+ ras_obj.check_initialized()
613
+
614
+ # Ensure geom_number is a string with proper formatting
615
+ if isinstance(geom_number, int):
616
+ geom_number = f"{geom_number:02d}"
617
+ elif isinstance(geom_number, str):
618
+ # Strip any leading zeros and reformat
619
+ stripped = geom_number.lstrip('0')
620
+ if not stripped: # Handle case where input was '0' or '00'
621
+ geom_number = '00'
622
+ else:
623
+ geom_number = f"{int(stripped):02d}"
624
+ else:
625
+ # Handle unexpected types
626
+ logger.warning(f"Unexpected type for geom_number: {type(geom_number)}")
627
+ return None
628
+
629
+ # Use updated geom dataframe
630
+ ras_obj.geom_df = ras_obj.get_prj_entries('Geom')
631
+
632
+ # Find the geometry file path
633
+ geom_path = ras_obj.geom_df[ras_obj.geom_df['geom_number'] == geom_number]
634
+ if not geom_path.empty:
635
+ if 'full_path' in geom_path.columns and pd.notna(geom_path['full_path'].iloc[0]):
636
+ full_path = geom_path['full_path'].iloc[0]
637
+ logger.info(f"Found geometry path: {full_path}")
638
+ return full_path
639
+ else:
640
+ # Fallback to constructing path
641
+ constructed_path = str(ras_obj.project_folder / f"{ras_obj.project_name}.g{geom_number}")
642
+ logger.info(f"Constructed geometry path: {constructed_path}")
643
+ return constructed_path
644
+ else:
645
+ logger.warning(f"No geometry file found with number: {geom_number}")
646
+ return None
647
+ except Exception as e:
648
+ logger.error(f"Error in get_geom_path: {str(e)}")
649
+ return None
650
+
651
+ # Clone Functions to copy unsteady, flow, and geometry files from templates
652
+
653
+ @staticmethod
654
+ @log_call
655
+ def clone_plan(template_plan, new_plan_shortid=None, ras_object=None):
656
+ """
657
+ Create a new plan file based on a template and update the project file.
658
+
659
+ Parameters:
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
662
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
663
+
664
+ Returns:
665
+ str: New plan number
666
+
667
+ 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
+
672
+ Note:
673
+ This function updates the ras object's dataframes after modifying the project structure.
674
+ """
675
+ ras_obj = ras_object or ras
676
+ ras_obj.check_initialized()
677
+
678
+ # Update plan entries without reinitializing the entire project
679
+ ras_obj.plan_df = ras_obj.get_prj_entries('Plan')
680
+
681
+ new_plan_num = RasPlan.get_next_number(ras_obj.plan_df['plan_number'])
682
+ template_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{template_plan}"
683
+ new_plan_path = ras_obj.project_folder / f"{ras_obj.project_name}.p{new_plan_num}"
684
+
685
+ def update_shortid(lines):
686
+ shortid_pattern = re.compile(r'^Short Identifier=(.*)$', re.IGNORECASE)
687
+ 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]
693
+ else:
694
+ new_shortid = new_plan_shortid[:24]
695
+ lines[i] = f"Short Identifier={new_shortid}\n"
696
+ break
697
+ return lines
698
+
699
+ # Use RasUtils to clone the file and update the short identifier
700
+ RasUtils.clone_file(template_plan_path, new_plan_path, update_shortid)
701
+
702
+ # Use RasUtils to update the project file
703
+ RasUtils.update_project_file(ras_obj.prj_file, 'Plan', new_plan_num, ras_object=ras_obj)
704
+
705
+ # Re-initialize the ras global object
706
+ ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
707
+
708
+ ras_obj.plan_df = ras_obj.get_plan_entries()
709
+ ras_obj.geom_df = ras_obj.get_geom_entries()
710
+ ras_obj.flow_df = ras_obj.get_flow_entries()
711
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
712
+
713
+ return new_plan_num
714
+
715
+ @staticmethod
716
+ @log_call
717
+ def clone_unsteady(template_unsteady, ras_object=None):
718
+ """
719
+ Copy unsteady flow files from a template, find the next unsteady number,
720
+ and update the project file accordingly.
721
+
722
+ Parameters:
723
+ template_unsteady (str): Unsteady flow number to be used as a template (e.g., '01')
724
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
725
+
726
+ Returns:
727
+ str: New unsteady flow number (e.g., '03')
728
+
729
+ Example:
730
+ >>> ras_plan = RasPlan()
731
+ >>> new_unsteady_num = ras_plan.clone_unsteady('01')
732
+ >>> print(f"New unsteady flow file created: u{new_unsteady_num}")
733
+
734
+ Note:
735
+ This function updates the ras object's dataframes after modifying the project structure.
736
+ """
737
+ ras_obj = ras_object or ras
738
+ ras_obj.check_initialized()
739
+
740
+ # Update unsteady entries without reinitializing the entire project
741
+ ras_obj.unsteady_df = ras_obj.get_prj_entries('Unsteady')
742
+
743
+ new_unsteady_num = RasPlan.get_next_number(ras_obj.unsteady_df['unsteady_number'])
744
+ template_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}"
745
+ new_unsteady_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_num}"
746
+
747
+ # Use RasUtils to clone the file
748
+ RasUtils.clone_file(template_unsteady_path, new_unsteady_path)
749
+
750
+ # Copy the corresponding .hdf file if it exists
751
+ template_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{template_unsteady}.hdf"
752
+ new_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.u{new_unsteady_num}.hdf"
753
+ if template_hdf_path.exists():
754
+ shutil.copy(template_hdf_path, new_hdf_path)
755
+
756
+ # Use RasUtils to update the project file
757
+ RasUtils.update_project_file(ras_obj.prj_file, 'Unsteady', new_unsteady_num, ras_object=ras_obj)
758
+
759
+ # Re-initialize the ras global object
760
+ ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
761
+
762
+ ras_obj.plan_df = ras_obj.get_plan_entries()
763
+ ras_obj.geom_df = ras_obj.get_geom_entries()
764
+ ras_obj.flow_df = ras_obj.get_flow_entries()
765
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
766
+
767
+ return new_unsteady_num
768
+
769
+
770
+ @staticmethod
771
+ @log_call
772
+ def clone_steady(template_flow, ras_object=None):
773
+ """
774
+ Copy steady flow files from a template, find the next flow number,
775
+ and update the project file accordingly.
776
+
777
+ Parameters:
778
+ template_flow (str): Flow number to be used as a template (e.g., '01')
779
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
780
+
781
+ Returns:
782
+ str: New flow number (e.g., '03')
783
+
784
+ Example:
785
+ >>> ras_plan = RasPlan()
786
+ >>> new_flow_num = ras_plan.clone_steady('01')
787
+ >>> print(f"New steady flow file created: f{new_flow_num}")
788
+
789
+ Note:
790
+ This function updates the ras object's dataframes after modifying the project structure.
791
+ """
792
+ ras_obj = ras_object or ras
793
+ ras_obj.check_initialized()
794
+
795
+ # Update flow entries without reinitializing the entire project
796
+ ras_obj.flow_df = ras_obj.get_prj_entries('Flow')
797
+
798
+ new_flow_num = RasPlan.get_next_number(ras_obj.flow_df['flow_number'])
799
+ template_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{template_flow}"
800
+ new_flow_path = ras_obj.project_folder / f"{ras_obj.project_name}.f{new_flow_num}"
801
+
802
+ # Use RasUtils to clone the file
803
+ RasUtils.clone_file(template_flow_path, new_flow_path)
804
+
805
+ # Use RasUtils to update the project file
806
+ RasUtils.update_project_file(ras_obj.prj_file, 'Flow', new_flow_num, ras_object=ras_obj)
807
+
808
+ # Re-initialize the ras global object
809
+ ras_obj.initialize(ras_obj.project_folder, ras_obj.ras_exe_path)
810
+
811
+ ras_obj.plan_df = ras_obj.get_plan_entries()
812
+ ras_obj.geom_df = ras_obj.get_geom_entries()
813
+ ras_obj.flow_df = ras_obj.get_flow_entries()
814
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
815
+
816
+ return new_flow_num
817
+
818
+ @staticmethod
819
+ @log_call
820
+ def clone_geom(template_geom, ras_object=None):
821
+ """
822
+ Copy geometry files from a template, find the next geometry number,
823
+ and update the project file accordingly.
824
+
825
+ Parameters:
826
+ template_geom (str): Geometry number to be used as a template (e.g., '01')
827
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
828
+
829
+ Returns:
830
+ str: New geometry number (e.g., '03')
831
+
832
+ Note:
833
+ This function updates the ras object's dataframes after modifying the project structure.
834
+ """
835
+ ras_obj = ras_object or ras
836
+ ras_obj.check_initialized()
837
+
838
+ # Update geometry entries without reinitializing the entire project
839
+ ras_obj.geom_df = ras_obj.get_prj_entries('Geom')
840
+
841
+ new_geom_num = RasPlan.get_next_number(ras_obj.geom_df['geom_number'])
842
+ template_geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{template_geom}"
843
+ new_geom_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom_num}"
844
+
845
+ # Use RasUtils to clone the file
846
+ RasUtils.clone_file(template_geom_path, new_geom_path)
847
+
848
+ # Handle HDF file copy
849
+ template_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{template_geom}.hdf"
850
+ new_hdf_path = ras_obj.project_folder / f"{ras_obj.project_name}.g{new_geom_num}.hdf"
851
+ if template_hdf_path.is_file():
852
+ RasUtils.clone_file(template_hdf_path, new_hdf_path)
853
+
854
+ # Use RasUtils to update the project file
855
+ RasUtils.update_project_file(ras_obj.prj_file, 'Geom', new_geom_num, ras_object=ras_obj)
856
+
857
+ # Update all dataframes in the ras object
858
+ ras_obj.plan_df = ras_obj.get_plan_entries()
859
+ ras_obj.geom_df = ras_obj.get_geom_entries()
860
+ ras_obj.flow_df = ras_obj.get_flow_entries()
861
+ ras_obj.unsteady_df = ras_obj.get_unsteady_entries()
862
+
863
+ return new_geom_num
864
+
865
+ @staticmethod
866
+ @log_call
867
+ def get_next_number(existing_numbers):
868
+ """
869
+ Determine the next available number from a list of existing numbers.
870
+
871
+ Parameters:
872
+ existing_numbers (list): List of existing numbers as strings
873
+
874
+ Returns:
875
+ str: Next available number as a zero-padded string
876
+
877
+ Example:
878
+ >>> existing_numbers = ['01', '02', '04']
879
+ >>> RasPlan.get_next_number(existing_numbers)
880
+ '03'
881
+ >>> existing_numbers = ['01', '02', '03']
882
+ >>> RasPlan.get_next_number(existing_numbers)
883
+ '04'
884
+ """
885
+ existing_numbers = sorted(int(num) for num in existing_numbers)
886
+ next_number = 1
887
+ for num in existing_numbers:
888
+ if num == next_number:
889
+ next_number += 1
890
+ else:
891
+ break
892
+ return f"{next_number:02d}"
893
+
894
+ @staticmethod
895
+ @log_call
896
+ def get_plan_value(
897
+ plan_number_or_path: Union[str, Path],
898
+ key: str,
899
+ ras_object=None
900
+ ) -> Any:
901
+ """
902
+ Retrieve a specific value from a HEC-RAS plan file.
903
+
904
+ Parameters:
905
+ plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
906
+ key (str): The key to retrieve from the plan file
907
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
908
+
909
+ Returns:
910
+ Any: The value associated with the specified key
911
+
912
+ Raises:
913
+ ValueError: If the plan file is not found
914
+ IOError: If there's an error reading the plan file
915
+
916
+ Available keys and their expected types:
917
+ - 'Computation Interval' (str): Time value for computational time step (e.g., '5SEC', '2MIN')
918
+ - 'DSS File' (str): Name of the DSS file used
919
+ - 'Flow File' (str): Name of the flow input file
920
+ - 'Friction Slope Method' (int): Method selection for friction slope (e.g., 1, 2)
921
+ - 'Geom File' (str): Name of the geometry input file
922
+ - 'Mapping Interval' (str): Time interval for mapping output
923
+ - 'Plan File' (str): Name of the plan file
924
+ - 'Plan Title' (str): Title of the simulation plan
925
+ - 'Program Version' (str): Version number of HEC-RAS
926
+ - 'Run HTab' (int): Flag to run HTab module (-1 or 1)
927
+ - 'Run Post Process' (int): Flag to run post-processing (-1 or 1)
928
+ - 'Run Sediment' (int): Flag to run sediment transport module (0 or 1)
929
+ - 'Run UNET' (int): Flag to run unsteady network module (-1 or 1)
930
+ - 'Run WQNET' (int): Flag to run water quality module (0 or 1)
931
+ - 'Short Identifier' (str): Short name or ID for the plan
932
+ - 'Simulation Date' (str): Start and end dates/times for simulation
933
+ - 'UNET D1 Cores' (int): Number of cores used in 1D calculations
934
+ - 'UNET D2 Cores' (int): Number of cores used in 2D calculations
935
+ - 'PS Cores' (int): Number of cores used in parallel simulation
936
+ - 'UNET Use Existing IB Tables' (int): Flag for using existing internal boundary tables (-1, 0, or 1)
937
+ - 'UNET 1D Methodology' (str): 1D calculation methodology
938
+ - 'UNET D2 Solver Type' (str): 2D solver type
939
+ - 'UNET D2 Name' (str): Name of the 2D area
940
+ - 'Run RASMapper' (int): Flag to run RASMapper for floodplain mapping (-1 for off, 0 for on)
941
+
942
+ Note:
943
+ Writing Multi line keys like 'Description' are not supported by this function.
944
+
945
+ Example:
946
+ >>> computation_interval = RasPlan.get_plan_value("01", "Computation Interval")
947
+ >>> print(f"Computation interval: {computation_interval}")
948
+ """
949
+ ras_obj = ras_object or ras
950
+ ras_obj.check_initialized()
951
+
952
+ supported_plan_keys = {
953
+ 'Description', 'Computation Interval', 'DSS File', 'Flow File', 'Friction Slope Method',
954
+ 'Geom File', 'Mapping Interval', 'Plan File', 'Plan Title', 'Program Version',
955
+ 'Run HTab', 'Run Post Process', 'Run Sediment', 'Run UNET', 'Run WQNET',
956
+ 'Short Identifier', 'Simulation Date', 'UNET D1 Cores', 'UNET D2 Cores', 'PS Cores',
957
+ 'UNET Use Existing IB Tables', 'UNET 1D Methodology', 'UNET D2 Solver Type',
958
+ 'UNET D2 Name', 'Run RASMapper', 'Run HTab', 'Run UNET'
959
+ }
960
+
961
+ if key not in supported_plan_keys:
962
+ logger = logging.getLogger(__name__)
963
+ logger.warning(f"Unknown key: {key}. Valid keys are: {', '.join(supported_plan_keys)}\n Add more keys and explanations in get_plan_value() as needed.")
964
+
965
+ plan_file_path = Path(plan_number_or_path)
966
+ if not plan_file_path.is_file():
967
+ plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
968
+ if plan_file_path is None or not Path(plan_file_path).exists():
969
+ raise ValueError(f"Plan file not found: {plan_file_path}")
970
+
971
+ try:
972
+ with open(plan_file_path, 'r') as file:
973
+ content = file.read()
974
+ except IOError as e:
975
+ logger = logging.getLogger(__name__)
976
+ logger.error(f"Error reading plan file {plan_file_path}: {e}")
977
+ raise
978
+
979
+ # Handle core settings specially to convert to integers
980
+ core_keys = {'UNET D1 Cores', 'UNET D2 Cores', 'PS Cores'}
981
+ if key in core_keys:
982
+ pattern = f"{key}=(.*)"
983
+ match = re.search(pattern, content)
984
+ if match:
985
+ try:
986
+ return int(match.group(1).strip())
987
+ except ValueError:
988
+ logger = logging.getLogger(__name__)
989
+ logger.error(f"Could not convert {key} value to integer")
990
+ return None
991
+ else:
992
+ logger = logging.getLogger(__name__)
993
+ logger.error(f"Key '{key}' not found in the plan file.")
994
+ return None
995
+ elif key == 'Description':
996
+ match = re.search(r'Begin DESCRIPTION(.*?)END DESCRIPTION', content, re.DOTALL)
997
+ return match.group(1).strip() if match else None
998
+ else:
999
+ pattern = f"{key}=(.*)"
1000
+ match = re.search(pattern, content)
1001
+ if match:
1002
+ return match.group(1).strip()
1003
+ else:
1004
+ logger = logging.getLogger(__name__)
1005
+ logger.error(f"Key '{key}' not found in the plan file.")
1006
+ return None
1007
+
1008
+
1009
+
1010
+
1011
+
1012
+ @staticmethod
1013
+ @log_call
1014
+ def update_run_flags(
1015
+ plan_number_or_path: Union[str, Path],
1016
+ geometry_preprocessor: bool = None,
1017
+ unsteady_flow_simulation: bool = None,
1018
+ run_sediment: bool = None,
1019
+ post_processor: bool = None,
1020
+ floodplain_mapping: bool = None,
1021
+ ras_object=None
1022
+ ) -> None:
1023
+ """
1024
+ Update the run flags in a HEC-RAS plan file.
1025
+
1026
+ Parameters:
1027
+ plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
1028
+ geometry_preprocessor (bool, optional): Flag for Geometry Preprocessor
1029
+ unsteady_flow_simulation (bool, optional): Flag for Unsteady Flow Simulation
1030
+ run_sediment (bool, optional): Flag for run_sediment
1031
+ post_processor (bool, optional): Flag for Post Processor
1032
+ floodplain_mapping (bool, optional): Flag for Floodplain Mapping
1033
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
1034
+
1035
+ Raises:
1036
+ ValueError: If the plan file is not found
1037
+ IOError: If there's an error reading or writing the plan file
1038
+
1039
+ Example:
1040
+ >>> RasPlan.update_run_flags("01", geometry_preprocessor=True, unsteady_flow_simulation=True, run_sediment=False, post_processor=True, floodplain_mapping=False)
1041
+ """
1042
+ ras_obj = ras_object or ras
1043
+ ras_obj.check_initialized()
1044
+
1045
+ plan_file_path = Path(plan_number_or_path)
1046
+ if not plan_file_path.is_file():
1047
+ plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
1048
+ if plan_file_path is None or not Path(plan_file_path).exists():
1049
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1050
+
1051
+ flag_mapping = {
1052
+ 'geometry_preprocessor': ('Run HTab', geometry_preprocessor),
1053
+ 'unsteady_flow_simulation': ('Run UNet', unsteady_flow_simulation),
1054
+ 'run_sediment': ('Run run_sediment', run_sediment),
1055
+ 'post_processor': ('Run PostProcess', post_processor),
1056
+ 'floodplain_mapping': ('Run RASMapper', floodplain_mapping)
1057
+ }
1058
+
1059
+ try:
1060
+ with open(plan_file_path, 'r') as file:
1061
+ lines = file.readlines()
1062
+
1063
+ for i, line in enumerate(lines):
1064
+ for key, (file_key, value) in flag_mapping.items():
1065
+ if value is not None and line.strip().startswith(file_key):
1066
+ lines[i] = f"{file_key}= {1 if value else 0}\n"
1067
+
1068
+ with open(plan_file_path, 'w') as file:
1069
+ file.writelines(lines)
1070
+
1071
+ logger = logging.getLogger(__name__)
1072
+ logger.info(f"Successfully updated run flags in plan file: {plan_file_path}")
1073
+
1074
+ except IOError as e:
1075
+ logger = logging.getLogger(__name__)
1076
+ logger.error(f"Error updating run flags in plan file {plan_file_path}: {e}")
1077
+ raise
1078
+
1079
+
1080
+
1081
+ @staticmethod
1082
+ @log_call
1083
+ def update_plan_intervals(
1084
+ plan_number_or_path: Union[str, Path],
1085
+ computation_interval: Optional[str] = None,
1086
+ output_interval: Optional[str] = None,
1087
+ instantaneous_interval: Optional[str] = None,
1088
+ mapping_interval: Optional[str] = None,
1089
+ ras_object=None
1090
+ ) -> None:
1091
+ """
1092
+ Update the computation and output intervals in a HEC-RAS plan file.
1093
+
1094
+ Parameters:
1095
+ plan_number_or_path (Union[str, Path]): The plan number (1 to 99) or full path to the plan file
1096
+ computation_interval (Optional[str]): The new computation interval. Valid entries include:
1097
+ '1SEC', '2SEC', '3SEC', '4SEC', '5SEC', '6SEC', '10SEC', '15SEC', '20SEC', '30SEC',
1098
+ '1MIN', '2MIN', '3MIN', '4MIN', '5MIN', '6MIN', '10MIN', '15MIN', '20MIN', '30MIN',
1099
+ '1HOUR', '2HOUR', '3HOUR', '4HOUR', '6HOUR', '8HOUR', '12HOUR', '1DAY'
1100
+ output_interval (Optional[str]): The new output interval. Valid entries are the same as computation_interval.
1101
+ instantaneous_interval (Optional[str]): The new instantaneous interval. Valid entries are the same as computation_interval.
1102
+ mapping_interval (Optional[str]): The new mapping interval. Valid entries are the same as computation_interval.
1103
+ ras_object (RasPrj, optional): Specific RAS object to use. If None, uses the global ras instance.
1104
+
1105
+ Raises:
1106
+ ValueError: If the plan file is not found or if an invalid interval is provided
1107
+ IOError: If there's an error reading or writing the plan file
1108
+
1109
+ Note: This function does not check if the intervals are equal divisors. Ensure you use valid values from HEC-RAS.
1110
+
1111
+ Example:
1112
+ >>> RasPlan.update_plan_intervals("01", computation_interval="5SEC", output_interval="1MIN", instantaneous_interval="1HOUR", mapping_interval="5MIN")
1113
+ >>> RasPlan.update_plan_intervals("/path/to/plan.p01", computation_interval="10SEC", output_interval="30SEC")
1114
+ """
1115
+ ras_obj = ras_object or ras
1116
+ ras_obj.check_initialized()
1117
+
1118
+ plan_file_path = Path(plan_number_or_path)
1119
+ if not plan_file_path.is_file():
1120
+ plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object=ras_obj)
1121
+ if plan_file_path is None or not Path(plan_file_path).exists():
1122
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1123
+
1124
+ valid_intervals = [
1125
+ '1SEC', '2SEC', '3SEC', '4SEC', '5SEC', '6SEC', '10SEC', '15SEC', '20SEC', '30SEC',
1126
+ '1MIN', '2MIN', '3MIN', '4MIN', '5MIN', '6MIN', '10MIN', '15MIN', '20MIN', '30MIN',
1127
+ '1HOUR', '2HOUR', '3HOUR', '4HOUR', '6HOUR', '8HOUR', '12HOUR', '1DAY'
1128
+ ]
1129
+
1130
+ interval_mapping = {
1131
+ 'Computation Interval': computation_interval,
1132
+ 'Output Interval': output_interval,
1133
+ 'Instantaneous Interval': instantaneous_interval,
1134
+ 'Mapping Interval': mapping_interval
1135
+ }
1136
+
1137
+ try:
1138
+ with open(plan_file_path, 'r') as file:
1139
+ lines = file.readlines()
1140
+
1141
+ for i, line in enumerate(lines):
1142
+ for key, value in interval_mapping.items():
1143
+ if value is not None:
1144
+ if value.upper() not in valid_intervals:
1145
+ raise ValueError(f"Invalid {key}: {value}. Must be one of {valid_intervals}")
1146
+ if line.strip().startswith(key):
1147
+ lines[i] = f"{key}={value.upper()}\n"
1148
+
1149
+ with open(plan_file_path, 'w') as file:
1150
+ file.writelines(lines)
1151
+
1152
+ logger = logging.getLogger(__name__)
1153
+ logger.info(f"Successfully updated intervals in plan file: {plan_file_path}")
1154
+
1155
+ except IOError as e:
1156
+ logger = logging.getLogger(__name__)
1157
+ logger.error(f"Error updating intervals in plan file {plan_file_path}: {e}")
1158
+ raise
1159
+
1160
+
1161
+ @log_call
1162
+ def update_plan_description(plan_number_or_path: Union[str, Path], description: str, ras_object: Optional['RasPrj'] = None) -> None:
1163
+ """
1164
+ Update the description block in a HEC-RAS plan file.
1165
+
1166
+ Args:
1167
+ plan_number_or_path (Union[str, Path]): The plan number or full path to the plan file
1168
+ description (str): The new description text to set
1169
+ ras_object (Optional[RasPrj]): Specific RAS object to use. If None, uses the global ras instance.
1170
+
1171
+ Raises:
1172
+ ValueError: If the plan file is not found
1173
+ IOError: If there's an error reading or writing the plan file
1174
+ """
1175
+ logger = get_logger(__name__)
1176
+ ras_obj = ras_object or ras
1177
+ ras_obj.check_initialized()
1178
+
1179
+ plan_file_path = Path(plan_number_or_path)
1180
+ if not plan_file_path.is_file():
1181
+ plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_object)
1182
+ if not plan_file_path.exists():
1183
+ logger.error(f"Plan file not found: {plan_file_path}")
1184
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1185
+
1186
+ try:
1187
+ with open(plan_file_path, 'r') as file:
1188
+ content = file.read()
1189
+
1190
+ # Find the description block
1191
+ desc_pattern = r'Begin DESCRIPTION.*?END DESCRIPTION'
1192
+ new_desc_block = f'Begin DESCRIPTION\n{description}\nEND DESCRIPTION'
1193
+
1194
+ if re.search(desc_pattern, content, re.DOTALL):
1195
+ # Replace existing description block
1196
+ new_content = re.sub(desc_pattern, new_desc_block, content, flags=re.DOTALL)
1197
+ else:
1198
+ # Add new description block at the start of the file
1199
+ new_content = new_desc_block + '\n' + content
1200
+
1201
+ # Write the updated content back to the file
1202
+ with open(plan_file_path, 'w') as file:
1203
+ file.write(new_content)
1204
+
1205
+ logger.info(f"Updated description in plan file: {plan_file_path}")
1206
+
1207
+ # Update the dataframes in the RAS object to reflect changes
1208
+ if ras_object:
1209
+ ras_object.plan_df = ras_object.get_plan_entries()
1210
+ ras_object.geom_df = ras_object.get_geom_entries()
1211
+ ras_object.flow_df = ras_object.get_flow_entries()
1212
+ ras_object.unsteady_df = ras_object.get_unsteady_entries()
1213
+
1214
+ except IOError as e:
1215
+ logger.error(f"Error updating plan description in {plan_file_path}: {e}")
1216
+ raise
1217
+ except Exception as e:
1218
+ logger.error(f"Unexpected error updating plan description: {e}")
1219
+ raise
1220
+
1221
+ @staticmethod
1222
+ @log_call
1223
+ def read_plan_description(plan_number_or_path: Union[str, Path], ras_object: Optional['RasPrj'] = None) -> str:
1224
+ """
1225
+ Read the description from the plan file.
1226
+
1227
+ Args:
1228
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1229
+ ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1230
+
1231
+ Returns:
1232
+ str: The description from the plan file.
1233
+
1234
+ Raises:
1235
+ ValueError: If the plan file is not found.
1236
+ IOError: If there's an error reading from the plan file.
1237
+ """
1238
+ logger = logging.getLogger(__name__)
1239
+
1240
+ plan_file_path = Path(plan_number_or_path)
1241
+ if not plan_file_path.is_file():
1242
+ plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object)
1243
+ if plan_file_path is None or not Path(plan_file_path).exists():
1244
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1245
+
1246
+ try:
1247
+ with open(plan_file_path, 'r') as file:
1248
+ lines = file.readlines()
1249
+ except IOError as e:
1250
+ logger.error(f"Error reading plan file {plan_file_path}: {e}")
1251
+ raise
1252
+
1253
+ description_lines = []
1254
+ in_description = False
1255
+ description_found = False
1256
+ for line in lines:
1257
+ if line.strip() == "BEGIN DESCRIPTION:":
1258
+ in_description = True
1259
+ description_found = True
1260
+ elif line.strip() == "END DESCRIPTION:":
1261
+ break
1262
+ elif in_description:
1263
+ description_lines.append(line.strip())
1264
+
1265
+ if not description_found:
1266
+ logger.warning(f"No description found in plan file: {plan_file_path}")
1267
+ return ""
1268
+
1269
+ description = '\n'.join(description_lines)
1270
+ logger.info(f"Read description from plan file: {plan_file_path}")
1271
+ return description
1272
+
1273
+
1274
+
1275
+
1276
+ @staticmethod
1277
+ @log_call
1278
+ def update_simulation_date(plan_number_or_path: Union[str, Path], start_date: datetime, end_date: datetime, ras_object: Optional['RasPrj'] = None) -> None:
1279
+ """
1280
+ Update the simulation date for a given plan.
1281
+
1282
+ Args:
1283
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1284
+ start_date (datetime): The start date and time for the simulation.
1285
+ end_date (datetime): The end date and time for the simulation.
1286
+ ras_object (Optional['RasPrj']): The RAS project object. Defaults to None.
1287
+
1288
+ Raises:
1289
+ ValueError: If the plan file is not found or if there's an error updating the file.
1290
+ """
1291
+
1292
+ # Get the plan file path
1293
+ plan_file_path = Path(plan_number_or_path)
1294
+ if not plan_file_path.is_file():
1295
+ plan_file_path = RasPlan.get_plan_path(plan_number_or_path, ras_object)
1296
+ if plan_file_path is None or not Path(plan_file_path).exists():
1297
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1298
+
1299
+ # Format the dates
1300
+ formatted_date = f"{start_date.strftime('%d%b%Y').upper()},{start_date.strftime('%H%M')},{end_date.strftime('%d%b%Y').upper()},{end_date.strftime('%H%M')}"
1301
+
1302
+ try:
1303
+ # Read the file
1304
+ with open(plan_file_path, 'r') as file:
1305
+ lines = file.readlines()
1306
+
1307
+ # Update the Simulation Date line
1308
+ updated = False
1309
+ for i, line in enumerate(lines):
1310
+ if line.startswith("Simulation Date="):
1311
+ lines[i] = f"Simulation Date={formatted_date}\n"
1312
+ updated = True
1313
+ break
1314
+
1315
+ # If Simulation Date line not found, add it at the end
1316
+ if not updated:
1317
+ lines.append(f"Simulation Date={formatted_date}\n")
1318
+
1319
+ # Write the updated content back to the file
1320
+ with open(plan_file_path, 'w') as file:
1321
+ file.writelines(lines)
1322
+
1323
+ logger.info(f"Updated simulation date in plan file: {plan_file_path}")
1324
+
1325
+ except IOError as e:
1326
+ logger.error(f"Error updating simulation date in plan file {plan_file_path}: {e}")
1327
+ raise ValueError(f"Error updating simulation date: {e}")
1328
+
1329
+ # Refresh RasPrj dataframes
1330
+ if ras_object:
1331
+ ras_object.plan_df = ras_object.get_plan_entries()
1332
+ ras_object.unsteady_df = ras_object.get_unsteady_entries()
1333
+
1334
+ @staticmethod
1335
+ @log_call
1336
+ def get_shortid(plan_number_or_path: Union[str, Path], ras_object=None) -> str:
1337
+ """
1338
+ Get the Short Identifier from a HEC-RAS plan file.
1339
+
1340
+ Args:
1341
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1342
+ ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1343
+
1344
+ Returns:
1345
+ str: The Short Identifier from the plan file.
1346
+
1347
+ Raises:
1348
+ ValueError: If the plan file is not found.
1349
+ IOError: If there's an error reading from the plan file.
1350
+
1351
+ Example:
1352
+ >>> shortid = RasPlan.get_shortid('01')
1353
+ >>> print(f"Plan's Short Identifier: {shortid}")
1354
+ """
1355
+ logger = get_logger(__name__)
1356
+ ras_obj = ras_object or ras
1357
+ ras_obj.check_initialized()
1358
+
1359
+ # Get the Short Identifier using get_plan_value
1360
+ shortid = RasPlan.get_plan_value(plan_number_or_path, "Short Identifier", ras_obj)
1361
+
1362
+ if shortid is None:
1363
+ logger.warning(f"Short Identifier not found in plan: {plan_number_or_path}")
1364
+ return ""
1365
+
1366
+ logger.info(f"Retrieved Short Identifier: {shortid}")
1367
+ return shortid
1368
+
1369
+ @staticmethod
1370
+ @log_call
1371
+ def set_shortid(plan_number_or_path: Union[str, Path], new_shortid: str, ras_object=None) -> None:
1372
+ """
1373
+ Set the Short Identifier in a HEC-RAS plan file.
1374
+
1375
+ Args:
1376
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1377
+ new_shortid (str): The new Short Identifier to set (max 24 characters).
1378
+ ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1379
+
1380
+ Raises:
1381
+ ValueError: If the plan file is not found or if new_shortid is too long.
1382
+ IOError: If there's an error updating the plan file.
1383
+
1384
+ Example:
1385
+ >>> RasPlan.set_shortid('01', 'NewShortIdentifier')
1386
+ """
1387
+ logger = get_logger(__name__)
1388
+ ras_obj = ras_object or ras
1389
+ ras_obj.check_initialized()
1390
+
1391
+ # Ensure new_shortid is not too long (HEC-RAS limits short identifiers to 24 characters)
1392
+ if len(new_shortid) > 24:
1393
+ logger.warning(f"Short Identifier too long (24 char max). Truncating: {new_shortid}")
1394
+ new_shortid = new_shortid[:24]
1395
+
1396
+ # Get the plan file path
1397
+ plan_file_path = Path(plan_number_or_path)
1398
+ if not plan_file_path.is_file():
1399
+ plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_obj)
1400
+ if not plan_file_path.exists():
1401
+ logger.error(f"Plan file not found: {plan_file_path}")
1402
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1403
+
1404
+ try:
1405
+ # Read the file
1406
+ with open(plan_file_path, 'r') as file:
1407
+ lines = file.readlines()
1408
+
1409
+ # Update the Short Identifier line
1410
+ updated = False
1411
+ for i, line in enumerate(lines):
1412
+ if line.startswith("Short Identifier="):
1413
+ lines[i] = f"Short Identifier={new_shortid}\n"
1414
+ updated = True
1415
+ break
1416
+
1417
+ # If Short Identifier line not found, add it after Plan Title
1418
+ if not updated:
1419
+ for i, line in enumerate(lines):
1420
+ if line.startswith("Plan Title="):
1421
+ lines.insert(i+1, f"Short Identifier={new_shortid}\n")
1422
+ updated = True
1423
+ break
1424
+
1425
+ # If Plan Title not found either, add at the beginning
1426
+ if not updated:
1427
+ lines.insert(0, f"Short Identifier={new_shortid}\n")
1428
+
1429
+ # Write the updated content back to the file
1430
+ with open(plan_file_path, 'w') as file:
1431
+ file.writelines(lines)
1432
+
1433
+ logger.info(f"Updated Short Identifier in plan file to: {new_shortid}")
1434
+
1435
+ except IOError as e:
1436
+ logger.error(f"Error updating Short Identifier in plan file {plan_file_path}: {e}")
1437
+ raise ValueError(f"Error updating Short Identifier: {e}")
1438
+
1439
+ # Refresh RasPrj dataframes if ras_object provided
1440
+ if ras_object:
1441
+ ras_object.plan_df = ras_object.get_plan_entries()
1442
+
1443
+ @staticmethod
1444
+ @log_call
1445
+ def get_plan_title(plan_number_or_path: Union[str, Path], ras_object=None) -> str:
1446
+ """
1447
+ Get the Plan Title from a HEC-RAS plan file.
1448
+
1449
+ Args:
1450
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1451
+ ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1452
+
1453
+ Returns:
1454
+ str: The Plan Title from the plan file.
1455
+
1456
+ Raises:
1457
+ ValueError: If the plan file is not found.
1458
+ IOError: If there's an error reading from the plan file.
1459
+
1460
+ Example:
1461
+ >>> title = RasPlan.get_plan_title('01')
1462
+ >>> print(f"Plan Title: {title}")
1463
+ """
1464
+ logger = get_logger(__name__)
1465
+ ras_obj = ras_object or ras
1466
+ ras_obj.check_initialized()
1467
+
1468
+ # Get the Plan Title using get_plan_value
1469
+ title = RasPlan.get_plan_value(plan_number_or_path, "Plan Title", ras_obj)
1470
+
1471
+ if title is None:
1472
+ logger.warning(f"Plan Title not found in plan: {plan_number_or_path}")
1473
+ return ""
1474
+
1475
+ logger.info(f"Retrieved Plan Title: {title}")
1476
+ return title
1477
+
1478
+ @staticmethod
1479
+ @log_call
1480
+ def set_plan_title(plan_number_or_path: Union[str, Path], new_title: str, ras_object=None) -> None:
1481
+ """
1482
+ Set the Plan Title in a HEC-RAS plan file.
1483
+
1484
+ Args:
1485
+ plan_number_or_path (Union[str, Path]): The plan number or path to the plan file.
1486
+ new_title (str): The new Plan Title to set.
1487
+ ras_object (Optional[RasPrj]): The RAS project object. If None, uses the global 'ras' object.
1488
+
1489
+ Raises:
1490
+ ValueError: If the plan file is not found.
1491
+ IOError: If there's an error updating the plan file.
1492
+
1493
+ Example:
1494
+ >>> RasPlan.set_plan_title('01', 'Updated Plan Scenario')
1495
+ """
1496
+ logger = get_logger(__name__)
1497
+ ras_obj = ras_object or ras
1498
+ ras_obj.check_initialized()
1499
+
1500
+ # Get the plan file path
1501
+ plan_file_path = Path(plan_number_or_path)
1502
+ if not plan_file_path.is_file():
1503
+ plan_file_path = RasUtils.get_plan_path(plan_number_or_path, ras_obj)
1504
+ if not plan_file_path.exists():
1505
+ logger.error(f"Plan file not found: {plan_file_path}")
1506
+ raise ValueError(f"Plan file not found: {plan_file_path}")
1507
+
1508
+ try:
1509
+ # Read the file
1510
+ with open(plan_file_path, 'r') as file:
1511
+ lines = file.readlines()
1512
+
1513
+ # Update the Plan Title line
1514
+ updated = False
1515
+ for i, line in enumerate(lines):
1516
+ if line.startswith("Plan Title="):
1517
+ lines[i] = f"Plan Title={new_title}\n"
1518
+ updated = True
1519
+ break
1520
+
1521
+ # If Plan Title line not found, add it at the beginning
1522
+ if not updated:
1523
+ lines.insert(0, f"Plan Title={new_title}\n")
1524
+
1525
+ # Write the updated content back to the file
1526
+ with open(plan_file_path, 'w') as file:
1527
+ file.writelines(lines)
1528
+
1529
+ logger.info(f"Updated Plan Title in plan file to: {new_title}")
1530
+
1531
+ except IOError as e:
1532
+ logger.error(f"Error updating Plan Title in plan file {plan_file_path}: {e}")
1533
+ raise ValueError(f"Error updating Plan Title: {e}")
1534
+
1535
+ # Refresh RasPrj dataframes if ras_object provided
1536
+ if ras_object:
1537
1537
  ras_object.plan_df = ras_object.get_plan_entries()