Coverage for pybeepop / pybeepop.py: 91%
143 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-16 23:00 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-16 23:00 +0000
1"""
2pybeepop - BeePop+ interface for Python
3"""
5import os
6import platform
7from pathlib import Path
8import pandas as pd
9from typing import Optional
10from .tools import BeePopModel
11from .plots import plot_timeseries
12from .engine_interface import BeepopEngineInterface
13import json
16class PyBeePop:
17 """
18 Python interface for the BeePop+ honey bee colony simulation model.
20 BeePop+ is a mechanistic model for simulating honey bee colony dynamics, designed for ecological risk assessment and research applications.
21 This interface enables programmatic access to BeePop+ from Python, supporting batch simulations, sensitivity analysis, and integration with
22 data analysis workflows.
24 For scientific background, model structure, and example applications, see:
25 Garber et al. (2022), "Simulating the Effects of Pesticides on Honey Bee (Apis mellifera L.) Colonies with BeePop+", Ecologies.
26 Minucci et al. (2025), "pybeepop: A Python interface for the BeePop+ honey bee colony model," Journal of Open Research Software.
28 Example usage:
29 >>> from pybeepop.pybeepop import PyBeePop
30 >>> model = PyBeePop(parameter_file='params.txt', weather_file='weather.csv', residue_file='residues.csv')
31 >>> model.run_model()
32 >>> results = model.get_output()
33 >>> model.plot_output()
34 """
36 def __init__(
37 self,
38 engine="python",
39 lib_file=None,
40 parameter_file=None,
41 weather_file=None,
42 residue_file=None,
43 latitude=30.0,
44 verbose=False,
45 ):
46 """
47 Initialize a PyBeePop object with choice of simulation engine.
49 Args:
50 engine (str, optional): Simulation engine to use. Options:
51 - 'python' (default): Use the pure Python engine. Available on all
52 platforms and requires no compiled BeePop+ library.
53 - 'cpp': Force C++ engine. Raises error if unavailable. Not supported on macOS.
55 lib_file (str, optional): Path to BeePop+ shared library (.dll or .so).
56 Only relevant when engine='cpp'. If None, attempts to auto-detect
57 based on OS and architecture.
59 parameter_file (str, optional): Path to a text file of BeePop+ parameters (one per line, parameter=value). If provided,
60 it is loaded after the bundled default parameter file so user values override package defaults. See
61 https://doi.org/10.3390/ecologies3030022 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 >>> # Default to the Python engine
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 )
105 if engine == "python":
106 self.engine = self._initialize_python_engine()
107 self.engine_type = "python"
108 elif engine == "cpp":
109 self.engine = self._initialize_cpp_engine(lib_file)
110 self.engine_type = "cpp"
111 # Store lib_file for backward compatibility
112 if hasattr(self.engine, "lib_file"):
113 self.lib_file = self.engine.lib_file
114 else:
115 raise ValueError(
116 f"Invalid engine type: '{engine}'. "
117 f"Must be 'cpp' or 'python'."
118 )
120 # Validate and set latitude
121 if not -90 <= latitude <= 90:
122 raise ValueError("Latitude must be between -90 and 90 degrees")
123 self.current_latitude = latitude
124 self.engine.set_latitude(self.current_latitude)
126 # Initialize file paths and parameters
127 self.parameter_file = None
128 self.weather_file = None
129 self.residue_file = None
130 self.parameters = {}
131 self.output = None
132 self.default_parameter_file = self._get_default_parameter_file()
134 # Add backward compatibility alias
135 self.beepop = self.engine
137 # Load bundled defaults before any user-supplied parameter files.
138 self._load_default_parameter_file()
140 # Load files if provided
141 if parameter_file is not None:
142 self.load_parameter_file(parameter_file)
144 if weather_file is not None:
145 self.load_weather(weather_file)
147 if residue_file is not None:
148 self.load_residue_file(residue_file)
150 def _get_default_parameter_file(self) -> str:
151 """Return the packaged default parameter file path."""
152 return str(Path(__file__).resolve().parent / "data" / "default_parameters.txt")
154 def _load_default_parameter_file(self) -> None:
155 """Load bundled default parameters without marking them as a user file."""
156 self.load_parameter_file(self.default_parameter_file)
157 self.parameter_file = None
159 def _initialize_cpp_engine(self, lib_file) -> BeepopEngineInterface:
160 """
161 Initialize C++ engine.
163 Args:
164 lib_file: Path to shared library, or None for auto-detection
166 Returns:
167 CppEngineAdapter: Initialized C++ engine adapter
169 Raises:
170 FileNotFoundError: If library file not found
171 RuntimeError: If initialization fails
172 """
173 from .adapters import (
174 CppEngineAdapter,
175 ) # Auto-detect lib_file if not provided (existing logic)
177 if lib_file is None:
178 parent = os.path.dirname(os.path.abspath(__file__))
179 platform_name = platform.system()
181 if platform_name == "Windows":
182 if platform.architecture()[0] == "32bit":
183 raise NotImplementedError(
184 "Windows x86 (32-bit) is not supported by BeePop+. "
185 "Please run on an x64 platform."
186 )
187 lib_file = os.path.join(parent, "lib/beepop_win64.dll")
188 elif platform_name == "Linux":
189 lib_file = os.path.join(parent, "lib/beepop_linux.so")
190 if self.verbose:
191 print(
192 "Running in Linux mode. Trying manylinux/musllinux version.\\n"
193 "If you encounter errors, you may need to compile your own version of BeePop+ from source and pass the path to your\\n"
194 ".so file with the lib_file option. Currently, only 64-bit architecture is supported.\\n"
195 "See the pybeepop README for instructions."
196 )
197 else:
198 raise NotImplementedError(
199 "BeePop+ C++ engine only supports Windows and Linux. "
200 "For macOS, use engine='python'."
201 )
203 if not os.path.isfile(lib_file):
204 raise FileNotFoundError(
205 f"BeePop+ shared library not found at: {lib_file}\\n"
206 f"You may need to compile BeePop+ from source or use engine='python'\\n"
207 f"See https://github.com/USEPA/pybeepop/blob/main/README.md for more info."
208 )
210 return CppEngineAdapter(lib_file, verbose=self.verbose)
212 def _initialize_python_engine(self) -> BeepopEngineInterface:
213 """
214 Initialize Python engine.
216 Returns:
217 PythonEngineAdapter: Initialized Python engine adapter
218 """
219 from .adapters import PythonEngineAdapter
221 return PythonEngineAdapter(verbose=self.verbose)
223 def set_parameters(self, parameters):
224 """
225 Set BeePop+ parameters based on a dictionary {parameter: value}.
227 Args:
228 parameters (dict): Dictionary of BeePop+ parameters {parameter: value}. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
230 Raises:
231 TypeError: If parameters is not a dict.
232 ValueError: If a parameter is not a valid BeePop+ parameter.
233 """
234 if (parameters is not None) and (not isinstance(parameters, dict)):
235 raise TypeError(
236 "parameters must be a named dictionary of BeePop+ parameters"
237 )
238 self.parameters = self.engine.set_parameters(parameters)
240 def get_parameters(self):
241 """
242 Return all parameters that have been set by the user.
244 Returns:
245 dict: Dictionary of current BeePop+ parameters.
246 """
247 return self.engine.get_parameters()
249 def set_latitude(self, latitude):
250 """
251 Set the latitude for daylight hour calculations.
253 Args:
254 latitude (float): Latitude in decimal degrees (-90 to 90). Positive values are North, negative are South.
256 Raises:
257 ValueError: If latitude is outside the valid range.
258 """
259 if not -90 <= latitude <= 90:
260 raise ValueError("Latitude must be between -90 and 90 degrees")
261 self.current_latitude = latitude
262 self.engine.set_latitude(latitude)
264 def get_latitude(self):
265 """
266 Get the currently set latitude.
268 Returns:
269 float: Current latitude in decimal degrees.
270 """
271 return self.current_latitude
273 def set_simulation_dates(self, start_date, end_date):
274 """
275 Convenience method to set simulation start and end dates. The dates can
276 also be set directly as SimStart/SimEnd using the set_parameters() or
277 load_parameters() methods.
279 Args:
280 start_date (str): Simulation start date in MM/DD/YYYY format.
281 end_date (str): Simulation end date in MM/DD/YYYY format.
282 """
283 date_params = {"SimStart": start_date, "SimEnd": end_date}
284 self.set_parameters(date_params)
286 if self.verbose:
287 print(f"Set simulation dates: {start_date} to {end_date}")
289 def load_weather(self, weather_file):
290 """
291 Load a weather file. The file should be a .csv or comma-delimited .txt file where each row denotes:
292 Date (MM/DD/YYYY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional).
294 Note: Loading weather may reset simulation dates (SimStart/SimEnd) to the weather file's date range.
295 Any previously set parameters will be automatically re-applied after weather loading.
297 Args:
298 weather_file (str): Path to the weather file (csv or txt). See docs/weather_readme.txt and manuscript for format details.
300 Raises:
301 TypeError: If weather_file is None.
302 FileNotFoundError: If the provided file does not exist at the specified path.
303 OSError: If the file cannot be opened or read.
304 RuntimeError: If weather file cannot be loaded.
305 """
306 if weather_file is None:
307 raise TypeError("Cannot set weather file to None")
308 if not os.path.isfile(weather_file):
309 raise FileNotFoundError(
310 "Weather file does not exist at path: {}!".format(weather_file)
311 )
312 self.weather_file = weather_file
314 # Load weather via adapter
315 success = self.engine.load_weather_file(self.weather_file)
316 if not success:
317 raise RuntimeError("Failed to load weather file")
319 def load_parameter_file(self, parameter_file):
320 """
321 Load a .txt file of parameter values to set. Each row of the file is a string with the format 'parameter=value'.
323 Args:
324 parameter_file (str): Path to a txt file of BeePop+ parameters. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
326 Raises:
327 FileNotFoundError: If the provided file does not exist at the specified path.
328 ValueError: If a listed parameter is not a valid BeePop+ parameter.
329 """
330 if not os.path.isfile(parameter_file):
331 raise FileNotFoundError(
332 "Paramter file does not exist at path: {}!".format(parameter_file)
333 )
334 self.parameter_file = parameter_file
336 # Load parameter file via adapter
337 # Note: adapter will raise ValueError for invalid parameters
338 success = self.engine.load_parameter_file(self.parameter_file)
339 if not success:
340 raise RuntimeError("Failed to load parameter file")
342 def load_residue_file(self, residue_file):
343 """
344 Load a .csv or comma-delimited .txt file of pesticide residues in pollen/nectar. Each row should specify Date (MM/DD/YYYY),
345 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").
347 Args:
348 residue_file (str): Path to the residue .csv or .txt file. See docs/residue_file_readme.txt and manuscript for format details.
350 Raises:
351 FileNotFoundError: If the provided file does not exist at the specified path.
352 """
353 if not os.path.isfile(residue_file):
354 raise FileNotFoundError(
355 "Residue file does not exist at path: {}!".format(residue_file)
356 )
357 self.residue_file = residue_file
359 # Load residue file via adapter
360 success = self.engine.load_residue_file(self.residue_file)
361 if not success:
362 raise RuntimeError("Failed to load residue file")
364 def run_model(self):
365 """
366 Run the BeePop+ model simulation.
368 Raises:
369 RuntimeError: If the weather file has not yet been set.
371 Returns:
372 pandas.DataFrame: DataFrame of daily time series results for the BeePop+ run, including colony size, adult workers, brood, eggs, and other metrics.
373 """
374 # check to see if parameters have been supplied
375 if (self.parameter_file is None) and (not self.parameters):
376 print("No user parameters have been set. Running with bundled default settings.")
377 if self.weather_file is None:
378 raise RuntimeError("Weather must be set before running BeePop+!")
380 # Run via adapter
381 self.output = self.engine.run_simulation()
383 if self.output is None:
384 raise RuntimeError("Simulation failed to produce results")
386 return self.output
388 def get_output(self, format="DataFrame"):
389 """
390 Get the output from the last BeePop+ run.
392 Args:
393 format (str, optional): Return results as DataFrame ('DataFrame') or JSON string ('json'). Defaults to 'DataFrame'.
395 Raises:
396 RuntimeError: If there is no output because run_model has not yet been called.
398 Returns:
399 pandas.DataFrame or str: DataFrame or JSON string of the model results. JSON output is a dictionary of lists keyed by column name.
400 """
401 if self.output is None:
402 raise RuntimeError(
403 "There are no results to plot. Please run the model first."
404 )
405 if format == "json":
406 result = json.dumps(self.output.to_dict(orient="list"))
407 else:
408 result = self.output
409 return result
411 def plot_output(
412 self,
413 columns=[
414 "Colony Size",
415 "Adult Workers",
416 "Capped Worker Brood",
417 "Worker Larvae",
418 "Worker Eggs",
419 ],
420 ):
421 """
422 Plot the output as a time series.
424 Args:
425 columns (list, optional): List of column names to plot (as strings). Defaults to key colony metrics.
427 Raises:
428 RuntimeError: If there is no output because run_model has not yet been called.
429 IndexError: If any column name is not a valid output column.
431 Returns:
432 matplotlib.axes.Axes: Matplotlib Axes object for further customization.
433 """
434 if self.output is None:
435 raise RuntimeError(
436 "There are no results to plot. Please run the model first."
437 )
438 invalid_cols = [col not in self.output.columns for col in columns]
439 if any(invalid_cols):
440 raise IndexError(
441 "The column name {} is not a valid output column.".format(
442 [i for (i, v) in zip(columns, invalid_cols) if v]
443 )
444 )
445 plot = plot_timeseries(output=self.output, columns=columns)
446 return plot
448 def get_error_log(self):
449 """
450 Return the BeePop+ session error log as a string for debugging. Useful for troubleshooting.
452 Returns:
453 str: Error log from the BeePop+ session.
454 """
455 return self.engine.get_error_log()
457 def get_info_log(self):
458 """
459 Return the BeePop+ session info log as a string for debugging..
461 Returns:
462 str: Info log from the BeePop+ session.
463 """
464 return self.engine.get_info_log()
466 def version(self):
467 """
468 Return the BeePop+ version as a string.
470 Returns:
471 str: BeePop+ version string.
472 """
473 return self.engine.get_version()
475 def exit(self):
476 """
477 Close the connection to the BeePop+ simulation engine and clean up resources.
478 """
479 if hasattr(self, "engine") and self.engine is not None:
480 try:
481 self.engine.cleanup()
482 except Exception as e:
483 if self.verbose:
484 print(f"Warning during cleanup: {e}")
485 self.engine = None
487 def __del__(self):
488 """Destructor to ensure cleanup when object is garbage collected."""
489 self.exit()