diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 3601cb848..28f9b8331 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -1,11 +1,6 @@ name: Test-Publish on: - push: - branches: ['master'] - tags: - - 'v*' # only publish when pushing version tags (e.g., v1.0.0) - pull_request: workflow_dispatch: jobs: @@ -14,9 +9,9 @@ jobs: timeout-minutes: 30 strategy: matrix: - python-version: ['3.10', '3.12', '3.13'] + python-version: ['3.14'] os: ['ubuntu-latest', 'windows-latest'] - omc-version: ['stable', 'nightly'] + omc-version: ['stable'] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml new file mode 100644 index 000000000..3fc1cce44 --- /dev/null +++ b/.github/workflows/Test_v400.yml @@ -0,0 +1,66 @@ +name: Test-v4.0.0 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10', '3.14'] + os: ['ubuntu-latest', 'windows-latest'] + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400 ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v400_py310.yml b/.github/workflows/Test_v400_py310.yml new file mode 100644 index 000000000..d980ec051 --- /dev/null +++ b/.github/workflows/Test_v400_py310.yml @@ -0,0 +1,66 @@ +name: Test-v4.0.0-py310 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10'] + os: ['ubuntu-latest'] + omc-version: ['stable'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400 ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v4xx.yml b/.github/workflows/Test_v4xx.yml new file mode 100644 index 000000000..020d5c871 --- /dev/null +++ b/.github/workflows/Test_v4xx.yml @@ -0,0 +1,66 @@ +name: Test-v4.x.x + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10', '3.14'] + os: ['ubuntu-latest', 'windows-latest'] + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 484570b61..349813e14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,12 +28,11 @@ repos: hooks: - id: codespell - - repo: https://github.com/pre-commit/mirrors-mypy.git - rev: "v1.15.0" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 hooks: - id: mypy - args: [] - exclude: tests/ + exclude: 'test|test_v400' additional_dependencies: - pyparsing - types-psutil diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 9db3da332..a54fb145c 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 @@ -11,31 +12,37 @@ import os import pathlib import queue +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 -import re - from OMPython.OMCSession import ( - OMCSessionException, - OMCSessionRunData, - OMCSession, + ModelExecutionException, + ModelExecutionData, + OMPathBase, + OMSessionABC, + OMSessionRunner, OMCSessionLocal, - OMCPath, + OMCSessionException, + + OMCProcessLocal, ) +# check OMPathBase usage +# check OMSessionABC usage + # define logger using the current module name as ID logger = logging.getLogger(__name__) class ModelicaSystemError(Exception): """ - Exception used in ModelicaSystem and ModelicaSystemCmd classes. + Exception used in ModelicaSystem classes. """ @@ -90,7 +97,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 @@ -99,16 +106,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] = {} @@ -153,26 +166,26 @@ def override2str( elif isinstance(oval, numbers.Number): oval_str = str(oval) else: - raise ModelicaSystemError(f"Invalid value for override key {okey}: {type(oval)}") + raise ModelExecutionException(f"Invalid value for override key {okey}: {type(oval)}") return f"{okey}={oval_str}" if not isinstance(key, str): - raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})") + 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: @@ -194,7 +207,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)} " @@ -234,7 +247,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. """ @@ -243,18 +256,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]]: @@ -263,15 +308,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("The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", 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: @@ -283,40 +332,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. """ @@ -324,11 +367,8 @@ def __init__( self._quantities: list[dict[str, Any]] = [] self._params: dict[str, str] = {} # even numerical values are stored as str self._inputs: dict[str, list[tuple[float, float]]] = {} - # _outputs values are str before simulate(), but they can be - # np.float64 after simulate(). - self._outputs: dict[str, Any] = {} - # same for _continuous - self._continuous: dict[str, Any] = {} + self._outputs: dict[str, np.float64] = {} # numpy.float64 as it allows to define None values + self._continuous: dict[str, np.float64] = {} # numpy.float64 as it allows to define None values self._simulate_options: dict[str, str] = {} self._override_variables: dict[str, str] = {} self._simulate_options_override: dict[str, str] = {} @@ -345,159 +385,37 @@ 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 - self._version = self._session.sendExpression("getVersion()", parsed=True) - # set commandLineOptions using default values or the user defined list - if command_line_options is None: - # set default command line options to improve the performance of linearization and to avoid recompilation if - # 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._version = self._session.get_version() self._simulated = False # True if the model has already been simulated - self._result_file: Optional[OMCPath] = None # for storing result file + self._result_file: Optional[OMPathBase] = None # for storing result file - self._work_dir: OMCPath = self.setWorkDirectory(work_directory) + self._work_dir: OMPathBase = 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[OMPathBase] = 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. """ - exp = f'setCommandLineOptions("{command_line_option}")' - self.sendExpression(exp) - - def _loadFile(self, fileName: OMCPath): - # load file - self.sendExpression(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_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) -> OMPathBase: """ 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. @@ -512,87 +430,47 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - raise IOError(f"{workdir} could not be created") logger.info("Define work dir as %s", workdir) - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) + self._session.set_workdir(workdir=workdir) # set the class variable _work_dir ... self._work_dir = workdir # ... and also return the defined path return workdir - def getWorkDirectory(self) -> OMCPath: + def getWorkDirectory(self) -> OMPathBase: """ 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: - try: - retval = self._session.sendExpression(expr, 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: - exp = f'{apiName}({entity}, {properties})' - elif entity is not None and properties is None: - if apiName in ("loadFile", "importFMU"): - exp = f'{apiName}("{entity}")' - else: - exp = f'{apiName}({entity})' - else: - exp = f'{apiName}()' - - return self.sendExpression(exp) - - def _xmlparse(self, xml_file: OMCPath): + def _xmlparse(self, xml_file: OMPathBase): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") xml_content = xml_file.read_text() tree = ET.ElementTree(ET.fromstring(xml_content)) root = tree.getroot() + if root is None: + raise ModelicaSystemError(f"Cannot read XML file: {xml_file}") for attr in root.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", "solver", "outputFormat"): @@ -628,11 +506,11 @@ def _xmlparse(self, xml_file: OMCPath): else: self._params[scalar["name"]] = scalar["start"] if scalar["variability"] == "continuous": - self._continuous[scalar["name"]] = scalar["start"] + self._continuous[scalar["name"]] = np.float64(scalar["start"]) if scalar["causality"] == "input": self._inputs[scalar["name"]] = scalar["start"] if scalar["causality"] == "output": - self._outputs[scalar["name"]] = scalar["start"] + self._outputs[scalar["name"]] = np.float64(scalar["start"]) self._quantities.append(scalar) @@ -693,15 +571,12 @@ def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: raise ModelicaSystemError("Unhandled input for getQuantities()") - def getContinuous( + def getContinuousInitial( self, names: Optional[str | list[str]] = None, - ) -> dict[str, str | numbers.Real] | list[str | numbers.Real]: - """Get values of continuous signals. - - If called before simulate(), the initial values are returned as - strings (or None). If called after simulate(), the final values (at - stopTime) are returned as numpy.float64. + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (initial) values of continuous signals. Args: names: Either None (default), a string with the continuous signal @@ -715,58 +590,21 @@ def getContinuous( in names is returned: [signal1_value, signal2_value, ...]. Examples: - Before simulate(): - >>> mod.getContinuous() + >>> mod.getContinuousInitial() {'x': '1.0', 'der(x)': None, 'y': '-0.4'} - >>> mod.getContinuous("y") + >>> mod.getContinuousInitial("y") ['-0.4'] - >>> mod.getContinuous(["y","x"]) + >>> mod.getContinuousInitial(["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.getOutputs(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] """ - if not self._simulated: - if names is None: - return self._continuous - if isinstance(names, str): - return [self._continuous[names]] - if isinstance(names, list): - return [self._continuous[x] for x in names] - if names is None: - for name in self._continuous: - try: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - except (OMCSessionException, ModelicaSystemError) as ex: - raise ModelicaSystemError(f"{name} could not be computed") from ex return self._continuous - if isinstance(names, str): - if names in self._continuous: - value = self.getSolutions(names) - self._continuous[names] = value[0][-1] - return [self._continuous[names]] - raise ModelicaSystemError(f"{names} is not continuous") - + return [self._continuous[names]] if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._continuous: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise ModelicaSystemError(f"{name} is not continuous") - return valuelist + return [self._continuous[x] for x in names] - raise ModelicaSystemError("Unhandled input for getContinous()") + raise ModelicaSystemError("Unhandled input for getContinousInitial()") def getParameters( self, @@ -839,15 +677,12 @@ def getInputs( raise ModelicaSystemError("Unhandled input for getInputs()") - def getOutputs( + def getOutputsInitial( self, names: Optional[str | list[str]] = None, - ) -> dict[str, str | numbers.Real] | list[str | numbers.Real]: - """Get values of output signals. - - If called before simulate(), the initial values are returned as - strings. If called after simulate(), the final values (at stopTime) - are returned as numpy.float64. + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (initial) values of output signals. Args: names: Either None (default), a string with the output name, @@ -861,54 +696,21 @@ def getOutputs( in names is returned: [output1_value, output2_value, ...]. Examples: - Before simulate(): - >>> mod.getOutputs() + >>> mod.getOutputsInitial() {'out1': '-0.4', 'out2': '1.2'} - >>> mod.getOutputs("out1") + >>> mod.getOutputsInitial("out1") ['-0.4'] - >>> mod.getOutputs(["out1","out2"]) + >>> mod.getOutputsInitial(["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: - if names is None: - return self._outputs - if isinstance(names, str): - return [self._outputs[names]] - return [self._outputs[x] for x in names] - if names is None: - for name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] return self._outputs - if isinstance(names, str): - if names in self._outputs: - value = self.getSolutions(names) - self._outputs[names] = value[0][-1] - return [self._outputs[names]] - raise KeyError(names) - + return [self._outputs[names]] if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise KeyError(name) - return valuelist + return [self._outputs[x] for x in names] - raise ModelicaSystemError("Unhandled input for getOutputs()") + raise ModelicaSystemError("Unhandled input for getOutputsInitial()") def getSimulationOptions( self, @@ -1023,19 +825,23 @@ def getOptimizationOptions( raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") - def parse_om_version(self, version: str) -> tuple[int, int, int]: - match = re.search(r"v?(\d+)\.(\d+)\.(\d+)", version) - if not match: - raise ValueError(f"Version not found in: {version}") + @staticmethod + def parse_om_version(version: str) -> tuple[int, int, int]: + """ + Evaluate an OMC version string and return a tuple of (epoch, major, minor). + """ + match = re.search(pattern=r"v?(\d+)\.(\d+)\.(\d+)", string=version) + if not match: + raise ValueError(f"Version not found in: {version}") major, minor, patch = map(int, match.groups()) return major, minor, patch def simulate_cmd( self, - result_file: OMCPath, + result_file: OMPathBase, 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. @@ -1057,10 +863,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 @@ -1145,14 +953,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, OMPathBase): 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, OMPathBase): raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") om_cmd = self.simulate_cmd( @@ -1166,7 +974,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 @@ -1180,107 +988,6 @@ def simulate( self._simulated = True - 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(f'readSimulationResultVars("{result_file.as_posix()}")') - self.sendExpression("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(f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') - np_res = np.array(res) - self.sendExpression("closeSimulationResultFile()") - return np_res - @staticmethod def _prepare_input_data( input_args: Any, @@ -1578,7 +1285,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: + def _createCSVData(self, csvfile: Optional[OMPathBase] = None) -> OMPathBase: """ 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. @@ -1605,9 +1312,9 @@ def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: for signal_name, signal_values in inputs.items(): signal = np.array(signal_values) interpolated_inputs[signal_name] = np.interp( - all_times, - signal[:, 0], # times - signal[:, 1], # values + x=all_times, + xp=signal[:, 0], # times + fp=signal[:, 1], # values ) # Write CSV file @@ -1633,110 +1340,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, @@ -1769,10 +1372,12 @@ def linearize( "use ModelicaSystem() 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, ) # See comment in simulate_cmd regarding override file and OM version @@ -1819,7 +1424,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(): @@ -1843,7 +1448,7 @@ def linearize( linear_data[target] = value_ast except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: - raise ModelicaSystemError(f"Error parsing linearization file {linear_file}!") from ex + raise ModelicaSystemError(f"Error parsing linearization file {linear_file}: {ex}") from ex # remove the file linear_file.unlink() @@ -1880,50 +1485,812 @@ 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], - } - + # 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) + + 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 set_command_line_options(self, command_line_option: str): + """ + Set the provided command line option via OMC setCommandLineOptions(). + """ + exp = f'setCommandLineOptions("{command_line_option}")' + self.sendExpression(exp) + + def _loadFile(self, fileName: OMPathBase): + # load file + self.sendExpression(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_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(command=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: + exp = f'{apiName}({entity}, {properties})' + elif entity is not None and properties is None: + if apiName in ("loadFile", "importFMU"): + exp = f'{apiName}("{entity}")' + else: + exp = f'{apiName}({entity})' + else: + exp = f'{apiName}()' + + return self.sendExpression(exp) + + 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(f'readSimulationResultVars("{result_file.as_posix()}")') + self.sendExpression("closeSimulationResultFile()") + if varList is None: + var_list = [str(var) for var in result_vars] + return tuple(var_list) + + 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(f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') + np_res = np.array(res) + self.sendExpression("closeSimulationResultFile()") + return np_res + + def convertMo2Fmu( + self, + version: str = "2.0", + fmuType: str = "me_cs", + fileNamePrefix: Optional[str] = None, + includeResources: bool = True, + ) -> OMPathBase: + """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, + ) -> OMPathBase: + """ + 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()) + if not isinstance(filename, str): + raise ModelicaSystemError(f"Invalid return value for the FMU filename: {filename}") + 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") + retval = self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + retval = cast(dict, retval) + return retval + + +class ModelicaSystem(ModelicaSystemOMC): + """ + Compatibility class. + """ + + def __init__( + self, + fileName: Optional[str | os.PathLike | pathlib.Path] = None, + modelName: Optional[str] = None, + lmodel: Optional[list[str | tuple[str, str]]] = None, + commandLineOptions: Optional[list[str]] = None, + variableFilter: Optional[str] = None, + customBuildDirectory: Optional[str | os.PathLike] = None, + omhome: Optional[str] = None, + omc_process: Optional[OMCProcessLocal] = None, + build: bool = True, + ) -> None: + super().__init__( + command_line_options=commandLineOptions, + work_directory=customBuildDirectory, + omhome=omhome, + session=omc_process, + ) + self.model( + model_name=modelName, + model_file=fileName, + libraries=lmodel, + variable_filter=variableFilter, + build=build, + ) + self._getconn = self._session + + def setCommandLineOptions(self, commandLineOptions: str): + super().set_command_line_options(command_line_option=commandLineOptions) + + def setContinuous( # type: ignore[override] + self, + cvals: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(cvals, dict): + return super().setContinuous(**cvals) + raise ModelicaSystemError("Only dict input supported for setContinuous()") + + def setParameters( # type: ignore[override] + self, + pvals: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(pvals, dict): + return super().setParameters(**pvals) + raise ModelicaSystemError("Only dict input supported for setParameters()") + + def setOptimizationOptions( # type: ignore[override] + self, + optimizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(optimizationOptions, dict): + return super().setOptimizationOptions(**optimizationOptions) + raise ModelicaSystemError("Only dict input supported for setOptimizationOptions()") + + def setInputs( # type: ignore[override] + self, + name: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(name, dict): + return super().setInputs(**name) + raise ModelicaSystemError("Only dict input supported for setInputs()") + + def setSimulationOptions( # type: ignore[override] + self, + simOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(simOptions, dict): + return super().setSimulationOptions(**simOptions) + raise ModelicaSystemError("Only dict input supported for setSimulationOptions()") + + def setLinearizationOptions( # type: ignore[override] + self, + linearizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(linearizationOptions, dict): + return super().setLinearizationOptions(**linearizationOptions) + raise ModelicaSystemError("Only dict input supported for setLinearizationOptions()") + + def getContinuous( + self, + names: Optional[str | list[str]] = None, + ): + retval = super().getContinuous(names=names) + if self._simulated: + return retval + + if isinstance(retval, dict): + retval2: dict = {} + for key, val in retval.items(): + if np.isnan(val): + retval2[key] = None + else: + retval2[key] = str(val) + return retval2 + if isinstance(retval, list): + retval3: list[str | None] = [] + for val in retval: + if np.isnan(val): + retval3.append(None) + else: + retval3.append(str(val)) + return retval3 + + raise ModelExecutionException("Invalid data!") + + def getOutputs( + self, + names: Optional[str | list[str]] = None, + ): + retval = super().getOutputs(names=names) + if self._simulated: + return retval + + if isinstance(retval, dict): + retval2: dict = {} + for key, val in retval.items(): + if np.isnan(val): + retval2[key] = None + else: + retval2[key] = str(val) + return retval2 + if isinstance(retval, list): + retval3: list[str | None] = [] + for val in retval: + if np.isnan(val): + retval3.append(None) + else: + retval3.append(str(val)) + return retval3 + + raise ModelExecutionException("Invalid data!") + + +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) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem() + mod.model( model_name="M", model_file=model.as_posix(), + ) + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param, resultpath=resdir, simargs={"override": {'stopTime': 1.0}}, @@ -1950,15 +2317,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, @@ -1971,30 +2331,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}!") @@ -2005,14 +2353,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) -> OMPathBase: + """ + Get the path the 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 @@ -2039,30 +2393,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) - expression = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" - elif isinstance(pk_value, bool): - pk_value_bool_str = "true" if pk_value else "false" - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" - else: - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expression) - if not res: - raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " - f"to {pk_value} using {repr(expression)}") - - self._mod.buildModel() + 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 = {} @@ -2107,6 +2442,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 @@ -2124,7 +2470,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. """ @@ -2170,13 +2516,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: @@ -2218,65 +2564,302 @@ 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) + expression = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" + elif isinstance(pk_value, bool): + pk_value_bool_str = "true" if pk_value else "false" + expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + else: + expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" + res = self._mod.sendExpression(expression) + if not res: + raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " + f"to {pk_value} using {repr(expression)}") + + self._mod.buildModel() + + 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!") - * A list of variables if val_list == None + return doe_get_solutions( + msomc=self._mod, + resultpath=self._resultpath, + doe_def=self.get_doe_definition(), + var_list=var_list, + ) - * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - The following code snippet can be used to convert the solution data for each run to a pandas dataframe: +def doe_get_solutions( + msomc: ModelicaSystemOMC, + resultpath: OMPathBase, + 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: - ``` - import pandas as pd + * A list of variables if val_list == None - 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 Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. + + The following code snippet can be used to convert the solution data for each run to a pandas dataframe: + + ``` + import pandas as pd + + doe_sol = doe_mod.get_doe_solutions() + for key in doe_sol: + data = doe_sol[key]['data'] + if data: + doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) + else: + doe_sol[key]['df'] = None + ``` + + """ + if not isinstance(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. + """ + + +# TODO: rename ModelicaRunner +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)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + # set variables + self._model_name = model_name # Model class name + self._variable_filter = variable_filter - sol_dict[resultfilename] = {} + # test if the model can be executed + self.check_model_executable() - 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 + # read XML file + xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" + self._xmlparse(xml_file=xml_file) - 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 + +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: 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, ModelicaSystemRunner): + 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]: + 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 {} + + +class ModelicaSystemCmd(ModelExecutionCmd): + def __init__( + self, + runpath: pathlib.Path, + modelname: str, + timeout: float = 10.0, + ) -> None: + super().__init__( + runpath=runpath, + timeout=timeout, + cmd_prefix=[], + model_name=modelname, + ) + + def get_exe(self) -> pathlib.Path: + """Get the path to the compiled model executable.""" + import platform + + path_run = pathlib.Path(self._runpath) + if platform.system() == "Windows": + path_exe = path_run / f"{self._model_name}.exe" + else: + path_exe = path_run / self._model_name + + if not path_exe.exists(): + raise ModelicaSystemError(f"Application file path not found: {path_exe}") + + return path_exe + + def get_cmd(self) -> list: + """Get a list with the path to the executable and all command line args. + + This can later be used as an argument for subprocess.run(). + """ + + cmdl = [self.get_exe().as_posix()] + self.get_cmd_args() + + return cmdl + + def run(self): + cmd_definition = self.definition() + return cmd_definition.run() + + +# TODO: class ModelicaRunnerLocal / ModelicaRunnerBashLocal / MOdelicaRunnerBashWSL / MOdelicaRunnerBashDocker +# TODO: error for < 3.12? (@docker / WSL / ...) +# TODO: move classes diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 861f2a3a1..1389fede1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -20,13 +20,13 @@ import sys import tempfile import time -from typing import Any, Optional, Tuple +from typing import Any, Optional, Tuple, Type import uuid import warnings -import zmq import psutil import pyparsing +import zmq # TODO: replace this with the new parser from OMPython.OMTypedParser import om_parser_typed @@ -50,7 +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: @@ -59,6 +59,7 @@ def wait(self, timeout): pass +# TODO: rename OMSessionException class OMCSessionException(Exception): """ Exception which is raised by any OMC* class. @@ -70,8 +71,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 @@ -255,179 +256,256 @@ 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). - """ - - def __init__(self, *path, session: OMCSession) -> None: - super().__init__(*path) - self._session = session +if sys.version_info >= (3, 12): - def with_segments(self, *pathsegments): + class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ - Create a new OMCPath object with the given path segments. + 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. - The original definition of Path is overridden to ensure the OMC session is set. + 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. """ - return type(self)(*pathsegments, session=self._session) - def is_file(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a regular file. - """ - return self._session.sendExpression(f'regularFileExists("{self.as_posix()}")') + def __init__(self, *path, session: OMSessionABC) -> None: + super().__init__(*path) + self._session = session - def is_dir(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a directory. - """ - return self._session.sendExpression(f'directoryExists("{self.as_posix()}")') + def get_session(self) -> OMSessionABC: + """ + Get session definition used for this instance of OMPath. + """ + return self._session - 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. - """ - if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return super().is_absolute() + def with_segments(self, *pathsegments) -> OMPathABC: + """ + Create a new OMCPath object with the given path segments. - def read_text(self, encoding=None, errors=None, newline=None) -> str: - """ - Read the content of the file represented by this path as text. + The original definition of Path is overridden to ensure the session data is set. + """ + return type(self)(*pathsegments, session=self._session) - The additional arguments `encoding`, `errors` and `newline` are only defined for compatibility with Path() - definition. - """ - return self._session.sendExpression(f'readFile("{self.as_posix()}")') + @abc.abstractmethod + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ - def write_text(self, data: str, encoding=None, errors=None, newline=None): - """ - Write text data to the file represented by this path. + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ - 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__}") + @abc.abstractmethod + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ - data_omc = self._session.escape_str(data) - self._session.sendExpression(f'writeFile("{self.as_posix()}", "{data_omc}", false);') + @abc.abstractmethod + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ - return len(data) + @abc.abstractmethod + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ - def mkdir(self, mode=0o777, parents=False, exist_ok=False): - """ - Create a directory at the path represented by this OMCPath object. + @abc.abstractmethod + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. - 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!") + 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._session.sendExpression(f'mkdir("{self.as_posix()}")') + @abc.abstractmethod + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ - def cwd(self): - """ - Returns the current working directory as an OMCPath object. - """ - cwd_str = self._session.sendExpression('cd()') - return OMCPath(cwd_str, session=self._session) + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(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!") + @abc.abstractmethod + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. + """ + + def absolute(self) -> OMPathABC: + """ + Resolve the path to an absolute path. Just a wrapper for resolve(). + """ + return self.resolve() + + def exists(self) -> bool: + """ + Semi replacement for pathlib.Path.exists(). + """ + return self.is_file() or self.is_dir() + + @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 resolve(self, strict: bool = False): + class _OMCPath(OMPathABC): """ - Resolve the path to an absolute path. This is done based on available OMC functions. + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. """ - 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!") + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + retval = self.get_session().sendExpression(f'regularFileExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMCSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + return retval - 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 is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + retval = self.get_session().sendExpression(f'directoryExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMCSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") + return retval - return omcpath_resolved + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. + """ + if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return pathlib.PurePosixPath(self.as_posix()).is_absolute() - 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. - """ - expression = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + retval = self._session.sendExpression(f'readFile("{self.as_posix()}")') + if not isinstance(retval, str): + raise OMCSessionException(f"Invalid return value for read_text(): {retval} - expect str") + return retval - try: - result = self._session.sendExpression(command=expression, 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 write_text(self, data: str) -> int: + """ + 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__}") - return pathstr_resolved + data_escape = self._session.escape_str(data) + self._session.sendExpression(f'writeFile("{self.as_posix()}", "{data_escape}", false);') - 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) + return len(data) - def exists(self, follow_symlinks=True) -> bool: - """ - Semi replacement for pathlib.Path.exists(). - """ - return self.is_file() or self.is_dir() + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. - 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!") + 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!") - res = self._session.sendExpression(f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) + if not self._session.sendExpression(f'mkdir("{self.as_posix()}")'): + raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") - raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression('cd()') + return type(self)(cwd_str, session=self._session) - def stat(self): - """ - The function stat() cannot be implemented using OMC. - """ - raise NotImplementedError("The function stat() cannot be implemented using OMC; " - "use size() to get the file size.") + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(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) -> OMPathABC: + """ + 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!") -if sys.version_info < (3, 12): + 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. + """ + expression = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + + try: + retval = self._session.sendExpression(command=expression, parsed=False) + if not isinstance(retval, str): + raise OMCSessionException(f"Invalid return value for _omc_resolve(): {retval} - expect str") + result_parts = retval.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: + """ + 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!") - class OMCPathCompatibility(pathlib.Path): + res = self._session.sendExpression(f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + +else: + + class OMPathCompatibility(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. + Compatibility class for OMPathBase in Python < 3.12. This allows to run all code which uses OMPathBase (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. """ # modified copy of pathlib.Path.__new__() definition @@ -435,8 +513,8 @@ def __new__(cls, *args, **kwargs): logger.warning("Python < 3.12 - using a version of class OMCPath " "based on pathlib.Path for local usage only.") - if cls is OMCPathCompatibility: - cls = OMCPathCompatibilityWindows if os.name == 'nt' else OMCPathCompatibilityPosix + if cls is 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") @@ -448,49 +526,57 @@ def size(self) -> int: """ return self.stat().st_size - class OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): + class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): """ Compatibility class for OMCPath on Posix systems (Python < 3.12) """ - class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): + class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): """ Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - OMCPath = OMCPathCompatibility +if sys.version_info >= (3, 12): + OMPathBase = OMPathABC + OMCPath = _OMCPath else: - OMCPath = OMCPathReal + OMPathBase = OMPathCompatibility + OMCPath = OMPathCompatibility + + +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 - # command timeout - cmd_timeout: Optional[float] = 10.0 - # working directory to be used on the *local* system cmd_cwd_local: Optional[str] = None @@ -499,90 +585,48 @@ 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 - -class OMCSessionZMQ: - """ - This class is a compatibility layer for the new schema using OMCSession* classes. - """ - - def __init__( - self, - timeout: float = 10.00, - omhome: Optional[str] = None, - omc_process: Optional[OMCSession] = None, - ) -> None: + def run(self) -> int: """ - Initialisation for OMCSessionZMQ + Run the model execution defined in this class. """ - warnings.warn(message="The class OMCSessionZMQ is depreciated and will be removed in future versions; " - "please use OMCProcess* classes instead!", - category=DeprecationWarning, - stacklevel=2) - - if omc_process is None: - omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) - elif not isinstance(omc_process, OMCSession): - raise OMCSessionException("Invalid definition of the OMC process!") - self.omc_process = omc_process - def __del__(self): - del self.omc_process - - @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 OMCSession.escape_str(value=value) - - def omcpath(self, *path) -> OMCPath: - """ - 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: - """ - 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. + my_env = os.environ.copy() + if isinstance(self.cmd_library_path, str): + my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] - Needs to be implemented in the subclasses. - """ - return self.omc_process.omc_run_data_update(omc_run_data=omc_run_data) + cmdl = self.get_cmd() - @staticmethod - def run_model_executable(cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - return OMCSession.run_model_executable(cmd_run_data=cmd_run_data) + 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 - def execute(self, command: str): - return self.omc_process.execute(command=command) + logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - def sendExpression(self, command: str, parsed: bool = True) -> Any: - """ - Send an expression to the OMC server and return the result. + 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 - The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. - Caller should only check for OMCSessionException. - """ - return self.omc_process.sendExpression(command=command, parsed=parsed) + return returncode class PostInitCaller(type): @@ -612,7 +656,9 @@ def __call__(cls, *args, **kwargs): return obj -class OMCSessionMeta(abc.ABCMeta, PostInitCaller): +# TODO: define BaseSession / RunnerSession(BaseSession) +# TODO: define OMCSession(BaseSession) +class OMSessionMeta(abc.ABCMeta, PostInitCaller): """ Helper class to get a combined metaclass of ABCMeta and PostInitCaller. @@ -621,7 +667,108 @@ class OMCSessionMeta(abc.ABCMeta, PostInitCaller): """ -class OMCSession(metaclass=OMCSessionMeta): +# TODO: check in __post_init__() if the OMPathABC instance is valid (OMC exist / bash exists / pathlib.Path is correct) +# define a OMPathABC.verify() function for this +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[OMPathBase] = 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: OMPathBase) -> None: + """ + Set the workdir for this session. + """ + + @abc.abstractmethod + def omcpath(self, *path) -> OMPathBase: + """ + Create an OMPathBase object based on the given path segments and the current class. + """ + + @abc.abstractmethod + def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMPathBase: + """ + Get a temporary directory based on the specific definition for this session. + """ + + @staticmethod + def _tempdir(tempdir_base: OMPathBase) -> OMPathBase: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathBase] = 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, command: 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. @@ -649,9 +796,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 @@ -698,6 +848,9 @@ def __post_init__(self) -> None: self._omc_zmq = omc + # TODO: define as abc.abstractmethod; fix in *Port + # def _omc_port_get(self) -> str: + def __del__(self): if isinstance(self._omc_zmq, zmq.Socket): try: @@ -725,6 +878,7 @@ def __del__(self): self._omc_process.kill() self._omc_process.wait() finally: + self._omc_process = None @staticmethod @@ -734,7 +888,29 @@ def escape_str(value: str) -> str: """ return value.replace("\\", "\\\\").replace('"', '\\"') - def omcpath(self, *path) -> OMCPath: + def model_execution_prefix(self, cwd: Optional[OMPathBase] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + return [] + + def get_version(self) -> str: + """ + Get the OM version. + """ + retval = self.sendExpression("getVersion()", parsed=True) + if not isinstance(retval, str): + raise OMCSessionException(f"Invalid return value for get_version(): {retval} - expect str") + return retval + + def set_workdir(self, workdir: OMPathBase) -> None: + """ + Set the workdir for this session. + """ + exp = f'cd("{workdir.as_posix()}")' + self.sendExpression(exp) + + def omcpath(self, *path) -> OMPathBase: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. """ @@ -747,12 +923,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[OMPathBase] = None) -> OMPathBase: """ Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. """ - names = [str(uuid.uuid4()) for _ in range(100)] if tempdir_base is None: # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement @@ -762,7 +937,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: tempdir_str = self.sendExpression("getTempDirectoryPath()") tempdir_base = self.omcpath(tempdir_str) - tempdir: Optional[OMCPath] = None + return self._tempdir(tempdir_base=tempdir_base) + + @staticmethod + def _tempdir(tempdir_base: OMPathBase) -> OMPathBase: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathBase] = None for name in names: # create a unique temporary directory name tempdir = tempdir_base / name @@ -778,48 +959,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: return tempdir - @staticmethod - def run_model_executable(cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - - my_env = os.environ.copy() - if isinstance(cmd_run_data.cmd_library_path, str): - my_env["PATH"] = cmd_run_data.cmd_library_path + os.pathsep + my_env["PATH"] - - cmdl = cmd_run_data.get_cmd() - - logger.debug("Run OM command %s in %s", repr(cmdl), cmd_run_data.cmd_path) - try: - cmdres = subprocess.run( - cmdl, - capture_output=True, - text=True, - env=my_env, - cwd=cmd_run_data.cmd_cwd_local, - timeout=cmd_run_data.cmd_timeout, - check=True, - ) - stdout = cmdres.stdout.strip() - stderr = cmdres.stderr.strip() - returncode = cmdres.returncode - - logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - - if stderr: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}: {stderr}") - except subprocess.TimeoutExpired as ex: - raise OMCSessionException(f"Timeout running model executable {repr(cmdl)}") from ex - except subprocess.CalledProcessError as ex: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}") from ex - - return returncode - def execute(self, command: str): - warnings.warn("This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", DeprecationWarning, stacklevel=2) + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) return self.sendExpression(command, parsed=False) @@ -997,20 +1143,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. """ @@ -1018,18 +1152,16 @@ class OMCSessionPort(OMCSession): def __init__( self, omc_port: str, + timeout: float = 10.00, ) -> None: - super().__init__() + super().__init__(timeout=timeout) self._omc_port = omc_port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - raise OMCSessionException("OMCSessionPort does not support omc_run_data_update()!") + # TODO: add _omc_port_get() + # TODO: add _omc_port_test in OMCSessionABC => test / get version using sendExpression() -class OMCSessionLocal(OMCSession): +class OMCSessionLocal(OMCSessionABC): """ OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). """ @@ -1042,6 +1174,8 @@ def __init__( super().__init__(timeout=timeout) + self.model_execution_local = True + # where to find OpenModelica self._omhome = self._omc_home_get(omhome=omhome) # start up omc executable, which is waiting for the ZMQ connection @@ -1112,60 +1246,99 @@ def _omc_port_get(self) -> str: return port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + +# TODO: OMCSessionCMd => separate file; based on OMCSessionZMQ? +class OMCSessionZMQ(OMSessionABC): + """ + This class is a compatibility layer for the new schema using OMCSession* classes. + """ + + def __init__( + self, + timeout: float = 10.00, + omhome: Optional[str] = None, + omc_process: Optional[OMCSessionABC] = None, + ) -> None: """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. + Initialisation for OMCSessionZMQ """ - # create a copy of the data - omc_run_data_copy = dataclasses.replace(omc_run_data) + super().__init__(timeout=timeout) + warnings.warn(message="The class OMCSessionZMQ is depreciated and will be removed in future versions; " + "please use OMCProcess* classes instead!", + category=DeprecationWarning, + stacklevel=2) + + if omc_process is None: + omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) + elif not isinstance(omc_process, OMCSessionABC): + raise OMCSessionException("Invalid definition of the OMC process!") + self.omc_process = omc_process - # as this is the local implementation, pathlib.Path can be used - cmd_path = pathlib.Path(omc_run_data_copy.cmd_path) + def __del__(self): + if hasattr(self, 'omc_process'): + del self.omc_process + + @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 OMCSessionABC.escape_str(value=value) - if platform.system() == "Windows": - path_dll = "" + def omcpath(self, *path) -> OMPathBase: + """ + Create an OMCPath object based on the given path segments and the current OMC process definition. + """ + return self.omc_process.omcpath(*path) - # 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)) + def omcpath_tempdir(self, tempdir_base: Optional[OMPathBase] = None) -> OMPathBase: + """ + 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) - 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"] + def execute(self, command: str): + return self.omc_process.execute(command=command) - omc_run_data_copy.cmd_library_path = path_dll + def sendExpression(self, command: str, parsed: bool = True) -> Any: + """ + Send an expression to the OMC server and return the result. - 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 + The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. + Caller should only check for OMCSessionException. + """ + return self.omc_process.sendExpression(command=command, parsed=parsed) - 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() + def get_version(self) -> str: + return self.omc_process.get_version() - # define local(!) working directory - omc_run_data_copy.cmd_cwd_local = omc_run_data.cmd_path + def model_execution_prefix(self, cwd: Optional[OMPathBase] = None) -> list[str]: + return self.omc_process.model_execution_prefix(cwd=cwd) - return omc_run_data_copy + def set_workdir(self, workdir: OMPathBase) -> None: + return self.omc_process.set_workdir(workdir=workdir) -class OMCSessionDockerHelper(OMCSession): +class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): """ Base class for OMCSession implementations which run the OMC server in a Docker container. """ def __init__( self, - timeout: float = 10.00, + timeout: float = 10.0, + # TODO: rename docker_image + docker: Optional[str] = None, + # TODO: rename docker_* + dockerContainer: Optional[str] = None, + # TODO: rename docker_* dockerExtraArgs: Optional[list] = None, + # TODO: rename docker_* dockerOpenModelicaPath: str | os.PathLike = "omc", + # TODO: rename docker_* dockerNetwork: Optional[str] = None, + # TODO: rename omc_port port: Optional[int] = None, ) -> None: super().__init__(timeout=timeout) @@ -1176,11 +1349,21 @@ def __init__( self._docker_extra_args = dockerExtraArgs self._docker_open_modelica_path = pathlib.PurePosixPath(dockerOpenModelicaPath) self._docker_network = dockerNetwork + self._docker_container_id: str + self._docker_process: Optional[DockerPopen] - self._interactive_port = port + # start up omc executable in docker container waiting for the ZMQ connection + self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start( + docker_image=docker, + docker_cid=dockerContainer, + omc_port=port, + ) + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get(docker_cid=self._docker_container_id) + if port is not None and not self._omc_port.endswith(f":{port}"): + raise OMCSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") - self._docker_container_id: Optional[str] = None - self._docker_process: Optional[DockerPopen] = None + self._cmd_prefix = self.model_execution_prefix() def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: if sys.platform == 'win32': @@ -1205,6 +1388,15 @@ def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: return docker_process + @abc.abstractmethod + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + pass + @staticmethod def _getuid() -> int: """ @@ -1216,11 +1408,14 @@ def _getuid() -> int: # Windows, hence the type: ignore comment. return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore - def _omc_port_get(self) -> str: + def _omc_port_get( + self, + docker_cid: str, + ) -> str: port = None - if not isinstance(self._docker_container_id, str): - raise OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}") + if not isinstance(docker_cid, str): + raise OMCSessionException(f"Invalid docker container ID: {docker_cid}") # See if the omc server is running attempts = 0 @@ -1229,7 +1424,7 @@ def _omc_port_get(self) -> str: if omc_portfile_path is not None: try: output = subprocess.check_output(args=["docker", - "exec", self._docker_container_id, + "exec", docker_cid, "cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL) port = output.decode().strip() @@ -1256,7 +1451,10 @@ def get_server_address(self) -> Optional[str]: """ if self._docker_network == "separate" and isinstance(self._docker_container_id, str): output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() - return json.loads(output)[0]["NetworkSettings"]["IPAddress"] + address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] + if not isinstance(address, str): + raise OMCSessionException(f"Invalid docker server address: {address}!") + return address return None @@ -1269,30 +1467,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[OMPathBase] = 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, OMPathBase): + 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. """ @@ -1309,27 +1501,16 @@ def __init__( super().__init__( timeout=timeout, + docker=docker, dockerExtraArgs=dockerExtraArgs, dockerOpenModelicaPath=dockerOpenModelicaPath, dockerNetwork=dockerNetwork, port=port, ) - if docker is None: - raise OMCSessionException("Argument docker must be set!") - - self._docker = docker - - # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - def __del__(self) -> None: - super().__del__() - - if isinstance(self._docker_process, DockerPopen): + if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): try: self._docker_process.wait(timeout=2.0) except subprocess.TimeoutExpired: @@ -1341,29 +1522,37 @@ def __del__(self) -> None: finally: self._docker_process = None + super().__del__() + def _docker_omc_cmd( self, - omc_path_and_args_list: list[str], + docker_image: str, docker_cid_file: pathlib.Path, + omc_path_and_args_list: list[str], + omc_port: Optional[int | str] = None, ) -> list: """ Define the command that will be called by the subprocess module. """ + extra_flags = [] if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactive_port: - raise OMCSessionException("docker on Windows requires knowing which port to connect to - " + if not self._omc_port: + raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " "please set the interactivePort argument") + port: Optional[int] = None + if isinstance(omc_port, str): + port = int(omc_port) + elif isinstance(omc_port, int): + port = omc_port + if sys.platform == "win32": - if isinstance(self._interactive_port, str): - port = int(self._interactive_port) - elif isinstance(self._interactive_port, int): - port = self._interactive_port - else: - raise OMCSessionException("Missing or invalid interactive port!") + if not isinstance(port, int): + raise OMCSessionException("OMC on Windows needs the interactive port - " + f"missing or invalid value: {repr(omc_port)}!") docker_network_str = ["-p", f"127.0.0.1:{port}:{port}"] elif self._docker_network == "host" or self._docker_network is None: docker_network_str = ["--network=host"] @@ -1374,8 +1563,8 @@ def _docker_omc_cmd( raise OMCSessionException(f'dockerNetwork was set to {self._docker_network}, ' 'but only \"host\" or \"separate\" is allowed') - if isinstance(self._interactive_port, int): - extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] + if isinstance(port, int): + extra_flags = extra_flags + [f"--interactivePort={port}"] omc_command = ([ "docker", "run", @@ -1385,22 +1574,33 @@ def _docker_omc_cmd( ] + self._docker_extra_args + docker_network_str - + [self._docker, self._docker_open_modelica_path.as_posix()] + + [docker_image, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_image, str): + raise OMCSessionException("A docker image name must be provided!") + my_env = os.environ.copy() docker_cid_file = self._temp_dir / (self._omc_filebase + ".docker.cid") omc_command = self._docker_omc_cmd( + docker_image=docker_image, + docker_cid_file=docker_cid_file, omc_path_and_args_list=["--locale=C", "--interactive=zmq", f"-z={self._random_string}"], - docker_cid_file=docker_cid_file, + omc_port=omc_port, ) omc_process = subprocess.Popen(omc_command, @@ -1411,6 +1611,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: if not isinstance(docker_cid_file, pathlib.Path): raise OMCSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") + # the provided value for docker_cid is not used docker_cid = None for _ in range(0, 40): try: @@ -1423,9 +1624,9 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: time.sleep(self._timeout / 40.0) if docker_cid is None: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") raise OMCSessionException(f"Docker did not start (timeout={self._timeout} might be too short " - "especially if you did not docker pull the image before this command).") + "especially if you did not docker pull the image before this command). " + f"Log-file says:\n{self.get_log()}") docker_process = self._docker_process_get(docker_cid=docker_cid) if docker_process is None: @@ -1435,7 +1636,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). """ @@ -1452,22 +1653,13 @@ def __init__( super().__init__( timeout=timeout, + dockerContainer=dockerContainer, dockerExtraArgs=dockerExtraArgs, dockerOpenModelicaPath=dockerOpenModelicaPath, dockerNetwork=dockerNetwork, port=port, ) - if not isinstance(dockerContainer, str): - raise OMCSessionException("Argument dockerContainer must be set!") - - self._docker_container_id = dockerContainer - - # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process = self._docker_omc_start() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - def __del__(self) -> None: super().__del__() @@ -1475,7 +1667,12 @@ def __del__(self) -> None: # docker container ID was provided - do NOT kill the docker process! self._docker_process = None - def _docker_omc_cmd(self, omc_path_and_args_list) -> list: + def _docker_omc_cmd( + self, + docker_cid: str, + omc_path_and_args_list: list[str], + omc_port: Optional[int] = None, + ) -> list: """ Define the command that will be called by the subprocess module. """ @@ -1483,33 +1680,44 @@ def _docker_omc_cmd(self, omc_path_and_args_list) -> list: if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactive_port: + if not isinstance(omc_port, int): raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " "Please set the interactivePort argument. Furthermore, the container needs " "to have already manually exposed this port when it was started " "(-p 127.0.0.1:n:n) or you get an error later.") - if isinstance(self._interactive_port, int): - extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] + if isinstance(omc_port, int): + extra_flags = extra_flags + [f"--interactivePort={omc_port}"] omc_command = ([ "docker", "exec", "--user", str(self._getuid()), ] + self._docker_extra_args - + [self._docker_container_id, self._docker_open_modelica_path.as_posix()] + + [docker_cid, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_cid, str): + raise OMCSessionException("A docker container ID must be provided!") + my_env = os.environ.copy() omc_command = self._docker_omc_cmd( + docker_cid=docker_cid, omc_path_and_args_list=["--locale=C", "--interactive=zmq", f"-z={self._random_string}"], + omc_port=omc_port, ) omc_process = subprocess.Popen(omc_command, @@ -1518,17 +1726,17 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: env=my_env) docker_process = None - if isinstance(self._docker_container_id, str): - docker_process = self._docker_process_get(docker_cid=self._docker_container_id) + if isinstance(docker_cid, str): + docker_process = self._docker_process_get(docker_cid=docker_cid) if docker_process is None: raise OMCSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {self._docker_container_id}. Log-file says:\n{self.get_log()}") + f"/ {docker_cid}. Log-file says:\n{self.get_log()}") - return omc_process, docker_process + return omc_process, docker_process, docker_cid -class OMCSessionWSL(OMCSession): +class OMCSessionWSL(OMCSessionABC): """ OMC process running in Windows Subsystem for Linux (WSL). """ @@ -1553,15 +1761,20 @@ 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]: + self._cmd_prefix = self.model_execution_prefix() + + def model_execution_prefix(self, cwd: Optional[OMPathBase] = 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, OMPathBase): + wsl_cmd += ['--cd', cwd.as_posix()] wsl_cmd += ['--'] return wsl_cmd @@ -1569,11 +1782,12 @@ 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", - f"-z={self._random_string}"] + f"-z={self._random_string}", + ] omc_process = subprocess.Popen(omc_command, stdout=self._omc_loghandle, @@ -1592,7 +1806,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() @@ -1614,16 +1828,358 @@ def _omc_port_get(self) -> str: return port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + +if sys.version_info >= (3, 12): + class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): + """ + Base function for OMPath definitions *without* OMC server + """ + + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + + class _OMPathRunnerLocal(OMPathRunnerABC): + """ + Implementation of OMPathABC 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 OMPathABC 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()). + """ + + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + return self._path().is_file() + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + return self._path().is_dir() + + def is_absolute(self) -> bool: + """ + 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. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + return self._path().write_text(data=data, encoding='utf-8') + + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + 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. + """ + self._path().mkdir(parents=parents, exist_ok=exist_ok) + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + return type(self)(self._path().cwd().as_posix(), session=self._session) + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + self._path().unlink(missing_ok=missing_ok) + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + 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 + + # TODO get_cmd_prefix() + # TODO: used by WSL / docker + # TODO: get cmd_prefix via OMSession.model_execution_prefix() + + class _OMPathRunnerBash(OMPathRunnerABC): + """ + Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. The special case of this class is the usage of POSIX bash to run all the + commands. Thus, it can be used in WSL or docker. + + This class is based on OMPathABC 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()). + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -d "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] + + try: + subprocess.check_call(cmdl) + return True + except subprocess.CalledProcessError: + return False + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, check=True) + if result.returncode == 0: + return result.stdout.decode('utf-8') + raise FileNotFoundError(f"Cannot read file: {self.as_posix()}") + + def write_text(self, data: str) -> int: + """ + 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__}") + + data_escape = self._session.escape_str(data) + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'printf %s "{data_escape}" > "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return len(data) + except subprocess.CalledProcessError: + raise IOError(f"Error writing data to file {self.as_posix()}!") + + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + """ + 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. + """ + + if self.is_file(): + raise OSError(f"The given path {self.as_posix()} exists and is a file!") + if self.is_dir() and not exist_ok: + raise OSError(f"The given path {self.as_posix()} exists and is a directory!") + if not parents and not self.parent.is_dir(): + raise FileNotFoundError(f"Parent directory of {self.as_posix()} does not exists!") + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'mkdir -p "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") from exc + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', 'pwd'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise OSError("Can not get current work directory ...") + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + if not self.is_file(): + raise OSError(f"Can not unlink a directory: {self.as_posix()}!") + + if not self.is_file(): + return + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OSError(f"Cannot unlink file {self.as_posix()}: {exc}") from exc + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'readlink -f "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise FileNotFoundError(f"Cannot resolve path: {self.as_posix()}") + + 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!") + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'stat -c %s "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + stdout = result.stdout.strip() + if result.returncode == 0: + try: + return int(stdout) + except ValueError as exc: + raise OSError(f"Invalid return value for filesize ({self.as_posix()}): {stdout}") from exc + else: + raise OSError(f"Cannot get size for file {self.as_posix()}") + + +if sys.version_info >= (3, 12): + OMPathRunnerBase = OMPathRunnerABC + OMPathRunnerLocal = _OMPathRunnerLocal + OMPathRunnerBash = _OMPathRunnerBash +else: + OMPathRunnerBase = OMPathCompatibility + OMPathRunnerLocal = OMPathCompatibility + OMPathRunnerBash = OMPathCompatibility + + +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", + ompath_runner: Type[OMPathRunnerBase] = OMPathRunnerLocal, + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, + ) -> None: + super().__init__(timeout=timeout) + self._version = version + + if not issubclass(ompath_runner, OMPathRunnerBase): + raise OMCSessionException(f"Invalid OMPathRunner class: {type(ompath_runner)}!") + self._ompath_runner = ompath_runner + + self.model_execution_local = model_execution_local + if cmd_prefix is not None: + self._cmd_prefix = cmd_prefix + + # TODO: some checking?! + # if ompath_runner == Type[OMPathRunnerBash]: + + def __post_init__(self) -> None: + """ + No connection to an OMC server is created by this class! + """ + + def model_execution_prefix(self, cwd: Optional[OMPathBase] = None) -> list[str]: + """ + Helper function which returns a command prefix. """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. + return self.get_cmd_prefix() + + 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: OMPathBase) -> None: + """ + Set the workdir for this session. For OMSessionRunner this is a nop. The workdir must be defined within the + definition of cmd_prefix. + """ + + def omcpath(self, *path) -> OMPathBase: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + return self._ompath_runner(*path, session=self) + + def omcpath_tempdir(self, tempdir_base: Optional[OMPathBase] = None) -> OMPathBase: + """ + Get a temporary directory without using OMC. """ - omc_run_data_copy = dataclasses.replace(omc_run_data) + if tempdir_base is None: + tempdir_str = tempfile.gettempdir() + tempdir_base = self.omcpath(tempdir_str) - omc_run_data_copy.cmd_prefix = self._wsl_cmd(wsl_cwd=omc_run_data.cmd_path) + return self._tempdir(tempdir_base=tempdir_base) + + def sendExpression(self, command: str, parsed: bool = True) -> Any: + raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") - cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() - return omc_run_data_copy +DummyPopen = DockerPopen +OMCProcessLocal = OMCSessionLocal +OMCProcessPort = OMCSessionPort +OMCProcessDocker = OMCSessionDocker +OMCProcessDockerContainer = OMCSessionDockerContainer +OMCProcessWSL = OMCSessionWSL diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index 8b3474066..f5c30d2a1 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -35,10 +35,8 @@ import sys from typing import Dict, Any -result: Dict[str, Any] = dict() +result: Dict[str, Any] = {} -inner_sets = [] -next_set_list = [] next_set = [] next_set.append('') diff --git a/OMPython/OMTypedParser.py b/OMPython/OMTypedParser.py index de6148145..9fe810e06 100644 --- a/OMPython/OMTypedParser.py +++ b/OMPython/OMTypedParser.py @@ -34,23 +34,23 @@ from typing import Any from pyparsing import ( + alphanums, + alphas, Combine, + DelimitedList, Dict, + infix_notation, Forward, Group, Keyword, + nums, + opAssoc, Optional, QuotedString, + replace_with, StringEnd, Suppress, Word, - alphanums, - alphas, - delimitedList, - nums, - replaceWith, - infixNotation, - opAssoc, ) @@ -97,7 +97,7 @@ def evaluate_expression(s, loc, toks): # Number parsing (supports arithmetic expressions in dimensions) (e.g., {1 + 1, 1}) -arrayDimension = infixNotation( +arrayDimension = infix_notation( Word(alphas + "_", alphanums + "_") | Word(nums), [ (Word("+-", exact=1), 1, opAssoc.RIGHT), @@ -109,28 +109,28 @@ def evaluate_expression(s, loc, toks): omcRecord = Forward() omcValue = Forward() -# pyparsing's replace_with (and thus replaceWith) has incorrect type +# pyparsing's replace_with (and thus replace_with) has incorrect type # annotation: https://github.com/pyparsing/pyparsing/issues/602 -TRUE = Keyword("true").set_parse_action(replaceWith(True)) # type: ignore -FALSE = Keyword("false").set_parse_action(replaceWith(False)) # type: ignore -NONE = (Keyword("NONE") + Suppress("(") + Suppress(")")).set_parse_action(replaceWith(None)) # type: ignore +TRUE = Keyword("true").set_parse_action(replace_with(True)) # type: ignore +FALSE = Keyword("false").set_parse_action(replace_with(False)) # type: ignore +NONE = (Keyword("NONE") + Suppress("(") + Suppress(")")).set_parse_action(replace_with(None)) # type: ignore SOME = (Suppress(Keyword("SOME")) + Suppress("(") + omcValue + Suppress(")")) -omcString = QuotedString(quoteChar='"', escChar='\\', multiline=True).set_parse_action(convert_string) +omcString = QuotedString(quote_char='"', esc_char='\\', multiline=True).set_parse_action(convert_string) omcNumber = Combine(Optional('-') + ('0' | Word('123456789', nums)) + Optional('.' + Word(nums)) + Optional(Word('eE', exact=1) + Word(nums + '+-', nums))) # ident = Word(alphas + "_", alphanums + "_") | Combine("'" + Word(alphanums + "!#$%&()*+,-./:;<>=?@[]^{}|~ ") + "'") ident = (Word(alphas + "_", alphanums + "_") - | QuotedString(quoteChar='\'', escChar='\\').set_parse_action(convert_string2)) + | QuotedString(quote_char='\'', esc_char='\\').set_parse_action(convert_string2)) fqident = Forward() fqident << ((ident + "." + fqident) | ident) -omcValues = delimitedList(omcValue) +omcValues = DelimitedList(omcValue) omcTuple = Group(Suppress('(') + Optional(omcValues) + Suppress(')')).set_parse_action(convert_tuple) omcArray = Group(Suppress('{') + Optional(omcValues) + Suppress('}')).set_parse_action(convert_tuple) omcArraySpecialTypes = Group(Suppress('{') - + delimitedList(arrayDimension) + + DelimitedList(arrayDimension) + Suppress('}')).set_parse_action(convert_tuple) omcValue << (omcString | omcNumber @@ -143,7 +143,7 @@ def evaluate_expression(s, loc, toks): | FALSE | NONE | Combine(fqident)) -recordMember = delimitedList(Group(ident + Suppress('=') + omcValue)) +recordMember = DelimitedList(Group(ident + Suppress('=') + omcValue)) omcRecord << Group(Suppress('record') + Suppress(fqident) + Dict(recordMember) @@ -161,3 +161,6 @@ def om_parser_typed(string) -> Any: if len(res) == 0: return None return res[0] + + +parseString = om_parser_typed diff --git a/OMPython/__init__.py b/OMPython/__init__.py index de8617366..f65f3aa7f 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -1,46 +1,100 @@ # -*- 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, + ModelicaSystemABC, ModelicaSystem, - ModelicaSystemCmd, + ModelicaSystemOMC, + ModelExecutionCmd, ModelicaSystemDoE, + ModelicaDoEOMC, ModelicaSystemError, + ModelicaSystemRunner, + + doe_get_solutions, + + ModelicaSystemCmd, ) from OMPython.OMCSession import ( + ModelExecutionData, + ModelExecutionException, + + OMSessionABC, + OMCSessionCmd, OMCSessionException, - OMCSessionRunData, OMCSessionZMQ, OMCSessionPort, OMCSessionLocal, + + OMCPath, + OMPathRunnerBash, + OMPathRunnerLocal, + + OMCSessionABC, OMCSessionDocker, OMCSessionDockerContainer, + OMSessionRunner, OMCSessionWSL, + + OMCProcessLocal, + OMCProcessPort, + OMCProcessDocker, + OMCProcessDockerContainer, ) # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', - 'ModelicaSystem', - 'ModelicaSystemCmd', + + 'ModelicaSystemABC', + 'ModelExecutionData', + 'ModelExecutionException', + + 'ModelicaSystem', # compatibility layer + 'ModelicaSystemOMC', + 'ModelExecutionCmd', 'ModelicaSystemDoE', + 'ModelicaDoEOMC', 'ModelicaSystemError', + 'ModelicaSystemRunner', + + 'ModelicaSystemCmd', + + 'doe_get_solutions', - 'OMCSessionCmd', + 'OMSessionABC', + + 'OMCSessionCmd', # obsolete 'OMCSessionException', - 'OMCSessionRunData', - 'OMCSessionZMQ', + 'OMCSessionZMQ', # compatibility layer 'OMCSessionPort', 'OMCSessionLocal', + + 'OMCPath', + 'OMPathRunnerBash', + 'OMPathRunnerLocal', + + 'OMSessionRunner', + + 'OMCSessionABC', 'OMCSessionDocker', 'OMCSessionDockerContainer', 'OMCSessionWSL', + + 'OMCProcessLocal', + 'OMCProcessPort', + 'OMCProcessDocker', + 'OMCProcessDockerContainer', ] diff --git a/pyproject.toml b/pyproject.toml index e9636ab57..5dbb7f252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,11 @@ disable = [ # TODO: the items below should be checked and corrected 'C0103', # Variable name doesn't conform to snake_case naming style (invalid-name) 'C0116', # Missing function or method docstring (missing-function-docstring) - 'W0511', # TODO / fixme (fixme) + 'W0221', # Variadics removed in overriding '???' method (arguments-differ) + 'W0511', # Warning for TODO, FIXME, or similar comments left in the code (fixme) 'W1203', # Use lazy % formatting in logging functions (logging-fstring-interpolation) + + 'C0114', # Missing module docstring (missing-module-docstring) + 'C0123', # Use isinstance() rather than type() for a typecheck. (unidiomatic-typecheck) + 'W0621', # Redefining name '???' from outer scope (line ???) (redefined-outer-name) ] diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index 006d2d177..65ac27664 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -1,12 +1,12 @@ -import shutil import os import pathlib +import shutil import OMPython 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 cb43e0aea..bb3a12017 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_FMIRegression.py b/tests/test_FMIRegression.py index b61b8d497..9bc65044b 100644 --- a/tests/test_FMIRegression.py +++ b/tests/test_FMIRegression.py @@ -1,11 +1,13 @@ -import tempfile +import os import pathlib +import pytest import shutil -import os +import tempfile import OMPython +# TODO: this checks OMC not OMPython, or? - needed, takes too long; move to separate workflow? def buildModelFMU(modelName): omc = OMPython.OMCSessionZMQ() @@ -29,41 +31,51 @@ def test_Modelica_Blocks_Examples_Filter(): buildModelFMU("Modelica.Blocks.Examples.Filter") +@pytest.mark.skip("takes too long") def test_Modelica_Blocks_Examples_RealNetwork1(): buildModelFMU("Modelica.Blocks.Examples.RealNetwork1") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Analog_Examples_CauerLowPassAnalog(): buildModelFMU("Modelica.Electrical.Analog.Examples.CauerLowPassAnalog") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Digital_Examples_FlipFlop(): buildModelFMU("Modelica.Electrical.Digital.Examples.FlipFlop") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_FirstGrounded(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.FirstGrounded") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_CoupledClutches(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.CoupledClutches") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_DoublePendulum(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.DoublePendulum") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_FreeBody(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.FreeBody") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_PumpingSystem(): buildModelFMU("Modelica.Fluid.Examples.PumpingSystem") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_TraceSubstances_RoomCO2WithControls(): buildModelFMU("Modelica.Fluid.Examples.TraceSubstances.RoomCO2WithControls") +@pytest.mark.skip("takes too long") def test_Modelica_Clocked_Examples_SimpleControlledDrive_ClockedWithDiscreteTextbookController(): buildModelFMU("Modelica.Clocked.Examples.SimpleControlledDrive.ClockedWithDiscreteTextbookController") diff --git a/tests/test_ModelicaDoEOMC.py b/tests/test_ModelicaDoEOMC.py new file mode 100644 index 000000000..1ef712afc --- /dev/null +++ b/tests/test_ModelicaDoEOMC.py @@ -0,0 +1,164 @@ +import pathlib +import sys + +import numpy as np +import pytest + +import OMPython + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + +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 = { + # structural + 'p': [1, 2], + 'q': [3, 4], + # simple + 'a': [5, 6], + 'b': [7, 8], + } + return param + + +def test_ModelicaDoEOMC_local(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", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, + parameters=param_doe, + resultpath=tmpdir, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaDoEOMC(doe_mod=doe_mod) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaDoEOMC_docker(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_doe, + model_name="M", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, + parameters=param_doe, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaDoEOMC(doe_mod=doe_mod) + + +@pytest.mark.skip(reason="Not able to run WSL on github") +@skip_python_older_312 +def test_ModelicaDoEOMC_WSL(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionWSL() + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_doe, + model_name="M", + ) + + doe_mod = OMPython.ModelicaDoEOMC( + mod=mod, + parameters=param_doe, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaDoEOMC(doe_mod=doe_mod) + + +def _run_ModelicaDoEOMC(doe_mod): + doe_count = doe_mod.prepare() + assert doe_count == 16 + + 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 + + doe_sol = doe_mod.get_doe_solutions() + assert isinstance(doe_sol, dict) + assert len(doe_sol.keys()) == doe_count + + 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']), + # structural parameters + 'p': float(row['p']), + 'q': float(row['q']), + # variables using the structural parameters + f"x[{row['p']}]": float(row['a']), + f"y[{row['p']}]": float(row['b']), + } + + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 2480aad97..db5aadeb4 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.get_model_name(), ) + return mscmd diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index 79c6e62d8..65df95f81 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -55,12 +55,17 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC() + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -69,16 +74,22 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): @skip_on_windows @skip_python_older_312 def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): - omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - omc = OMPython.OMCSessionZMQ(omc_process=omcp) + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omc = OMPython.OMCSessionZMQ(omc_process=omcs) assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, - session=omcp, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -87,15 +98,22 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): - tmpdir = tmp_path / 'DoE' - tmpdir.mkdir(exist_ok=True) + omcs = OMPython.OMCSessionWSL() + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, - resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -141,6 +159,6 @@ def _run_ModelicaSystemDoe(doe_mod): f"y[{row['p']}]": float(row['b']), } - for var in var_dict: - assert var in sol['data'] - assert np.isclose(sol['data'][var][-1], var_dict[var]) + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystemOMC.py similarity index 89% rename from tests/test_ModelicaSystem.py rename to tests/test_ModelicaSystemOMC.py index dcc55d0ba..a8c215c48 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystemOMC.py @@ -38,9 +38,10 @@ def model_firstorder(tmp_path, model_firstorder_content): return mod +@pytest.mark.skip("takes too long") def test_ModelicaSystem_loop(model_firstorder): def worker(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", @@ -55,7 +56,7 @@ def test_setParameters(): omc = OMPython.OMCSessionZMQ() model_path_str = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omc.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -90,7 +91,7 @@ def test_setSimulationOptions(): omc = OMPython.OMCSessionZMQ() model_path_str = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omc.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -127,7 +128,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", @@ -140,7 +141,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", @@ -158,7 +159,7 @@ def test_getSolutions_docker(model_firstorder): omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") omc = OMPython.OMCSessionZMQ(omc_process=omcp) - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omc.omc_process, ) mod.model( @@ -170,7 +171,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", @@ -218,7 +219,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", @@ -346,20 +347,33 @@ def test_getters(tmp_path): with pytest.raises(KeyError): mod.getInputs("thisInputDoesNotExist") # getOutputs before simulate() - assert mod.getOutputs() == {'y': '-0.4'} - assert mod.getOutputs("y") == ["-0.4"] - assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + output = mod.getOutputs() + assert len(output) == 1 + assert 'y' in output.keys() + assert np.isclose(output['y'], -0.4) + assert np.isclose(mod.getOutputs("y"), -0.4) + output = mod.getOutputs(["y", "y"]) + assert len(output) == 2 + assert np.isclose(output[0], -0.4) + assert np.isclose(output[1], -0.4) with pytest.raises(KeyError): mod.getOutputs("thisOutputDoesNotExist") # getContinuous before simulate(): - assert mod.getContinuous() == { - 'x': '1.0', - 'der(x)': None, - 'y': '-0.4' - } - assert mod.getContinuous("y") == ['-0.4'] - assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + continuous = mod.getContinuous() + assert len(continuous) == 3 + assert 'x' in continuous.keys() + assert np.isclose(continuous['x'], 1.0) + assert 'der(x)' in continuous.keys() + assert np.isnan(continuous['der(x)']) + assert 'y' in continuous.keys() + assert np.isclose(continuous['y'], -0.4) + continuous = mod.getContinuous('y') + assert np.isclose(continuous, -0.4) + continuous = mod.getContinuous(['y', 'x']) + assert np.isclose(continuous[0], -0.4) + assert np.isclose(continuous[1], 1.0) + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter @@ -382,9 +396,9 @@ def test_getters(tmp_path): mod.getOutputs("thisOutputDoesNotExist") # getContinuous after simulate() should return values at end of simulation: - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous(["x", "a", "y"]) # a is a parameter d = mod.getContinuous() assert d.keys() == {"x", "der(x)", "y"} @@ -394,7 +408,7 @@ def test_getters(tmp_path): assert mod.getContinuous("x") == [d["x"]] assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter with pytest.raises(OMPython.ModelicaSystemError): @@ -414,7 +428,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", @@ -479,7 +493,7 @@ def test_simulate_inputs(tmp_path): } mod.setInputs(**inputs) csv_file = mod._createCSVData() - assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end + assert pathlib.Path(csv_file).read_text(encoding='utf-8') == """time,u1,u2,end 0.0,0.0,0.0,0 0.25,0.25,0.5,0 0.5,0.5,1.0,0 diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py new file mode 100644 index 000000000..199a136a0 --- /dev/null +++ b/tests/test_ModelicaSystemRunner.py @@ -0,0 +1,225 @@ +import sys + +import numpy as np +import pytest + +import OMPython + + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + +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_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_ModelicaSystemRunner(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + 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 test_ModelicaSystemRunner_local(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + 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(), + ompath_runner=OMPython.OMPathRunnerLocal, + ) + 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) + + +@skip_on_windows +def test_ModelicaSystemRunner_bash(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + 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(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + 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) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaSystemRunner_bash_docker(model_firstorder, param): + omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcp, + ) + 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(), + cmd_prefix=omcp.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, + ) + 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 b37e7c633..6bbb17abe 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -18,50 +18,78 @@ def test_OMCPath_OMCSessionZMQ(): om = OMPython.OMCSessionZMQ() - _run_OMCPath_checks(om) + # OMCSessionZMQ is NOT derived from OMSessionABC + _run_OMPath_checks(om) + _run_OMPath_write_file(om) del om -def test_OMCPath_OMCProcessLocal(): - omp = OMPython.OMCSessionLocal() - om = OMPython.OMCSessionZMQ(omc_process=omp) +def test_OMCPath_OMCSessionLocal(): + oms = OMPython.OMCSessionLocal() - _run_OMCPath_checks(om) - - del om + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) @skip_on_windows @skip_python_older_312 -def test_OMCPath_OMCProcessDocker(): - omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - om = OMPython.OMCSessionZMQ(omc_process=omcp) - assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" - - _run_OMCPath_checks(om) +def test_OMCPath_OMCSessionDocker(): + oms = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + assert oms.sendExpression("getVersion()") == "OpenModelica 1.25.0" - del omcp - del om + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 -def test_OMCPath_OMCProcessWSL(): - omcp = OMPython.OMCSessionWSL( +def test_OMCPath_OMCSessionWSL(): + oms = OMPython.OMCSessionWSL( wsl_omc='omc', wsl_user='omc', timeout=30.0, ) - om = OMPython.OMCSessionZMQ(omc_process=omcp) - _run_OMCPath_checks(om) + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_python_older_312 +def test_OMPath_OMPathRunnerLocal(): + oms = OMPython.OMSessionRunner() + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) - del omcp - del om +@skip_on_windows +@skip_python_older_312 +def test_OMPath_OMSessionRunnerBash(): + oms = OMPython.OMSessionRunner( + ompath_runner=OMPython.OMPathRunnerBash, + ) -def _run_OMCPath_checks(om: OMPython.OMCSessionZMQ): + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPath_OMPathRunnerBash_Docker(): + oms_docker = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + assert oms_docker.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +def _run_OMPath_checks(om: OMPython.OMSessionABC): p1 = om.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() @@ -71,8 +99,8 @@ def _run_OMCPath_checks(om: OMPython.OMCSessionZMQ): assert p3.write_text('test') assert p3.is_file() assert p3.size() > 0 - p3 = p3.resolve().absolute() - assert str(p3) == str((p2 / 'test.txt').resolve().absolute()) + p3 = p3.resolve() + assert str(p3) == str((p2 / 'test.txt').resolve()) assert p3.read_text() == "test" assert p3.is_file() assert p3.parent.is_dir() @@ -80,9 +108,7 @@ def _run_OMCPath_checks(om: OMPython.OMCSessionZMQ): assert p3.is_file() is False -def test_OMCPath_write_file(tmpdir): - om = OMPython.OMCSessionZMQ() - +def _run_OMPath_write_file(om: OMPython.OMSessionABC): data = "abc # \\t # \" # \\n # xyz" p1 = om.omcpath_tempdir() diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py index bff4afde0..830722429 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_ZMQ.py b/tests/test_ZMQ.py index ba101560d..56e34f548 100644 --- a/tests/test_ZMQ.py +++ b/tests/test_ZMQ.py @@ -1,5 +1,6 @@ import pathlib import os + import pytest import OMPython diff --git a/tests/test_docker.py b/tests/test_docker.py index 025c48e31..4200031eb 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -1,5 +1,7 @@ import sys + import pytest + import OMPython skip_on_windows = pytest.mark.skipif( diff --git a/tests/test_linearization.py b/tests/test_linearization.py index c61462bba..7070a45bd 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 d74942817..823ba1e39 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", diff --git a/tests_v400/__init__.py b/tests_v400/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests_v400/test_ArrayDimension.py b/tests_v400/test_ArrayDimension.py new file mode 100644 index 000000000..13b3c11b9 --- /dev/null +++ b/tests_v400/test_ArrayDimension.py @@ -0,0 +1,19 @@ +import OMPython + + +def test_ArrayDimension(tmp_path): + omc = OMPython.OMCSessionZMQ() + + omc.sendExpression(f'cd("{tmp_path.as_posix()}")') + + omc.sendExpression('loadString("model A Integer x[5+1,1+6]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[0][-1] == (6, 7), "array dimension does not match" + + omc.sendExpression('loadString("model A Integer y = 5; Integer x[y+1,1+9]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[-1][-1] == ('y+1', 10), "array dimension does not match" diff --git a/tests_v400/test_FMIExport.py b/tests_v400/test_FMIExport.py new file mode 100644 index 000000000..f47b87ae2 --- /dev/null +++ b/tests_v400/test_FMIExport.py @@ -0,0 +1,24 @@ +import OMPython +import shutil +import os + + +def test_CauerLowPassAnalog(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="CauerLowPassAnalog") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def test_DrumBoiler(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="DrumBoiler") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests_v400/test_FMIRegression.py b/tests_v400/test_FMIRegression.py new file mode 100644 index 000000000..60c23e076 --- /dev/null +++ b/tests_v400/test_FMIRegression.py @@ -0,0 +1,68 @@ +import OMPython +import tempfile +import pathlib +import shutil +import os + + +def buildModelFMU(modelName): + omc = OMPython.OMCSessionZMQ() + + tempdir = pathlib.Path(tempfile.mkdtemp()) + try: + omc.sendExpression(f'cd("{tempdir.as_posix()}")') + + omc.sendExpression("loadModel(Modelica)") + omc.sendExpression("getErrorString()") + + fileNamePrefix = modelName.split(".")[-1] + exp = f'buildModelFMU({modelName}, fileNamePrefix="{fileNamePrefix}")' + fmu = omc.sendExpression(exp) + assert os.path.exists(fmu) + finally: + del omc + shutil.rmtree(tempdir, ignore_errors=True) + + +def test_Modelica_Blocks_Examples_Filter(): + buildModelFMU("Modelica.Blocks.Examples.Filter") + + +def test_Modelica_Blocks_Examples_RealNetwork1(): + buildModelFMU("Modelica.Blocks.Examples.RealNetwork1") + + +def test_Modelica_Electrical_Analog_Examples_CauerLowPassAnalog(): + buildModelFMU("Modelica.Electrical.Analog.Examples.CauerLowPassAnalog") + + +def test_Modelica_Electrical_Digital_Examples_FlipFlop(): + buildModelFMU("Modelica.Electrical.Digital.Examples.FlipFlop") + + +def test_Modelica_Mechanics_Rotational_Examples_FirstGrounded(): + buildModelFMU("Modelica.Mechanics.Rotational.Examples.FirstGrounded") + + +def test_Modelica_Mechanics_Rotational_Examples_CoupledClutches(): + buildModelFMU("Modelica.Mechanics.Rotational.Examples.CoupledClutches") + + +def test_Modelica_Mechanics_MultiBody_Examples_Elementary_DoublePendulum(): + buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.DoublePendulum") + + +def test_Modelica_Mechanics_MultiBody_Examples_Elementary_FreeBody(): + buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.FreeBody") + + +def test_Modelica_Fluid_Examples_PumpingSystem(): + buildModelFMU("Modelica.Fluid.Examples.PumpingSystem") + + +def test_Modelica_Fluid_Examples_TraceSubstances_RoomCO2WithControls(): + buildModelFMU("Modelica.Fluid.Examples.TraceSubstances.RoomCO2WithControls") + + +def test_Modelica_Clocked_Examples_SimpleControlledDrive_ClockedWithDiscreteTextbookController(): + buildModelFMU("Modelica.Clocked.Examples.SimpleControlledDrive.ClockedWithDiscreteTextbookController") diff --git a/tests_v400/test_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py new file mode 100644 index 000000000..c55e95fc3 --- /dev/null +++ b/tests_v400/test_ModelicaSystem.py @@ -0,0 +1,411 @@ +import OMPython +import os +import pathlib +import pytest +import tempfile +import numpy as np + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +def test_ModelicaSystem_loop(model_firstorder): + def worker(): + filePath = model_firstorder.as_posix() + m = OMPython.ModelicaSystem(filePath, "M") + m.simulate() + m.convertMo2Fmu(fmuType="me") + for _ in range(10): + worker() + + +def test_setParameters(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") + + # method 1 + mod.setParameters(pvals={"e": 1.234}) + mod.setParameters(pvals={"g": 321.0}) + assert mod.getParameters("e") == ["1.234"] + assert mod.getParameters("g") == ["321.0"] + assert mod.getParameters() == { + "e": "1.234", + "g": "321.0", + } + with pytest.raises(KeyError): + mod.getParameters("thisParameterDoesNotExist") + + # method 2 + mod.setParameters(pvals={"e": 21.3, "g": 0.12}) + assert mod.getParameters() == { + "e": "21.3", + "g": "0.12", + } + assert mod.getParameters(["e", "g"]) == ["21.3", "0.12"] + assert mod.getParameters(["g", "e"]) == ["0.12", "21.3"] + with pytest.raises(KeyError): + mod.getParameters(["g", "thisParameterDoesNotExist"]) + + +def test_setSimulationOptions(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(fileName=model_path + "BouncingBall.mo", modelName="BouncingBall") + + # method 1 + mod.setSimulationOptions(simOptions={"stopTime": 1.234}) + mod.setSimulationOptions(simOptions={"tolerance": 1.1e-08}) + assert mod.getSimulationOptions("stopTime") == ["1.234"] + assert mod.getSimulationOptions("tolerance") == ["1.1e-08"] + assert mod.getSimulationOptions(["tolerance", "stopTime"]) == ["1.1e-08", "1.234"] + d = mod.getSimulationOptions() + assert isinstance(d, dict) + assert d["stopTime"] == "1.234" + assert d["tolerance"] == "1.1e-08" + with pytest.raises(KeyError): + mod.getSimulationOptions("thisOptionDoesNotExist") + + # method 2 + mod.setSimulationOptions(simOptions={"stopTime": 2.1, "tolerance": "1.2e-08"}) + d = mod.getSimulationOptions() + assert d["stopTime"] == "2.1" + assert d["tolerance"] == "1.2e-08" + + +def test_relative_path(model_firstorder): + cwd = pathlib.Path.cwd() + (fd, name) = tempfile.mkstemp(prefix='tmpOMPython.tests', dir=cwd, text=True) + try: + with os.fdopen(fd, 'w') as f: + f.write(model_firstorder.read_text()) + + model_file = pathlib.Path(name).relative_to(cwd) + model_relative = str(model_file) + assert "/" not in model_relative + + mod = OMPython.ModelicaSystem(fileName=model_relative, modelName="M") + assert float(mod.getParameters("a")[0]) == -1 + finally: + model_file.unlink() # clean up the temporary file + + +def test_customBuildDirectory(tmp_path, model_firstorder): + filePath = model_firstorder.as_posix() + tmpdir = tmp_path / "tmpdir1" + tmpdir.mkdir() + m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) + assert pathlib.Path(m.getWorkDirectory().resolve()) == tmpdir.resolve() + result_file = tmpdir / "a.mat" + assert not result_file.exists() + m.simulate(resultfile="a.mat") + assert result_file.is_file() + + +def test_getSolutions(model_firstorder): + filePath = model_firstorder.as_posix() + mod = OMPython.ModelicaSystem(filePath, "M") + x0 = 1 + a = -1 + tau = -1 / a + stopTime = 5*tau + mod.setSimulationOptions(simOptions={"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8}) + mod.simulate() + + x = mod.getSolutions("x") + t, x2 = mod.getSolutions(["time", "x"]) + assert (x2 == x).all() + sol_names = mod.getSolutions() + 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("thisVariableDoesNotExist") + assert np.isclose(t[0], 0), "time does not start at 0" + assert np.isclose(t[-1], stopTime), "time does not end at stopTime" + x_analytical = x0 * np.exp(a*t) + assert np.isclose(x, x_analytical, rtol=1e-4).all() + + +def test_getters(tmp_path): + model_file = tmp_path / "M_getters.mo" + model_file.write_text(""" +model M_getters +Real x(start = 1, fixed = true); +output Real y "the derivative"; +parameter Real a = -0.5; +parameter Real b = 0.1; +equation +der(x) = x*a + b; +y = der(x); +end M_getters; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_getters") + + q = mod.getQuantities() + assert isinstance(q, list) + assert sorted(q, key=lambda d: d["name"]) == sorted([ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'false', + 'description': None, + 'max': None, + 'min': None, + 'name': 'der(x)', + 'start': None, + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'a', + 'start': '-0.5', + 'unit': None, + 'variability': 'parameter', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'b', + 'start': '0.1', + 'unit': None, + 'variability': 'parameter', + } + ], key=lambda d: d["name"]) + + assert mod.getQuantities("y") == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + } + ] + + assert mod.getQuantities(["y", "x"]) == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + ] + + with pytest.raises(KeyError): + mod.getQuantities("thisQuantityDoesNotExist") + + assert mod.getInputs() == {} + with pytest.raises(KeyError): + mod.getInputs("thisInputDoesNotExist") + # getOutputs before simulate() + assert mod.getOutputs() == {'y': '-0.4'} + assert mod.getOutputs("y") == ["-0.4"] + assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous before simulate(): + assert mod.getContinuous() == { + 'x': '1.0', + 'der(x)': None, + 'y': '-0.4' + } + assert mod.getContinuous("y") == ['-0.4'] + assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + stopTime = 1.0 + a = -0.5 + b = 0.1 + x0 = 1.0 + x_analytical = -b/a + (x0 + b/a) * np.exp(a * stopTime) + dx_analytical = (x0 + b/a) * a * np.exp(a * stopTime) + mod.setSimulationOptions(simOptions={"stopTime": stopTime}) + mod.simulate() + + # getOutputs after simulate() + d = mod.getOutputs() + assert d.keys() == {"y"} + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getOutputs("y") == [d["y"]] + assert mod.getOutputs(["y", "y"]) == [d["y"], d["y"]] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous after simulate() should return values at end of simulation: + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + with pytest.raises(KeyError): + mod.getContinuous(["x", "a", "y"]) # a is a parameter + d = mod.getContinuous() + assert d.keys() == {"x", "der(x)", "y"} + assert np.isclose(d["x"], x_analytical, 1e-4) + assert np.isclose(d["der(x)"], dx_analytical, 1e-4) + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getContinuous("x") == [d["x"]] + assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] + + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + with pytest.raises(OMPython.ModelicaSystemError): + mod.setSimulationOptions(simOptions={"thisOptionDoesNotExist": 3}) + + +def test_simulate_inputs(tmp_path): + model_file = tmp_path / "M_input.mo" + model_file.write_text(""" +model M_input +Real x(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y; +equation +der(x) = u1 + u2; +y = x; +end M_input; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_input") + + mod.setSimulationOptions(simOptions={"stopTime": 1.0}) + + # integrate zero (no setInputs call) - it should default to None -> 0 + assert mod.getInputs() == { + "u1": None, + "u2": None, + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 0.0) + + # integrate a constant + mod.setInputs(name={"u1": 2.5}) + assert mod.getInputs() == { + "u1": [ + (0.0, 2.5), + (1.0, 2.5), + ], + # u2 is set due to the call to simulate() above + "u2": [ + (0.0, 0.0), + (1.0, 0.0), + ], + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 2.5) + + # now let's integrate the sum of two ramps + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 2), (1.0, 0)]}) + assert mod.getInputs("u1") == [[ + (0.0, 0.0), + (0.5, 2.0), + (1.0, 0.0), + ]] + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) + + # let's try some edge cases + # unmatched startTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(-0.5, 0.0), (1.0, 1)]}) + mod.simulate() + # unmatched stopTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 1)]}) + mod.simulate() + + # Let's use both inputs, but each one with different number of + # samples. This has an effect when generating the csv file. + mod.setInputs(name={"u1": [(0.0, 0), (1.0, 1)], + "u2": [(0.0, 0), (0.25, 0.5), (0.5, 1.0), (1.0, 0)]}) + csv_file = mod._createCSVData() + assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end +0.0,0.0,0.0,0 +0.25,0.25,0.5,0 +0.5,0.5,1.0,0 +1.0,1.0,0.0,0 +""" + + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py new file mode 100644 index 000000000..3544a1bd2 --- /dev/null +++ b/tests_v400/test_ModelicaSystemCmd.py @@ -0,0 +1,51 @@ +import OMPython +import pytest + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +@pytest.fixture +def mscmd_firstorder(model_firstorder): + mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") + mscmd = OMPython.ModelicaSystemCmd(runpath=mod.getWorkDirectory(), modelname=mod._model_name) + return mscmd + + +def test_simflags(mscmd_firstorder): + mscmd = mscmd_firstorder + + mscmd.args_set({ + "noEventEmit": None, + "override": {'b': 2} + }) + with pytest.deprecated_call(): + mscmd.args_set(args=mscmd.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,b=2,x=3', + ] + + mscmd.args_set({ + "override": {'b': None}, + }) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,x=3', + ] diff --git a/tests_v400/test_OMParser.py b/tests_v400/test_OMParser.py new file mode 100644 index 000000000..875604e52 --- /dev/null +++ b/tests_v400/test_OMParser.py @@ -0,0 +1,43 @@ +from OMPython import OMParser + +typeCheck = OMParser.typeCheck + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('TRUE') is True + assert typeCheck('True') is True + assert typeCheck('true') is True + assert typeCheck('FALSE') is False + assert typeCheck('False') is False + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +# def test_dict(): +# assert type(typeCheck('{"a": "b"}')) == dict + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_str(): + pass + + +def test_UnStringable(): + pass diff --git a/tests_v400/test_OMSessionCmd.py b/tests_v400/test_OMSessionCmd.py new file mode 100644 index 000000000..1588fac8d --- /dev/null +++ b/tests_v400/test_OMSessionCmd.py @@ -0,0 +1,17 @@ +import OMPython + + +def test_isPackage(): + omczmq = OMPython.OMCSessionZMQ() + omccmd = OMPython.OMCSessionCmd(session=omczmq) + assert not omccmd.isPackage('Modelica') + + +def test_isPackage2(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + omccmd = OMPython.OMCSessionCmd(session=mod._getconn) + assert omccmd.isPackage('Modelica') + + +# TODO: add more checks ... diff --git a/tests_v400/test_ZMQ.py b/tests_v400/test_ZMQ.py new file mode 100644 index 000000000..30bf78e7b --- /dev/null +++ b/tests_v400/test_ZMQ.py @@ -0,0 +1,70 @@ +import OMPython +import pathlib +import os +import pytest + + +@pytest.fixture +def model_time_str(): + return """model M + Real r = time; +end M; +""" + + +@pytest.fixture +def om(tmp_path): + origDir = pathlib.Path.cwd() + os.chdir(tmp_path) + om = OMPython.OMCSessionZMQ() + os.chdir(origDir) + return om + + +def testHelloWorld(om): + assert om.sendExpression('"HelloWorld!"') == "HelloWorld!" + + +def test_Translate(om, model_time_str): + assert om.sendExpression(model_time_str) == ("M",) + assert om.sendExpression('translateModel(M)') is True + + +def test_Simulate(om, model_time_str): + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') + + +def test_execute(om): + with pytest.deprecated_call(): + assert om.execute('"HelloWorld!"') == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=True) == 'HelloWorld!' + + +def test_omcprocessport_execute(om): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + # run 1 + om1 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om1.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + # run 2 + om2 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om2.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + del om1 + del om2 + + +def test_omcprocessport_simulate(om, model_time_str): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') != "" + del om diff --git a/tests_v400/test_docker.py b/tests_v400/test_docker.py new file mode 100644 index 000000000..8d68f11f0 --- /dev/null +++ b/tests_v400/test_docker.py @@ -0,0 +1,32 @@ +import sys +import pytest +import OMPython + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + + +@skip_on_windows +def test_docker(): + omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcpInner = OMPython.OMCProcessDockerContainer(dockerContainer=omcp.get_docker_container_id()) + omInner = OMPython.OMCSessionZMQ(omc_process=omcpInner) + assert omInner.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcp2 = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal", port=11111) + om2 = OMPython.OMCSessionZMQ(omc_process=omcp2) + assert om2.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + del omcp2 + del om2 + + del omcpInner + del omInner + + del omcp + del om diff --git a/tests_v400/test_linearization.py b/tests_v400/test_linearization.py new file mode 100644 index 000000000..bccbc40b5 --- /dev/null +++ b/tests_v400/test_linearization.py @@ -0,0 +1,102 @@ +import OMPython +import pytest +import numpy as np + + +@pytest.fixture +def model_linearTest(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text(""" +model linearTest + Real x1(start=1); + Real x2(start=-2); + Real x3(start=3); + Real x4(start=-5); + parameter Real a=3,b=2,c=5,d=7,e=1,f=4; +equation + a*x1 = b*x2 -der(x1); + der(x2) + c*x3 + d*x1 = x4; + f*x4 - e*x3 - der(x3) = x1; + der(x4) = x1 + x2 + der(x3) + x4; +end linearTest; +""") + return mod + + +def test_example(model_linearTest): + mod = OMPython.ModelicaSystem(model_linearTest, "linearTest") + [A, B, C, D] = mod.linearize() + expected_matrixA = [[-3, 2, 0, 0], [-7, 0, -5, 1], [-1, 0, -1, 4], [0, 1, -1, 5]] + assert A == expected_matrixA, f"Matrix does not match the expected value. Got: {A}, Expected: {expected_matrixA}" + assert B == [], f"Matrix does not match the expected value. Got: {B}, Expected: {[]}" + assert C == [], f"Matrix does not match the expected value. Got: {C}, Expected: {[]}" + assert D == [], f"Matrix does not match the expected value. Got: {D}, Expected: {[]}" + assert mod.getLinearInputs() == [] + assert mod.getLinearOutputs() == [] + assert mod.getLinearStates() == ["x1", "x2", "x3", "x4"] + + +def test_getters(tmp_path): + model_file = tmp_path / "pendulum.mo" + model_file.write_text(""" +model Pendulum +Real phi(start=Modelica.Constants.pi, fixed=true); +Real omega(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y1; +output Real y2; +parameter Real l = 1.2; +parameter Real g = 9.81; +equation +der(phi) = omega + u2; +der(omega) = -g/l * sin(phi); +y1 = y2 + 0.5*omega; +y2 = phi + u1; +end Pendulum; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="Pendulum", lmodel=["Modelica"]) + + d = mod.getLinearizationOptions() + assert isinstance(d, dict) + assert "startTime" in d + assert "stopTime" in d + assert mod.getLinearizationOptions(["stopTime", "startTime"]) == [d["stopTime"], d["startTime"]] + mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) + assert mod.getLinearizationOptions("stopTime") == ["0.02"] + + mod.setInputs(name={"u1": 10, "u2": 0}) + [A, B, C, D] = mod.linearize() + param_g = float(mod.getParameters("g")[0]) + param_l = float(mod.getParameters("l")[0]) + assert mod.getLinearInputs() == ["u1", "u2"] + assert mod.getLinearStates() == ["omega", "phi"] + assert mod.getLinearOutputs() == ["y1", "y2"] + assert np.isclose(A, [[0, param_g/param_l], [1, 0]]).all() + assert np.isclose(B, [[0, 0], [0, 1]]).all() + assert np.isclose(C, [[0.5, 1], [0, 1]]).all() + assert np.isclose(D, [[1, 0], [1, 0]]).all() + + # test LinearizationResult + result = mod.linearize() + assert result[0] == A + assert result[1] == B + assert result[2] == C + assert result[3] == D + with pytest.raises(KeyError): + result[4] + + A2, B2, C2, D2 = result + assert A2 == A + assert B2 == B + assert C2 == C + assert D2 == D + + assert result.n == 2 + assert result.m == 2 + assert result.p == 2 + assert np.isclose(result.x0, [0, np.pi]).all() + assert np.isclose(result.u0, [10, 0]).all() + assert result.stateVars == ["omega", "phi"] + assert result.inputVars == ["u1", "u2"] + assert result.outputVars == ["y1", "y2"] diff --git a/tests_v400/test_optimization.py b/tests_v400/test_optimization.py new file mode 100644 index 000000000..b41643971 --- /dev/null +++ b/tests_v400/test_optimization.py @@ -0,0 +1,67 @@ +import OMPython +import numpy as np + + +def test_optimization_example(tmp_path): + model_file = tmp_path / "BangBang2021.mo" + model_file.write_text(""" +model BangBang2021 "Model to verify that optimization gives bang-bang optimal control" +parameter Real m = 1; +parameter Real p = 1 "needed for final constraints"; + +Real a; +Real v(start = 0, fixed = true); +Real pos(start = 0, fixed = true); +Real pow(min = -30, max = 30) = f * v annotation(isConstraint = true); + +input Real f(min = -10, max = 10); + +Real costPos(nominal = 1) = -pos "minimize -pos(tf)" annotation(isMayer=true); + +Real conSpeed(min = 0, max = 0) = p * v " 0<= p*v(tf) <=0" annotation(isFinalConstraint = true); + +equation + +der(pos) = v; +der(v) = a; +f = m * a; + +annotation(experiment(StartTime = 0, StopTime = 1, Tolerance = 1e-07, Interval = 0.01), +__OpenModelica_simulationFlags(s="optimization", optimizerNP="1"), +__OpenModelica_commandLineOptions="+g=Optimica"); + +end BangBang2021; +""") + + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="BangBang2021") + + mod.setOptimizationOptions(optimizationOptions={"numberOfIntervals": 16, + "stopTime": 1, + "stepSize": 0.001, + "tolerance": 1e-8}) + + # test the getter + assert mod.getOptimizationOptions()["stopTime"] == "1" + assert mod.getOptimizationOptions("stopTime") == ["1"] + assert mod.getOptimizationOptions(["tolerance", "stopTime"]) == ["1e-08", "1"] + + r = mod.optimize() + # it is necessary to specify resultfile, otherwise it wouldn't find it. + time, f, v = mod.getSolutions(["time", "f", "v"], resultfile=r["resultFile"]) + assert np.isclose(f[0], 10) + assert np.isclose(f[-1], -10) + + def f_fcn(time, v): + if time < 0.3: + return 10 + if time <= 0.5: + return 30 / v + if time < 0.7: + return -30 / v + return -10 + f_expected = [f_fcn(t, v) for t, v in zip(time, v)] + + # The sharp edge at time=0.5 probably won't match, let's leave that out. + matches = np.isclose(f, f_expected, 1e-3) + assert matches[:498].all() + assert matches[502:].all() diff --git a/tests_v400/test_typedParser.py b/tests_v400/test_typedParser.py new file mode 100644 index 000000000..60daedec5 --- /dev/null +++ b/tests_v400/test_typedParser.py @@ -0,0 +1,53 @@ +from OMPython import OMTypedParser + +typeCheck = OMTypedParser.parseString + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('true') is True + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_empty(): + assert typeCheck('') is None + + +def test_str(): + pass + + +def test_UnStringable(): + pass + + +def test_everything(): + # this test used to be in OMTypedParser.py's main() + testdata = """ + (1.0,{{1,true,3},{"4\\" +",5.9,6,NONE ( )},record ABC + startTime = ErrorLevel.warning, + 'stop*Time' = SOME(1.0) +end ABC;}) + """ + expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) + results = typeCheck(testdata) + assert results == expected