Coverage for pybeepop / pybeepop.py: 91%

143 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-16 23:00 +0000

1""" 

2pybeepop - BeePop+ interface for Python 

3""" 

4 

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 

14 

15 

16class PyBeePop: 

17 """ 

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

19 

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. 

23 

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. 

27 

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

35 

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. 

48 

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. 

54 

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. 

58 

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. 

68 

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. 

73 

74 Examples: 

75 >>> # Default to the Python engine 

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

77 >>> results = model.run_model() 

78 

79 >>> # Force Python engine 

80 >>> model = PyBeePop(engine='python') 

81 >>> model.load_weather('weather.csv') 

82 >>> results = model.run_model() 

83 

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 

93 

94 # Engine selection logic 

95 current_platform = platform.system() 

96 

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 

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 ) 

119 

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) 

125 

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

133 

134 # Add backward compatibility alias 

135 self.beepop = self.engine 

136 

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

138 self._load_default_parameter_file() 

139 

140 # Load files if provided 

141 if parameter_file is not None: 

142 self.load_parameter_file(parameter_file) 

143 

144 if weather_file is not None: 

145 self.load_weather(weather_file) 

146 

147 if residue_file is not None: 

148 self.load_residue_file(residue_file) 

149 

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

153 

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 

158 

159 def _initialize_cpp_engine(self, lib_file) -> BeepopEngineInterface: 

160 """ 

161 Initialize C++ engine. 

162 

163 Args: 

164 lib_file: Path to shared library, or None for auto-detection 

165 

166 Returns: 

167 CppEngineAdapter: Initialized C++ engine adapter 

168 

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) 

176 

177 if lib_file is None: 

178 parent = os.path.dirname(os.path.abspath(__file__)) 

179 platform_name = platform.system() 

180 

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 ) 

202 

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 ) 

209 

210 return CppEngineAdapter(lib_file, verbose=self.verbose) 

211 

212 def _initialize_python_engine(self) -> BeepopEngineInterface: 

213 """ 

214 Initialize Python engine. 

215 

216 Returns: 

217 PythonEngineAdapter: Initialized Python engine adapter 

218 """ 

219 from .adapters import PythonEngineAdapter 

220 

221 return PythonEngineAdapter(verbose=self.verbose) 

222 

223 def set_parameters(self, parameters): 

224 """ 

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

226 

227 Args: 

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

229 

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) 

239 

240 def get_parameters(self): 

241 """ 

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

243 

244 Returns: 

245 dict: Dictionary of current BeePop+ parameters. 

246 """ 

247 return self.engine.get_parameters() 

248 

249 def set_latitude(self, latitude): 

250 """ 

251 Set the latitude for daylight hour calculations. 

252 

253 Args: 

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

255 

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) 

263 

264 def get_latitude(self): 

265 """ 

266 Get the currently set latitude. 

267 

268 Returns: 

269 float: Current latitude in decimal degrees. 

270 """ 

271 return self.current_latitude 

272 

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. 

278 

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) 

285 

286 if self.verbose: 

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

288 

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

293 

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. 

296 

297 Args: 

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

299 

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 

313 

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

318 

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

322 

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. 

325 

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 

335 

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

341 

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

346 

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. 

349 

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 

358 

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

363 

364 def run_model(self): 

365 """ 

366 Run the BeePop+ model simulation. 

367 

368 Raises: 

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

370 

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

379 

380 # Run via adapter 

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

382 

383 if self.output is None: 

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

385 

386 return self.output 

387 

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

389 """ 

390 Get the output from the last BeePop+ run. 

391 

392 Args: 

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

394 

395 Raises: 

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

397 

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 

410 

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. 

423 

424 Args: 

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

426 

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. 

430 

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 

447 

448 def get_error_log(self): 

449 """ 

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

451 

452 Returns: 

453 str: Error log from the BeePop+ session. 

454 """ 

455 return self.engine.get_error_log() 

456 

457 def get_info_log(self): 

458 """ 

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

460 

461 Returns: 

462 str: Info log from the BeePop+ session. 

463 """ 

464 return self.engine.get_info_log() 

465 

466 def version(self): 

467 """ 

468 Return the BeePop+ version as a string. 

469 

470 Returns: 

471 str: BeePop+ version string. 

472 """ 

473 return self.engine.get_version() 

474 

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 

486 

487 def __del__(self): 

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

489 self.exit()