Coverage for pybeepop/pybeepop.py: 88%

146 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 13:34 +0000

1""" 

2pybeepop - BeePop+ interface for Python 

3""" 

4 

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 

13 

14 

15class PyBeePop: 

16 """ 

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

18 

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. 

22 

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. 

26 

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

34 

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. 

47 

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

55 

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. 

59 

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. 

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 >>> # Auto-select engine (backward compatible) 

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 elif engine == "auto": 

105 if verbose: 

106 print( 

107 "macOS detected: using Python engine (C++ engine not supported on macOS)" 

108 ) 

109 engine = "python" 

110 

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 ) 

143 

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) 

149 

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 

156 

157 # Add backward compatibility alias 

158 self.beepop = self.engine 

159 

160 # Load files if provided 

161 if parameter_file is not None: 

162 self.load_parameter_file(parameter_file) 

163 

164 if weather_file is not None: 

165 self.load_weather(weather_file) 

166 

167 if residue_file is not None: 

168 self.load_residue_file(residue_file) 

169 

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

171 """ 

172 Initialize C++ engine. 

173 

174 Args: 

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

176 

177 Returns: 

178 CppEngineAdapter: Initialized C++ engine adapter 

179 

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) 

187 

188 if lib_file is None: 

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

190 platform_name = platform.system() 

191 

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 ) 

213 

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 ) 

220 

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

222 

223 def _initialize_python_engine(self) -> BeepopEngineInterface: 

224 """ 

225 Initialize Python engine. 

226 

227 Returns: 

228 PythonEngineAdapter: Initialized Python engine adapter 

229 """ 

230 from .adapters import PythonEngineAdapter 

231 

232 return PythonEngineAdapter(verbose=self.verbose) 

233 

234 def set_parameters(self, parameters): 

235 """ 

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

237 

238 Args: 

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

240 

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) 

250 

251 def get_parameters(self): 

252 """ 

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

254 

255 Returns: 

256 dict: Dictionary of current BeePop+ parameters. 

257 """ 

258 return self.engine.get_parameters() 

259 

260 def set_latitude(self, latitude): 

261 """ 

262 Set the latitude for daylight hour calculations. 

263 

264 Args: 

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

266 

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) 

274 

275 def get_latitude(self): 

276 """ 

277 Get the currently set latitude. 

278 

279 Returns: 

280 float: Current latitude in decimal degrees. 

281 """ 

282 return self.current_latitude 

283 

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. 

289 

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) 

296 

297 if self.verbose: 

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

299 

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

304 

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. 

307 

308 Args: 

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

310 

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 

324 

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

329 

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

333 

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. 

336 

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 

346 

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

352 

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

357 

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. 

360 

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 

369 

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

374 

375 def run_model(self): 

376 """ 

377 Run the BeePop+ model simulation. 

378 

379 Raises: 

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

381 

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

390 

391 # Run via adapter 

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

393 

394 if self.output is None: 

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

396 

397 return self.output 

398 

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

400 """ 

401 Get the output from the last BeePop+ run. 

402 

403 Args: 

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

405 

406 Raises: 

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

408 

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 

421 

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. 

434 

435 Args: 

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

437 

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. 

441 

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 

458 

459 def get_error_log(self): 

460 """ 

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

462 

463 Returns: 

464 str: Error log from the BeePop+ session. 

465 """ 

466 return self.engine.get_error_log() 

467 

468 def get_info_log(self): 

469 """ 

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

471 

472 Returns: 

473 str: Info log from the BeePop+ session. 

474 """ 

475 return self.engine.get_info_log() 

476 

477 def version(self): 

478 """ 

479 Return the BeePop+ version as a string. 

480 

481 Returns: 

482 str: BeePop+ version string. 

483 """ 

484 return self.engine.get_version() 

485 

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 

497 

498 def __del__(self): 

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

500 self.exit()