Coverage for pybeepop/adapters.py: 78%
148 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-19 20:58 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-19 20:58 +0000
1"""
2Engine adapter for PyBeePop.
4Wraps the Python BeePop+ engine (beepop.BeePop) to provide an interface conforming
5to the BeepopEngineInterface protocol.
6"""
8import os
9from typing import Dict, Optional, Type
10import pandas as pd
12from .beepop.parameters import validate_parameter
13from .exceptions import (
14 BeepopException,
15 BeepopParameterError,
16 BeepopRuntimeError,
17 BeepopFileError,
18)
20# Parameters removed from pybeepop+, mapped to migration guidance. Keys are lowercase.
21_VTDATA_GUIDANCE = (
22 "Varroa treatments are now scheduled with VTData, which takes "
23 "start_date,duration_weeks,mortality% (e.g. VTData=6/2/2015,6,75). Pass one VTData "
24 "entry per treatment, or VTData=Clear to reset. Mite resistance is set on the "
25 "population with InitMitePctResistant and PctImmMitesResistant."
26)
28RETIRED_PARAMETERS = {
29 "eseedconcentration": (
30 "ESeedConcentration was removed in pybeepop+ 0.3.0. Use ESeedAppRate instead, "
31 "the seed treatment application rate in mg a.i./seed. Nectar and pollen residues "
32 "are now derived separately from that rate rather than sharing one concentration."
33 ),
34 "vttreatmentstart": f"VTTreatmentStart was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}",
35 "vttreatmentduration": (
36 f"VTTreatmentDuration was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}"
37 ),
38 "vtmortality": f"VTMortality was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}",
39 "rqwkrdrnratio": (
40 "RQWkrDrnRatio was removed in pybeepop+ 0.3.0. It was read but never used by the "
41 "model, so removing it does not change simulation results. The worker to drone "
42 "ratio follows from the queen's sperm reserves."
43 ),
44}
47def invalid_parameter_message(par_name: str) -> str:
48 """Return the error message for an unrecognized parameter name."""
49 retired = RETIRED_PARAMETERS.get(par_name.strip().lower())
50 if retired is not None:
51 return retired
52 return f"{par_name} is not a valid parameter."
55class PythonEngineAdapter:
56 """
57 Adapter for pure Python BeePop+ engine.
59 Wraps the Python port (beepop.BeePop) to conform to the BeepopEngineInterface
60 protocol, handling format conversions between PyBeePop's dict-based API and
61 the Python engine's list-based parameter format.
63 Attributes:
64 engine_type (str): Always 'python'
65 model (BeePop): The underlying Python engine
66 """
68 def __init__(self, verbose: bool = False):
69 """
70 Initialize Python engine adapter.
72 Args:
73 verbose: Enable verbose output
74 """
75 from pybeepop.beepop import BeePop
77 self.model = BeePop()
78 self.engine_type = "python"
79 self.verbose = verbose
80 self._parameters = {} # Track parameters set
82 # Load valid parameters for validation
83 parent = os.path.dirname(os.path.abspath(__file__))
84 self.valid_parameters = pd.read_csv(
85 os.path.join(parent, "data/BeePop_exposed_parameters.csv"), skiprows=1
86 )["Exposed Variable Name"].tolist()
87 self._valid_parameters_lower = {x.lower() for x in self.valid_parameters}
89 # Initialize model during adapter construction
90 self.model.initialize_model()
92 if verbose:
93 self.model.enable_error_reporting(True)
94 self.model.enable_info_reporting(True)
96 def _raise_with_log(
97 self, exception_class: Type[BeepopException], message: str
98 ) -> None:
99 """
100 Raise exception with BeePop+ error log included.
102 Args:
103 exception_class: The exception class to raise (BeepopParameterError, etc.)
104 message: The error message
106 Raises:
107 exception_class: Raised with error log and info log included
108 """
109 error_log = self.get_error_log()
110 info_log = self.get_info_log()
111 raise exception_class(
112 message=message,
113 error_log=error_log,
114 info_log=None, # info_log,
115 engine_type=self.engine_type,
116 )
118 def set_parameters(self, parameters: Dict[str, str]) -> Dict[str, str]:
119 """
120 Set parameters, converting from dict to list format.
122 Python engine expects: ["ParamName=value", ...]
123 PyBeePop provides: {"ParamName": "value", ...}
125 Raises:
126 BeepopParameterError: If parameter name is not valid
127 BeepopRuntimeError: If parameters cannot be set
128 """
129 try:
130 # Validate parameter names and values
131 for par_name, par_value in parameters.items():
132 if par_name.lower() not in self._valid_parameters_lower:
133 self._raise_with_log(
134 BeepopParameterError, invalid_parameter_message(par_name)
135 )
136 ok, _, error = validate_parameter(
137 par_name.lower(), str(par_value).strip(), par_name
138 )
139 if not ok:
140 self._raise_with_log(BeepopParameterError, error)
142 # Convert dict to list format
143 param_list = [f"{k}={v}" for k, v in parameters.items()]
145 # Set parameters (don't reset ICs to preserve previous settings)
146 success = self.model.set_ic_variables_v(param_list, reset_ics=False)
148 if success:
149 self._parameters.update(parameters)
150 return parameters
151 else:
152 self._raise_with_log(BeepopRuntimeError, "Error setting parameters")
153 except BeepopException:
154 # Re-raise our custom exceptions
155 raise
156 except Exception as e:
157 if self.verbose:
158 print(f"Error setting parameters: {e}")
159 self._raise_with_log(BeepopRuntimeError, f"Error setting parameters: {e}")
161 def get_parameters(self) -> Dict[str, str]:
162 """Get currently set parameters with lowercase keys."""
163 return {k.lower(): v for k, v in self._parameters.items()}
165 def load_parameter_file(self, file_path: str) -> bool:
166 """
167 Load parameter file via Python engine.
169 Raises:
170 BeepopFileError: If file cannot be opened or read
171 BeepopParameterError: If parameter is invalid
172 BeepopRuntimeError: If parameters cannot be loaded
173 """
174 try:
175 # Try to open and read file to catch OSError early
176 with open(file_path, "r") as f:
177 lines = f.readlines()
179 # Validate parameter names and values before loading
180 for line in lines:
181 clean_line = line.strip()
182 if clean_line and not clean_line.startswith("#") and "=" in clean_line:
183 raw_name, raw_value = clean_line.split("=", 1)
184 param_name = raw_name.strip().lower()
185 if param_name not in self._valid_parameters_lower:
186 self._raise_with_log(
187 BeepopParameterError,
188 invalid_parameter_message(raw_name.strip()),
189 )
190 ok, _, error = validate_parameter(
191 param_name, raw_value.strip(), raw_name.strip()
192 )
193 if not ok:
194 self._raise_with_log(BeepopParameterError, error)
196 success = self.model.load_parameter_file(file_path)
198 # If successful, parse file to track parameters
199 if success:
200 for line in lines:
201 clean_line = line.strip()
202 if (
203 clean_line
204 and not clean_line.startswith("#")
205 and "=" in clean_line
206 ):
207 key, value = clean_line.split("=", 1)
208 self._parameters[key.strip()] = value.strip()
209 return True
210 else:
211 self._raise_with_log(BeepopRuntimeError, "Error loading parameter file")
212 except BeepopException:
213 # Re-raise our custom exceptions
214 raise
215 except OSError as e:
216 # Re-raise as BeepopFileError with error logs
217 self._raise_with_log(BeepopFileError, str(e))
218 except Exception as e:
219 if self.verbose:
220 print(f"Error loading parameter file: {e}")
221 self._raise_with_log(
222 BeepopRuntimeError, f"Error loading parameter file: {e}"
223 )
225 def load_weather_file(self, file_path: str) -> bool:
226 """
227 Load weather file via Python engine.
229 Raises:
230 BeepopFileError: If file cannot be opened or read
231 BeepopRuntimeError: If weather cannot be loaded
232 """
233 try:
234 # Try to open file to catch OSError early
235 with open(file_path, "r") as f:
236 f.read()
238 success = self.model.set_weather_from_file(file_path)
239 if success:
240 return True
241 else:
242 self._raise_with_log(BeepopRuntimeError, "Error Loading Weather")
243 except BeepopException:
244 # Re-raise our custom exceptions
245 raise
246 except OSError:
247 # Re-raise as BeepopFileError with error logs
248 self._raise_with_log(BeepopFileError, "Weather file is invalid.")
249 except Exception as e:
250 if self.verbose:
251 print(f"Error loading weather file: {e}")
252 self._raise_with_log(BeepopRuntimeError, f"Error loading weather file: {e}")
254 def load_residue_file(self, file_path: str) -> bool:
255 """
256 Load residue file, converting to list format.
258 Python engine expects list of strings, not a file path.
259 We need to parse the file ourselves.
261 Raises:
262 BeepopFileError: If file cannot be opened or read
263 BeepopRuntimeError: If residue file cannot be loaded
264 """
265 try:
266 with open(file_path, "r") as f:
267 lines = [
268 line.strip()
269 for line in f
270 if line.strip() and not line.startswith("#")
271 ]
272 except Exception:
273 self._raise_with_log(BeepopFileError, "Residue file is invalid.")
275 try:
276 # Load the contamination table
277 success = self.model.set_contamination_table(lines)
278 if success:
279 self.set_parameters(
280 {"NecPolFileEnable": "true"}
281 ) # Enable residue file mode
282 return True
283 else:
284 self._raise_with_log(BeepopRuntimeError, "Error loading residue file")
285 except BeepopException:
286 # Re-raise our custom exceptions
287 raise
288 except Exception as e:
289 if self.verbose:
290 print(f"Error loading residue file: {e}")
291 self._raise_with_log(BeepopRuntimeError, f"Error loading residue file: {e}")
293 def set_latitude(self, latitude: float) -> bool:
294 """Set latitude via Python engine."""
295 try:
296 return self.model.set_latitude(latitude)
297 except Exception as e:
298 if self.verbose:
299 print(f"Error setting latitude: {e}")
300 return False
302 def run_simulation(self) -> Optional[pd.DataFrame]:
303 """
304 Run simulation via Python engine.
306 Raises:
307 BeepopRuntimeError: If simulation fails to run
308 """
309 try:
310 success = self.model.run_simulation()
311 if success:
312 success, df = self.model.get_results_dataframe()
313 if success:
314 return df
315 else:
316 self._raise_with_log(
317 BeepopRuntimeError, "Error running BeePop+ simulation."
318 )
319 else:
320 self._raise_with_log(
321 BeepopRuntimeError, "Error running BeePop+ simulation."
322 )
323 except BeepopException:
324 # Re-raise our custom exceptions
325 raise
326 except Exception as e:
327 if self.verbose:
328 print(f"Error running simulation: {e}")
329 self._raise_with_log(
330 BeepopRuntimeError, f"Error running BeePop+ simulation: {e}"
331 )
333 def get_error_log(self) -> str:
334 """
335 Get error log, converting from tuple format to string.
337 Python engine returns: (success: bool, errors: List[str])
338 We need: string (newline-separated)
339 """
340 try:
341 success, errors = self.model.get_error_list()
342 if success and errors:
343 return "\n".join(errors)
344 return ""
345 except Exception:
346 return ""
348 def get_info_log(self) -> str:
349 """
350 Get info log, converting from tuple format to string.
352 Python engine returns: (success: bool, info: List[str])
353 We need: string (newline-separated)
354 """
355 try:
356 success, info = self.model.get_info_list()
357 if success and info:
358 return "\n".join(info)
359 return ""
360 except Exception:
361 return ""
363 def get_version(self) -> str:
364 """Get version from Python engine."""
365 try:
366 success, version = self.model.get_lib_version()
367 return version if success else "Unknown"
368 except Exception:
369 return "Unknown"
371 def cleanup(self) -> None:
372 """Python engine handles cleanup automatically via garbage collection."""
373 pass # No explicit cleanup needed