Coverage for pybeepop/adapters.py: 75%
208 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 13:34 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 13:34 +0000
1"""
2Engine adapters for PyBeePop dual-engine architecture.
4This module provides adapter classes that wrap both the C++ engine (BeePopModel)
5and Python engine (beepop.BeePop) to provide a consistent interface conforming
6to the BeepopEngineInterface protocol.
7"""
9import os
10from typing import Dict, Optional, Type
11import pandas as pd
13from .exceptions import (
14 BeepopException,
15 BeepopParameterError,
16 BeepopRuntimeError,
17 BeepopFileError,
18)
21class CppEngineAdapter:
22 """
23 Adapter for C++ BeePop+ engine.
25 Wraps the existing BeePopModel class (ctypes wrapper around C++ library)
26 to conform to the BeepopEngineInterface protocol.
28 Attributes:
29 engine_type (str): Always 'cpp'
30 model (BeePopModel): The underlying C++ engine wrapper
31 """
33 def __init__(self, lib_file: str, verbose: bool = False):
34 """
35 Initialize C++ engine adapter.
37 Args:
38 lib_file: Path to BeePop+ shared library (.dll or .so)
39 verbose: Enable verbose output
41 Raises:
42 FileNotFoundError: If lib_file doesn't exist
43 RuntimeError: If C++ library initialization fails
44 """
45 from .tools import BeePopModel
47 if not os.path.isfile(lib_file):
48 raise FileNotFoundError(f"C++ library not found: {lib_file}")
50 self.model = BeePopModel(lib_file, verbose=verbose)
51 self.engine_type = "cpp"
52 self.verbose = verbose
53 self.lib_file = lib_file # Store for backward compatibility
54 self._parameters = {} # Track parameters set
56 def _raise_with_log(
57 self, exception_class: Type[BeepopException], message: str
58 ) -> None:
59 """
60 Raise exception with BeePop+ error log included.
62 Args:
63 exception_class: The exception class to raise (BeepopParameterError, etc.)
64 message: The error message
66 Raises:
67 exception_class: Raised with error log and info log included
68 """
69 error_log = self.get_error_log()
70 info_log = self.get_info_log()
71 raise exception_class(
72 message=message,
73 error_log=error_log,
74 info_log=info_log,
75 engine_type=self.engine_type,
76 )
78 def set_parameters(self, parameters: Dict[str, str]) -> Dict[str, str]:
79 """Set parameters via BeePopModel."""
80 result = self.model.set_parameters(parameters)
81 self._parameters.update(result)
82 return result
84 def get_parameters(self) -> Dict[str, str]:
85 """Get parameters from BeePopModel."""
86 return self.model.get_parameters()
88 def load_parameter_file(self, file_path: str) -> bool:
89 """Load parameter file via BeePopModel."""
90 try:
91 self.model.load_input_file(file_path)
92 return True
93 except ValueError as e:
94 # Re-raise as BeepopParameterError with error logs
95 self._raise_with_log(BeepopParameterError, str(e))
96 except OSError as e:
97 # Re-raise as BeepopFileError with error logs
98 self._raise_with_log(BeepopFileError, str(e))
99 except Exception as e:
100 if self.verbose:
101 print(f"Error loading parameter file: {e}")
102 return False
104 def load_weather_file(self, file_path: str) -> bool:
105 """Load weather file via BeePopModel."""
106 try:
107 self.model.load_weather(file_path)
108 return True
109 except OSError as e:
110 # Re-raise as BeepopFileError with error logs
111 self._raise_with_log(BeepopFileError, str(e))
112 except Exception as e:
113 if self.verbose:
114 print(f"Error loading weather file: {e}")
115 return False
117 def load_residue_file(self, file_path: str) -> bool:
118 """Load residue file via BeePopModel."""
119 try:
120 self.model.load_contam_file(file_path)
121 return True
122 except OSError as e:
123 # Re-raise as BeepopFileError with error logs
124 self._raise_with_log(BeepopFileError, str(e))
125 except Exception as e:
126 if self.verbose:
127 print(f"Error loading residue file: {e}")
128 return False
130 def set_latitude(self, latitude: float) -> bool:
131 """Set latitude via BeePopModel."""
132 try:
133 self.model.set_latitude(latitude)
134 return True
135 except Exception as e:
136 if self.verbose:
137 print(f"Error setting latitude: {e}")
138 return False
140 def run_simulation(self) -> Optional[pd.DataFrame]:
141 """Run simulation via BeePopModel."""
142 try:
143 return self.model.run_beepop()
144 except Exception as e:
145 if self.verbose:
146 print(f"Error running simulation: {e}")
147 return None
149 def get_error_log(self) -> str:
150 """Get error log from BeePopModel."""
151 try:
152 return self.model.get_errors()
153 except Exception:
154 return ""
156 def get_info_log(self) -> str:
157 """Get info log from BeePopModel."""
158 try:
159 return self.model.get_info()
160 except Exception:
161 return ""
163 def get_version(self) -> str:
164 """Get version from BeePopModel."""
165 try:
166 return self.model.get_version()
167 except Exception:
168 return "Unknown"
170 def cleanup(self) -> None:
171 """Clean up C++ library resources."""
172 if hasattr(self.model, "close_library"):
173 try:
174 self.model.close_library()
175 except Exception as e:
176 if self.verbose:
177 print(f"Warning during cleanup: {e}")
180class PythonEngineAdapter:
181 """
182 Adapter for pure Python BeePop+ engine.
184 Wraps the Python port (beepop.BeePop) to conform to the BeepopEngineInterface
185 protocol, handling format conversions between PyBeePop's dict-based API and
186 the Python engine's list-based parameter format.
188 Attributes:
189 engine_type (str): Always 'python'
190 model (BeePop): The underlying Python engine
191 """
193 def __init__(self, verbose: bool = False):
194 """
195 Initialize Python engine adapter.
197 Args:
198 verbose: Enable verbose output
199 """
200 from pybeepop.beepop import BeePop
202 self.model = BeePop()
203 self.engine_type = "python"
204 self.verbose = verbose
205 self._parameters = {} # Track parameters set
207 # Load valid parameters for validation (matching C++ engine behavior)
208 parent = os.path.dirname(os.path.abspath(__file__))
209 self.valid_parameters = pd.read_csv(
210 os.path.join(parent, "data/BeePop_exposed_parameters.csv"), skiprows=1
211 )["Exposed Variable Name"].tolist()
213 # Initialize model during adapter construction
214 self.model.initialize_model()
216 if verbose:
217 self.model.enable_error_reporting(True)
218 self.model.enable_info_reporting(True)
220 def _raise_with_log(
221 self, exception_class: Type[BeepopException], message: str
222 ) -> None:
223 """
224 Raise exception with BeePop+ error log included.
226 Args:
227 exception_class: The exception class to raise (BeepopParameterError, etc.)
228 message: The error message
230 Raises:
231 exception_class: Raised with error log and info log included
232 """
233 error_log = self.get_error_log()
234 info_log = self.get_info_log()
235 raise exception_class(
236 message=message,
237 error_log=error_log,
238 info_log=None, # info_log,
239 engine_type=self.engine_type,
240 )
242 def set_parameters(self, parameters: Dict[str, str]) -> Dict[str, str]:
243 """
244 Set parameters, converting from dict to list format.
246 Python engine expects: ["ParamName=value", ...]
247 PyBeePop provides: {"ParamName": "value", ...}
249 Raises:
250 BeepopParameterError: If parameter name is not valid
251 BeepopRuntimeError: If parameters cannot be set
252 """
253 try:
254 # Validate parameter names (matching C++ engine behavior)
255 for par_name in parameters.keys():
256 if par_name.lower() not in [x.lower() for x in self.valid_parameters]:
257 self._raise_with_log(
258 BeepopParameterError, f"{par_name} is not a valid parameter."
259 )
261 # Convert dict to list format
262 param_list = [f"{k}={v}" for k, v in parameters.items()]
264 # Set parameters (don't reset ICs to preserve previous settings)
265 success = self.model.set_ic_variables_v(param_list, reset_ics=False)
267 if success:
268 self._parameters.update(parameters)
269 return parameters
270 else:
271 self._raise_with_log(BeepopRuntimeError, "Error setting parameters")
272 except BeepopException:
273 # Re-raise our custom exceptions
274 raise
275 except Exception as e:
276 if self.verbose:
277 print(f"Error setting parameters: {e}")
278 self._raise_with_log(BeepopRuntimeError, f"Error setting parameters: {e}")
280 def get_parameters(self) -> Dict[str, str]:
281 """Get currently set parameters with lowercase keys."""
282 return {k.lower(): v for k, v in self._parameters.items()}
284 def load_parameter_file(self, file_path: str) -> bool:
285 """
286 Load parameter file via Python engine.
288 Raises:
289 BeepopFileError: If file cannot be opened or read
290 BeepopParameterError: If parameter is invalid
291 BeepopRuntimeError: If parameters cannot be loaded
292 """
293 try:
294 # Try to open and read file to catch OSError early
295 with open(file_path, "r") as f:
296 lines = f.readlines()
298 # Validate parameter names before loading
299 for line in lines:
300 clean_line = line.strip()
301 if clean_line and not clean_line.startswith("#") and "=" in clean_line:
302 param_name = clean_line.split("=", 1)[0].strip().lower()
303 if param_name not in [x.lower() for x in self.valid_parameters]:
304 self._raise_with_log(
305 BeepopParameterError,
306 f"{param_name} is not a valid parameter.",
307 )
309 success = self.model.load_parameter_file(file_path)
311 # If successful, parse file to track parameters
312 if success:
313 for line in lines:
314 clean_line = line.strip()
315 if (
316 clean_line
317 and not clean_line.startswith("#")
318 and "=" in clean_line
319 ):
320 key, value = clean_line.split("=", 1)
321 self._parameters[key.strip()] = value.strip()
322 return True
323 else:
324 self._raise_with_log(BeepopRuntimeError, "Error loading parameter file")
325 except BeepopException:
326 # Re-raise our custom exceptions
327 raise
328 except OSError as e:
329 # Re-raise as BeepopFileError with error logs
330 self._raise_with_log(BeepopFileError, str(e))
331 except Exception as e:
332 if self.verbose:
333 print(f"Error loading parameter file: {e}")
334 self._raise_with_log(
335 BeepopRuntimeError, f"Error loading parameter file: {e}"
336 )
338 def load_weather_file(self, file_path: str) -> bool:
339 """
340 Load weather file via Python engine.
342 Raises:
343 BeepopFileError: If file cannot be opened or read
344 BeepopRuntimeError: If weather cannot be loaded
345 """
346 try:
347 # Try to open file to catch OSError early (matching C++ engine)
348 with open(file_path, "r") as f:
349 f.read()
351 success = self.model.set_weather_from_file(file_path)
352 if success:
353 return True
354 else:
355 self._raise_with_log(BeepopRuntimeError, "Error Loading Weather")
356 except BeepopException:
357 # Re-raise our custom exceptions
358 raise
359 except OSError:
360 # Re-raise as BeepopFileError with error logs
361 self._raise_with_log(BeepopFileError, "Weather file is invalid.")
362 except Exception as e:
363 if self.verbose:
364 print(f"Error loading weather file: {e}")
365 self._raise_with_log(BeepopRuntimeError, f"Error loading weather file: {e}")
367 def load_residue_file(self, file_path: str) -> bool:
368 """
369 Load residue file, converting to list format.
371 Python engine expects list of strings, not a file path.
372 We need to parse the file ourselves.
374 Raises:
375 BeepopFileError: If file cannot be opened or read
376 BeepopRuntimeError: If residue file cannot be loaded
377 """
378 try:
379 with open(file_path, "r") as f:
380 lines = [
381 line.strip()
382 for line in f
383 if line.strip() and not line.startswith("#")
384 ]
385 except Exception:
386 self._raise_with_log(BeepopFileError, "Residue file is invalid.")
388 try:
389 success = self.model.set_contamination_table(lines)
390 if success:
391 return True
392 else:
393 self._raise_with_log(BeepopRuntimeError, "Error loading residue file")
394 except BeepopException:
395 # Re-raise our custom exceptions
396 raise
397 except Exception as e:
398 if self.verbose:
399 print(f"Error loading residue file: {e}")
400 self._raise_with_log(BeepopRuntimeError, f"Error loading residue file: {e}")
402 def set_latitude(self, latitude: float) -> bool:
403 """Set latitude via Python engine."""
404 try:
405 return self.model.set_latitude(latitude)
406 except Exception as e:
407 if self.verbose:
408 print(f"Error setting latitude: {e}")
409 return False
411 def run_simulation(self) -> Optional[pd.DataFrame]:
412 """
413 Run simulation via Python engine.
415 Raises:
416 BeepopRuntimeError: If simulation fails to run
417 """
418 try:
419 success = self.model.run_simulation()
420 if success:
421 success, df = self.model.get_results_dataframe()
422 if success:
423 return df
424 else:
425 self._raise_with_log(
426 BeepopRuntimeError, "Error running BeePop+ simulation."
427 )
428 else:
429 self._raise_with_log(
430 BeepopRuntimeError, "Error running BeePop+ simulation."
431 )
432 except BeepopException:
433 # Re-raise our custom exceptions
434 raise
435 except Exception as e:
436 if self.verbose:
437 print(f"Error running simulation: {e}")
438 self._raise_with_log(
439 BeepopRuntimeError, f"Error running BeePop+ simulation: {e}"
440 )
442 def get_error_log(self) -> str:
443 """
444 Get error log, converting from tuple format to string.
446 Python engine returns: (success: bool, errors: List[str])
447 We need: string (newline-separated)
448 """
449 try:
450 success, errors = self.model.get_error_list()
451 if success and errors:
452 return "\n".join(errors)
453 return ""
454 except Exception:
455 return ""
457 def get_info_log(self) -> str:
458 """
459 Get info log, converting from tuple format to string.
461 Python engine returns: (success: bool, info: List[str])
462 We need: string (newline-separated)
463 """
464 try:
465 success, info = self.model.get_info_list()
466 if success and info:
467 return "\n".join(info)
468 return ""
469 except Exception:
470 return ""
472 def get_version(self) -> str:
473 """Get version from Python engine."""
474 try:
475 success, version = self.model.get_lib_version()
476 return version if success else "Unknown"
477 except Exception:
478 return "Unknown"
480 def cleanup(self) -> None:
481 """Python engine handles cleanup automatically via garbage collection."""
482 pass # No explicit cleanup needed