diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 816d8fc3..0c49baef 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -3,6 +3,7 @@ Definition of main class to run Modelica simulations - ModelicaSystem. """ +import abc import ast from dataclasses import dataclass import itertools @@ -14,18 +15,23 @@ import re import textwrap import threading -from typing import Any, cast, Optional +from typing import Any, cast, Optional, Tuple import warnings import xml.etree.ElementTree as ET import numpy as np from OMPython.OMCSession import ( + ModelExecutionData, + ModelExecutionException, + OMCSessionException, - OMCSessionRunData, - OMCSession, OMCSessionLocal, - OMCPath, + + OMPathABC, + + OMSessionABC, + OMSessionRunner, ) # define logger using the current module name as ID @@ -34,7 +40,7 @@ class ModelicaSystemError(Exception): """ - Exception used in ModelicaSystem and ModelicaSystemCmd classes. + Exception used in ModelicaSystem classes. """ @@ -89,7 +95,7 @@ def __getitem__(self, index: int): return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] -class ModelicaSystemCmd: +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 @@ -98,16 +104,22 @@ class ModelicaSystemCmd: def __init__( self, - session: OMCSession, - runpath: OMCPath, - modelname: Optional[str] = None, + 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 modelname is None: - raise ModelicaSystemError("Missing model name!") + if model_name is None: + raise ModelExecutionException("Missing model name!") - self._session = session - self._runpath = runpath - self._model_name = modelname + 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] = {} @@ -152,26 +164,26 @@ def override2str( elif isinstance(orval, numbers.Number): val_str = str(orval) else: - raise ModelicaSystemError(f"Invalid value for override key {orkey}: {type(orval)}") + raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") return f"{orkey}={val_str}" if not isinstance(key, str): - raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})") + raise ModelExecutionException(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'!") + raise ModelExecutionException("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)})") + raise ModelExecutionException("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)})") + 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: @@ -193,7 +205,7 @@ def override2str( elif isinstance(val, numbers.Number): argval = str(val) else: - raise ModelicaSystemError(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") + 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)} " @@ -233,7 +245,7 @@ def get_cmd_args(self) -> list[str]: return cmdl - def definition(self) -> OMCSessionRunData: + def definition(self) -> ModelExecutionData: """ Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. """ @@ -242,18 +254,50 @@ def definition(self) -> OMCSessionRunData: 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(), + # 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_path=result_file, - ) - - omc_run_data_updated = self._session.omc_run_data_update( - omc_run_data=omc_run_data, + 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_updated + return omc_run_data @staticmethod def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: @@ -262,17 +306,19 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | n 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) + 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 ModelicaSystemError(f"Invalid simulation flag: {arg}") + raise ModelExecutionException(f"Invalid simulation flag: {arg}") arg = arg[1:] parts = arg.split('=') if len(parts) == 1: @@ -284,40 +330,34 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | n for item in override.split(','): kv = item.split('=') if not 0 < len(kv) < 3: - raise ModelicaSystemError(f"Invalid value for '-override': {override}") + raise ModelExecutionException(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 + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex simargs[parts[0]] = override_dict return simargs -class ModelicaSystem: +class ModelicaSystemABC(metaclass=abc.ABCMeta): """ - Class to simulate a Modelica model using OpenModelica via OMCSession. + Base class to simulate a Modelica models. """ def __init__( self, - command_line_options: Optional[list[str]] = None, + session: OMSessionABC, work_directory: Optional[str | os.PathLike] = None, - omhome: Optional[str] = None, - session: Optional[OMCSession] = None, ) -> None: """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). Args: - command_line_options: List with extra command line options as elements. The list elements are - provided to omc via setCommandLineOptions(). If set, the default values will be overridden. - To disable any command line options, use an empty list. 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. - omhome: path to OMC to be used when creating the OMC session (see OMCSession). session: definition of a (local) OMC session to be used. If unspecified, a new local session will be created. """ @@ -343,160 +383,38 @@ def __init__( 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) + self._session = session # get OpenModelica version version_str = self._session.get_version() self._version = self._parse_om_version(version=version_str) - # 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 - # the simulation executable is reused in linearize() via the runtime flag '-l' - command_line_options = [ - "--linearizationDumpLanguage=python", - "--generateSymbolicLinearization", - ] - 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._result_file: Optional[OMPathABC] = None # for storing result file - self._work_dir: OMCPath = self.setWorkDirectory(work_directory) + self._work_dir: OMPathABC = 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._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None - def model( - self, - model_name: Optional[str] = None, - model_file: Optional[str | os.PathLike] = None, - libraries: Optional[list[str | tuple[str, str]]] = None, - variable_filter: Optional[str] = None, - build: bool = True, - ) -> None: - """Load and build a Modelica model. - - This method loads the model file and builds it if requested (build == True). - - Args: - model_file: Path to the model file. Either absolute or relative to - the current working directory. - model_name: The name of the model class. If it is contained within - a package, "PackageName.ModelName" should be used. - libraries: List of libraries to be loaded before the model itself is - loaded. Two formats are supported for the list elements: - lmodel=["Modelica"] for just the library name - and lmodel=[("Modelica","3.2.3")] for specifying both the name - and the version. - variable_filter: A regular expression. Only variables fully - matching the regexp will be stored in the result file. - Leaving it unspecified is equivalent to ".*". - build: Boolean controlling whether the model should be - built when constructor is called. If False, the constructor - simply loads the model without compiling. - - Examples: - mod = ModelicaSystem() - # and then one of the lines below - mod.model(name="modelName", file="ModelicaModel.mo", ) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) - """ - - 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!") - - if libraries is None: - libraries = [] - - if not isinstance(libraries, list): - raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") - - # set variables - self._model_name = model_name # Model class name - self._libraries = libraries # may be needed if model is derived from other model - self._variable_filter = variable_filter - - if self._libraries: - self._loadLibrary(libraries=self._libraries) - - self._file_name = None - if model_file is not None: - file_path = pathlib.Path(model_file) - # special handling for OMCProcessLocal - consider a relative path - if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): - file_path = pathlib.Path.cwd() / file_path - if not file_path.is_file(): - raise IOError(f"Model file {file_path} does not exist!") - - self._file_name = self.getWorkDirectory() / file_path.name - if (isinstance(self._session, OMCSessionLocal) - and file_path.as_posix() == self._file_name.as_posix()): - pass - elif self._file_name.is_file(): - raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") - else: - content = file_path.read_text(encoding='utf-8') - self._file_name.write_text(content) - - if self._file_name is not None: - self._loadFile(fileName=self._file_name) - - if build: - self.buildModel(variable_filter) - - def get_session(self) -> OMCSession: + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. """ return self._session - def set_command_line_options(self, command_line_option: str): + def get_model_name(self) -> str: """ - Set the provided command line option via OMC setCommandLineOptions(). + Return the defined model name. """ - expr = f'setCommandLineOptions("{command_line_option}")' - self.sendExpression(expr=expr) - - def _loadFile(self, fileName: OMCPath): - # load file - self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') + if not isinstance(self._model_name, str): + raise ModelicaSystemError("No model name defined!") - # for loading file/package, loading model and building model - def _loadLibrary(self, libraries: list): - # load Modelica standard libraries or Modelica files if needed - for element in libraries: - if element is not None: - if isinstance(element, str): - if element.endswith(".mo"): - api_call = "loadFile" - else: - api_call = "loadModel" - self._requestApi(apiName=api_call, entity=element) - elif isinstance(element, tuple): - if not element[1]: - expr_load_lib = f"loadModel({element[0]})" - else: - expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' - self.sendExpression(expr=expr_load_lib) - else: - raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " - f"{element} is of type {type(element)}, " - "The following patterns are supported:\n" - '1)["Modelica"]\n' - '2)[("Modelica","3.2.3"), "PowerSystems"]\n') + return self._model_name - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath: + def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathABC: """ 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. @@ -518,76 +436,32 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - # ... and also return the defined path return workdir - def getWorkDirectory(self) -> OMCPath: + def getWorkDirectory(self) -> OMPathABC: """ 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: - filter_def = variableFilter - elif self._variable_filter is not None: - filter_def = self._variable_filter - - if filter_def is not None: - var_filter = f'variableFilter="{filter_def}"' - else: - var_filter = 'variableFilter=".*"' - - build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) - logger.debug("OM model build result: %s", build_model_result) - + def check_model_executable(self): + """ + Check if the model executable is working + """ # check if the executable exists ... - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + 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 = self._session.run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() if returncode != 0: raise ModelicaSystemError("Model executable not working!") - 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: - """ - Wrapper for OMCSession.sendExpression(). - """ - try: - retval = self._session.sendExpression(expr=expr, parsed=parsed) - except OMCSessionException as ex: - raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex - - logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") - - return retval - - # request to OMC - def _requestApi( - self, - apiName: str, - entity: Optional[str] = None, - properties: Optional[str] = None, - ) -> Any: - if entity is not None and properties is not None: - expr = f'{apiName}({entity}, {properties})' - elif entity is not None and properties is None: - if apiName in ("loadFile", "importFMU"): - expr = f'{apiName}("{entity}")' - else: - expr = f'{apiName}({entity})' - else: - expr = f'{apiName}()' - - return self.sendExpression(expr=expr) - - def _xmlparse(self, xml_file: OMCPath): + def _xmlparse(self, xml_file: OMPathABC): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -729,132 +603,35 @@ def getContinuousInitial( raise ModelicaSystemError("Unhandled input for getContinousInitial()") - def getContinuousFinal( + def getParameters( self, names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """ - Get (final) values of continuous signals (at stopTime). + ) -> dict[str, str] | list[str]: + """Get parameter values. Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. + 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 - {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, ...]. + {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.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)] + >>> mod.getParameters() + {'Name1': '1.23', 'Name2': '4.56'} + >>> mod.getParameters("Name1") + ['1.23'] + >>> mod.getParameters(["Name1","Name2"]) + ['1.23', '4.56'] """ - if not self._simulated: - raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") - - 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 KeyError(f"{names} is not continuous") - if names is None: - get_continuous_solution(name_list=list(self._continuous.keys())) - return self._continuous - + return self._params if isinstance(names, str): - get_continuous_solution(name_list=[names]) - return [self._continuous[names]] - - if isinstance(names, list): - get_continuous_solution(name_list=names) - values = [] - for name in names: - values.append(self._continuous[name]) - return values - - raise ModelicaSystemError("Unhandled input for getContinousFinal()") - - def getContinuous( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of continuous signals. - - 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 - 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: - Before simulate(): - >>> mod.getContinuous() - {'x': '1.0', 'der(x)': None, 'y': '-0.4'} - >>> mod.getContinuous("y") - ['-0.4'] - >>> mod.getContinuous(["y","x"]) - ['-0.4', '1.0'] - - After simulate(): - >>> mod.getContinuous() - {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} - >>> mod.getContinuous("x") - [np.float64(0.68)] - >>> mod.getContinuous(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] - """ - if not self._simulated: - return self.getContinuousInitial(names=names) - - return self.getContinuousFinal(names=names) - - 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]] + return [self._params[names]] if isinstance(names, list): return [self._params[x] for x in names] @@ -932,102 +709,6 @@ def getOutputsInitial( raise ModelicaSystemError("Unhandled input for getOutputsInitial()") - def getOutputsFinal( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get (final) values of output signals (at stopTime). - - 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.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 not self._simulated: - raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") - - 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 KeyError(f"{names} is not a valid output") - - if names is None: - get_outputs_solution(name_list=list(self._outputs.keys())) - return self._outputs - - if isinstance(names, str): - get_outputs_solution(name_list=[names]) - return [self._outputs[names]] - - if isinstance(names, list): - get_outputs_solution(name_list=names) - values = [] - for name in names: - values.append(self._outputs[name]) - return values - - raise ModelicaSystemError("Unhandled input for getOutputs()") - - def getOutputs( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of output signals. - - 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, - 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: - Before simulate(): - >>> mod.getOutputs() - {'out1': '-0.4', 'out2': '1.2'} - >>> mod.getOutputs("out1") - ['-0.4'] - >>> mod.getOutputs(["out1","out2"]) - ['-0.4', '1.2'] - - After simulate(): - >>> mod.getOutputs() - {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} - >>> mod.getOutputs("out1") - [np.float64(-0.1234)] - >>> mod.getOutputs(["out1","out2"]) - [np.float64(-0.1234), np.float64(2.1)] - """ - if not self._simulated: - return self.getOutputsInitial(names=names) - - return self.getOutputsFinal(names=names) - def getSimulationOptions( self, names: Optional[str | list[str]] = None, @@ -1153,8 +834,8 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, - om_cmd: ModelicaSystemCmd, - override_file: OMCPath, + om_cmd: ModelExecutionCmd, + override_file: OMPathABC, override_var: dict[str, str], override_sim: dict[str, str], ) -> None: @@ -1186,10 +867,10 @@ def _process_override_data( def simulate_cmd( self, - result_file: OMCPath, + result_file: OMPathABC, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelicaSystemCmd: + ) -> 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. @@ -1211,10 +892,12 @@ def simulate_cmd( An instance if ModelicaSystemCmd to run the requested simulation. """ - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + 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 @@ -1282,14 +965,14 @@ def simulate( 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): + elif isinstance(resultfile, OMPathABC): 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): + if not isinstance(self._result_file, OMPathABC): raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") om_cmd = self.simulate_cmd( @@ -1303,7 +986,7 @@ def simulate( self._result_file.unlink() # ... run simulation ... cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=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 @@ -1317,151 +1000,50 @@ def simulate( self._simulated = True - def plot( - self, - plotdata: str, - resultfile: Optional[str | os.PathLike] = None, - ) -> None: + @staticmethod + def _prepare_input_data( + input_args: Any, + input_kwargs: dict[str, Any], + ) -> dict[str, str]: """ - Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the - plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. + Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. """ - if not isinstance(self._session, OMCSessionLocal): - raise ModelicaSystemError("Plot is using the OMC plot functionality; " - "thus, it is only working if OMC is running locally!") - - if resultfile is not None: - plot_result_file = self._session.omcpath(resultfile) - elif self._result_file is not None: - plot_result_file = self._result_file - else: - raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " - "or provide a result file!") + 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}") - if not plot_result_file.is_file(): - raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") + input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' - self.sendExpression(expr=expr) + return input_data_from_str - def getSolutions( - self, - varList: Optional[str | list[str]] = None, - resultfile: Optional[str | os.PathLike] = None, - ) -> tuple[str] | np.ndarray: - """Extract simulation results from a result data file. + input_data: dict[str, str] = {} - Args: - varList: Names of variables to be extracted. Either unspecified to - get names of available variables, or a single variable name - as a string, or a list of variable names. - resultfile: Path to the result file. If unspecified, the result - file created by simulate() is used. + 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) - Returns: - If varList is None, a tuple with names of all variables - is returned. - If varList is a string, a 1D numpy array is returned. - If varList is a list, a 2D numpy array is returned. - - Examples: - >>> mod.getSolutions() - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"]) - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - >>> mod.getSolutions(resultfile="c:/a.mat") - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x", resultfile="c:/a.mat") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - """ - if resultfile is None: - if self._result_file is None: - raise ModelicaSystemError("No result file found. Run simulate() first.") - result_file = self._result_file - else: - result_file = self._session.omcpath(resultfile) - - # check if the result file exits - if not result_file.is_file(): - raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") - - # get absolute path - result_file = result_file.absolute() - - result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') - self.sendExpression(expr="closeSimulationResultFile()") - if varList is None: - return result_vars - - if isinstance(varList, str): - var_list_checked = [varList] - elif isinstance(varList, list): - var_list_checked = varList - else: - raise ModelicaSystemError("Unhandled input for getSolutions()") - - for var in var_list_checked: - if var == "time": - continue - if var not in result_vars: - raise ModelicaSystemError(f"Requested data {repr(var)} does not exist") - variables = ",".join(var_list_checked) - res = self.sendExpression(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') - np_res = np.array(res) - self.sendExpression(expr="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)}!") + 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(): @@ -1715,7 +1297,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: + def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: """ 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. @@ -1770,110 +1352,6 @@ def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: return csvfile - def convertMo2Fmu( - self, - version: str = "2.0", - fmuType: str = "me_cs", - fileNamePrefix: Optional[str] = None, - includeResources: bool = True, - ) -> OMCPath: - """Translate the model into a Functional Mockup Unit. - - Args: - See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html - - Returns: - str: Path to the created '*.fmu' file. - - Examples: - >>> mod.convertMo2Fmu() - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", - includeResources=True) - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - """ - - if fileNamePrefix is None: - if self._model_name is None: - fileNamePrefix = "" - else: - fileNamePrefix = self._model_name - include_resources_str = "true" if includeResources else "false" - - properties = (f'version="{version}", fmuType="{fmuType}", ' - f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') - fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) - fmu_path = self._session.omcpath(fmu) - - # report proper error message - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - return fmu_path - - # to convert FMU to Modelica model - def convertFmu2Mo( - self, - fmu: os.PathLike, - ) -> OMCPath: - """ - In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate - Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". - Currently, it only supports Model Exchange conversion. - usage - >>> convertFmu2Mo("c:/BouncingBall.Fmu") - """ - - fmu_path = self._session.omcpath(fmu) - - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) - filepath = self.getWorkDirectory() / filename - - # report proper error message - if not filepath.is_file(): - raise ModelicaSystemError(f"Missing file {filepath.as_posix()}") - - self.model( - model_name=f"{fmu_path.stem}_me_FMU", - model_file=filepath, - ) - - return filepath - - def optimize(self) -> dict[str, Any]: - """Perform model-based optimization. - - Optimization options set by setOptimizationOptions() are used. - - Returns: - A dict with various values is returned. One of these values is the - path to the result file. - - Examples: - >>> mod.optimize() - {'messages': 'LOG_SUCCESS | info | The initialization finished successfully without homotopy method. ...' - 'resultFile': '/tmp/tmp68guvjhs/BangBang2021_res.mat', - 'simulationOptions': 'startTime = 0.0, stopTime = 1.0, numberOfIntervals = ' - "1000, tolerance = 1e-8, method = 'optimization', " - "fileNamePrefix = 'BangBang2021', options = '', " - "outputFormat = 'mat', variableFilter = '.*', cflags = " - "'', simflags = '-s=\\'optimization\\' " - "-optimizerNP=\\'1\\''", - 'timeBackend': 0.008684897, - 'timeCompile': 0.7546678929999999, - 'timeFrontend': 0.045438053000000006, - 'timeSimCode': 0.0018537170000000002, - 'timeSimulation': 0.266354356, - 'timeTemplates': 0.002007785, - 'timeTotal': 1.079097854} - """ - 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, @@ -1903,13 +1381,15 @@ def linearize( # 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" + "use ModelicaSystemOMC() to build the model first" ) - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + 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, ) self._process_override_data( @@ -1949,7 +1429,7 @@ def linearize( linear_file.unlink(missing_ok=True) cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() if returncode != 0: raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") if not linear_file.is_file(): @@ -2010,50 +1490,675 @@ def getLinearStates(self) -> list[str]: return self._linearized_states -class ModelicaSystemDoE: +class ModelicaSystemOMC(ModelicaSystemABC): + """ + Class to simulate a Modelica model using OpenModelica via OMCSession. """ - Class to run DoEs based on a (Open)Modelica model using ModelicaSystem - Example - ------- - ``` - import OMPython - import pathlib + def __init__( + self, + command_line_options: Optional[list[str]] = None, + work_directory: Optional[str | os.PathLike] = None, + omhome: Optional[str] = None, + session: Optional[OMSessionABC] = None, + ) -> None: + """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). + Args: + command_line_options: List with extra command line options as elements. The list elements are + provided to omc via setCommandLineOptions(). If set, the default values will be overridden. + To disable any command line options, use an empty list. + 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. + omhome: path to OMC to be used when creating the OMC session (see OMCSession). + session: definition of a (local) OMC session to be used. If + unspecified, a new local session will be created. + """ - def run_doe(): - mypath = pathlib.Path('.') + if session is None: + session = OMCSessionLocal(omhome=omhome) - 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" + super().__init__( + session=session, + work_directory=work_directory, ) - param = { - # structural - 'p': [1, 2], - 'q': [3, 4], - # non-structural - 'a': [5, 6], - 'b': [7, 8], - } - - resdir = mypath / 'DoE' - resdir.mkdir(exist_ok=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 + # the simulation executable is reused in linearize() via the runtime flag '-l' + command_line_options = [ + "--linearizationDumpLanguage=python", + "--generateSymbolicLinearization", + ] + for opt in command_line_options: + self.set_command_line_options(command_line_option=opt) - doe_mod = OMPython.ModelicaSystemDoE( + def model( + self, + model_name: Optional[str] = None, + model_file: Optional[str | os.PathLike] = None, + libraries: Optional[list[str | tuple[str, str]]] = None, + variable_filter: Optional[str] = None, + build: bool = True, + ) -> None: + """Load and build a Modelica model. + + This method loads the model file and builds it if requested (build == True). + + Args: + model_file: Path to the model file. Either absolute or relative to + the current working directory. + model_name: The name of the model class. If it is contained within + a package, "PackageName.ModelName" should be used. + libraries: List of libraries to be loaded before the model itself is + loaded. Two formats are supported for the list elements: + lmodel=["Modelica"] for just the library name + and lmodel=[("Modelica","3.2.3")] for specifying both the name + and the version. + variable_filter: A regular expression. Only variables fully + matching the regexp will be stored in the result file. + Leaving it unspecified is equivalent to ".*". + build: Boolean controlling whether the model should be + built when constructor is called. If False, the constructor + simply loads the model without compiling. + + Examples: + mod = ModelicaSystemOMC() + # and then one of the lines below + mod.model(name="modelName", file="ModelicaModel.mo", ) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) + """ + + 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!") + + if libraries is None: + libraries = [] + + if not isinstance(libraries, list): + raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") + + # set variables + self._model_name = model_name # Model class name + self._libraries = libraries # may be needed if model is derived from other model + self._variable_filter = variable_filter + + if self._libraries: + self._loadLibrary(libraries=self._libraries) + + self._file_name = None + if model_file is not None: + file_path = pathlib.Path(model_file) + # special handling for OMCProcessLocal - consider a relative path + if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): + file_path = pathlib.Path.cwd() / file_path + if not file_path.is_file(): + raise IOError(f"Model file {file_path} does not exist!") + + self._file_name = self.getWorkDirectory() / file_path.name + if (isinstance(self._session, OMCSessionLocal) + and file_path.as_posix() == self._file_name.as_posix()): + pass + elif self._file_name.is_file(): + raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") + else: + content = file_path.read_text(encoding='utf-8') + self._file_name.write_text(content) + + if self._file_name is not None: + self._loadFile(fileName=self._file_name) + + if build: + self.buildModel(variable_filter) + + def set_command_line_options(self, command_line_option: str): + """ + Set the provided command line option via OMC setCommandLineOptions(). + """ + expr = f'setCommandLineOptions("{command_line_option}")' + self.sendExpression(expr=expr) + + def _loadFile(self, fileName: OMPathABC): + # load file + self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') + + # for loading file/package, loading model and building model + def _loadLibrary(self, libraries: list): + # load Modelica standard libraries or Modelica files if needed + for element in libraries: + if element is not None: + if isinstance(element, str): + if element.endswith(".mo"): + api_call = "loadFile" + else: + api_call = "loadModel" + self._requestApi(apiName=api_call, entity=element) + elif isinstance(element, tuple): + if not element[1]: + expr_load_lib = f"loadModel({element[0]})" + else: + expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' + self.sendExpression(expr=expr_load_lib) + else: + raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " + f"{element} is of type {type(element)}, " + "The following patterns are supported:\n" + '1)["Modelica"]\n' + '2)[("Modelica","3.2.3"), "PowerSystems"]\n') + + def buildModel(self, variableFilter: Optional[str] = None): + filter_def: Optional[str] = None + if variableFilter is not None: + filter_def = variableFilter + elif self._variable_filter is not None: + filter_def = self._variable_filter + + if filter_def is not None: + var_filter = f'variableFilter="{filter_def}"' + else: + var_filter = 'variableFilter=".*"' + + 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 ... + 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: + """ + Wrapper for OMCSession.sendExpression(). + """ + try: + retval = self._session.sendExpression(expr=expr, parsed=parsed) + except OMCSessionException as ex: + raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex + + logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") + + return retval + + # request to OMC + def _requestApi( + self, + apiName: str, + entity: Optional[str] = None, + properties: Optional[str] = None, + ) -> Any: + if entity is not None and properties is not None: + expr = f'{apiName}({entity}, {properties})' + elif entity is not None and properties is None: + if apiName in ("loadFile", "importFMU"): + expr = f'{apiName}("{entity}")' + else: + expr = f'{apiName}({entity})' + else: + expr = f'{apiName}()' + + return self.sendExpression(expr=expr) + + def getContinuousFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (final) values of continuous signals (at stopTime). + + 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.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)] + """ + if not self._simulated: + raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") + + 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 KeyError(f"{names} is not continuous") + + if names is None: + get_continuous_solution(name_list=list(self._continuous.keys())) + return self._continuous + + if isinstance(names, str): + get_continuous_solution(name_list=[names]) + return [self._continuous[names]] + + if isinstance(names, list): + get_continuous_solution(name_list=names) + values = [] + for name in names: + values.append(self._continuous[name]) + return values + + raise ModelicaSystemError("Unhandled input for getContinousFinal()") + + def getContinuous( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of continuous signals. + + 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 + 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: + Before simulate(): + >>> mod.getContinuous() + {'x': '1.0', 'der(x)': None, 'y': '-0.4'} + >>> mod.getContinuous("y") + ['-0.4'] + >>> mod.getContinuous(["y","x"]) + ['-0.4', '1.0'] + + After simulate(): + >>> mod.getContinuous() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuous("x") + [np.float64(0.68)] + >>> mod.getContinuous(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] + """ + if not self._simulated: + return self.getContinuousInitial(names=names) + + return self.getContinuousFinal(names=names) + + def getOutputsFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get (final) values of output signals (at stopTime). + + 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.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 not self._simulated: + raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") + + 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 KeyError(f"{names} is not a valid output") + + if names is None: + get_outputs_solution(name_list=list(self._outputs.keys())) + return self._outputs + + if isinstance(names, str): + get_outputs_solution(name_list=[names]) + return [self._outputs[names]] + + if isinstance(names, list): + get_outputs_solution(name_list=names) + values = [] + for name in names: + values.append(self._outputs[name]) + return values + + raise ModelicaSystemError("Unhandled input for getOutputs()") + + def getOutputs( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of output signals. + + 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, + 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: + Before simulate(): + >>> mod.getOutputs() + {'out1': '-0.4', 'out2': '1.2'} + >>> mod.getOutputs("out1") + ['-0.4'] + >>> mod.getOutputs(["out1","out2"]) + ['-0.4', '1.2'] + + After simulate(): + >>> mod.getOutputs() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputs("out1") + [np.float64(-0.1234)] + >>> mod.getOutputs(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] + """ + if not self._simulated: + return self.getOutputsInitial(names=names) + + return self.getOutputsFinal(names=names) + + def plot( + self, + plotdata: str, + resultfile: Optional[str | os.PathLike] = None, + ) -> None: + """ + Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the + plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. + """ + + if not isinstance(self._session, OMCSessionLocal): + raise ModelicaSystemError("Plot is using the OMC plot functionality; " + "thus, it is only working if OMC is running locally!") + + if resultfile is not None: + plot_result_file = self._session.omcpath(resultfile) + elif self._result_file is not None: + plot_result_file = self._result_file + else: + raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " + "or provide a result file!") + + if not plot_result_file.is_file(): + raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") + + expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' + self.sendExpression(expr=expr) + + def getSolutions( + self, + varList: Optional[str | list[str]] = None, + resultfile: Optional[str | os.PathLike] = None, + ) -> tuple[str] | np.ndarray: + """Extract simulation results from a result data file. + + Args: + varList: Names of variables to be extracted. Either unspecified to + get names of available variables, or a single variable name + as a string, or a list of variable names. + resultfile: Path to the result file. If unspecified, the result + file created by simulate() is used. + + Returns: + If varList is None, a tuple with names of all variables + is returned. + If varList is a string, a 1D numpy array is returned. + If varList is a list, a 2D numpy array is returned. + + Examples: + >>> mod.getSolutions() + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"]) + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + >>> mod.getSolutions(resultfile="c:/a.mat") + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x", resultfile="c:/a.mat") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + """ + if resultfile is None: + if self._result_file is None: + raise ModelicaSystemError("No result file found. Run simulate() first.") + result_file = self._result_file + else: + result_file = self._session.omcpath(resultfile) + + # check if the result file exits + if not result_file.is_file(): + raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") + + # get absolute path + result_file = result_file.absolute() + + result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') + self.sendExpression(expr="closeSimulationResultFile()") + if varList is None: + return result_vars + + if isinstance(varList, str): + var_list_checked = [varList] + elif isinstance(varList, list): + var_list_checked = varList + else: + raise ModelicaSystemError("Unhandled input for getSolutions()") + + for var in var_list_checked: + if var == "time": + continue + if var not in result_vars: + raise ModelicaSystemError(f"Requested data {repr(var)} does not exist") + variables = ",".join(var_list_checked) + res = self.sendExpression(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') + np_res = np.array(res) + self.sendExpression(expr="closeSimulationResultFile()") + return np_res + + def convertMo2Fmu( + self, + version: str = "2.0", + fmuType: str = "me_cs", + fileNamePrefix: Optional[str] = None, + includeResources: bool = True, + ) -> OMPathABC: + """Translate the model into a Functional Mockup Unit. + + Args: + See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html + + Returns: + str: Path to the created '*.fmu' file. + + Examples: + >>> mod.convertMo2Fmu() + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", + includeResources=True) + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + """ + + if fileNamePrefix is None: + if self._model_name is None: + fileNamePrefix = "" + else: + fileNamePrefix = self._model_name + include_resources_str = "true" if includeResources else "false" + + properties = (f'version="{version}", fmuType="{fmuType}", ' + f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') + fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) + fmu_path = self._session.omcpath(fmu) + + # report proper error message + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + return fmu_path + + # to convert FMU to Modelica model + def convertFmu2Mo( + self, + fmu: os.PathLike, + ) -> OMPathABC: + """ + In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate + Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". + Currently, it only supports Model Exchange conversion. + usage + >>> convertFmu2Mo("c:/BouncingBall.Fmu") + """ + + fmu_path = self._session.omcpath(fmu) + + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) + filepath = self.getWorkDirectory() / filename + + # report proper error message + if not filepath.is_file(): + raise ModelicaSystemError(f"Missing file {filepath.as_posix()}") + + self.model( + model_name=f"{fmu_path.stem}_me_FMU", + model_file=filepath, + ) + + return filepath + + def optimize(self) -> dict[str, Any]: + """Perform model-based optimization. + + Optimization options set by setOptimizationOptions() are used. + + Returns: + A dict with various values is returned. One of these values is the + path to the result file. + + Examples: + >>> mod.optimize() + {'messages': 'LOG_SUCCESS | info | The initialization finished successfully without homotopy method. ...' + 'resultFile': '/tmp/tmp68guvjhs/BangBang2021_res.mat', + 'simulationOptions': 'startTime = 0.0, stopTime = 1.0, numberOfIntervals = ' + "1000, tolerance = 1e-8, method = 'optimization', " + "fileNamePrefix = 'BangBang2021', options = '', " + "outputFormat = 'mat', variableFilter = '.*', cflags = " + "'', simflags = '-s=\\'optimization\\' " + "-optimizerNP=\\'1\\''", + 'timeBackend': 0.008684897, + 'timeCompile': 0.7546678929999999, + 'timeFrontend': 0.045438053000000006, + 'timeSimCode': 0.0018537170000000002, + 'timeSimulation': 0.266354356, + 'timeTemplates': 0.002007785, + 'timeTotal': 1.079097854} + """ + 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) + + +class ModelicaSystem(ModelicaSystemOMC): + """ + Compatibility class. + """ + + +class ModelicaDoEABC(metaclass=abc.ABCMeta): + """ + Base 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.ModelicaSystemOMC() + 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}}, @@ -2080,15 +2185,8 @@ def run_doe(): 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, + # ModelicaSystem definition to use + mod: ModelicaSystemABC, # simulation specific input # TODO: add more settings (simulation options, input options, ...) simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, @@ -2101,30 +2199,18 @@ def __init__( 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, - ) + if not isinstance(mod, ModelicaSystemABC): + raise ModelicaSystemError("Missing definition of ModelicaSystem!") - self._model_name = model_name + 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) + 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}!") @@ -2135,14 +2221,20 @@ def __init__( self._parameters = {} self._doe_def: Optional[dict[str, dict[str, Any]]] = None - self._doe_cmd: Optional[dict[str, OMCSessionRunData]] = None + self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None - def get_session(self) -> OMCSession: + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. """ return self._mod.get_session() + def get_resultpath(self) -> OMPathABC: + """ + Get the path there the result data is saved. + """ + return self._resultpath + def prepare(self) -> int: """ Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of @@ -2169,30 +2261,11 @@ def prepare(self) -> int: 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) - expr = 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" - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" - else: - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expr=expr) - if not res: - raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " - f"to {pk_value} using {repr(expr)}") - - self._mod.buildModel() + sim_param_structure = self._prepare_structure_parameters( + idx_pc_structure=idx_pc_structure, + pc_structure=pc_structure, + param_structure=param_structure, + ) for idx_non_structural, pk_non_structural in enumerate(param_non_structural_combinations): sim_param_non_structural = {} @@ -2237,6 +2310,17 @@ def prepare(self) -> int: return len(doe_sim) + @abc.abstractmethod + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + """ + Handle structural parameters. This should be implemented by the derived class + """ + 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 @@ -2254,7 +2338,7 @@ def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: """ return self._doe_def - def get_doe_command(self) -> Optional[dict[str, OMCSessionRunData]]: + def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: """ Get the definitions of simulations commands to run for this DoE. """ @@ -2300,13 +2384,13 @@ def worker(worker_id, task_queue): if cmd_definition is None: raise ModelicaSystemError("Missing simulation definition!") - resultfile = cmd_definition.cmd_result_path + resultfile = cmd_definition.cmd_result_file 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) + returncode = cmd_definition.run() logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " f"finished with return code: {returncode}") except ModelicaSystemError as ex: @@ -2348,65 +2432,252 @@ def worker(worker_id, task_queue): return doe_def_total == doe_def_done + +class ModelicaDoEOMC(ModelicaDoEABC): + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystemOMC + + The example is the same as defined for ModelicaDoEABC + """ + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemOMC, + # 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: + + if not isinstance(mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" + build_dir.mkdir() + self._mod.setWorkDirectory(work_directory=build_dir) + + # need to repeat this check to make the linters happy + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + + 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) + expr = 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" + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + else: + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" + res = self._mod.sendExpression(expr=expr) + if not res: + raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " + f"to {pk_value} using {repr(expr)}") + + self._mod.buildModel() + + return sim_param_structure + 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: + Wrapper for doe_get_solutions() + """ + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + + return doe_get_solutions( + msomc=self._mod, + resultpath=self._resultpath, + doe_def=self.get_doe_definition(), + var_list=var_list, + ) - * A list of variables if val_list == None - * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. +def doe_get_solutions( + msomc: ModelicaSystemOMC, + resultpath: OMPathABC, + doe_def: Optional[dict] = None, + 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: - The following code snippet can be used to convert the solution data for each run to a pandas dataframe: + * A list of variables if val_list == None - ``` - import pandas as pd + * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - 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 - ``` + 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(doe_def, dict): + return None + + if len(doe_def) == 0: + raise ModelicaSystemError("No result files available - all simulations did fail?") + + sol_dict: dict[str, dict[str, Any]] = {} + for resultfilename in doe_def: + resultfile = resultpath / resultfilename + sol_dict[resultfilename] = {} + + if not doe_def[resultfilename][ModelicaDoEABC.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(msomc.getSolutions(resultfile=resultfile)) + else: + var_list_row = var_list + + try: + sol = msomc.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 + + +class ModelicaSystemDoE(ModelicaDoEOMC): + """ + Compatibility class. + """ + + +class ModelicaSystemRunner(ModelicaSystemABC): + """ + Class to simulate a Modelica model using a pre-compiled model binary. + """ + + def __init__( + self, + work_directory: Optional[str | os.PathLike] = None, + session: Optional[OMSessionABC] = None, + ) -> None: + if session is None: + session = OMSessionRunner() + + if not isinstance(session, OMSessionRunner): + 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: """ - if not isinstance(self._doe_def, dict): - return 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: - if len(self._doe_def) == 0: - raise ModelicaSystemError("No result files available - all simulations did fail?") + * 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') + """ - sol_dict: dict[str, dict[str, Any]] = {} - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") - sol_dict[resultfilename] = {} + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") - 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 + # set variables + self._model_name = model_name # Model class name + self._variable_filter = variable_filter - 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 + # 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) + + +class ModelicaDoERunner(ModelicaDoEABC): + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystemRunner + + The example is the same as defined for ModelicaDoEABC + """ + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemABC, + # 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: + if not isinstance(mod, ModelicaSystemABC): + raise ModelicaSystemError(f"Invalid definition for ModelicaSystem*: {type(mod)}!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + if len(param_structure.keys()) > 0: + raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " + "pre-compiled binary of model.") + + return {} diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index c0e5499b..406e1e76 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -50,7 +50,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: @@ -70,8 +70,8 @@ class OMCSessionCmd: Implementation of Open Modelica Compiler API functions. Depreciated! """ - def __init__(self, session: OMCSession, readonly: bool = False): - if not isinstance(session, OMCSession): + def __init__(self, session: OMSessionABC, readonly: bool = False): + if not isinstance(session, OMSessionABC): raise OMCSessionException("Invalid OMC process definition!") self._session = session self._readonly = readonly @@ -249,233 +249,396 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -class OMCPathReal(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. - - PurePosixPath is selected to cover usage of OMC in docker or via WSL. Usage of specialised function could result in - errors as well as usage on a Windows system due to slightly different definitions (PureWindowsPath). - """ +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + class OMPathCompatibility(pathlib.Path): + """ + Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + """ - def __init__(self, *path, session: OMCSession) -> None: - super().__init__(*path) - self._session = session + # modified copy of pathlib.Path.__new__() definition + def __new__(cls, *args, **kwargs): + logger.warning("Python < 3.12 - using a version of class OMCPath " + "based on pathlib.Path for local usage only.") - def with_segments(self, *pathsegments): - """ - Create a new OMCPath object with the given path segments. + if cls is OMPathCompatibility: + cls = OMPathCompatibilityWindows if os.name == 'nt' else OMPathCompatibilityPosix + self = cls._from_parts(args) + if not self._flavour.is_supported: + raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") + return self - The original definition of Path is overridden to ensure the OMC session is set. - """ - return type(self)(*pathsegments, session=self._session) + def size(self) -> int: + """ + Needed compatibility function to have the same interface as OMCPathReal + """ + return self.stat().st_size - def is_file(self, *, follow_symlinks=True) -> bool: + class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): """ - Check if the path is a regular file. + Compatibility class for OMCPath on Posix systems (Python < 3.12) """ - return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - def is_dir(self, *, follow_symlinks=True) -> bool: + class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): """ - Check if the path is a directory. + Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') - 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. + OMPathABC = OMPathCompatibility + OMCPath = OMPathCompatibility + OMPathRunnerABC = OMPathCompatibility + OMPathRunnerLocal = OMPathCompatibility +else: + class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ - if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return super().is_absolute() + Implementation of a basic (PurePosix)Path object to be used within OMPython. The derived classes can use OMC as + backend and - thus - work on different configurations like docker or WSL. The connection to OMC is provided via + an instances of classes derived from BaseSession. - def read_text(self, encoding=None, errors=None, newline=None) -> str: + PurePosixPath is selected as it covers all but Windows systems (Linux, docker, WSL). However, the code is + written such that possible Windows system are taken into account. Nevertheless, the overall functionality is + limited compared to standard pathlib.Path objects. """ - 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._session.sendExpression(expr=f'readFile("{self.as_posix()}")') + def __init__(self, *path, session: OMSessionABC) -> None: + super().__init__(*path) + self._session = session - def write_text(self, data: str, encoding=None, errors=None, newline=None): - """ - Write text data to the file represented by this path. + def with_segments(self, *pathsegments): + """ + Create a new OMCPath object with the given path segments. - The additional arguments `encoding`, `errors`, and `newline` are only defined for compatibility with Path() - definitions. - """ - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") + The original definition of Path is overridden to ensure the session data is set. + """ + return type(self)(*pathsegments, session=self._session) - data_omc = self._session.escape_str(data) - self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') + @abc.abstractmethod + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ - return len(data) + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ - def mkdir(self, mode=0o777, parents=False, exist_ok=False): - """ - Create a directory at the path represented by this OMCPath object. + @abc.abstractmethod + def is_absolute(self): + """ + Check if the path is an absolute path. + """ - The additional arguments `mode`, and `parents` are only defined for compatibility with Path() definitions. - """ - if self.is_dir() and not exist_ok: - raise FileExistsError(f"Directory {self.as_posix()} already exists!") + @abc.abstractmethod + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ - return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') + @abc.abstractmethod + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ - def cwd(self): - """ - Returns the current working directory as an OMCPath object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return OMCPath(cwd_str, session=self._session) + @abc.abstractmethod + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') - if not res and not missing_ok: - raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ - def resolve(self, strict: bool = False): - """ - Resolve the path to an absolute path. This is done based on available OMC functions. - """ - if strict and not (self.is_file() or self.is_dir()): - raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + @abc.abstractmethod + def cwd(self): + """ + Returns the current working directory as an OMPathABC object. + """ - if self.is_file(): - pathstr_resolved = self._omc_resolve(self.parent.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name - elif self.is_dir(): - pathstr_resolved = self._omc_resolve(self.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) - else: - raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + @abc.abstractmethod + def resolve(self, strict: bool = False): + """ + Resolve the path to an absolute path. + """ + + def absolute(self): + """ + Resolve the path to an absolute path. Just a wrapper for resolve(). + """ + return self.resolve() - if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): - raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + def exists(self) -> bool: + """ + Semi replacement for pathlib.Path.exists(). + """ + return self.is_file() or self.is_dir() - return omcpath_resolved + @abc.abstractmethod + 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. + """ - def _omc_resolve(self, pathstr: str) -> str: + class _OMCPath(OMPathABC): """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') - try: - result = self._session.sendExpression(expr=expr, parsed=False) - result_parts = result.split('\n') - pathstr_resolved = result_parts[1] - pathstr_resolved = pathstr_resolved[1:-1] # remove quotes - except OMCSessionException as ex: - raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - return pathstr_resolved + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') - def absolute(self): - """ - Resolve the path to an absolute path. This is done by calling resolve() as it is the best we can do - using OMC functions. - """ - return self.resolve(strict=True) + def is_absolute(self): + """ + Check if the path is an absolute path. + """ + if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return super().is_absolute() - def exists(self, follow_symlinks=True) -> bool: - """ - Semi replacement for pathlib.Path.exists(). - """ - return self.is_file() or self.is_dir() + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + return self._session.sendExpression(expr=f'readFile("{self.as_posix()}")') - 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. - """ - if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") - res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) + data_omc = self._session.escape_str(data) + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') - raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + return len(data) + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. -if sys.version_info < (3, 12): + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + if self.is_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") - 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. - """ + return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') - # modified copy of pathlib.Path.__new__() definition - def __new__(cls, *args, **kwargs): - logger.warning("Python < 3.12 - using a version of class OMCPath " - "based on pathlib.Path for local usage only.") + def cwd(self): + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return OMCPath(cwd_str, session=self._session) - 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") - return self + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') + if not res and not missing_ok: + raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + + def resolve(self, strict: bool = False): + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + if strict and not (self.is_file() or self.is_dir()): + raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + + if self.is_file(): + pathstr_resolved = self._omc_resolve(self.parent.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name + elif self.is_dir(): + pathstr_resolved = self._omc_resolve(self.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) + else: + raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + + if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): + raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + + return omcpath_resolved + + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + + try: + result = self._session.sendExpression(expr=expr, parsed=False) + result_parts = result.split('\n') + pathstr_resolved = result_parts[1] + pathstr_resolved = pathstr_resolved[1:-1] # remove quotes + except OMCSessionException as ex: + raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + + return pathstr_resolved def size(self) -> int: """ - Needed compatibility function to have the same interface as OMCPathReal + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. """ - return self.stat().st_size + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") - class OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + + class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) + Base function for OMPath definitions *without* OMC server """ - class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + + class _OMPathRunnerLocal(OMPathRunnerABC): """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) + Implementation of OMPathBase which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. + + This class is based on OMPathBase and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + the correct implementation on Windows systems. To get a valid Windows representation of the path, use the + conversion via pathlib.Path(.as_posix()). """ - OMCPath = OMCPathCompatibility + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + return self._path().is_file() -else: - OMCPath = OMCPathReal + def is_dir(self) -> 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. + """ + return self._path().is_absolute() + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + return self._path().read_text(encoding='utf-8') + + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ + return self._path().write_text(data=data, encoding='utf-8') + + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + return self._path().mkdir(parents=parents, exist_ok=exist_ok) + + def cwd(self): + """ + Returns the current working directory as an OMPathBase 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 - implementation baseon on pathlib.Path. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + + path = self._path() + return path.stat().st_size + + OMCPath = _OMCPath + OMPathRunnerLocal = _OMPathRunnerLocal + + +class ModelExecutionException(Exception): + """ + Exception which is raised by ModelException* classes. + """ @dataclasses.dataclass -class OMCSessionRunData: +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*.omc_run_data_update(). This defines the attribute cmd_model_executable. + 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_path: str + cmd_result_file: str + # command timeout + cmd_timeout: float - # 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 - # working directory to be used on the *local* system cmd_cwd_local: Optional[str] = None @@ -484,14 +647,49 @@ 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 + 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 + class OMCSessionZMQ: """ @@ -502,7 +700,7 @@ def __init__( self, timeout: float = 10.00, omhome: Optional[str] = None, - omc_process: Optional[OMCSession] = None, + omc_process: Optional[OMCSessionABC] = None, ) -> None: """ Initialisation for OMCSessionZMQ @@ -514,7 +712,7 @@ def __init__( if omc_process is None: omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) - elif not isinstance(omc_process, OMCSession): + elif not isinstance(omc_process, OMCSessionABC): raise OMCSessionException("Invalid definition of the OMC process!") self.omc_process = omc_process @@ -526,36 +724,21 @@ def escape_str(value: str) -> str: """ Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. """ - return OMCSession.escape_str(value=value) + return OMCSessionABC.escape_str(value=value) - def omcpath(self, *path) -> OMCPath: + def omcpath(self, *path) -> OMPathABC: """ Create an OMCPath object based on the given path segments and the current OMC process definition. """ return self.omc_process.omcpath(*path) - def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: """ Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. """ 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) - - def run_model_executable(self, 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 self.omc_process.run_model_executable(cmd_run_data=cmd_run_data) - def execute(self, command: str): return self.omc_process.execute(command=command) @@ -596,7 +779,7 @@ def __call__(cls, *args, **kwargs): return obj -class OMCSessionMeta(abc.ABCMeta, PostInitCaller): +class OMSessionMeta(abc.ABCMeta, PostInitCaller): """ Helper class to get a combined metaclass of ABCMeta and PostInitCaller. @@ -605,7 +788,106 @@ class OMCSessionMeta(abc.ABCMeta, PostInitCaller): """ -class OMCSession(metaclass=OMCSessionMeta): +class OMSessionABC(metaclass=OMSessionMeta): + """ + This class implements the basic structure a OMPython session definition needs. It provides the structure for an + implementation using OMC as backend (via ZMQ) or a dummy implementation which just runs a model executable. + """ + + def __init__( + self, + timeout: float = 10.00, + **kwargs, + ) -> None: + """ + Initialisation for OMSessionBase + """ + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # store variables + self._timeout = timeout + # command prefix (to be used for docker or WSL) + self._cmd_prefix: list[str] = [] + + def __post_init__(self) -> None: + """ + Post initialisation method. + """ + + def get_cmd_prefix(self) -> list[str]: + """ + Get session definition used for this instance of OMPath. + """ + return self._cmd_prefix.copy() + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + @abc.abstractmethod + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + + @abc.abstractmethod + def get_version(self) -> str: + """ + Get the OM version. + """ + + @abc.abstractmethod + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + + @abc.abstractmethod + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMPathABC object based on the given path segments and the current class. + """ + + @abc.abstractmethod + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory based on the specific definition for this session. + """ + + @staticmethod + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathABC] = None + for name in names: + # create a unique temporary directory name + tempdir = tempdir_base / name + + if tempdir.exists(): + continue + + tempdir.mkdir(parents=True, exist_ok=False) + break + + if tempdir is None or not tempdir.is_dir(): + raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") + + return tempdir + + @abc.abstractmethod + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Function needed to send expressions to the OMC server via ZMQ. + """ + + +class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): """ Base class for an OMC session started via ZMQ. This class contains common functionality for all variants of an OMC session definition. @@ -633,9 +915,12 @@ def __init__( """ Initialisation for OMCSession """ + super().__init__(timeout=timeout) + + # 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 self._random_string = uuid.uuid4().hex # get a temporary directory @@ -712,6 +997,7 @@ def __del__(self): self._omc_process.kill() self._omc_process.wait() finally: + self._omc_process = None def _timeout_loop( @@ -765,14 +1051,21 @@ def get_version(self) -> str: """ return self.sendExpression("getVersion()", parsed=True) - def set_workdir(self, workdir: OMCPath) -> None: + def set_workdir(self, workdir: OMPathABC) -> None: """ Set the workdir for this session. """ exp = f'cd("{workdir.as_posix()}")' self.sendExpression(exp) - def omcpath(self, *path) -> OMCPath: + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + + return [] + + def omcpath(self, *path) -> OMPathABC: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. """ @@ -785,12 +1078,11 @@ def omcpath(self, *path) -> OMCPath: raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") return OMCPath(*path, session=self) - def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: """ 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 @@ -800,7 +1092,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") tempdir_base = self.omcpath(tempdir_str) - tempdir: Optional[OMCPath] = None + return self._tempdir(tempdir_base=tempdir_base) + + @staticmethod + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathABC] = None for name in names: # create a unique temporary directory name tempdir = tempdir_base / name @@ -816,48 +1114,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: return tempdir - def run_model_executable(self, cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. - """ - - 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=self._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(message="This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", - category=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) @@ -1029,20 +1292,8 @@ def _get_portfile_path(self) -> Optional[pathlib.Path]: return portfile_path - @abc.abstractmethod - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - 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. - - Needs to be implemented in the subclasses. - """ - raise NotImplementedError("This method must be implemented in subclasses!") - -class OMCSessionPort(OMCSession): +class OMCSessionPort(OMCSessionABC): """ OMCSession implementation which uses a port to connect to an already running OMC server. """ @@ -1054,30 +1305,8 @@ def __init__( super().__init__() self._omc_port = omc_port - @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. - """ - raise OMCSessionException("OMCSessionPort does not support run_model_executable()!") - - def get_log(self) -> str: - """ - Get the log file content of the OMC session. - """ - log = f"No log available if OMC session is defined by port ({self.__class__.__name__})" - - return log - - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - raise OMCSessionException(f"({self.__class__.__name__}) does not support omc_run_data_update()!") - -class OMCSessionLocal(OMCSession): +class OMCSessionLocal(OMCSessionABC): """ OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). """ @@ -1090,6 +1319,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 @@ -1155,50 +1386,8 @@ 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): +class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): """ Base class for OMCSession implementations which run the OMC server in a Docker container. """ @@ -1309,30 +1498,24 @@ 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[OMPathABC] = 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, OMPathABC): + 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): +class OMCSessionDocker(OMCSessionDockerABC): """ OMC process running in a Docker container. """ @@ -1474,7 +1657,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: return omc_process, docker_process, docker_cid -class OMCSessionDockerContainer(OMCSessionDockerHelper): +class OMCSessionDockerContainer(OMCSessionDockerABC): """ OMC process running in a Docker container (by container ID). """ @@ -1567,7 +1750,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: return omc_process, docker_process -class OMCSessionWSL(OMCSession): +class OMCSessionWSL(OMCSessionABC): """ OMC process running in Windows Subsystem for Linux (WSL). """ @@ -1592,15 +1775,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[OMPathABC] = 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, OMPathABC): + wsl_cmd += ['--cd', cwd.as_posix()] wsl_cmd += ['--'] return wsl_cmd @@ -1608,7 +1794,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", @@ -1630,7 +1816,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() @@ -1648,16 +1834,60 @@ def _omc_port_get(self) -> str: return port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + +class OMSessionRunner(OMSessionABC): + """ + Implementation based on OMSessionABC 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! + """ + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + return [] + + 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 + + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + os.chdir(workdir.as_posix()) + + def omcpath(self, *path) -> OMPathABC: """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. + Create an OMCPath object based on the given path segments and the current OMCSession* class. """ - omc_run_data_copy = dataclasses.replace(omc_run_data) + return OMPathRunnerLocal(*path, session=self) - omc_run_data_copy.cmd_prefix = self._wsl_cmd(wsl_cwd=omc_run_data.cmd_path) + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory without using OMC. + """ + if tempdir_base is None: + tempdir_str = tempfile.gettempdir() + tempdir_base = self.omcpath(tempdir_str) - 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 self._tempdir(tempdir_base=tempdir_base) - return omc_run_data_copy + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 59a0ad10..d6016e53 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -1,51 +1,82 @@ # -*- 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.ModelicaSystem import ( LinearizationResult, ModelicaSystem, - ModelicaSystemCmd, + ModelicaSystemOMC, + ModelExecutionCmd, ModelicaSystemDoE, + ModelicaDoEOMC, ModelicaSystemError, + ModelicaSystemRunner, + ModelicaDoERunner, + + doe_get_solutions, ) from OMPython.OMCSession import ( + OMPathABC, OMCPath, - OMCSession, + + OMSessionRunner, + + OMCSessionABC, + + ModelExecutionData, + ModelExecutionException, + OMCSessionCmd, - OMCSessionException, - OMCSessionRunData, - OMCSessionZMQ, - OMCSessionPort, - OMCSessionLocal, OMCSessionDocker, OMCSessionDockerContainer, + OMCSessionException, + OMCSessionLocal, + OMCSessionPort, OMCSessionWSL, + OMCSessionZMQ, ) # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', + + 'ModelExecutionData', + 'ModelExecutionException', + 'ModelicaSystem', - 'ModelicaSystemCmd', + 'ModelicaSystemOMC', + 'ModelExecutionCmd', 'ModelicaSystemDoE', + 'ModelicaDoEOMC', 'ModelicaSystemError', + 'ModelicaSystemRunner', + 'ModelicaDoERunner', + + 'OMPathABC', 'OMCPath', - 'OMCSession', + 'OMSessionRunner', + + 'OMCSessionABC', + + 'doe_get_solutions', + 'OMCSessionCmd', + 'OMCSessionDocker', + 'OMCSessionDockerContainer', 'OMCSessionException', - 'OMCSessionRunData', - 'OMCSessionZMQ', 'OMCSessionPort', 'OMCSessionLocal', - 'OMCSessionDocker', - 'OMCSessionDockerContainer', 'OMCSessionWSL', + 'OMCSessionZMQ', ] diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index 006d2d17..c7ab038a 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -6,7 +6,7 @@ def test_CauerLowPassAnalog(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", libraries=["Modelica"], @@ -20,7 +20,7 @@ def test_CauerLowPassAnalog(): def test_DrumBoiler(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", libraries=["Modelica"], diff --git a/tests/test_FMIImport.py b/tests/test_FMIImport.py index cb43e0ae..bb3a1201 100644 --- a/tests/test_FMIImport.py +++ b/tests/test_FMIImport.py @@ -22,7 +22,7 @@ def model_firstorder(tmp_path): def test_FMIImport(model_firstorder): # create model & simulate it - mod1 = OMPython.ModelicaSystem() + mod1 = OMPython.ModelicaSystemOMC() mod1.model( model_file=model_firstorder, model_name="M", @@ -35,7 +35,7 @@ def test_FMIImport(model_firstorder): # import FMU & check & simulate # TODO: why is '--allowNonStandardModelica=reinitInAlgorithms' needed? any example without this possible? - mod2 = OMPython.ModelicaSystem(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) + mod2 = OMPython.ModelicaSystemOMC(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) mo = mod2.convertFmu2Mo(fmu=fmu) assert os.path.exists(mo) diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaDoEOMC.py similarity index 75% rename from tests/test_ModelicaSystemDoE.py rename to tests/test_ModelicaDoEOMC.py index 0e8d6caa..c0b9fda3 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaDoEOMC.py @@ -51,56 +51,73 @@ def param_doe() -> dict[str, list]: return param -def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): +def test_ModelicaDoEOMC_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC() + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, parameters=param_doe, resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) @skip_on_windows @skip_python_older_312 -def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): +def test_ModelicaDoEOMC_docker(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, parameters=param_doe, - session=omcs, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) @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) +def test_ModelicaDoEOMC_WSL(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionWSL() + assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, parameters=param_doe, - resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) -def _run_ModelicaSystemDoe(doe_mod): +def _run_ModelicaDoEOMC(doe_mod): doe_count = doe_mod.prepare() assert doe_count == 16 diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py new file mode 100644 index 00000000..2d41315f --- /dev/null +++ b/tests/test_ModelicaDoERunner.py @@ -0,0 +1,158 @@ +import pathlib +import sys + +import numpy as np +import pytest + +import OMPython + +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath(non-local) only working for Python >= 3.12.", +) + + +@pytest.fixture +def model_doe(tmp_path: pathlib.Path) -> pathlib.Path: + # see: https://trac.openmodelica.org/OpenModelica/ticket/4052 + mod = tmp_path / "M.mo" + # TODO: update for bool and string parameters; check if these can be used in DoE + mod.write_text(""" +model M + parameter Integer p=1; + parameter Integer q=1; + parameter Real a = -1; + parameter Real b = -1; + Real x[p]; + Real y[q]; +equation + der(x) = a * fill(1.0, p); + der(y) = b * fill(1.0, q); +end M; +""") + return mod + + +@pytest.fixture +def param_doe() -> dict[str, list]: + param = { + # simple + 'a': [5, 6], + 'b': [7, 8], + } + return param + + +def test_ModelicaDoERunner_ModelicaSystemOMC(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param_doe) + + doe_mod = OMPython.ModelicaDoERunner( + mod=mod, + parameters=param_doe, + resultpath=tmpdir, + ) + + _run_ModelicaDoERunner(doe_mod=doe_mod) + + _check_runner_result(mod=mod, doe_mod=doe_mod) + + +def test_ModelicaDoERunner_ModelicaSystemRunner(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param_doe) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + doe_mod = OMPython.ModelicaDoERunner( + mod=modr, + parameters=param_doe, + resultpath=tmpdir, + ) + + _run_ModelicaDoERunner(doe_mod=doe_mod) + + _check_runner_result(mod=mod, doe_mod=doe_mod) + + +def _run_simulation(mod, resultfile, param): + simOptions = {"stopTime": 1.0, "stepSize": 0.1, "tolerance": 1e-8} + mod.setSimulationOptions(**simOptions) + mod.simulate(resultfile=resultfile) + + assert resultfile.exists() + + +def _run_ModelicaDoERunner(doe_mod): + doe_count = doe_mod.prepare() + assert doe_count == 4 + + doe_def = doe_mod.get_doe_definition() + assert isinstance(doe_def, dict) + assert len(doe_def.keys()) == doe_count + + doe_cmd = doe_mod.get_doe_command() + assert isinstance(doe_cmd, dict) + assert len(doe_cmd.keys()) == doe_count + + doe_status = doe_mod.simulate() + assert doe_status is True + + +def _check_runner_result(mod, doe_mod): + doe_cmd = doe_mod.get_doe_command() + doe_def = doe_mod.get_doe_definition() + + doe_sol = OMPython.doe_get_solutions( + msomc=mod, + resultpath=doe_mod.get_resultpath(), + doe_def=doe_def, + ) + assert isinstance(doe_sol, dict) + assert len(doe_sol.keys()) == len(doe_cmd.keys()) + + assert sorted(doe_def.keys()) == sorted(doe_cmd.keys()) + assert sorted(doe_cmd.keys()) == sorted(doe_sol.keys()) + + for resultfilename in doe_def: + row = doe_def[resultfilename] + + assert resultfilename in doe_sol + sol = doe_sol[resultfilename] + + var_dict = { + # simple / non-structural parameters + 'a': float(row['a']), + 'b': float(row['b']), + } + + for var in var_dict: + assert var in sol['data'] + assert np.isclose(sol['data'][var][-1], var_dict[var]) diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 2480aad9..3d35376b 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -18,16 +18,20 @@ def model_firstorder(tmp_path): @pytest.fixture def mscmd_firstorder(model_firstorder): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( 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_ModelicaSystem.py b/tests/test_ModelicaSystemOMC.py similarity index 96% rename from tests/test_ModelicaSystem.py rename to tests/test_ModelicaSystemOMC.py index 9bf0a7b9..8dd17ef0 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystemOMC.py @@ -40,7 +40,7 @@ def model_firstorder(tmp_path, model_firstorder_content): def test_ModelicaSystem_loop(model_firstorder): def worker(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", @@ -56,7 +56,9 @@ def test_setParameters(): omcs = OMPython.OMCSessionLocal() model_path_str = omcs.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omcs.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -91,7 +93,9 @@ def test_setSimulationOptions(): omcs = OMPython.OMCSessionLocal() model_path_str = omcs.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omcs.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -128,7 +132,7 @@ def test_relative_path(model_firstorder): model_relative = str(model_file) assert "/" not in model_relative - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_relative, model_name="M", @@ -141,7 +145,7 @@ def test_relative_path(model_firstorder): def test_customBuildDirectory(tmp_path, model_firstorder): tmpdir = tmp_path / "tmpdir1" tmpdir.mkdir() - mod = OMPython.ModelicaSystem(work_directory=tmpdir) + mod = OMPython.ModelicaSystemOMC(work_directory=tmpdir) mod.model( model_file=model_firstorder, model_name="M", @@ -157,7 +161,7 @@ def test_customBuildDirectory(tmp_path, model_firstorder): @skip_python_older_312 def test_getSolutions_docker(model_firstorder): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( @@ -169,7 +173,7 @@ def test_getSolutions_docker(model_firstorder): def test_getSolutions(model_firstorder): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", @@ -217,7 +221,7 @@ def test_getters(tmp_path): y = der(x); end M_getters; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="M_getters", @@ -426,7 +430,7 @@ def test_simulate_inputs(tmp_path): y = x; end M_input; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="M_input", diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py new file mode 100644 index 00000000..35541c99 --- /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.OMSessionRunner( + 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 have 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_OMCPath.py b/tests/test_OMCPath.py index 2ea8b8c8..9a69b738 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -48,7 +48,7 @@ def test_OMCPath_OMCProcessWSL(): del omcs -def _run_OMCPath_checks(omcs: OMPython.OMCSession): +def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): p1 = omcs.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py index d3997ecf..7dbb9705 100644 --- a/tests/test_OMSessionCmd.py +++ b/tests/test_OMSessionCmd.py @@ -8,7 +8,7 @@ def test_isPackage(): def test_isPackage2(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", libraries=["Modelica"], diff --git a/tests/test_linearization.py b/tests/test_linearization.py index c61462bb..7070a45b 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -25,7 +25,7 @@ def model_linearTest(tmp_path): def test_example(model_linearTest): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_linearTest, model_name="linearTest", @@ -60,7 +60,7 @@ def test_getters(tmp_path): y2 = phi + u1; end Pendulum; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="Pendulum", diff --git a/tests/test_optimization.py b/tests/test_optimization.py index d7494281..823ba1e3 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -34,7 +34,7 @@ def test_optimization_example(tmp_path): end BangBang2021; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="BangBang2021",