Coverage for pybeepop/adapters.py: 75%

208 statements  

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

1""" 

2Engine adapters for PyBeePop dual-engine architecture. 

3 

4This module provides adapter classes that wrap both the C++ engine (BeePopModel) 

5and Python engine (beepop.BeePop) to provide a consistent interface conforming 

6to the BeepopEngineInterface protocol. 

7""" 

8 

9import os 

10from typing import Dict, Optional, Type 

11import pandas as pd 

12 

13from .exceptions import ( 

14 BeepopException, 

15 BeepopParameterError, 

16 BeepopRuntimeError, 

17 BeepopFileError, 

18) 

19 

20 

21class CppEngineAdapter: 

22 """ 

23 Adapter for C++ BeePop+ engine. 

24 

25 Wraps the existing BeePopModel class (ctypes wrapper around C++ library) 

26 to conform to the BeepopEngineInterface protocol. 

27 

28 Attributes: 

29 engine_type (str): Always 'cpp' 

30 model (BeePopModel): The underlying C++ engine wrapper 

31 """ 

32 

33 def __init__(self, lib_file: str, verbose: bool = False): 

34 """ 

35 Initialize C++ engine adapter. 

36 

37 Args: 

38 lib_file: Path to BeePop+ shared library (.dll or .so) 

39 verbose: Enable verbose output 

40 

41 Raises: 

42 FileNotFoundError: If lib_file doesn't exist 

43 RuntimeError: If C++ library initialization fails 

44 """ 

45 from .tools import BeePopModel 

46 

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

48 raise FileNotFoundError(f"C++ library not found: {lib_file}") 

49 

50 self.model = BeePopModel(lib_file, verbose=verbose) 

51 self.engine_type = "cpp" 

52 self.verbose = verbose 

53 self.lib_file = lib_file # Store for backward compatibility 

54 self._parameters = {} # Track parameters set 

55 

56 def _raise_with_log( 

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

58 ) -> None: 

59 """ 

60 Raise exception with BeePop+ error log included. 

61 

62 Args: 

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

64 message: The error message 

65 

66 Raises: 

67 exception_class: Raised with error log and info log included 

68 """ 

69 error_log = self.get_error_log() 

70 info_log = self.get_info_log() 

71 raise exception_class( 

72 message=message, 

73 error_log=error_log, 

74 info_log=info_log, 

75 engine_type=self.engine_type, 

76 ) 

77 

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

79 """Set parameters via BeePopModel.""" 

80 result = self.model.set_parameters(parameters) 

81 self._parameters.update(result) 

82 return result 

83 

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

85 """Get parameters from BeePopModel.""" 

86 return self.model.get_parameters() 

87 

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

89 """Load parameter file via BeePopModel.""" 

90 try: 

91 self.model.load_input_file(file_path) 

92 return True 

93 except ValueError as e: 

94 # Re-raise as BeepopParameterError with error logs 

95 self._raise_with_log(BeepopParameterError, str(e)) 

96 except OSError as e: 

97 # Re-raise as BeepopFileError with error logs 

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

99 except Exception as e: 

100 if self.verbose: 

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

102 return False 

103 

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

105 """Load weather file via BeePopModel.""" 

106 try: 

107 self.model.load_weather(file_path) 

108 return True 

109 except OSError as e: 

110 # Re-raise as BeepopFileError with error logs 

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

112 except Exception as e: 

113 if self.verbose: 

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

115 return False 

116 

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

118 """Load residue file via BeePopModel.""" 

119 try: 

120 self.model.load_contam_file(file_path) 

121 return True 

122 except OSError as e: 

123 # Re-raise as BeepopFileError with error logs 

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

125 except Exception as e: 

126 if self.verbose: 

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

128 return False 

129 

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

131 """Set latitude via BeePopModel.""" 

132 try: 

133 self.model.set_latitude(latitude) 

134 return True 

135 except Exception as e: 

136 if self.verbose: 

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

138 return False 

139 

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

141 """Run simulation via BeePopModel.""" 

142 try: 

143 return self.model.run_beepop() 

144 except Exception as e: 

145 if self.verbose: 

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

147 return None 

148 

149 def get_error_log(self) -> str: 

150 """Get error log from BeePopModel.""" 

151 try: 

152 return self.model.get_errors() 

153 except Exception: 

154 return "" 

155 

156 def get_info_log(self) -> str: 

157 """Get info log from BeePopModel.""" 

158 try: 

159 return self.model.get_info() 

160 except Exception: 

161 return "" 

162 

163 def get_version(self) -> str: 

164 """Get version from BeePopModel.""" 

165 try: 

166 return self.model.get_version() 

167 except Exception: 

168 return "Unknown" 

169 

170 def cleanup(self) -> None: 

171 """Clean up C++ library resources.""" 

172 if hasattr(self.model, "close_library"): 

173 try: 

174 self.model.close_library() 

175 except Exception as e: 

176 if self.verbose: 

177 print(f"Warning during cleanup: {e}") 

178 

179 

180class PythonEngineAdapter: 

181 """ 

182 Adapter for pure Python BeePop+ engine. 

183 

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

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

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

187 

188 Attributes: 

189 engine_type (str): Always 'python' 

190 model (BeePop): The underlying Python engine 

191 """ 

192 

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

194 """ 

195 Initialize Python engine adapter. 

196 

197 Args: 

198 verbose: Enable verbose output 

199 """ 

200 from pybeepop.beepop import BeePop 

201 

202 self.model = BeePop() 

203 self.engine_type = "python" 

204 self.verbose = verbose 

205 self._parameters = {} # Track parameters set 

206 

207 # Load valid parameters for validation (matching C++ engine behavior) 

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

209 self.valid_parameters = pd.read_csv( 

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

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

212 

213 # Initialize model during adapter construction 

214 self.model.initialize_model() 

215 

216 if verbose: 

217 self.model.enable_error_reporting(True) 

218 self.model.enable_info_reporting(True) 

219 

220 def _raise_with_log( 

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

222 ) -> None: 

223 """ 

224 Raise exception with BeePop+ error log included. 

225 

226 Args: 

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

228 message: The error message 

229 

230 Raises: 

231 exception_class: Raised with error log and info log included 

232 """ 

233 error_log = self.get_error_log() 

234 info_log = self.get_info_log() 

235 raise exception_class( 

236 message=message, 

237 error_log=error_log, 

238 info_log=None, # info_log, 

239 engine_type=self.engine_type, 

240 ) 

241 

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

243 """ 

244 Set parameters, converting from dict to list format. 

245 

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

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

248 

249 Raises: 

250 BeepopParameterError: If parameter name is not valid 

251 BeepopRuntimeError: If parameters cannot be set 

252 """ 

253 try: 

254 # Validate parameter names (matching C++ engine behavior) 

255 for par_name in parameters.keys(): 

256 if par_name.lower() not in [x.lower() for x in self.valid_parameters]: 

257 self._raise_with_log( 

258 BeepopParameterError, f"{par_name} is not a valid parameter." 

259 ) 

260 

261 # Convert dict to list format 

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

263 

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

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

266 

267 if success: 

268 self._parameters.update(parameters) 

269 return parameters 

270 else: 

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

272 except BeepopException: 

273 # Re-raise our custom exceptions 

274 raise 

275 except Exception as e: 

276 if self.verbose: 

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

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

279 

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

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

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

283 

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

285 """ 

286 Load parameter file via Python engine. 

287 

288 Raises: 

289 BeepopFileError: If file cannot be opened or read 

290 BeepopParameterError: If parameter is invalid 

291 BeepopRuntimeError: If parameters cannot be loaded 

292 """ 

293 try: 

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

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

296 lines = f.readlines() 

297 

298 # Validate parameter names before loading 

299 for line in lines: 

300 clean_line = line.strip() 

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

302 param_name = clean_line.split("=", 1)[0].strip().lower() 

303 if param_name not in [x.lower() for x in self.valid_parameters]: 

304 self._raise_with_log( 

305 BeepopParameterError, 

306 f"{param_name} is not a valid parameter.", 

307 ) 

308 

309 success = self.model.load_parameter_file(file_path) 

310 

311 # If successful, parse file to track parameters 

312 if success: 

313 for line in lines: 

314 clean_line = line.strip() 

315 if ( 

316 clean_line 

317 and not clean_line.startswith("#") 

318 and "=" in clean_line 

319 ): 

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

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

322 return True 

323 else: 

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

325 except BeepopException: 

326 # Re-raise our custom exceptions 

327 raise 

328 except OSError as e: 

329 # Re-raise as BeepopFileError with error logs 

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

331 except Exception as e: 

332 if self.verbose: 

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

334 self._raise_with_log( 

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

336 ) 

337 

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

339 """ 

340 Load weather file via Python engine. 

341 

342 Raises: 

343 BeepopFileError: If file cannot be opened or read 

344 BeepopRuntimeError: If weather cannot be loaded 

345 """ 

346 try: 

347 # Try to open file to catch OSError early (matching C++ engine) 

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

349 f.read() 

350 

351 success = self.model.set_weather_from_file(file_path) 

352 if success: 

353 return True 

354 else: 

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

356 except BeepopException: 

357 # Re-raise our custom exceptions 

358 raise 

359 except OSError: 

360 # Re-raise as BeepopFileError with error logs 

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

362 except Exception as e: 

363 if self.verbose: 

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

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

366 

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

368 """ 

369 Load residue file, converting to list format. 

370 

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

372 We need to parse the file ourselves. 

373 

374 Raises: 

375 BeepopFileError: If file cannot be opened or read 

376 BeepopRuntimeError: If residue file cannot be loaded 

377 """ 

378 try: 

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

380 lines = [ 

381 line.strip() 

382 for line in f 

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

384 ] 

385 except Exception: 

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

387 

388 try: 

389 success = self.model.set_contamination_table(lines) 

390 if success: 

391 return True 

392 else: 

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

394 except BeepopException: 

395 # Re-raise our custom exceptions 

396 raise 

397 except Exception as e: 

398 if self.verbose: 

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

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

401 

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

403 """Set latitude via Python engine.""" 

404 try: 

405 return self.model.set_latitude(latitude) 

406 except Exception as e: 

407 if self.verbose: 

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

409 return False 

410 

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

412 """ 

413 Run simulation via Python engine. 

414 

415 Raises: 

416 BeepopRuntimeError: If simulation fails to run 

417 """ 

418 try: 

419 success = self.model.run_simulation() 

420 if success: 

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

422 if success: 

423 return df 

424 else: 

425 self._raise_with_log( 

426 BeepopRuntimeError, "Error running BeePop+ simulation." 

427 ) 

428 else: 

429 self._raise_with_log( 

430 BeepopRuntimeError, "Error running BeePop+ simulation." 

431 ) 

432 except BeepopException: 

433 # Re-raise our custom exceptions 

434 raise 

435 except Exception as e: 

436 if self.verbose: 

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

438 self._raise_with_log( 

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

440 ) 

441 

442 def get_error_log(self) -> str: 

443 """ 

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

445 

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

447 We need: string (newline-separated) 

448 """ 

449 try: 

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

451 if success and errors: 

452 return "\n".join(errors) 

453 return "" 

454 except Exception: 

455 return "" 

456 

457 def get_info_log(self) -> str: 

458 """ 

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

460 

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

462 We need: string (newline-separated) 

463 """ 

464 try: 

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

466 if success and info: 

467 return "\n".join(info) 

468 return "" 

469 except Exception: 

470 return "" 

471 

472 def get_version(self) -> str: 

473 """Get version from Python engine.""" 

474 try: 

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

476 return version if success else "Unknown" 

477 except Exception: 

478 return "Unknown" 

479 

480 def cleanup(self) -> None: 

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

482 pass # No explicit cleanup needed