Coverage for pybeepop/pybeepop.py: 88%
146 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"""
2pybeepop - BeePop+ interface for Python
3"""
5import os
6import platform
7import pandas as pd
8from typing import Optional
9from .tools import BeePopModel
10from .plots import plot_timeseries
11from .engine_interface import BeepopEngineInterface
12import json
15class PyBeePop:
16 """
17 Python interface for the BeePop+ honey bee colony simulation model.
19 BeePop+ is a mechanistic model for simulating honey bee colony dynamics, designed for ecological risk assessment and research applications.
20 This interface enables programmatic access to BeePop+ from Python, supporting batch simulations, sensitivity analysis, and integration with
21 data analysis workflows.
23 For scientific background, model structure, and example applications, see:
24 Garber et al. (2022), "Simulating the Effects of Pesticides on Honey Bee (Apis mellifera L.) Colonies with BeePop+", Ecologies.
25 Minucci et al. (2025), "pybeepop: A Python interface for the BeePop+ honey bee colony model," Journal of Open Research Software.
27 Example usage:
28 >>> from pybeepop.pybeepop import PyBeePop
29 >>> model = PyBeePop(parameter_file='params.txt', weather_file='weather.csv', residue_file='residues.csv')
30 >>> model.run_model()
31 >>> results = model.get_output()
32 >>> model.plot_output()
33 """
35 def __init__(
36 self,
37 engine="auto",
38 lib_file=None,
39 parameter_file=None,
40 weather_file=None,
41 residue_file=None,
42 latitude=30.0,
43 verbose=False,
44 ):
45 """
46 Initialize a PyBeePop object with choice of simulation engine.
48 Args:
49 engine (str, optional): Simulation engine to use. Options:
50 - 'auto' (default): Automatically select engine. Tries C++ first on
51 Windows/Linux, uses Python on macOS. Falls back to Python if C++
52 unavailable or initialization fails.
53 - 'cpp': Force C++ engine. Raises error if unavailable. Not supported on macOS.
54 - 'python': Force pure Python engine (available on all platforms).
56 lib_file (str, optional): Path to BeePop+ shared library (.dll or .so).
57 Only relevant when engine='cpp' or engine='auto'. If None, attempts
58 to auto-detect based on OS and architecture.
60 parameter_file (str, optional): Path to a text file of BeePop+ parameters (one per line, parameter=value). See https://doi.org/10.3390/ecologies3030022
61 or the documentation for valid parameters.
62 weather_file (str, optional): Path to a .csv or comma-separated .txt file containing weather data, where each row denotes:
63 Date (MM/DD/YY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional).
64 residue_file (str, optional): Path to a .csv or comma-separated .txt file containing pesticide residue data. Each row should specify Date (MM/DD/YYYY),
65 Concentration in nectar (g A.I. / g), Concentration in pollen (g A.I. / g). Values can be in scientific notation (e.g., "9.00E-08").
66 latitude (float, optional): Latitude in decimal degrees for daylight hour calculations (-90 to 90). Defaults to 30.0.
67 verbose (bool, optional): If True, print additional debugging statements. Defaults to False.
69 Raises:
70 FileNotFoundError: If a provided file does not exist at the specified path.
71 NotImplementedError: If run on a platform that is not 64-bit Windows or Linux.
72 ValueError: If engine parameter is invalid or latitude is outside the valid range.
74 Examples:
75 >>> # Auto-select engine (backward compatible)
76 >>> model = PyBeePop(weather_file='weather.csv')
77 >>> results = model.run_model()
79 >>> # Force Python engine
80 >>> model = PyBeePop(engine='python')
81 >>> model.load_weather('weather.csv')
82 >>> results = model.run_model()
84 >>> # Force C++ engine with custom library
85 >>> model = PyBeePop(engine='cpp', lib_file='/path/to/custom_beepop.so')
86 >>> model.load_weather('weather.csv')
87 >>> results = model.run_model()
88 """
89 self.verbose = verbose
90 self.engine_type: Optional[str] = None
91 self.engine: Optional[BeepopEngineInterface] = None
92 self.lib_file: Optional[str] = None # For backward compatibility
94 # Engine selection logic
95 current_platform = platform.system()
97 # macOS only supports Python engine
98 if current_platform == "Darwin":
99 if engine == "cpp":
100 raise NotImplementedError(
101 "The C++ engine is not supported on macOS due to architecture compatibility issues. "
102 "Please use engine='python' instead."
103 )
104 elif engine == "auto":
105 if verbose:
106 print(
107 "macOS detected: using Python engine (C++ engine not supported on macOS)"
108 )
109 engine = "python"
111 if engine == "python":
112 self.engine = self._initialize_python_engine()
113 self.engine_type = "python"
114 elif engine == "cpp":
115 self.engine = self._initialize_cpp_engine(lib_file)
116 self.engine_type = "cpp"
117 # Store lib_file for backward compatibility
118 if hasattr(self.engine, "lib_file"):
119 self.lib_file = self.engine.lib_file
120 elif engine == "auto":
121 # Try C++ first (backward compatible), fall back to Python
122 try:
123 self.engine = self._initialize_cpp_engine(lib_file)
124 self.engine_type = "cpp"
125 # Store lib_file for backward compatibility
126 if hasattr(self.engine, "lib_file"):
127 self.lib_file = self.engine.lib_file
128 if verbose:
129 print("Using C++ engine")
130 except Exception as e:
131 if verbose:
132 print(f"C++ engine initialization failed: {e}")
133 print("Falling back to Python engine...")
134 self.engine = self._initialize_python_engine()
135 self.engine_type = "python"
136 if verbose:
137 print("Using Python engine")
138 else:
139 raise ValueError(
140 f"Invalid engine type: '{engine}'. "
141 f"Must be 'auto', 'cpp', or 'python'."
142 )
144 # Validate and set latitude
145 if not -90 <= latitude <= 90:
146 raise ValueError("Latitude must be between -90 and 90 degrees")
147 self.current_latitude = latitude
148 self.engine.set_latitude(self.current_latitude)
150 # Initialize file paths and parameters
151 self.parameter_file = None
152 self.weather_file = None
153 self.residue_file = None
154 self.parameters = {}
155 self.output = None
157 # Add backward compatibility alias
158 self.beepop = self.engine
160 # Load files if provided
161 if parameter_file is not None:
162 self.load_parameter_file(parameter_file)
164 if weather_file is not None:
165 self.load_weather(weather_file)
167 if residue_file is not None:
168 self.load_residue_file(residue_file)
170 def _initialize_cpp_engine(self, lib_file) -> BeepopEngineInterface:
171 """
172 Initialize C++ engine.
174 Args:
175 lib_file: Path to shared library, or None for auto-detection
177 Returns:
178 CppEngineAdapter: Initialized C++ engine adapter
180 Raises:
181 FileNotFoundError: If library file not found
182 RuntimeError: If initialization fails
183 """
184 from .adapters import (
185 CppEngineAdapter,
186 ) # Auto-detect lib_file if not provided (existing logic)
188 if lib_file is None:
189 parent = os.path.dirname(os.path.abspath(__file__))
190 platform_name = platform.system()
192 if platform_name == "Windows":
193 if platform.architecture()[0] == "32bit":
194 raise NotImplementedError(
195 "Windows x86 (32-bit) is not supported by BeePop+. "
196 "Please run on an x64 platform."
197 )
198 lib_file = os.path.join(parent, "lib/beepop_win64.dll")
199 elif platform_name == "Linux":
200 lib_file = os.path.join(parent, "lib/beepop_linux.so")
201 if self.verbose:
202 print(
203 "Running in Linux mode. Trying manylinux/musllinux version.\\n"
204 "If you encounter errors, you may need to compile your own version of BeePop+ from source and pass the path to your\\n"
205 ".so file with the lib_file option. Currently, only 64-bit architecture is supported.\\n"
206 "See the pybeepop README for instructions."
207 )
208 else:
209 raise NotImplementedError(
210 "BeePop+ C++ engine only supports Windows and Linux. "
211 "For macOS, use engine='python'."
212 )
214 if not os.path.isfile(lib_file):
215 raise FileNotFoundError(
216 f"BeePop+ shared library not found at: {lib_file}\\n"
217 f"You may need to compile BeePop+ from source or use engine='python'\\n"
218 f"See https://github.com/USEPA/pybeepop/blob/main/README.md for more info."
219 )
221 return CppEngineAdapter(lib_file, verbose=self.verbose)
223 def _initialize_python_engine(self) -> BeepopEngineInterface:
224 """
225 Initialize Python engine.
227 Returns:
228 PythonEngineAdapter: Initialized Python engine adapter
229 """
230 from .adapters import PythonEngineAdapter
232 return PythonEngineAdapter(verbose=self.verbose)
234 def set_parameters(self, parameters):
235 """
236 Set BeePop+ parameters based on a dictionary {parameter: value}.
238 Args:
239 parameters (dict): Dictionary of BeePop+ parameters {parameter: value}. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
241 Raises:
242 TypeError: If parameters is not a dict.
243 ValueError: If a parameter is not a valid BeePop+ parameter.
244 """
245 if (parameters is not None) and (not isinstance(parameters, dict)):
246 raise TypeError(
247 "parameters must be a named dictionary of BeePop+ parameters"
248 )
249 self.parameters = self.engine.set_parameters(parameters)
251 def get_parameters(self):
252 """
253 Return all parameters that have been set by the user.
255 Returns:
256 dict: Dictionary of current BeePop+ parameters.
257 """
258 return self.engine.get_parameters()
260 def set_latitude(self, latitude):
261 """
262 Set the latitude for daylight hour calculations.
264 Args:
265 latitude (float): Latitude in decimal degrees (-90 to 90). Positive values are North, negative are South.
267 Raises:
268 ValueError: If latitude is outside the valid range.
269 """
270 if not -90 <= latitude <= 90:
271 raise ValueError("Latitude must be between -90 and 90 degrees")
272 self.current_latitude = latitude
273 self.engine.set_latitude(latitude)
275 def get_latitude(self):
276 """
277 Get the currently set latitude.
279 Returns:
280 float: Current latitude in decimal degrees.
281 """
282 return self.current_latitude
284 def set_simulation_dates(self, start_date, end_date):
285 """
286 Convenience method to set simulation start and end dates. The dates can
287 also be set directly as SimStart/SimEnd using the set_parameters() or
288 load_parameters() methods.
290 Args:
291 start_date (str): Simulation start date in MM/DD/YYYY format.
292 end_date (str): Simulation end date in MM/DD/YYYY format.
293 """
294 date_params = {"SimStart": start_date, "SimEnd": end_date}
295 self.set_parameters(date_params)
297 if self.verbose:
298 print(f"Set simulation dates: {start_date} to {end_date}")
300 def load_weather(self, weather_file):
301 """
302 Load a weather file. The file should be a .csv or comma-delimited .txt file where each row denotes:
303 Date (MM/DD/YYYY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional).
305 Note: Loading weather may reset simulation dates (SimStart/SimEnd) to the weather file's date range.
306 Any previously set parameters will be automatically re-applied after weather loading.
308 Args:
309 weather_file (str): Path to the weather file (csv or txt). See docs/weather_readme.txt and manuscript for format details.
311 Raises:
312 TypeError: If weather_file is None.
313 FileNotFoundError: If the provided file does not exist at the specified path.
314 OSError: If the file cannot be opened or read.
315 RuntimeError: If weather file cannot be loaded.
316 """
317 if weather_file is None:
318 raise TypeError("Cannot set weather file to None")
319 if not os.path.isfile(weather_file):
320 raise FileNotFoundError(
321 "Weather file does not exist at path: {}!".format(weather_file)
322 )
323 self.weather_file = weather_file
325 # Load weather via adapter
326 success = self.engine.load_weather_file(self.weather_file)
327 if not success:
328 raise RuntimeError("Failed to load weather file")
330 def load_parameter_file(self, parameter_file):
331 """
332 Load a .txt file of parameter values to set. Each row of the file is a string with the format 'parameter=value'.
334 Args:
335 parameter_file (str): Path to a txt file of BeePop+ parameters. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
337 Raises:
338 FileNotFoundError: If the provided file does not exist at the specified path.
339 ValueError: If a listed parameter is not a valid BeePop+ parameter.
340 """
341 if not os.path.isfile(parameter_file):
342 raise FileNotFoundError(
343 "Paramter file does not exist at path: {}!".format(parameter_file)
344 )
345 self.parameter_file = parameter_file
347 # Load parameter file via adapter
348 # Note: adapter will raise ValueError for invalid parameters
349 success = self.engine.load_parameter_file(self.parameter_file)
350 if not success:
351 raise RuntimeError("Failed to load parameter file")
353 def load_residue_file(self, residue_file):
354 """
355 Load a .csv or comma-delimited .txt file of pesticide residues in pollen/nectar. Each row should specify Date (MM/DD/YYYY),
356 Concentration in nectar (g A.I. / g), Concentration in pollen (g A.I. / g). Values can be in scientific notation (e.g., "9.00E-08").
358 Args:
359 residue_file (str): Path to the residue .csv or .txt file. See docs/residue_file_readme.txt and manuscript for format details.
361 Raises:
362 FileNotFoundError: If the provided file does not exist at the specified path.
363 """
364 if not os.path.isfile(residue_file):
365 raise FileNotFoundError(
366 "Residue file does not exist at path: {}!".format(residue_file)
367 )
368 self.residue_file = residue_file
370 # Load residue file via adapter
371 success = self.engine.load_residue_file(self.residue_file)
372 if not success:
373 raise RuntimeError("Failed to load residue file")
375 def run_model(self):
376 """
377 Run the BeePop+ model simulation.
379 Raises:
380 RuntimeError: If the weather file has not yet been set.
382 Returns:
383 pandas.DataFrame: DataFrame of daily time series results for the BeePop+ run, including colony size, adult workers, brood, eggs, and other metrics.
384 """
385 # check to see if parameters have been supplied
386 if (self.parameter_file is None) and (not self.parameters):
387 print("No parameters have been set. Running with default settings.")
388 if self.weather_file is None:
389 raise RuntimeError("Weather must be set before running BeePop+!")
391 # Run via adapter
392 self.output = self.engine.run_simulation()
394 if self.output is None:
395 raise RuntimeError("Simulation failed to produce results")
397 return self.output
399 def get_output(self, format="DataFrame"):
400 """
401 Get the output from the last BeePop+ run.
403 Args:
404 format (str, optional): Return results as DataFrame ('DataFrame') or JSON string ('json'). Defaults to 'DataFrame'.
406 Raises:
407 RuntimeError: If there is no output because run_model has not yet been called.
409 Returns:
410 pandas.DataFrame or str: DataFrame or JSON string of the model results. JSON output is a dictionary of lists keyed by column name.
411 """
412 if self.output is None:
413 raise RuntimeError(
414 "There are no results to plot. Please run the model first."
415 )
416 if format == "json":
417 result = json.dumps(self.output.to_dict(orient="list"))
418 else:
419 result = self.output
420 return result
422 def plot_output(
423 self,
424 columns=[
425 "Colony Size",
426 "Adult Workers",
427 "Capped Worker Brood",
428 "Worker Larvae",
429 "Worker Eggs",
430 ],
431 ):
432 """
433 Plot the output as a time series.
435 Args:
436 columns (list, optional): List of column names to plot (as strings). Defaults to key colony metrics.
438 Raises:
439 RuntimeError: If there is no output because run_model has not yet been called.
440 IndexError: If any column name is not a valid output column.
442 Returns:
443 matplotlib.axes.Axes: Matplotlib Axes object for further customization.
444 """
445 if self.output is None:
446 raise RuntimeError(
447 "There are no results to plot. Please run the model first."
448 )
449 invalid_cols = [col not in self.output.columns for col in columns]
450 if any(invalid_cols):
451 raise IndexError(
452 "The column name {} is not a valid output column.".format(
453 [i for (i, v) in zip(columns, invalid_cols) if v]
454 )
455 )
456 plot = plot_timeseries(output=self.output, columns=columns)
457 return plot
459 def get_error_log(self):
460 """
461 Return the BeePop+ session error log as a string for debugging. Useful for troubleshooting.
463 Returns:
464 str: Error log from the BeePop+ session.
465 """
466 return self.engine.get_error_log()
468 def get_info_log(self):
469 """
470 Return the BeePop+ session info log as a string for debugging..
472 Returns:
473 str: Info log from the BeePop+ session.
474 """
475 return self.engine.get_info_log()
477 def version(self):
478 """
479 Return the BeePop+ version as a string.
481 Returns:
482 str: BeePop+ version string.
483 """
484 return self.engine.get_version()
486 def exit(self):
487 """
488 Close the connection to the BeePop+ simulation engine and clean up resources.
489 """
490 if hasattr(self, "engine") and self.engine is not None:
491 try:
492 self.engine.cleanup()
493 except Exception as e:
494 if self.verbose:
495 print(f"Warning during cleanup: {e}")
496 self.engine = None
498 def __del__(self):
499 """Destructor to ensure cleanup when object is garbage collected."""
500 self.exit()