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/RasMap.py CHANGED
@@ -1,252 +1,467 @@
1
- """
2
- RasMap - Parses HEC-RAS mapper configuration files (.rasmap)
3
-
4
- This module provides functionality to extract and organize information from
5
- HEC-RAS mapper configuration files, including paths to terrain, soil, and land cover data.
6
-
7
- This module is part of the ras-commander library and uses a centralized logging configuration.
8
-
9
- Logging Configuration:
10
- - The logging is set up in the logging_config.py file.
11
- - A @log_call decorator is available to automatically log function calls.
12
- - Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
13
-
14
- Classes:
15
- RasMap: Class for parsing and accessing HEC-RAS mapper configuration.
16
- """
17
-
18
- import os
19
- import re
20
- import xml.etree.ElementTree as ET
21
- from pathlib import Path
22
- import pandas as pd
23
- from typing import Union, Optional, Dict, List, Any
24
-
25
- from .RasPrj import ras
26
- from .LoggingConfig import get_logger
27
- from .Decorators import log_call
28
-
29
- logger = get_logger(__name__)
30
-
31
- class RasMap:
32
- """
33
- Class for parsing and accessing information from HEC-RAS mapper configuration files (.rasmap).
34
-
35
- This class provides methods to extract paths to terrain, soil, land cover data,
36
- and various project settings from the .rasmap file associated with a HEC-RAS project.
37
- """
38
-
39
- @staticmethod
40
- @log_call
41
- def parse_rasmap(rasmap_path: Union[str, Path], ras_object=None) -> pd.DataFrame:
42
- """
43
- Parse a .rasmap file and extract relevant information.
44
-
45
- Args:
46
- rasmap_path (Union[str, Path]): Path to the .rasmap file.
47
- ras_object: Optional RAS object instance.
48
-
49
- Returns:
50
- pd.DataFrame: DataFrame containing extracted information from the .rasmap file.
51
- """
52
- ras_obj = ras_object or ras
53
- ras_obj.check_initialized()
54
-
55
- rasmap_path = Path(rasmap_path)
56
- if not rasmap_path.exists():
57
- logger.error(f"RASMapper file not found: {rasmap_path}")
58
- # Create a single row DataFrame with all empty values
59
- return pd.DataFrame({
60
- 'projection_path': [None],
61
- 'profile_lines_path': [[]],
62
- 'soil_layer_path': [[]],
63
- 'infiltration_hdf_path': [[]],
64
- 'landcover_hdf_path': [[]],
65
- 'terrain_hdf_path': [[]],
66
- 'current_settings': [{}]
67
- })
68
-
69
- try:
70
- # Initialize data for the DataFrame - just one row with lists
71
- data = {
72
- 'projection_path': [None],
73
- 'profile_lines_path': [[]],
74
- 'soil_layer_path': [[]],
75
- 'infiltration_hdf_path': [[]],
76
- 'landcover_hdf_path': [[]],
77
- 'terrain_hdf_path': [[]],
78
- 'current_settings': [{}]
79
- }
80
-
81
- # Read the file content
82
- with open(rasmap_path, 'r', encoding='utf-8') as f:
83
- xml_content = f.read()
84
-
85
- # Check if it's a valid XML file
86
- if not xml_content.strip().startswith('<'):
87
- logger.error(f"File does not appear to be valid XML: {rasmap_path}")
88
- return pd.DataFrame(data)
89
-
90
- # Parse the XML file
91
- try:
92
- tree = ET.parse(rasmap_path)
93
- root = tree.getroot()
94
- except ET.ParseError as e:
95
- logger.error(f"Error parsing XML in {rasmap_path}: {e}")
96
- return pd.DataFrame(data)
97
-
98
- # Helper function to convert relative paths to absolute paths
99
- def to_absolute_path(relative_path: str) -> str:
100
- if not relative_path:
101
- return None
102
- # Remove any leading .\ or ./
103
- relative_path = relative_path.lstrip('.\\').lstrip('./')
104
- # Convert to absolute path relative to project folder
105
- return str(ras_obj.project_folder / relative_path)
106
-
107
- # Extract projection path
108
- try:
109
- projection_elem = root.find(".//RASProjectionFilename")
110
- if projection_elem is not None and 'Filename' in projection_elem.attrib:
111
- data['projection_path'][0] = to_absolute_path(projection_elem.attrib['Filename'])
112
- except Exception as e:
113
- logger.warning(f"Error extracting projection path: {e}")
114
-
115
- # Extract profile lines path
116
- try:
117
- profile_lines_elem = root.find(".//Features/Layer[@Name='Profile Lines']")
118
- if profile_lines_elem is not None and 'Filename' in profile_lines_elem.attrib:
119
- data['profile_lines_path'][0].append(to_absolute_path(profile_lines_elem.attrib['Filename']))
120
- except Exception as e:
121
- logger.warning(f"Error extracting profile lines path: {e}")
122
-
123
- # Extract soil layer paths
124
- try:
125
- soil_layers = root.findall(".//Layer[@Name='Hydrologic Soil Groups']")
126
- for layer in soil_layers:
127
- if 'Filename' in layer.attrib:
128
- data['soil_layer_path'][0].append(to_absolute_path(layer.attrib['Filename']))
129
- except Exception as e:
130
- logger.warning(f"Error extracting soil layer paths: {e}")
131
-
132
- # Extract infiltration HDF paths
133
- try:
134
- infiltration_layers = root.findall(".//Layer[@Name='Infiltration']")
135
- for layer in infiltration_layers:
136
- if 'Filename' in layer.attrib:
137
- data['infiltration_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
138
- except Exception as e:
139
- logger.warning(f"Error extracting infiltration HDF paths: {e}")
140
-
141
- # Extract landcover HDF paths
142
- try:
143
- landcover_layers = root.findall(".//Layer[@Name='LandCover']")
144
- for layer in landcover_layers:
145
- if 'Filename' in layer.attrib:
146
- data['landcover_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
147
- except Exception as e:
148
- logger.warning(f"Error extracting landcover HDF paths: {e}")
149
-
150
- # Extract terrain HDF paths
151
- try:
152
- terrain_layers = root.findall(".//Terrains/Layer")
153
- for layer in terrain_layers:
154
- if 'Filename' in layer.attrib:
155
- data['terrain_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
156
- except Exception as e:
157
- logger.warning(f"Error extracting terrain HDF paths: {e}")
158
-
159
- # Extract current settings
160
- current_settings = {}
161
- try:
162
- settings_elem = root.find(".//CurrentSettings")
163
- if settings_elem is not None:
164
- # Extract ProjectSettings
165
- project_settings_elem = settings_elem.find("ProjectSettings")
166
- if project_settings_elem is not None:
167
- for child in project_settings_elem:
168
- current_settings[child.tag] = child.text
169
-
170
- # Extract Folders
171
- folders_elem = settings_elem.find("Folders")
172
- if folders_elem is not None:
173
- for child in folders_elem:
174
- current_settings[child.tag] = child.text
175
-
176
- data['current_settings'][0] = current_settings
177
- except Exception as e:
178
- logger.warning(f"Error extracting current settings: {e}")
179
-
180
- # Create DataFrame
181
- df = pd.DataFrame(data)
182
- logger.info(f"Successfully parsed RASMapper file: {rasmap_path}")
183
- return df
184
-
185
- except Exception as e:
186
- logger.error(f"Unexpected error processing RASMapper file {rasmap_path}: {e}")
187
- # Create a single row DataFrame with all empty values
188
- return pd.DataFrame({
189
- 'projection_path': [None],
190
- 'profile_lines_path': [[]],
191
- 'soil_layer_path': [[]],
192
- 'infiltration_hdf_path': [[]],
193
- 'landcover_hdf_path': [[]],
194
- 'terrain_hdf_path': [[]],
195
- 'current_settings': [{}]
196
- })
197
-
198
- @staticmethod
199
- @log_call
200
- def get_rasmap_path(ras_object=None) -> Optional[Path]:
201
- """
202
- Get the path to the .rasmap file based on the current project.
203
-
204
- Args:
205
- ras_object: Optional RAS object instance.
206
-
207
- Returns:
208
- Optional[Path]: Path to the .rasmap file if found, None otherwise.
209
- """
210
- ras_obj = ras_object or ras
211
- ras_obj.check_initialized()
212
-
213
- project_name = ras_obj.project_name
214
- project_folder = ras_obj.project_folder
215
- rasmap_path = project_folder / f"{project_name}.rasmap"
216
-
217
- if not rasmap_path.exists():
218
- logger.warning(f"RASMapper file not found: {rasmap_path}")
219
- return None
220
-
221
- return rasmap_path
222
-
223
- @staticmethod
224
- @log_call
225
- def initialize_rasmap_df(ras_object=None) -> pd.DataFrame:
226
- """
227
- Initialize the rasmap_df as part of project initialization.
228
-
229
- Args:
230
- ras_object: Optional RAS object instance.
231
-
232
- Returns:
233
- pd.DataFrame: DataFrame containing information from the .rasmap file.
234
- """
235
- ras_obj = ras_object or ras
236
- ras_obj.check_initialized()
237
-
238
- rasmap_path = RasMap.get_rasmap_path(ras_obj)
239
- if rasmap_path is None:
240
- logger.warning("No .rasmap file found for this project. Creating empty rasmap_df.")
241
- # Create a single row DataFrame with all empty values
242
- return pd.DataFrame({
243
- 'projection_path': [None],
244
- 'profile_lines_path': [[]],
245
- 'soil_layer_path': [[]],
246
- 'infiltration_hdf_path': [[]],
247
- 'landcover_hdf_path': [[]],
248
- 'terrain_hdf_path': [[]],
249
- 'current_settings': [{}]
250
- })
251
-
252
- return RasMap.parse_rasmap(rasmap_path, ras_obj)
1
+ """
2
+ RasMap - Parses HEC-RAS mapper configuration files (.rasmap)
3
+
4
+ This module provides functionality to extract and organize information from
5
+ HEC-RAS mapper configuration files, including paths to terrain, soil, and land cover data.
6
+ It also includes functions to automate the post-processing of stored maps.
7
+
8
+ This module is part of the ras-commander library and uses a centralized logging configuration.
9
+
10
+ Logging Configuration:
11
+ - The logging is set up in the logging_config.py file.
12
+ - A @log_call decorator is available to automatically log function calls.
13
+ - Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
14
+
15
+ Classes:
16
+ RasMap: Class for parsing and accessing HEC-RAS mapper configuration.
17
+ """
18
+
19
+ import os
20
+ import re
21
+ import xml.etree.ElementTree as ET
22
+ from pathlib import Path
23
+ import pandas as pd
24
+ import shutil
25
+ from typing import Union, Optional, Dict, List, Any
26
+
27
+ from .RasPrj import ras
28
+ from .RasPlan import RasPlan
29
+ from .RasCmdr import RasCmdr
30
+ from .LoggingConfig import get_logger
31
+ from .Decorators import log_call
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ class RasMap:
36
+ """
37
+ Class for parsing and accessing information from HEC-RAS mapper configuration files (.rasmap).
38
+
39
+ This class provides methods to extract paths to terrain, soil, land cover data,
40
+ and various project settings from the .rasmap file associated with a HEC-RAS project.
41
+ It also includes functionality to automate the post-processing of stored maps.
42
+ """
43
+
44
+ @staticmethod
45
+ @log_call
46
+ def parse_rasmap(rasmap_path: Union[str, Path], ras_object=None) -> pd.DataFrame:
47
+ """
48
+ Parse a .rasmap file and extract relevant information.
49
+
50
+ Args:
51
+ rasmap_path (Union[str, Path]): Path to the .rasmap file.
52
+ ras_object: Optional RAS object instance.
53
+
54
+ Returns:
55
+ pd.DataFrame: DataFrame containing extracted information from the .rasmap file.
56
+ """
57
+ ras_obj = ras_object or ras
58
+ ras_obj.check_initialized()
59
+
60
+ rasmap_path = Path(rasmap_path)
61
+ if not rasmap_path.exists():
62
+ logger.error(f"RASMapper file not found: {rasmap_path}")
63
+ # Create a single row DataFrame with all empty values
64
+ return pd.DataFrame({
65
+ 'projection_path': [None],
66
+ 'profile_lines_path': [[]],
67
+ 'soil_layer_path': [[]],
68
+ 'infiltration_hdf_path': [[]],
69
+ 'landcover_hdf_path': [[]],
70
+ 'terrain_hdf_path': [[]],
71
+ 'current_settings': [{}]
72
+ })
73
+
74
+ try:
75
+ # Initialize data for the DataFrame - just one row with lists
76
+ data = {
77
+ 'projection_path': [None],
78
+ 'profile_lines_path': [[]],
79
+ 'soil_layer_path': [[]],
80
+ 'infiltration_hdf_path': [[]],
81
+ 'landcover_hdf_path': [[]],
82
+ 'terrain_hdf_path': [[]],
83
+ 'current_settings': [{}]
84
+ }
85
+
86
+ # Read the file content
87
+ with open(rasmap_path, 'r', encoding='utf-8') as f:
88
+ xml_content = f.read()
89
+
90
+ # Check if it's a valid XML file
91
+ if not xml_content.strip().startswith('<'):
92
+ logger.error(f"File does not appear to be valid XML: {rasmap_path}")
93
+ return pd.DataFrame(data)
94
+
95
+ # Parse the XML file
96
+ try:
97
+ tree = ET.parse(rasmap_path)
98
+ root = tree.getroot()
99
+ except ET.ParseError as e:
100
+ logger.error(f"Error parsing XML in {rasmap_path}: {e}")
101
+ return pd.DataFrame(data)
102
+
103
+ # Helper function to convert relative paths to absolute paths
104
+ def to_absolute_path(relative_path: str) -> str:
105
+ if not relative_path:
106
+ return None
107
+ # Remove any leading .\ or ./
108
+ relative_path = relative_path.lstrip('.\\').lstrip('./')
109
+ # Convert to absolute path relative to project folder
110
+ return str(ras_obj.project_folder / relative_path)
111
+
112
+ # Extract projection path
113
+ try:
114
+ projection_elem = root.find(".//RASProjectionFilename")
115
+ if projection_elem is not None and 'Filename' in projection_elem.attrib:
116
+ data['projection_path'][0] = to_absolute_path(projection_elem.attrib['Filename'])
117
+ except Exception as e:
118
+ logger.warning(f"Error extracting projection path: {e}")
119
+
120
+ # Extract profile lines path
121
+ try:
122
+ profile_lines_elem = root.find(".//Features/Layer[@Name='Profile Lines']")
123
+ if profile_lines_elem is not None and 'Filename' in profile_lines_elem.attrib:
124
+ data['profile_lines_path'][0].append(to_absolute_path(profile_lines_elem.attrib['Filename']))
125
+ except Exception as e:
126
+ logger.warning(f"Error extracting profile lines path: {e}")
127
+
128
+ # Extract soil layer paths
129
+ try:
130
+ soil_layers = root.findall(".//Layer[@Name='Hydrologic Soil Groups']")
131
+ for layer in soil_layers:
132
+ if 'Filename' in layer.attrib:
133
+ data['soil_layer_path'][0].append(to_absolute_path(layer.attrib['Filename']))
134
+ except Exception as e:
135
+ logger.warning(f"Error extracting soil layer paths: {e}")
136
+
137
+ # Extract infiltration HDF paths
138
+ try:
139
+ infiltration_layers = root.findall(".//Layer[@Name='Infiltration']")
140
+ for layer in infiltration_layers:
141
+ if 'Filename' in layer.attrib:
142
+ data['infiltration_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
143
+ except Exception as e:
144
+ logger.warning(f"Error extracting infiltration HDF paths: {e}")
145
+
146
+ # Extract landcover HDF paths
147
+ try:
148
+ landcover_layers = root.findall(".//Layer[@Name='LandCover']")
149
+ for layer in landcover_layers:
150
+ if 'Filename' in layer.attrib:
151
+ data['landcover_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
152
+ except Exception as e:
153
+ logger.warning(f"Error extracting landcover HDF paths: {e}")
154
+
155
+ # Extract terrain HDF paths
156
+ try:
157
+ terrain_layers = root.findall(".//Terrains/Layer")
158
+ for layer in terrain_layers:
159
+ if 'Filename' in layer.attrib:
160
+ data['terrain_hdf_path'][0].append(to_absolute_path(layer.attrib['Filename']))
161
+ except Exception as e:
162
+ logger.warning(f"Error extracting terrain HDF paths: {e}")
163
+
164
+ # Extract current settings
165
+ current_settings = {}
166
+ try:
167
+ settings_elem = root.find(".//CurrentSettings")
168
+ if settings_elem is not None:
169
+ # Extract ProjectSettings
170
+ project_settings_elem = settings_elem.find("ProjectSettings")
171
+ if project_settings_elem is not None:
172
+ for child in project_settings_elem:
173
+ current_settings[child.tag] = child.text
174
+
175
+ # Extract Folders
176
+ folders_elem = settings_elem.find("Folders")
177
+ if folders_elem is not None:
178
+ for child in folders_elem:
179
+ current_settings[child.tag] = child.text
180
+
181
+ data['current_settings'][0] = current_settings
182
+ except Exception as e:
183
+ logger.warning(f"Error extracting current settings: {e}")
184
+
185
+ # Create DataFrame
186
+ df = pd.DataFrame(data)
187
+ logger.info(f"Successfully parsed RASMapper file: {rasmap_path}")
188
+ return df
189
+
190
+ except Exception as e:
191
+ logger.error(f"Unexpected error processing RASMapper file {rasmap_path}: {e}")
192
+ # Create a single row DataFrame with all empty values
193
+ return pd.DataFrame({
194
+ 'projection_path': [None],
195
+ 'profile_lines_path': [[]],
196
+ 'soil_layer_path': [[]],
197
+ 'infiltration_hdf_path': [[]],
198
+ 'landcover_hdf_path': [[]],
199
+ 'terrain_hdf_path': [[]],
200
+ 'current_settings': [{}]
201
+ })
202
+
203
+ @staticmethod
204
+ @log_call
205
+ def get_rasmap_path(ras_object=None) -> Optional[Path]:
206
+ """
207
+ Get the path to the .rasmap file based on the current project.
208
+
209
+ Args:
210
+ ras_object: Optional RAS object instance.
211
+
212
+ Returns:
213
+ Optional[Path]: Path to the .rasmap file if found, None otherwise.
214
+ """
215
+ ras_obj = ras_object or ras
216
+ ras_obj.check_initialized()
217
+
218
+ project_name = ras_obj.project_name
219
+ project_folder = ras_obj.project_folder
220
+ rasmap_path = project_folder / f"{project_name}.rasmap"
221
+
222
+ if not rasmap_path.exists():
223
+ logger.warning(f"RASMapper file not found: {rasmap_path}")
224
+ return None
225
+
226
+ return rasmap_path
227
+
228
+ @staticmethod
229
+ @log_call
230
+ def initialize_rasmap_df(ras_object=None) -> pd.DataFrame:
231
+ """
232
+ Initialize the rasmap_df as part of project initialization.
233
+
234
+ Args:
235
+ ras_object: Optional RAS object instance.
236
+
237
+ Returns:
238
+ pd.DataFrame: DataFrame containing information from the .rasmap file.
239
+ """
240
+ ras_obj = ras_object or ras
241
+ ras_obj.check_initialized()
242
+
243
+ rasmap_path = RasMap.get_rasmap_path(ras_obj)
244
+ if rasmap_path is None:
245
+ logger.warning("No .rasmap file found for this project. Creating empty rasmap_df.")
246
+ # Create a single row DataFrame with all empty values
247
+ return pd.DataFrame({
248
+ 'projection_path': [None],
249
+ 'profile_lines_path': [[]],
250
+ 'soil_layer_path': [[]],
251
+ 'infiltration_hdf_path': [[]],
252
+ 'landcover_hdf_path': [[]],
253
+ 'terrain_hdf_path': [[]],
254
+ 'current_settings': [{}]
255
+ })
256
+
257
+ return RasMap.parse_rasmap(rasmap_path, ras_obj)
258
+
259
+ @staticmethod
260
+ @log_call
261
+ def get_terrain_names(rasmap_path: Union[str, Path]) -> List[str]:
262
+ """
263
+ Extracts terrain layer names from a given .rasmap file.
264
+
265
+ Args:
266
+ rasmap_path (Union[str, Path]): Path to the .rasmap file.
267
+
268
+ Returns:
269
+ List[str]: A list of terrain names.
270
+
271
+ Raises:
272
+ FileNotFoundError: If the rasmap file does not exist.
273
+ ValueError: If the file is not a valid XML or lacks a 'Terrains' section.
274
+ """
275
+ rasmap_path = Path(rasmap_path)
276
+ if not rasmap_path.is_file():
277
+ raise FileNotFoundError(f"The file '{rasmap_path}' does not exist.")
278
+
279
+ try:
280
+ tree = ET.parse(rasmap_path)
281
+ root = tree.getroot()
282
+ except ET.ParseError as e:
283
+ raise ValueError(f"Failed to parse the RASMAP file. Ensure it is a valid XML file. Error: {e}")
284
+
285
+ terrains_element = root.find('Terrains')
286
+ if terrains_element is None:
287
+ logger.warning("The RASMAP file does not contain a 'Terrains' section.")
288
+ return []
289
+
290
+ terrain_names = [layer.get('Name') for layer in terrains_element.findall('Layer') if layer.get('Name')]
291
+ logger.info(f"Extracted terrain names: {terrain_names}")
292
+ return terrain_names
293
+
294
+
295
+ @staticmethod
296
+ @log_call
297
+ def postprocess_stored_maps(
298
+ plan_number: Union[str, List[str]],
299
+ specify_terrain: Optional[str] = None,
300
+ layers: Union[str, List[str]] = None,
301
+ ras_object: Optional[Any] = None
302
+ ) -> bool:
303
+ """
304
+ Automates the generation of stored floodplain map outputs (e.g., .tif files).
305
+
306
+ This function modifies the plan and .rasmap files to generate floodplain maps
307
+ for one or more plans, then restores the original files.
308
+
309
+ Args:
310
+ plan_number (Union[str, List[str]]): Plan number(s) to generate maps for.
311
+ specify_terrain (Optional[str]): The name of a specific terrain to use.
312
+ layers (Union[str, List[str]], optional): A list of map layers to generate.
313
+ Defaults to ['WSEL', 'Velocity', 'Depth'].
314
+ ras_object (Optional[Any]): The RAS project object.
315
+
316
+ Returns:
317
+ bool: True if the process completed successfully, False otherwise.
318
+ """
319
+ ras_obj = ras_object or ras
320
+ ras_obj.check_initialized()
321
+
322
+ if layers is None:
323
+ layers = ['WSEL', 'Velocity', 'Depth']
324
+ elif isinstance(layers, str):
325
+ layers = [layers]
326
+
327
+ # Convert plan_number to list if it's a string
328
+ plan_number_list = [plan_number] if isinstance(plan_number, str) else plan_number
329
+
330
+ rasmap_path = ras_obj.project_folder / f"{ras_obj.project_name}.rasmap"
331
+ rasmap_backup_path = rasmap_path.with_suffix(f"{rasmap_path.suffix}.storedmap.bak")
332
+
333
+ # Store plan paths and their backups
334
+ plan_paths = []
335
+ plan_backup_paths = []
336
+ for plan_num in plan_number_list:
337
+ plan_path = Path(RasPlan.get_plan_path(plan_num, ras_obj))
338
+ plan_backup_path = plan_path.with_suffix(f"{plan_path.suffix}.storedmap.bak")
339
+ plan_paths.append(plan_path)
340
+ plan_backup_paths.append(plan_backup_path)
341
+
342
+ def _create_map_element(name, map_type, profile_name="Max"):
343
+ map_params = {
344
+ "MapType": map_type,
345
+ "OutputMode": "Stored Current Terrain",
346
+ "ProfileIndex": "2147483647",
347
+ "ProfileName": profile_name
348
+ }
349
+ if specify_terrain:
350
+ map_params["Terrain"] = specify_terrain
351
+
352
+ layer_elem = ET.Element('Layer', Name=name, Type="RASResultsMap", Checked="True")
353
+ map_params_elem = ET.SubElement(layer_elem, 'MapParameters')
354
+ for k, v in map_params.items():
355
+ map_params_elem.set(k, str(v))
356
+ return layer_elem
357
+
358
+ try:
359
+ # --- 1. Backup and Modify Plan Files ---
360
+ for plan_num, plan_path, plan_backup_path in zip(plan_number_list, plan_paths, plan_backup_paths):
361
+ logger.info(f"Backing up plan file {plan_path} to {plan_backup_path}")
362
+ shutil.copy2(plan_path, plan_backup_path)
363
+
364
+ logger.info(f"Updating plan run flags for floodplain mapping for plan {plan_num}...")
365
+ RasPlan.update_run_flags(
366
+ plan_num,
367
+ geometry_preprocessor=False,
368
+ unsteady_flow_simulation=False,
369
+ post_processor=False,
370
+ floodplain_mapping=True, # Note: True maps to 0, which means "Run"
371
+ ras_object=ras_obj
372
+ )
373
+
374
+ # --- 2. Backup and Modify RASMAP File ---
375
+ logger.info(f"Backing up rasmap file {rasmap_path} to {rasmap_backup_path}")
376
+ shutil.copy2(rasmap_path, rasmap_backup_path)
377
+
378
+ tree = ET.parse(rasmap_path)
379
+ root = tree.getroot()
380
+
381
+ results_section = root.find('Results')
382
+ if results_section is None:
383
+ raise ValueError(f"No <Results> section found in {rasmap_path}")
384
+
385
+ # Process each plan's results layer
386
+ for plan_num in plan_number_list:
387
+ plan_hdf_part = f".p{plan_num}.hdf"
388
+ results_layer = None
389
+ for layer in results_section.findall("Layer[@Type='RASResults']"):
390
+ filename = layer.get("Filename")
391
+ if filename and plan_hdf_part.lower() in filename.lower():
392
+ results_layer = layer
393
+ break
394
+
395
+ if results_layer is None:
396
+ logger.warning(f"Could not find RASResults layer for plan ending in '{plan_hdf_part}' in {rasmap_path}")
397
+ continue
398
+
399
+ map_definitions = {"WSEL": "elevation", "Velocity": "velocity", "Depth": "depth"}
400
+ for layer_name in layers:
401
+ if layer_name in map_definitions:
402
+ map_type = map_definitions[layer_name]
403
+ layername_attr = "Water Surface" if layer_name == "WSEL" else None
404
+ map_elem = _create_map_element(layer_name, map_type)
405
+ if layername_attr:
406
+ map_elem.find("MapParameters").set("LayerName", layername_attr)
407
+ results_layer.append(map_elem)
408
+ logger.info(f"Added '{layer_name}' stored map to results layer for plan {plan_num}.")
409
+
410
+ if specify_terrain:
411
+ terrains_elem = root.find('Terrains')
412
+ if terrains_elem is not None:
413
+ for layer in list(terrains_elem):
414
+ if layer.get('Name') != specify_terrain:
415
+ terrains_elem.remove(layer)
416
+ logger.info(f"Filtered terrains, keeping only '{specify_terrain}'.")
417
+
418
+ tree.write(rasmap_path, encoding='utf-8', xml_declaration=True)
419
+
420
+ # --- 3. Execute HEC-RAS ---
421
+ logger.info("Opening HEC-RAS...")
422
+ ras_exe = ras_obj.ras_exe_path
423
+ prj_path = f'"{str(ras_obj.prj_file)}"'
424
+ command = f"{ras_exe} {prj_path}"
425
+
426
+ try:
427
+ import sys
428
+ import subprocess
429
+ if sys.platform == "win32":
430
+ hecras_process = subprocess.Popen(command)
431
+ else:
432
+ hecras_process = subprocess.Popen([ras_exe, prj_path])
433
+
434
+ logger.info(f"HEC-RAS opened with Process ID: {hecras_process.pid}")
435
+ logger.info(f"Please run plan(s) {', '.join(plan_number_list)} using the 'Compute Multiple' window in HEC-RAS to generate floodplain mapping results.")
436
+
437
+ # Wait for HEC-RAS to close
438
+ logger.info("Waiting for HEC-RAS to close...")
439
+ hecras_process.wait()
440
+ logger.info("HEC-RAS has closed")
441
+
442
+ success = True
443
+
444
+ except Exception as e:
445
+ logger.error(f"Failed to launch HEC-RAS: {e}")
446
+ success = False
447
+
448
+ if not success:
449
+ logger.error("Floodplain mapping computation failed.")
450
+ return False
451
+
452
+ logger.info("Floodplain mapping computation successful.")
453
+ return True
454
+
455
+ except Exception as e:
456
+ logger.error(f"Error in postprocess_stored_maps: {e}")
457
+ return False
458
+
459
+ finally:
460
+ # --- 4. Restore Files ---
461
+ for plan_path, plan_backup_path in zip(plan_paths, plan_backup_paths):
462
+ if plan_backup_path.exists():
463
+ logger.info(f"Restoring original plan file from {plan_backup_path}")
464
+ shutil.move(plan_backup_path, plan_path)
465
+ if rasmap_backup_path.exists():
466
+ logger.info(f"Restoring original rasmap file from {rasmap_backup_path}")
467
+ shutil.move(rasmap_backup_path, rasmap_path)