Coverage for pybeepop/adapters.py: 78%

148 statements  

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

1""" 

2Engine adapter for PyBeePop. 

3 

4Wraps the Python BeePop+ engine (beepop.BeePop) to provide an interface conforming 

5to the BeepopEngineInterface protocol. 

6""" 

7 

8import os 

9from typing import Dict, Optional, Type 

10import pandas as pd 

11 

12from .beepop.parameters import validate_parameter 

13from .exceptions import ( 

14 BeepopException, 

15 BeepopParameterError, 

16 BeepopRuntimeError, 

17 BeepopFileError, 

18) 

19 

20# Parameters removed from pybeepop+, mapped to migration guidance. Keys are lowercase. 

21_VTDATA_GUIDANCE = ( 

22 "Varroa treatments are now scheduled with VTData, which takes " 

23 "start_date,duration_weeks,mortality% (e.g. VTData=6/2/2015,6,75). Pass one VTData " 

24 "entry per treatment, or VTData=Clear to reset. Mite resistance is set on the " 

25 "population with InitMitePctResistant and PctImmMitesResistant." 

26) 

27 

28RETIRED_PARAMETERS = { 

29 "eseedconcentration": ( 

30 "ESeedConcentration was removed in pybeepop+ 0.3.0. Use ESeedAppRate instead, " 

31 "the seed treatment application rate in mg a.i./seed. Nectar and pollen residues " 

32 "are now derived separately from that rate rather than sharing one concentration." 

33 ), 

34 "vttreatmentstart": f"VTTreatmentStart was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}", 

35 "vttreatmentduration": ( 

36 f"VTTreatmentDuration was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}" 

37 ), 

38 "vtmortality": f"VTMortality was removed in pybeepop+ 0.3.0. {_VTDATA_GUIDANCE}", 

39 "rqwkrdrnratio": ( 

40 "RQWkrDrnRatio was removed in pybeepop+ 0.3.0. It was read but never used by the " 

41 "model, so removing it does not change simulation results. The worker to drone " 

42 "ratio follows from the queen's sperm reserves." 

43 ), 

44} 

45 

46 

47def invalid_parameter_message(par_name: str) -> str: 

48 """Return the error message for an unrecognized parameter name.""" 

49 retired = RETIRED_PARAMETERS.get(par_name.strip().lower()) 

50 if retired is not None: 

51 return retired 

52 return f"{par_name} is not a valid parameter." 

53 

54 

55class PythonEngineAdapter: 

56 """ 

57 Adapter for pure Python BeePop+ engine. 

58 

59 Wraps the Python port (beepop.BeePop) to conform to the BeepopEngineInterface 

60 protocol, handling format conversions between PyBeePop's dict-based API and 

61 the Python engine's list-based parameter format. 

62 

63 Attributes: 

64 engine_type (str): Always 'python' 

65 model (BeePop): The underlying Python engine 

66 """ 

67 

68 def __init__(self, verbose: bool = False): 

69 """ 

70 Initialize Python engine adapter. 

71 

72 Args: 

73 verbose: Enable verbose output 

74 """ 

75 from pybeepop.beepop import BeePop 

76 

77 self.model = BeePop() 

78 self.engine_type = "python" 

79 self.verbose = verbose 

80 self._parameters = {} # Track parameters set 

81 

82 # Load valid parameters for validation 

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

84 self.valid_parameters = pd.read_csv( 

85 os.path.join(parent, "data/BeePop_exposed_parameters.csv"), skiprows=1 

86 )["Exposed Variable Name"].tolist() 

87 self._valid_parameters_lower = {x.lower() for x in self.valid_parameters} 

88 

89 # Initialize model during adapter construction 

90 self.model.initialize_model() 

91 

92 if verbose: 

93 self.model.enable_error_reporting(True) 

94 self.model.enable_info_reporting(True) 

95 

96 def _raise_with_log( 

97 self, exception_class: Type[BeepopException], message: str 

98 ) -> None: 

99 """ 

100 Raise exception with BeePop+ error log included. 

101 

102 Args: 

103 exception_class: The exception class to raise (BeepopParameterError, etc.) 

104 message: The error message 

105 

106 Raises: 

107 exception_class: Raised with error log and info log included 

108 """ 

109 error_log = self.get_error_log() 

110 info_log = self.get_info_log() 

111 raise exception_class( 

112 message=message, 

113 error_log=error_log, 

114 info_log=None, # info_log, 

115 engine_type=self.engine_type, 

116 ) 

117 

118 def set_parameters(self, parameters: Dict[str, str]) -> Dict[str, str]: 

119 """ 

120 Set parameters, converting from dict to list format. 

121 

122 Python engine expects: ["ParamName=value", ...] 

123 PyBeePop provides: {"ParamName": "value", ...} 

124 

125 Raises: 

126 BeepopParameterError: If parameter name is not valid 

127 BeepopRuntimeError: If parameters cannot be set 

128 """ 

129 try: 

130 # Validate parameter names and values 

131 for par_name, par_value in parameters.items(): 

132 if par_name.lower() not in self._valid_parameters_lower: 

133 self._raise_with_log( 

134 BeepopParameterError, invalid_parameter_message(par_name) 

135 ) 

136 ok, _, error = validate_parameter( 

137 par_name.lower(), str(par_value).strip(), par_name 

138 ) 

139 if not ok: 

140 self._raise_with_log(BeepopParameterError, error) 

141 

142 # Convert dict to list format 

143 param_list = [f"{k}={v}" for k, v in parameters.items()] 

144 

145 # Set parameters (don't reset ICs to preserve previous settings) 

146 success = self.model.set_ic_variables_v(param_list, reset_ics=False) 

147 

148 if success: 

149 self._parameters.update(parameters) 

150 return parameters 

151 else: 

152 self._raise_with_log(BeepopRuntimeError, "Error setting parameters") 

153 except BeepopException: 

154 # Re-raise our custom exceptions 

155 raise 

156 except Exception as e: 

157 if self.verbose: 

158 print(f"Error setting parameters: {e}") 

159 self._raise_with_log(BeepopRuntimeError, f"Error setting parameters: {e}") 

160 

161 def get_parameters(self) -> Dict[str, str]: 

162 """Get currently set parameters with lowercase keys.""" 

163 return {k.lower(): v for k, v in self._parameters.items()} 

164 

165 def load_parameter_file(self, file_path: str) -> bool: 

166 """ 

167 Load parameter file via Python engine. 

168 

169 Raises: 

170 BeepopFileError: If file cannot be opened or read 

171 BeepopParameterError: If parameter is invalid 

172 BeepopRuntimeError: If parameters cannot be loaded 

173 """ 

174 try: 

175 # Try to open and read file to catch OSError early 

176 with open(file_path, "r") as f: 

177 lines = f.readlines() 

178 

179 # Validate parameter names and values before loading 

180 for line in lines: 

181 clean_line = line.strip() 

182 if clean_line and not clean_line.startswith("#") and "=" in clean_line: 

183 raw_name, raw_value = clean_line.split("=", 1) 

184 param_name = raw_name.strip().lower() 

185 if param_name not in self._valid_parameters_lower: 

186 self._raise_with_log( 

187 BeepopParameterError, 

188 invalid_parameter_message(raw_name.strip()), 

189 ) 

190 ok, _, error = validate_parameter( 

191 param_name, raw_value.strip(), raw_name.strip() 

192 ) 

193 if not ok: 

194 self._raise_with_log(BeepopParameterError, error) 

195 

196 success = self.model.load_parameter_file(file_path) 

197 

198 # If successful, parse file to track parameters 

199 if success: 

200 for line in lines: 

201 clean_line = line.strip() 

202 if ( 

203 clean_line 

204 and not clean_line.startswith("#") 

205 and "=" in clean_line 

206 ): 

207 key, value = clean_line.split("=", 1) 

208 self._parameters[key.strip()] = value.strip() 

209 return True 

210 else: 

211 self._raise_with_log(BeepopRuntimeError, "Error loading parameter file") 

212 except BeepopException: 

213 # Re-raise our custom exceptions 

214 raise 

215 except OSError as e: 

216 # Re-raise as BeepopFileError with error logs 

217 self._raise_with_log(BeepopFileError, str(e)) 

218 except Exception as e: 

219 if self.verbose: 

220 print(f"Error loading parameter file: {e}") 

221 self._raise_with_log( 

222 BeepopRuntimeError, f"Error loading parameter file: {e}" 

223 ) 

224 

225 def load_weather_file(self, file_path: str) -> bool: 

226 """ 

227 Load weather file via Python engine. 

228 

229 Raises: 

230 BeepopFileError: If file cannot be opened or read 

231 BeepopRuntimeError: If weather cannot be loaded 

232 """ 

233 try: 

234 # Try to open file to catch OSError early 

235 with open(file_path, "r") as f: 

236 f.read() 

237 

238 success = self.model.set_weather_from_file(file_path) 

239 if success: 

240 return True 

241 else: 

242 self._raise_with_log(BeepopRuntimeError, "Error Loading Weather") 

243 except BeepopException: 

244 # Re-raise our custom exceptions 

245 raise 

246 except OSError: 

247 # Re-raise as BeepopFileError with error logs 

248 self._raise_with_log(BeepopFileError, "Weather file is invalid.") 

249 except Exception as e: 

250 if self.verbose: 

251 print(f"Error loading weather file: {e}") 

252 self._raise_with_log(BeepopRuntimeError, f"Error loading weather file: {e}") 

253 

254 def load_residue_file(self, file_path: str) -> bool: 

255 """ 

256 Load residue file, converting to list format. 

257 

258 Python engine expects list of strings, not a file path. 

259 We need to parse the file ourselves. 

260 

261 Raises: 

262 BeepopFileError: If file cannot be opened or read 

263 BeepopRuntimeError: If residue file cannot be loaded 

264 """ 

265 try: 

266 with open(file_path, "r") as f: 

267 lines = [ 

268 line.strip() 

269 for line in f 

270 if line.strip() and not line.startswith("#") 

271 ] 

272 except Exception: 

273 self._raise_with_log(BeepopFileError, "Residue file is invalid.") 

274 

275 try: 

276 # Load the contamination table 

277 success = self.model.set_contamination_table(lines) 

278 if success: 

279 self.set_parameters( 

280 {"NecPolFileEnable": "true"} 

281 ) # Enable residue file mode 

282 return True 

283 else: 

284 self._raise_with_log(BeepopRuntimeError, "Error loading residue file") 

285 except BeepopException: 

286 # Re-raise our custom exceptions 

287 raise 

288 except Exception as e: 

289 if self.verbose: 

290 print(f"Error loading residue file: {e}") 

291 self._raise_with_log(BeepopRuntimeError, f"Error loading residue file: {e}") 

292 

293 def set_latitude(self, latitude: float) -> bool: 

294 """Set latitude via Python engine.""" 

295 try: 

296 return self.model.set_latitude(latitude) 

297 except Exception as e: 

298 if self.verbose: 

299 print(f"Error setting latitude: {e}") 

300 return False 

301 

302 def run_simulation(self) -> Optional[pd.DataFrame]: 

303 """ 

304 Run simulation via Python engine. 

305 

306 Raises: 

307 BeepopRuntimeError: If simulation fails to run 

308 """ 

309 try: 

310 success = self.model.run_simulation() 

311 if success: 

312 success, df = self.model.get_results_dataframe() 

313 if success: 

314 return df 

315 else: 

316 self._raise_with_log( 

317 BeepopRuntimeError, "Error running BeePop+ simulation." 

318 ) 

319 else: 

320 self._raise_with_log( 

321 BeepopRuntimeError, "Error running BeePop+ simulation." 

322 ) 

323 except BeepopException: 

324 # Re-raise our custom exceptions 

325 raise 

326 except Exception as e: 

327 if self.verbose: 

328 print(f"Error running simulation: {e}") 

329 self._raise_with_log( 

330 BeepopRuntimeError, f"Error running BeePop+ simulation: {e}" 

331 ) 

332 

333 def get_error_log(self) -> str: 

334 """ 

335 Get error log, converting from tuple format to string. 

336 

337 Python engine returns: (success: bool, errors: List[str]) 

338 We need: string (newline-separated) 

339 """ 

340 try: 

341 success, errors = self.model.get_error_list() 

342 if success and errors: 

343 return "\n".join(errors) 

344 return "" 

345 except Exception: 

346 return "" 

347 

348 def get_info_log(self) -> str: 

349 """ 

350 Get info log, converting from tuple format to string. 

351 

352 Python engine returns: (success: bool, info: List[str]) 

353 We need: string (newline-separated) 

354 """ 

355 try: 

356 success, info = self.model.get_info_list() 

357 if success and info: 

358 return "\n".join(info) 

359 return "" 

360 except Exception: 

361 return "" 

362 

363 def get_version(self) -> str: 

364 """Get version from Python engine.""" 

365 try: 

366 success, version = self.model.get_lib_version() 

367 return version if success else "Unknown" 

368 except Exception: 

369 return "Unknown" 

370 

371 def cleanup(self) -> None: 

372 """Python engine handles cleanup automatically via garbage collection.""" 

373 pass # No explicit cleanup needed