Coverage for pybeepop/pybeepop.py: 92%
125 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"""
2pybeepop - BeePop+ interface for Python
3"""
5import json
6import os
7from pathlib import Path
9from .engine_interface import BeepopEngineInterface
10from .plots import plot_timeseries
13class PyBeePop:
14 """
15 Python interface for the BeePop+ honey bee colony simulation model.
17 BeePop+ is a mechanistic model for simulating honey bee colony dynamics, designed for ecological risk assessment and research applications.
18 This interface enables programmatic access to BeePop+ from Python, supporting batch simulations, sensitivity analysis, and integration with
19 data analysis workflows.
21 For scientific background, model structure, and example applications, see:
22 Garber et al. (2022), "Simulating the Effects of Pesticides on Honey Bee (Apis mellifera L.) Colonies with BeePop+", Ecologies.
23 Minucci et al. (2025), "pybeepop: A Python interface for the BeePop+ honey bee colony model," Journal of Open Research Software.
25 Example usage:
26 >>> from pybeepop.pybeepop import PyBeePop
27 >>> model = PyBeePop(parameter_file='params.txt', weather_file='weather.csv', residue_file='residues.csv')
28 >>> model.run_model()
29 >>> results = model.get_output()
30 >>> model.plot_output()
31 """
33 def __init__(
34 self,
35 engine="python",
36 lib_file=None,
37 parameter_file=None,
38 weather_file=None,
39 residue_file=None,
40 latitude=30.0,
41 verbose=False,
42 ):
43 """
44 Initialize a PyBeePop object with choice of simulation engine.
46 Args:
47 engine (str, optional): Retained for backward compatibility. Only 'python'
48 is accepted; the C++ engine was removed in version 0.3.0.
49 lib_file (str, optional): Retained for backward compatibility. Accepted only
50 as None; the C++ engine was removed in version 0.3.0.
51 parameter_file (str, optional): Path to a text file of BeePop+ parameters (one per line, parameter=value). If provided,
52 it is loaded after the bundled default parameter file so user values override package defaults. See
53 https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
54 weather_file (str, optional): Path to a .csv or comma-separated .txt file containing weather data, where each row denotes:
55 Date (MM/DD/YY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional).
56 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),
57 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").
58 latitude (float, optional): Latitude in decimal degrees for daylight hour calculations (-90 to 90). Defaults to 30.0.
59 verbose (bool, optional): If True, print additional debugging statements. Defaults to False.
61 Raises:
62 FileNotFoundError: If a provided file does not exist at the specified path.
63 ValueError: If engine or lib_file requests the removed C++ engine, or if
64 latitude is outside the valid range.
66 Examples:
67 >>> model = PyBeePop(weather_file='weather.csv')
68 >>> results = model.run_model()
70 >>> model = PyBeePop()
71 >>> model.load_weather('weather.csv')
72 >>> results = model.run_model()
73 """
74 self.verbose = verbose
75 self.engine_type: str | None = None
76 self.engine: BeepopEngineInterface | None = None
77 self.lib_file: str | None = None # For backward compatibility
79 self._check_removed_cpp_options(engine, lib_file)
81 self.engine = self._initialize_python_engine()
82 self.engine_type = "python"
84 # Validate and set latitude
85 if not -90 <= latitude <= 90:
86 raise ValueError("Latitude must be between -90 and 90 degrees")
87 self.current_latitude = latitude
88 self.engine.set_latitude(self.current_latitude)
90 # Initialize file paths and parameters
91 self.parameter_file = None
92 self.weather_file = None
93 self.residue_file = None
94 self.parameters = {}
95 self.output = None
96 self.default_parameter_file = self._get_default_parameter_file()
98 # Add backward compatibility alias
99 self.beepop = self.engine
101 # Load bundled defaults before any user-supplied parameter files.
102 self._load_default_parameter_file()
104 # Load files if provided
105 if parameter_file is not None:
106 self.load_parameter_file(parameter_file)
108 if weather_file is not None:
109 self.load_weather(weather_file)
111 if residue_file is not None:
112 self.load_residue_file(residue_file)
114 def _get_default_parameter_file(self) -> str:
115 """Return the packaged default parameter file path."""
116 return str(Path(__file__).resolve().parent / "data" / "default_parameters.txt")
118 def _load_default_parameter_file(self) -> None:
119 """Load bundled default parameters without marking them as a user file."""
120 self.load_parameter_file(self.default_parameter_file)
121 self.parameter_file = None
123 @staticmethod
124 def _check_removed_cpp_options(engine, lib_file) -> None:
125 """
126 Reject arguments that requested the C++ engine, removed in version 0.3.0.
128 Raises:
129 ValueError: If engine is anything other than 'python', or lib_file is set.
130 """
131 if engine == "cpp":
132 raise ValueError(
133 "The C++ engine was removed in pybeepop+ 0.3.0. Remove the engine "
134 "argument, or pass engine='python'. The Python engine requires no "
135 "compiled library."
136 )
137 if engine != "python":
138 raise ValueError(
139 f"Invalid engine type: '{engine}'. 'python' is the only option."
140 )
141 if lib_file is not None:
142 raise ValueError(
143 "The lib_file argument is no longer supported. It pointed at a "
144 "compiled BeePop+ library for the C++ engine, which was removed in "
145 "pybeepop+ 0.3.0. Remove the argument; the Python engine needs no "
146 "shared library."
147 )
149 def _initialize_python_engine(self) -> BeepopEngineInterface:
150 """
151 Initialize Python engine.
153 Returns:
154 PythonEngineAdapter: Initialized Python engine adapter
155 """
156 from .adapters import PythonEngineAdapter
158 return PythonEngineAdapter(verbose=self.verbose)
160 def set_parameters(self, parameters):
161 """
162 Set BeePop+ parameters based on a dictionary {parameter: value}.
164 Args:
165 parameters (dict): Dictionary of BeePop+ parameters {parameter: value}. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
167 Raises:
168 TypeError: If parameters is not a dict.
169 ValueError: If a parameter is not a valid BeePop+ parameter.
170 """
171 if (parameters is not None) and (not isinstance(parameters, dict)):
172 raise TypeError(
173 "parameters must be a named dictionary of BeePop+ parameters"
174 )
175 self.parameters = self.engine.set_parameters(parameters)
177 def get_parameters(self):
178 """
179 Return all parameters that have been set by the user.
181 Returns:
182 dict: Dictionary of current BeePop+ parameters.
183 """
184 return self.engine.get_parameters()
186 def set_latitude(self, latitude):
187 """
188 Set the latitude for daylight hour calculations.
190 Args:
191 latitude (float): Latitude in decimal degrees (-90 to 90). Positive values are North, negative are South.
193 Raises:
194 ValueError: If latitude is outside the valid range.
195 """
196 if not -90 <= latitude <= 90:
197 raise ValueError("Latitude must be between -90 and 90 degrees")
198 self.current_latitude = latitude
199 self.engine.set_latitude(latitude)
201 def get_latitude(self):
202 """
203 Get the currently set latitude.
205 Returns:
206 float: Current latitude in decimal degrees.
207 """
208 return self.current_latitude
210 def set_simulation_dates(self, start_date, end_date):
211 """
212 Convenience method to set simulation start and end dates. The dates can
213 also be set directly as SimStart/SimEnd using the set_parameters() or
214 load_parameters() methods.
216 Args:
217 start_date (str): Simulation start date in MM/DD/YYYY format.
218 end_date (str): Simulation end date in MM/DD/YYYY format.
219 """
220 date_params = {"SimStart": start_date, "SimEnd": end_date}
221 self.set_parameters(date_params)
223 if self.verbose:
224 print(f"Set simulation dates: {start_date} to {end_date}")
226 def load_weather(self, weather_file):
227 """
228 Load a weather file. The file should be a .csv or comma-delimited .txt file where each row denotes:
229 Date (MM/DD/YYYY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional).
231 Note: Loading weather may reset simulation dates (SimStart/SimEnd) to the weather file's date range.
232 Any previously set parameters will be automatically re-applied after weather loading.
234 Args:
235 weather_file (str): Path to the weather file (csv or txt). See docs/weather_readme.txt and manuscript for format details.
237 Raises:
238 TypeError: If weather_file is None.
239 FileNotFoundError: If the provided file does not exist at the specified path.
240 OSError: If the file cannot be opened or read.
241 RuntimeError: If weather file cannot be loaded.
242 """
243 if weather_file is None:
244 raise TypeError("Cannot set weather file to None")
245 if not os.path.isfile(weather_file):
246 raise FileNotFoundError(
247 f"Weather file does not exist at path: {weather_file}!"
248 )
249 self.weather_file = weather_file
251 # Load weather via adapter
252 success = self.engine.load_weather_file(self.weather_file)
253 if not success:
254 raise RuntimeError("Failed to load weather file")
256 def load_parameter_file(self, parameter_file):
257 """
258 Load a .txt file of parameter values to set. Each row of the file is a string with the format 'parameter=value'.
260 Args:
261 parameter_file (str): Path to a txt file of BeePop+ parameters. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters.
263 Raises:
264 FileNotFoundError: If the provided file does not exist at the specified path.
265 ValueError: If a listed parameter is not a valid BeePop+ parameter.
266 """
267 if not os.path.isfile(parameter_file):
268 raise FileNotFoundError(
269 f"Paramter file does not exist at path: {parameter_file}!"
270 )
271 self.parameter_file = parameter_file
273 # Load parameter file via adapter
274 # Note: adapter will raise ValueError for invalid parameters
275 success = self.engine.load_parameter_file(self.parameter_file)
276 if not success:
277 raise RuntimeError("Failed to load parameter file")
279 def load_residue_file(self, residue_file):
280 """
281 Load a .csv or comma-delimited .txt file of pesticide residues in pollen/nectar. Each row should specify Date (MM/DD/YYYY),
282 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").
284 Args:
285 residue_file (str): Path to the residue .csv or .txt file. See docs/residue_file_readme.txt and manuscript for format details.
287 Raises:
288 FileNotFoundError: If the provided file does not exist at the specified path.
289 """
290 if not os.path.isfile(residue_file):
291 raise FileNotFoundError(
292 f"Residue file does not exist at path: {residue_file}!"
293 )
294 self.residue_file = residue_file
296 # Load residue file via adapter
297 success = self.engine.load_residue_file(self.residue_file)
298 if not success:
299 raise RuntimeError("Failed to load residue file")
301 def run_model(self):
302 """
303 Run the BeePop+ model simulation.
305 Raises:
306 RuntimeError: If the weather file has not yet been set.
308 Returns:
309 pandas.DataFrame: DataFrame of daily time series results for the BeePop+ run, including colony size, adult workers, brood, eggs, and other metrics.
310 """
311 # check to see if parameters have been supplied
312 if (self.parameter_file is None) and (not self.parameters):
313 print(
314 "No user parameters have been set. Running with bundled default settings."
315 )
316 if self.weather_file is None:
317 raise RuntimeError("Weather must be set before running BeePop+!")
319 # Run via adapter
320 self.output = self.engine.run_simulation()
322 if self.output is None:
323 raise RuntimeError("Simulation failed to produce results")
325 return self.output
327 def get_output(self, format="DataFrame"):
328 """
329 Get the output from the last BeePop+ run.
331 Args:
332 format (str, optional): Return results as DataFrame ('DataFrame') or JSON string ('json'). Defaults to 'DataFrame'.
334 Raises:
335 RuntimeError: If there is no output because run_model has not yet been called.
337 Returns:
338 pandas.DataFrame or str: DataFrame or JSON string of the model results. JSON output is a dictionary of lists keyed by column name.
339 """
340 if self.output is None:
341 raise RuntimeError(
342 "There are no results to plot. Please run the model first."
343 )
344 if format == "json":
345 result = json.dumps(self.output.to_dict(orient="list"))
346 else:
347 result = self.output
348 return result
350 def plot_output(
351 self,
352 columns=[
353 "Colony Size",
354 "Adult Workers",
355 "Capped Worker Brood",
356 "Worker Larvae",
357 "Worker Eggs",
358 ],
359 ):
360 """
361 Plot the output as a time series.
363 Args:
364 columns (list, optional): List of column names to plot (as strings). Defaults to key colony metrics.
366 Raises:
367 RuntimeError: If there is no output because run_model has not yet been called.
368 IndexError: If any column name is not a valid output column.
370 Returns:
371 matplotlib.axes.Axes: Matplotlib Axes object for further customization.
372 """
373 if self.output is None:
374 raise RuntimeError(
375 "There are no results to plot. Please run the model first."
376 )
377 invalid_cols = [col not in self.output.columns for col in columns]
378 if any(invalid_cols):
379 raise IndexError(
380 f"The column name {[i for (i, v) in zip(columns, invalid_cols) if v]} is not a valid output column."
381 )
382 plot = plot_timeseries(output=self.output, columns=columns)
383 return plot
385 def get_error_log(self):
386 """
387 Return the BeePop+ session error log as a string for debugging. Useful for troubleshooting.
389 Returns:
390 str: Error log from the BeePop+ session.
391 """
392 return self.engine.get_error_log()
394 def get_info_log(self):
395 """
396 Return the BeePop+ session info log as a string for debugging..
398 Returns:
399 str: Info log from the BeePop+ session.
400 """
401 return self.engine.get_info_log()
403 def version(self):
404 """
405 Return the BeePop+ version as a string.
407 Returns:
408 str: BeePop+ version string.
409 """
410 return self.engine.get_version()
412 def exit(self):
413 """
414 Close the connection to the BeePop+ simulation engine and clean up resources.
415 """
416 if hasattr(self, "engine") and self.engine is not None:
417 try:
418 self.engine.cleanup()
419 except Exception as e:
420 if self.verbose:
421 print(f"Warning during cleanup: {e}")
422 self.engine = None
424 def __del__(self):
425 """Destructor to ensure cleanup when object is garbage collected."""
426 self.exit()