Coverage for pybeepop/pybeepop.py: 88%

107 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-04 14:01 +0000

1""" 

2pybeepop - BeePop+ interface for Python 

3""" 

4 

5import os 

6import platform 

7import pandas as pd 

8from .tools import BeePopModel 

9from .plots import plot_timeseries 

10import json 

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 lib_file=None, 

36 parameter_file=None, 

37 weather_file=None, 

38 residue_file=None, 

39 latitude=30.0, 

40 verbose=False, 

41 ): 

42 """ 

43 Initialize a PyBeePop object connected to a BeePop+ shared library. 

44 

45 Args: 

46 lib_file (str, optional): Path to the BeePop+ shared library (.dll or .so). If None, attempts to auto-detect based on OS and architecture. 

47 parameter_file (str, optional): Path to a text file of BeePop+ parameters (one per line, parameter=value). See https://doi.org/10.3390/ecologies3030022 

48 or the documentation for valid parameters. 

49 weather_file (str, optional): Path to a .csv or comma-separated .txt file containing weather data, where each row denotes: 

50 Date (MM/DD/YY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional). 

51 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), 

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

53 latitude (float, optional): Latitude in decimal degrees for daylight hour calculations (-90 to 90). Defaults to 30.0. 

54 verbose (bool, optional): If True, print additional debugging statements. Defaults to False. 

55 

56 Raises: 

57 FileNotFoundError: If a provided file does not exist at the specified path. 

58 NotImplementedError: If run on a platform that is not 64-bit Windows or Linux. 

59 ValueError: If latitude is outside the valid range. 

60 """ 

61 

62 self.parent = os.path.dirname(os.path.abspath(__file__)) 

63 self.platform = platform.system() 

64 self.verbose = verbose 

65 if ( 

66 lib_file is None 

67 ): # detect OS and architecture and use pre-compiled BeePop+ if possible 

68 if self.platform == "Windows": 

69 if platform.architecture()[0] == "32bit": 

70 raise NotImplementedError( 

71 "Windows x86 (32-bit) is not supported by BeePop+. Please run on an x64 platform." 

72 ) 

73 else: 

74 lib_file = os.path.join(self.parent, "lib/beepop_win64.dll") 

75 elif self.platform == "Linux": 

76 lib_file = os.path.join(self.parent, "lib/beepop_linux.so") 

77 if self.verbose: 

78 print( 

79 """ 

80 Running in Linux mode. Trying manylinux/musllinux version. 

81 If you encounter errors, you may need to compile your own version of BeePop+ from source and pass the path to your 

82 .so file with the lib_file option. Currently, only 64-bit architecture is supported. 

83 See the pybeepop README for instructions. 

84 """ 

85 ) 

86 else: 

87 raise NotImplementedError("BeePop+ only supports Windows and Linux.") 

88 if not os.path.isfile(lib_file): 

89 raise FileNotFoundError( 

90 """ 

91 BeePop+ shared object library does not exist or is not compatible with your operating system.  

92 You may need to compile BeePop+ from source (see https://github.com/USEPA/pybeepop/blob/main/README.md for more info.) 

93 Currently, only 64-bit architecture is supported. 

94 """ 

95 ) 

96 self.lib_file = lib_file 

97 self.beepop = BeePopModel(self.lib_file, verbose=self.verbose) 

98 # Reset latitude to avoid inheritance from previous instances 

99 # Validate and set the provided latitude 

100 if not -90 <= latitude <= 90: 

101 raise ValueError("Latitude must be between -90 and 90 degrees") 

102 self.current_latitude = latitude 

103 self.beepop.set_latitude(self.current_latitude) 

104 self.parameters = None 

105 if parameter_file is not None: 

106 self.load_parameter_file(self.parameter_file) 

107 else: 

108 self.parameter_file = None 

109 if weather_file is not None: 

110 self.load_weather(weather_file) 

111 else: 

112 self.weather_file = None 

113 if residue_file is not None: 

114 self.load_residue_file(self.residue_file) 

115 else: 

116 self.residue_file = None 

117 # self.new_features = new_features # not being used? 

118 self.output = None 

119 

120 def set_parameters(self, parameters): 

121 """ 

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

123 

124 Args: 

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

126 

127 Raises: 

128 TypeError: If parameters is not a dict. 

129 ValueError: If a parameter is not a valid BeePop+ parameter. 

130 """ 

131 if (parameters is not None) and (not isinstance(parameters, dict)): 

132 raise TypeError( 

133 "parameters must be a named dictionary of BeePop+ parameters" 

134 ) 

135 self.parameters = self.beepop.set_parameters(parameters) 

136 

137 def get_parameters(self): 

138 """ 

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

140 

141 Returns: 

142 dict: Dictionary of current BeePop+ parameters. 

143 """ 

144 return self.beepop.get_parameters() 

145 

146 def set_latitude(self, latitude): 

147 """ 

148 Set the latitude for daylight hour calculations. 

149 

150 Args: 

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

152 

153 Raises: 

154 ValueError: If latitude is outside the valid range. 

155 """ 

156 if not -90 <= latitude <= 90: 

157 raise ValueError("Latitude must be between -90 and 90 degrees") 

158 self.current_latitude = latitude 

159 self.beepop.set_latitude(latitude) 

160 

161 def get_latitude(self): 

162 """ 

163 Get the currently set latitude. 

164 

165 Returns: 

166 float: Current latitude in decimal degrees. 

167 """ 

168 return self.current_latitude 

169 

170 def set_simulation_dates(self, start_date, end_date): 

171 """ 

172 Convenience method to set simulation start and end dates. The dates can 

173 also be set directly as SimStart/SimEnd using the set_parameters() or 

174 load_parameters() methods. 

175 

176 Args: 

177 start_date (str): Simulation start date in MM/DD/YYYY format. 

178 end_date (str): Simulation end date in MM/DD/YYYY format. 

179 """ 

180 date_params = {"SimStart": start_date, "SimEnd": end_date} 

181 self.set_parameters(date_params) 

182 

183 if self.verbose: 

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

185 

186 def load_weather(self, weather_file): 

187 """ 

188 Load a weather file. The file should be a .csv or comma-delimited .txt file where each row denotes: 

189 Date (MM/DD/YYYY), Max Temp (C), Min Temp (C), Avg Temp (C), Windspeed (m/s), Rainfall (mm), Hours of daylight (optional). 

190 

191 Note: Loading weather may reset simulation dates (SimStart/SimEnd) to the weather file's date range. 

192 Any previously set parameters will be automatically re-applied after weather loading. 

193 

194 Args: 

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

196 

197 Raises: 

198 FileNotFoundError: If the provided file does not exist at the specified path. 

199 """ 

200 if not os.path.isfile(weather_file): 

201 raise FileNotFoundError( 

202 "Weather file does not exist at path: {}!".format(weather_file) 

203 ) 

204 self.weather_file = weather_file 

205 

206 # Load weather - the underlying BeePopModel.load_weather() will automatically 

207 # re-apply any previously set parameters after loading 

208 self.beepop.load_weather(self.weather_file) 

209 

210 def load_parameter_file(self, parameter_file): 

211 """ 

212 Load a .txt file of parameter values to set. Each row of the file is a string with the format 'parameter=value'. 

213 

214 Args: 

215 parameter_file (str): Path to a txt file of BeePop+ parameters. See https://doi.org/10.3390/ecologies3030022 or the documentation for valid parameters. 

216 

217 Raises: 

218 FileNotFoundError: If the provided file does not exist at the specified path. 

219 ValueError: If a listed parameter is not a valid BeePop+ parameter. 

220 """ 

221 if not os.path.isfile(parameter_file): 

222 raise FileNotFoundError( 

223 "Paramter file does not exist at path: {}!".format(parameter_file) 

224 ) 

225 self.parameter_file = parameter_file 

226 self.beepop.load_input_file(self.parameter_file) 

227 

228 def load_residue_file(self, residue_file): 

229 """ 

230 Load a .csv or comma-delimited .txt file of pesticide residues in pollen/nectar. Each row should specify Date (MM/DD/YYYY), 

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

232 

233 Args: 

234 residue_file (str): Path to the residue .csv or .txt file. See docs/residue_file_readme.txt and manuscript for format details. 

235 

236 Raises: 

237 FileNotFoundError: If the provided file does not exist at the specified path. 

238 """ 

239 if not os.path.isfile(residue_file): 

240 raise FileNotFoundError( 

241 "Residue file does not exist at path: {}!".format(residue_file) 

242 ) 

243 self.residue_file = residue_file 

244 self.beepop.load_contam_file(self.residue_file) 

245 

246 def run_model(self): 

247 """ 

248 Run the BeePop+ model simulation. 

249 

250 Raises: 

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

252 

253 Returns: 

254 pandas.DataFrame: DataFrame of daily time series results for the BeePop+ run, including colony size, adult workers, brood, eggs, and other metrics. 

255 """ 

256 # check to see if parameters have been supplied 

257 if (self.parameter_file is None) and (self.parameters is None): 

258 print("No parameters have been set. Running with defualt settings.") 

259 if self.weather_file is None: 

260 raise RuntimeError("Weather must be set before running BeePop+!") 

261 self.output = self.beepop.run_beepop() 

262 return self.output 

263 

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

265 """ 

266 Get the output from the last BeePop+ run. 

267 

268 Args: 

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

270 

271 Raises: 

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

273 

274 Returns: 

275 pandas.DataFrame or str: DataFrame or JSON string of the model results. JSON output is a dictionary of lists keyed by column name. 

276 """ 

277 if self.output is None: 

278 raise RuntimeError( 

279 "There are no results to plot. Please run the model first." 

280 ) 

281 if format == "json": 

282 result = json.dumps(self.output.to_dict(orient="list")) 

283 else: 

284 result = self.output 

285 return result 

286 

287 def plot_output( 

288 self, 

289 columns=[ 

290 "Colony Size", 

291 "Adult Workers", 

292 "Capped Worker Brood", 

293 "Worker Larvae", 

294 "Worker Eggs", 

295 ], 

296 ): 

297 """ 

298 Plot the output as a time series. 

299 

300 Args: 

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

302 

303 Raises: 

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

305 IndexError: If any column name is not a valid output column. 

306 

307 Returns: 

308 matplotlib.axes.Axes: Matplotlib Axes object for further customization. 

309 """ 

310 if self.output is None: 

311 raise RuntimeError( 

312 "There are no results to plot. Please run the model first." 

313 ) 

314 invalid_cols = [col not in self.output.columns for col in columns] 

315 if any(invalid_cols): 

316 raise IndexError( 

317 "The column name {} is not a valid output column.".format( 

318 [i for (i, v) in zip(columns, invalid_cols) if v] 

319 ) 

320 ) 

321 plot = plot_timeseries(output=self.output, columns=columns) 

322 return plot 

323 

324 def get_error_log(self): 

325 """ 

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

327 

328 Returns: 

329 str: Error log from the BeePop+ session. 

330 """ 

331 return self.beepop.get_errors() 

332 

333 def get_info_log(self): 

334 """ 

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

336 

337 Returns: 

338 str: Info log from the BeePop+ session. 

339 """ 

340 return self.beepop.get_info() 

341 

342 def version(self): 

343 """ 

344 Return the BeePop+ version as a string. 

345 

346 Returns: 

347 str: BeePop+ version string. 

348 """ 

349 version = self.beepop.get_version() 

350 return version 

351 

352 def exit(self): 

353 """ 

354 Close the connection to the BeePop+ shared library and clean up resources. 

355 """ 

356 if hasattr(self, "beepop") and self.beepop is not None: 

357 if hasattr(self.beepop, "lib") and self.beepop.lib is not None: 

358 # Clear any remaining buffers 

359 try: 

360 self.beepop.clear_buffers() 

361 self.beepop.close_library() 

362 except: 

363 pass # Ignore errors during cleanup 

364 self.beepop = None 

365 

366 def __del__(self): 

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

368 self.exit()