diff --git a/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml new file mode 100644 index 00000000..2407e060 --- /dev/null +++ b/.github/workflows/Test_v400.yml @@ -0,0 +1,73 @@ +name: Test-v4.0.0 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + # * latest available Python version + python-version: ['3.10', '3.14'] + # * Linux using ubuntu-latest + # * Windows using windows-latest + os: ['ubuntu-latest', 'windows-latest'] + # * OM stable - latest stable version + # * OM nightly - latest nightly build + 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.6 + 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 00000000..dbe635be --- /dev/null +++ b/.github/workflows/Test_v400_py310.yml @@ -0,0 +1,70 @@ +name: Test-v4.0.0-py310 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + python-version: ['3.10'] + # * Linux using ubuntu-latest + os: ['ubuntu-latest'] + # * OM stable - latest stable version + 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.6 + 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 00000000..cc662ff9 --- /dev/null +++ b/.github/workflows/Test_v4xx.yml @@ -0,0 +1,73 @@ +name: Test-v4.x.x + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + # * latest available Python version + python-version: ['3.10', '3.14'] + # * Linux using ubuntu-latest + # * Windows using windows-latest + os: ['ubuntu-latest', 'windows-latest'] + # * OM stable - latest stable version + # * OM nightly - latest nightly build + 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.6 + 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 484570b6..dd477775 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: 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 0eea5f15..03fd060b 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -383,22 +383,21 @@ def __init__( self._linearized_outputs: list[str] = [] # linearization output list self._linearized_states: list[str] = [] # linearization states list - self._session = session - - # get OpenModelica version - version_str = self._session.get_version() - self._version = self._parse_om_version(version=version_str) - self._simulated = False # True if the model has already been simulated self._result_file: Optional[OMPathABC] = None # for storing result file - self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) - self._model_name: Optional[str] = None self._libraries: Optional[list[str | tuple[str, str]]] = None self._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None + self._session = session + # get OpenModelica version + version_str = self._session.get_version() + self._version = self._parse_om_version(version=version_str) + + self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. @@ -468,6 +467,8 @@ def _xmlparse(self, xml_file: OMPathABC): 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"): @@ -1935,7 +1936,7 @@ def getSolutions( self, varList: Optional[str | list[str]] = None, resultfile: Optional[str | os.PathLike] = None, - ) -> tuple[str] | np.ndarray: + ) -> tuple[str, ...] | np.ndarray: """Extract simulation results from a result data file. Args: @@ -1984,7 +1985,8 @@ def getSolutions( result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') self.sendExpression(expr="closeSimulationResultFile()") if varList is None: - return result_vars + var_list = [str(var) for var in result_vars] + return tuple(var_list) if isinstance(varList, str): var_list_checked = [varList] @@ -2064,6 +2066,8 @@ def convertFmu2Mo( 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 @@ -2106,7 +2110,9 @@ def optimize(self) -> dict[str, Any]: """ properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) self.set_command_line_options("-g=Optimica") - return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + retval = self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + retval = cast(dict, retval) + return retval class ModelicaSystem(ModelicaSystemOMC): @@ -2114,6 +2120,138 @@ 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[OMCSessionLocal] = 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): """ @@ -2685,3 +2823,50 @@ def _prepare_structure_parameters( "pre-compiled binary of model.") return {} + + +class ModelicaSystemCmd(ModelExecutionCmd): + # TODO: docstring + + 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.""" + # TODO: move to the top + 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() diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 83b5bb32..731005f1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -2154,3 +2154,11 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC def sendExpression(self, expr: str, parsed: bool = True) -> Any: raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") + + +DummyPopen = DockerPopen +OMCProcessLocal = OMCSessionLocal +OMCProcessPort = OMCSessionPort +OMCProcessDocker = OMCSessionDocker +OMCProcessDockerContainer = OMCSessionDockerContainer +OMCProcessWSL = OMCSessionWSL diff --git a/OMPython/OMTypedParser.py b/OMPython/OMTypedParser.py index 06912221..9fe810e0 100644 --- a/OMPython/OMTypedParser.py +++ b/OMPython/OMTypedParser.py @@ -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 4dc2f974..c12f8524 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -23,6 +23,8 @@ ModelicaDoERunner, doe_get_solutions, + + ModelicaSystemCmd, ) from OMPython.OMCSession import ( OMPathABC, @@ -47,6 +49,11 @@ OMCSessionWSL, OMCSessionZMQ, + + OMCProcessLocal, + OMCProcessPort, + OMCProcessDocker, + OMCProcessDockerContainer, ) # global names imported if import 'from OMPython import *' is used @@ -58,6 +65,7 @@ 'ModelicaSystem', 'ModelicaSystemOMC', + 'ModelicaSystemCmd', 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaDoEOMC', @@ -87,4 +95,9 @@ 'OMCSessionWSL', 'OMCSessionZMQ', + + 'OMCProcessLocal', + 'OMCProcessPort', + 'OMCProcessDocker', + 'OMCProcessDockerContainer', ] diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py index 35541c99..ec9d734d 100644 --- a/tests/test_ModelicaSystemRunner.py +++ b/tests/test_ModelicaSystemRunner.py @@ -39,7 +39,7 @@ def param(): def test_runner(model_firstorder, param): # create a model using ModelicaSystem - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", diff --git a/tests_v400/__init__.py b/tests_v400/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests_v400/test_ArrayDimension.py b/tests_v400/test_ArrayDimension.py new file mode 100644 index 00000000..13b3c11b --- /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 00000000..f47b87ae --- /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_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py new file mode 100644 index 00000000..c55e95fc --- /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 00000000..3544a1bd --- /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 00000000..875604e5 --- /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 00000000..1588fac8 --- /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 00000000..30bf78e7 --- /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 00000000..8d68f11f --- /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 00000000..bccbc40b --- /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 00000000..b4164397 --- /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 00000000..60daedec --- /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