Coverage for pydelica/__init__.py: 66%

281 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-19 07:38 +0000

1import json 

2import logging 

3import os 

4import pydantic 

5import platform 

6import subprocess 

7import tempfile 

8import warnings 

9import typing 

10import pathlib 

11 

12import pandas 

13 

14import pydelica.exception as pde 

15from pydelica.compiler import Compiler 

16from pydelica.logger import OMLogLevel 

17from pydelica.model import Model 

18from pydelica.options import SimulationOptions, Solver, OutputFormat, RuntimeOptions 

19from pydelica.solutions import SolutionHandler 

20 

21 

22class Session: 

23 @pydantic.validate_call 

24 def __init__(self, log_level: OMLogLevel | int | str = logging.INFO) -> None: 

25 """Session object which handles model runs. 

26 

27 This class is used to initiate model runs, building the models and 

28 allowing the user to set parameter values etc. 

29 

30 Parameters 

31 ---------- 

32 log_level : OMLogLevel | str | int, optional 

33 level of Modelica logging, by default logging.INFO 

34 (use of OMLogLevel will be deprecated in v0.7.0) 

35 """ 

36 self._solutions: dict[str, SolutionHandler] = {} 

37 self._model_parameters: dict[str, Model] = {} 

38 self._simulation_opts: dict[str, SimulationOptions] = {} 

39 self._runtime_opts: dict[str, RuntimeOptions] = {} 

40 self._current_profile: dict | None = None 

41 self._current_info: dict | None = None 

42 self._binaries: dict[str, pathlib.Path] = {} 

43 self._custom_libraries: list[dict[str, str]] = [] 

44 self._logger: logging.Logger = logging.getLogger("PyDelica") 

45 if log_level == OMLogLevel.DEBUG: 

46 warnings.warn( 

47 "DeprecationNotice: Use of 'OMLogLevel' for log level settings will be deprecated in v0.7.0, " 

48 "please use Session.get_run_options.log_level to enabled/disable log flags" 

49 ) 

50 self._logger.setLevel(logging.DEBUG) 

51 elif isinstance(log_level, (int, str)): 

52 self._logger.setLevel(log_level) 

53 self._compiler = Compiler() 

54 self._log_level = log_level 

55 self._session_directory = os.getcwd() 

56 self._assert_fail_level = "error" 

57 

58 def __enter__(self): 

59 return self 

60 

61 def __exit__(self, *_, **__): 

62 self._compiler.clear_cache() 

63 

64 @pydantic.validate_call 

65 def use_libraries(self, library_specs: list[dict[str, str]]) -> None: 

66 """Use a Modelica library specification list 

67 

68 Parameters 

69 ---------- 

70 libary_specs : list[dict[str, str]] 

71 list of dictionaries containing version and location info of libraries 

72 """ 

73 self._custom_libraries = library_specs 

74 

75 @pydantic.validate_call 

76 def use_library( 

77 self, 

78 library_name: str, 

79 library_version: str | None = None, 

80 library_directory: str | None = None, 

81 ) -> None: 

82 """Specify version of a library to use 

83 

84 Parameters 

85 ---------- 

86 library_name : str 

87 name of the Modelica library 

88 library_version : str, optional 

89 semantic version number, by default latest 

90 library_directory : str, optional 

91 location of library on system, by default OM default paths 

92 """ 

93 self._custom_libraries.append( 

94 { 

95 "name": library_name, 

96 "version": library_version or "", 

97 "directory": library_directory or "", 

98 } 

99 ) 

100 

101 @pydantic.validate_call 

102 def fail_on_assert_level( 

103 self, assert_level: typing.Literal["info", "warning", "error", "debug", "never"] 

104 ) -> None: 

105 """Change assertion level on which model execution should fail 

106 

107 By default Modelica model simulation will only fail if an assertion 

108 of level 'error' is given. The possible values and rankings are: 

109 

110 info < warning < error < never 

111 

112 where 'never' will mean no exception is raised 

113 

114 Parameters 

115 ---------- 

116 assert_level : str 

117 new level of assertion to trigger failure 

118 """ 

119 self._assert_fail_level = assert_level 

120 

121 @property 

122 def code_profile(self) -> dict | None: 

123 return self._current_profile 

124 

125 @property 

126 def code_info(self) -> dict | None: 

127 return self._current_info 

128 

129 @property 

130 def default_model(self) -> str: 

131 return list(self._binaries.keys())[0] 

132 

133 @pydantic.validate_call 

134 def _recover_profile( 

135 self, build_dir: pydantic.DirectoryPath, run_dir: pydantic.DirectoryPath 

136 ) -> None: 

137 """Recovers a profile file if one exists""" 

138 _prof_files = list(run_dir.glob("*_prof.json")) 

139 _info_files = list(build_dir.glob("*_info.json")) 

140 if _prof_files: 

141 self._current_profile = json.load(_prof_files[0].open()) 

142 if _info_files: 

143 self._current_info = json.load(_info_files[0].open()) 

144 

145 @pydantic.validate_call 

146 def build_model( 

147 self, 

148 modelica_source_file: pydantic.FilePath, 

149 model_addr: str | None = None, 

150 extra_models: list[str] | None = None, 

151 c_source_dir: pydantic.DirectoryPath | None = None, 

152 profiling: ( 

153 typing.Literal["none", "blocks", "all", "all_perf", "all_stat"] | None 

154 ) = None, 

155 update_input_paths_to: pydantic.DirectoryPath | None = None, 

156 omc_build_flags: dict[str, str | None] | None = None, 

157 ) -> None: 

158 """Build a Modelica model from a source file 

159 

160 Parameters 

161 ---------- 

162 modelica_source_file : str 

163 Modelica source file to compile 

164 model_addr : str, optional 

165 address of model within source file, else default 

166 extra_models : list[str], optional 

167 additional models required for compile, by default None 

168 c_source_dir : str, optional 

169 directory containing any additional required C sources, by default None 

170 profiling : Literal["none", "blocks", "all", "all_perf", "all_stat"], optional 

171 if set, activates the OMC profiling at the specified level 

172 update_input_paths_to : str, optional 

173 update input paths within model to another location, by default None 

174 omc_build_flags : dict[str, str | None], optional 

175 additional flags to pass to the OMC compiler 

176 

177 Raises 

178 ------ 

179 pde.ModelicaFileGenerationError 

180 if the XML parameter file was not generated 

181 RuntimeError 

182 If model compilation failed 

183 """ 

184 if not omc_build_flags: 

185 omc_build_flags = {} 

186 

187 self._logger.debug( 

188 "Building model %sfrom file '%s'", 

189 f"{model_addr} " if model_addr else "", 

190 modelica_source_file, 

191 ) 

192 

193 for flag, value in omc_build_flags.items(): 

194 self._compiler.set_omc_flag(flag, value) 

195 

196 self._compiler.set_profile_level(profiling) 

197 

198 _binary_loc = self._compiler.compile( 

199 modelica_source_file=modelica_source_file, 

200 model_addr=model_addr, 

201 extra_models=extra_models, 

202 c_source_dir=c_source_dir, 

203 custom_library_spec=self._custom_libraries, 

204 ) 

205 

206 _xml_files = _binary_loc.glob("*.xml") 

207 

208 if not _xml_files: 

209 raise pde.ModelicaFileGenerationError( 

210 "Failed to retrieve XML files from model compilation" 

211 ) 

212 

213 self._logger.debug("Parsing generated XML files") 

214 for xml_file in _xml_files: 

215 _model_name = os.path.basename(xml_file).split("_init")[0] 

216 self._model_parameters[_model_name] = Model(modelica_source_file, xml_file) 

217 

218 _binary = _model_name 

219 

220 if platform.system() == "Windows": 

221 _binary += ".exe" 

222 

223 _binary_addr = _binary_loc.absolute().joinpath(_binary) 

224 

225 # In later versions of OM the binary name cannot have '.' within the name 

226 if not os.path.exists(_binary_addr): 

227 if not ( 

228 ( 

229 _binary_addr := pathlib.Path( 

230 f"{_binary_addr}".replace(".", "_", 1) 

231 ) 

232 ).exists 

233 ): 

234 raise RuntimeError( 

235 f"Compilation of model '{_model_name}' failed, " 

236 f"no binary for '{_model_name}' found." 

237 ) 

238 

239 self._logger.debug("Located compiled binary '%s'", _binary_addr) 

240 

241 self._binaries[_model_name] = _binary_addr 

242 self._solutions[_model_name] = SolutionHandler(self._session_directory) 

243 

244 self._logger.debug( 

245 "Extracting default simulation options for model '%s' from XML file", 

246 _model_name, 

247 ) 

248 

249 self._simulation_opts[_model_name] = SimulationOptions(xml_file) 

250 self._runtime_opts[_model_name] = RuntimeOptions() 

251 

252 # Option to update any variable recognised as an input 

253 # i.e. .mat/.csv to point to the correct location as an 

254 # absolute path 

255 if update_input_paths_to: 

256 self._set_input_files_directory(_model_name, update_input_paths_to) 

257 

258 # Allows easy creation of a Pandas dataframe for displaying solutions 

259 self.set_output_format("csv") 

260 

261 def _get_cache_key(self, model_name: str, member_dict: dict) -> str: 

262 """Retrieve Model Name in cache dictionary 

263 

264 Retrieves model name as stored within the given dictionary. In some versions of OM 

265 '.' in the model name is replaced by '_' in the files. 

266 """ 

267 if (_model_name := model_name) not in member_dict and ( 

268 _model_name := model_name.replace(".", "_") 

269 ) not in member_dict: 

270 raise KeyError(f"Key '{model_name}' not found") 

271 return _model_name 

272 

273 @pydantic.validate_call 

274 def get_binary_location(self, model_name: str) -> pathlib.Path: 

275 try: 

276 _model_name: str = self._get_cache_key(model_name, self._binaries) 

277 except KeyError as e: 

278 raise pde.BinaryNotFoundError( 

279 f"Failed to retrieve binary for model '{model_name}'" 

280 ) from e 

281 return self._binaries[_model_name] 

282 

283 def get_parameters( 

284 self, model_name: str | None = None 

285 ) -> Model | dict[str, typing.Any]: 

286 """Retrieve a full parameter list 

287 

288 Parameters 

289 ---------- 

290 model_name : str, optional 

291 specify name of model to extract parameters, by default extract all 

292 

293 Returns 

294 ------- 

295 dict[str, typing.Any] 

296 dictionary containing parameters by name and their values 

297 """ 

298 if model_name: 

299 try: 

300 _model_name: str = self._get_cache_key( 

301 model_name, self._model_parameters 

302 ) 

303 except KeyError as e: 

304 raise pde.UnknownModelError(model_name) from e 

305 

306 return self._model_parameters[_model_name] 

307 else: 

308 _out_params: dict[str, typing.Any] = {} 

309 for model in self._model_parameters: 

310 for param in self._model_parameters[model]: 

311 if param in _out_params: 

312 continue 

313 _out_params[param] = self._model_parameters[model][param] 

314 return _out_params 

315 

316 @pydantic.validate_call 

317 def get_parameter(self, param_name: str) -> typing.Any: 

318 """Retrieve the value of a specific parameter 

319 

320 Parameters 

321 ---------- 

322 param_name : str 

323 name of parameter to retrieve 

324 

325 Returns 

326 ------- 

327 typing.Any 

328 the value of the parameter specified 

329 """ 

330 for model in self._model_parameters: 

331 if param_name in (_model_params := self.get_parameters(model)): 

332 if isinstance( 

333 _model_params, 

334 dict, 

335 ): 

336 raise AssertionError( 

337 "Expected type 'Model' for parameter retrieval for model " 

338 f"'{model}' but got type 'dict'" 

339 ) 

340 if isinstance( 

341 _param := _model_params.get_parameter(param_name), 

342 dict, 

343 ): 

344 raise AssertionError( 

345 "Expected non-mutable value for requested parameter" 

346 f"'{param_name}' but got type 'dict'" 

347 ) 

348 return _param 

349 raise pde.UnknownParameterError(param_name) 

350 

351 @pydantic.validate_call 

352 def get_simulation_options( 

353 self, model_name: str | None = None 

354 ) -> SimulationOptions: 

355 """Retrieve dictionary of the Simulation Options 

356 

357 Parameters 

358 ---------- 

359 model_name : str 

360 name of model to get simulation options for 

361 

362 Returns 

363 ------- 

364 SimulationOptions 

365 dictionary containing all simulation options 

366 

367 Raises 

368 ------ 

369 KeyError 

370 if the given model name is not recognised 

371 """ 

372 if not model_name: 

373 model_name = self.default_model 

374 try: 

375 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

376 except KeyError as e: 

377 raise pde.UnknownModelError(model_name) from e 

378 return self._simulation_opts[_model_name] 

379 

380 @pydantic.validate_call 

381 def get_simulation_option( 

382 self, option: str, model_name: str | None = None 

383 ) -> typing.Any: 

384 """Retrieve a single option for a given model. 

385 

386 Parameters 

387 ---------- 

388 option : str 

389 option to search for 

390 model_name : str, optional 

391 name of modelica model 

392 

393 Returns 

394 ------- 

395 typing.Any 

396 value for the given option 

397 

398 Raises 

399 ------ 

400 KeyError 

401 if the given model is not recognised 

402 KeyError 

403 if the given option name is not recognised 

404 """ 

405 if not model_name: 

406 return self._simulation_opts[self.default_model][option] 

407 model_name = self._get_cache_key(model_name, self._simulation_opts) 

408 return self._simulation_opts[model_name][option] 

409 

410 @pydantic.validate_call 

411 def set_parameter(self, param_name: str, value: typing.Any) -> None: 

412 """Set a parameter to a given value 

413 

414 Parameters 

415 ---------- 

416 param_name : str 

417 name of model parameter to update 

418 value : typing.Any 

419 new value to assign to the given parameters 

420 """ 

421 if isinstance(value, dict): 

422 raise TypeError( 

423 "Cannot assign a value of type dictionary as a parameter value" 

424 f" for parameter '{param_name}'" 

425 ) 

426 self._logger.debug( 

427 "Searching for parameter '%s' and assigning new value", param_name 

428 ) 

429 for model in self._model_parameters: 

430 if param_name in self._model_parameters[model]: 

431 self._model_parameters[model].set_parameter(param_name, value) 

432 return 

433 raise pde.UnknownParameterError(param_name) 

434 

435 @pydantic.validate_call 

436 def get_runtime_options(self, model_name: str | None = None) -> RuntimeOptions: 

437 """Retrieve runtime options object for the given model 

438 

439 Parameters 

440 ---------- 

441 model_name : str 

442 name of model to get runtime options for 

443 

444 Returns 

445 ------- 

446 RuntimeOptions 

447 contains all runtime options 

448 

449 Raises 

450 ------ 

451 KeyError 

452 if the given model name is not recognised 

453 """ 

454 if not model_name: 

455 model_name = self.default_model 

456 try: 

457 _model_name: str = self._get_cache_key(model_name, self._runtime_opts) 

458 except KeyError as e: 

459 raise pde.UnknownModelError(model_name) from e 

460 return self._runtime_opts[_model_name] 

461 

462 def _set_input_files_directory( 

463 self, model_name: str, input_dir: pathlib.Path | None = None 

464 ) -> None: 

465 if not input_dir: 

466 input_dir = self._model_parameters[model_name].get_source_path().parent 

467 

468 for param, value in self._model_parameters[model_name].items(): 

469 if not value["value"]: 

470 continue 

471 

472 _has_addr_elem = any( 

473 i in value["value"] 

474 for i in [os.path.sep, ".mat", ".csv"] 

475 if value["type"] == str 

476 ) 

477 

478 if value["type"] == str and _has_addr_elem: 

479 _addr = input_dir.joinpath(value["value"]) 

480 self._model_parameters[model_name].set_parameter(param, f"{_addr}") 

481 

482 @pydantic.validate_call 

483 def simulate( 

484 self, model_name: str | None = None, verbosity: OMLogLevel | None = None 

485 ) -> None: 

486 """Run simulation using the built models 

487 

488 Parameters 

489 ---------- 

490 model_name : str, optional 

491 Specify model to execute, by default use first in list 

492 verbosity : OMLogLevel, optional (Deprecated) 

493 specify level of Modelica outputs, else use default 

494 """ 

495 if not model_name: 

496 model_name = self.default_model 

497 self._logger.warning( 

498 "No model name specified, using first result '%s'", model_name 

499 ) 

500 try: 

501 _binary_loc: pathlib.Path = self.get_binary_location(model_name) 

502 _model_name: str = self._get_cache_key(model_name, self._model_parameters) 

503 except KeyError as e: 

504 raise pde.BinaryNotFoundError( 

505 f"Could not find binary for Model '{model_name}'," 

506 " did you run 'build_models' on the source file?" 

507 ) from e 

508 

509 self._logger.debug("Launching simulation for model '%s'", model_name) 

510 

511 # Write parameters to the XML file read by the binary 

512 self._model_parameters[_model_name].write_params() 

513 

514 _binary_dir = _binary_loc.parent 

515 

516 _env = os.environ.copy() 

517 

518 # If the binary or library directories are not in PATH temporarily add them during 

519 # model execution in Windows 

520 if platform.system() == "Windows": 

521 self._append_locs_to_winpath(_env) 

522 _args: list[str] = [f"{_binary_loc}", f"-inputPath={_binary_dir}"] 

523 

524 if not os.path.exists(_binary_loc): 

525 raise pde.BinaryNotFoundError( 

526 f"Failed to retrieve binary for model '{model_name}' " 

527 f"from location '{_binary_dir}'" 

528 ) 

529 

530 if verbosity and verbosity.value: 

531 _args += [verbosity.value] 

532 elif isinstance(self._log_level, OMLogLevel) and self._log_level.value: 

533 _args += [self._log_level.value] 

534 

535 # Add any C runtime options 

536 # Model name is stored with XML file key ('.' -> '_') 

537 _model_name_key: str = model_name.replace(".", "_") 

538 _args += self._runtime_opts[_model_name_key].assemble_args() 

539 

540 self._logger.debug("Executing simulation command: %s ", " ".join(_args)) 

541 

542 with tempfile.TemporaryDirectory() as run_dir: 

543 _run_path = pathlib.Path(run_dir) 

544 _run_sim = subprocess.run( 

545 _args, 

546 stderr=subprocess.PIPE, 

547 stdout=subprocess.PIPE, 

548 text=True, 

549 shell=False, 

550 cwd=_run_path, 

551 env=_env, 

552 ) 

553 

554 pde.parse_error_string_simulate(_run_sim.stdout, self._assert_fail_level) 

555 

556 if _run_sim.returncode != 0: 

557 _print_msg = _run_sim.stderr or _run_sim.stdout 

558 if not _print_msg: 

559 _print_msg = "Cause unknown, no error logs were found." 

560 raise pde.OMExecutionError( 

561 f"[{_run_sim.returncode}] Simulation failed with: {_print_msg}" 

562 ) 

563 

564 self._solutions[_model_name].retrieve_session_solutions(_run_path) 

565 self._recover_profile(_binary_dir, _run_path) 

566 

567 def _append_locs_to_winpath(self, _env: dict[str, typing.Any]) -> None: 

568 _om_home = os.environ["OPENMODELICAHOME"] 

569 _path: str = os.environ["PATH"] 

570 _separator: str = ";" if ";" in _path else ":" 

571 _path_entries: list[str] = os.environ["PATH"].split(_separator) 

572 

573 _required: tuple[str, str] = ( 

574 os.path.join(_om_home, "bin"), 

575 os.path.join(_om_home, "lib"), 

576 ) 

577 

578 for path in _required: 

579 if path not in _path_entries: 

580 _path_entries.append(path) 

581 

582 _env["PATH"] = f"{_separator}".join(_path_entries) 

583 

584 @pydantic.validate_call 

585 def set_output_format( 

586 self, format: OutputFormat | typing.Literal["csv", "mat", "plt"] 

587 ) -> None: 

588 if isinstance(format, OutputFormat): 

589 warnings.warn( 

590 "DeprecationNotice: Use of 'OutputFormat' for output format will be deprecated in v0.7.0, " 

591 "please use literal strings 'csv', 'mat' or 'plt'" 

592 ) 

593 format = format.value 

594 

595 for model in self._simulation_opts: 

596 self._simulation_opts[model].set_option("outputFormat", format) 

597 

598 @pydantic.validate_call 

599 def set_solver( 

600 self, 

601 solver: Solver | typing.Literal["dassl", "euler", "rungekutta"], 

602 model_name: str | None = None, 

603 ) -> None: 

604 if isinstance(solver, Solver): 

605 warnings.warn( 

606 "DeprecationNotice: Use of 'Solver' for solver type will be deprecated in v0.7.0, " 

607 "please use literal strings 'euler', 'dassl' or 'rungekutta'" 

608 ) 

609 solver = solver.value 

610 if model_name: 

611 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

612 self._simulation_opts[_model_name].set_option("solver", solver) 

613 else: 

614 for model in self._simulation_opts: 

615 self._simulation_opts[model].set_option("solver", solver) 

616 

617 @pydantic.validate_call 

618 def set_time_range( 

619 self, 

620 start_time: pydantic.NonNegativeInt | None = None, 

621 stop_time: pydantic.PositiveInt | None = None, 

622 model_name: str | None = None, 

623 ) -> None: 

624 if model_name: 

625 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

626 if start_time: 

627 self._simulation_opts[_model_name].set_option("startTime", start_time) 

628 if stop_time: 

629 self._simulation_opts[_model_name].set_option("stopTime", stop_time) 

630 else: 

631 for model in self._simulation_opts: 

632 if start_time: 

633 self._simulation_opts[model].set_option("startTime", start_time) 

634 if stop_time: 

635 self._simulation_opts[model].set_option("stopTime", stop_time) 

636 

637 @pydantic.validate_call 

638 def set_tolerance( 

639 self, tolerance: pydantic.PositiveFloat, model_name: str | None = None 

640 ) -> None: 

641 if model_name: 

642 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

643 self._simulation_opts[_model_name].set_option("tolerance", tolerance) 

644 else: 

645 for model in self._simulation_opts: 

646 self._simulation_opts[model].set_option("tolerance", tolerance) 

647 

648 @pydantic.validate_call 

649 def set_variable_filter( 

650 self, filter_str: str, model_name: str | None = None 

651 ) -> None: 

652 if model_name: 

653 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

654 self._simulation_opts[_model_name].set_option("variableFilter", filter_str) 

655 else: 

656 for model in self._simulation_opts: 

657 self._simulation_opts[model].set_option("variableFilter", filter_str) 

658 

659 @pydantic.validate_call 

660 def set_simulation_option( 

661 self, option_name: str, value: typing.Any, model_name: str | None = None 

662 ) -> None: 

663 if model_name: 

664 _model_name: str = self._get_cache_key(model_name, self._simulation_opts) 

665 self._simulation_opts[_model_name].set_option(option_name, value) 

666 else: 

667 for model in self._simulation_opts: 

668 self._simulation_opts[model].set_option(option_name, value) 

669 

670 def get_solutions(self) -> dict[str, pandas.DataFrame]: 

671 """Returns solutions to all simulated models as a dictionary of dataframes 

672 

673 Outputs are written as Pandas dataframes the columns of which can be 

674 accessed by variable name. 

675 

676 Returns 

677 ------- 

678 dict 

679 dictionary containing outputs to all simulated models as Pandas 

680 dataframes 

681 

682 Raises 

683 ------ 

684 pde.BinaryNotFoundError 

685 if no models have been compiled 

686 """ 

687 if not self._binaries: 

688 raise pde.BinaryNotFoundError( 

689 "Cannot retrieve solutions, you need to compile and" 

690 " run one or more models first" 

691 ) 

692 

693 # The SolutionHandler class takes into account the case of multiple 

694 # output files, however with OM there is only ever a single file per 

695 # model so we only need to retrieve the first one 

696 

697 return { 

698 model: self._solutions[model].get_solutions()[ 

699 list(self._solutions[model].get_solutions().keys())[0] 

700 ] 

701 for model in self._solutions 

702 }