Coverage for pybeepop/pybeepop.py: 92%

125 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-19 20:58 +0000

1""" 

2pybeepop - BeePop+ interface for Python 

3""" 

4 

5import json 

6import os 

7from pathlib import Path 

8 

9from .engine_interface import BeepopEngineInterface 

10from .plots import plot_timeseries 

11 

12 

13class PyBeePop: 

14 """ 

15 Python interface for the BeePop+ honey bee colony simulation model. 

16 

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. 

20 

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. 

24 

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 """ 

32 

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. 

45 

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. 

60 

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. 

65 

66 Examples: 

67 >>> model = PyBeePop(weather_file='weather.csv') 

68 >>> results = model.run_model() 

69 

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 

78 

79 self._check_removed_cpp_options(engine, lib_file) 

80 

81 self.engine = self._initialize_python_engine() 

82 self.engine_type = "python" 

83 

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) 

89 

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() 

97 

98 # Add backward compatibility alias 

99 self.beepop = self.engine 

100 

101 # Load bundled defaults before any user-supplied parameter files. 

102 self._load_default_parameter_file() 

103 

104 # Load files if provided 

105 if parameter_file is not None: 

106 self.load_parameter_file(parameter_file) 

107 

108 if weather_file is not None: 

109 self.load_weather(weather_file) 

110 

111 if residue_file is not None: 

112 self.load_residue_file(residue_file) 

113 

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") 

117 

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 

122 

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. 

127 

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 ) 

148 

149 def _initialize_python_engine(self) -> BeepopEngineInterface: 

150 """ 

151 Initialize Python engine. 

152 

153 Returns: 

154 PythonEngineAdapter: Initialized Python engine adapter 

155 """ 

156 from .adapters import PythonEngineAdapter 

157 

158 return PythonEngineAdapter(verbose=self.verbose) 

159 

160 def set_parameters(self, parameters): 

161 """ 

162 Set BeePop+ parameters based on a dictionary {parameter: value}. 

163 

164 Args: 

165 parameters (dict): Dictionary of BeePop+ parameters {parameter: value}. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters. 

166 

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) 

176 

177 def get_parameters(self): 

178 """ 

179 Return all parameters that have been set by the user. 

180 

181 Returns: 

182 dict: Dictionary of current BeePop+ parameters. 

183 """ 

184 return self.engine.get_parameters() 

185 

186 def set_latitude(self, latitude): 

187 """ 

188 Set the latitude for daylight hour calculations. 

189 

190 Args: 

191 latitude (float): Latitude in decimal degrees (-90 to 90). Positive values are North, negative are South. 

192 

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) 

200 

201 def get_latitude(self): 

202 """ 

203 Get the currently set latitude. 

204 

205 Returns: 

206 float: Current latitude in decimal degrees. 

207 """ 

208 return self.current_latitude 

209 

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. 

215 

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) 

222 

223 if self.verbose: 

224 print(f"Set simulation dates: {start_date} to {end_date}") 

225 

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). 

230 

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. 

233 

234 Args: 

235 weather_file (str): Path to the weather file (csv or txt). See docs/weather_readme.txt and manuscript for format details. 

236 

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 

250 

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") 

255 

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'. 

259 

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. 

262 

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 

272 

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") 

278 

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"). 

283 

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. 

286 

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 

295 

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") 

300 

301 def run_model(self): 

302 """ 

303 Run the BeePop+ model simulation. 

304 

305 Raises: 

306 RuntimeError: If the weather file has not yet been set. 

307 

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+!") 

318 

319 # Run via adapter 

320 self.output = self.engine.run_simulation() 

321 

322 if self.output is None: 

323 raise RuntimeError("Simulation failed to produce results") 

324 

325 return self.output 

326 

327 def get_output(self, format="DataFrame"): 

328 """ 

329 Get the output from the last BeePop+ run. 

330 

331 Args: 

332 format (str, optional): Return results as DataFrame ('DataFrame') or JSON string ('json'). Defaults to 'DataFrame'. 

333 

334 Raises: 

335 RuntimeError: If there is no output because run_model has not yet been called. 

336 

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 

349 

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. 

362 

363 Args: 

364 columns (list, optional): List of column names to plot (as strings). Defaults to key colony metrics. 

365 

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. 

369 

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 

384 

385 def get_error_log(self): 

386 """ 

387 Return the BeePop+ session error log as a string for debugging. Useful for troubleshooting. 

388 

389 Returns: 

390 str: Error log from the BeePop+ session. 

391 """ 

392 return self.engine.get_error_log() 

393 

394 def get_info_log(self): 

395 """ 

396 Return the BeePop+ session info log as a string for debugging.. 

397 

398 Returns: 

399 str: Info log from the BeePop+ session. 

400 """ 

401 return self.engine.get_info_log() 

402 

403 def version(self): 

404 """ 

405 Return the BeePop+ version as a string. 

406 

407 Returns: 

408 str: BeePop+ version string. 

409 """ 

410 return self.engine.get_version() 

411 

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 

423 

424 def __del__(self): 

425 """Destructor to ensure cleanup when object is garbage collected.""" 

426 self.exit()