Coverage for pydelica/__init__.py: 66%

1 import json

2 import logging

3 import os

4 import pydantic

5 import platform

6 import subprocess

7 import tempfile

8 import warnings

9 import typing

10 import pathlib

11

12 import pandas

13

14 import pydelica.exception as pde

15 from pydelica.compiler import Compiler

16 from pydelica.logger import OMLogLevel

17 from pydelica.model import Model

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

19 from pydelica.solutions import SolutionHandler

20

21

22 class 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 }