diff --git a/OMPython/ModelExecution.py b/OMPython/ModelExecution.py new file mode 100644 index 00000000..b195b6f7 --- /dev/null +++ b/OMPython/ModelExecution.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +""" +Definition of all data needed to run a simulation based on a compiled model executable. +""" + +from __future__ import annotations + +import ast +import dataclasses +import logging +import numbers +import os +import pathlib +import re +import subprocess +from typing import Any, Optional +import warnings + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelExecutionException(Exception): + """ + Exception which is raised by ModelException* classes. + """ + + +class ModelExecutionCmd: + """ + All information about a compiled model executable. This should include data about all structured parameters, i.e. + parameters which need a recompilation of the model. All non-structured parameters can be easily changed without + the need for recompilation. + """ + + def __init__( + self, + runpath: os.PathLike, + cmd_prefix: list[str], + cmd_local: bool = False, + cmd_windows: bool = False, + timeout: float = 10.0, + model_name: Optional[str] = None, + ) -> None: + if model_name is None: + raise ModelExecutionException("Missing model name!") + + self._cmd_local = cmd_local + self._cmd_windows = cmd_windows + self._cmd_prefix = cmd_prefix + self._runpath = pathlib.PurePosixPath(runpath) + self._model_name = model_name + self._timeout = timeout + + # dictionaries of command line arguments for the model executable + self._args: dict[str, str | None] = {} + # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the + # structure: 'key' => 'key=value' + self._arg_override: dict[str, str] = {} + + def arg_set( + self, + key: str, + val: Optional[str | dict[str, Any] | numbers.Number] = None, + ) -> None: + """ + Set one argument for the executable model. + + Args: + key: identifier / argument name to be used for the call of the model executable. + val: value for the given key; None for no value and for key == 'override' a dictionary can be used which + indicates variables to override + """ + + def override2str( + okey: str, + oval: str | bool | numbers.Number, + ) -> str: + """ + Convert a value for 'override' to a string taking into account differences between Modelica and Python. + """ + # check oval for any string representations of numbers (or bool) and convert these to Python representations + if isinstance(oval, str): + try: + oval_evaluated = ast.literal_eval(oval) + if isinstance(oval_evaluated, (numbers.Number, bool)): + oval = oval_evaluated + except (ValueError, SyntaxError): + pass + + if isinstance(oval, str): + oval_str = oval.strip() + elif isinstance(oval, bool): + oval_str = 'true' if oval else 'false' + elif isinstance(oval, numbers.Number): + oval_str = str(oval) + else: + raise ModelExecutionException(f"Invalid value for override key {okey}: {type(oval)}") + + return f"{okey}={oval_str}" + + if not isinstance(key, str): + raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") + key = key.strip() + + if isinstance(val, dict): + if key != 'override': + raise ModelExecutionException("Dictionary input only possible for key 'override'!") + + for okey, oval in val.items(): + if not isinstance(okey, str): + raise ModelExecutionException("Invalid key for argument 'override': " + f"{repr(okey)} (type: {type(okey)})") + + if not isinstance(oval, (str, bool, numbers.Number, type(None))): + raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " + f"{repr(oval)} (type: {type(oval)})") + + if okey in self._arg_override: + if oval is None: + logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") + del self._arg_override[okey] + continue + + logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " + f"(was: {repr(self._arg_override[okey])})") + + if oval is not None: + self._arg_override[okey] = override2str(okey=okey, oval=oval) + + argval = ','.join(sorted(self._arg_override.values())) + elif val is None: + argval = None + elif isinstance(val, str): + argval = val.strip() + elif isinstance(val, numbers.Number): + argval = str(val) + else: + raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") + + if key in self._args: + logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " + f"(was: {repr(self._args[key])})") + self._args[key] = argval + + def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: + """ + Return the value for the given key + """ + if key in self._args: + return self._args[key] + + return None + + def args_set( + self, + args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], + ) -> None: + """ + Define arguments for the model executable. + """ + for arg in args: + self.arg_set(key=arg, val=args[arg]) + + def get_cmd_args(self) -> list[str]: + """ + Get a list with the command arguments for the model executable. + """ + + cmdl = [] + for key in sorted(self._args): + if self._args[key] is None: + cmdl.append(f"-{key}") + else: + cmdl.append(f"-{key}={self._args[key]}") + + return cmdl + + def definition(self) -> ModelExecutionData: + """ + Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. + """ + # ensure that a result filename is provided + result_file = self.arg_get('r') + if not isinstance(result_file, str): + result_file = (self._runpath / f"{self._model_name}.mat").as_posix() + + # as this is the local implementation, pathlib.Path can be used + cmd_path = self._runpath + + cmd_library_path = None + if self._cmd_local and self._cmd_windows: + cmd_library_path = "" + + # set the process environment from the generated .bat file in windows which should have all the dependencies + # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath + path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" + if not path_bat.is_file(): + raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) + + content = path_bat.read_text(encoding='utf-8') + for line in content.splitlines(): + match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) + if match: + cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons + my_env = os.environ.copy() + my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] + + cmd_model_executable = cmd_path / f"{self._model_name}.exe" + else: + # for Linux the paths to the needed libraries should be included in the executable (using rpath) + cmd_model_executable = cmd_path / self._model_name + + # define local(!) working directory + cmd_cwd_local = None + if self._cmd_local: + cmd_cwd_local = cmd_path.as_posix() + + omc_run_data = ModelExecutionData( + cmd_path=cmd_path.as_posix(), + cmd_model_name=self._model_name, + cmd_args=self.get_cmd_args(), + cmd_result_file=result_file, + cmd_prefix=self._cmd_prefix, + cmd_library_path=cmd_library_path, + cmd_model_executable=cmd_model_executable.as_posix(), + cmd_cwd_local=cmd_cwd_local, + cmd_timeout=self._timeout, + ) + + return omc_run_data + + @staticmethod + def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: + """ + Parse a simflag definition; this is deprecated! + + The return data can be used as input for self.args_set(). + """ + warnings.warn( + message="The argument 'simflags' is depreciated and will be removed in future versions; " + "please use 'simargs' instead", + category=DeprecationWarning, + stacklevel=2, + ) + + simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} + + args = [s for s in simflags.split(' ') if s] + for arg in args: + if arg[0] != '-': + raise ModelExecutionException(f"Invalid simulation flag: {arg}") + arg = arg[1:] + parts = arg.split('=') + if len(parts) == 1: + simargs[parts[0]] = None + elif parts[0] == 'override': + override = '='.join(parts[1:]) + + override_dict = {} + for item in override.split(','): + kv = item.split('=') + if not 0 < len(kv) < 3: + raise ModelExecutionException(f"Invalid value for '-override': {override}") + if kv[0]: + try: + override_dict[kv[0]] = kv[1] + except (KeyError, IndexError) as ex: + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex + + simargs[parts[0]] = override_dict + + return simargs + + +@dataclasses.dataclass +class ModelExecutionData: + """ + Data class to store the command line data for running a model executable in the OMC environment. + + All data should be defined for the environment, where OMC is running (local, docker or WSL) + + To use this as a definition of an OMC simulation run, it has to be processed within + OMCProcess*.self_update(). This defines the attribute cmd_model_executable. + """ + # cmd_path is the expected working directory + cmd_path: str + cmd_model_name: str + # command prefix data (as list of strings); needed for docker or WSL + cmd_prefix: list[str] + # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) + cmd_model_executable: str + # command line arguments for the model executable + cmd_args: list[str] + # result file with the simulation output + cmd_result_file: str + # command timeout + cmd_timeout: float + + # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows + cmd_library_path: Optional[str] = None + # working directory to be used on the *local* system + cmd_cwd_local: Optional[str] = None + + def get_cmd(self) -> list[str]: + """ + Get the command line to run the model executable in the environment defined by the OMCProcess definition. + """ + + cmdl = self.cmd_prefix + cmdl += [self.cmd_model_executable] + cmdl += self.cmd_args + + return cmdl + + def run(self) -> int: + """ + Run the model execution defined in this class. + """ + + my_env = os.environ.copy() + if isinstance(self.cmd_library_path, str): + my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] + + cmdl = self.get_cmd() + + logger.debug("Run OM command %s in %s", repr(cmdl), self.cmd_path) + try: + cmdres = subprocess.run( + cmdl, + capture_output=True, + text=True, + env=my_env, + cwd=self.cmd_cwd_local, + timeout=self.cmd_timeout, + check=True, + ) + stdout = cmdres.stdout.strip() + stderr = cmdres.stderr.strip() + returncode = cmdres.returncode + + logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) + + if stderr: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") + except subprocess.TimeoutExpired as ex: + raise ModelExecutionException(f"Timeout running model executable {repr(cmdl)}: {ex}") from ex + except subprocess.CalledProcessError as ex: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex + + return returncode diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 9db3da33..d1d331cb 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -3,299 +3,30 @@ Definition of main class to run Modelica simulations - ModelicaSystem. """ -import ast -from dataclasses import dataclass -import itertools import logging -import numbers import os import pathlib -import queue import textwrap -import threading -from typing import Any, cast, Optional -import warnings -import xml.etree.ElementTree as ET +from typing import Any, Optional import numpy as np -import re - from OMPython.OMCSession import ( OMCSessionException, - OMCSessionRunData, OMCSession, OMCSessionLocal, OMCPath, ) +from OMPython.ModelicaSystemBase import ( + ModelicaSystemBase, + ModelicaSystemError, +) # define logger using the current module name as ID logger = logging.getLogger(__name__) -class ModelicaSystemError(Exception): - """ - Exception used in ModelicaSystem and ModelicaSystemCmd classes. - """ - - -@dataclass -class LinearizationResult: - """Modelica model linearization results. - - Attributes: - n: number of states - m: number of inputs - p: number of outputs - A: state matrix (n x n) - B: input matrix (n x m) - C: output matrix (p x n) - D: feedthrough matrix (p x m) - x0: fixed point - u0: input corresponding to the fixed point - stateVars: names of state variables - inputVars: names of inputs - outputVars: names of outputs - """ - - n: int - m: int - p: int - - A: list - B: list - C: list - D: list - - x0: list[float] - u0: list[float] - - stateVars: list[str] - inputVars: list[str] - outputVars: list[str] - - def __iter__(self): - """Allow unpacking A, B, C, D = result.""" - yield self.A - yield self.B - yield self.C - yield self.D - - def __getitem__(self, index: int): - """Allow accessing A, B, C, D via result[0] through result[3]. - - This is needed for backwards compatibility, because - ModelicaSystem.linearize() used to return [A, B, C, D]. - """ - return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] - - -class ModelicaSystemCmd: - """ - All information about a compiled model executable. This should include data about all structured parameters, i.e. - parameters which need a recompilation of the model. All non-structured parameters can be easily changed without - the need for recompilation. - """ - - def __init__( - self, - session: OMCSession, - runpath: OMCPath, - modelname: Optional[str] = None, - ) -> None: - if modelname is None: - raise ModelicaSystemError("Missing model name!") - - self._session = session - self._runpath = runpath - self._model_name = modelname - - # dictionaries of command line arguments for the model executable - self._args: dict[str, str | None] = {} - # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the - # structure: 'key' => 'key=value' - self._arg_override: dict[str, str] = {} - - def arg_set( - self, - key: str, - val: Optional[str | dict[str, Any] | numbers.Number] = None, - ) -> None: - """ - Set one argument for the executable model. - - Args: - key: identifier / argument name to be used for the call of the model executable. - val: value for the given key; None for no value and for key == 'override' a dictionary can be used which - indicates variables to override - """ - - def override2str( - okey: str, - oval: str | bool | numbers.Number, - ) -> str: - """ - Convert a value for 'override' to a string taking into account differences between Modelica and Python. - """ - # check oval for any string representations of numbers (or bool) and convert these to Python representations - if isinstance(oval, str): - try: - oval_evaluated = ast.literal_eval(oval) - if isinstance(oval_evaluated, (numbers.Number, bool)): - oval = oval_evaluated - except (ValueError, SyntaxError): - pass - - if isinstance(oval, str): - oval_str = oval.strip() - elif isinstance(oval, bool): - oval_str = 'true' if oval else 'false' - elif isinstance(oval, numbers.Number): - oval_str = str(oval) - else: - raise ModelicaSystemError(f"Invalid value for override key {okey}: {type(oval)}") - - return f"{okey}={oval_str}" - - if not isinstance(key, str): - raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})") - key = key.strip() - - if isinstance(val, dict): - if key != 'override': - raise ModelicaSystemError("Dictionary input only possible for key 'override'!") - - for okey, oval in val.items(): - if not isinstance(okey, str): - raise ModelicaSystemError("Invalid key for argument 'override': " - f"{repr(okey)} (type: {type(okey)})") - - if not isinstance(oval, (str, bool, numbers.Number, type(None))): - raise ModelicaSystemError(f"Invalid input for 'override'.{repr(okey)}: " - f"{repr(oval)} (type: {type(oval)})") - - if okey in self._arg_override: - if oval is None: - logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") - del self._arg_override[okey] - continue - - logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " - f"(was: {repr(self._arg_override[okey])})") - - if oval is not None: - self._arg_override[okey] = override2str(okey=okey, oval=oval) - - argval = ','.join(sorted(self._arg_override.values())) - elif val is None: - argval = None - elif isinstance(val, str): - argval = val.strip() - elif isinstance(val, numbers.Number): - argval = str(val) - else: - raise ModelicaSystemError(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") - - if key in self._args: - logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " - f"(was: {repr(self._args[key])})") - self._args[key] = argval - - def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: - """ - Return the value for the given key - """ - if key in self._args: - return self._args[key] - - return None - - def args_set( - self, - args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], - ) -> None: - """ - Define arguments for the model executable. - """ - for arg in args: - self.arg_set(key=arg, val=args[arg]) - - def get_cmd_args(self) -> list[str]: - """ - Get a list with the command arguments for the model executable. - """ - - cmdl = [] - for key in sorted(self._args): - if self._args[key] is None: - cmdl.append(f"-{key}") - else: - cmdl.append(f"-{key}={self._args[key]}") - - return cmdl - - def definition(self) -> OMCSessionRunData: - """ - Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. - """ - # ensure that a result filename is provided - result_file = self.arg_get('r') - if not isinstance(result_file, str): - result_file = (self._runpath / f"{self._model_name}.mat").as_posix() - - omc_run_data = OMCSessionRunData( - cmd_path=self._runpath.as_posix(), - cmd_model_name=self._model_name, - cmd_args=self.get_cmd_args(), - cmd_result_path=result_file, - ) - - omc_run_data_updated = self._session.omc_run_data_update( - omc_run_data=omc_run_data, - ) - - return omc_run_data_updated - - @staticmethod - def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: - """ - Parse a simflag definition; this is deprecated! - - The return data can be used as input for self.args_set(). - """ - warnings.warn("The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", DeprecationWarning, stacklevel=2) - - simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} - - args = [s for s in simflags.split(' ') if s] - for arg in args: - if arg[0] != '-': - raise ModelicaSystemError(f"Invalid simulation flag: {arg}") - arg = arg[1:] - parts = arg.split('=') - if len(parts) == 1: - simargs[parts[0]] = None - elif parts[0] == 'override': - override = '='.join(parts[1:]) - - override_dict = {} - for item in override.split(','): - kv = item.split('=') - if not 0 < len(kv) < 3: - raise ModelicaSystemError(f"Invalid value for '-override': {override}") - if kv[0]: - try: - override_dict[kv[0]] = kv[1] - except (KeyError, IndexError) as ex: - raise ModelicaSystemError(f"Invalid value for '-override': {override}") from ex - - simargs[parts[0]] = override_dict - - return simargs - - -class ModelicaSystem: +class ModelicaSystem(ModelicaSystemBase): """ Class to simulate a Modelica model using OpenModelica via OMCSession. """ @@ -321,37 +52,14 @@ def __init__( unspecified, a new local session will be created. """ - self._quantities: list[dict[str, Any]] = [] - self._params: dict[str, str] = {} # even numerical values are stored as str - self._inputs: dict[str, list[tuple[float, float]]] = {} - # _outputs values are str before simulate(), but they can be - # np.float64 after simulate(). - self._outputs: dict[str, Any] = {} - # same for _continuous - self._continuous: dict[str, Any] = {} - self._simulate_options: dict[str, str] = {} - self._override_variables: dict[str, str] = {} - self._simulate_options_override: dict[str, str] = {} - self._linearization_options: dict[str, str | float] = { - 'startTime': 0.0, - 'stopTime': 1.0, - 'stepSize': 0.002, - 'tolerance': 1e-8, - } - self._optimization_options = self._linearization_options | { - 'numberOfIntervals': 500, - } - self._linearized_inputs: list[str] = [] # linearization input list - self._linearized_outputs: list[str] = [] # linearization output list - self._linearized_states: list[str] = [] # linearization states list - - if session is not None: - self._session = session - else: - self._session = OMCSessionLocal(omhome=omhome) + if session is None: + session = OMCSessionLocal(omhome=omhome) + + super().__init__( + session=session, + work_directory=work_directory, + ) - # get OpenModelica version - self._version = self._session.sendExpression("getVersion()", parsed=True) # set commandLineOptions using default values or the user defined list if command_line_options is None: # set default command line options to improve the performance of linearization and to avoid recompilation if @@ -363,16 +71,6 @@ def __init__( for opt in command_line_options: self.set_command_line_options(command_line_option=opt) - self._simulated = False # True if the model has already been simulated - self._result_file: Optional[OMCPath] = None # for storing result file - - self._work_dir: OMCPath = self.setWorkDirectory(work_directory) - - self._model_name: Optional[str] = None - self._libraries: Optional[list[str | tuple[str, str]]] = None - self._file_name: Optional[OMCPath] = None - self._variable_filter: Optional[str] = None - def model( self, model_name: Optional[str] = None, @@ -456,12 +154,6 @@ def model( if build: self.buildModel(variable_filter) - def get_session(self) -> OMCSession: - """ - Return the OMC session used for this class. - """ - return self._session - def set_command_line_options(self, command_line_option: str): """ Set the provided command line option via OMC setCommandLineOptions(). @@ -497,35 +189,6 @@ def _loadLibrary(self, libraries: list): '1)["Modelica"]\n' '2)[("Modelica","3.2.3"), "PowerSystems"]\n') - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath: - """ - Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this - directory. If no directory is defined a unique temporary directory is created. - """ - if work_directory is not None: - workdir = self._session.omcpath(work_directory).absolute() - if not workdir.is_dir(): - raise IOError(f"Provided work directory does not exists: {work_directory}!") - else: - workdir = self._session.omcpath_tempdir().absolute() - if not workdir.is_dir(): - raise IOError(f"{workdir} could not be created") - - logger.info("Define work dir as %s", workdir) - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) - - # set the class variable _work_dir ... - self._work_dir = workdir - # ... and also return the defined path - return workdir - - def getWorkDirectory(self) -> OMCPath: - """ - Return the defined working directory for this ModelicaSystem / OpenModelica session. - """ - return self._work_dir - def buildModel(self, variableFilter: Optional[str] = None): filter_def: Optional[str] = None if variableFilter is not None: @@ -541,25 +204,14 @@ def buildModel(self, variableFilter: Optional[str] = None): build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) logger.debug("OM model build result: %s", build_model_result) - # check if the executable exists ... - om_cmd = ModelicaSystemCmd( - session=self._session, - runpath=self.getWorkDirectory(), - modelname=self._model_name, - ) - # ... by running it - output help for command help - om_cmd.arg_set(key="help", val="help") - cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) - if returncode != 0: - raise ModelicaSystemError("Model executable not working!") + self.check_model_executable() xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] self._xmlparse(xml_file=xml_file) def sendExpression(self, expr: str, parsed: bool = True) -> Any: try: - retval = self._session.sendExpression(expr, parsed) + retval = self._session.sendExpression(command=expr, parsed=parsed) except OMCSessionException as ex: raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex @@ -586,122 +238,69 @@ def _requestApi( return self.sendExpression(exp) - def _xmlparse(self, xml_file: OMCPath): - if not xml_file.is_file(): - raise ModelicaSystemError(f"XML file not generated: {xml_file}") - - xml_content = xml_file.read_text() - tree = ET.ElementTree(ET.fromstring(xml_content)) - root = tree.getroot() - for attr in root.iter('DefaultExperiment'): - for key in ("startTime", "stopTime", "stepSize", "tolerance", - "solver", "outputFormat"): - self._simulate_options[key] = str(attr.get(key)) - - for sv in root.iter('ScalarVariable'): - translations = { - "alias": "alias", - "aliasvariable": "aliasVariable", - "causality": "causality", - "changeable": "isValueChangeable", - "description": "description", - "name": "name", - "variability": "variability", - } - - scalar: dict[str, Any] = {} - for key_dst, key_src in translations.items(): - val = sv.get(key_src) - scalar[key_dst] = None if val is None else str(val) - - ch = list(sv) - for att in ch: - scalar["start"] = att.get('start') - scalar["min"] = att.get('min') - scalar["max"] = att.get('max') - scalar["unit"] = att.get('unit') - - # save parameters in the corresponding class variables - if scalar["variability"] == "parameter": - if scalar["name"] in self._override_variables: - self._params[scalar["name"]] = self._override_variables[scalar["name"]] - else: - self._params[scalar["name"]] = scalar["start"] - if scalar["variability"] == "continuous": - self._continuous[scalar["name"]] = scalar["start"] - if scalar["causality"] == "input": - self._inputs[scalar["name"]] = scalar["start"] - if scalar["causality"] == "output": - self._outputs[scalar["name"]] = scalar["start"] + def getContinuousFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (final) values of continuous signals (at stopTime). - self._quantities.append(scalar) + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. - def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: + Examples: + >>> mod.getContinuousFinal() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuousFinal("x") + [np.float64(0.68)] + >>> mod.getContinuousFinal(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] """ - This method returns list of dictionaries. It displays details of - quantities such as name, value, changeable, and description. + if not self._simulated: + raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") - Examples: - >>> mod.getQuantities() - [ - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'local', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'x', - 'start': '1.0', - 'unit': None, - 'variability': 'continuous', - }, - { - 'name': 'der(x)', - # ... - }, - # ... - ] + def get_continuous_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._continuous: + value = self.getSolutions(name) + self._continuous[name] = np.float64(value[0][-1]) + else: + raise ModelicaSystemError(f"{names} is not continuous") - >>> getQuantities("y") - [{ - 'name': 'y', # ... - }] - - >>> getQuantities(["y","x"]) - [ - { - 'name': 'y', # ... - }, - { - 'name': 'x', # ... - } - ] - """ if names is None: - return self._quantities + get_continuous_solution(name_list=list(self._continuous.keys())) + return self._continuous if isinstance(names, str): - r = [x for x in self._quantities if x["name"] == names] - if r == []: - raise KeyError(names) - return r + get_continuous_solution(name_list=[names]) + return [self._continuous[names]] if isinstance(names, list): - return [x for y in names for x in self._quantities if x["name"] == y] + get_continuous_solution(name_list=names) + values = [] + for name in names: + values.append(self._continuous[name]) + return values - raise ModelicaSystemError("Unhandled input for getQuantities()") + raise ModelicaSystemError("Unhandled input for getContinousFinal()") def getContinuous( self, names: Optional[str | list[str]] = None, - ) -> dict[str, str | numbers.Real] | list[str | numbers.Real]: + ) -> dict[str, np.float64] | list[np.float64]: """Get values of continuous signals. - If called before simulate(), the initial values are returned as - strings (or None). If called after simulate(), the final values (at - stopTime) are returned as numpy.float64. + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. Args: names: Either None (default), a string with the continuous signal @@ -732,122 +331,72 @@ def getContinuous( [np.float64(-0.24), np.float64(0.68)] """ if not self._simulated: - if names is None: - return self._continuous - if isinstance(names, str): - return [self._continuous[names]] - if isinstance(names, list): - return [self._continuous[x] for x in names] - - if names is None: - for name in self._continuous: - try: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - except (OMCSessionException, ModelicaSystemError) as ex: - raise ModelicaSystemError(f"{name} could not be computed") from ex - return self._continuous + return self.getContinuousInitial(names=names) - if isinstance(names, str): - if names in self._continuous: - value = self.getSolutions(names) - self._continuous[names] = value[0][-1] - return [self._continuous[names]] - raise ModelicaSystemError(f"{names} is not continuous") + return self.getContinuousFinal(names=names) - if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._continuous: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise ModelicaSystemError(f"{name} is not continuous") - return valuelist - - raise ModelicaSystemError("Unhandled input for getContinous()") - - def getParameters( + def getOutputsFinal( self, names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get parameter values. + ) -> dict[str, np.float64] | list[np.float64]: + """Get (final) values of output signals (at stopTime). Args: - names: Either None (default), a string with the parameter name, - or a list of parameter name strings. + names: Either None (default), a string with the output name, + or a list of output name strings. Returns: If `names` is None, a dict in the format - {parameter_name: parameter_value} is returned. - If `names` is a string, a single element list is returned. - If `names` is a list, a list with one value for each parameter name - in names is returned. - In all cases, parameter values are returned as strings. + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. Examples: - >>> mod.getParameters() - {'Name1': '1.23', 'Name2': '4.56'} - >>> mod.getParameters("Name1") - ['1.23'] - >>> mod.getParameters(["Name1","Name2"]) - ['1.23', '4.56'] + >>> mod.getOutputsFinal() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputsFinal("out1") + [np.float64(-0.1234)] + >>> mod.getOutputsFinal(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] """ - if names is None: - return self._params - if isinstance(names, str): - return [self._params[names]] - if isinstance(names, list): - return [self._params[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getParameters()") - - def getInputs( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, list[tuple[float, float]]] | list[list[tuple[float, float]]]: - """Get values of input signals. + if not self._simulated: + raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") - Args: - names: Either None (default), a string with the input name, - or a list of input name strings. - Returns: - If `names` is None, a dict in the format - {input_name: input_value} is returned. - If `names` is a string, a single element list [input_value] is - returned. - If `names` is a list, a list with one value for each input name - in names is returned: [input1_values, input2_values, ...]. - In all cases, input values are returned as a list of tuples, - where the first element in the tuple is the time and the second - element is the input value. + def get_outputs_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._outputs: + value = self.getSolutions(name) + self._outputs[name] = np.float64(value[0][-1]) + else: + raise ModelicaSystemError(f"{names} is not a valid output") - Examples: - >>> mod.getInputs() - {'Name1': [(0.0, 0.0), (1.0, 1.0)], 'Name2': None} - >>> mod.getInputs("Name1") - [[(0.0, 0.0), (1.0, 1.0)]] - >>> mod.getInputs(["Name1","Name2"]) - [[(0.0, 0.0), (1.0, 1.0)], None] - """ if names is None: - return self._inputs + get_outputs_solution(name_list=list(self._outputs.keys())) + return self._outputs + if isinstance(names, str): - return [self._inputs[names]] + get_outputs_solution(name_list=[names]) + return [self._outputs[names]] + if isinstance(names, list): - return [self._inputs[x] for x in names] + get_outputs_solution(name_list=names) + values = [] + for name in names: + values.append(self._outputs[name]) + return values - raise ModelicaSystemError("Unhandled input for getInputs()") + raise ModelicaSystemError("Unhandled input for getOutputs()") def getOutputs( self, names: Optional[str | list[str]] = None, - ) -> dict[str, str | numbers.Real] | list[str | numbers.Real]: + ) -> dict[str, np.float64] | list[np.float64]: """Get values of output signals. - If called before simulate(), the initial values are returned as - strings. If called after simulate(), the final values (at stopTime) - are returned as numpy.float64. + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. Args: names: Either None (default), a string with the output name, @@ -878,307 +427,9 @@ def getOutputs( [np.float64(-0.1234), np.float64(2.1)] """ if not self._simulated: - if names is None: - return self._outputs - if isinstance(names, str): - return [self._outputs[names]] - return [self._outputs[x] for x in names] - - if names is None: - for name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] - return self._outputs - - if isinstance(names, str): - if names in self._outputs: - value = self.getSolutions(names) - self._outputs[names] = value[0][-1] - return [self._outputs[names]] - raise KeyError(names) - - if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise KeyError(name) - return valuelist - - raise ModelicaSystemError("Unhandled input for getOutputs()") - - def getSimulationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get simulation options such as stopTime and tolerance. - - Args: - names: Either None (default), a string with the simulation option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - Option values are always returned as strings. - - Examples: - >>> mod.getSimulationOptions() - {'startTime': '0', 'stopTime': '1.234', - 'stepSize': '0.002', 'tolerance': '1.1e-08', 'solver': 'dassl', 'outputFormat': 'mat'} - >>> mod.getSimulationOptions("stopTime") - ['1.234'] - >>> mod.getSimulationOptions(["tolerance", "stopTime"]) - ['1.1e-08', '1.234'] - """ - if names is None: - return self._simulate_options - if isinstance(names, str): - return [self._simulate_options[names]] - if isinstance(names, list): - return [self._simulate_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getSimulationOptions()") - - def getLinearizationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str | float] | list[str | float]: - """Get simulation options used for linearization. - - Args: - names: Either None (default), a string with the linearization option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - Some option values are returned as float when first initialized, - but always as strings after setLinearizationOptions is used to - change them. - - Examples: - >>> mod.getLinearizationOptions() - {'startTime': 0.0, 'stopTime': 1.0, 'stepSize': 0.002, 'tolerance': 1e-08} - >>> mod.getLinearizationOptions("stopTime") - [1.0] - >>> mod.getLinearizationOptions(["tolerance", "stopTime"]) - [1e-08, 1.0] - """ - if names is None: - return self._linearization_options - if isinstance(names, str): - return [self._linearization_options[names]] - if isinstance(names, list): - return [self._linearization_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getLinearizationOptions()") - - def getOptimizationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str | float] | list[str | float]: - """Get simulation options used for optimization. - - Args: - names: Either None (default), a string with the optimization option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - Some option values are returned as float when first initialized, - but always as strings after setOptimizationOptions is used to - change them. - - Examples: - >>> mod.getOptimizationOptions() - {'startTime': 0.0, 'stopTime': 1.0, 'numberOfIntervals': 500, 'stepSize': 0.002, 'tolerance': 1e-08} - >>> mod.getOptimizationOptions("stopTime") - [1.0] - >>> mod.getOptimizationOptions(["tolerance", "stopTime"]) - [1e-08, 1.0] - """ - if names is None: - return self._optimization_options - if isinstance(names, str): - return [self._optimization_options[names]] - if isinstance(names, list): - return [self._optimization_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") - - def parse_om_version(self, version: str) -> tuple[int, int, int]: - match = re.search(r"v?(\d+)\.(\d+)\.(\d+)", version) - if not match: - raise ValueError(f"Version not found in: {version}") - major, minor, patch = map(int, match.groups()) - return major, minor, patch + return self.getOutputsInitial(names=names) - def simulate_cmd( - self, - result_file: OMCPath, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelicaSystemCmd: - """ - This method prepares the simulates model according to the simulation options. It returns an instance of - ModelicaSystemCmd which can be used to run the simulation. - - Due to the tempdir being unique for the ModelicaSystem instance, *NEVER* use this to create several simulations - with the same instance of ModelicaSystem! Restart each simulation process with a new instance of ModelicaSystem. - - However, if only non-structural parameters are used, it is possible to reuse an existing instance of - ModelicaSystem to create several version ModelicaSystemCmd to run the model using different settings. - - Parameters - ---------- - result_file - simflags - simargs - - Returns - ------- - An instance if ModelicaSystemCmd to run the requested simulation. - """ - - om_cmd = ModelicaSystemCmd( - session=self._session, - runpath=self.getWorkDirectory(), - modelname=self._model_name, - ) - - # always define the result file to use - om_cmd.arg_set(key="r", val=result_file.as_posix()) - - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - - if simargs: - om_cmd.args_set(args=simargs) - - if self._override_variables or self._simulate_options_override: - override_file = result_file.parent / f"{result_file.stem}_override.txt" - - # simulation options are not read from override file from version >= 1.26.0, - # pass them to simulation executable directly as individual arguments - # see https://github.com/OpenModelica/OpenModelica/pull/14813 - major, minor, patch = self.parse_om_version(self._version) - if (major, minor, patch) >= (1, 26, 0): - for key, opt_value in self._simulate_options_override.items(): - om_cmd.arg_set(key=key, val=str(opt_value)) - override_content = ( - "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) - + "\n" - ) - else: - override_content = ( - "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) - + "\n".join([f"{key}={value}" for key, value in self._simulate_options_override.items()]) - + "\n" - ) - - override_file.write_text(override_content) - om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) - - if self._inputs: # if model has input quantities - for key, val in self._inputs.items(): - if val is None: - val = [(float(self._simulate_options["startTime"]), 0.0), - (float(self._simulate_options["stopTime"]), 0.0)] - self._inputs[key] = val - if float(self._simulate_options["startTime"]) != val[0][0]: - raise ModelicaSystemError(f"startTime not matched for Input {key}!") - if float(self._simulate_options["stopTime"]) != val[-1][0]: - raise ModelicaSystemError(f"stopTime not matched for Input {key}!") - - # csvfile is based on name used for result file - csvfile = result_file.parent / f"{result_file.stem}.csv" - # write csv file and store the name - csvfile = self._createCSVData(csvfile=csvfile) - - om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) - - return om_cmd - - def simulate( - self, - resultfile: Optional[str | os.PathLike] = None, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> None: - """Simulate the model according to simulation options. - - See setSimulationOptions(). - - Args: - resultfile: Path to a custom result file - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. - simargs: Dict with simulation runtime flags. - - Examples: - mod.simulate() - mod.simulate(resultfile="a.mat") - # set runtime simulation flags, deprecated - mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10") - # using simargs - mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}}) - """ - - if resultfile is None: - # default result file generated by OM - self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" - elif isinstance(resultfile, OMCPath): - self._result_file = resultfile - else: - self._result_file = self._session.omcpath(resultfile) - if not self._result_file.is_absolute(): - self._result_file = self.getWorkDirectory() / resultfile - - if not isinstance(self._result_file, OMCPath): - raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") - - om_cmd = self.simulate_cmd( - result_file=self._result_file, - simflags=simflags, - simargs=simargs, - ) - - # delete resultfile ... - if self._result_file.is_file(): - self._result_file.unlink() - # ... run simulation ... - cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) - # and check returncode *AND* resultfile - if returncode != 0 and self._result_file.is_file(): - # check for an empty (=> 0B) result file which indicates a crash of the model executable - # see: https://github.com/OpenModelica/OMPython/issues/261 - # https://github.com/OpenModelica/OpenModelica/issues/13829 - if self._result_file.size() == 0: - self._result_file.unlink() - raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!") - - logger.warning(f"Return code = {returncode} but result file exists!") - - self._simulated = True + return self.getOutputsFinal(names=names) def plot( self, @@ -1281,358 +532,6 @@ def getSolutions( self.sendExpression("closeSimulationResultFile()") return np_res - @staticmethod - def _prepare_input_data( - input_args: Any, - input_kwargs: dict[str, Any], - ) -> dict[str, str]: - """ - Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. - """ - - def prepare_str(str_in: str) -> dict[str, str]: - str_in = str_in.replace(" ", "") - key_val_list: list[str] = str_in.split("=") - if len(key_val_list) != 2: - raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - - input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - - return input_data_from_str - - input_data: dict[str, str] = {} - - for input_arg in input_args: - if isinstance(input_arg, str): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - input_data = input_data | prepare_str(input_arg) - elif isinstance(input_arg, list): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - - for item in input_arg: - if not isinstance(item, str): - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") - input_data = input_data | prepare_str(item) - elif isinstance(input_arg, dict): - input_data = input_data | input_arg - else: - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") - - if len(input_kwargs): - for key, val in input_kwargs.items(): - # ensure all values are strings to align it on one type: dict[str, str] - if not isinstance(val, str): - # spaces have to be removed as setInput() could take list of tuples as input and spaces would - # result in an error on recreating the input data - str_val = str(val).replace(' ', '') - else: - str_val = val - if ' ' in key or ' ' in str_val: - raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") - input_data[key] = str_val - - return input_data - - def _set_method_helper( - self, - inputdata: dict[str, str], - classdata: dict[str, Any], - datatype: str, - overridedata: Optional[dict[str, str]] = None, - ) -> bool: - """ - Helper function for: - * setParameter() - * setContinuous() - * setSimulationOptions() - * setLinearizationOption() - * setOptimizationOption() - * setInputs() - - Parameters - ---------- - inputdata - string or list of string given by user - classdata - dict() containing the values of different variables (eg: parameter, continuous, simulation parameters) - datatype - type identifier (eg; continuous, parameter, simulation, linearization, optimization) - overridedata - dict() which stores the new override variables list, - """ - - for key, val in inputdata.items(): - if key not in classdata: - raise ModelicaSystemError(f"Invalid variable for type {repr(datatype)}: {repr(key)}") - - if datatype == "parameter" and not self.isParameterChangeable(key): - raise ModelicaSystemError(f"It is not possible to set the parameter {repr(key)}. It seems to be " - "structural, final, protected, evaluated or has a non-constant binding. " - "Use sendExpression(...) and rebuild the model using buildModel() API; " - "command to set the parameter before rebuilding the model: " - "sendExpression(\"setParameterValue(" - f"{self._model_name}, {key}, {val if val is not None else ''}" - ")\").") - - classdata[key] = val - if overridedata is not None: - overridedata[key] = val - - return True - - def isParameterChangeable( - self, - name: str, - ) -> bool: - """ - Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to - recompile the model). - """ - q = self.getQuantities(name) - if q[0]["changeable"] == "false": - return False - return True - - def setContinuous( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set continuous values. It can be called: - with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: - usage - >>> setContinuous("Name=value") # depreciated - >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated - - >>> setContinuous(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setContinuous(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._continuous, - datatype="continuous", - overridedata=self._override_variables) - - def setParameters( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set parameter values. It can be called: - with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: - usage - >>> setParameters("Name=value") # depreciated - >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated - - >>> setParameters(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setParameters(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._params, - datatype="parameter", - overridedata=self._override_variables) - - def setSimulationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set simulation options. It can be called: - with a sequence of simulation options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setSimulationOptions("Name=value") # depreciated - >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setSimulationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setSimulationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._simulate_options, - datatype="simulation-option", - overridedata=self._simulate_options_override) - - def setLinearizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set linearization options. It can be called: - with a sequence of linearization options name and assigning corresponding value as arguments as show in the - example below - usage - >>> setLinearizationOptions("Name=value") # depreciated - >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setLinearizationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setLinearizationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._linearization_options, - datatype="Linearization-option", - overridedata=None) - - def setOptimizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set optimization options. It can be called: - with a sequence of optimization options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setOptimizationOptions("Name=value") # depreciated - >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setOptimizationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setOptimizationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._optimization_options, - datatype="optimization-option", - overridedata=None) - - def setInputs( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set input values. It can be called with a sequence of input name and assigning - corresponding values as arguments as show in the example below. Compared to other set*() methods this is a - special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() - and restored here via ast.literal_eval(). - - >>> setInputs("Name=value") # depreciated - >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated - - >>> setInputs(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setInputs(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - for key, val in inputdata.items(): - if key not in self._inputs: - raise ModelicaSystemError(f"{key} is not an input") - - if not isinstance(val, str): - raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") - - val_evaluated = ast.literal_eval(val) - - if isinstance(val_evaluated, (int, float)): - self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), - (float(self._simulate_options["stopTime"]), float(val))] - elif isinstance(val_evaluated, list): - if not all([isinstance(item, tuple) for item in val_evaluated]): - raise ModelicaSystemError("Value for setInput() must be in tuple format; " - f"got {repr(val_evaluated)}") - if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): - raise ModelicaSystemError("Time value should be in increasing order; " - f"got {repr(val_evaluated)}") - - for item in val_evaluated: - if item[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " - "than the simulation start time") - if len(item) != 2: - raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " - "is in incorrect format!") - - self._inputs[key] = val_evaluated - else: - raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") - - return True - - def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: - """ - Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, - this file is used; else a generic file name is created. - """ - start_time: float = float(self._simulate_options["startTime"]) - stop_time: float = float(self._simulate_options["stopTime"]) - - # Replace None inputs with a default constant zero signal - inputs: dict[str, list[tuple[float, float]]] = {} - for input_name, input_signal in self._inputs.items(): - if input_signal is None: - inputs[input_name] = [(start_time, 0.0), (stop_time, 0.0)] - else: - inputs[input_name] = input_signal - - # Collect all unique timestamps across all input signals - all_times = np.array( - sorted({t for signal in inputs.values() for t, _ in signal}), - dtype=float - ) - - # Interpolate missing values - interpolated_inputs: dict[str, np.ndarray] = {} - for signal_name, signal_values in inputs.items(): - signal = np.array(signal_values) - interpolated_inputs[signal_name] = np.interp( - all_times, - signal[:, 0], # times - signal[:, 1], # values - ) - - # Write CSV file - input_names = list(interpolated_inputs.keys()) - header = ['time'] + input_names + ['end'] - - csv_rows = [header] - for i, t in enumerate(all_times): - row = [ - t, # time - *(interpolated_inputs[name][i] for name in input_names), # input values - 0, # trailing 'end' column - ] - csv_rows.append(row) - - if csvfile is None: - csvfile = self.getWorkDirectory() / f'{self._model_name}.csv' - - # basic definition of a CSV file using csv_rows as input - csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n" - - csvfile.write_text(csv_content) - - return csvfile - def convertMo2Fmu( self, version: str = "2.0", @@ -1736,547 +635,3 @@ def optimize(self) -> dict[str, Any]: properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) self.set_command_line_options("-g=Optimica") return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) - - def linearize( - self, - lintime: Optional[float] = None, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> LinearizationResult: - """Linearize the model according to linearization options. - - See setLinearizationOptions. - - Args: - lintime: Override "stopTime" value. - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. - simargs: A dict with command line flags and possible options; example: "simargs={'csvInput': 'a.csv'}" - - Returns: - A LinearizationResult object is returned. This allows several - uses: - * `(A, B, C, D) = linearize()` to get just the matrices, - * `result = linearize(); result.A` to get everything and access the - attributes one by one, - * `result = linearize(); A = result[0]` mostly just for backwards - compatibility, because linearize() used to return `[A, B, C, D]`. - """ - if len(self._quantities) == 0: - # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() - raise ModelicaSystemError( - "Linearization cannot be performed as the model is not build, " - "use ModelicaSystem() to build the model first" - ) - - om_cmd = ModelicaSystemCmd( - session=self._session, - runpath=self.getWorkDirectory(), - modelname=self._model_name, - ) - - # See comment in simulate_cmd regarding override file and OM version - major, minor, patch = self.parse_om_version(self._version) - if (major, minor, patch) >= (1, 26, 0): - for key, opt_value in self._linearization_options.items(): - om_cmd.arg_set(key=key, val=str(opt_value)) - override_content = ( - "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) - + "\n" - ) - else: - override_content = ( - "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) - + "\n".join([f"{key}={value}" for key, value in self._linearization_options.items()]) - + "\n" - ) - - override_file = self.getWorkDirectory() / f'{self._model_name}_override_linear.txt' - override_file.write_text(override_content) - - om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) - - if self._inputs: - for key, data in self._inputs.items(): - if data is not None: - for value in data: - if value[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError('Input time value is less than simulation startTime') - csvfile = self._createCSVData() - om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) - - om_cmd.arg_set(key="l", val=str(lintime or self._linearization_options["stopTime"])) - - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - - if simargs: - om_cmd.args_set(args=simargs) - - # the file create by the model executable which contains the matrix and linear inputs, outputs and states - linear_file = self.getWorkDirectory() / "linearized_model.py" - linear_file.unlink(missing_ok=True) - - cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) - if returncode != 0: - raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") - if not linear_file.is_file(): - raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") - - self._simulated = True - - # extract data from the python file with the linearized model using the ast module - this allows to get the - # needed information without executing the created code - linear_data = {} - linear_file_content = linear_file.read_text() - try: - # ignore possible typing errors below (mypy) - these are caught by the try .. except .. block - linear_file_ast = ast.parse(linear_file_content) - for body_part in linear_file_ast.body[0].body: # type: ignore - if not isinstance(body_part, ast.Assign): - continue - - target = body_part.targets[0].id # type: ignore - value_ast = ast.literal_eval(body_part.value) - - linear_data[target] = value_ast - except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: - raise ModelicaSystemError(f"Error parsing linearization file {linear_file}!") from ex - - # remove the file - linear_file.unlink() - - self._linearized_inputs = linear_data["inputVars"] - self._linearized_outputs = linear_data["outputVars"] - self._linearized_states = linear_data["stateVars"] - - return LinearizationResult( - n=linear_data["n"], - m=linear_data["m"], - p=linear_data["p"], - x0=linear_data["x0"], - u0=linear_data["u0"], - A=linear_data["A"], - B=linear_data["B"], - C=linear_data["C"], - D=linear_data["D"], - stateVars=linear_data["stateVars"], - inputVars=linear_data["inputVars"], - outputVars=linear_data["outputVars"], - ) - - def getLinearInputs(self) -> list[str]: - """Get names of input variables of the linearized model.""" - return self._linearized_inputs - - def getLinearOutputs(self) -> list[str]: - """Get names of output variables of the linearized model.""" - return self._linearized_outputs - - def getLinearStates(self) -> list[str]: - """Get names of state variables of the linearized model.""" - return self._linearized_states - - -class ModelicaSystemDoE: - """ - Class to run DoEs based on a (Open)Modelica model using ModelicaSystem - - Example - ------- - ``` - import OMPython - import pathlib - - - def run_doe(): - mypath = pathlib.Path('.') - - model = mypath / "M.mo" - model.write_text( - " model M\n" - " parameter Integer p=1;\n" - " parameter Integer q=1;\n" - " parameter Real a = -1;\n" - " parameter Real b = -1;\n" - " Real x[p];\n" - " Real y[q];\n" - " equation\n" - " der(x) = a * fill(1.0, p);\n" - " der(y) = b * fill(1.0, q);\n" - " end M;\n" - ) - - param = { - # structural - 'p': [1, 2], - 'q': [3, 4], - # non-structural - 'a': [5, 6], - 'b': [7, 8], - } - - resdir = mypath / 'DoE' - resdir.mkdir(exist_ok=True) - - doe_mod = OMPython.ModelicaSystemDoE( - model_name="M", - model_file=model.as_posix(), - parameters=param, - resultpath=resdir, - simargs={"override": {'stopTime': 1.0}}, - ) - doe_mod.prepare() - doe_def = doe_mod.get_doe_definition() - doe_mod.simulate() - doe_sol = doe_mod.get_doe_solutions() - - # ... work with doe_def and doe_sol ... - - - if __name__ == "__main__": - run_doe() - ``` - - """ - - # Dictionary keys used in simulation dict (see _sim_dict or get_doe()). These dict keys contain a space and, thus, - # cannot be used as OM variable identifiers. They are defined here as reference for any evaluation of the data. - DICT_ID_STRUCTURE: str = 'ID structure' - DICT_ID_NON_STRUCTURE: str = 'ID non-structure' - DICT_RESULT_AVAILABLE: str = 'result available' - - def __init__( - self, - # data to be used for ModelicaSystem - model_file: Optional[str | os.PathLike] = None, - model_name: Optional[str] = None, - libraries: Optional[list[str | tuple[str, str]]] = None, - command_line_options: Optional[list[str]] = None, - variable_filter: Optional[str] = None, - work_directory: Optional[str | os.PathLike] = None, - omhome: Optional[str] = None, - session: Optional[OMCSession] = None, - # simulation specific input - # TODO: add more settings (simulation options, input options, ...) - simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, - # DoE specific inputs - resultpath: Optional[str | os.PathLike] = None, - parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, - ) -> None: - """ - Initialisation of ModelicaSystemDoE. The parameters are based on: ModelicaSystem.__init__() and - ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as - a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. - """ - if model_name is None: - raise ModelicaSystemError("No model name provided!") - - self._mod = ModelicaSystem( - command_line_options=command_line_options, - work_directory=work_directory, - omhome=omhome, - session=session, - ) - self._mod.model( - model_file=model_file, - model_name=model_name, - libraries=libraries, - variable_filter=variable_filter, - ) - - self._model_name = model_name - - self._simargs = simargs - - if resultpath is None: - self._resultpath = self.get_session().omcpath_tempdir() - else: - self._resultpath = self.get_session().omcpath(resultpath) - if not self._resultpath.is_dir(): - raise ModelicaSystemError("Argument resultpath must be set to a valid path within the environment used " - f"for the OpenModelica session: {resultpath}!") - - if isinstance(parameters, dict): - self._parameters = parameters - else: - self._parameters = {} - - self._doe_def: Optional[dict[str, dict[str, Any]]] = None - self._doe_cmd: Optional[dict[str, OMCSessionRunData]] = None - - def get_session(self) -> OMCSession: - """ - Return the OMC session used for this class. - """ - return self._mod.get_session() - - def prepare(self) -> int: - """ - Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of - ModelicaSystem while the non-structural parameters can just be set on the executable. - - The return value is the number of simulation defined. - """ - - doe_sim = {} - doe_def = {} - - param_structure = {} - param_non_structure = {} - for param_name in self._parameters.keys(): - changeable = self._mod.isParameterChangeable(name=param_name) - logger.info(f"Parameter {repr(param_name)} is changeable? {changeable}") - - if changeable: - param_non_structure[param_name] = self._parameters[param_name] - else: - param_structure[param_name] = self._parameters[param_name] - - param_structure_combinations = list(itertools.product(*param_structure.values())) - param_non_structural_combinations = list(itertools.product(*param_non_structure.values())) - - for idx_pc_structure, pc_structure in enumerate(param_structure_combinations): - - build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" - build_dir.mkdir() - self._mod.setWorkDirectory(work_directory=build_dir) - - sim_param_structure = {} - for idx_structure, pk_structure in enumerate(param_structure.keys()): - sim_param_structure[pk_structure] = pc_structure[idx_structure] - - pk_value = pc_structure[idx_structure] - if isinstance(pk_value, str): - pk_value_str = self.get_session().escape_str(pk_value) - expression = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" - elif isinstance(pk_value, bool): - pk_value_bool_str = "true" if pk_value else "false" - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" - else: - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expression) - if not res: - raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " - f"to {pk_value} using {repr(expression)}") - - self._mod.buildModel() - - for idx_non_structural, pk_non_structural in enumerate(param_non_structural_combinations): - sim_param_non_structural = {} - for idx, pk in enumerate(param_non_structure.keys()): - sim_param_non_structural[pk] = cast(Any, pk_non_structural[idx]) - - resfilename = f"DOE_{idx_pc_structure:09d}_{idx_non_structural:09d}.mat" - logger.info(f"use result file {repr(resfilename)} " - f"for structural parameters: {sim_param_structure} " - f"and non-structural parameters: {sim_param_non_structural}") - resultfile = self._resultpath / resfilename - - df_data = ( - { - self.DICT_ID_STRUCTURE: idx_pc_structure, - } - | sim_param_structure - | { - self.DICT_ID_NON_STRUCTURE: idx_non_structural, - } - | sim_param_non_structural - | { - self.DICT_RESULT_AVAILABLE: False, - } - ) - - self._mod.setParameters(sim_param_non_structural) - mscmd = self._mod.simulate_cmd( - result_file=resultfile, - ) - if self._simargs is not None: - mscmd.args_set(args=self._simargs) - cmd_definition = mscmd.definition() - del mscmd - - doe_sim[resfilename] = cmd_definition - doe_def[resfilename] = df_data - - logger.info(f"Prepared {len(doe_sim)} simulation definitions for the defined DoE.") - self._doe_cmd = doe_sim - self._doe_def = doe_def - - return len(doe_sim) - - def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: - """ - Get the defined DoE as a dict, where each key is the result filename and the value is a dict of simulation - settings including structural and non-structural parameters. - - The following code snippet can be used to convert the data to a pandas dataframe: - - ``` - import pandas as pd - - doe_dict = doe_mod.get_doe_definition() - doe_df = pd.DataFrame.from_dict(data=doe_dict, orient='index') - ``` - - """ - return self._doe_def - - def get_doe_command(self) -> Optional[dict[str, OMCSessionRunData]]: - """ - Get the definitions of simulations commands to run for this DoE. - """ - return self._doe_cmd - - def simulate( - self, - num_workers: int = 3, - ) -> bool: - """ - Simulate the DoE using the defined number of workers. - - Returns True if all simulations were done successfully, else False. - """ - - if self._doe_cmd is None or self._doe_def is None: - raise ModelicaSystemError("DoE preparation missing - call prepare() first!") - - doe_cmd_total = len(self._doe_cmd) - doe_def_total = len(self._doe_def) - - if doe_cmd_total != doe_def_total: - raise ModelicaSystemError(f"Mismatch between number simulation commands ({doe_cmd_total}) " - f"and simulation definitions ({doe_def_total}).") - - doe_task_query: queue.Queue = queue.Queue() - if self._doe_cmd is not None: - for doe_cmd in self._doe_cmd.values(): - doe_task_query.put(doe_cmd) - - if not isinstance(self._doe_def, dict) or len(self._doe_def) == 0: - raise ModelicaSystemError("Missing Doe Summary!") - - def worker(worker_id, task_queue): - while True: - try: - # Get the next task from the queue - cmd_definition = task_queue.get(block=False) - except queue.Empty: - logger.info(f"[Worker {worker_id}] No more simulations to run.") - break - - if cmd_definition is None: - raise ModelicaSystemError("Missing simulation definition!") - - resultfile = cmd_definition.cmd_result_path - resultpath = self.get_session().omcpath(resultfile) - - logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}") - - try: - returncode = self.get_session().run_model_executable(cmd_run_data=cmd_definition) - logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " - f"finished with return code: {returncode}") - except ModelicaSystemError as ex: - logger.warning(f"Simulation error for {resultpath.name}: {ex}") - - # Mark the task as done - task_queue.task_done() - - sim_query_done = doe_cmd_total - doe_task_query.qsize() - logger.info(f"[Worker {worker_id}] Task completed: {resultpath.name} " - f"({doe_cmd_total - sim_query_done}/{doe_cmd_total} = " - f"{(doe_cmd_total - sim_query_done) / doe_cmd_total * 100:.2f}% of tasks left)") - - # Create and start worker threads - logger.info(f"Start simulations for DoE with {doe_cmd_total} simulations " - f"using {num_workers} workers ...") - threads = [] - for i in range(num_workers): - thread = threading.Thread(target=worker, args=(i, doe_task_query)) - thread.start() - threads.append(thread) - - # Wait for all threads to complete - for thread in threads: - thread.join() - - doe_def_done = 0 - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename - - # include check for an empty (=> 0B) result file which indicates a crash of the model executable - # see: https://github.com/OpenModelica/OMPython/issues/261 - # https://github.com/OpenModelica/OpenModelica/issues/13829 - if resultfile.is_file() and resultfile.size() > 0: - self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE] = True - doe_def_done += 1 - - logger.info(f"All workers finished ({doe_def_done} of {doe_def_total} simulations with a result file).") - - return doe_def_total == doe_def_done - - def get_doe_solutions( - self, - var_list: Optional[list] = None, - ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: - """ - Get all solutions of the DoE run. The following return values are possible: - - * A list of variables if val_list == None - - * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - - The following code snippet can be used to convert the solution data for each run to a pandas dataframe: - - ``` - import pandas as pd - - doe_sol = doe_mod.get_doe_solutions() - for key in doe_sol: - data = doe_sol[key]['data'] - if data: - doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) - else: - doe_sol[key]['df'] = None - ``` - - """ - if not isinstance(self._doe_def, dict): - return None - - if len(self._doe_def) == 0: - raise ModelicaSystemError("No result files available - all simulations did fail?") - - sol_dict: dict[str, dict[str, Any]] = {} - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename - - sol_dict[resultfilename] = {} - - if not self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE]: - msg = f"No result file available for {resultfilename}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - continue - - if var_list is None: - var_list_row = list(self._mod.getSolutions(resultfile=resultfile)) - else: - var_list_row = var_list - - try: - sol = self._mod.getSolutions(varList=var_list_row, resultfile=resultfile) - sol_data = {var: sol[idx] for idx, var in enumerate(var_list_row)} - sol_dict[resultfilename]['msg'] = 'Simulation available' - sol_dict[resultfilename]['data'] = sol_data - except ModelicaSystemError as ex: - msg = f"Error reading solution for {resultfilename}: {ex}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - - return sol_dict diff --git a/OMPython/ModelicaSystemBase.py b/OMPython/ModelicaSystemBase.py new file mode 100644 index 00000000..ee085e06 --- /dev/null +++ b/OMPython/ModelicaSystemBase.py @@ -0,0 +1,1219 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import ast +from dataclasses import dataclass +import logging +import numbers +import os +import re +from typing import Any, Optional +import warnings +import xml.etree.ElementTree as ET + +import numpy as np + +from OMPython.OMCSession import ( + OMCPath, + OMCSession, +) +from OMPython.ModelExecution import ModelExecutionCmd + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaSystemError(Exception): + """ + Exception used in ModelicaSystem and ModelicaSystemCmd classes. + """ + + +@dataclass +class LinearizationResult: + """Modelica model linearization results. + + Attributes: + n: number of states + m: number of inputs + p: number of outputs + A: state matrix (n x n) + B: input matrix (n x m) + C: output matrix (p x n) + D: feedthrough matrix (p x m) + x0: fixed point + u0: input corresponding to the fixed point + stateVars: names of state variables + inputVars: names of inputs + outputVars: names of outputs + """ + + n: int + m: int + p: int + + A: list + B: list + C: list + D: list + + x0: list[float] + u0: list[float] + + stateVars: list[str] + inputVars: list[str] + outputVars: list[str] + + def __iter__(self): + """Allow unpacking A, B, C, D = result.""" + yield self.A + yield self.B + yield self.C + yield self.D + + def __getitem__(self, index: int): + """Allow accessing A, B, C, D via result[0] through result[3]. + + This is needed for backwards compatibility, because + ModelicaSystem.linearize() used to return [A, B, C, D]. + """ + return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] + + +class ModelicaSystemBase: + """ + Class to simulate a Modelica model using OpenModelica via OMCSession. + """ + + def __init__( + self, + session: OMCSession, + work_directory: Optional[str | os.PathLike] = None, + ) -> None: + """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). + + Args: + work_directory: Path to a directory to be used for temporary + files like the model executable. If left unspecified, a tmp + directory will be created. + session: definition of a (local) OMC session to be used. If + unspecified, a new local session will be created. + """ + + self._quantities: list[dict[str, Any]] = [] + self._params: dict[str, str] = {} # even numerical values are stored as str + self._inputs: dict[str, list[tuple[float, float]]] = {} + self._outputs: dict[str, np.float64] = {} + self._continuous: dict[str, np.float64] = {} + self._simulate_options: dict[str, str] = {} + self._override_variables: dict[str, str] = {} + self._simulate_options_override: dict[str, str] = {} + self._linearization_options: dict[str, str | float] = { + 'startTime': 0.0, + 'stopTime': 1.0, + 'stepSize': 0.002, + 'tolerance': 1e-8, + } + self._optimization_options = self._linearization_options | { + 'numberOfIntervals': 500, + } + self._linearized_inputs: list[str] = [] # linearization input list + self._linearized_outputs: list[str] = [] # linearization output list + self._linearized_states: list[str] = [] # linearization states list + + self._session = session + + # get OpenModelica version + self._version = self._session.get_version() + + self._simulated = False # True if the model has already been simulated + self._result_file: Optional[OMCPath] = None # for storing result file + + self._work_dir: OMCPath = self.setWorkDirectory(work_directory) + + self._model_name: Optional[str] = None + self._libraries: Optional[list[str | tuple[str, str]]] = None + self._file_name: Optional[OMCPath] = None + self._variable_filter: Optional[str] = None + + def get_session(self) -> OMCSession: + """ + Return the OMC session used for this class. + """ + return self._session + + def get_model_name(self) -> str: + """ + Return the defined model name. + """ + if not isinstance(self._model_name, str): + raise ModelicaSystemError("No model name defined!") + + return self._model_name + + def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath: + """ + Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this + directory. If no directory is defined a unique temporary directory is created. + """ + if work_directory is not None: + workdir = self._session.omcpath(work_directory).absolute() + if not workdir.is_dir(): + raise IOError(f"Provided work directory does not exists: {work_directory}!") + else: + workdir = self._session.omcpath_tempdir().absolute() + if not workdir.is_dir(): + raise IOError(f"{workdir} could not be created") + + logger.info("Define work dir as %s", workdir) + self._session.set_workdir(workdir=workdir) + + # set the class variable _work_dir ... + self._work_dir = workdir + # ... and also return the defined path + return workdir + + def getWorkDirectory(self) -> OMCPath: + """ + Return the defined working directory for this ModelicaSystem / OpenModelica session. + """ + return self._work_dir + + def check_model_executable(self): + """ + Check if the model executable is working + """ + # check if the executable exists ... + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, + ) + # ... by running it - output help for command help + om_cmd.arg_set(key="help", val="help") + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + if returncode != 0: + raise ModelicaSystemError("Model executable not working!") + + def _xmlparse(self, xml_file: OMCPath): + if not xml_file.is_file(): + raise ModelicaSystemError(f"XML file not generated: {xml_file}") + + xml_content = xml_file.read_text() + tree = ET.ElementTree(ET.fromstring(xml_content)) + root = tree.getroot() + for attr in root.iter('DefaultExperiment'): + for key in ("startTime", "stopTime", "stepSize", "tolerance", + "solver", "outputFormat"): + self._simulate_options[key] = str(attr.get(key)) + + for sv in root.iter('ScalarVariable'): + translations = { + "alias": "alias", + "aliasvariable": "aliasVariable", + "causality": "causality", + "changeable": "isValueChangeable", + "description": "description", + "name": "name", + "variability": "variability", + } + + scalar: dict[str, Any] = {} + for key_dst, key_src in translations.items(): + val = sv.get(key_src) + scalar[key_dst] = None if val is None else str(val) + + ch = list(sv) + for att in ch: + scalar["start"] = att.get('start') + scalar["min"] = att.get('min') + scalar["max"] = att.get('max') + scalar["unit"] = att.get('unit') + + # save parameters in the corresponding class variables + if scalar["variability"] == "parameter": + if scalar["name"] in self._override_variables: + self._params[scalar["name"]] = self._override_variables[scalar["name"]] + else: + self._params[scalar["name"]] = scalar["start"] + if scalar["variability"] == "continuous": + self._continuous[scalar["name"]] = np.float64(scalar["start"]) + if scalar["causality"] == "input": + self._inputs[scalar["name"]] = scalar["start"] + if scalar["causality"] == "output": + self._outputs[scalar["name"]] = np.float64(scalar["start"]) + + self._quantities.append(scalar) + + def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: + """ + This method returns list of dictionaries. It displays details of + quantities such as name, value, changeable, and description. + + Examples: + >>> mod.getQuantities() + [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + { + 'name': 'der(x)', + # ... + }, + # ... + ] + + >>> getQuantities("y") + [{ + 'name': 'y', # ... + }] + + >>> getQuantities(["y","x"]) + [ + { + 'name': 'y', # ... + }, + { + 'name': 'x', # ... + } + ] + """ + if names is None: + return self._quantities + + if isinstance(names, str): + r = [x for x in self._quantities if x["name"] == names] + if r == []: + raise KeyError(names) + return r + + if isinstance(names, list): + return [x for y in names for x in self._quantities if x["name"] == y] + + raise ModelicaSystemError("Unhandled input for getQuantities()") + + def getContinuousInitial( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get (initial) values of continuous signals. + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + >>> mod.getContinuousInitial() + {'x': '1.0', 'der(x)': None, 'y': '-0.4'} + >>> mod.getContinuousInitial("y") + ['-0.4'] + >>> mod.getContinuousInitial(["y","x"]) + ['-0.4', '1.0'] + """ + if names is None: + return self._continuous + if isinstance(names, str): + return [self._continuous[names]] + if isinstance(names, list): + return [self._continuous[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getContinousInitial()") + + def getParameters( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get parameter values. + + Args: + names: Either None (default), a string with the parameter name, + or a list of parameter name strings. + Returns: + If `names` is None, a dict in the format + {parameter_name: parameter_value} is returned. + If `names` is a string, a single element list is returned. + If `names` is a list, a list with one value for each parameter name + in names is returned. + In all cases, parameter values are returned as strings. + + Examples: + >>> mod.getParameters() + {'Name1': '1.23', 'Name2': '4.56'} + >>> mod.getParameters("Name1") + ['1.23'] + >>> mod.getParameters(["Name1","Name2"]) + ['1.23', '4.56'] + """ + if names is None: + return self._params + if isinstance(names, str): + return [self._params[names]] + if isinstance(names, list): + return [self._params[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getParameters()") + + def getInputs( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, list[tuple[float, float]]] | list[list[tuple[float, float]]]: + """Get values of input signals. + + Args: + names: Either None (default), a string with the input name, + or a list of input name strings. + Returns: + If `names` is None, a dict in the format + {input_name: input_value} is returned. + If `names` is a string, a single element list [input_value] is + returned. + If `names` is a list, a list with one value for each input name + in names is returned: [input1_values, input2_values, ...]. + In all cases, input values are returned as a list of tuples, + where the first element in the tuple is the time and the second + element is the input value. + + Examples: + >>> mod.getInputs() + {'Name1': [(0.0, 0.0), (1.0, 1.0)], 'Name2': None} + >>> mod.getInputs("Name1") + [[(0.0, 0.0), (1.0, 1.0)]] + >>> mod.getInputs(["Name1","Name2"]) + [[(0.0, 0.0), (1.0, 1.0)], None] + """ + if names is None: + return self._inputs + if isinstance(names, str): + return [self._inputs[names]] + if isinstance(names, list): + return [self._inputs[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getInputs()") + + def getOutputsInitial( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get (initial) values of output signals. + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + >>> mod.getOutputsInitial() + {'out1': '-0.4', 'out2': '1.2'} + >>> mod.getOutputsInitial("out1") + ['-0.4'] + >>> mod.getOutputsInitial(["out1","out2"]) + ['-0.4', '1.2'] + """ + if names is None: + return self._outputs + if isinstance(names, str): + return [self._outputs[names]] + if isinstance(names, list): + return [self._outputs[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getOutputsInitial()") + + def getSimulationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get simulation options such as stopTime and tolerance. + + Args: + names: Either None (default), a string with the simulation option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + Option values are always returned as strings. + + Examples: + >>> mod.getSimulationOptions() + {'startTime': '0', 'stopTime': '1.234', + 'stepSize': '0.002', 'tolerance': '1.1e-08', 'solver': 'dassl', 'outputFormat': 'mat'} + >>> mod.getSimulationOptions("stopTime") + ['1.234'] + >>> mod.getSimulationOptions(["tolerance", "stopTime"]) + ['1.1e-08', '1.234'] + """ + if names is None: + return self._simulate_options + if isinstance(names, str): + return [self._simulate_options[names]] + if isinstance(names, list): + return [self._simulate_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getSimulationOptions()") + + def getLinearizationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str | float] | list[str | float]: + """Get simulation options used for linearization. + + Args: + names: Either None (default), a string with the linearization option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + Some option values are returned as float when first initialized, + but always as strings after setLinearizationOptions is used to + change them. + + Examples: + >>> mod.getLinearizationOptions() + {'startTime': 0.0, 'stopTime': 1.0, 'stepSize': 0.002, 'tolerance': 1e-08} + >>> mod.getLinearizationOptions("stopTime") + [1.0] + >>> mod.getLinearizationOptions(["tolerance", "stopTime"]) + [1e-08, 1.0] + """ + if names is None: + return self._linearization_options + if isinstance(names, str): + return [self._linearization_options[names]] + if isinstance(names, list): + return [self._linearization_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getLinearizationOptions()") + + def getOptimizationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str | float] | list[str | float]: + """Get simulation options used for optimization. + + Args: + names: Either None (default), a string with the optimization option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + Some option values are returned as float when first initialized, + but always as strings after setOptimizationOptions is used to + change them. + + Examples: + >>> mod.getOptimizationOptions() + {'startTime': 0.0, 'stopTime': 1.0, 'numberOfIntervals': 500, 'stepSize': 0.002, 'tolerance': 1e-08} + >>> mod.getOptimizationOptions("stopTime") + [1.0] + >>> mod.getOptimizationOptions(["tolerance", "stopTime"]) + [1e-08, 1.0] + """ + if names is None: + return self._optimization_options + if isinstance(names, str): + return [self._optimization_options[names]] + if isinstance(names, list): + return [self._optimization_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") + + @staticmethod + def parse_om_version(version: str) -> tuple[int, int, int]: + """ + Evaluate a OMC version string and return a tuple of (epoch, major, minor). + """ + match = re.search(pattern=r"v?(\d+)\.(\d+)\.(\d+)", string=version) + if not match: + raise ValueError(f"Version not found in: {version}") + major, minor, patch = map(int, match.groups()) + return major, minor, patch + + def simulate_cmd( + self, + result_file: OMCPath, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> ModelExecutionCmd: + """ + This method prepares the simulates model according to the simulation options. It returns an instance of + ModelicaSystemCmd which can be used to run the simulation. + + Due to the tempdir being unique for the ModelicaSystem instance, *NEVER* use this to create several simulations + with the same instance of ModelicaSystem! Restart each simulation process with a new instance of ModelicaSystem. + + However, if only non-structural parameters are used, it is possible to reuse an existing instance of + ModelicaSystem to create several version ModelicaSystemCmd to run the model using different settings. + + Parameters + ---------- + result_file + simflags + simargs + + Returns + ------- + An instance if ModelicaSystemCmd to run the requested simulation. + """ + + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, + ) + + # always define the result file to use + om_cmd.arg_set(key="r", val=result_file.as_posix()) + + # allow runtime simulation flags from user input + if simflags is not None: + om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) + + if simargs: + om_cmd.args_set(args=simargs) + + if self._override_variables or self._simulate_options_override: + override_file = result_file.parent / f"{result_file.stem}_override.txt" + + # simulation options are not read from override file from version >= 1.26.0, + # pass them to simulation executable directly as individual arguments + # see https://github.com/OpenModelica/OpenModelica/pull/14813 + major, minor, patch = self.parse_om_version(self._version) + if (major, minor, patch) >= (1, 26, 0): + for key, opt_value in self._simulate_options_override.items(): + om_cmd.arg_set(key=key, val=str(opt_value)) + override_content = ( + "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) + + "\n" + ) + else: + override_content = ( + "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) + + "\n".join([f"{key}={value}" for key, value in self._simulate_options_override.items()]) + + "\n" + ) + + override_file.write_text(override_content) + om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) + + if self._inputs: # if model has input quantities + for key, val in self._inputs.items(): + if val is None: + val = [(float(self._simulate_options["startTime"]), 0.0), + (float(self._simulate_options["stopTime"]), 0.0)] + self._inputs[key] = val + if float(self._simulate_options["startTime"]) != val[0][0]: + raise ModelicaSystemError(f"startTime not matched for Input {key}!") + if float(self._simulate_options["stopTime"]) != val[-1][0]: + raise ModelicaSystemError(f"stopTime not matched for Input {key}!") + + # csvfile is based on name used for result file + csvfile = result_file.parent / f"{result_file.stem}.csv" + # write csv file and store the name + csvfile = self._createCSVData(csvfile=csvfile) + + om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) + + return om_cmd + + def simulate( + self, + resultfile: Optional[str | os.PathLike] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> None: + """Simulate the model according to simulation options. + + See setSimulationOptions(). + + Args: + resultfile: Path to a custom result file + simflags: String of extra command line flags for the model binary. + This argument is deprecated, use simargs instead. + simargs: Dict with simulation runtime flags. + + Examples: + mod.simulate() + mod.simulate(resultfile="a.mat") + # set runtime simulation flags, deprecated + mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10") + # using simargs + mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}}) + """ + + if resultfile is None: + # default result file generated by OM + self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" + elif isinstance(resultfile, OMCPath): + self._result_file = resultfile + else: + self._result_file = self._session.omcpath(resultfile) + if not self._result_file.is_absolute(): + self._result_file = self.getWorkDirectory() / resultfile + + if not isinstance(self._result_file, OMCPath): + raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") + + om_cmd = self.simulate_cmd( + result_file=self._result_file, + simflags=simflags, + simargs=simargs, + ) + + # delete resultfile ... + if self._result_file.is_file(): + self._result_file.unlink() + # ... run simulation ... + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + # and check returncode *AND* resultfile + if returncode != 0 and self._result_file.is_file(): + # check for an empty (=> 0B) result file which indicates a crash of the model executable + # see: https://github.com/OpenModelica/OMPython/issues/261 + # https://github.com/OpenModelica/OpenModelica/issues/13829 + if self._result_file.size() == 0: + self._result_file.unlink() + raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!") + + logger.warning(f"Return code = {returncode} but result file exists!") + + self._simulated = True + + @staticmethod + def _prepare_input_data( + input_args: Any, + input_kwargs: dict[str, Any], + ) -> dict[str, str]: + """ + Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. + """ + + def prepare_str(str_in: str) -> dict[str, str]: + str_in = str_in.replace(" ", "") + key_val_list: list[str] = str_in.split("=") + if len(key_val_list) != 2: + raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") + + input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} + + return input_data_from_str + + input_data: dict[str, str] = {} + + for input_arg in input_args: + if isinstance(input_arg, str): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + input_data = input_data | prepare_str(input_arg) + elif isinstance(input_arg, list): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + + for item in input_arg: + if not isinstance(item, str): + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") + input_data = input_data | prepare_str(item) + elif isinstance(input_arg, dict): + input_data = input_data | input_arg + else: + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") + + if len(input_kwargs): + for key, val in input_kwargs.items(): + # ensure all values are strings to align it on one type: dict[str, str] + if not isinstance(val, str): + # spaces have to be removed as setInput() could take list of tuples as input and spaces would + # result in an error on recreating the input data + str_val = str(val).replace(' ', '') + else: + str_val = val + if ' ' in key or ' ' in str_val: + raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") + input_data[key] = str_val + + return input_data + + def _set_method_helper( + self, + inputdata: dict[str, str], + classdata: dict[str, Any], + datatype: str, + overridedata: Optional[dict[str, str]] = None, + ) -> bool: + """ + Helper function for: + * setParameter() + * setContinuous() + * setSimulationOptions() + * setLinearizationOption() + * setOptimizationOption() + * setInputs() + + Parameters + ---------- + inputdata + string or list of string given by user + classdata + dict() containing the values of different variables (eg: parameter, continuous, simulation parameters) + datatype + type identifier (eg; continuous, parameter, simulation, linearization, optimization) + overridedata + dict() which stores the new override variables list, + """ + + for key, val in inputdata.items(): + if key not in classdata: + raise ModelicaSystemError(f"Invalid variable for type {repr(datatype)}: {repr(key)}") + + if datatype == "parameter" and not self.isParameterChangeable(key): + raise ModelicaSystemError(f"It is not possible to set the parameter {repr(key)}. It seems to be " + "structural, final, protected, evaluated or has a non-constant binding. " + "Use sendExpression(...) and rebuild the model using buildModel() API; " + "command to set the parameter before rebuilding the model: " + "sendExpression(\"setParameterValue(" + f"{self._model_name}, {key}, {val if val is not None else ''}" + ")\").") + + classdata[key] = val + if overridedata is not None: + overridedata[key] = val + + return True + + def isParameterChangeable( + self, + name: str, + ) -> bool: + """ + Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to + recompile the model). + """ + q = self.getQuantities(name) + if q[0]["changeable"] == "false": + return False + return True + + def setContinuous( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set continuous values. It can be called: + with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: + usage + >>> setContinuous("Name=value") # depreciated + >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated + + >>> setContinuous(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setContinuous(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._continuous, + datatype="continuous", + overridedata=self._override_variables) + + def setParameters( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set parameter values. It can be called: + with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: + usage + >>> setParameters("Name=value") # depreciated + >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated + + >>> setParameters(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setParameters(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._params, + datatype="parameter", + overridedata=self._override_variables) + + def setSimulationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set simulation options. It can be called: + with a sequence of simulation options name and assigning corresponding values as arguments as show in the + example below: + usage + >>> setSimulationOptions("Name=value") # depreciated + >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setSimulationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setSimulationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._simulate_options, + datatype="simulation-option", + overridedata=self._simulate_options_override) + + def setLinearizationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set linearization options. It can be called: + with a sequence of linearization options name and assigning corresponding value as arguments as show in the + example below + usage + >>> setLinearizationOptions("Name=value") # depreciated + >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setLinearizationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setLinearizationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._linearization_options, + datatype="Linearization-option", + overridedata=None) + + def setOptimizationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set optimization options. It can be called: + with a sequence of optimization options name and assigning corresponding values as arguments as show in the + example below: + usage + >>> setOptimizationOptions("Name=value") # depreciated + >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setOptimizationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setOptimizationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._optimization_options, + datatype="optimization-option", + overridedata=None) + + def setInputs( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set input values. It can be called with a sequence of input name and assigning + corresponding values as arguments as show in the example below. Compared to other set*() methods this is a + special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() + and restored here via ast.literal_eval(). + + >>> setInputs("Name=value") # depreciated + >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated + + >>> setInputs(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setInputs(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + for key, val in inputdata.items(): + if key not in self._inputs: + raise ModelicaSystemError(f"{key} is not an input") + + if not isinstance(val, str): + raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") + + val_evaluated = ast.literal_eval(val) + + if isinstance(val_evaluated, (int, float)): + self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), + (float(self._simulate_options["stopTime"]), float(val))] + elif isinstance(val_evaluated, list): + if not all([isinstance(item, tuple) for item in val_evaluated]): + raise ModelicaSystemError("Value for setInput() must be in tuple format; " + f"got {repr(val_evaluated)}") + if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): + raise ModelicaSystemError("Time value should be in increasing order; " + f"got {repr(val_evaluated)}") + + for item in val_evaluated: + if item[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " + "than the simulation start time") + if len(item) != 2: + raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " + "is in incorrect format!") + + self._inputs[key] = val_evaluated + else: + raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") + + return True + + def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: + """ + Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, + this file is used; else a generic file name is created. + """ + start_time: float = float(self._simulate_options["startTime"]) + stop_time: float = float(self._simulate_options["stopTime"]) + + # Replace None inputs with a default constant zero signal + inputs: dict[str, list[tuple[float, float]]] = {} + for input_name, input_signal in self._inputs.items(): + if input_signal is None: + inputs[input_name] = [(start_time, 0.0), (stop_time, 0.0)] + else: + inputs[input_name] = input_signal + + # Collect all unique timestamps across all input signals + all_times = np.array( + sorted({t for signal in inputs.values() for t, _ in signal}), + dtype=float + ) + + # Interpolate missing values + interpolated_inputs: dict[str, np.ndarray] = {} + for signal_name, signal_values in inputs.items(): + signal = np.array(signal_values) + interpolated_inputs[signal_name] = np.interp( + x=all_times, + xp=signal[:, 0], # times + fp=signal[:, 1], # values + ) + + # Write CSV file + input_names = list(interpolated_inputs.keys()) + header = ['time'] + input_names + ['end'] + + csv_rows = [header] + for i, t in enumerate(all_times): + row = [ + t, # time + *(interpolated_inputs[name][i] for name in input_names), # input values + 0, # trailing 'end' column + ] + csv_rows.append(row) + + if csvfile is None: + csvfile = self.getWorkDirectory() / f'{self._model_name}.csv' + + # basic definition of a CSV file using csv_rows as input + csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n" + + csvfile.write_text(csv_content) + + return csvfile + + def linearize( + self, + lintime: Optional[float] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> LinearizationResult: + """Linearize the model according to linearization options. + + See setLinearizationOptions. + + Args: + lintime: Override "stopTime" value. + simflags: String of extra command line flags for the model binary. + This argument is deprecated, use simargs instead. + simargs: A dict with command line flags and possible options; example: "simargs={'csvInput': 'a.csv'}" + + Returns: + A LinearizationResult object is returned. This allows several + uses: + * `(A, B, C, D) = linearize()` to get just the matrices, + * `result = linearize(); result.A` to get everything and access the + attributes one by one, + * `result = linearize(); A = result[0]` mostly just for backwards + compatibility, because linearize() used to return `[A, B, C, D]`. + """ + if len(self._quantities) == 0: + # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() + raise ModelicaSystemError( + "Linearization cannot be performed as the model is not build, " + "use ModelicaSystem() to build the model first" + ) + + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, + ) + + # See comment in simulate_cmd regarding override file and OM version + major, minor, patch = self.parse_om_version(self._version) + if (major, minor, patch) >= (1, 26, 0): + for key, opt_value in self._linearization_options.items(): + om_cmd.arg_set(key=key, val=str(opt_value)) + override_content = ( + "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) + + "\n" + ) + else: + override_content = ( + "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) + + "\n".join([f"{key}={value}" for key, value in self._linearization_options.items()]) + + "\n" + ) + + override_file = self.getWorkDirectory() / f'{self._model_name}_override_linear.txt' + override_file.write_text(override_content) + + om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) + + if self._inputs: + for key, data in self._inputs.items(): + if data is not None: + for value in data: + if value[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError('Input time value is less than simulation startTime') + csvfile = self._createCSVData() + om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) + + om_cmd.arg_set(key="l", val=str(lintime or self._linearization_options["stopTime"])) + + # allow runtime simulation flags from user input + if simflags is not None: + om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) + + if simargs: + om_cmd.args_set(args=simargs) + + # the file create by the model executable which contains the matrix and linear inputs, outputs and states + linear_file = self.getWorkDirectory() / "linearized_model.py" + linear_file.unlink(missing_ok=True) + + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + if returncode != 0: + raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") + if not linear_file.is_file(): + raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") + + self._simulated = True + + # extract data from the python file with the linearized model using the ast module - this allows to get the + # needed information without executing the created code + linear_data = {} + linear_file_content = linear_file.read_text() + try: + # ignore possible typing errors below (mypy) - these are caught by the try .. except .. block + linear_file_ast = ast.parse(linear_file_content) + for body_part in linear_file_ast.body[0].body: # type: ignore + if not isinstance(body_part, ast.Assign): + continue + + target = body_part.targets[0].id # type: ignore + value_ast = ast.literal_eval(body_part.value) + + linear_data[target] = value_ast + except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: + raise ModelicaSystemError(f"Error parsing linearization file {linear_file}: {ex}") from ex + + # remove the file + linear_file.unlink() + + self._linearized_inputs = linear_data["inputVars"] + self._linearized_outputs = linear_data["outputVars"] + self._linearized_states = linear_data["stateVars"] + + return LinearizationResult( + n=linear_data["n"], + m=linear_data["m"], + p=linear_data["p"], + x0=linear_data["x0"], + u0=linear_data["u0"], + A=linear_data["A"], + B=linear_data["B"], + C=linear_data["C"], + D=linear_data["D"], + stateVars=linear_data["stateVars"], + inputVars=linear_data["inputVars"], + outputVars=linear_data["outputVars"], + ) + + def getLinearInputs(self) -> list[str]: + """Get names of input variables of the linearized model.""" + return self._linearized_inputs + + def getLinearOutputs(self) -> list[str]: + """Get names of output variables of the linearized model.""" + return self._linearized_outputs + + def getLinearStates(self) -> list[str]: + """Get names of state variables of the linearized model.""" + return self._linearized_states diff --git a/OMPython/ModelicaSystemDoE.py b/OMPython/ModelicaSystemDoE.py new file mode 100644 index 00000000..5882c7be --- /dev/null +++ b/OMPython/ModelicaSystemDoE.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import itertools +import logging +import numbers +import os +import queue +import threading +from typing import Any, cast, Optional + +import numpy as np + +from OMPython.ModelExecution import ModelExecutionData +from OMPython.ModelicaSystem import ModelicaSystem +from OMPython.ModelicaSystemBase import ModelicaSystemError +from OMPython.OMCSession import OMCSession + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaSystemDoE: + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystem + + Example + ------- + ``` + import OMPython + import pathlib + + + def run_doe(): + mypath = pathlib.Path('.') + + model = mypath / "M.mo" + model.write_text( + " model M\n" + " parameter Integer p=1;\n" + " parameter Integer q=1;\n" + " parameter Real a = -1;\n" + " parameter Real b = -1;\n" + " Real x[p];\n" + " Real y[q];\n" + " equation\n" + " der(x) = a * fill(1.0, p);\n" + " der(y) = b * fill(1.0, q);\n" + " end M;\n" + ) + + param = { + # structural + 'p': [1, 2], + 'q': [3, 4], + # non-structural + 'a': [5, 6], + 'b': [7, 8], + } + + resdir = mypath / 'DoE' + resdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystem() + mod.model( + model_name="M", + model_file=model.as_posix(), + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, + parameters=param, + resultpath=resdir, + simargs={"override": {'stopTime': 1.0}}, + ) + doe_mod.prepare() + doe_def = doe_mod.get_doe_definition() + doe_mod.simulate() + doe_sol = doe_mod.get_doe_solutions() + + # ... work with doe_def and doe_sol ... + + + if __name__ == "__main__": + run_doe() + ``` + + """ + + # Dictionary keys used in simulation dict (see _sim_dict or get_doe()). These dict keys contain a space and, thus, + # cannot be used as OM variable identifiers. They are defined here as reference for any evaluation of the data. + DICT_ID_STRUCTURE: str = 'ID structure' + DICT_ID_NON_STRUCTURE: str = 'ID non-structure' + DICT_RESULT_AVAILABLE: str = 'result available' + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystem, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + """ + Initialisation of ModelicaSystemDoE. The parameters are based on: ModelicaSystem.__init__() and + ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as + a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. + """ + + if not isinstance(mod, ModelicaSystem): + raise ModelicaSystemError("Missing definition of ModelicaSystem!") + + self._mod = mod + self._model_name = mod.get_model_name() + + self._simargs = simargs + + if resultpath is None: + self._resultpath = self.get_session().omcpath_tempdir() + else: + self._resultpath = self.get_session().omcpath(resultpath).resolve() + if not self._resultpath.is_dir(): + raise ModelicaSystemError("Argument resultpath must be set to a valid path within the environment used " + f"for the OpenModelica session: {resultpath}!") + + if isinstance(parameters, dict): + self._parameters = parameters + else: + self._parameters = {} + + self._doe_def: Optional[dict[str, dict[str, Any]]] = None + self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None + + def get_session(self) -> OMCSession: + """ + Return the OMC session used for this class. + """ + return self._mod.get_session() + + def prepare(self) -> int: + """ + Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of + ModelicaSystem while the non-structural parameters can just be set on the executable. + + The return value is the number of simulation defined. + """ + + doe_sim = {} + doe_def = {} + + param_structure = {} + param_non_structure = {} + for param_name in self._parameters.keys(): + changeable = self._mod.isParameterChangeable(name=param_name) + logger.info(f"Parameter {repr(param_name)} is changeable? {changeable}") + + if changeable: + param_non_structure[param_name] = self._parameters[param_name] + else: + param_structure[param_name] = self._parameters[param_name] + + param_structure_combinations = list(itertools.product(*param_structure.values())) + param_non_structural_combinations = list(itertools.product(*param_non_structure.values())) + + for idx_pc_structure, pc_structure in enumerate(param_structure_combinations): + + build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" + build_dir.mkdir() + self._mod.setWorkDirectory(work_directory=build_dir) + + sim_param_structure = {} + for idx_structure, pk_structure in enumerate(param_structure.keys()): + sim_param_structure[pk_structure] = pc_structure[idx_structure] + + pk_value = pc_structure[idx_structure] + if isinstance(pk_value, str): + pk_value_str = self.get_session().escape_str(pk_value) + expression = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" + elif isinstance(pk_value, bool): + pk_value_bool_str = "true" if pk_value else "false" + expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + else: + expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" + res = self._mod.sendExpression(expression) + if not res: + raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " + f"to {pk_value} using {repr(expression)}") + + self._mod.buildModel() + + for idx_non_structural, pk_non_structural in enumerate(param_non_structural_combinations): + sim_param_non_structural = {} + for idx, pk in enumerate(param_non_structure.keys()): + sim_param_non_structural[pk] = cast(Any, pk_non_structural[idx]) + + resfilename = f"DOE_{idx_pc_structure:09d}_{idx_non_structural:09d}.mat" + logger.info(f"use result file {repr(resfilename)} " + f"for structural parameters: {sim_param_structure} " + f"and non-structural parameters: {sim_param_non_structural}") + resultfile = self._resultpath / resfilename + + df_data = ( + { + self.DICT_ID_STRUCTURE: idx_pc_structure, + } + | sim_param_structure + | { + self.DICT_ID_NON_STRUCTURE: idx_non_structural, + } + | sim_param_non_structural + | { + self.DICT_RESULT_AVAILABLE: False, + } + ) + + self._mod.setParameters(sim_param_non_structural) + mscmd = self._mod.simulate_cmd( + result_file=resultfile, + ) + if self._simargs is not None: + mscmd.args_set(args=self._simargs) + cmd_definition = mscmd.definition() + del mscmd + + doe_sim[resfilename] = cmd_definition + doe_def[resfilename] = df_data + + logger.info(f"Prepared {len(doe_sim)} simulation definitions for the defined DoE.") + self._doe_cmd = doe_sim + self._doe_def = doe_def + + return len(doe_sim) + + def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: + """ + Get the defined DoE as a dict, where each key is the result filename and the value is a dict of simulation + settings including structural and non-structural parameters. + + The following code snippet can be used to convert the data to a pandas dataframe: + + ``` + import pandas as pd + + doe_dict = doe_mod.get_doe_definition() + doe_df = pd.DataFrame.from_dict(data=doe_dict, orient='index') + ``` + + """ + return self._doe_def + + def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: + """ + Get the definitions of simulations commands to run for this DoE. + """ + return self._doe_cmd + + def simulate( + self, + num_workers: int = 3, + ) -> bool: + """ + Simulate the DoE using the defined number of workers. + + Returns True if all simulations were done successfully, else False. + """ + + if self._doe_cmd is None or self._doe_def is None: + raise ModelicaSystemError("DoE preparation missing - call prepare() first!") + + doe_cmd_total = len(self._doe_cmd) + doe_def_total = len(self._doe_def) + + if doe_cmd_total != doe_def_total: + raise ModelicaSystemError(f"Mismatch between number simulation commands ({doe_cmd_total}) " + f"and simulation definitions ({doe_def_total}).") + + doe_task_query: queue.Queue = queue.Queue() + if self._doe_cmd is not None: + for doe_cmd in self._doe_cmd.values(): + doe_task_query.put(doe_cmd) + + if not isinstance(self._doe_def, dict) or len(self._doe_def) == 0: + raise ModelicaSystemError("Missing Doe Summary!") + + def worker(worker_id, task_queue): + while True: + try: + # Get the next task from the queue + cmd_definition = task_queue.get(block=False) + except queue.Empty: + logger.info(f"[Worker {worker_id}] No more simulations to run.") + break + + if cmd_definition is None: + raise ModelicaSystemError("Missing simulation definition!") + + resultfile = cmd_definition.cmd_result_file + resultpath = self.get_session().omcpath(resultfile) + + logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}") + + try: + returncode = cmd_definition.run() + logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " + f"finished with return code: {returncode}") + except ModelicaSystemError as ex: + logger.warning(f"Simulation error for {resultpath.name}: {ex}") + + # Mark the task as done + task_queue.task_done() + + sim_query_done = doe_cmd_total - doe_task_query.qsize() + logger.info(f"[Worker {worker_id}] Task completed: {resultpath.name} " + f"({doe_cmd_total - sim_query_done}/{doe_cmd_total} = " + f"{(doe_cmd_total - sim_query_done) / doe_cmd_total * 100:.2f}% of tasks left)") + + # Create and start worker threads + logger.info(f"Start simulations for DoE with {doe_cmd_total} simulations " + f"using {num_workers} workers ...") + threads = [] + for i in range(num_workers): + thread = threading.Thread(target=worker, args=(i, doe_task_query)) + thread.start() + threads.append(thread) + + # Wait for all threads to complete + for thread in threads: + thread.join() + + doe_def_done = 0 + for resultfilename in self._doe_def: + resultfile = self._resultpath / resultfilename + + # include check for an empty (=> 0B) result file which indicates a crash of the model executable + # see: https://github.com/OpenModelica/OMPython/issues/261 + # https://github.com/OpenModelica/OpenModelica/issues/13829 + if resultfile.is_file() and resultfile.size() > 0: + self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE] = True + doe_def_done += 1 + + logger.info(f"All workers finished ({doe_def_done} of {doe_def_total} simulations with a result file).") + + return doe_def_total == doe_def_done + + def get_doe_solutions( + self, + var_list: Optional[list] = None, + ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: + """ + Get all solutions of the DoE run. The following return values are possible: + + * A list of variables if val_list == None + + * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. + + The following code snippet can be used to convert the solution data for each run to a pandas dataframe: + + ``` + import pandas as pd + + doe_sol = doe_mod.get_doe_solutions() + for key in doe_sol: + data = doe_sol[key]['data'] + if data: + doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) + else: + doe_sol[key]['df'] = None + ``` + + """ + if not isinstance(self._doe_def, dict): + return None + + if len(self._doe_def) == 0: + raise ModelicaSystemError("No result files available - all simulations did fail?") + + sol_dict: dict[str, dict[str, Any]] = {} + for resultfilename in self._doe_def: + resultfile = self._resultpath / resultfilename + + sol_dict[resultfilename] = {} + + if not self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE]: + msg = f"No result file available for {resultfilename}" + logger.warning(msg) + sol_dict[resultfilename]['msg'] = msg + sol_dict[resultfilename]['data'] = {} + continue + + if var_list is None: + var_list_row = list(self._mod.getSolutions(resultfile=resultfile)) + else: + var_list_row = var_list + + try: + sol = self._mod.getSolutions(varList=var_list_row, resultfile=resultfile) + sol_data = {var: sol[idx] for idx, var in enumerate(var_list_row)} + sol_dict[resultfilename]['msg'] = 'Simulation available' + sol_dict[resultfilename]['data'] = sol_data + except ModelicaSystemError as ex: + msg = f"Error reading solution for {resultfilename}: {ex}" + logger.warning(msg) + sol_dict[resultfilename]['msg'] = msg + sol_dict[resultfilename]['data'] = {} + + return sol_dict diff --git a/OMPython/ModelicaSystemRunner.py b/OMPython/ModelicaSystemRunner.py new file mode 100644 index 00000000..928748cc --- /dev/null +++ b/OMPython/ModelicaSystemRunner.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +""" +Runner class for a pre-compiled model binary. It does not use OMC at all. +""" +import logging +import os +from typing import Optional + +# Import base class and exception +from OMPython.ModelicaSystemBase import ( + ModelicaSystemBase, + ModelicaSystemError, +) +from OMPython.OMCSession import ( + OMCSession, + OMCSessionDummy, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaSystemRunner(ModelicaSystemBase): + """ + Class to simulate a Modelica model using a pre-compiled model binary. + """ + + def __init__( + self, + work_directory: Optional[str | os.PathLike] = None, + session: Optional[OMCSession] = None, + ) -> None: + if session is None: + session = OMCSessionDummy() + + if not isinstance(session, OMCSessionDummy): + raise ModelicaSystemError("Only working if OMCsessionDummy is used!") + + super().__init__( + work_directory=work_directory, + session=session, + ) + + def setup( + self, + model_name: Optional[str] = None, + variable_filter: Optional[str] = None, + ) -> None: + """ + Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists + within the working directory. At least two files are needed: + + * model executable (as '' or '.exe'; in case of Windows additional '.bat' + is expected to evaluate the path to needed dlls + * the model initialization file (as '_init.xml') + """ + + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + # set variables + self._model_name = model_name # Model class name + self._variable_filter = variable_filter + + # test if the model can be executed + self.check_model_executable() + + # read XML file + xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" + self._xmlparse(xml_file=xml_file) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 861f2a3a..b5d82f21 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -6,7 +6,6 @@ from __future__ import annotations import abc -import dataclasses import io import json import logging @@ -23,10 +22,10 @@ from typing import Any, Optional, Tuple import uuid import warnings -import zmq import psutil import pyparsing +import zmq # TODO: replace this with the new parser from OMPython.OMTypedParser import om_parser_typed @@ -50,7 +49,7 @@ def poll(self): return None if self.process.is_running() else True def kill(self): - return os.kill(self.pid, signal.SIGKILL) + return os.kill(pid=self.pid, signal=signal.SIGKILL) def wait(self, timeout): try: @@ -65,197 +64,7 @@ class OMCSessionException(Exception): """ -class OMCSessionCmd: - """ - Implementation of Open Modelica Compiler API functions. Depreciated! - """ - - def __init__(self, session: OMCSession, readonly: bool = False): - if not isinstance(session, OMCSession): - raise OMCSessionException("Invalid OMC process definition!") - self._session = session - self._readonly = readonly - self._omc_cache: dict[tuple[str, bool], Any] = {} - - def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = True): - - if opt is None: - expression = question - elif isinstance(opt, list): - expression = f"{question}({','.join([str(x) for x in opt])})" - else: - raise OMCSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") - - p = (expression, parsed) - - if self._readonly and question != 'getErrorString': - # can use cache if readonly - if p in self._omc_cache: - return self._omc_cache[p] - - try: - res = self._session.sendExpression(expression, parsed=parsed) - except OMCSessionException as ex: - raise OMCSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex - - # save response - self._omc_cache[p] = res - - return res - - # TODO: Open Modelica Compiler API functions. Would be nice to generate these. - def loadFile(self, filename): - return self._ask(question='loadFile', opt=[f'"{filename}"']) - - def loadModel(self, className): - return self._ask(question='loadModel', opt=[className]) - - def isModel(self, className): - return self._ask(question='isModel', opt=[className]) - - def isPackage(self, className): - return self._ask(question='isPackage', opt=[className]) - - def isPrimitive(self, className): - return self._ask(question='isPrimitive', opt=[className]) - - def isConnector(self, className): - return self._ask(question='isConnector', opt=[className]) - - def isRecord(self, className): - return self._ask(question='isRecord', opt=[className]) - - def isBlock(self, className): - return self._ask(question='isBlock', opt=[className]) - - def isType(self, className): - return self._ask(question='isType', opt=[className]) - - def isFunction(self, className): - return self._ask(question='isFunction', opt=[className]) - - def isClass(self, className): - return self._ask(question='isClass', opt=[className]) - - def isParameter(self, className): - return self._ask(question='isParameter', opt=[className]) - - def isConstant(self, className): - return self._ask(question='isConstant', opt=[className]) - - def isProtected(self, className): - return self._ask(question='isProtected', opt=[className]) - - def getPackages(self, className="AllLoadedClasses"): - return self._ask(question='getPackages', opt=[className]) - - def getClassRestriction(self, className): - return self._ask(question='getClassRestriction', opt=[className]) - - def getDerivedClassModifierNames(self, className): - return self._ask(question='getDerivedClassModifierNames', opt=[className]) - - def getDerivedClassModifierValue(self, className, modifierName): - return self._ask(question='getDerivedClassModifierValue', opt=[className, modifierName]) - - def typeNameStrings(self, className): - return self._ask(question='typeNameStrings', opt=[className]) - - def getComponents(self, className): - return self._ask(question='getComponents', opt=[className]) - - def getClassComment(self, className): - try: - return self._ask(question='getClassComment', opt=[className]) - except pyparsing.ParseException as ex: - logger.warning("Method 'getClassComment(%s)' failed; OMTypedParser error: %s", - className, ex.msg) - return 'No description available' - except OMCSessionException: - raise - - def getNthComponent(self, className, comp_id): - """ returns with (type, name, description) """ - return self._ask(question='getNthComponent', opt=[className, comp_id]) - - def getNthComponentAnnotation(self, className, comp_id): - return self._ask(question='getNthComponentAnnotation', opt=[className, comp_id]) - - def getImportCount(self, className): - return self._ask(question='getImportCount', opt=[className]) - - def getNthImport(self, className, importNumber): - # [Path, id, kind] - return self._ask(question='getNthImport', opt=[className, importNumber]) - - def getInheritanceCount(self, className): - return self._ask(question='getInheritanceCount', opt=[className]) - - def getNthInheritedClass(self, className, inheritanceDepth): - return self._ask(question='getNthInheritedClass', opt=[className, inheritanceDepth]) - - def getParameterNames(self, className): - try: - return self._ask(question='getParameterNames', opt=[className]) - except KeyError as ex: - logger.warning('OMPython error: %s', ex) - # FIXME: OMC returns with a different structure for empty parameter set - return [] - except OMCSessionException: - raise - - def getParameterValue(self, className, parameterName): - try: - return self._ask(question='getParameterValue', opt=[className, parameterName]) - except pyparsing.ParseException as ex: - logger.warning("Method 'getParameterValue(%s, %s)' failed; OMTypedParser error: %s", - className, parameterName, ex.msg) - return "" - except OMCSessionException: - raise - - def getComponentModifierNames(self, className, componentName): - return self._ask(question='getComponentModifierNames', opt=[className, componentName]) - - def getComponentModifierValue(self, className, componentName): - return self._ask(question='getComponentModifierValue', opt=[className, componentName]) - - def getExtendsModifierNames(self, className, componentName): - return self._ask(question='getExtendsModifierNames', opt=[className, componentName]) - - def getExtendsModifierValue(self, className, extendsName, modifierName): - return self._ask(question='getExtendsModifierValue', opt=[className, extendsName, modifierName]) - - def getNthComponentModification(self, className, comp_id): - # FIXME: OMPython exception Results KeyError exception - - # get {$Code(....)} field - # \{\$Code\((\S*\s*)*\)\} - value = self._ask(question='getNthComponentModification', opt=[className, comp_id], parsed=False) - value = value.replace("{$Code(", "") - return value[:-3] - # return self.re_Code.findall(value) - - # function getClassNames - # input TypeName class_ = $Code(AllLoadedClasses); - # input Boolean recursive = false; - # input Boolean qualified = false; - # input Boolean sort = false; - # input Boolean builtin = false "List also builtin classes if true"; - # input Boolean showProtected = false "List also protected classes if true"; - # output TypeName classNames[:]; - # end getClassNames; - def getClassNames(self, className=None, recursive=False, qualified=False, sort=False, builtin=False, - showProtected=False): - opt = [className] if className else [] + [f'recursive={str(recursive).lower()}', - f'qualified={str(qualified).lower()}', - f'sort={str(sort).lower()}', - f'builtin={str(builtin).lower()}', - f'showProtected={str(showProtected).lower()}'] - return self._ask(question='getClassNames', opt=opt) - - -class OMCPathReal(pathlib.PurePosixPath): +class _OMCPath(pathlib.PurePosixPath): """ Implementation of a basic (PurePosix)Path object which uses OMC as backend. The connection to OMC is provided via an instances of OMCSession* classes. @@ -413,21 +222,96 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") - def stat(self): + +class _OMCPathDummy(_OMCPath): + """ + Implementation of OMCPath which does not use the session data but pathlib.Path. This is a fallback solution if + OMCPath should be used without the overhead of OMCsession*. This class is based on _OMCPath and, thus, on + pathlib.PurePosixPath. THis is working well but not the correct implementation on Windows systems. To get a + representation, use pathlib.Path(.as_posix()). + """ + + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + + def is_file(self, *, follow_symlinks=True) -> bool: + """ + Check if the path is a regular file. + """ + return self._path().is_file() + + def is_dir(self, *, follow_symlinks=True) -> bool: + """ + Check if the path is a directory. + """ + return self._path().is_dir() + + def is_absolute(self): + """ + Check if the path is an absolute path considering the possibility that we are running locally on Windows. This + case needs special handling as the definition of is_absolute() differs. + """ + return self._path().is_absolute() + + def read_text(self, encoding=None, errors=None, newline=None) -> str: + """ + Read the content of the file represented by this path as text. + + The additional arguments `encoding`, `errors` and `newline` are only defined for compatibility with Path() + definition. + """ + return self._path().read_text(encoding='utf-8') + + def write_text(self, data: str, encoding=None, errors=None, newline=None): + """ + Write text data to the file represented by this path. + + The additional arguments `encoding`, `errors`, and `newline` are only defined for compatibility with Path() + definitions. + """ + return self._path().write_text(data=data, encoding='utf-8') + + def mkdir(self, mode=0o777, parents=False, exist_ok=False): + """ + Create a directory at the path represented by this OMCPath object. + + The additional arguments `mode`, and `parents` are only defined for compatibility with Path() definitions. + """ + return self._path().mkdir(exist_ok=exist_ok) + + def cwd(self): """ - The function stat() cannot be implemented using OMC. + Returns the current working directory as an OMCPath object. + """ + return self._path().cwd() + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + return self._path().unlink(missing_ok=missing_ok) + + def resolve(self, strict: bool = False): + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + path_resolved = self._path().resolve(strict=strict) + return type(self)(path_resolved, session=self._session) + + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. """ - raise NotImplementedError("The function stat() cannot be implemented using OMC; " - "use size() to get the file size.") + return self._path().stat().st_size if sys.version_info < (3, 12): - class OMCPathCompatibility(pathlib.Path): + class _OMCPathCompatibility(pathlib.Path): """ Compatibility class for OMCPath in Python < 3.12. This allows to run all code which uses OMCPath (mainly ModelicaSystem) on these Python versions. There is one remaining limitation: only OMCProcessLocal will work as - OMCPathCompatibility is based on the standard pathlib.Path implementation. + _OMCPathCompatibility is based on the standard pathlib.Path implementation. """ # modified copy of pathlib.Path.__new__() definition @@ -435,8 +319,8 @@ def __new__(cls, *args, **kwargs): logger.warning("Python < 3.12 - using a version of class OMCPath " "based on pathlib.Path for local usage only.") - if cls is OMCPathCompatibility: - cls = OMCPathCompatibilityWindows if os.name == 'nt' else OMCPathCompatibilityPosix + if cls is _OMCPathCompatibility: + cls = _OMCPathCompatibilityWindows if os.name == 'nt' else _OMCPathCompatibilityPosix self = cls._from_parts(args) if not self._flavour.is_supported: raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") @@ -448,64 +332,22 @@ def size(self) -> int: """ return self.stat().st_size - class OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): + class _OMCPathCompatibilityPosix(pathlib.PosixPath, _OMCPathCompatibility): """ Compatibility class for OMCPath on Posix systems (Python < 3.12) """ - class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): + class _OMCPathCompatibilityWindows(pathlib.WindowsPath, _OMCPathCompatibility): """ Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - OMCPath = OMCPathCompatibility + OMCPath = _OMCPathCompatibility + OMCPathDummy = _OMCPathCompatibility else: - OMCPath = OMCPathReal - - -@dataclasses.dataclass -class OMCSessionRunData: - """ - Data class to store the command line data for running a model executable in the OMC environment. - - All data should be defined for the environment, where OMC is running (local, docker or WSL) - - To use this as a definition of an OMC simulation run, it has to be processed within - OMCProcess*.omc_run_data_update(). This defines the attribute cmd_model_executable. - """ - # cmd_path is the expected working directory - cmd_path: str - cmd_model_name: str - # command line arguments for the model executable - cmd_args: list[str] - # result file with the simulation output - cmd_result_path: str - - # command prefix data (as list of strings); needed for docker or WSL - cmd_prefix: Optional[list[str]] = None - # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) - cmd_model_executable: Optional[str] = None - # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows - cmd_library_path: Optional[str] = None - # command timeout - cmd_timeout: Optional[float] = 10.0 - - # working directory to be used on the *local* system - cmd_cwd_local: Optional[str] = None - - def get_cmd(self) -> list[str]: - """ - Get the command line to run the model executable in the environment defined by the OMCProcess definition. - """ - - if self.cmd_model_executable is None: - raise OMCSessionException("No model file defined for the model executable!") - - cmdl = [] if self.cmd_prefix is None else self.cmd_prefix - cmdl += [self.cmd_model_executable] + self.cmd_args - - return cmdl + OMCPath = _OMCPath + OMCPathDummy = _OMCPathDummy class OMCSessionZMQ: @@ -556,22 +398,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: """ return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Modify data based on the selected OMCProcess implementation. - - Needs to be implemented in the subclasses. - """ - return self.omc_process.omc_run_data_update(omc_run_data=omc_run_data) - - @staticmethod - def run_model_executable(cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - return OMCSession.run_model_executable(cmd_run_data=cmd_run_data) - def execute(self, command: str): return self.omc_process.execute(command=command) @@ -650,6 +476,10 @@ def __init__( Initialisation for OMCSession """ + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + # store variables self._timeout = timeout # generate a random string for this instance of OMC @@ -734,6 +564,25 @@ def escape_str(value: str) -> str: """ return value.replace("\\", "\\\\").replace('"', '\\"') + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + return [] + + def get_version(self) -> str: + """ + Get the OM version. + """ + return self.sendExpression("getVersion()", parsed=True) + + def set_workdir(self, workdir: OMCPath) -> None: + """ + Set the workdir for this session. + """ + exp = f'cd("{workdir.as_posix()}")' + self.sendExpression(exp) + def omcpath(self, *path) -> OMCPath: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. @@ -752,7 +601,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. """ - names = [str(uuid.uuid4()) for _ in range(100)] if tempdir_base is None: # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement @@ -762,6 +610,12 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: tempdir_str = self.sendExpression("getTempDirectoryPath()") tempdir_base = self.omcpath(tempdir_str) + return self._tempdir(tempdir_base=tempdir_base) + + @staticmethod + def _tempdir(tempdir_base: OMCPath) -> OMCPath: + names = [str(uuid.uuid4()) for _ in range(100)] + tempdir: Optional[OMCPath] = None for name in names: # create a unique temporary directory name @@ -778,48 +632,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: return tempdir - @staticmethod - def run_model_executable(cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - - my_env = os.environ.copy() - if isinstance(cmd_run_data.cmd_library_path, str): - my_env["PATH"] = cmd_run_data.cmd_library_path + os.pathsep + my_env["PATH"] - - cmdl = cmd_run_data.get_cmd() - - logger.debug("Run OM command %s in %s", repr(cmdl), cmd_run_data.cmd_path) - try: - cmdres = subprocess.run( - cmdl, - capture_output=True, - text=True, - env=my_env, - cwd=cmd_run_data.cmd_cwd_local, - timeout=cmd_run_data.cmd_timeout, - check=True, - ) - stdout = cmdres.stdout.strip() - stderr = cmdres.stderr.strip() - returncode = cmdres.returncode - - logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - - if stderr: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}: {stderr}") - except subprocess.TimeoutExpired as ex: - raise OMCSessionException(f"Timeout running model executable {repr(cmdl)}") from ex - except subprocess.CalledProcessError as ex: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}") from ex - - return returncode - def execute(self, command: str): - warnings.warn("This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", DeprecationWarning, stacklevel=2) + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) return self.sendExpression(command, parsed=False) @@ -997,17 +816,57 @@ def _get_portfile_path(self) -> Optional[pathlib.Path]: return portfile_path - @abc.abstractmethod - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + +class OMCSessionDummy(OMCSession): + """ + Dummy OMCSession implementation without any use of an OMC server. + """ + + def __init__( + self, + timeout: float = 10.00, + version: str = "1.27.0" + ) -> None: + super().__init__(timeout=timeout) + self.model_execution_local = True + self._version = version + + def __post_init__(self) -> None: + """ + No connection to an OMC server is created by this class! """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - The main point is the definition of OMCSessionRunData.cmd_model_executable which contains the specific command - to run depending on the selected system. + def get_version(self) -> str: + """ + We can not provide an OM version as we are not link to an OMC server. Thus, the provided version string is used + directly. + """ + return self._version - Needs to be implemented in the subclasses. + def set_workdir(self, workdir: OMCPath) -> None: """ - raise NotImplementedError("This method must be implemented in subclasses!") + Set the workdir for this session. + """ + os.chdir(workdir.as_posix()) + + def omcpath(self, *path) -> OMCPath: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + return OMCPathDummy(*path, session=self) + + def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: + """ + Get a temporary directory without using OMC. + """ + if tempdir_base is None: + tempdir_str = tempfile.gettempdir() + tempdir_base = self.omcpath(tempdir_str) + + return self._tempdir(tempdir_base=tempdir_base) + + def sendExpression(self, command: str, parsed: bool = True) -> Any: + raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") class OMCSessionPort(OMCSession): @@ -1018,16 +877,11 @@ class OMCSessionPort(OMCSession): def __init__( self, omc_port: str, + timeout: float = 10.00, ) -> None: - super().__init__() + super().__init__(timeout=timeout) self._omc_port = omc_port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - raise OMCSessionException("OMCSessionPort does not support omc_run_data_update()!") - class OMCSessionLocal(OMCSession): """ @@ -1042,6 +896,8 @@ def __init__( super().__init__(timeout=timeout) + self.model_execution_local = True + # where to find OpenModelica self._omhome = self._omc_home_get(omhome=omhome) # start up omc executable, which is waiting for the ZMQ connection @@ -1112,48 +968,6 @@ def _omc_port_get(self) -> str: return port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - # create a copy of the data - omc_run_data_copy = dataclasses.replace(omc_run_data) - - # as this is the local implementation, pathlib.Path can be used - cmd_path = pathlib.Path(omc_run_data_copy.cmd_path) - - if platform.system() == "Windows": - path_dll = "" - - # set the process environment from the generated .bat file in windows which should have all the dependencies - path_bat = cmd_path / f"{omc_run_data.cmd_model_name}.bat" - if not path_bat.is_file(): - raise OMCSessionException("Batch file (*.bat) does not exist " + str(path_bat)) - - content = path_bat.read_text(encoding='utf-8') - for line in content.splitlines(): - match = re.match(r"^SET PATH=([^%]*)", line, re.IGNORECASE) - if match: - path_dll = match.group(1).strip(';') # Remove any trailing semicolons - my_env = os.environ.copy() - my_env["PATH"] = path_dll + os.pathsep + my_env["PATH"] - - omc_run_data_copy.cmd_library_path = path_dll - - cmd_model_executable = cmd_path / f"{omc_run_data_copy.cmd_model_name}.exe" - else: - # for Linux the paths to the needed libraries should be included in the executable (using rpath) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - - if not cmd_model_executable.is_file(): - raise OMCSessionException(f"Application file path not found: {cmd_model_executable}") - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() - - # define local(!) working directory - omc_run_data_copy.cmd_cwd_local = omc_run_data.cmd_path - - return omc_run_data_copy - class OMCSessionDockerHelper(OMCSession): """ @@ -1269,27 +1083,21 @@ def get_docker_container_id(self) -> str: return self._docker_container_id - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. """ - omc_run_data_copy = dataclasses.replace(omc_run_data) - - omc_run_data_copy.cmd_prefix = ( - [ - "docker", "exec", - "--user", str(self._getuid()), - "--workdir", omc_run_data_copy.cmd_path, - ] - + self._docker_extra_args - + [self._docker_container_id] - ) - - cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() + docker_cmd = [ + "docker", "exec", + "--user", str(self._getuid()), + ] + if isinstance(cwd, OMCPath): + docker_cmd += ["--workdir", cwd.as_posix()] + docker_cmd += self._docker_extra_args + if isinstance(self._docker_container_id, str): + docker_cmd += [self._docker_container_id] - return omc_run_data_copy + return docker_cmd class OMCSessionDocker(OMCSessionDockerHelper): @@ -1553,15 +1361,18 @@ def __init__( # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get() - def _wsl_cmd(self, wsl_cwd: Optional[str] = None) -> list[str]: + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ # get wsl base command wsl_cmd = ['wsl'] if isinstance(self._wsl_distribution, str): wsl_cmd += ['--distribution', self._wsl_distribution] if isinstance(self._wsl_user, str): wsl_cmd += ['--user', self._wsl_user] - if isinstance(wsl_cwd, str): - wsl_cmd += ['--cd', wsl_cwd] + if isinstance(cwd, OMCPath): + wsl_cmd += ['--cd', cwd.as_posix()] wsl_cmd += ['--'] return wsl_cmd @@ -1569,7 +1380,7 @@ def _wsl_cmd(self, wsl_cwd: Optional[str] = None) -> list[str]: def _omc_process_get(self) -> subprocess.Popen: my_env = os.environ.copy() - omc_command = self._wsl_cmd() + [ + omc_command = self.model_execution_prefix() + [ self._wsl_omc, "--locale=C", "--interactive=zmq", @@ -1592,7 +1403,7 @@ def _omc_port_get(self) -> str: omc_portfile_path = self._get_portfile_path() if omc_portfile_path is not None: output = subprocess.check_output( - args=self._wsl_cmd() + ["cat", omc_portfile_path.as_posix()], + args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL, ) port = output.decode().strip() @@ -1613,17 +1424,3 @@ def _omc_port_get(self) -> str: f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") return port - - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - omc_run_data_copy = dataclasses.replace(omc_run_data) - - omc_run_data_copy.cmd_prefix = self._wsl_cmd(wsl_cwd=omc_run_data.cmd_path) - - cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() - - return omc_run_data_copy diff --git a/OMPython/__init__.py b/OMPython/__init__.py index de861736..a9f4cf93 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -1,46 +1,78 @@ # -*- coding: utf-8 -*- """ OMPython is a Python interface to OpenModelica. -To get started, create an OMCSessionZMQ object: -from OMPython import OMCSessionZMQ -omc = OMCSessionZMQ() +To get started on a local OMC server, create an OMCSessionLocal object: + +``` +import OMPython +omc = OMPython.OMCSessionLocal() omc.sendExpression("command") +``` + """ +from OMPython.ModelExecution import ( + ModelExecutionCmd, + ModelExecutionData, + ModelExecutionException, +) from OMPython.ModelicaSystem import ( - LinearizationResult, ModelicaSystem, - ModelicaSystemCmd, - ModelicaSystemDoE, +) +from OMPython.ModelicaSystemBase import ( + LinearizationResult, + ModelicaSystemBase, ModelicaSystemError, ) +from OMPython.ModelicaSystemDoE import ( + ModelicaSystemDoE, +) +from OMPython.ModelicaSystemRunner import ( + ModelicaSystemRunner, +) from OMPython.OMCSession import ( - OMCSessionCmd, OMCSessionException, - OMCSessionRunData, + OMCSessionZMQ, - OMCSessionPort, - OMCSessionLocal, + + OMCPath, + OMCPathDummy, + + OMCSession, OMCSessionDocker, OMCSessionDockerContainer, + OMCSessionDummy, + OMCSessionLocal, + OMCSessionPort, OMCSessionWSL, ) # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', + + 'ModelicaSystemBase', 'ModelicaSystem', - 'ModelicaSystemCmd', 'ModelicaSystemDoE', 'ModelicaSystemError', + 'ModelicaSystemRunner', + + 'ModelExecutionCmd', + 'ModelExecutionData', + 'ModelExecutionException', - 'OMCSessionCmd', 'OMCSessionException', - 'OMCSessionRunData', + 'OMCSessionZMQ', - 'OMCSessionPort', - 'OMCSessionLocal', + + 'OMCPath', + 'OMCPathDummy', + + 'OMCSession', 'OMCSessionDocker', 'OMCSessionDockerContainer', + 'OMCSessionDummy', + 'OMCSessionLocal', + 'OMCSessionPort', 'OMCSessionWSL', ] diff --git a/tests/test_FMIRegression.py b/tests/test_FMIRegression.py index b61b8d49..55b2ce20 100644 --- a/tests/test_FMIRegression.py +++ b/tests/test_FMIRegression.py @@ -1,5 +1,6 @@ import tempfile import pathlib +import pytest import shutil import os @@ -29,41 +30,51 @@ def test_Modelica_Blocks_Examples_Filter(): buildModelFMU("Modelica.Blocks.Examples.Filter") +@pytest.mark.skip("takes too long") def test_Modelica_Blocks_Examples_RealNetwork1(): buildModelFMU("Modelica.Blocks.Examples.RealNetwork1") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Analog_Examples_CauerLowPassAnalog(): buildModelFMU("Modelica.Electrical.Analog.Examples.CauerLowPassAnalog") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Digital_Examples_FlipFlop(): buildModelFMU("Modelica.Electrical.Digital.Examples.FlipFlop") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_FirstGrounded(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.FirstGrounded") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_CoupledClutches(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.CoupledClutches") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_DoublePendulum(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.DoublePendulum") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_FreeBody(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.FreeBody") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_PumpingSystem(): buildModelFMU("Modelica.Fluid.Examples.PumpingSystem") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_TraceSubstances_RoomCO2WithControls(): buildModelFMU("Modelica.Fluid.Examples.TraceSubstances.RoomCO2WithControls") +@pytest.mark.skip("takes too long") def test_Modelica_Clocked_Examples_SimpleControlledDrive_ClockedWithDiscreteTextbookController(): buildModelFMU("Modelica.Clocked.Examples.SimpleControlledDrive.ClockedWithDiscreteTextbookController") diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index dcc55d0b..2b20501f 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -38,6 +38,7 @@ def model_firstorder(tmp_path, model_firstorder_content): return mod +@pytest.mark.skip("takes too long") def test_ModelicaSystem_loop(model_firstorder): def worker(): mod = OMPython.ModelicaSystem() @@ -155,11 +156,10 @@ def test_customBuildDirectory(tmp_path, model_firstorder): @skip_on_windows @skip_python_older_312 def test_getSolutions_docker(model_firstorder): - omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - omc = OMPython.OMCSessionZMQ(omc_process=omcp) + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") mod = OMPython.ModelicaSystem( - session=omc.omc_process, + session=omcs, ) mod.model( model_file=model_firstorder, @@ -346,20 +346,33 @@ def test_getters(tmp_path): with pytest.raises(KeyError): mod.getInputs("thisInputDoesNotExist") # getOutputs before simulate() - assert mod.getOutputs() == {'y': '-0.4'} - assert mod.getOutputs("y") == ["-0.4"] - assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + output = mod.getOutputs() + assert len(output) == 1 + assert 'y' in output.keys() + assert np.isclose(output['y'], -0.4) + assert np.isclose(mod.getOutputs("y"), -0.4) + output = mod.getOutputs(["y", "y"]) + assert len(output) == 2 + assert np.isclose(output[0], -0.4) + assert np.isclose(output[1], -0.4) with pytest.raises(KeyError): mod.getOutputs("thisOutputDoesNotExist") # getContinuous before simulate(): - assert mod.getContinuous() == { - 'x': '1.0', - 'der(x)': None, - 'y': '-0.4' - } - assert mod.getContinuous("y") == ['-0.4'] - assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + continuous = mod.getContinuous() + assert len(continuous) == 3 + assert 'x' in continuous.keys() + assert np.isclose(continuous['x'], 1.0) + assert 'der(x)' in continuous.keys() + assert np.isnan(continuous['der(x)']) + assert 'y' in continuous.keys() + assert np.isclose(continuous['y'], -0.4) + continuous = mod.getContinuous('y') + assert np.isclose(continuous, -0.4) + continuous = mod.getContinuous(['y', 'x']) + assert np.isclose(continuous[0], -0.4) + assert np.isclose(continuous[1], 1.0) + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter @@ -378,7 +391,7 @@ def test_getters(tmp_path): assert np.isclose(d["y"], dx_analytical, 1e-4) assert mod.getOutputs("y") == [d["y"]] assert mod.getOutputs(["y", "y"]) == [d["y"], d["y"]] - with pytest.raises(KeyError): + with pytest.raises(OMPython.ModelicaSystemError): mod.getOutputs("thisOutputDoesNotExist") # getContinuous after simulate() should return values at end of simulation: diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 2480aad9..6fa2658f 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -23,11 +23,15 @@ def mscmd_firstorder(model_firstorder): model_file=model_firstorder, model_name="M", ) - mscmd = OMPython.ModelicaSystemCmd( - session=mod.get_session(), + + mscmd = OMPython.ModelExecutionCmd( runpath=mod.getWorkDirectory(), - modelname=mod._model_name, + cmd_local=mod.get_session().model_execution_local, + cmd_windows=mod.get_session().model_execution_windows, + cmd_prefix=mod.get_session().model_execution_prefix(cwd=mod.getWorkDirectory()), + model_name=mod._model_name, ) + return mscmd diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index 79c6e62d..94de5e69 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -55,12 +55,17 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem() + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -69,16 +74,22 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): @skip_on_windows @skip_python_older_312 def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): - omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - omc = OMPython.OMCSessionZMQ(omc_process=omcp) + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omc = OMPython.OMCSessionZMQ(omc_process=omcs) assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, - session=omcp, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -87,15 +98,22 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): - tmpdir = tmp_path / 'DoE' - tmpdir.mkdir(exist_ok=True) + omcs = OMPython.OMCSessionWSL() + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, - resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py new file mode 100644 index 00000000..74757215 --- /dev/null +++ b/tests/test_ModelicaSystemRunner.py @@ -0,0 +1,96 @@ +import numpy as np +import pytest + +import OMPython + + +@pytest.fixture +def model_firstorder_content(): + return """ +model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""" + + +@pytest.fixture +def model_firstorder(tmp_path, model_firstorder_content): + mod = tmp_path / "M.mo" + mod.write_text(model_firstorder_content) + return mod + + +@pytest.fixture +def param(): + x0 = 1 + a = -1 + tau = -1 / a + stopTime = 5*tau + + return { + 'x0': x0, + 'a': a, + 'stopTime': stopTime, + } + + +def test_runner(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystem() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMCSessionDummy( + version=mod.get_session().get_version(), + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not has the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +def _run_simulation(mod, resultfile, param): + simOptions = {"stopTime": param['stopTime'], "stepSize": 0.1, "tolerance": 1e-8} + mod.setSimulationOptions(**simOptions) + mod.simulate(resultfile=resultfile) + + assert resultfile.exists() + + +def _check_result(mod, resultfile, param): + x = mod.getSolutions(resultfile=resultfile, varList="x") + t, x2 = mod.getSolutions(resultfile=resultfile, varList=["time", "x"]) + assert (x2 == x).all() + sol_names = mod.getSolutions(resultfile=resultfile) + assert isinstance(sol_names, tuple) + assert "time" in sol_names + assert "x" in sol_names + assert "der(x)" in sol_names + with pytest.raises(OMPython.ModelicaSystemError): + mod.getSolutions(resultfile=resultfile, varList="thisVariableDoesNotExist") + assert np.isclose(t[0], 0), "time does not start at 0" + assert np.isclose(t[-1], param['stopTime']), "time does not end at stopTime" + x_analytical = param['x0'] * np.exp(param['a']*t) + assert np.isclose(x, x_analytical, rtol=1e-4).all() diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py deleted file mode 100644 index bff4afde..00000000 --- a/tests/test_OMSessionCmd.py +++ /dev/null @@ -1,20 +0,0 @@ -import OMPython - - -def test_isPackage(): - omczmq = OMPython.OMCSessionZMQ() - omccmd = OMPython.OMCSessionCmd(session=omczmq.omc_process) - assert not omccmd.isPackage('Modelica') - - -def test_isPackage2(): - mod = OMPython.ModelicaSystem() - mod.model( - model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", - libraries=["Modelica"], - ) - omccmd = OMPython.OMCSessionCmd(session=mod.get_session()) - assert omccmd.isPackage('Modelica') - - -# TODO: add more checks ...