Coverage for pydelica/__init__.py: 66%
281 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-19 07:38 +0000
« 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
12import pandas
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
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.
27 This class is used to initiate model runs, building the models and
28 allowing the user to set parameter values etc.
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"
58 def __enter__(self):
59 return self
61 def __exit__(self, *_, **__):
62 self._compiler.clear_cache()
64 @pydantic.validate_call
65 def use_libraries(self, library_specs: list[dict[str, str]]) -> None:
66 """Use a Modelica library specification list
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
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
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 )
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
107 By default Modelica model simulation will only fail if an assertion
108 of level 'error' is given. The possible values and rankings are:
110 info < warning < error < never
112 where 'never' will mean no exception is raised
114 Parameters
115 ----------
116 assert_level : str
117 new level of assertion to trigger failure
118 """
119 self._assert_fail_level = assert_level
121 @property
122 def code_profile(self) -> dict | None:
123 return self._current_profile
125 @property
126 def code_info(self) -> dict | None:
127 return self._current_info
129 @property
130 def default_model(self) -> str:
131 return list(self._binaries.keys())[0]
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())
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
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
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 = {}
187 self._logger.debug(
188 "Building model %sfrom file '%s'",
189 f"{model_addr} " if model_addr else "",
190 modelica_source_file,
191 )
193 for flag, value in omc_build_flags.items():
194 self._compiler.set_omc_flag(flag, value)
196 self._compiler.set_profile_level(profiling)
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 )
206 _xml_files = _binary_loc.glob("*.xml")
208 if not _xml_files:
209 raise pde.ModelicaFileGenerationError(
210 "Failed to retrieve XML files from model compilation"
211 )
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)
218 _binary = _model_name
220 if platform.system() == "Windows":
221 _binary += ".exe"
223 _binary_addr = _binary_loc.absolute().joinpath(_binary)
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 )
239 self._logger.debug("Located compiled binary '%s'", _binary_addr)
241 self._binaries[_model_name] = _binary_addr
242 self._solutions[_model_name] = SolutionHandler(self._session_directory)
244 self._logger.debug(
245 "Extracting default simulation options for model '%s' from XML file",
246 _model_name,
247 )
249 self._simulation_opts[_model_name] = SimulationOptions(xml_file)
250 self._runtime_opts[_model_name] = RuntimeOptions()
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)
258 # Allows easy creation of a Pandas dataframe for displaying solutions
259 self.set_output_format("csv")
261 def _get_cache_key(self, model_name: str, member_dict: dict) -> str:
262 """Retrieve Model Name in cache dictionary
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
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]
283 def get_parameters(
284 self, model_name: str | None = None
285 ) -> Model | dict[str, typing.Any]:
286 """Retrieve a full parameter list
288 Parameters
289 ----------
290 model_name : str, optional
291 specify name of model to extract parameters, by default extract all
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
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
316 @pydantic.validate_call
317 def get_parameter(self, param_name: str) -> typing.Any:
318 """Retrieve the value of a specific parameter
320 Parameters
321 ----------
322 param_name : str
323 name of parameter to retrieve
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)
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
357 Parameters
358 ----------
359 model_name : str
360 name of model to get simulation options for
362 Returns
363 -------
364 SimulationOptions
365 dictionary containing all simulation options
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]
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.
386 Parameters
387 ----------
388 option : str
389 option to search for
390 model_name : str, optional
391 name of modelica model
393 Returns
394 -------
395 typing.Any
396 value for the given option
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]
410 @pydantic.validate_call
411 def set_parameter(self, param_name: str, value: typing.Any) -> None:
412 """Set a parameter to a given value
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)
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
439 Parameters
440 ----------
441 model_name : str
442 name of model to get runtime options for
444 Returns
445 -------
446 RuntimeOptions
447 contains all runtime options
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]
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
468 for param, value in self._model_parameters[model_name].items():
469 if not value["value"]:
470 continue
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 )
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}")
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
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
509 self._logger.debug("Launching simulation for model '%s'", model_name)
511 # Write parameters to the XML file read by the binary
512 self._model_parameters[_model_name].write_params()
514 _binary_dir = _binary_loc.parent
516 _env = os.environ.copy()
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}"]
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 )
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]
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()
540 self._logger.debug("Executing simulation command: %s ", " ".join(_args))
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 )
554 pde.parse_error_string_simulate(_run_sim.stdout, self._assert_fail_level)
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 )
564 self._solutions[_model_name].retrieve_session_solutions(_run_path)
565 self._recover_profile(_binary_dir, _run_path)
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)
573 _required: tuple[str, str] = (
574 os.path.join(_om_home, "bin"),
575 os.path.join(_om_home, "lib"),
576 )
578 for path in _required:
579 if path not in _path_entries:
580 _path_entries.append(path)
582 _env["PATH"] = f"{_separator}".join(_path_entries)
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
595 for model in self._simulation_opts:
596 self._simulation_opts[model].set_option("outputFormat", format)
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)
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)
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)
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)
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)
670 def get_solutions(self) -> dict[str, pandas.DataFrame]:
671 """Returns solutions to all simulated models as a dictionary of dataframes
673 Outputs are written as Pandas dataframes the columns of which can be
674 accessed by variable name.
676 Returns
677 -------
678 dict
679 dictionary containing outputs to all simulated models as Pandas
680 dataframes
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 )
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
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 }