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.
@@ -1,424 +1,543 @@
1
- """
2
- RasExamples - Manage and load HEC-RAS example projects for testing and development
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 RasExamples:
32
- - get_example_projects()
33
- - list_categories()
34
- - list_projects()
35
- - extract_project()
36
- - is_project_extracted()
37
- - clean_projects_directory()
38
-
39
- """
40
- import os
41
- import requests
42
- import zipfile
43
- import pandas as pd
44
- from pathlib import Path
45
- import shutil
46
- from typing import Union, List
47
- import csv
48
- from datetime import datetime
49
- import logging
50
- import re
51
- from tqdm import tqdm
52
- from ras_commander import get_logger
53
- from ras_commander.LoggingConfig import log_call
54
-
55
- logger = get_logger(__name__)
56
-
57
- class RasExamples:
58
- """
59
- A class for quickly loading HEC-RAS example projects for testing and development of ras-commander.
60
- All methods are class methods, so no initialization is required.
61
- """
62
- base_url = 'https://github.com/HydrologicEngineeringCenter/hec-downloads/releases/download/'
63
- valid_versions = [
64
- "6.6", "6.5", "6.4.1", "6.3.1", "6.3", "6.2", "6.1", "6.0",
65
- "5.0.7", "5.0.6", "5.0.5", "5.0.4", "5.0.3", "5.0.1", "5.0",
66
- "4.1", "4.0", "3.1.3", "3.1.2", "3.1.1", "3.0", "2.2"
67
- ]
68
- base_dir = Path.cwd()
69
- examples_dir = base_dir
70
- projects_dir = examples_dir / 'example_projects'
71
- csv_file_path = examples_dir / 'example_projects.csv'
72
-
73
- _folder_df = None
74
- _zip_file_path = None
75
-
76
- def __init__(self):
77
- """Initialize RasExamples and ensure data is loaded"""
78
- self._ensure_initialized()
79
-
80
- @property
81
- def folder_df(self):
82
- """Access the folder DataFrame"""
83
- self._ensure_initialized()
84
- return self._folder_df
85
-
86
- def _ensure_initialized(self):
87
- """Ensure the class is initialized with required data"""
88
- self.projects_dir.mkdir(parents=True, exist_ok=True)
89
- if self._folder_df is None:
90
- self._load_project_data()
91
-
92
- def _load_project_data(self):
93
- """Load project data from CSV if up-to-date, otherwise extract from zip."""
94
- logger.debug("Loading project data")
95
- self._find_zip_file()
96
-
97
- if not self._zip_file_path:
98
- logger.info("No example projects zip file found. Downloading...")
99
- self.get_example_projects()
100
-
101
- try:
102
- zip_modified_time = os.path.getmtime(self._zip_file_path)
103
- except FileNotFoundError:
104
- logger.error(f"Zip file not found at {self._zip_file_path}.")
105
- return
106
-
107
- if self.csv_file_path.exists():
108
- csv_modified_time = os.path.getmtime(self.csv_file_path)
109
-
110
- if csv_modified_time >= zip_modified_time:
111
- logger.info("Loading project data from CSV...")
112
- try:
113
- self._folder_df = pd.read_csv(self.csv_file_path)
114
- logger.info(f"Loaded {len(self._folder_df)} projects from CSV.")
115
- return
116
- except Exception as e:
117
- logger.error(f"Failed to read CSV file: {e}")
118
- self._folder_df = None
119
-
120
- logger.info("Extracting folder structure from zip file...")
121
- self._extract_folder_structure()
122
- self._save_to_csv()
123
-
124
- @classmethod
125
- def extract_project(cls, project_names: Union[str, List[str]]) -> Union[Path, List[Path]]:
126
- """Extract one or more specific HEC-RAS projects from the zip file.
127
-
128
- Args:
129
- project_names: Single project name as string or list of project names
130
-
131
- Returns:
132
- Path: Single Path object if one project extracted
133
- List[Path]: List of Path objects if multiple projects extracted
134
- """
135
- logger.debug(f"Extracting projects: {project_names}")
136
-
137
- # Initialize if needed
138
- if cls._folder_df is None:
139
- cls._find_zip_file()
140
- if not cls._zip_file_path:
141
- logger.info("No example projects zip file found. Downloading...")
142
- cls.get_example_projects()
143
- cls._load_project_data()
144
-
145
- if isinstance(project_names, str):
146
- project_names = [project_names]
147
-
148
- extracted_paths = []
149
-
150
- for project_name in project_names:
151
- logger.info("----- RasExamples Extracting Project -----")
152
- logger.info(f"Extracting project '{project_name}'")
153
- project_path = cls.projects_dir
154
-
155
- if (project_path / project_name).exists():
156
- logger.info(f"Project '{project_name}' already exists. Deleting existing folder...")
157
- try:
158
- shutil.rmtree(project_path / project_name)
159
- logger.info(f"Existing folder for project '{project_name}' has been deleted.")
160
- except Exception as e:
161
- logger.error(f"Failed to delete existing project folder '{project_name}': {e}")
162
- continue
163
-
164
- project_info = cls._folder_df[cls._folder_df['Project'] == project_name]
165
- if project_info.empty:
166
- error_msg = f"Project '{project_name}' not found in the zip file."
167
- logger.error(error_msg)
168
- raise ValueError(error_msg)
169
-
170
- try:
171
- with zipfile.ZipFile(cls._zip_file_path, 'r') as zip_ref:
172
- for file in zip_ref.namelist():
173
- parts = Path(file).parts
174
- if len(parts) > 1 and parts[1] == project_name:
175
- relative_path = Path(*parts[1:])
176
- extract_path = project_path / relative_path
177
- if file.endswith('/'):
178
- extract_path.mkdir(parents=True, exist_ok=True)
179
- else:
180
- extract_path.parent.mkdir(parents=True, exist_ok=True)
181
- with zip_ref.open(file) as source, open(extract_path, "wb") as target:
182
- shutil.copyfileobj(source, target)
183
-
184
- logger.info(f"Successfully extracted project '{project_name}' to {project_path / project_name}")
185
- extracted_paths.append(project_path / project_name)
186
- except Exception as e:
187
- logger.error(f"An error occurred while extracting project '{project_name}': {str(e)}")
188
-
189
- # Return single path if only one project was extracted, otherwise return list
190
- return extracted_paths[0] if len(project_names) == 1 else extracted_paths
191
-
192
- @classmethod
193
- def _find_zip_file(cls):
194
- """Locate the example projects zip file in the examples directory."""
195
- for version in cls.valid_versions:
196
- potential_zip = cls.examples_dir / f"Example_Projects_{version.replace('.', '_')}.zip"
197
- if potential_zip.exists():
198
- cls._zip_file_path = potential_zip
199
- logger.info(f"Found zip file: {cls._zip_file_path}")
200
- break
201
- else:
202
- logger.warning("No existing example projects zip file found.")
203
-
204
- @classmethod
205
- def get_example_projects(cls, version_number='6.6'):
206
- """
207
- Download and extract HEC-RAS example projects for a specified version.
208
- """
209
- logger.info(f"Getting example projects for version {version_number}")
210
- if version_number not in cls.valid_versions:
211
- error_msg = f"Invalid version number. Valid versions are: {', '.join(cls.valid_versions)}"
212
- logger.error(error_msg)
213
- raise ValueError(error_msg)
214
-
215
- zip_url = f"{cls.base_url}1.0.33/Example_Projects_{version_number.replace('.', '_')}.zip"
216
-
217
- cls.examples_dir.mkdir(parents=True, exist_ok=True)
218
-
219
- cls._zip_file_path = cls.examples_dir / f"Example_Projects_{version_number.replace('.', '_')}.zip"
220
-
221
- if not cls._zip_file_path.exists():
222
- logger.info(f"Downloading HEC-RAS Example Projects from {zip_url}. \nThe file is over 400 MB, so it may take a few minutes to download....")
223
- try:
224
- response = requests.get(zip_url, stream=True)
225
- response.raise_for_status()
226
- with open(cls._zip_file_path, 'wb') as file:
227
- shutil.copyfileobj(response.raw, file)
228
- logger.info(f"Downloaded to {cls._zip_file_path}")
229
- except requests.exceptions.RequestException as e:
230
- logger.error(f"Failed to download the zip file: {e}")
231
- raise
232
- else:
233
- logger.info("HEC-RAS Example Projects zip file already exists. Skipping download.")
234
-
235
- cls._load_project_data()
236
- return cls.projects_dir
237
-
238
- @classmethod
239
- def _load_project_data(cls):
240
- """Load project data from CSV if up-to-date, otherwise extract from zip."""
241
- logger.debug("Loading project data")
242
-
243
- try:
244
- zip_modified_time = os.path.getmtime(cls._zip_file_path)
245
- except FileNotFoundError:
246
- logger.error(f"Zip file not found at {cls._zip_file_path}.")
247
- return
248
-
249
- if cls.csv_file_path.exists():
250
- csv_modified_time = os.path.getmtime(cls.csv_file_path)
251
-
252
- if csv_modified_time >= zip_modified_time:
253
- logger.info("Loading project data from CSV...")
254
- try:
255
- cls._folder_df = pd.read_csv(cls.csv_file_path)
256
- logger.info(f"Loaded {len(cls._folder_df)} projects from CSV.")
257
- return
258
- except Exception as e:
259
- logger.error(f"Failed to read CSV file: {e}")
260
- cls._folder_df = None
261
-
262
- logger.info("Extracting folder structure from zip file...")
263
- cls._extract_folder_structure()
264
- cls._save_to_csv()
265
-
266
- @classmethod
267
- def _extract_folder_structure(cls):
268
- """
269
- Extract folder structure from the zip file.
270
-
271
- Populates folder_df with category and project information.
272
- """
273
- folder_data = []
274
- try:
275
- with zipfile.ZipFile(cls._zip_file_path, 'r') as zip_ref:
276
- for file in zip_ref.namelist():
277
- parts = Path(file).parts
278
- if len(parts) > 1:
279
- folder_data.append({
280
- 'Category': parts[0],
281
- 'Project': parts[1]
282
- })
283
-
284
- cls._folder_df = pd.DataFrame(folder_data).drop_duplicates()
285
- logger.info(f"Extracted {len(cls._folder_df)} projects.")
286
- logger.debug(f"folder_df:\n{cls._folder_df}")
287
- except zipfile.BadZipFile:
288
- logger.error(f"The file {cls._zip_file_path} is not a valid zip file.")
289
- cls._folder_df = pd.DataFrame(columns=['Category', 'Project'])
290
- except Exception as e:
291
- logger.error(f"An error occurred while extracting the folder structure: {str(e)}")
292
- cls._folder_df = pd.DataFrame(columns=['Category', 'Project'])
293
-
294
- @classmethod
295
- def _save_to_csv(cls):
296
- """Save the extracted folder structure to CSV file."""
297
- if cls._folder_df is not None and not cls._folder_df.empty:
298
- try:
299
- cls._folder_df.to_csv(cls.csv_file_path, index=False)
300
- logger.info(f"Saved project data to {cls.csv_file_path}")
301
- except Exception as e:
302
- logger.error(f"Failed to save project data to CSV: {e}")
303
- else:
304
- logger.warning("No folder data to save to CSV.")
305
-
306
- @classmethod
307
- def list_categories(cls):
308
- """
309
- List all categories of example projects.
310
- """
311
- if cls._folder_df is None or 'Category' not in cls._folder_df.columns:
312
- logger.warning("No categories available. Make sure the zip file is properly loaded.")
313
- return []
314
- categories = cls._folder_df['Category'].unique()
315
- logger.info(f"Available categories: {', '.join(categories)}")
316
- return categories.tolist()
317
-
318
- @classmethod
319
- def list_projects(cls, category=None):
320
- """
321
- List all projects or projects in a specific category.
322
- """
323
- if cls._folder_df is None:
324
- logger.warning("No projects available. Make sure the zip file is properly loaded.")
325
- return []
326
- if category:
327
- projects = cls._folder_df[cls._folder_df['Category'] == category]['Project'].unique()
328
- logger.info(f"Projects in category '{category}': {', '.join(projects)}")
329
- else:
330
- projects = cls._folder_df['Project'].unique()
331
- logger.info(f"All available projects: {', '.join(projects)}")
332
- return projects.tolist()
333
-
334
- @classmethod
335
- def is_project_extracted(cls, project_name):
336
- """
337
- Check if a specific project is already extracted.
338
- """
339
- project_path = cls.projects_dir / project_name
340
- is_extracted = project_path.exists()
341
- logger.info(f"Project '{project_name}' extracted: {is_extracted}")
342
- return is_extracted
343
-
344
- @classmethod
345
- def clean_projects_directory(cls):
346
- """Remove all extracted projects from the example_projects directory."""
347
- logger.info(f"Cleaning projects directory: {cls.projects_dir}")
348
- if cls.projects_dir.exists():
349
- try:
350
- shutil.rmtree(cls.projects_dir)
351
- logger.info("All projects have been removed.")
352
- except Exception as e:
353
- logger.error(f"Failed to remove projects directory: {e}")
354
- else:
355
- logger.warning("Projects directory does not exist.")
356
- cls.projects_dir.mkdir(parents=True, exist_ok=True)
357
- logger.info("Projects directory cleaned and recreated.")
358
-
359
- @classmethod
360
- def download_fema_ble_model(cls, huc8, output_dir=None):
361
- """
362
- Download a FEMA Base Level Engineering (BLE) model for a given HUC8.
363
-
364
- Args:
365
- huc8 (str): The 8-digit Hydrologic Unit Code (HUC) for the desired watershed.
366
- output_dir (str, optional): The directory to save the downloaded files. If None, uses the current working directory.
367
-
368
- Returns:
369
- str: The path to the downloaded and extracted model directory.
370
-
371
- Note:
372
- This method downloads the BLE model from the FEMA website and extracts it to the specified directory.
373
- """
374
- # Method implementation...
375
-
376
- @classmethod
377
- def _make_safe_folder_name(cls, name: str) -> str:
378
- """
379
- Convert a string to a safe folder name by replacing unsafe characters with underscores.
380
- """
381
- safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
382
- logger.debug(f"Converted '{name}' to safe folder name '{safe_name}'")
383
- return safe_name
384
-
385
- @classmethod
386
- def _download_file_with_progress(cls, url: str, dest_folder: Path, file_size: int) -> Path:
387
- """
388
- Download a file from a URL to a specified destination folder with progress bar.
389
- """
390
- local_filename = dest_folder / url.split('/')[-1]
391
- try:
392
- with requests.get(url, stream=True) as r:
393
- r.raise_for_status()
394
- with open(local_filename, 'wb') as f, tqdm(
395
- desc=local_filename.name,
396
- total=file_size,
397
- unit='iB',
398
- unit_scale=True,
399
- unit_divisor=1024,
400
- ) as progress_bar:
401
- for chunk in r.iter_content(chunk_size=8192):
402
- size = f.write(chunk)
403
- progress_bar.update(size)
404
- logger.info(f"Successfully downloaded {url} to {local_filename}")
405
- return local_filename
406
- except requests.exceptions.RequestException as e:
407
- logger.error(f"Request failed for {url}: {e}")
408
- raise
409
- except Exception as e:
410
- logger.error(f"Failed to write file {local_filename}: {e}")
411
- raise
412
-
413
- @classmethod
414
- def _convert_size_to_bytes(cls, size_str: str) -> int:
415
- """
416
- Convert a human-readable file size to bytes.
417
- """
418
- units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
419
- size_str = size_str.upper().replace(' ', '')
420
- if not re.match(r'^\d+(\.\d+)?[BKMGT]B?$', size_str):
421
- raise ValueError(f"Invalid size string: {size_str}")
422
-
423
- number, unit = float(re.findall(r'[\d\.]+', size_str)[0]), re.findall(r'[BKMGT]B?', size_str)[0]
424
- return int(number * units[unit])
1
+ """
2
+ RasExamples - Manage and load HEC-RAS example projects for testing and development
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 RasExamples:
32
+ - get_example_projects()
33
+ - list_categories()
34
+ - list_projects()
35
+ - extract_project()
36
+ - is_project_extracted()
37
+ - clean_projects_directory()
38
+
39
+ """
40
+ import os
41
+ import requests
42
+ import zipfile
43
+ import pandas as pd
44
+ from pathlib import Path
45
+ import shutil
46
+ from typing import Union, List
47
+ import csv
48
+ from datetime import datetime
49
+ import logging
50
+ import re
51
+ from tqdm import tqdm
52
+ from ras_commander import get_logger
53
+ from ras_commander.LoggingConfig import log_call
54
+
55
+ logger = get_logger(__name__)
56
+
57
+ class RasExamples:
58
+ """
59
+ A class for quickly loading HEC-RAS example projects for testing and development of ras-commander.
60
+ All methods are class methods, so no initialization is required.
61
+ """
62
+ base_url = 'https://github.com/HydrologicEngineeringCenter/hec-downloads/releases/download/'
63
+ valid_versions = [
64
+ "6.6", "6.5", "6.4.1", "6.3.1", "6.3", "6.2", "6.1", "6.0",
65
+ "5.0.7", "5.0.6", "5.0.5", "5.0.4", "5.0.3", "5.0.1", "5.0",
66
+ "4.1", "4.0", "3.1.3", "3.1.2", "3.1.1", "3.0", "2.2"
67
+ ]
68
+ base_dir = Path.cwd()
69
+ examples_dir = base_dir
70
+ projects_dir = examples_dir / 'example_projects'
71
+ csv_file_path = examples_dir / 'example_projects.csv'
72
+
73
+ # Special projects that are not in the main zip file
74
+ SPECIAL_PROJECTS = {
75
+ 'NewOrleansMetro': 'https://www.hec.usace.army.mil/confluence/rasdocs/hgt/files/latest/299502039/299502111/1/1747692522764/NewOrleansMetroPipesExample.zip',
76
+ 'BeaverLake': 'https://www.hec.usace.army.mil/confluence/rasdocs/hgt/files/latest/299501780/299502090/1/1747692179014/BeaverLake-SWMM-Import-Solution.zip'
77
+ }
78
+
79
+ _folder_df = None
80
+ _zip_file_path = None
81
+
82
+ def __init__(self):
83
+ """Initialize RasExamples and ensure data is loaded"""
84
+ self._ensure_initialized()
85
+
86
+ @property
87
+ def folder_df(self):
88
+ """Access the folder DataFrame"""
89
+ self._ensure_initialized()
90
+ return self._folder_df
91
+
92
+ def _ensure_initialized(self):
93
+ """Ensure the class is initialized with required data"""
94
+ self.projects_dir.mkdir(parents=True, exist_ok=True)
95
+ if self._folder_df is None:
96
+ self._load_project_data()
97
+
98
+ def _load_project_data(self):
99
+ """Load project data from CSV if up-to-date, otherwise extract from zip."""
100
+ logger.debug("Loading project data")
101
+ self._find_zip_file()
102
+
103
+ if not self._zip_file_path:
104
+ logger.info("No example projects zip file found. Downloading...")
105
+ self.get_example_projects()
106
+
107
+ try:
108
+ zip_modified_time = os.path.getmtime(self._zip_file_path)
109
+ except FileNotFoundError:
110
+ logger.error(f"Zip file not found at {self._zip_file_path}.")
111
+ return
112
+
113
+ if self.csv_file_path.exists():
114
+ csv_modified_time = os.path.getmtime(self.csv_file_path)
115
+
116
+ if csv_modified_time >= zip_modified_time:
117
+ logger.info("Loading project data from CSV...")
118
+ try:
119
+ self._folder_df = pd.read_csv(self.csv_file_path)
120
+ logger.info(f"Loaded {len(self._folder_df)} projects from CSV.")
121
+ return
122
+ except Exception as e:
123
+ logger.error(f"Failed to read CSV file: {e}")
124
+ self._folder_df = None
125
+
126
+ logger.info("Extracting folder structure from zip file...")
127
+ self._extract_folder_structure()
128
+ self._save_to_csv()
129
+
130
+ @classmethod
131
+ def extract_project(cls, project_names: Union[str, List[str]]) -> Union[Path, List[Path]]:
132
+ """Extract one or more specific HEC-RAS projects from the zip file.
133
+
134
+ Args:
135
+ project_names: Single project name as string or list of project names
136
+
137
+ Returns:
138
+ Path: Single Path object if one project extracted
139
+ List[Path]: List of Path objects if multiple projects extracted
140
+ """
141
+ logger.debug(f"Extracting projects: {project_names}")
142
+
143
+ # Initialize if needed
144
+ if cls._folder_df is None:
145
+ cls._find_zip_file()
146
+ if not cls._zip_file_path:
147
+ logger.info("No example projects zip file found. Downloading...")
148
+ cls.get_example_projects()
149
+ cls._load_project_data()
150
+
151
+ if isinstance(project_names, str):
152
+ project_names = [project_names]
153
+
154
+ extracted_paths = []
155
+
156
+ for project_name in project_names:
157
+ # Check if this is a special project
158
+ if project_name in cls.SPECIAL_PROJECTS:
159
+ try:
160
+ special_path = cls._extract_special_project(project_name)
161
+ extracted_paths.append(special_path)
162
+ continue
163
+ except Exception as e:
164
+ logger.error(f"Failed to extract special project '{project_name}': {e}")
165
+ continue
166
+
167
+ # Regular project extraction logic
168
+ logger.info("----- RasExamples Extracting Project -----")
169
+ logger.info(f"Extracting project '{project_name}'")
170
+ project_path = cls.projects_dir
171
+
172
+ if (project_path / project_name).exists():
173
+ logger.info(f"Project '{project_name}' already exists. Deleting existing folder...")
174
+ try:
175
+ shutil.rmtree(project_path / project_name)
176
+ logger.info(f"Existing folder for project '{project_name}' has been deleted.")
177
+ except Exception as e:
178
+ logger.error(f"Failed to delete existing project folder '{project_name}': {e}")
179
+ continue
180
+
181
+ project_info = cls._folder_df[cls._folder_df['Project'] == project_name]
182
+ if project_info.empty:
183
+ error_msg = f"Project '{project_name}' not found in the zip file."
184
+ logger.error(error_msg)
185
+ raise ValueError(error_msg)
186
+
187
+ try:
188
+ with zipfile.ZipFile(cls._zip_file_path, 'r') as zip_ref:
189
+ for file in zip_ref.namelist():
190
+ parts = Path(file).parts
191
+ if len(parts) > 1 and parts[1] == project_name:
192
+ relative_path = Path(*parts[1:])
193
+ extract_path = project_path / relative_path
194
+ if file.endswith('/'):
195
+ extract_path.mkdir(parents=True, exist_ok=True)
196
+ else:
197
+ extract_path.parent.mkdir(parents=True, exist_ok=True)
198
+ with zip_ref.open(file) as source, open(extract_path, "wb") as target:
199
+ shutil.copyfileobj(source, target)
200
+
201
+ logger.info(f"Successfully extracted project '{project_name}' to {project_path / project_name}")
202
+ extracted_paths.append(project_path / project_name)
203
+ except Exception as e:
204
+ logger.error(f"An error occurred while extracting project '{project_name}': {str(e)}")
205
+
206
+ # Return single path if only one project was extracted, otherwise return list
207
+ return extracted_paths[0] if len(project_names) == 1 else extracted_paths
208
+
209
+ @classmethod
210
+ def _find_zip_file(cls):
211
+ """Locate the example projects zip file in the examples directory."""
212
+ for version in cls.valid_versions:
213
+ potential_zip = cls.examples_dir / f"Example_Projects_{version.replace('.', '_')}.zip"
214
+ if potential_zip.exists():
215
+ cls._zip_file_path = potential_zip
216
+ logger.info(f"Found zip file: {cls._zip_file_path}")
217
+ break
218
+ else:
219
+ logger.warning("No existing example projects zip file found.")
220
+
221
+ @classmethod
222
+ def get_example_projects(cls, version_number='6.6'):
223
+ """
224
+ Download and extract HEC-RAS example projects for a specified version.
225
+ """
226
+ logger.info(f"Getting example projects for version {version_number}")
227
+ if version_number not in cls.valid_versions:
228
+ error_msg = f"Invalid version number. Valid versions are: {', '.join(cls.valid_versions)}"
229
+ logger.error(error_msg)
230
+ raise ValueError(error_msg)
231
+
232
+ zip_url = f"{cls.base_url}1.0.33/Example_Projects_{version_number.replace('.', '_')}.zip"
233
+
234
+ cls.examples_dir.mkdir(parents=True, exist_ok=True)
235
+
236
+ cls._zip_file_path = cls.examples_dir / f"Example_Projects_{version_number.replace('.', '_')}.zip"
237
+
238
+ if not cls._zip_file_path.exists():
239
+ logger.info(f"Downloading HEC-RAS Example Projects from {zip_url}. \nThe file is over 400 MB, so it may take a few minutes to download....")
240
+ try:
241
+ response = requests.get(zip_url, stream=True)
242
+ response.raise_for_status()
243
+ with open(cls._zip_file_path, 'wb') as file:
244
+ shutil.copyfileobj(response.raw, file)
245
+ logger.info(f"Downloaded to {cls._zip_file_path}")
246
+ except requests.exceptions.RequestException as e:
247
+ logger.error(f"Failed to download the zip file: {e}")
248
+ raise
249
+ else:
250
+ logger.info("HEC-RAS Example Projects zip file already exists. Skipping download.")
251
+
252
+ cls._load_project_data()
253
+ return cls.projects_dir
254
+
255
+ @classmethod
256
+ def _load_project_data(cls):
257
+ """Load project data from CSV if up-to-date, otherwise extract from zip."""
258
+ logger.debug("Loading project data")
259
+
260
+ try:
261
+ zip_modified_time = os.path.getmtime(cls._zip_file_path)
262
+ except FileNotFoundError:
263
+ logger.error(f"Zip file not found at {cls._zip_file_path}.")
264
+ return
265
+
266
+ if cls.csv_file_path.exists():
267
+ csv_modified_time = os.path.getmtime(cls.csv_file_path)
268
+
269
+ if csv_modified_time >= zip_modified_time:
270
+ logger.info("Loading project data from CSV...")
271
+ try:
272
+ cls._folder_df = pd.read_csv(cls.csv_file_path)
273
+ logger.info(f"Loaded {len(cls._folder_df)} projects from CSV.")
274
+ return
275
+ except Exception as e:
276
+ logger.error(f"Failed to read CSV file: {e}")
277
+ cls._folder_df = None
278
+
279
+ logger.info("Extracting folder structure from zip file...")
280
+ cls._extract_folder_structure()
281
+ cls._save_to_csv()
282
+
283
+ @classmethod
284
+ def _extract_folder_structure(cls):
285
+ """
286
+ Extract folder structure from the zip file.
287
+
288
+ Populates folder_df with category and project information.
289
+ """
290
+ folder_data = []
291
+ try:
292
+ with zipfile.ZipFile(cls._zip_file_path, 'r') as zip_ref:
293
+ for file in zip_ref.namelist():
294
+ parts = Path(file).parts
295
+ if len(parts) > 1:
296
+ folder_data.append({
297
+ 'Category': parts[0],
298
+ 'Project': parts[1]
299
+ })
300
+
301
+ cls._folder_df = pd.DataFrame(folder_data).drop_duplicates()
302
+ logger.info(f"Extracted {len(cls._folder_df)} projects.")
303
+ logger.debug(f"folder_df:\n{cls._folder_df}")
304
+ except zipfile.BadZipFile:
305
+ logger.error(f"The file {cls._zip_file_path} is not a valid zip file.")
306
+ cls._folder_df = pd.DataFrame(columns=['Category', 'Project'])
307
+ except Exception as e:
308
+ logger.error(f"An error occurred while extracting the folder structure: {str(e)}")
309
+ cls._folder_df = pd.DataFrame(columns=['Category', 'Project'])
310
+
311
+ @classmethod
312
+ def _save_to_csv(cls):
313
+ """Save the extracted folder structure to CSV file."""
314
+ if cls._folder_df is not None and not cls._folder_df.empty:
315
+ try:
316
+ cls._folder_df.to_csv(cls.csv_file_path, index=False)
317
+ logger.info(f"Saved project data to {cls.csv_file_path}")
318
+ except Exception as e:
319
+ logger.error(f"Failed to save project data to CSV: {e}")
320
+ else:
321
+ logger.warning("No folder data to save to CSV.")
322
+
323
+ @classmethod
324
+ def list_categories(cls):
325
+ """
326
+ List all categories of example projects.
327
+ """
328
+ if cls._folder_df is None or 'Category' not in cls._folder_df.columns:
329
+ logger.warning("No categories available. Make sure the zip file is properly loaded.")
330
+ return []
331
+ categories = cls._folder_df['Category'].unique()
332
+ logger.info(f"Available categories: {', '.join(categories)}")
333
+ return categories.tolist()
334
+
335
+ @classmethod
336
+ def list_projects(cls, category=None):
337
+ """
338
+ List all projects or projects in a specific category.
339
+
340
+ Note: Special projects (NewOrleansMetro, BeaverLake) are also available but not listed
341
+ in categories as they are downloaded separately.
342
+ """
343
+ if cls._folder_df is None:
344
+ logger.warning("No projects available. Make sure the zip file is properly loaded.")
345
+ return []
346
+ if category:
347
+ projects = cls._folder_df[cls._folder_df['Category'] == category]['Project'].unique()
348
+ logger.info(f"Projects in category '{category}': {', '.join(projects)}")
349
+ else:
350
+ projects = cls._folder_df['Project'].unique()
351
+ # Add special projects to the list
352
+ all_projects = list(projects) + list(cls.SPECIAL_PROJECTS.keys())
353
+ logger.info(f"All available projects: {', '.join(all_projects)}")
354
+ return all_projects
355
+ return projects.tolist()
356
+
357
+ @classmethod
358
+ def is_project_extracted(cls, project_name):
359
+ """
360
+ Check if a specific project is already extracted.
361
+ """
362
+ project_path = cls.projects_dir / project_name
363
+ is_extracted = project_path.exists()
364
+ logger.info(f"Project '{project_name}' extracted: {is_extracted}")
365
+ return is_extracted
366
+
367
+ @classmethod
368
+ def clean_projects_directory(cls):
369
+ """Remove all extracted projects from the example_projects directory."""
370
+ logger.info(f"Cleaning projects directory: {cls.projects_dir}")
371
+ if cls.projects_dir.exists():
372
+ try:
373
+ shutil.rmtree(cls.projects_dir)
374
+ logger.info("All projects have been removed.")
375
+ except Exception as e:
376
+ logger.error(f"Failed to remove projects directory: {e}")
377
+ else:
378
+ logger.warning("Projects directory does not exist.")
379
+ cls.projects_dir.mkdir(parents=True, exist_ok=True)
380
+ logger.info("Projects directory cleaned and recreated.")
381
+
382
+ @classmethod
383
+ def download_fema_ble_model(cls, huc8, output_dir=None):
384
+ """
385
+ Download a FEMA Base Level Engineering (BLE) model for a given HUC8.
386
+
387
+ Args:
388
+ huc8 (str): The 8-digit Hydrologic Unit Code (HUC) for the desired watershed.
389
+ output_dir (str, optional): The directory to save the downloaded files. If None, uses the current working directory.
390
+
391
+ Returns:
392
+ str: The path to the downloaded and extracted model directory.
393
+
394
+ Note:
395
+ This method downloads the BLE model from the FEMA website and extracts it to the specified directory.
396
+ """
397
+ # Method implementation...
398
+
399
+ @classmethod
400
+ def _make_safe_folder_name(cls, name: str) -> str:
401
+ """
402
+ Convert a string to a safe folder name by replacing unsafe characters with underscores.
403
+ """
404
+ safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name)
405
+ logger.debug(f"Converted '{name}' to safe folder name '{safe_name}'")
406
+ return safe_name
407
+
408
+ @classmethod
409
+ def _download_file_with_progress(cls, url: str, dest_folder: Path, file_size: int) -> Path:
410
+ """
411
+ Download a file from a URL to a specified destination folder with progress bar.
412
+ """
413
+ local_filename = dest_folder / url.split('/')[-1]
414
+ try:
415
+ with requests.get(url, stream=True) as r:
416
+ r.raise_for_status()
417
+ with open(local_filename, 'wb') as f, tqdm(
418
+ desc=local_filename.name,
419
+ total=file_size,
420
+ unit='iB',
421
+ unit_scale=True,
422
+ unit_divisor=1024,
423
+ ) as progress_bar:
424
+ for chunk in r.iter_content(chunk_size=8192):
425
+ size = f.write(chunk)
426
+ progress_bar.update(size)
427
+ logger.info(f"Successfully downloaded {url} to {local_filename}")
428
+ return local_filename
429
+ except requests.exceptions.RequestException as e:
430
+ logger.error(f"Request failed for {url}: {e}")
431
+ raise
432
+ except Exception as e:
433
+ logger.error(f"Failed to write file {local_filename}: {e}")
434
+ raise
435
+
436
+ @classmethod
437
+ def _convert_size_to_bytes(cls, size_str: str) -> int:
438
+ """
439
+ Convert a human-readable file size to bytes.
440
+ """
441
+ units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
442
+ size_str = size_str.upper().replace(' ', '')
443
+ if not re.match(r'^\d+(\.\d+)?[BKMGT]B?$', size_str):
444
+ raise ValueError(f"Invalid size string: {size_str}")
445
+
446
+ number, unit = float(re.findall(r'[\d\.]+', size_str)[0]), re.findall(r'[BKMGT]B?', size_str)[0]
447
+ return int(number * units[unit])
448
+
449
+ @classmethod
450
+ def _extract_special_project(cls, project_name: str) -> Path:
451
+ """
452
+ Download and extract special projects that are not in the main zip file.
453
+
454
+ Args:
455
+ project_name: Name of the special project ('NewOrleansMetro' or 'BeaverLake')
456
+
457
+ Returns:
458
+ Path: Path to the extracted project directory
459
+
460
+ Raises:
461
+ ValueError: If the project is not a recognized special project
462
+ """
463
+ if project_name not in cls.SPECIAL_PROJECTS:
464
+ raise ValueError(f"'{project_name}' is not a recognized special project")
465
+
466
+ logger.info(f"----- RasExamples Extracting Special Project -----")
467
+ logger.info(f"Extracting special project '{project_name}'")
468
+
469
+ # Create the project directory
470
+ project_path = cls.projects_dir / project_name
471
+
472
+ # Check if already exists
473
+ if project_path.exists():
474
+ logger.info(f"Special project '{project_name}' already exists. Deleting existing folder...")
475
+ try:
476
+ shutil.rmtree(project_path)
477
+ logger.info(f"Existing folder for project '{project_name}' has been deleted.")
478
+ except Exception as e:
479
+ logger.error(f"Failed to delete existing project folder '{project_name}': {e}")
480
+ raise
481
+
482
+ # Create the project directory
483
+ project_path.mkdir(parents=True, exist_ok=True)
484
+
485
+ # Download the zip file
486
+ url = cls.SPECIAL_PROJECTS[project_name]
487
+ zip_file_path = cls.projects_dir / f"{project_name}_temp.zip"
488
+
489
+ logger.info(f"Downloading special project from: {url}")
490
+ logger.info("This may take a few moments...")
491
+
492
+ try:
493
+ response = requests.get(url, stream=True, timeout=300)
494
+ response.raise_for_status()
495
+
496
+ # Get total file size if available
497
+ total_size = int(response.headers.get('content-length', 0))
498
+
499
+ # Download with progress bar
500
+ with open(zip_file_path, 'wb') as file:
501
+ if total_size > 0:
502
+ with tqdm(
503
+ desc=f"Downloading {project_name}",
504
+ total=total_size,
505
+ unit='iB',
506
+ unit_scale=True,
507
+ unit_divisor=1024,
508
+ ) as progress_bar:
509
+ for chunk in response.iter_content(chunk_size=8192):
510
+ size = file.write(chunk)
511
+ progress_bar.update(size)
512
+ else:
513
+ # No content length, download without progress bar
514
+ for chunk in response.iter_content(chunk_size=8192):
515
+ file.write(chunk)
516
+
517
+ logger.info(f"Downloaded special project zip file to {zip_file_path}")
518
+
519
+ except requests.exceptions.RequestException as e:
520
+ logger.error(f"Failed to download special project '{project_name}': {e}")
521
+ if zip_file_path.exists():
522
+ zip_file_path.unlink()
523
+ raise
524
+
525
+ # Extract the zip file directly to the project directory
526
+ try:
527
+ with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
528
+ # Extract directly to the project directory (no internal folder structure)
529
+ zip_ref.extractall(project_path)
530
+ logger.info(f"Successfully extracted special project '{project_name}' to {project_path}")
531
+
532
+ except Exception as e:
533
+ logger.error(f"Failed to extract special project '{project_name}': {e}")
534
+ if project_path.exists():
535
+ shutil.rmtree(project_path)
536
+ raise
537
+ finally:
538
+ # Clean up the temporary zip file
539
+ if zip_file_path.exists():
540
+ zip_file_path.unlink()
541
+ logger.debug(f"Removed temporary zip file: {zip_file_path}")
542
+
543
+ return project_path