From 49ef9cc226bf887a6a9a902d256231708664fb80 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:41:37 +0100 Subject: [PATCH 01/20] [OMCSession] split file --- OMPython/ModelicaSystem.py | 12 +- OMPython/OMCSession.py | 1817 +-------------------------------- OMPython/__init__.py | 68 +- OMPython/om_session_abc.py | 323 ++++++ OMPython/om_session_omc.py | 1169 +++++++++++++++++++++ OMPython/om_session_runner.py | 383 +++++++ 6 files changed, 1935 insertions(+), 1837 deletions(-) create mode 100644 OMPython/om_session_abc.py create mode 100644 OMPython/om_session_omc.py create mode 100644 OMPython/om_session_runner.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 02b39abb..4e07b43e 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -26,13 +26,15 @@ ModelExecutionData, ModelExecutionException, ) -from OMPython.OMCSession import ( - OMSessionException, - OMCSessionLocal, - +from OMPython.om_session_abc import ( OMPathABC, - OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + OMCSessionLocal, +) +from OMPython.om_session_runner import ( OMSessionRunner, ) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index ac1e8d90..c5511923 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -5,66 +5,31 @@ from __future__ import annotations -import abc -import io -import json import logging -import os -import pathlib -import platform -import re -import shutil -import signal -import subprocess -import sys -import tempfile -import time -from typing import Any, Optional, Tuple, Type -import uuid +from typing import Any, Optional import warnings -import psutil import pyparsing -import zmq -# TODO: replace this with the new parser -from OMPython.OMTypedParser import om_parser_typed -from OMPython.OMParser import om_parser_basic +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + DockerPopen, + OMCSessionABC, + OMCSessionDocker, + OMCSessionDockerContainer, + OMCSessionLocal, + OMCSessionPort, + OMCSessionWSL, +) + # define logger using the current module name as ID logger = logging.getLogger(__name__) -OMSESSION_TIMEOUT: float = 300.0 - - -class DockerPopen: - """ - Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). - """ - - def __init__(self, pid): - self.pid = pid - self.process = psutil.Process(pid) - self.returncode = 0 - - def poll(self): - return None if self.process.is_running() else True - - def kill(self): - return os.kill(pid=self.pid, signal=signal.SIGKILL) - - def wait(self, timeout): - try: - self.process.wait(timeout=timeout) - except psutil.TimeoutExpired: - pass - - -class OMSessionException(Exception): - """ - Exception which is raised by any OMC* class. - """ - class OMCSessionException(OMSessionException): """ @@ -263,1171 +228,6 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if -# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. -# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible -if sys.version_info < (3, 12): - class OMPathCompatibility(pathlib.Path): - """ - Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly - ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. - """ - - # modified copy of pathlib.Path.__new__() definition - def __new__(cls, *args, **kwargs): - logger.warning("Python < 3.12 - using a version of class OMCPath " - "based on pathlib.Path for local usage only.") - - if cls is OMPathCompatibility: - cls = OMPathCompatibilityWindows if os.name == 'nt' else OMPathCompatibilityPosix - self = cls._from_parts(args) - if not self._flavour.is_supported: - raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") - return self - - def size(self) -> int: - """ - Needed compatibility function to have the same interface as OMCPathReal - """ - return self.stat().st_size - - class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) - """ - - class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) - """ - - OMPathABC = OMPathCompatibility - OMCPath = OMPathCompatibility - OMPathRunnerABC = OMPathCompatibility - OMPathRunnerLocal = OMPathCompatibility - OMPathRunnerBash = OMPathCompatibility - -else: - class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): - """ - 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. - - 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. - """ - - def __init__(self, *path, session: OMSessionABC) -> None: - super().__init__(*path) - self._session = session - - def get_session(self) -> OMSessionABC: - """ - Get session definition used for this instance of OMPath. - """ - return self._session - - def with_segments(self, *pathsegments) -> OMPathABC: - """ - Create a new OMCPath object with the given path segments. - - The original definition of Path is overridden to ensure the session data is set. - """ - return type(self)(*pathsegments, session=self._session) - - @abc.abstractmethod - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - - @abc.abstractmethod - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - - @abc.abstractmethod - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - - @abc.abstractmethod - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - - @abc.abstractmethod - def write_text(self, data: str) -> int: - """ - Write text data to the file represented by this path. - """ - - @abc.abstractmethod - 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. - """ - - @abc.abstractmethod - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - - @abc.abstractmethod - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - - @abc.abstractmethod - def resolve(self, strict: bool = False) -> 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. - """ - - class _OMCPath(OMPathABC): - """ - Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an - OMCSession* classes. - """ - - def is_file(self) -> bool: - """ - Check if the path is a regular file. - """ - retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") - return retval - - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") - return retval - - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. - """ - if self._session.model_execution_windows and self._session.model_execution_local: - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return pathlib.PurePosixPath(self.as_posix()).is_absolute() - - def read_text(self) -> str: - """ - Read the content of the file represented by this path as text. - """ - retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') - if not isinstance(retval, str): - raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") - return retval - - 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_omc = self._session.escape_str(data) - self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') - - return len(data) - - 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_dir() and not exist_ok: - raise FileExistsError(f"Directory {self.as_posix()} already exists!") - - if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): - raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") - - def cwd(self) -> OMPathABC: - """ - Returns the current working directory as an OMPathABC object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return type(self)(cwd_str, session=self._session) - - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') - if not res and not missing_ok: - raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") - - def resolve(self, strict: bool = False) -> 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 OMSessionException(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 OMSessionException(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 OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") - - return omcpath_resolved - - def _omc_resolve(self, pathstr: str) -> str: - """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. - """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') - - try: - retval = self.get_session().sendExpression(expr=expr, parsed=False) - if not isinstance(retval, str): - raise OMSessionException(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 OMSessionException as ex: - raise OMSessionException(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 OMSessionException(f"Path {self.as_posix()} is not a file!") - - res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) - - raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") - - 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 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 based on pathlib.Path. - """ - if not self.is_file(): - raise OMSessionException(f"Path {self.as_posix()} is not a file!") - - path = self._path() - return path.stat().st_size - - 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 as exc: - raise IOError(f"Error writing data to file {self.as_posix()}!") from exc - - 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 OMSessionException(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 based on pathlib.Path. - """ - if not self.is_file(): - raise OMSessionException(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 file size ({self.as_posix()}): {stdout}") from exc - else: - raise OSError(f"Cannot get size for file {self.as_posix()}") - - OMCPath = _OMCPath - OMPathRunnerLocal = _OMPathRunnerLocal - OMPathRunnerBash = _OMPathRunnerBash - - -class PostInitCaller(type): - """ - Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where - executed. The workflow would read as follows: - - On creating a class with the following inheritance Class2 => Class1 => Class0, where each class calls the __init__() - functions of its parent, i.e. super().__init__(), as well as __post_init__() the call schema would be: - - myclass = Class2() - Class2.__init__() - Class1.__init__() - Class0.__init__() - Class2.__post_init__() <= this is done due to the metaclass - Class1.__post_init__() - Class0.__post_init__() - - References: - * https://stackoverflow.com/questions/100003/what-are-metaclasses-in-python - * https://stackoverflow.com/questions/795190/how-to-perform-common-post-initialization-tasks-in-inherited-classes - """ - - def __call__(cls, *args, **kwargs): - obj = type.__call__(cls, *args, **kwargs) - obj.__post_init__() - return obj - - -class OMSessionMeta(abc.ABCMeta, PostInitCaller): - """ - Helper class to get a combined metaclass of ABCMeta and PostInitCaller. - - References: - * https://stackoverflow.com/questions/11276037/resolving-metaclass-conflicts - """ - - -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: Optional[float] = None, - **kwargs, - ) -> None: - """ - Initialisation for OMSessionBase - """ - - # some helper data - self.model_execution_windows = platform.system() == "Windows" - self.model_execution_local = False - - # store variables - self._timeout = OMSESSION_TIMEOUT - self.set_timeout(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 set_timeout(self, timeout: Optional[float] = None) -> float: - """ - Set the timeout to be used for OMC communication (OMCSession). - - The defined value is set and the current value is returned. If None is provided as argument, nothing is changed. - """ - retval = self._timeout - if timeout is not None: - if timeout <= 0.0: - raise OMSessionException(f"Invalid timeout value: {timeout}s!") - logger.info(f"Update timeout for {self.__class__.__name__}: {retval}s => {timeout}s") - self._timeout = timeout - return retval - - def get_cmd_prefix(self) -> list[str]: - """ - Get session definition used for this instance of OMPath. - """ - return self._cmd_prefix.copy() - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return value.replace("\\", "\\\\").replace('"', '\\"') - - @abc.abstractmethod - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix. - """ - - @abc.abstractmethod - def get_version(self) -> str: - """ - Get the OM version. - """ - - @abc.abstractmethod - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. - """ - - @abc.abstractmethod - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMPathABC object based on the given path segments and the current class. - """ - - @abc.abstractmethod - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory based on the specific definition for this session. - """ - - @staticmethod - def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: - names = [str(uuid.uuid4()) for _ in range(100)] - - tempdir: Optional[OMPathABC] = None - for name in names: - # create a unique temporary directory name - tempdir = tempdir_base / name - - if tempdir.exists(): - continue - - tempdir.mkdir(parents=True, exist_ok=False) - break - - if tempdir is None or not tempdir.is_dir(): - raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") - - return tempdir - - @abc.abstractmethod - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Function needed to send expressions to the OMC server via ZMQ. - """ - - -class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): - """ - Base class for an OMC session started via ZMQ. This class contains common functionality for all variants of an - OMC session definition. - - The main method is sendExpression() which is used to send commands to the OMC process. - - The following variants are defined: - - * OMCSessionLocal - - * OMCSessionPort - - * OMCSessionDocker - - * OMCSessionDockerContainer - - * OMCSessionWSL - """ - - def __init__( - self, - timeout: Optional[float] = None, - **kwargs, - ) -> None: - """ - Initialisation for OMCSession - """ - super().__init__(timeout=timeout) - - # some helper data - self.model_execution_windows = platform.system() == "Windows" - self.model_execution_local = False - - # generate a random string for this instance of OMC - self._random_string = uuid.uuid4().hex - # get a temporary directory - self._temp_dir = pathlib.Path(tempfile.gettempdir()) - - # omc process - self._omc_process: Optional[subprocess.Popen] = None - # omc ZMQ port to use - self._omc_port: Optional[str] = None - # omc port and log file - self._omc_filebase = f"openmodelica.{self._random_string}" - # ZMQ socket to communicate with OMC - self._omc_zmq: Optional[zmq.Socket[bytes]] = None - - # setup log file - this file must be closed in the destructor - self._omc_logfile = self._temp_dir / (self._omc_filebase + ".log") - self._omc_loghandle: Optional[io.TextIOWrapper] = None - try: - self._omc_loghandle = open(file=self._omc_logfile, mode="w+", encoding="utf-8") - except OSError as ex: - raise OMSessionException(f"Cannot open log file {self._omc_logfile}.") from ex - - # variables to store compiled re expressions use in self.sendExpression() - self._re_log_entries: Optional[re.Pattern[str]] = None - self._re_log_raw: Optional[re.Pattern[str]] = None - - self._re_portfile_path = re.compile(pattern=r'\nDumped server port in file: (.*?)($|\n)', - flags=re.MULTILINE | re.DOTALL) - - def __post_init__(self) -> None: - """ - Create the connection to the OMC server using ZeroMQ. - """ - port = self.get_port() - if not isinstance(port, str): - raise OMSessionException(f"Invalid content for port: {port}") - - # Create the ZeroMQ socket and connect to OMC server - context = zmq.Context.instance() - omc = context.socket(zmq.REQ) - omc.setsockopt(zmq.LINGER, 0) # Dismisses pending messages if closed - omc.setsockopt(zmq.IMMEDIATE, True) # Queue messages only to completed connections - omc.connect(port) - - self._omc_zmq = omc - - def __del__(self): - if isinstance(self._omc_zmq, zmq.Socket): - try: - self.sendExpression(expr="quit()") - except OMSessionException as exc: - logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") - finally: - self._omc_zmq = None - - if self._omc_loghandle is not None: - try: - self._omc_loghandle.close() - except (OSError, IOError): - pass - finally: - self._omc_loghandle = None - - if isinstance(self._omc_process, subprocess.Popen): - try: - self._omc_process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - if self._omc_process: - logger.warning("OMC did not exit after being sent the 'quit()' command; " - "killing the process with pid=%s", self._omc_process.pid) - self._omc_process.kill() - self._omc_process.wait() - finally: - - self._omc_process = None - - def _timeout_loop( - self, - timeout: Optional[float] = None, - timestep: float = 0.1, - ): - """ - Helper (using yield) for while loops to check OMC startup / response. The loop is executed as long as True is - returned, i.e. the first False will stop the while loop. - """ - - if timeout is None: - timeout = self._timeout - if timeout <= 0: - raise OMSessionException(f"Invalid timeout: {timeout}") - - timer = 0.0 - yield True - while True: - timer += timestep - if timer > timeout: - break - time.sleep(timestep) - yield True - yield False - - @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('"', '\\"') - - def get_version(self) -> str: - """ - Get the OM version. - """ - return self.sendExpression("getVersion()", parsed=True) - - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. - """ - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - - return [] - - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMCPath object based on the given path segments and the current OMCSession* class. - """ - - # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement - if sys.version_info < (3, 12): - if isinstance(self, OMCSessionLocal): - # noinspection PyArgumentList - return OMCPath(*path) - raise OMSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") - return OMCPath(*path, session=self) - - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all - filesystem related access. - """ - - if tempdir_base is None: - # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement - if sys.version_info < (3, 12): - tempdir_str = tempfile.gettempdir() - else: - tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") - tempdir_base = self.omcpath(tempdir_str) - - return self._tempdir(tempdir_base=tempdir_base) - - def execute(self, command: str): - warnings.warn( - message="This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", - category=DeprecationWarning, - stacklevel=2, - ) - - return self.sendExpression(command, parsed=False) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Send an expression to the OMC server and return the result. - - The complete error handling of the OMC result is done within this method using 'getMessagesStringInternal()'. - Caller should only check for OMSessionException. - """ - - if self._omc_zmq is None: - raise OMSessionException("No OMC running. Please create a new instance of OMCSession!") - - logger.debug("sendExpression(expr='%r', parsed=%r)", str(expr), parsed) - - loop = self._timeout_loop(timestep=0.05) - while next(loop): - try: - self._omc_zmq.send_string(str(expr), flags=zmq.NOBLOCK) - break - except zmq.error.Again: - pass - else: - # in the deletion process, the content is cleared. Thus, any access to a class attribute must be checked - try: - log_content = self.get_log() - except OMSessionException: - log_content = 'log not available' - - logger.error(f"OMC did not start. Log-file says:\n{log_content}") - raise OMSessionException(f"No connection with OMC (timeout={self._timeout:.2f}s).") - - if expr == "quit()": - self._omc_zmq.close() - self._omc_zmq = None - return None - - result = self._omc_zmq.recv_string() - - if result.startswith('Error occurred building AST'): - raise OMSessionException(f"OMC error: {result}") - - if expr == "getErrorString()": - # no error handling if 'getErrorString()' is called - if parsed: - logger.warning("Result of 'getErrorString()' cannot be parsed!") - return result - - if expr == "getMessagesStringInternal()": - # no error handling if 'getMessagesStringInternal()' is called - if parsed: - logger.warning("Result of 'getMessagesStringInternal()' cannot be parsed!") - return result - - # always check for error - self._omc_zmq.send_string('getMessagesStringInternal()', flags=zmq.NOBLOCK) - error_raw = self._omc_zmq.recv_string() - # run error handling only if there is something to check - msg_long_list = [] - has_error = False - if error_raw != "{}\n": - if not self._re_log_entries: - self._re_log_entries = re.compile(pattern=r'record OpenModelica\.Scripting\.ErrorMessage' - '(.*?)' - r'end OpenModelica\.Scripting\.ErrorMessage;', - flags=re.MULTILINE | re.DOTALL) - if not self._re_log_raw: - self._re_log_raw = re.compile( - pattern=r"\s*info = record OpenModelica\.Scripting\.SourceInfo\n" - r"\s*filename = \"(.*?)\",\n" - r"\s*readonly = (.*?),\n" - r"\s*lineStart = (\d+),\n" - r"\s*columnStart = (\d+),\n" - r"\s*lineEnd = (\d+),\n" - r"\s*columnEnd = (\d+)\n" - r"\s*end OpenModelica\.Scripting\.SourceInfo;,\n" - r"\s*message = \"(.*?)\",\n" # message - r"\s*kind = \.OpenModelica\.Scripting\.ErrorKind\.(.*?),\n" # kind - r"\s*level = \.OpenModelica\.Scripting\.ErrorLevel\.(.*?),\n" # level - r"\s*id = (\d+)", # id - flags=re.MULTILINE | re.DOTALL) - - # extract all ErrorMessage records - log_entries = self._re_log_entries.findall(string=error_raw) - for log_entry in reversed(log_entries): - log_raw = self._re_log_raw.findall(string=log_entry) - if len(log_raw) != 1 or len(log_raw[0]) != 10: - logger.warning("Invalid ErrorMessage record returned by 'getMessagesStringInternal()':" - f" {repr(log_entry)}!") - continue - - log_filename = log_raw[0][0] - log_readonly = log_raw[0][1] - log_lstart = log_raw[0][2] - log_cstart = log_raw[0][3] - log_lend = log_raw[0][4] - log_cend = log_raw[0][5] - log_message = log_raw[0][6].encode().decode('unicode_escape') - log_kind = log_raw[0][7] - log_level = log_raw[0][8] - log_id = log_raw[0][9] - - msg_short = (f"[OMC log for 'sendExpression(expr={expr}, parsed={parsed})']: " - f"[{log_kind}:{log_level}:{log_id}] {log_message}") - - # response according to the used log level - # see: https://build.openmodelica.org/Documentation/OpenModelica.Scripting.ErrorLevel.html - if log_level == 'error': - logger.error(msg_short) - has_error = True - elif log_level == 'warning': - logger.warning(msg_short) - elif log_level == 'notification': - logger.info(msg_short) - else: # internal - logger.debug(msg_short) - - # track all messages such that this list can be reported if an error occurred - msg_long = (f"[{log_kind}:{log_level}:{log_id}] " - f"[{log_filename}:{log_readonly}:{log_lstart}:{log_cstart}:{log_lend}:{log_cend}] " - f"{log_message}") - msg_long_list.append(msg_long) - if has_error: - msg_long_str = '\n'.join(f"{idx:02d}: {msg}" for idx, msg in enumerate(msg_long_list)) - raise OMSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" - f"{msg_long_str}") - - if not parsed: - return result - - try: - return om_parser_typed(result) - except pyparsing.ParseException as ex1: - logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex1.msg) - try: - return om_parser_basic(result) - except (TypeError, UnboundLocalError) as ex2: - raise OMSessionException("Cannot parse OMC result") from ex2 - - def get_port(self) -> Optional[str]: - """ - Get the port to connect to the OMC session. - """ - if not isinstance(self._omc_port, str): - raise OMSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") - return self._omc_port - - def get_log(self) -> str: - """ - Get the log file content of the OMC session. - """ - if self._omc_loghandle is None: - raise OMSessionException("Log file not available!") - - self._omc_loghandle.seek(0) - log = self._omc_loghandle.read() - - return log - - def _get_portfile_path(self) -> Optional[pathlib.Path]: - omc_log = self.get_log() - - portfile = self._re_portfile_path.findall(string=omc_log) - - portfile_path = None - if portfile: - portfile_path = pathlib.Path(portfile[-1][0]) - - return portfile_path - - -class OMCSessionPort(OMCSessionABC): - """ - OMCSession implementation which uses a port to connect to an already running OMC server. - """ - - def __init__( - self, - omc_port: str, - timeout: Optional[float] = None, - ) -> None: - super().__init__(timeout=timeout) - self._omc_port = omc_port - - -class OMCSessionLocal(OMCSessionABC): - """ - OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). - """ - - def __init__( - self, - timeout: Optional[float] = None, - omhome: Optional[str | os.PathLike] = None, - ) -> None: - - 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 - self._omc_process = self._omc_process_get() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - - @staticmethod - def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: - # use the provided path - if omhome is not None: - return pathlib.Path(omhome) - - # check the environment variable - omhome = os.environ.get('OPENMODELICAHOME') - if omhome is not None: - return pathlib.Path(omhome) - - # Get the path to the OMC executable, if not installed this will be None - path_to_omc = shutil.which("omc") - if path_to_omc is not None: - return pathlib.Path(path_to_omc).parents[1] - - raise OMSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") - - def _omc_process_get(self) -> subprocess.Popen: - my_env = os.environ.copy() - my_env["PATH"] = (self._omhome / "bin").as_posix() + os.pathsep + my_env["PATH"] - - omc_command = [ - (self._omhome / "bin" / "omc").as_posix(), - "--locale=C", - "--interactive=zmq", - f"-z={self._random_string}"] - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - return omc_process - - def _omc_port_get(self) -> str: - port = None - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None and omc_portfile_path.is_file(): - # Read the port file - with open(file=omc_portfile_path, mode='r', encoding="utf-8") as f_p: - port = f_p.readline() - break - if port is not None: - break - else: - logger.error(f"OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMSessionException(f"OMC Server did not start (timeout={self._timeout:.2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"Local OMC Server is up and running at ZMQ port {port} " - f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") - - return port - - class OMCSessionZMQ(OMSessionABC): """ This class is a compatibility layer for the new schema using OMCSession* classes. @@ -1501,591 +301,6 @@ def set_workdir(self, workdir: OMPathABC) -> None: return self.omc_process.set_workdir(workdir=workdir) -class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): - """ - Base class for OMCSession implementations which run the OMC server in a Docker container. - """ - - def __init__( - self, - timeout: Optional[float] = None, - docker: Optional[str] = None, - dockerContainer: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - super().__init__(timeout=timeout) - - if dockerExtraArgs is None: - dockerExtraArgs = [] - - 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] - - # 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 OMSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") - - self._cmd_prefix = self.model_execution_prefix() - - def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: - if sys.platform == 'win32': - raise NotImplementedError("Docker not supported on win32!") - - loop = self._timeout_loop(timestep=0.2) - while next(loop): - docker_top = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() - docker_process = None - for line in docker_top.split("\n"): - columns = line.split() - if self._random_string in line: - try: - docker_process = DockerPopen(int(columns[1])) - except psutil.NoSuchProcess as ex: - raise OMSessionException(f"Could not find PID {docker_top} - " - "is this a docker instance spawned without --pid=host?") from ex - if docker_process is not None: - break - else: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s).") - - 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: - """ - The uid to give to docker. - On Windows, volumes are mapped with all files are chmod ugo+rwx, - so uid does not matter as long as it is not the root user. - """ - # mypy complained about os.getuid() not being available on - # Windows, hence the type: ignore comment. - return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore - - def _omc_port_get( - self, - docker_cid: str, - ) -> str: - port = None - - if not isinstance(docker_cid, str): - raise OMSessionException(f"Invalid docker container ID: {docker_cid}") - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None: - try: - output = subprocess.check_output(args=["docker", - "exec", docker_cid, - "cat", omc_portfile_path.as_posix()], - stderr=subprocess.DEVNULL) - port = output.decode().strip() - except subprocess.CalledProcessError: - pass - if port is not None: - break - else: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"Docker based OMC Server is up and running at port {port}") - - return port - - def get_server_address(self) -> Optional[str]: - """ - Get the server address of the OMC server running in a Docker container. - """ - if self._docker_network == "separate" and isinstance(self._docker_container_id, str): - output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() - address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] - if not isinstance(address, str): - raise OMSessionException(f"Invalid docker server address: {address}!") - return address - - return None - - def get_docker_container_id(self) -> str: - """ - Get the Docker container ID of the Docker container with the OMC server. - """ - if not isinstance(self._docker_container_id, str): - raise OMSessionException(f"Invalid docker container ID: {self._docker_container_id}!") - - return self._docker_container_id - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - docker_cmd = [ - "docker", "exec", - "--user", str(self._getuid()), - ] - if isinstance(cwd, OMPathABC): - docker_cmd += ["--workdir", cwd.as_posix()] - docker_cmd += self._docker_extra_args - if isinstance(self._docker_container_id, str): - docker_cmd += [self._docker_container_id] - - return docker_cmd - - -class OMCSessionDocker(OMCSessionDockerABC): - """ - OMC process running in a Docker container. - """ - - def __init__( - self, - timeout: Optional[float] = None, - docker: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - - super().__init__( - timeout=timeout, - docker=docker, - dockerExtraArgs=dockerExtraArgs, - dockerOpenModelicaPath=dockerOpenModelicaPath, - dockerNetwork=dockerNetwork, - port=port, - ) - - def __del__(self) -> None: - - if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): - try: - self._docker_process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - if self._docker_process: - logger.warning("OMC did not exit after being sent the quit() command; " - "killing the process with pid=%s", self._docker_process.pid) - self._docker_process.kill() - self._docker_process.wait(timeout=2.0) - finally: - self._docker_process = None - - super().__del__() - - def _docker_omc_cmd( - self, - 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._omc_port: - raise OMSessionException("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 not isinstance(port, int): - raise OMSessionException("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"] - elif self._docker_network == "separate": - docker_network_str = [] - extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - else: - raise OMSessionException(f'dockerNetwork was set to {self._docker_network}, ' - 'but only \"host\" or \"separate\" is allowed') - - if isinstance(port, int): - extra_flags = extra_flags + [f"--interactivePort={port}"] - - omc_command = ([ - "docker", "run", - "--cidfile", docker_cid_file.as_posix(), - "--rm", - "--user", str(self._getuid()), - ] - + self._docker_extra_args - + docker_network_str - + [docker_image, self._docker_open_modelica_path.as_posix()] - + omc_path_and_args_list - + extra_flags) - - return omc_command - - 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 OMSessionException("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}"], - omc_port=omc_port, - ) - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - - if not isinstance(docker_cid_file, pathlib.Path): - raise OMSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") - - # the provided value for docker_cid is not used - docker_cid = None - loop = self._timeout_loop(timestep=0.1) - while next(loop): - try: - with open(file=docker_cid_file, mode="r", encoding="utf-8") as fh: - docker_cid = fh.read().strip() - except IOError: - pass - if docker_cid is not None: - break - - if docker_cid is None: - raise OMSessionException(f"Docker did not start (timeout={self._timeout:.2f}s might be too short " - "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: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMSessionException(f"Docker top did not contain omc process {self._random_string}.") - - return omc_process, docker_process, docker_cid - - -class OMCSessionDockerContainer(OMCSessionDockerABC): - """ - OMC process running in a Docker container (by container ID). - """ - - def __init__( - self, - timeout: Optional[float] = None, - dockerContainer: Optional[str] = None, - dockerExtraArgs: Optional[list] = None, - dockerOpenModelicaPath: str | os.PathLike = "omc", - dockerNetwork: Optional[str] = None, - port: Optional[int] = None, - ) -> None: - - super().__init__( - timeout=timeout, - dockerContainer=dockerContainer, - dockerExtraArgs=dockerExtraArgs, - dockerOpenModelicaPath=dockerOpenModelicaPath, - dockerNetwork=dockerNetwork, - port=port, - ) - - def __del__(self) -> None: - - super().__del__() - - # docker container ID was provided - do NOT kill the docker process! - self._docker_process = None - - 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. - """ - extra_flags: list[str] = [] - - if sys.platform == "win32": - extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not isinstance(omc_port, int): - raise OMSessionException("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(omc_port, int): - extra_flags = extra_flags + [f"--interactivePort={omc_port}"] - - omc_command = ([ - "docker", "exec", - "--user", str(self._getuid()), - ] - + self._docker_extra_args - + [docker_cid, self._docker_open_modelica_path.as_posix()] - + omc_path_and_args_list - + extra_flags) - - return omc_command - - 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 OMSessionException("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, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - - docker_process = None - if isinstance(docker_cid, str): - docker_process = self._docker_process_get(docker_cid=docker_cid) - - if docker_process is None: - raise OMSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {docker_cid}. Log-file says:\n{self.get_log()}") - - return omc_process, docker_process, docker_cid - - -class OMCSessionWSL(OMCSessionABC): - """ - OMC process running in Windows Subsystem for Linux (WSL). - """ - - def __init__( - self, - timeout: Optional[float] = None, - wsl_omc: str = 'omc', - wsl_distribution: Optional[str] = None, - wsl_user: Optional[str] = None, - ) -> None: - - super().__init__(timeout=timeout) - - # where to find OpenModelica - self._wsl_omc = wsl_omc - # store WSL distribution and user - self._wsl_distribution = wsl_distribution - self._wsl_user = wsl_user - # start up omc executable, which is waiting for the ZMQ connection - self._omc_process = self._omc_process_get() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - - self._cmd_prefix = self.model_execution_prefix() - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. - """ - # get wsl base command - wsl_cmd = ['wsl'] - if isinstance(self._wsl_distribution, str): - wsl_cmd += ['--distribution', self._wsl_distribution] - if isinstance(self._wsl_user, str): - wsl_cmd += ['--user', self._wsl_user] - if isinstance(cwd, OMPathABC): - wsl_cmd += ['--cd', cwd.as_posix()] - wsl_cmd += ['--'] - - return wsl_cmd - - def _omc_process_get(self) -> subprocess.Popen: - my_env = os.environ.copy() - - omc_command = self.model_execution_prefix() + [ - self._wsl_omc, - "--locale=C", - "--interactive=zmq", - f"-z={self._random_string}", - ] - - omc_process = subprocess.Popen(omc_command, - stdout=self._omc_loghandle, - stderr=self._omc_loghandle, - env=my_env) - return omc_process - - def _omc_port_get(self) -> str: - port = None - - # See if the omc server is running - loop = self._timeout_loop(timestep=0.1) - while next(loop): - try: - omc_portfile_path = self._get_portfile_path() - if omc_portfile_path is not None: - output = subprocess.check_output( - args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], - stderr=subprocess.DEVNULL, - ) - port = output.decode().strip() - except subprocess.CalledProcessError: - pass - if port is not None: - break - else: - logger.error(f"WSL based OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMSessionException(f"WSL based OMC Server did not start (timeout={self._timeout:2f}s, " - f"logfile={repr(self._omc_logfile)}).") - - logger.info(f"WSL based OMC Server is up and running at ZMQ port {port} " - f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") - - return port - - -class OMSessionRunnerABC(OMSessionABC, metaclass=abc.ABCMeta): - """ - Implementation based on OMSessionABC without any use of an OMC server. - """ - - def __init__( - self, - ompath_runner: Type[OMPathRunnerABC], - timeout: Optional[float] = None, - version: str = "1.27.0", - cmd_prefix: Optional[list[str]] = None, - model_execution_local: bool = True, - ) -> None: - super().__init__(timeout=timeout) - self._version = version - - if not issubclass(ompath_runner, OMPathRunnerABC): - raise OMSessionException(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 - - -class OMSessionRunner(OMSessionRunnerABC): - """ - Implementation based on OMSessionABC without any use of an OMC server. - """ - - def __init__( - self, - ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, - timeout: Optional[float] = None, - version: str = "1.27.0", - cmd_prefix: Optional[list[str]] = None, - model_execution_local: bool = True, - ) -> None: - super().__init__( - ompath_runner=ompath_runner, - timeout=timeout, - version=version, - cmd_prefix=cmd_prefix, - model_execution_local=model_execution_local, - ) - - def __post_init__(self) -> None: - """ - No connection to an OMC server is created by this class! - """ - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix. - """ - return 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: OMPathABC) -> 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) -> OMPathABC: - """ - 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[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory without using OMC. - """ - if tempdir_base is None: - tempdir_str = tempfile.gettempdir() - tempdir_base = self.omcpath(tempdir_str) - - return self._tempdir(tempdir_base=tempdir_base) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - raise OMSessionException(f"{self.__class__.__name__} does not uses an OMC server!") - - DummyPopen = DockerPopen OMCProcessLocal = OMCSessionLocal OMCProcessPort = OMCSessionPort diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 3401585d..f541df25 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -16,6 +16,25 @@ ModelExecutionData, ModelExecutionException, ) +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + OMCPath, + OMCSessionABC, + OMCSessionDocker, + OMCSessionDockerContainer, + OMCSessionLocal, + OMCSessionPort, + OMCSessionWSL, +) +from OMPython.om_session_runner import ( + OMPathRunnerBash, + OMPathRunnerLocal, + OMSessionRunner, +) from OMPython.ModelicaSystem import ( LinearizationResult, @@ -32,25 +51,9 @@ ModelicaSystemCmd, ) from OMPython.OMCSession import ( - OMPathABC, - OMCPath, - - OMSessionABC, - OMSessionRunner, - - OMCSessionABC, OMCSessionCmd, - OMCSessionDocker, - OMCSessionDockerContainer, - OMCSessionException, - OMCSessionLocal, - OMCSessionPort, - - OMPathRunnerBash, - OMPathRunnerLocal, - - OMCSessionWSL, OMCSessionZMQ, + OMCSessionException, OMCProcessLocal, OMCProcessPort, @@ -66,6 +69,22 @@ 'ModelExecutionData', 'ModelExecutionException', + 'OMPathABC', + 'OMSessionABC', + 'OMSessionException', + + 'OMCPath', + 'OMCSessionABC', + 'OMCSessionDocker', + 'OMCSessionDockerContainer', + 'OMCSessionLocal', + 'OMCSessionPort', + 'OMCSessionWSL', + + 'OMPathRunnerBash', + 'OMPathRunnerLocal', + 'OMSessionRunner', + 'ModelicaSystem', 'ModelicaSystemOMC', 'ModelicaSystemCmd', @@ -76,26 +95,13 @@ 'ModelicaSystemRunner', 'ModelicaDoERunner', - 'OMPathABC', - 'OMCPath', - - 'OMSessionABC', - 'OMSessionRunner', - 'doe_get_solutions', 'OMCSessionABC', 'OMCSessionCmd', - 'OMCSessionDocker', - 'OMCSessionDockerContainer', - 'OMCSessionException', - 'OMCSessionPort', - 'OMCSessionLocal', - 'OMPathRunnerBash', - 'OMPathRunnerLocal', + 'OMCSessionException', - 'OMCSessionWSL', 'OMCSessionZMQ', 'OMCProcessLocal', diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py new file mode 100644 index 00000000..70e897d7 --- /dev/null +++ b/OMPython/om_session_abc.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +""" +Definition of a generic OM session. +""" + +from __future__ import annotations + +import abc +import logging +import os +import pathlib +import platform +import sys +from typing import Any, Optional +import uuid + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + +OMSESSION_TIMEOUT: float = 300.0 + + +class OMSessionException(Exception): + """ + Exception which is raised by any OMC* class. + """ + + +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + class _OMPathCompatibility(pathlib.Path): + """ + Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + """ + + # modified copy of pathlib.Path.__new__() definition + def __new__(cls, *args, **kwargs): + logger.warning("Python < 3.12 - using a version of class OMCPath " + "based on pathlib.Path for local usage only.") + + if cls is _OMPathCompatibility: + cls = _OMPathCompatibilityWindows if os.name == 'nt' else _OMPathCompatibilityPosix + self = cls._from_parts(args) + if not self._flavour.is_supported: + raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") + return self + + def size(self) -> int: + """ + Needed compatibility function to have the same interface as OMCPathReal + """ + return self.stat().st_size + + class _OMPathCompatibilityPosix(pathlib.PosixPath, _OMPathCompatibility): + """ + Compatibility class for OMCPath on Posix systems (Python < 3.12) + """ + + class _OMPathCompatibilityWindows(pathlib.WindowsPath, _OMPathCompatibility): + """ + Compatibility class for OMCPath on Windows systems (Python < 3.12) + """ + + OMPathABC = _OMPathCompatibility + +else: + class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): + """ + 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. + + 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. + """ + + def __init__(self, *path, session: OMSessionABC) -> None: + super().__init__(*path) + self._session = session + + def get_session(self) -> OMSessionABC: + """ + Get session definition used for this instance of OMPath. + """ + return self._session + + def with_segments(self, *pathsegments) -> OMPathABC: + """ + Create a new OMCPath object with the given path segments. + + The original definition of Path is overridden to ensure the session data is set. + """ + return type(self)(*pathsegments, session=self._session) + + @abc.abstractmethod + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + + @abc.abstractmethod + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + + @abc.abstractmethod + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + + @abc.abstractmethod + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ + + @abc.abstractmethod + 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. + """ + + @abc.abstractmethod + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + @abc.abstractmethod + def resolve(self, strict: bool = False) -> 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. + """ + + +class PostInitCaller(type): + """ + Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where + executed. The workflow would read as follows: + + On creating a class with the following inheritance Class2 => Class1 => Class0, where each class calls the __init__() + functions of its parent, i.e. super().__init__(), as well as __post_init__() the call schema would be: + + myclass = Class2() + Class2.__init__() + Class1.__init__() + Class0.__init__() + Class2.__post_init__() <= this is done due to the metaclass + Class1.__post_init__() + Class0.__post_init__() + + References: + * https://stackoverflow.com/questions/100003/what-are-metaclasses-in-python + * https://stackoverflow.com/questions/795190/how-to-perform-common-post-initialization-tasks-in-inherited-classes + """ + + def __call__(cls, *args, **kwargs): + obj = type.__call__(cls, *args, **kwargs) + obj.__post_init__() + return obj + + +class OMSessionMeta(abc.ABCMeta, PostInitCaller): + """ + Helper class to get a combined metaclass of ABCMeta and PostInitCaller. + + References: + * https://stackoverflow.com/questions/11276037/resolving-metaclass-conflicts + """ + + +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: Optional[float] = None, + **kwargs, + ) -> None: + """ + Initialisation for OMSessionBase + """ + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # store variables + self._timeout = OMSESSION_TIMEOUT + self.set_timeout(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 set_timeout(self, timeout: Optional[float] = None) -> float: + """ + Set the timeout to be used for OMC communication (OMCSession). + + The defined value is set and the current value is returned. If None is provided as argument, nothing is changed. + """ + retval = self._timeout + if timeout is not None: + if timeout <= 0.0: + raise OMSessionException(f"Invalid timeout value: {timeout}s!") + logger.info(f"Update timeout for {self.__class__.__name__}: {retval}s => {timeout}s") + self._timeout = timeout + return retval + + def get_cmd_prefix(self) -> list[str]: + """ + Get session definition used for this instance of OMPath. + """ + return self._cmd_prefix.copy() + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + @abc.abstractmethod + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + + @abc.abstractmethod + def get_version(self) -> str: + """ + Get the OM version. + """ + + @abc.abstractmethod + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + + @abc.abstractmethod + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMPathABC object based on the given path segments and the current class. + """ + + @abc.abstractmethod + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory based on the specific definition for this session. + """ + + @staticmethod + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathABC] = None + for name in names: + # create a unique temporary directory name + tempdir = tempdir_base / name + + if tempdir.exists(): + continue + + tempdir.mkdir(parents=True, exist_ok=False) + break + + if tempdir is None or not tempdir.is_dir(): + raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") + + return tempdir + + @abc.abstractmethod + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Function needed to send expressions to the OMC server via ZMQ. + """ diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py new file mode 100644 index 00000000..6626cd17 --- /dev/null +++ b/OMPython/om_session_omc.py @@ -0,0 +1,1169 @@ +# -*- coding: utf-8 -*- +""" +Definition of an OMC session using OMC server. +""" + +from __future__ import annotations + +import abc +import io +import json +import logging +import os +import pathlib +import platform +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from typing import Any, Optional, Tuple +import uuid +import warnings + +import psutil +import pyparsing +import zmq + +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) + +# TODO: replace this with the new parser +from OMPython.OMTypedParser import om_parser_typed +from OMPython.OMParser import om_parser_basic + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + OMCPath = OMPathABC + +else: + class _OMCPath(OMPathABC): + """ + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + return retval + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") + return retval + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. + """ + if self._session.model_execution_windows and self._session.model_execution_local: + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return pathlib.PurePosixPath(self.as_posix()).is_absolute() + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') + if not isinstance(retval, str): + raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") + return retval + + 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_omc = self._session.escape_str(data) + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') + + return len(data) + + 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_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") + + if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return type(self)(cwd_str, session=self._session) + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') + if not res and not missing_ok: + raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + + def resolve(self, strict: bool = False) -> 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 OMSessionException(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 OMSessionException(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 OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + + return omcpath_resolved + + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + + try: + retval = self.get_session().sendExpression(expr=expr, parsed=False) + if not isinstance(retval, str): + raise OMSessionException(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 OMSessionException as ex: + raise OMSessionException(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 OMSessionException(f"Path {self.as_posix()} is not a file!") + + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") + + OMCPath = _OMCPath + + +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. + + The main method is sendExpression() which is used to send commands to the OMC process. + + The following variants are defined: + + * OMCSessionLocal + + * OMCSessionPort + + * OMCSessionDocker + + * OMCSessionDockerContainer + + * OMCSessionWSL + """ + + def __init__( + self, + timeout: Optional[float] = None, + **kwargs, + ) -> None: + """ + Initialisation for OMCSession + """ + super().__init__(timeout=timeout) + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # generate a random string for this instance of OMC + self._random_string = uuid.uuid4().hex + # get a temporary directory + self._temp_dir = pathlib.Path(tempfile.gettempdir()) + + # omc process + self._omc_process: Optional[subprocess.Popen] = None + # omc ZMQ port to use + self._omc_port: Optional[str] = None + # omc port and log file + self._omc_filebase = f"openmodelica.{self._random_string}" + # ZMQ socket to communicate with OMC + self._omc_zmq: Optional[zmq.Socket[bytes]] = None + + # setup log file - this file must be closed in the destructor + self._omc_logfile = self._temp_dir / (self._omc_filebase + ".log") + self._omc_loghandle: Optional[io.TextIOWrapper] = None + try: + self._omc_loghandle = open(file=self._omc_logfile, mode="w+", encoding="utf-8") + except OSError as ex: + raise OMSessionException(f"Cannot open log file {self._omc_logfile}.") from ex + + # variables to store compiled re expressions use in self.sendExpression() + self._re_log_entries: Optional[re.Pattern[str]] = None + self._re_log_raw: Optional[re.Pattern[str]] = None + + self._re_portfile_path = re.compile(pattern=r'\nDumped server port in file: (.*?)($|\n)', + flags=re.MULTILINE | re.DOTALL) + + def __post_init__(self) -> None: + """ + Create the connection to the OMC server using ZeroMQ. + """ + port = self.get_port() + if not isinstance(port, str): + raise OMSessionException(f"Invalid content for port: {port}") + + # Create the ZeroMQ socket and connect to OMC server + context = zmq.Context.instance() + omc = context.socket(zmq.REQ) + omc.setsockopt(zmq.LINGER, 0) # Dismisses pending messages if closed + omc.setsockopt(zmq.IMMEDIATE, True) # Queue messages only to completed connections + omc.connect(port) + + self._omc_zmq = omc + + def __del__(self): + if isinstance(self._omc_zmq, zmq.Socket): + try: + self.sendExpression(expr="quit()") + except OMSessionException as exc: + logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") + finally: + self._omc_zmq = None + + if self._omc_loghandle is not None: + try: + self._omc_loghandle.close() + except (OSError, IOError): + pass + finally: + self._omc_loghandle = None + + if isinstance(self._omc_process, subprocess.Popen): + try: + self._omc_process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + if self._omc_process: + logger.warning("OMC did not exit after being sent the 'quit()' command; " + "killing the process with pid=%s", self._omc_process.pid) + self._omc_process.kill() + self._omc_process.wait() + finally: + + self._omc_process = None + + def _timeout_loop( + self, + timeout: Optional[float] = None, + timestep: float = 0.1, + ): + """ + Helper (using yield) for while loops to check OMC startup / response. The loop is executed as long as True is + returned, i.e. the first False will stop the while loop. + """ + + if timeout is None: + timeout = self._timeout + if timeout <= 0: + raise OMSessionException(f"Invalid timeout: {timeout}") + + timer = 0.0 + yield True + while True: + timer += timestep + if timer > timeout: + break + time.sleep(timestep) + yield True + yield False + + @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('"', '\\"') + + def get_version(self) -> str: + """ + Get the OM version. + """ + return self.sendExpression("getVersion()", parsed=True) + + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + exp = f'cd("{workdir.as_posix()}")' + self.sendExpression(exp) + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + + return [] + + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + + # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement + if sys.version_info < (3, 12): + if isinstance(self, OMCSessionLocal): + # noinspection PyArgumentList + return OMCPath(*path) + raise OMSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") + return OMCPath(*path, session=self) + + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all + filesystem related access. + """ + + if tempdir_base is None: + # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement + if sys.version_info < (3, 12): + tempdir_str = tempfile.gettempdir() + else: + tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") + tempdir_base = self.omcpath(tempdir_str) + + return self._tempdir(tempdir_base=tempdir_base) + + def execute(self, command: str): + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) + + return self.sendExpression(command, parsed=False) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Send an expression to the OMC server and return the result. + + The complete error handling of the OMC result is done within this method using 'getMessagesStringInternal()'. + Caller should only check for OMSessionException. + """ + + if self._omc_zmq is None: + raise OMSessionException("No OMC running. Please create a new instance of OMCSession!") + + logger.debug("sendExpression(expr='%r', parsed=%r)", str(expr), parsed) + + loop = self._timeout_loop(timestep=0.05) + while next(loop): + try: + self._omc_zmq.send_string(str(expr), flags=zmq.NOBLOCK) + break + except zmq.error.Again: + pass + else: + # in the deletion process, the content is cleared. Thus, any access to a class attribute must be checked + try: + log_content = self.get_log() + except OMSessionException: + log_content = 'log not available' + + logger.error(f"OMC did not start. Log-file says:\n{log_content}") + raise OMSessionException(f"No connection with OMC (timeout={self._timeout:.2f}s).") + + if expr == "quit()": + self._omc_zmq.close() + self._omc_zmq = None + return None + + result = self._omc_zmq.recv_string() + + if result.startswith('Error occurred building AST'): + raise OMSessionException(f"OMC error: {result}") + + if expr == "getErrorString()": + # no error handling if 'getErrorString()' is called + if parsed: + logger.warning("Result of 'getErrorString()' cannot be parsed!") + return result + + if expr == "getMessagesStringInternal()": + # no error handling if 'getMessagesStringInternal()' is called + if parsed: + logger.warning("Result of 'getMessagesStringInternal()' cannot be parsed!") + return result + + # always check for error + self._omc_zmq.send_string('getMessagesStringInternal()', flags=zmq.NOBLOCK) + error_raw = self._omc_zmq.recv_string() + # run error handling only if there is something to check + msg_long_list = [] + has_error = False + if error_raw != "{}\n": + if not self._re_log_entries: + self._re_log_entries = re.compile(pattern=r'record OpenModelica\.Scripting\.ErrorMessage' + '(.*?)' + r'end OpenModelica\.Scripting\.ErrorMessage;', + flags=re.MULTILINE | re.DOTALL) + if not self._re_log_raw: + self._re_log_raw = re.compile( + pattern=r"\s*info = record OpenModelica\.Scripting\.SourceInfo\n" + r"\s*filename = \"(.*?)\",\n" + r"\s*readonly = (.*?),\n" + r"\s*lineStart = (\d+),\n" + r"\s*columnStart = (\d+),\n" + r"\s*lineEnd = (\d+),\n" + r"\s*columnEnd = (\d+)\n" + r"\s*end OpenModelica\.Scripting\.SourceInfo;,\n" + r"\s*message = \"(.*?)\",\n" # message + r"\s*kind = \.OpenModelica\.Scripting\.ErrorKind\.(.*?),\n" # kind + r"\s*level = \.OpenModelica\.Scripting\.ErrorLevel\.(.*?),\n" # level + r"\s*id = (\d+)", # id + flags=re.MULTILINE | re.DOTALL) + + # extract all ErrorMessage records + log_entries = self._re_log_entries.findall(string=error_raw) + for log_entry in reversed(log_entries): + log_raw = self._re_log_raw.findall(string=log_entry) + if len(log_raw) != 1 or len(log_raw[0]) != 10: + logger.warning("Invalid ErrorMessage record returned by 'getMessagesStringInternal()':" + f" {repr(log_entry)}!") + continue + + log_filename = log_raw[0][0] + log_readonly = log_raw[0][1] + log_lstart = log_raw[0][2] + log_cstart = log_raw[0][3] + log_lend = log_raw[0][4] + log_cend = log_raw[0][5] + log_message = log_raw[0][6].encode().decode('unicode_escape') + log_kind = log_raw[0][7] + log_level = log_raw[0][8] + log_id = log_raw[0][9] + + msg_short = (f"[OMC log for 'sendExpression(expr={expr}, parsed={parsed})']: " + f"[{log_kind}:{log_level}:{log_id}] {log_message}") + + # response according to the used log level + # see: https://build.openmodelica.org/Documentation/OpenModelica.Scripting.ErrorLevel.html + if log_level == 'error': + logger.error(msg_short) + has_error = True + elif log_level == 'warning': + logger.warning(msg_short) + elif log_level == 'notification': + logger.info(msg_short) + else: # internal + logger.debug(msg_short) + + # track all messages such that this list can be reported if an error occurred + msg_long = (f"[{log_kind}:{log_level}:{log_id}] " + f"[{log_filename}:{log_readonly}:{log_lstart}:{log_cstart}:{log_lend}:{log_cend}] " + f"{log_message}") + msg_long_list.append(msg_long) + if has_error: + msg_long_str = '\n'.join(f"{idx:02d}: {msg}" for idx, msg in enumerate(msg_long_list)) + raise OMSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" + f"{msg_long_str}") + + if not parsed: + return result + + try: + return om_parser_typed(result) + except pyparsing.ParseException as ex1: + logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex1.msg) + try: + return om_parser_basic(result) + except (TypeError, UnboundLocalError) as ex2: + raise OMSessionException("Cannot parse OMC result") from ex2 + + def get_port(self) -> Optional[str]: + """ + Get the port to connect to the OMC session. + """ + if not isinstance(self._omc_port, str): + raise OMSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") + return self._omc_port + + def get_log(self) -> str: + """ + Get the log file content of the OMC session. + """ + if self._omc_loghandle is None: + raise OMSessionException("Log file not available!") + + self._omc_loghandle.seek(0) + log = self._omc_loghandle.read() + + return log + + def _get_portfile_path(self) -> Optional[pathlib.Path]: + omc_log = self.get_log() + + portfile = self._re_portfile_path.findall(string=omc_log) + + portfile_path = None + if portfile: + portfile_path = pathlib.Path(portfile[-1][0]) + + return portfile_path + + +class DockerPopen: + """ + Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). + """ + + def __init__(self, pid): + self.pid = pid + self.process = psutil.Process(pid) + self.returncode = 0 + + def poll(self): + return None if self.process.is_running() else True + + def kill(self): + return os.kill(pid=self.pid, signal=signal.SIGKILL) + + def wait(self, timeout): + try: + self.process.wait(timeout=timeout) + except psutil.TimeoutExpired: + pass + + +class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): + """ + Base class for OMCSession implementations which run the OMC server in a Docker container. + """ + + def __init__( + self, + timeout: Optional[float] = None, + docker: Optional[str] = None, + dockerContainer: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + super().__init__(timeout=timeout) + + if dockerExtraArgs is None: + dockerExtraArgs = [] + + 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] + + # 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 OMSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") + + self._cmd_prefix = self.model_execution_prefix() + + def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: + if sys.platform == 'win32': + raise NotImplementedError("Docker not supported on win32!") + + loop = self._timeout_loop(timestep=0.2) + while next(loop): + docker_top = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() + docker_process = None + for line in docker_top.split("\n"): + columns = line.split() + if self._random_string in line: + try: + docker_process = DockerPopen(int(columns[1])) + except psutil.NoSuchProcess as ex: + raise OMSessionException(f"Could not find PID {docker_top} - " + "is this a docker instance spawned without --pid=host?") from ex + if docker_process is not None: + break + else: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s).") + + 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: + """ + The uid to give to docker. + On Windows, volumes are mapped with all files are chmod ugo+rwx, + so uid does not matter as long as it is not the root user. + """ + # mypy complained about os.getuid() not being available on + # Windows, hence the type: ignore comment. + return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore + + def _omc_port_get( + self, + docker_cid: str, + ) -> str: + port = None + + if not isinstance(docker_cid, str): + raise OMSessionException(f"Invalid docker container ID: {docker_cid}") + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None: + try: + output = subprocess.check_output(args=["docker", + "exec", docker_cid, + "cat", omc_portfile_path.as_posix()], + stderr=subprocess.DEVNULL) + port = output.decode().strip() + except subprocess.CalledProcessError: + pass + if port is not None: + break + else: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout:.2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"Docker based OMC Server is up and running at port {port}") + + return port + + def get_server_address(self) -> Optional[str]: + """ + Get the server address of the OMC server running in a Docker container. + """ + if self._docker_network == "separate" and isinstance(self._docker_container_id, str): + output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() + address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] + if not isinstance(address, str): + raise OMSessionException(f"Invalid docker server address: {address}!") + return address + + return None + + def get_docker_container_id(self) -> str: + """ + Get the Docker container ID of the Docker container with the OMC server. + """ + if not isinstance(self._docker_container_id, str): + raise OMSessionException(f"Invalid docker container ID: {self._docker_container_id}!") + + return self._docker_container_id + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + docker_cmd = [ + "docker", "exec", + "--user", str(self._getuid()), + ] + if isinstance(cwd, OMPathABC): + docker_cmd += ["--workdir", cwd.as_posix()] + docker_cmd += self._docker_extra_args + if isinstance(self._docker_container_id, str): + docker_cmd += [self._docker_container_id] + + return docker_cmd + + +class OMCSessionDocker(OMCSessionDockerABC): + """ + OMC process running in a Docker container. + """ + + def __init__( + self, + timeout: Optional[float] = None, + docker: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + + super().__init__( + timeout=timeout, + docker=docker, + dockerExtraArgs=dockerExtraArgs, + dockerOpenModelicaPath=dockerOpenModelicaPath, + dockerNetwork=dockerNetwork, + port=port, + ) + + def __del__(self) -> None: + + if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): + try: + self._docker_process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + if self._docker_process: + logger.warning("OMC did not exit after being sent the quit() command; " + "killing the process with pid=%s", self._docker_process.pid) + self._docker_process.kill() + self._docker_process.wait(timeout=2.0) + finally: + self._docker_process = None + + super().__del__() + + def _docker_omc_cmd( + self, + 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._omc_port: + raise OMSessionException("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 not isinstance(port, int): + raise OMSessionException("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"] + elif self._docker_network == "separate": + docker_network_str = [] + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + else: + raise OMSessionException(f'dockerNetwork was set to {self._docker_network}, ' + 'but only \"host\" or \"separate\" is allowed') + + if isinstance(port, int): + extra_flags = extra_flags + [f"--interactivePort={port}"] + + omc_command = ([ + "docker", "run", + "--cidfile", docker_cid_file.as_posix(), + "--rm", + "--user", str(self._getuid()), + ] + + self._docker_extra_args + + docker_network_str + + [docker_image, self._docker_open_modelica_path.as_posix()] + + omc_path_and_args_list + + extra_flags) + + return omc_command + + 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 OMSessionException("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}"], + omc_port=omc_port, + ) + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + + if not isinstance(docker_cid_file, pathlib.Path): + raise OMSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") + + # the provided value for docker_cid is not used + docker_cid = None + loop = self._timeout_loop(timestep=0.1) + while next(loop): + try: + with open(file=docker_cid_file, mode="r", encoding="utf-8") as fh: + docker_cid = fh.read().strip() + except IOError: + pass + if docker_cid is not None: + break + + if docker_cid is None: + raise OMSessionException(f"Docker did not start (timeout={self._timeout:.2f}s might be too short " + "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: + logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker top did not contain omc process {self._random_string}.") + + return omc_process, docker_process, docker_cid + + +class OMCSessionDockerContainer(OMCSessionDockerABC): + """ + OMC process running in a Docker container (by container ID). + """ + + def __init__( + self, + timeout: Optional[float] = None, + dockerContainer: Optional[str] = None, + dockerExtraArgs: Optional[list] = None, + dockerOpenModelicaPath: str | os.PathLike = "omc", + dockerNetwork: Optional[str] = None, + port: Optional[int] = None, + ) -> None: + + super().__init__( + timeout=timeout, + dockerContainer=dockerContainer, + dockerExtraArgs=dockerExtraArgs, + dockerOpenModelicaPath=dockerOpenModelicaPath, + dockerNetwork=dockerNetwork, + port=port, + ) + + def __del__(self) -> None: + + super().__del__() + + # docker container ID was provided - do NOT kill the docker process! + self._docker_process = None + + 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. + """ + extra_flags: list[str] = [] + + if sys.platform == "win32": + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + if not isinstance(omc_port, int): + raise OMSessionException("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(omc_port, int): + extra_flags = extra_flags + [f"--interactivePort={omc_port}"] + + omc_command = ([ + "docker", "exec", + "--user", str(self._getuid()), + ] + + self._docker_extra_args + + [docker_cid, self._docker_open_modelica_path.as_posix()] + + omc_path_and_args_list + + extra_flags) + + return omc_command + + 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 OMSessionException("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, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + + docker_process = None + if isinstance(docker_cid, str): + docker_process = self._docker_process_get(docker_cid=docker_cid) + + if docker_process is None: + raise OMSessionException(f"Docker top did not contain omc process {self._random_string} " + f"/ {docker_cid}. Log-file says:\n{self.get_log()}") + + return omc_process, docker_process, docker_cid + + +class OMCSessionLocal(OMCSessionABC): + """ + OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). + """ + + def __init__( + self, + timeout: Optional[float] = None, + omhome: Optional[str | os.PathLike] = None, + ) -> None: + + 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 + self._omc_process = self._omc_process_get() + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get() + + @staticmethod + def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: + # use the provided path + if omhome is not None: + return pathlib.Path(omhome) + + # check the environment variable + omhome = os.environ.get('OPENMODELICAHOME') + if omhome is not None: + return pathlib.Path(omhome) + + # Get the path to the OMC executable, if not installed this will be None + path_to_omc = shutil.which("omc") + if path_to_omc is not None: + return pathlib.Path(path_to_omc).parents[1] + + raise OMSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") + + def _omc_process_get(self) -> subprocess.Popen: + my_env = os.environ.copy() + my_env["PATH"] = (self._omhome / "bin").as_posix() + os.pathsep + my_env["PATH"] + + omc_command = [ + (self._omhome / "bin" / "omc").as_posix(), + "--locale=C", + "--interactive=zmq", + f"-z={self._random_string}"] + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + return omc_process + + def _omc_port_get(self) -> str: + port = None + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None and omc_portfile_path.is_file(): + # Read the port file + with open(file=omc_portfile_path, mode='r', encoding="utf-8") as f_p: + port = f_p.readline() + break + if port is not None: + break + else: + logger.error(f"OMC server did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"OMC Server did not start (timeout={self._timeout:.2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"Local OMC Server is up and running at ZMQ port {port} " + f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") + + return port + + +class OMCSessionPort(OMCSessionABC): + """ + OMCSession implementation which uses a port to connect to an already running OMC server. + """ + + def __init__( + self, + omc_port: str, + timeout: Optional[float] = None, + ) -> None: + super().__init__(timeout=timeout) + self._omc_port = omc_port + + +class OMCSessionWSL(OMCSessionABC): + """ + OMC process running in Windows Subsystem for Linux (WSL). + """ + + def __init__( + self, + timeout: Optional[float] = None, + wsl_omc: str = 'omc', + wsl_distribution: Optional[str] = None, + wsl_user: Optional[str] = None, + ) -> None: + + super().__init__(timeout=timeout) + + # where to find OpenModelica + self._wsl_omc = wsl_omc + # store WSL distribution and user + self._wsl_distribution = wsl_distribution + self._wsl_user = wsl_user + # start up omc executable, which is waiting for the ZMQ connection + self._omc_process = self._omc_process_get() + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get() + + self._cmd_prefix = self.model_execution_prefix() + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + # get wsl base command + wsl_cmd = ['wsl'] + if isinstance(self._wsl_distribution, str): + wsl_cmd += ['--distribution', self._wsl_distribution] + if isinstance(self._wsl_user, str): + wsl_cmd += ['--user', self._wsl_user] + if isinstance(cwd, OMPathABC): + wsl_cmd += ['--cd', cwd.as_posix()] + wsl_cmd += ['--'] + + return wsl_cmd + + def _omc_process_get(self) -> subprocess.Popen: + my_env = os.environ.copy() + + omc_command = self.model_execution_prefix() + [ + self._wsl_omc, + "--locale=C", + "--interactive=zmq", + f"-z={self._random_string}", + ] + + omc_process = subprocess.Popen(omc_command, + stdout=self._omc_loghandle, + stderr=self._omc_loghandle, + env=my_env) + return omc_process + + def _omc_port_get(self) -> str: + port = None + + # See if the omc server is running + loop = self._timeout_loop(timestep=0.1) + while next(loop): + try: + omc_portfile_path = self._get_portfile_path() + if omc_portfile_path is not None: + output = subprocess.check_output( + args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], + stderr=subprocess.DEVNULL, + ) + port = output.decode().strip() + except subprocess.CalledProcessError: + pass + if port is not None: + break + else: + logger.error(f"WSL based OMC server did not start. Log-file says:\n{self.get_log()}") + raise OMSessionException(f"WSL based OMC Server did not start (timeout={self._timeout:2f}s, " + f"logfile={repr(self._omc_logfile)}).") + + logger.info(f"WSL based OMC Server is up and running at ZMQ port {port} " + f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") + + return port diff --git a/OMPython/om_session_runner.py b/OMPython/om_session_runner.py new file mode 100644 index 00000000..fc8e5ac8 --- /dev/null +++ b/OMPython/om_session_runner.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +""" +Definition of an OM session just executing a compiled model executable (Runner). +""" + +from __future__ import annotations + +import abc +import logging +import pathlib +import subprocess +import sys +import tempfile +from typing import Any, Optional, Type + +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + OMPathRunnerABC = OMPathABC + OMPathRunnerLocal = OMPathABC + OMPathRunnerBash = OMPathABC + +else: + 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 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 based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") + + path = self._path() + return path.stat().st_size + + 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 as exc: + raise IOError(f"Error writing data to file {self.as_posix()}!") from exc + + 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 OMSessionException(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 based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(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 file size ({self.as_posix()}): {stdout}") from exc + else: + raise OSError(f"Cannot get size for file {self.as_posix()}") + + OMPathRunnerLocal = _OMPathRunnerLocal + OMPathRunnerBash = _OMPathRunnerBash + + +class OMSessionRunnerABC(OMSessionABC, metaclass=abc.ABCMeta): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + ompath_runner: Type[OMPathRunnerABC], + timeout: Optional[float] = None, + version: str = "1.27.0", + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, + ) -> None: + super().__init__(timeout=timeout) + self._version = version + + if not issubclass(ompath_runner, OMPathRunnerABC): + raise OMSessionException(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 + + +class OMSessionRunner(OMSessionRunnerABC): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, + timeout: Optional[float] = None, + version: str = "1.27.0", + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, + ) -> None: + super().__init__( + ompath_runner=ompath_runner, + timeout=timeout, + version=version, + cmd_prefix=cmd_prefix, + model_execution_local=model_execution_local, + ) + + def __post_init__(self) -> None: + """ + No connection to an OMC server is created by this class! + """ + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + return 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: OMPathABC) -> 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) -> OMPathABC: + """ + 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[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory without using OMC. + """ + if tempdir_base is None: + tempdir_str = tempfile.gettempdir() + tempdir_base = self.omcpath(tempdir_str) + + return self._tempdir(tempdir_base=tempdir_base) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + raise OMSessionException(f"{self.__class__.__name__} does not uses an OMC server!") From fb2af97d718dfd1d26ef33ae9fabdefb29e74d39 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 13 Feb 2026 22:17:53 +0100 Subject: [PATCH 02/20] [ModelicaSystem] split file --- OMPython/ModelicaSystem.py | 2443 +--------------------------- OMPython/__init__.py | 51 +- OMPython/modelica_doe_abc.py | 350 ++++ OMPython/modelica_doe_omc.py | 176 ++ OMPython/modelica_doe_runner.py | 61 + OMPython/modelica_system_abc.py | 1241 ++++++++++++++ OMPython/modelica_system_omc.py | 648 ++++++++ OMPython/modelica_system_runner.py | 76 + 8 files changed, 2601 insertions(+), 2445 deletions(-) create mode 100644 OMPython/modelica_doe_abc.py create mode 100644 OMPython/modelica_doe_omc.py create mode 100644 OMPython/modelica_doe_runner.py create mode 100644 OMPython/modelica_system_abc.py create mode 100644 OMPython/modelica_system_omc.py create mode 100644 OMPython/modelica_system_runner.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 4e07b43e..17678bb0 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -3,1876 +3,33 @@ Definition of main class to run Modelica simulations - ModelicaSystem. """ -import abc -import ast -from dataclasses import dataclass -import itertools import logging -import numbers import os import pathlib -import queue -import re -import textwrap -import threading -from typing import Any, cast, Optional, Tuple -import warnings -import xml.etree.ElementTree as ET +import platform +from typing import Any, Optional import numpy as np from OMPython.model_execution import ( ModelExecutionCmd, - ModelExecutionData, ModelExecutionException, ) -from OMPython.om_session_abc import ( - OMPathABC, - OMSessionABC, - OMSessionException, -) -from OMPython.om_session_omc import ( - OMCSessionLocal, -) -from OMPython.om_session_runner import ( - OMSessionRunner, -) - -# define logger using the current module name as ID -logger = logging.getLogger(__name__) - - -class ModelicaSystemError(Exception): - """ - Exception used in ModelicaSystem classes. - """ - - -@dataclass -class LinearizationResult: - """Modelica model linearization results. - - Attributes: - n: number of states - m: number of inputs - p: number of outputs - A: state matrix (n x n) - B: input matrix (n x m) - C: output matrix (p x n) - D: feedthrough matrix (p x m) - x0: fixed point - u0: input corresponding to the fixed point - stateVars: names of state variables - inputVars: names of inputs - outputVars: names of outputs - """ - - n: int - m: int - p: int - - A: list - B: list - C: list - D: list - - x0: list[float] - u0: list[float] - - stateVars: list[str] - inputVars: list[str] - outputVars: list[str] - - def __iter__(self): - """Allow unpacking A, B, C, D = result.""" - yield self.A - yield self.B - yield self.C - yield self.D - - def __getitem__(self, index: int): - """Allow accessing A, B, C, D via result[0] through result[3]. - - This is needed for backwards compatibility, because - ModelicaSystem.linearize() used to return [A, B, C, D]. - """ - return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] - - -class ModelicaSystemABC(metaclass=abc.ABCMeta): - """ - Base class to simulate a Modelica models. - """ - - def __init__( - self, - session: OMSessionABC, - work_directory: Optional[str | os.PathLike] = None, - ) -> None: - """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). - - Args: - work_directory: Path to a directory to be used for temporary - files like the model executable. If left unspecified, a tmp - directory will be created. - session: definition of a (local) OMC session to be used. If - unspecified, a new local session will be created. - """ - - self._quantities: list[dict[str, Any]] = [] - self._params: dict[str, str] = {} # even numerical values are stored as str - self._inputs: dict[str, list[tuple[float, float]]] = {} - self._outputs: dict[str, np.float64] = {} # 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] = {} - self._linearization_options: dict[str, str] = { - 'startTime': str(0.0), - 'stopTime': str(1.0), - 'stepSize': str(0.002), - 'tolerance': str(1e-8), - } - self._optimization_options = self._linearization_options | { - 'numberOfIntervals': str(500), - } - self._linearized_inputs: list[str] = [] # linearization input list - self._linearized_outputs: list[str] = [] # linearization output list - self._linearized_states: list[str] = [] # linearization states list - - self._simulated = False # True if the model has already been simulated - self._result_file: Optional[OMPathABC] = None # for storing result file - - 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. - """ - return self._session - - def get_model_name(self) -> str: - """ - Return the defined model name. - """ - if not isinstance(self._model_name, str): - raise ModelicaSystemError("No model name defined!") - - return self._model_name - - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathABC: - """ - Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this - directory. If no directory is defined a unique temporary directory is created. - """ - if work_directory is not None: - workdir = self._session.omcpath(work_directory).absolute() - if not workdir.is_dir(): - raise IOError(f"Provided work directory does not exists: {work_directory}!") - else: - workdir = self._session.omcpath_tempdir().absolute() - if not workdir.is_dir(): - raise IOError(f"{workdir} could not be created") - - logger.info("Define work dir as %s", workdir) - self._session.set_workdir(workdir=workdir) - - # set the class variable _work_dir ... - self._work_dir = workdir - # ... and also return the defined path - return workdir - - def getWorkDirectory(self) -> OMPathABC: - """ - Return the defined working directory for this ModelicaSystem / OpenModelica session. - """ - return self._work_dir - - def check_model_executable(self): - """ - Check if the model executable is working - """ - # check if the executable exists ... - om_cmd = ModelExecutionCmd( - runpath=self.getWorkDirectory(), - cmd_local=self._session.model_execution_local, - cmd_windows=self._session.model_execution_windows, - cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), - timeout=self._session.set_timeout(), - model_name=self._model_name, - ) - # ... by running it - output help for command help - om_cmd.arg_set(key="help", val="help") - cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() - if returncode != 0: - raise ModelicaSystemError("Model executable not working!") - - def _xmlparse(self, xml_file: OMPathABC): - 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"): - self._simulate_options[key] = str(attr.get(key)) - - for sv in root.iter('ScalarVariable'): - translations = { - "alias": "alias", - "aliasvariable": "aliasVariable", - "causality": "causality", - "changeable": "isValueChangeable", - "description": "description", - "name": "name", - "variability": "variability", - } - - scalar: dict[str, Any] = {} - for key_dst, key_src in translations.items(): - val = sv.get(key_src) - scalar[key_dst] = None if val is None else str(val) - - ch = list(sv) - for att in ch: - scalar["start"] = att.get('start') - scalar["min"] = att.get('min') - scalar["max"] = att.get('max') - scalar["unit"] = att.get('unit') - - # save parameters in the corresponding class variables - if scalar["variability"] == "parameter": - if scalar["name"] in self._override_variables: - self._params[scalar["name"]] = self._override_variables[scalar["name"]] - else: - self._params[scalar["name"]] = scalar["start"] - if scalar["variability"] == "continuous": - self._continuous[scalar["name"]] = np.float64(scalar["start"]) - if scalar["causality"] == "input": - self._inputs[scalar["name"]] = scalar["start"] - if scalar["causality"] == "output": - self._outputs[scalar["name"]] = np.float64(scalar["start"]) - - self._quantities.append(scalar) - - def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: - """ - This method returns list of dictionaries. It displays details of - quantities such as name, value, changeable, and description. - - Examples: - >>> mod.getQuantities() - [ - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'local', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'x', - 'start': '1.0', - 'unit': None, - 'variability': 'continuous', - }, - { - 'name': 'der(x)', - # ... - }, - # ... - ] - - >>> getQuantities("y") - [{ - 'name': 'y', # ... - }] - - >>> getQuantities(["y","x"]) - [ - { - 'name': 'y', # ... - }, - { - 'name': 'x', # ... - } - ] - """ - if names is None: - return self._quantities - - if isinstance(names, str): - r = [x for x in self._quantities if x["name"] == names] - if r == []: - raise KeyError(names) - return r - - if isinstance(names, list): - return [x for y in names for x in self._quantities if x["name"] == y] - - raise ModelicaSystemError("Unhandled input for getQuantities()") - - def getContinuousInitial( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """ - Get (initial) values of continuous signals. - - Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. - Returns: - If `names` is None, a dict in the format - {signal_name: signal_value} is returned. - If `names` is a string, a single element list [signal_value] is - returned. - If `names` is a list, a list with one value for each signal name - in names is returned: [signal1_value, signal2_value, ...]. - - Examples: - >>> mod.getContinuousInitial() - {'x': '1.0', 'der(x)': None, 'y': '-0.4'} - >>> mod.getContinuousInitial("y") - ['-0.4'] - >>> mod.getContinuousInitial(["y","x"]) - ['-0.4', '1.0'] - """ - if names is None: - return self._continuous - if isinstance(names, str): - return [self._continuous[names]] - if isinstance(names, list): - return [self._continuous[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getContinousInitial()") - - def getParameters( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get parameter values. - - Args: - names: Either None (default), a string with the parameter name, - or a list of parameter name strings. - Returns: - If `names` is None, a dict in the format - {parameter_name: parameter_value} is returned. - If `names` is a string, a single element list is returned. - If `names` is a list, a list with one value for each parameter name - in names is returned. - In all cases, parameter values are returned as strings. - - Examples: - >>> mod.getParameters() - {'Name1': '1.23', 'Name2': '4.56'} - >>> mod.getParameters("Name1") - ['1.23'] - >>> mod.getParameters(["Name1","Name2"]) - ['1.23', '4.56'] - """ - if names is None: - return self._params - if isinstance(names, str): - return [self._params[names]] - if isinstance(names, list): - return [self._params[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getParameters()") - - def getInputs( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, list[tuple[float, float]]] | list[list[tuple[float, float]]]: - """Get values of input signals. - - Args: - names: Either None (default), a string with the input name, - or a list of input name strings. - Returns: - If `names` is None, a dict in the format - {input_name: input_value} is returned. - If `names` is a string, a single element list [input_value] is - returned. - If `names` is a list, a list with one value for each input name - in names is returned: [input1_values, input2_values, ...]. - In all cases, input values are returned as a list of tuples, - where the first element in the tuple is the time and the second - element is the input value. - - Examples: - >>> mod.getInputs() - {'Name1': [(0.0, 0.0), (1.0, 1.0)], 'Name2': None} - >>> mod.getInputs("Name1") - [[(0.0, 0.0), (1.0, 1.0)]] - >>> mod.getInputs(["Name1","Name2"]) - [[(0.0, 0.0), (1.0, 1.0)], None] - """ - if names is None: - return self._inputs - if isinstance(names, str): - return [self._inputs[names]] - if isinstance(names, list): - return [self._inputs[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getInputs()") - - def getOutputsInitial( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """ - Get (initial) values of output signals. - - Args: - names: Either None (default), a string with the output name, - or a list of output name strings. - Returns: - If `names` is None, a dict in the format - {output_name: output_value} is returned. - If `names` is a string, a single element list [output_value] is - returned. - If `names` is a list, a list with one value for each output name - in names is returned: [output1_value, output2_value, ...]. - - Examples: - >>> mod.getOutputsInitial() - {'out1': '-0.4', 'out2': '1.2'} - >>> mod.getOutputsInitial("out1") - ['-0.4'] - >>> mod.getOutputsInitial(["out1","out2"]) - ['-0.4', '1.2'] - """ - if names is None: - return self._outputs - if isinstance(names, str): - return [self._outputs[names]] - if isinstance(names, list): - return [self._outputs[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getOutputsInitial()") - - def getSimulationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get simulation options such as stopTime and tolerance. - - Args: - names: Either None (default), a string with the simulation option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - Option values are always returned as strings. - - Examples: - >>> mod.getSimulationOptions() - {'startTime': '0', 'stopTime': '1.234', - 'stepSize': '0.002', 'tolerance': '1.1e-08', 'solver': 'dassl', 'outputFormat': 'mat'} - >>> mod.getSimulationOptions("stopTime") - ['1.234'] - >>> mod.getSimulationOptions(["tolerance", "stopTime"]) - ['1.1e-08', '1.234'] - """ - if names is None: - return self._simulate_options - if isinstance(names, str): - return [self._simulate_options[names]] - if isinstance(names, list): - return [self._simulate_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getSimulationOptions()") - - def getLinearizationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get simulation options used for linearization. - - Args: - names: Either None (default), a string with the linearization option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - - The option values are always returned as strings. - - Examples: - >>> mod.getLinearizationOptions() - {'startTime': '0.0', 'stopTime': '1.0', 'stepSize': '0.002', 'tolerance': '1e-08'} - >>> mod.getLinearizationOptions("stopTime") - ['1.0'] - >>> mod.getLinearizationOptions(["tolerance", "stopTime"]) - ['1e-08', '1.0'] - """ - if names is None: - return self._linearization_options - if isinstance(names, str): - return [self._linearization_options[names]] - if isinstance(names, list): - return [self._linearization_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getLinearizationOptions()") - - def getOptimizationOptions( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get simulation options used for optimization. - - Args: - names: Either None (default), a string with the optimization option - name, or a list of option name strings. - - Returns: - If `names` is None, a dict in the format - {option_name: option_value} is returned. - If `names` is a string, a single element list [option_value] is - returned. - If `names` is a list, a list with one value for each option name - in names is returned: [option1_value, option2_value, ...]. - - The option values are always returned as string. - - Examples: - >>> mod.getOptimizationOptions() - {'startTime': 0.0, 'stopTime': 1.0, 'numberOfIntervals': 500, 'stepSize': 0.002, 'tolerance': 1e-08} - >>> mod.getOptimizationOptions("stopTime") - [1.0] - >>> mod.getOptimizationOptions(["tolerance", "stopTime"]) - [1e-08, 1.0] - """ - if names is None: - return self._optimization_options - if isinstance(names, str): - return [self._optimization_options[names]] - if isinstance(names, list): - return [self._optimization_options[x] for x in names] - - raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") - - @staticmethod - def _parse_om_version(version: str) -> tuple[int, int, int]: - """ - Evaluate 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 _process_override_data( - self, - om_cmd: ModelExecutionCmd, - override_file: OMPathABC, - override_var: dict[str, str], - override_sim: dict[str, str], - ) -> None: - """ - Define the override parameters. As the definition of simulation specific override parameter changes with OM - 1.26.0, version specific code is needed. Please keep in mind, that this will fail if OMC is not used to run the - model executable. - """ - if len(override_var) == 0 and len(override_sim) == 0: - return - - override_content = "" - if override_var: - override_content += "\n".join([f"{key}={value}" for key, value in override_var.items()]) + "\n" - - # simulation options are not read from override file from version >= 1.26.0, - # pass them to simulation executable directly as individual arguments - # see https://github.com/OpenModelica/OpenModelica/pull/14813 - if override_sim: - if self._version >= (1, 26, 0): - for key, opt_value in override_sim.items(): - if key == "solver": - k = "s" - else: - k = key - om_cmd.arg_set(key=k, val=str(opt_value)) - else: - override_content += "\n".join([f"{key}={value}" for key, value in override_sim.items()]) + "\n" - - if override_content: - override_file.write_text(override_content) - om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) - - def simulate_cmd( - self, - result_file: OMPathABC, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelExecutionCmd: - """ - This method prepares the simulates model according to the simulation options. It returns an instance of - ModelicaSystemCmd which can be used to run the simulation. - - Due to the tempdir being unique for the ModelicaSystem instance, *NEVER* use this to create several simulations - with the same instance of ModelicaSystem! Restart each simulation process with a new instance of ModelicaSystem. - - However, if only non-structural parameters are used, it is possible to reuse an existing instance of - ModelicaSystem to create several version ModelicaSystemCmd to run the model using different settings. - - Parameters - ---------- - result_file - simflags - simargs - - Returns - ------- - An instance if ModelicaSystemCmd to run the requested simulation. - """ - - om_cmd = ModelExecutionCmd( - runpath=self.getWorkDirectory(), - cmd_local=self._session.model_execution_local, - cmd_windows=self._session.model_execution_windows, - cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), - timeout=self._session.set_timeout(), - model_name=self._model_name, - ) - - # always define the result file to use - om_cmd.arg_set(key="r", val=result_file.as_posix()) - - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - - if simargs: - om_cmd.args_set(args=simargs) - - self._process_override_data( - om_cmd=om_cmd, - override_file=result_file.parent / f"{result_file.stem}_override.txt", - override_var=self._override_variables, - override_sim=self._simulate_options_override, - ) - - if self._inputs: # if model has input quantities - for key, val in self._inputs.items(): - if val is None: - val = [(float(self._simulate_options["startTime"]), 0.0), - (float(self._simulate_options["stopTime"]), 0.0)] - self._inputs[key] = val - if float(self._simulate_options["startTime"]) != val[0][0]: - raise ModelicaSystemError(f"startTime not matched for Input {key}!") - if float(self._simulate_options["stopTime"]) != val[-1][0]: - raise ModelicaSystemError(f"stopTime not matched for Input {key}!") - - # csvfile is based on name used for result file - csvfile = result_file.parent / f"{result_file.stem}.csv" - # write csv file and store the name - csvfile = self._createCSVData(csvfile=csvfile) - - om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) - - return om_cmd - - def simulate( - self, - resultfile: Optional[str | os.PathLike] = None, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> None: - """Simulate the model according to simulation options. - - See setSimulationOptions(). - - Args: - resultfile: Path to a custom result file - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. - simargs: Dict with simulation runtime flags. - - Examples: - mod.simulate() - mod.simulate(resultfile="a.mat") - # set runtime simulation flags, deprecated - mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10") - # using simargs - mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}}) - """ - - if resultfile is None: - # default result file generated by OM - self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" - elif isinstance(resultfile, OMPathABC): - self._result_file = resultfile - else: - self._result_file = self._session.omcpath(resultfile) - if not self._result_file.is_absolute(): - self._result_file = self.getWorkDirectory() / resultfile - - if not isinstance(self._result_file, OMPathABC): - raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") - - om_cmd = self.simulate_cmd( - result_file=self._result_file, - simflags=simflags, - simargs=simargs, - ) - - # delete resultfile ... - if self._result_file.is_file(): - self._result_file.unlink() - # ... run simulation ... - cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() - # and check returncode *AND* resultfile - if returncode != 0 and self._result_file.is_file(): - # check for an empty (=> 0B) result file which indicates a crash of the model executable - # see: https://github.com/OpenModelica/OMPython/issues/261 - # https://github.com/OpenModelica/OpenModelica/issues/13829 - if self._result_file.size() == 0: - self._result_file.unlink() - raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!") - - logger.warning(f"Return code = {returncode} but result file exists!") - - self._simulated = True - - @staticmethod - def _prepare_input_data( - input_args: Any, - input_kwargs: dict[str, Any], - ) -> dict[str, str]: - """ - Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. - """ - - def prepare_str(str_in: str) -> dict[str, str]: - str_in = str_in.replace(" ", "") - key_val_list: list[str] = str_in.split("=") - if len(key_val_list) != 2: - raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - - input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - - return input_data_from_str - - input_data: dict[str, str] = {} - - for input_arg in input_args: - if isinstance(input_arg, str): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - input_data = input_data | prepare_str(input_arg) - elif isinstance(input_arg, list): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - - for item in input_arg: - if not isinstance(item, str): - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") - input_data = input_data | prepare_str(item) - elif isinstance(input_arg, dict): - input_data = input_data | input_arg - else: - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") - - if len(input_kwargs): - for key, val in input_kwargs.items(): - # ensure all values are strings to align it on one type: dict[str, str] - if not isinstance(val, str): - # spaces have to be removed as setInput() could take list of tuples as input and spaces would - # result in an error on recreating the input data - str_val = str(val).replace(' ', '') - else: - str_val = val - if ' ' in key or ' ' in str_val: - raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") - input_data[key] = str_val - - return input_data - - def _set_method_helper( - self, - inputdata: dict[str, str], - classdata: dict[str, Any], - datatype: str, - overridedata: Optional[dict[str, str]] = None, - ) -> bool: - """ - Helper function for: - * setParameter() - * setContinuous() - * setSimulationOptions() - * setLinearizationOption() - * setOptimizationOption() - * setInputs() - - Parameters - ---------- - inputdata - string or list of string given by user - classdata - dict() containing the values of different variables (eg: parameter, continuous, simulation parameters) - datatype - type identifier (eg; continuous, parameter, simulation, linearization, optimization) - overridedata - dict() which stores the new override variables list, - """ - - for key, val in inputdata.items(): - if key not in classdata: - raise ModelicaSystemError(f"Invalid variable for type {repr(datatype)}: {repr(key)}") - - if datatype == "parameter" and not self.isParameterChangeable(key): - raise ModelicaSystemError(f"It is not possible to set the parameter {repr(key)}. It seems to be " - "structural, final, protected, evaluated or has a non-constant binding. " - "Use sendExpression(...) and rebuild the model using buildModel() API; " - "command to set the parameter before rebuilding the model: " - "sendExpression(expr=\"setParameterValue(" - f"{self._model_name}, {key}, {val if val is not None else ''}" - ")\").") - - classdata[key] = val - if overridedata is not None: - overridedata[key] = val - - return True - - def isParameterChangeable( - self, - name: str, - ) -> bool: - """ - Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to - recompile the model). - """ - q = self.getQuantities(name) - if q[0]["changeable"] == "false": - return False - return True - - def setContinuous( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set continuous values. It can be called: - with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: - usage - >>> setContinuous("Name=value") # depreciated - >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated - - >>> setContinuous(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setContinuous(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._continuous, - datatype="continuous", - overridedata=self._override_variables) - - def setParameters( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set parameter values. It can be called: - with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: - usage - >>> setParameters("Name=value") # depreciated - >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated - - >>> setParameters(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setParameters(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._params, - datatype="parameter", - overridedata=self._override_variables) - - def setSimulationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set simulation options. It can be called: - with a sequence of simulation options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setSimulationOptions("Name=value") # depreciated - >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setSimulationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setSimulationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._simulate_options, - datatype="simulation-option", - overridedata=self._simulate_options_override) - - def setLinearizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set linearization options. It can be called: - with a sequence of linearization options name and assigning corresponding value as arguments as show in the - example below - usage - >>> setLinearizationOptions("Name=value") # depreciated - >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setLinearizationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setLinearizationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._linearization_options, - datatype="Linearization-option", - overridedata=None) - - def setOptimizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set optimization options. It can be called: - with a sequence of optimization options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setOptimizationOptions("Name=value") # depreciated - >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated - - >>> setOptimizationOptions(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setOptimizationOptions(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - return self._set_method_helper( - inputdata=inputdata, - classdata=self._optimization_options, - datatype="optimization-option", - overridedata=None) - - def setInputs( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - This method is used to set input values. It can be called with a sequence of input name and assigning - corresponding values as arguments as show in the example below. Compared to other set*() methods this is a - special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() - and restored here via ast.literal_eval(). - - >>> setInputs("Name=value") # depreciated - >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated - - >>> setInputs(Name1="value1", Name2="value2") - >>> param = {"Name1": "value1", "Name2": "value2"} - >>> setInputs(**param) - """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) - - for key, val in inputdata.items(): - if key not in self._inputs: - raise ModelicaSystemError(f"{key} is not an input") - - if not isinstance(val, str): - raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") - - val_evaluated = ast.literal_eval(val) - - if isinstance(val_evaluated, (int, float)): - self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), - (float(self._simulate_options["stopTime"]), float(val))] - elif isinstance(val_evaluated, list): - if not all([isinstance(item, tuple) for item in val_evaluated]): - raise ModelicaSystemError("Value for setInput() must be in tuple format; " - f"got {repr(val_evaluated)}") - if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): - raise ModelicaSystemError("Time value should be in increasing order; " - f"got {repr(val_evaluated)}") - - for item in val_evaluated: - if item[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " - "than the simulation start time") - if len(item) != 2: - raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " - "is in incorrect format!") - - self._inputs[key] = val_evaluated - else: - raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") - - return True - - def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: - """ - Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, - this file is used; else a generic file name is created. - """ - start_time: float = float(self._simulate_options["startTime"]) - stop_time: float = float(self._simulate_options["stopTime"]) - - # Replace None inputs with a default constant zero signal - inputs: dict[str, list[tuple[float, float]]] = {} - for input_name, input_signal in self._inputs.items(): - if input_signal is None: - inputs[input_name] = [(start_time, 0.0), (stop_time, 0.0)] - else: - inputs[input_name] = input_signal - - # Collect all unique timestamps across all input signals - all_times = np.array( - sorted({t for signal in inputs.values() for t, _ in signal}), - dtype=float - ) - - # Interpolate missing values - interpolated_inputs: dict[str, np.ndarray] = {} - for signal_name, signal_values in inputs.items(): - signal = np.array(signal_values) - interpolated_inputs[signal_name] = np.interp( - x=all_times, - xp=signal[:, 0], # times - fp=signal[:, 1], # values - ) - - # Write CSV file - input_names = list(interpolated_inputs.keys()) - header = ['time'] + input_names + ['end'] - - csv_rows = [header] - for i, t in enumerate(all_times): - row = [ - t, # time - *(interpolated_inputs[name][i] for name in input_names), # input values - 0, # trailing 'end' column - ] - csv_rows.append(row) - - if csvfile is None: - csvfile = self.getWorkDirectory() / f'{self._model_name}.csv' - - # basic definition of a CSV file using csv_rows as input - csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n" - - csvfile.write_text(csv_content) - - return csvfile - - def linearize( - self, - lintime: Optional[float] = None, - simflags: Optional[str] = None, - simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> LinearizationResult: - """Linearize the model according to linearization options. - - See setLinearizationOptions. - - Args: - lintime: Override "stopTime" value. - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. - simargs: A dict with command line flags and possible options; example: "simargs={'csvInput': 'a.csv'}" - - Returns: - A LinearizationResult object is returned. This allows several - uses: - * `(A, B, C, D) = linearize()` to get just the matrices, - * `result = linearize(); result.A` to get everything and access the - attributes one by one, - * `result = linearize(); A = result[0]` mostly just for backwards - compatibility, because linearize() used to return `[A, B, C, D]`. - """ - if len(self._quantities) == 0: - # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() - raise ModelicaSystemError( - "Linearization cannot be performed as the model is not build, " - "use ModelicaSystemOMC() to build the model first" - ) - - om_cmd = ModelExecutionCmd( - runpath=self.getWorkDirectory(), - cmd_local=self._session.model_execution_local, - cmd_windows=self._session.model_execution_windows, - cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), - timeout=self._session.set_timeout(), - model_name=self._model_name, - ) - - self._process_override_data( - om_cmd=om_cmd, - override_file=self.getWorkDirectory() / f'{self._model_name}_override_linear.txt', - override_var=self._override_variables, - override_sim=self._linearization_options, - ) - - if self._inputs: - for data in self._inputs.values(): - if data is not None: - for value in data: - if value[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError('Input time value is less than simulation startTime') - csvfile = self._createCSVData() - om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) - - if lintime is None: - lintime = float(self._linearization_options["stopTime"]) - if (float(self._linearization_options["startTime"]) > lintime - or float(self._linearization_options["stopTime"]) < lintime): - raise ModelicaSystemError(f"Invalid linearisation time: {lintime=}; " - f"expected value: {self._linearization_options['startTime']} " - f"<= lintime <= {self._linearization_options['stopTime']}") - om_cmd.arg_set(key="l", val=str(lintime)) - - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - - if simargs: - om_cmd.args_set(args=simargs) - - # the file create by the model executable which contains the matrix and linear inputs, outputs and states - linear_file = self.getWorkDirectory() / "linearized_model.py" - linear_file.unlink(missing_ok=True) - - cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() - if returncode != 0: - raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") - if not linear_file.is_file(): - raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") - - self._simulated = True - - # extract data from the python file with the linearized model using the ast module - this allows to get the - # needed information without executing the created code - linear_data = {} - linear_file_content = linear_file.read_text() - try: - # ignore possible typing errors below (mypy) - these are caught by the try .. except .. block - linear_file_ast = ast.parse(linear_file_content) - for body_part in linear_file_ast.body[0].body: # type: ignore - if not isinstance(body_part, ast.Assign): - continue - - target = body_part.targets[0].id # type: ignore - value_ast = ast.literal_eval(body_part.value) - - linear_data[target] = value_ast - except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: - raise ModelicaSystemError(f"Error parsing linearization file {linear_file}: {ex}") from ex - - # remove the file - linear_file.unlink() - - self._linearized_inputs = linear_data["inputVars"] - self._linearized_outputs = linear_data["outputVars"] - self._linearized_states = linear_data["stateVars"] - - return LinearizationResult( - n=linear_data["n"], - m=linear_data["m"], - p=linear_data["p"], - x0=linear_data["x0"], - u0=linear_data["u0"], - A=linear_data["A"], - B=linear_data["B"], - C=linear_data["C"], - D=linear_data["D"], - stateVars=linear_data["stateVars"], - inputVars=linear_data["inputVars"], - outputVars=linear_data["outputVars"], - ) - - def getLinearInputs(self) -> list[str]: - """Get names of input variables of the linearized model.""" - return self._linearized_inputs - - def getLinearOutputs(self) -> list[str]: - """Get names of output variables of the linearized model.""" - return self._linearized_outputs - - def getLinearStates(self) -> list[str]: - """Get names of state variables of the linearized model.""" - return self._linearized_states - - -class ModelicaSystemOMC(ModelicaSystemABC): - """ - Class to simulate a Modelica model using OpenModelica via OMCSession. - """ - - 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. - """ - - if session is None: - session = OMCSessionLocal(omhome=omhome) - - super().__init__( - session=session, - work_directory=work_directory, - ) - - # 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 = ModelicaSystemOMC() - # and then one of the lines below - mod.model(name="modelName", file="ModelicaModel.mo", ) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) - """ - - if self._model_name is not None: - raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " - f"defined for {repr(self._model_name)}!") - - if model_name is None or not isinstance(model_name, str): - raise ModelicaSystemError("A model name must be provided!") - - if libraries is None: - libraries = [] - - if not isinstance(libraries, list): - raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") - - # set variables - self._model_name = model_name # Model class name - self._libraries = libraries # may be needed if model is derived from other model - self._variable_filter = variable_filter - - if self._libraries: - self._loadLibrary(libraries=self._libraries) - - self._file_name = None - if model_file is not None: - file_path = pathlib.Path(model_file) - # special handling for OMCProcessLocal - consider a relative path - if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): - file_path = pathlib.Path.cwd() / file_path - if not file_path.is_file(): - raise IOError(f"Model file {file_path} does not exist!") - - self._file_name = self.getWorkDirectory() / file_path.name - if (isinstance(self._session, OMCSessionLocal) - and file_path.as_posix() == self._file_name.as_posix()): - pass - elif self._file_name.is_file(): - raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") - else: - content = file_path.read_text(encoding='utf-8') - self._file_name.write_text(content) - - if self._file_name is not None: - self._loadFile(fileName=self._file_name) - - if build: - self.buildModel(variable_filter) - - def set_command_line_options(self, command_line_option: str): - """ - Set the provided command line option via OMC setCommandLineOptions(). - """ - expr = f'setCommandLineOptions("{command_line_option}")' - self.sendExpression(expr=expr, parsed=False) - - def _loadFile(self, fileName: OMPathABC): - # load file - self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') - - # for loading file/package, loading model and building model - def _loadLibrary(self, libraries: list): - # load Modelica standard libraries or Modelica files if needed - for element in libraries: - if element is not None: - if isinstance(element, str): - if element.endswith(".mo"): - api_call = "loadFile" - else: - api_call = "loadModel" - self._requestApi(apiName=api_call, entity=element) - elif isinstance(element, tuple): - if not element[1]: - expr_load_lib = f"loadModel({element[0]})" - else: - expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' - self.sendExpression(expr=expr_load_lib) - else: - raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " - f"{element} is of type {type(element)}, " - "The following patterns are supported:\n" - '1)["Modelica"]\n' - '2)[("Modelica","3.2.3"), "PowerSystems"]\n') - - def buildModel(self, variableFilter: Optional[str] = None): - filter_def: Optional[str] = None - if variableFilter is not None: - filter_def = variableFilter - elif self._variable_filter is not None: - filter_def = self._variable_filter - - if filter_def is not None: - var_filter = f'variableFilter="{filter_def}"' - else: - var_filter = 'variableFilter=".*"' - - build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) - logger.debug("OM model build result: %s", build_model_result) - - # check if the executable exists ... - self.check_model_executable() - - xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] - self._xmlparse(xml_file=xml_file) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Wrapper for OMCSession.sendExpression(). - """ - try: - retval = self._session.sendExpression(expr=expr, parsed=parsed) - except OMSessionException as ex: - raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex - - logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") - - return retval - - # request to OMC - def _requestApi( - self, - apiName: str, - entity: Optional[str] = None, - properties: Optional[str] = None, - ) -> Any: - if entity is not None and properties is not None: - expr = f'{apiName}({entity}, {properties})' - elif entity is not None and properties is None: - if apiName in ("loadFile", "importFMU"): - expr = f'{apiName}("{entity}")' - else: - expr = f'{apiName}({entity})' - else: - expr = f'{apiName}()' - - return self.sendExpression(expr=expr) - - def getContinuousFinal( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """ - Get (final) values of continuous signals (at stopTime). - - Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. - Returns: - If `names` is None, a dict in the format - {signal_name: signal_value} is returned. - If `names` is a string, a single element list [signal_value] is - returned. - If `names` is a list, a list with one value for each signal name - in names is returned: [signal1_value, signal2_value, ...]. - - Examples: - >>> mod.getContinuousFinal() - {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} - >>> mod.getContinuousFinal("x") - [np.float64(0.68)] - >>> mod.getContinuousFinal(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] - """ - if not self._simulated: - raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") - - def get_continuous_solution(name_list: list[str]) -> None: - for name in name_list: - if name in self._continuous: - value = self.getSolutions(name) - self._continuous[name] = np.float64(value[0][-1]) - else: - raise KeyError(f"{names} is not continuous") - - if names is None: - get_continuous_solution(name_list=list(self._continuous.keys())) - return self._continuous - - if isinstance(names, str): - get_continuous_solution(name_list=[names]) - return [self._continuous[names]] - - if isinstance(names, list): - get_continuous_solution(name_list=names) - values = [] - for name in names: - values.append(self._continuous[name]) - return values - - raise ModelicaSystemError("Unhandled input for getContinousFinal()") - - def getContinuous( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of continuous signals. - - If called before simulate(), the initial values are returned. - If called after simulate(), the final values (at stopTime) are returned. - The return format is always numpy.float64. - - Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. - Returns: - If `names` is None, a dict in the format - {signal_name: signal_value} is returned. - If `names` is a string, a single element list [signal_value] is - returned. - If `names` is a list, a list with one value for each signal name - in names is returned: [signal1_value, signal2_value, ...]. - - Examples: - Before simulate(): - >>> mod.getContinuous() - {'x': '1.0', 'der(x)': None, 'y': '-0.4'} - >>> mod.getContinuous("y") - ['-0.4'] - >>> mod.getContinuous(["y","x"]) - ['-0.4', '1.0'] - - After simulate(): - >>> mod.getContinuous() - {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} - >>> mod.getContinuous("x") - [np.float64(0.68)] - >>> mod.getContinuous(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] - """ - if not self._simulated: - return self.getContinuousInitial(names=names) - - return self.getContinuousFinal(names=names) - - def getOutputsFinal( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get (final) values of output signals (at stopTime). - - Args: - names: Either None (default), a string with the output name, - or a list of output name strings. - Returns: - If `names` is None, a dict in the format - {output_name: output_value} is returned. - If `names` is a string, a single element list [output_value] is - returned. - If `names` is a list, a list with one value for each output name - in names is returned: [output1_value, output2_value, ...]. - - Examples: - >>> mod.getOutputsFinal() - {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} - >>> mod.getOutputsFinal("out1") - [np.float64(-0.1234)] - >>> mod.getOutputsFinal(["out1","out2"]) - [np.float64(-0.1234), np.float64(2.1)] - """ - if not self._simulated: - raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") - - def get_outputs_solution(name_list: list[str]) -> None: - for name in name_list: - if name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = np.float64(value[0][-1]) - else: - raise KeyError(f"{names} is not a valid output") - - if names is None: - get_outputs_solution(name_list=list(self._outputs.keys())) - return self._outputs - - if isinstance(names, str): - get_outputs_solution(name_list=[names]) - return [self._outputs[names]] - - if isinstance(names, list): - get_outputs_solution(name_list=names) - values = [] - for name in names: - values.append(self._outputs[name]) - return values - - raise ModelicaSystemError("Unhandled input for getOutputs()") - - def getOutputs( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of output signals. - - If called before simulate(), the initial values are returned. - If called after simulate(), the final values (at stopTime) are returned. - The return format is always numpy.float64. - - Args: - names: Either None (default), a string with the output name, - or a list of output name strings. - Returns: - If `names` is None, a dict in the format - {output_name: output_value} is returned. - If `names` is a string, a single element list [output_value] is - returned. - If `names` is a list, a list with one value for each output name - in names is returned: [output1_value, output2_value, ...]. - - Examples: - Before simulate(): - >>> mod.getOutputs() - {'out1': '-0.4', 'out2': '1.2'} - >>> mod.getOutputs("out1") - ['-0.4'] - >>> mod.getOutputs(["out1","out2"]) - ['-0.4', '1.2'] - - After simulate(): - >>> mod.getOutputs() - {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} - >>> mod.getOutputs("out1") - [np.float64(-0.1234)] - >>> mod.getOutputs(["out1","out2"]) - [np.float64(-0.1234), np.float64(2.1)] - """ - if not self._simulated: - return self.getOutputsInitial(names=names) - - return self.getOutputsFinal(names=names) - - def plot( - self, - plotdata: str, - resultfile: Optional[str | os.PathLike] = None, - ) -> None: - """ - Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the - plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. - """ - - if not isinstance(self._session, OMCSessionLocal): - raise ModelicaSystemError("Plot is using the OMC plot functionality; " - "thus, it is only working if OMC is running locally!") - - if resultfile is not None: - plot_result_file = self._session.omcpath(resultfile) - elif self._result_file is not None: - plot_result_file = self._result_file - else: - raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " - "or provide a result file!") - - if not plot_result_file.is_file(): - raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") - - expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' - self.sendExpression(expr=expr) - - def getSolutions( - self, - varList: Optional[str | list[str]] = None, - resultfile: Optional[str | os.PathLike] = None, - ) -> tuple[str, ...] | np.ndarray: - """Extract simulation results from a result data file. - - Args: - varList: Names of variables to be extracted. Either unspecified to - get names of available variables, or a single variable name - as a string, or a list of variable names. - resultfile: Path to the result file. If unspecified, the result - file created by simulate() is used. - - Returns: - If varList is None, a tuple with names of all variables - is returned. - If varList is a string, a 1D numpy array is returned. - If varList is a list, a 2D numpy array is returned. - - Examples: - >>> mod.getSolutions() - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"]) - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - >>> mod.getSolutions(resultfile="c:/a.mat") - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x", resultfile="c:/a.mat") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - """ - if resultfile is None: - if self._result_file is None: - raise ModelicaSystemError("No result file found. Run simulate() first.") - result_file = self._result_file - else: - result_file = self._session.omcpath(resultfile) - - # check if the result file exits - if not result_file.is_file(): - raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") - - # get absolute path - result_file = result_file.absolute() - - result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') - self.sendExpression(expr="closeSimulationResultFile()") - if varList is None: - 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(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') - np_res = np.array(res) - self.sendExpression(expr="closeSimulationResultFile()") - return np_res - - def convertMo2Fmu( - self, - version: str = "2.0", - fmuType: str = "me_cs", - fileNamePrefix: Optional[str] = None, - includeResources: bool = True, - ) -> OMPathABC: - """Translate the model into a Functional Mockup Unit. - - Args: - See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html - - Returns: - str: Path to the created '*.fmu' file. - - Examples: - >>> mod.convertMo2Fmu() - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", - includeResources=True) - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - """ - - if fileNamePrefix is None: - if self._model_name is None: - fileNamePrefix = "" - else: - fileNamePrefix = self._model_name - include_resources_str = "true" if includeResources else "false" - - properties = (f'version="{version}", fmuType="{fmuType}", ' - f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') - fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) - fmu_path = self._session.omcpath(fmu) - - # report proper error message - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - return fmu_path - - # to convert FMU to Modelica model - def convertFmu2Mo( - self, - fmu: os.PathLike, - ) -> OMPathABC: - """ - In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate - Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". - Currently, it only supports Model Exchange conversion. - usage - >>> convertFmu2Mo("c:/BouncingBall.Fmu") - """ - - fmu_path = self._session.omcpath(fmu) - - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) - 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. +from OMPython.om_session_omc import ( + OMCSessionLocal, +) +from OMPython.modelica_system_abc import ( + ModelicaSystemError, +) +from OMPython.modelica_system_omc import ( + ModelicaSystemOMC, +) +from OMPython.modelica_doe_omc import ( + ModelicaDoEOMC, +) - 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 +# define logger using the current module name as ID +logger = logging.getLogger(__name__) class ModelicaSystem(ModelicaSystemOMC): @@ -2013,578 +170,12 @@ def getOutputs( 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) - - mod = OMPython.ModelicaSystemOMC() - mod.model( - model_name="M", - model_file=model.as_posix(), - ) - doe_mod = OMPython.ModelicaSystemDoE( - mod=mod, - parameters=param, - resultpath=resdir, - simargs={"override": {'stopTime': 1.0}}, - ) - doe_mod.prepare() - doe_def = doe_mod.get_doe_definition() - doe_mod.simulate() - doe_sol = doe_mod.get_doe_solutions() - - # ... work with doe_def and doe_sol ... - - - if __name__ == "__main__": - run_doe() - ``` - - """ - - # Dictionary keys used in simulation dict (see _sim_dict or get_doe()). These dict keys contain a space and, thus, - # cannot be used as OM variable identifiers. They are defined here as reference for any evaluation of the data. - DICT_ID_STRUCTURE: str = 'ID structure' - DICT_ID_NON_STRUCTURE: str = 'ID non-structure' - DICT_RESULT_AVAILABLE: str = 'result available' - - def __init__( - self, - # ModelicaSystem definition to use - mod: ModelicaSystemABC, - # simulation specific input - # TODO: add more settings (simulation options, input options, ...) - simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, - # DoE specific inputs - resultpath: Optional[str | os.PathLike] = None, - parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, - ) -> None: - """ - Initialisation of ModelicaSystemDoE. The parameters are based on: ModelicaSystem.__init__() and - ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as - a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. - """ - if not isinstance(mod, ModelicaSystemABC): - raise ModelicaSystemError("Missing definition of ModelicaSystem!") - - self._mod = mod - self._model_name = mod.get_model_name() - - self._simargs = simargs - - if resultpath is None: - self._resultpath = self.get_session().omcpath_tempdir() - else: - self._resultpath = self.get_session().omcpath(resultpath).resolve() - if not self._resultpath.is_dir(): - raise ModelicaSystemError("Argument resultpath must be set to a valid path within the environment used " - f"for the OpenModelica session: {resultpath}!") - - if isinstance(parameters, dict): - self._parameters = parameters - else: - self._parameters = {} - - self._doe_def: Optional[dict[str, dict[str, Any]]] = None - self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None - - def get_session(self) -> OMSessionABC: - """ - Return the OMC session used for this class. - """ - return self._mod.get_session() - - def get_resultpath(self) -> OMPathABC: - """ - Get the path there the result data is saved. - """ - return self._resultpath - - def prepare(self) -> int: - """ - Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of - ModelicaSystem while the non-structural parameters can just be set on the executable. - - The return value is the number of simulation defined. - """ - - doe_sim = {} - doe_def = {} - - param_structure = {} - param_non_structure = {} - for param_name in self._parameters.keys(): - changeable = self._mod.isParameterChangeable(name=param_name) - logger.info(f"Parameter {repr(param_name)} is changeable? {changeable}") - - if changeable: - param_non_structure[param_name] = self._parameters[param_name] - else: - param_structure[param_name] = self._parameters[param_name] - - param_structure_combinations = list(itertools.product(*param_structure.values())) - param_non_structural_combinations = list(itertools.product(*param_non_structure.values())) - - for idx_pc_structure, pc_structure in enumerate(param_structure_combinations): - 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 = {} - for idx, pk in enumerate(param_non_structure.keys()): - sim_param_non_structural[pk] = cast(Any, pk_non_structural[idx]) - - resfilename = f"DOE_{idx_pc_structure:09d}_{idx_non_structural:09d}.mat" - logger.info(f"use result file {repr(resfilename)} " - f"for structural parameters: {sim_param_structure} " - f"and non-structural parameters: {sim_param_non_structural}") - resultfile = self._resultpath / resfilename - - df_data = ( - { - self.DICT_ID_STRUCTURE: idx_pc_structure, - } - | sim_param_structure - | { - self.DICT_ID_NON_STRUCTURE: idx_non_structural, - } - | sim_param_non_structural - | { - self.DICT_RESULT_AVAILABLE: False, - } - ) - - self._mod.setParameters(sim_param_non_structural) - mscmd = self._mod.simulate_cmd( - result_file=resultfile, - ) - if self._simargs is not None: - mscmd.args_set(args=self._simargs) - cmd_definition = mscmd.definition() - del mscmd - - doe_sim[resfilename] = cmd_definition - doe_def[resfilename] = df_data - - logger.info(f"Prepared {len(doe_sim)} simulation definitions for the defined DoE.") - self._doe_cmd = doe_sim - self._doe_def = doe_def - - return len(doe_sim) - - @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 - settings including structural and non-structural parameters. - - The following code snippet can be used to convert the data to a pandas dataframe: - - ``` - import pandas as pd - - doe_dict = doe_mod.get_doe_definition() - doe_df = pd.DataFrame.from_dict(data=doe_dict, orient='index') - ``` - - """ - return self._doe_def - - def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: - """ - Get the definitions of simulations commands to run for this DoE. - """ - return self._doe_cmd - - def simulate( - self, - num_workers: int = 3, - ) -> bool: - """ - Simulate the DoE using the defined number of workers. - - Returns True if all simulations were done successfully, else False. - """ - - if self._doe_cmd is None or self._doe_def is None: - raise ModelicaSystemError("DoE preparation missing - call prepare() first!") - - doe_cmd_total = len(self._doe_cmd) - doe_def_total = len(self._doe_def) - - if doe_cmd_total != doe_def_total: - raise ModelicaSystemError(f"Mismatch between number simulation commands ({doe_cmd_total}) " - f"and simulation definitions ({doe_def_total}).") - - doe_task_query: queue.Queue = queue.Queue() - if self._doe_cmd is not None: - for doe_cmd in self._doe_cmd.values(): - doe_task_query.put(doe_cmd) - - if not isinstance(self._doe_def, dict) or len(self._doe_def) == 0: - raise ModelicaSystemError("Missing Doe Summary!") - - def worker(worker_id, task_queue): - while True: - try: - # Get the next task from the queue - cmd_definition = task_queue.get(block=False) - except queue.Empty: - logger.info(f"[Worker {worker_id}] No more simulations to run.") - break - - if cmd_definition is None: - raise ModelicaSystemError("Missing simulation definition!") - - resultfile = cmd_definition.cmd_result_file - resultpath = self.get_session().omcpath(resultfile) - - logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}") - - try: - returncode = cmd_definition.run() - logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " - f"finished with return code: {returncode}") - except ModelicaSystemError as ex: - logger.warning(f"Simulation error for {resultpath.name}: {ex}") - - # Mark the task as done - task_queue.task_done() - - sim_query_done = doe_cmd_total - doe_task_query.qsize() - logger.info(f"[Worker {worker_id}] Task completed: {resultpath.name} " - f"({doe_cmd_total - sim_query_done}/{doe_cmd_total} = " - f"{(doe_cmd_total - sim_query_done) / doe_cmd_total * 100:.2f}% of tasks left)") - - # Create and start worker threads - logger.info(f"Start simulations for DoE with {doe_cmd_total} simulations " - f"using {num_workers} workers ...") - threads = [] - for i in range(num_workers): - thread = threading.Thread(target=worker, args=(i, doe_task_query)) - thread.start() - threads.append(thread) - - # Wait for all threads to complete - for thread in threads: - thread.join() - - doe_def_done = 0 - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename - - # include check for an empty (=> 0B) result file which indicates a crash of the model executable - # see: https://github.com/OpenModelica/OMPython/issues/261 - # https://github.com/OpenModelica/OpenModelica/issues/13829 - if resultfile.is_file() and resultfile.size() > 0: - self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE] = True - doe_def_done += 1 - - logger.info(f"All workers finished ({doe_def_done} of {doe_def_total} simulations with a result file).") - - return doe_def_total == doe_def_done - - -class ModelicaDoEOMC(ModelicaDoEABC): - """ - Class to run DoEs based on a (Open)Modelica model using ModelicaSystemOMC - - The example is the same as defined for ModelicaDoEABC - """ - - def __init__( - self, - # ModelicaSystem definition to use - mod: ModelicaSystemOMC, - # simulation specific input - # TODO: add more settings (simulation options, input options, ...) - simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, - # DoE specific inputs - resultpath: Optional[str | os.PathLike] = None, - parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, - ) -> None: - - if not isinstance(mod, ModelicaSystemOMC): - raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") - - super().__init__( - mod=mod, - simargs=simargs, - resultpath=resultpath, - parameters=parameters, - ) - - def _prepare_structure_parameters( - self, - idx_pc_structure: int, - pc_structure: Tuple, - param_structure: dict[str, list[str] | list[int] | list[float]], - ) -> dict[str, str | int | float]: - build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" - build_dir.mkdir() - self._mod.setWorkDirectory(work_directory=build_dir) - - # need to repeat this check to make the linters happy - if not isinstance(self._mod, ModelicaSystemOMC): - raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") - - sim_param_structure = {} - for idx_structure, pk_structure in enumerate(param_structure.keys()): - sim_param_structure[pk_structure] = pc_structure[idx_structure] - - pk_value = pc_structure[idx_structure] - if isinstance(pk_value, str): - pk_value_str = self.get_session().escape_str(pk_value) - expr = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" - elif isinstance(pk_value, bool): - pk_value_bool_str = "true" if pk_value else "false" - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" - else: - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expr=expr) - if not res: - raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " - f"to {pk_value} using {repr(expr)}") - - self._mod.buildModel() - - return sim_param_structure - - def get_doe_solutions( - self, - var_list: Optional[list] = None, - ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: - """ - Wrapper for doe_get_solutions() - """ - if not isinstance(self._mod, ModelicaSystemOMC): - raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") - - return doe_get_solutions( - msomc=self._mod, - resultpath=self._resultpath, - doe_def=self.get_doe_definition(), - var_list=var_list, - ) - - -def doe_get_solutions( - msomc: ModelicaSystemOMC, - resultpath: OMPathABC, - doe_def: Optional[dict] = None, - var_list: Optional[list] = None, -) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: - """ - Get all solutions of the DoE run. The following return values are possible: - - * A list of variables if val_list == None - - * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - - The following code snippet can be used to convert the solution data for each run to a pandas dataframe: - - ``` - import pandas as pd - - doe_sol = doe_mod.get_doe_solutions() - for key in doe_sol: - data = doe_sol[key]['data'] - if data: - doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) - else: - doe_sol[key]['df'] = None - ``` - - """ - if not isinstance(doe_def, dict): - return None - - if len(doe_def) == 0: - raise ModelicaSystemError("No result files available - all simulations did fail?") - - sol_dict: dict[str, dict[str, Any]] = {} - for resultfilename in doe_def: - resultfile = resultpath / resultfilename - - sol_dict[resultfilename] = {} - - if not doe_def[resultfilename][ModelicaDoEABC.DICT_RESULT_AVAILABLE]: - msg = f"No result file available for {resultfilename}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - continue - - if var_list is None: - var_list_row = list(msomc.getSolutions(resultfile=resultfile)) - else: - var_list_row = var_list - - try: - sol = msomc.getSolutions(varList=var_list_row, resultfile=resultfile) - sol_data = {var: sol[idx] for idx, var in enumerate(var_list_row)} - sol_dict[resultfilename]['msg'] = 'Simulation available' - sol_dict[resultfilename]['data'] = sol_data - except ModelicaSystemError as ex: - msg = f"Error reading solution for {resultfilename}: {ex}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - - return sol_dict - - class ModelicaSystemDoE(ModelicaDoEOMC): """ Compatibility class. """ -class ModelicaSystemRunner(ModelicaSystemABC): - """ - Class to simulate a Modelica model using a pre-compiled model binary. - """ - - def __init__( - self, - work_directory: Optional[str | os.PathLike] = None, - session: Optional[OMSessionABC] = None, - ) -> None: - if session is None: - session = OMSessionRunner() - - if not isinstance(session, OMSessionRunner): - raise ModelicaSystemError("Only working if OMCsessionRunner is used!") - - super().__init__( - work_directory=work_directory, - session=session, - ) - - def setup( - self, - model_name: Optional[str] = None, - variable_filter: Optional[str] = None, - ) -> None: - """ - Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists - within the working directory. At least two files are needed: - - * model executable (as '' or '.exe'; in case of Windows additional '.bat' - is expected to evaluate the path to needed dlls - * the model initialization file (as '_init.xml') - """ - - if self._model_name is not None: - raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " - f"defined for {repr(self._model_name)}!") - - if model_name is None or not isinstance(model_name, str): - raise ModelicaSystemError("A model name must be provided!") - - # set variables - self._model_name = model_name # Model class name - self._variable_filter = variable_filter - - # test if the model can be executed - self.check_model_executable() - - # read XML file - xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" - self._xmlparse(xml_file=xml_file) - - -class ModelicaDoERunner(ModelicaDoEABC): - """ - Class to run DoEs based on a (Open)Modelica model using ModelicaSystemRunner - - The example is the same as defined for ModelicaDoEABC - """ - - def __init__( - self, - # ModelicaSystem definition to use - mod: ModelicaSystemABC, - # simulation specific input - # TODO: add more settings (simulation options, input options, ...) - simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, - # DoE specific inputs - resultpath: Optional[str | os.PathLike] = None, - parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, - ) -> None: - if not isinstance(mod, ModelicaSystemABC): - raise ModelicaSystemError(f"Invalid definition for ModelicaSystem*: {type(mod)}!") - - super().__init__( - mod=mod, - simargs=simargs, - resultpath=resultpath, - parameters=parameters, - ) - - def _prepare_structure_parameters( - self, - idx_pc_structure: int, - pc_structure: Tuple, - param_structure: dict[str, list[str] | list[int] | list[float]], - ) -> dict[str, str | int | float]: - if len(param_structure.keys()) > 0: - raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " - "pre-compiled binary of model.") - - return {} - - class ModelicaSystemCmd(ModelExecutionCmd): """ Compatibility class; in the new version it is renamed as ModelExecutionCmd. @@ -2605,8 +196,6 @@ def __init__( 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": diff --git a/OMPython/__init__.py b/OMPython/__init__.py index f541df25..282923a7 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -35,19 +35,32 @@ OMPathRunnerLocal, OMSessionRunner, ) - -from OMPython.ModelicaSystem import ( +from OMPython.modelica_system_abc import ( LinearizationResult, - ModelicaSystem, - ModelicaSystemOMC, - ModelicaSystemDoE, - ModelicaDoEOMC, + ModelicaSystemABC, ModelicaSystemError, +) +from OMPython.modelica_system_omc import ( + ModelicaSystemOMC, +) +from OMPython.modelica_system_runner import ( ModelicaSystemRunner, - ModelicaDoERunner, - +) +from OMPython.modelica_doe_abc import ( + ModelicaDoEABC, +) +from OMPython.modelica_doe_omc import ( doe_get_solutions, + ModelicaDoEOMC, +) +from OMPython.modelica_doe_runner import ( + ModelicaDoERunner, +) + +from OMPython.ModelicaSystem import ( + ModelicaSystem, + ModelicaSystemDoE, ModelicaSystemCmd, ) from OMPython.OMCSession import ( @@ -63,12 +76,23 @@ # global names imported if import 'from OMPython import *' is used __all__ = [ + 'doe_get_solutions', + 'LinearizationResult', 'ModelExecutionCmd', 'ModelExecutionData', 'ModelExecutionException', + 'ModelicaDoEABC', + 'ModelicaDoEOMC', + 'ModelicaDoERunner', + 'ModelicaSystemABC', + 'ModelicaSystemDoE', + 'ModelicaSystemError', + 'ModelicaSystemOMC', + 'ModelicaSystemRunner', + 'OMPathABC', 'OMSessionABC', 'OMSessionException', @@ -85,17 +109,8 @@ 'OMPathRunnerLocal', 'OMSessionRunner', - 'ModelicaSystem', - 'ModelicaSystemOMC', 'ModelicaSystemCmd', - 'ModelicaSystemDoE', - 'ModelicaDoEOMC', - 'ModelicaSystemError', - - 'ModelicaSystemRunner', - 'ModelicaDoERunner', - - 'doe_get_solutions', + 'ModelicaSystem', 'OMCSessionABC', 'OMCSessionCmd', diff --git a/OMPython/modelica_doe_abc.py b/OMPython/modelica_doe_abc.py new file mode 100644 index 00000000..e3ab8403 --- /dev/null +++ b/OMPython/modelica_doe_abc.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import abc +import itertools +import logging +import numbers +import os +import queue +import threading +from typing import Any, cast, Optional, Tuple + +from OMPython.model_execution import ( + ModelExecutionData, +) +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, +) +from OMPython.modelica_system_abc import ( + ModelicaSystemABC, + ModelicaSystemError, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaDoEABC(metaclass=abc.ABCMeta): + """ + Base class to run DoEs based on a (Open)Modelica model using ModelicaSystem + + Example + ------- + ``` + import OMPython + import pathlib + + + def run_doe(): + mypath = pathlib.Path('.') + + model = mypath / "M.mo" + model.write_text( + " model M\n" + " parameter Integer p=1;\n" + " parameter Integer q=1;\n" + " parameter Real a = -1;\n" + " parameter Real b = -1;\n" + " Real x[p];\n" + " Real y[q];\n" + " equation\n" + " der(x) = a * fill(1.0, p);\n" + " der(y) = b * fill(1.0, q);\n" + " end M;\n" + ) + + param = { + # structural + 'p': [1, 2], + 'q': [3, 4], + # non-structural + 'a': [5, 6], + 'b': [7, 8], + } + + resdir = mypath / 'DoE' + resdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_name="M", + model_file=model.as_posix(), + ) + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, + parameters=param, + resultpath=resdir, + simargs={"override": {'stopTime': 1.0}}, + ) + doe_mod.prepare() + doe_def = doe_mod.get_doe_definition() + doe_mod.simulate() + doe_sol = doe_mod.get_doe_solutions() + + # ... work with doe_def and doe_sol ... + + + if __name__ == "__main__": + run_doe() + ``` + + """ + + # Dictionary keys used in simulation dict (see _sim_dict or get_doe()). These dict keys contain a space and, thus, + # cannot be used as OM variable identifiers. They are defined here as reference for any evaluation of the data. + DICT_ID_STRUCTURE: str = 'ID structure' + DICT_ID_NON_STRUCTURE: str = 'ID non-structure' + DICT_RESULT_AVAILABLE: str = 'result available' + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemABC, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + """ + Initialisation of ModelicaSystemDoE. The parameters are based on: ModelicaSystem.__init__() and + ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as + a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. + """ + if not isinstance(mod, ModelicaSystemABC): + raise ModelicaSystemError("Missing definition of ModelicaSystem!") + + self._mod = mod + self._model_name = mod.get_model_name() + + self._simargs = simargs + + if resultpath is None: + self._resultpath = self.get_session().omcpath_tempdir() + else: + self._resultpath = self.get_session().omcpath(resultpath).resolve() + if not self._resultpath.is_dir(): + raise ModelicaSystemError("Argument resultpath must be set to a valid path within the environment used " + f"for the OpenModelica session: {resultpath}!") + + if isinstance(parameters, dict): + self._parameters = parameters + else: + self._parameters = {} + + self._doe_def: Optional[dict[str, dict[str, Any]]] = None + self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None + + def get_session(self) -> OMSessionABC: + """ + Return the OMC session used for this class. + """ + return self._mod.get_session() + + def get_resultpath(self) -> OMPathABC: + """ + Get the path there the result data is saved. + """ + return self._resultpath + + def prepare(self) -> int: + """ + Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of + ModelicaSystem while the non-structural parameters can just be set on the executable. + + The return value is the number of simulation defined. + """ + + doe_sim = {} + doe_def = {} + + param_structure = {} + param_non_structure = {} + for param_name in self._parameters.keys(): + changeable = self._mod.isParameterChangeable(name=param_name) + logger.info(f"Parameter {repr(param_name)} is changeable? {changeable}") + + if changeable: + param_non_structure[param_name] = self._parameters[param_name] + else: + param_structure[param_name] = self._parameters[param_name] + + param_structure_combinations = list(itertools.product(*param_structure.values())) + param_non_structural_combinations = list(itertools.product(*param_non_structure.values())) + + for idx_pc_structure, pc_structure in enumerate(param_structure_combinations): + 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 = {} + for idx, pk in enumerate(param_non_structure.keys()): + sim_param_non_structural[pk] = cast(Any, pk_non_structural[idx]) + + resfilename = f"DOE_{idx_pc_structure:09d}_{idx_non_structural:09d}.mat" + logger.info(f"use result file {repr(resfilename)} " + f"for structural parameters: {sim_param_structure} " + f"and non-structural parameters: {sim_param_non_structural}") + resultfile = self._resultpath / resfilename + + df_data = ( + { + self.DICT_ID_STRUCTURE: idx_pc_structure, + } + | sim_param_structure + | { + self.DICT_ID_NON_STRUCTURE: idx_non_structural, + } + | sim_param_non_structural + | { + self.DICT_RESULT_AVAILABLE: False, + } + ) + + self._mod.setParameters(sim_param_non_structural) + mscmd = self._mod.simulate_cmd( + result_file=resultfile, + ) + if self._simargs is not None: + mscmd.args_set(args=self._simargs) + cmd_definition = mscmd.definition() + del mscmd + + doe_sim[resfilename] = cmd_definition + doe_def[resfilename] = df_data + + logger.info(f"Prepared {len(doe_sim)} simulation definitions for the defined DoE.") + self._doe_cmd = doe_sim + self._doe_def = doe_def + + return len(doe_sim) + + @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 + settings including structural and non-structural parameters. + + The following code snippet can be used to convert the data to a pandas dataframe: + + ``` + import pandas as pd + + doe_dict = doe_mod.get_doe_definition() + doe_df = pd.DataFrame.from_dict(data=doe_dict, orient='index') + ``` + + """ + return self._doe_def + + def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: + """ + Get the definitions of simulations commands to run for this DoE. + """ + return self._doe_cmd + + def simulate( + self, + num_workers: int = 3, + ) -> bool: + """ + Simulate the DoE using the defined number of workers. + + Returns True if all simulations were done successfully, else False. + """ + + if self._doe_cmd is None or self._doe_def is None: + raise ModelicaSystemError("DoE preparation missing - call prepare() first!") + + doe_cmd_total = len(self._doe_cmd) + doe_def_total = len(self._doe_def) + + if doe_cmd_total != doe_def_total: + raise ModelicaSystemError(f"Mismatch between number simulation commands ({doe_cmd_total}) " + f"and simulation definitions ({doe_def_total}).") + + doe_task_query: queue.Queue = queue.Queue() + if self._doe_cmd is not None: + for doe_cmd in self._doe_cmd.values(): + doe_task_query.put(doe_cmd) + + if not isinstance(self._doe_def, dict) or len(self._doe_def) == 0: + raise ModelicaSystemError("Missing Doe Summary!") + + def worker(worker_id, task_queue): + while True: + try: + # Get the next task from the queue + cmd_definition = task_queue.get(block=False) + except queue.Empty: + logger.info(f"[Worker {worker_id}] No more simulations to run.") + break + + if cmd_definition is None: + raise ModelicaSystemError("Missing simulation definition!") + + resultfile = cmd_definition.cmd_result_file + resultpath = self.get_session().omcpath(resultfile) + + logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}") + + try: + returncode = cmd_definition.run() + logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " + f"finished with return code: {returncode}") + except ModelicaSystemError as ex: + logger.warning(f"Simulation error for {resultpath.name}: {ex}") + + # Mark the task as done + task_queue.task_done() + + sim_query_done = doe_cmd_total - doe_task_query.qsize() + logger.info(f"[Worker {worker_id}] Task completed: {resultpath.name} " + f"({doe_cmd_total - sim_query_done}/{doe_cmd_total} = " + f"{(doe_cmd_total - sim_query_done) / doe_cmd_total * 100:.2f}% of tasks left)") + + # Create and start worker threads + logger.info(f"Start simulations for DoE with {doe_cmd_total} simulations " + f"using {num_workers} workers ...") + threads = [] + for i in range(num_workers): + thread = threading.Thread(target=worker, args=(i, doe_task_query)) + thread.start() + threads.append(thread) + + # Wait for all threads to complete + for thread in threads: + thread.join() + + doe_def_done = 0 + for resultfilename in self._doe_def: + resultfile = self._resultpath / resultfilename + + # include check for an empty (=> 0B) result file which indicates a crash of the model executable + # see: https://github.com/OpenModelica/OMPython/issues/261 + # https://github.com/OpenModelica/OpenModelica/issues/13829 + if resultfile.is_file() and resultfile.size() > 0: + self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE] = True + doe_def_done += 1 + + logger.info(f"All workers finished ({doe_def_done} of {doe_def_total} simulations with a result file).") + + return doe_def_total == doe_def_done diff --git a/OMPython/modelica_doe_omc.py b/OMPython/modelica_doe_omc.py new file mode 100644 index 00000000..f8f95030 --- /dev/null +++ b/OMPython/modelica_doe_omc.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import logging +import numbers +import os +from typing import Any, Optional, Tuple + +import numpy as np + +from OMPython.om_session_abc import ( + OMPathABC, +) +from OMPython.modelica_system_abc import ( + ModelicaSystemError, +) +from OMPython.modelica_system_omc import ( + ModelicaSystemOMC, +) +from OMPython.modelica_doe_abc import ( + ModelicaDoEABC, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaDoEOMC(ModelicaDoEABC): + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystemOMC + + The example is the same as defined for ModelicaDoEABC + """ + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemOMC, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + + if not isinstance(mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" + build_dir.mkdir() + self._mod.setWorkDirectory(work_directory=build_dir) + + # need to repeat this check to make the linters happy + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + + sim_param_structure = {} + for idx_structure, pk_structure in enumerate(param_structure.keys()): + sim_param_structure[pk_structure] = pc_structure[idx_structure] + + pk_value = pc_structure[idx_structure] + if isinstance(pk_value, str): + pk_value_str = self.get_session().escape_str(pk_value) + expr = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" + elif isinstance(pk_value, bool): + pk_value_bool_str = "true" if pk_value else "false" + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + else: + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" + res = self._mod.sendExpression(expr=expr) + if not res: + raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " + f"to {pk_value} using {repr(expr)}") + + self._mod.buildModel() + + return sim_param_structure + + def get_doe_solutions( + self, + var_list: Optional[list] = None, + ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: + """ + Wrapper for doe_get_solutions() + """ + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + + return doe_get_solutions( + msomc=self._mod, + resultpath=self._resultpath, + doe_def=self.get_doe_definition(), + var_list=var_list, + ) + + +def doe_get_solutions( + msomc: ModelicaSystemOMC, + resultpath: OMPathABC, + doe_def: Optional[dict] = None, + var_list: Optional[list] = None, +) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: + """ + Get all solutions of the DoE run. The following return values are possible: + + * A list of variables if val_list == None + + * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. + + The following code snippet can be used to convert the solution data for each run to a pandas dataframe: + + ``` + import pandas as pd + + doe_sol = doe_mod.get_doe_solutions() + for key in doe_sol: + data = doe_sol[key]['data'] + if data: + doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) + else: + doe_sol[key]['df'] = None + ``` + + """ + if not isinstance(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 diff --git a/OMPython/modelica_doe_runner.py b/OMPython/modelica_doe_runner.py new file mode 100644 index 00000000..6efc4681 --- /dev/null +++ b/OMPython/modelica_doe_runner.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import logging +import numbers +import os +from typing import Optional, Tuple + +from OMPython.modelica_system_abc import ( + ModelicaSystemABC, + ModelicaSystemError, +) +from OMPython.modelica_doe_abc import ( + ModelicaDoEABC, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaDoERunner(ModelicaDoEABC): + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystemRunner + + The example is the same as defined for ModelicaDoEABC + """ + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemABC, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + if not isinstance(mod, ModelicaSystemABC): + raise ModelicaSystemError(f"Invalid definition for ModelicaSystem*: {type(mod)}!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + if len(param_structure.keys()) > 0: + raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " + "pre-compiled binary of model.") + + return {} diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py new file mode 100644 index 00000000..fcc31deb --- /dev/null +++ b/OMPython/modelica_system_abc.py @@ -0,0 +1,1241 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import abc +import ast +from dataclasses import dataclass +import logging +import numbers +import os +import re +from typing import Any, Optional +import warnings +import xml.etree.ElementTree as ET + +import numpy as np + +from OMPython.model_execution import ( + ModelExecutionCmd, +) +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaSystemError(Exception): + """ + Exception used in ModelicaSystem classes. + """ + + +@dataclass +class LinearizationResult: + """Modelica model linearization results. + + Attributes: + n: number of states + m: number of inputs + p: number of outputs + A: state matrix (n x n) + B: input matrix (n x m) + C: output matrix (p x n) + D: feedthrough matrix (p x m) + x0: fixed point + u0: input corresponding to the fixed point + stateVars: names of state variables + inputVars: names of inputs + outputVars: names of outputs + """ + + n: int + m: int + p: int + + A: list + B: list + C: list + D: list + + x0: list[float] + u0: list[float] + + stateVars: list[str] + inputVars: list[str] + outputVars: list[str] + + def __iter__(self): + """Allow unpacking A, B, C, D = result.""" + yield self.A + yield self.B + yield self.C + yield self.D + + def __getitem__(self, index: int): + """Allow accessing A, B, C, D via result[0] through result[3]. + + This is needed for backwards compatibility, because + ModelicaSystem.linearize() used to return [A, B, C, D]. + """ + return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] + + +class ModelicaSystemABC(metaclass=abc.ABCMeta): + """ + Base class to simulate a Modelica models. + """ + + def __init__( + self, + session: OMSessionABC, + work_directory: Optional[str | os.PathLike] = None, + ) -> None: + """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). + + Args: + work_directory: Path to a directory to be used for temporary + files like the model executable. If left unspecified, a tmp + directory will be created. + session: definition of a (local) OMC session to be used. If + unspecified, a new local session will be created. + """ + + self._quantities: list[dict[str, Any]] = [] + self._params: dict[str, str] = {} # even numerical values are stored as str + self._inputs: dict[str, list[tuple[float, float]]] = {} + self._outputs: dict[str, np.float64] = {} # 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] = {} + self._linearization_options: dict[str, str] = { + 'startTime': str(0.0), + 'stopTime': str(1.0), + 'stepSize': str(0.002), + 'tolerance': str(1e-8), + } + self._optimization_options = self._linearization_options | { + 'numberOfIntervals': str(500), + } + self._linearized_inputs: list[str] = [] # linearization input list + self._linearized_outputs: list[str] = [] # linearization output list + self._linearized_states: list[str] = [] # linearization states list + + self._simulated = False # True if the model has already been simulated + self._result_file: Optional[OMPathABC] = None # for storing result file + + 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. + """ + return self._session + + def get_model_name(self) -> str: + """ + Return the defined model name. + """ + if not isinstance(self._model_name, str): + raise ModelicaSystemError("No model name defined!") + + return self._model_name + + def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathABC: + """ + Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this + directory. If no directory is defined a unique temporary directory is created. + """ + if work_directory is not None: + workdir = self._session.omcpath(work_directory).absolute() + if not workdir.is_dir(): + raise IOError(f"Provided work directory does not exists: {work_directory}!") + else: + workdir = self._session.omcpath_tempdir().absolute() + if not workdir.is_dir(): + raise IOError(f"{workdir} could not be created") + + logger.info("Define work dir as %s", workdir) + self._session.set_workdir(workdir=workdir) + + # set the class variable _work_dir ... + self._work_dir = workdir + # ... and also return the defined path + return workdir + + def getWorkDirectory(self) -> OMPathABC: + """ + Return the defined working directory for this ModelicaSystem / OpenModelica session. + """ + return self._work_dir + + def check_model_executable(self): + """ + Check if the model executable is working + """ + # check if the executable exists ... + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + timeout=self._session.set_timeout(), + model_name=self._model_name, + ) + # ... by running it - output help for command help + om_cmd.arg_set(key="help", val="help") + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + if returncode != 0: + raise ModelicaSystemError("Model executable not working!") + + def _xmlparse(self, xml_file: OMPathABC): + 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"): + self._simulate_options[key] = str(attr.get(key)) + + for sv in root.iter('ScalarVariable'): + translations = { + "alias": "alias", + "aliasvariable": "aliasVariable", + "causality": "causality", + "changeable": "isValueChangeable", + "description": "description", + "name": "name", + "variability": "variability", + } + + scalar: dict[str, Any] = {} + for key_dst, key_src in translations.items(): + val = sv.get(key_src) + scalar[key_dst] = None if val is None else str(val) + + ch = list(sv) + for att in ch: + scalar["start"] = att.get('start') + scalar["min"] = att.get('min') + scalar["max"] = att.get('max') + scalar["unit"] = att.get('unit') + + # save parameters in the corresponding class variables + if scalar["variability"] == "parameter": + if scalar["name"] in self._override_variables: + self._params[scalar["name"]] = self._override_variables[scalar["name"]] + else: + self._params[scalar["name"]] = scalar["start"] + if scalar["variability"] == "continuous": + self._continuous[scalar["name"]] = np.float64(scalar["start"]) + if scalar["causality"] == "input": + self._inputs[scalar["name"]] = scalar["start"] + if scalar["causality"] == "output": + self._outputs[scalar["name"]] = np.float64(scalar["start"]) + + self._quantities.append(scalar) + + def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: + """ + This method returns list of dictionaries. It displays details of + quantities such as name, value, changeable, and description. + + Examples: + >>> mod.getQuantities() + [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + { + 'name': 'der(x)', + # ... + }, + # ... + ] + + >>> getQuantities("y") + [{ + 'name': 'y', # ... + }] + + >>> getQuantities(["y","x"]) + [ + { + 'name': 'y', # ... + }, + { + 'name': 'x', # ... + } + ] + """ + if names is None: + return self._quantities + + if isinstance(names, str): + r = [x for x in self._quantities if x["name"] == names] + if r == []: + raise KeyError(names) + return r + + if isinstance(names, list): + return [x for y in names for x in self._quantities if x["name"] == y] + + raise ModelicaSystemError("Unhandled input for getQuantities()") + + def getContinuousInitial( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (initial) values of continuous signals. + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + >>> mod.getContinuousInitial() + {'x': '1.0', 'der(x)': None, 'y': '-0.4'} + >>> mod.getContinuousInitial("y") + ['-0.4'] + >>> mod.getContinuousInitial(["y","x"]) + ['-0.4', '1.0'] + """ + if names is None: + return self._continuous + if isinstance(names, str): + return [self._continuous[names]] + if isinstance(names, list): + return [self._continuous[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getContinousInitial()") + + def getParameters( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get parameter values. + + Args: + names: Either None (default), a string with the parameter name, + or a list of parameter name strings. + Returns: + If `names` is None, a dict in the format + {parameter_name: parameter_value} is returned. + If `names` is a string, a single element list is returned. + If `names` is a list, a list with one value for each parameter name + in names is returned. + In all cases, parameter values are returned as strings. + + Examples: + >>> mod.getParameters() + {'Name1': '1.23', 'Name2': '4.56'} + >>> mod.getParameters("Name1") + ['1.23'] + >>> mod.getParameters(["Name1","Name2"]) + ['1.23', '4.56'] + """ + if names is None: + return self._params + if isinstance(names, str): + return [self._params[names]] + if isinstance(names, list): + return [self._params[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getParameters()") + + def getInputs( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, list[tuple[float, float]]] | list[list[tuple[float, float]]]: + """Get values of input signals. + + Args: + names: Either None (default), a string with the input name, + or a list of input name strings. + Returns: + If `names` is None, a dict in the format + {input_name: input_value} is returned. + If `names` is a string, a single element list [input_value] is + returned. + If `names` is a list, a list with one value for each input name + in names is returned: [input1_values, input2_values, ...]. + In all cases, input values are returned as a list of tuples, + where the first element in the tuple is the time and the second + element is the input value. + + Examples: + >>> mod.getInputs() + {'Name1': [(0.0, 0.0), (1.0, 1.0)], 'Name2': None} + >>> mod.getInputs("Name1") + [[(0.0, 0.0), (1.0, 1.0)]] + >>> mod.getInputs(["Name1","Name2"]) + [[(0.0, 0.0), (1.0, 1.0)], None] + """ + if names is None: + return self._inputs + if isinstance(names, str): + return [self._inputs[names]] + if isinstance(names, list): + return [self._inputs[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getInputs()") + + def getOutputsInitial( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (initial) values of output signals. + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + >>> mod.getOutputsInitial() + {'out1': '-0.4', 'out2': '1.2'} + >>> mod.getOutputsInitial("out1") + ['-0.4'] + >>> mod.getOutputsInitial(["out1","out2"]) + ['-0.4', '1.2'] + """ + if names is None: + return self._outputs + if isinstance(names, str): + return [self._outputs[names]] + if isinstance(names, list): + return [self._outputs[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getOutputsInitial()") + + def getSimulationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get simulation options such as stopTime and tolerance. + + Args: + names: Either None (default), a string with the simulation option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + Option values are always returned as strings. + + Examples: + >>> mod.getSimulationOptions() + {'startTime': '0', 'stopTime': '1.234', + 'stepSize': '0.002', 'tolerance': '1.1e-08', 'solver': 'dassl', 'outputFormat': 'mat'} + >>> mod.getSimulationOptions("stopTime") + ['1.234'] + >>> mod.getSimulationOptions(["tolerance", "stopTime"]) + ['1.1e-08', '1.234'] + """ + if names is None: + return self._simulate_options + if isinstance(names, str): + return [self._simulate_options[names]] + if isinstance(names, list): + return [self._simulate_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getSimulationOptions()") + + def getLinearizationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get simulation options used for linearization. + + Args: + names: Either None (default), a string with the linearization option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + + The option values are always returned as strings. + + Examples: + >>> mod.getLinearizationOptions() + {'startTime': '0.0', 'stopTime': '1.0', 'stepSize': '0.002', 'tolerance': '1e-08'} + >>> mod.getLinearizationOptions("stopTime") + ['1.0'] + >>> mod.getLinearizationOptions(["tolerance", "stopTime"]) + ['1e-08', '1.0'] + """ + if names is None: + return self._linearization_options + if isinstance(names, str): + return [self._linearization_options[names]] + if isinstance(names, list): + return [self._linearization_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getLinearizationOptions()") + + def getOptimizationOptions( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, str] | list[str]: + """Get simulation options used for optimization. + + Args: + names: Either None (default), a string with the optimization option + name, or a list of option name strings. + + Returns: + If `names` is None, a dict in the format + {option_name: option_value} is returned. + If `names` is a string, a single element list [option_value] is + returned. + If `names` is a list, a list with one value for each option name + in names is returned: [option1_value, option2_value, ...]. + + The option values are always returned as string. + + Examples: + >>> mod.getOptimizationOptions() + {'startTime': 0.0, 'stopTime': 1.0, 'numberOfIntervals': 500, 'stepSize': 0.002, 'tolerance': 1e-08} + >>> mod.getOptimizationOptions("stopTime") + [1.0] + >>> mod.getOptimizationOptions(["tolerance", "stopTime"]) + [1e-08, 1.0] + """ + if names is None: + return self._optimization_options + if isinstance(names, str): + return [self._optimization_options[names]] + if isinstance(names, list): + return [self._optimization_options[x] for x in names] + + raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") + + @staticmethod + def _parse_om_version(version: str) -> tuple[int, int, int]: + """ + Evaluate 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 _process_override_data( + self, + om_cmd: ModelExecutionCmd, + override_file: OMPathABC, + override_var: dict[str, str], + override_sim: dict[str, str], + ) -> None: + """ + Define the override parameters. As the definition of simulation specific override parameter changes with OM + 1.26.0, version specific code is needed. Please keep in mind, that this will fail if OMC is not used to run the + model executable. + """ + if len(override_var) == 0 and len(override_sim) == 0: + return + + override_content = "" + if override_var: + override_content += "\n".join([f"{key}={value}" for key, value in override_var.items()]) + "\n" + + # simulation options are not read from override file from version >= 1.26.0, + # pass them to simulation executable directly as individual arguments + # see https://github.com/OpenModelica/OpenModelica/pull/14813 + if override_sim: + if self._version >= (1, 26, 0): + for key, opt_value in override_sim.items(): + if key == "solver": + k = "s" + else: + k = key + om_cmd.arg_set(key=k, val=str(opt_value)) + else: + override_content += "\n".join([f"{key}={value}" for key, value in override_sim.items()]) + "\n" + + if override_content: + override_file.write_text(override_content) + om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) + + def simulate_cmd( + self, + result_file: OMPathABC, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> ModelExecutionCmd: + """ + This method prepares the simulates model according to the simulation options. It returns an instance of + ModelicaSystemCmd which can be used to run the simulation. + + Due to the tempdir being unique for the ModelicaSystem instance, *NEVER* use this to create several simulations + with the same instance of ModelicaSystem! Restart each simulation process with a new instance of ModelicaSystem. + + However, if only non-structural parameters are used, it is possible to reuse an existing instance of + ModelicaSystem to create several version ModelicaSystemCmd to run the model using different settings. + + Parameters + ---------- + result_file + simflags + simargs + + Returns + ------- + An instance if ModelicaSystemCmd to run the requested simulation. + """ + + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + timeout=self._session.set_timeout(), + model_name=self._model_name, + ) + + # always define the result file to use + om_cmd.arg_set(key="r", val=result_file.as_posix()) + + # allow runtime simulation flags from user input + if simflags is not None: + om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) + + if simargs: + om_cmd.args_set(args=simargs) + + self._process_override_data( + om_cmd=om_cmd, + override_file=result_file.parent / f"{result_file.stem}_override.txt", + override_var=self._override_variables, + override_sim=self._simulate_options_override, + ) + + if self._inputs: # if model has input quantities + for key, val in self._inputs.items(): + if val is None: + val = [(float(self._simulate_options["startTime"]), 0.0), + (float(self._simulate_options["stopTime"]), 0.0)] + self._inputs[key] = val + if float(self._simulate_options["startTime"]) != val[0][0]: + raise ModelicaSystemError(f"startTime not matched for Input {key}!") + if float(self._simulate_options["stopTime"]) != val[-1][0]: + raise ModelicaSystemError(f"stopTime not matched for Input {key}!") + + # csvfile is based on name used for result file + csvfile = result_file.parent / f"{result_file.stem}.csv" + # write csv file and store the name + csvfile = self._createCSVData(csvfile=csvfile) + + om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) + + return om_cmd + + def simulate( + self, + resultfile: Optional[str | os.PathLike] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> None: + """Simulate the model according to simulation options. + + See setSimulationOptions(). + + Args: + resultfile: Path to a custom result file + simflags: String of extra command line flags for the model binary. + This argument is deprecated, use simargs instead. + simargs: Dict with simulation runtime flags. + + Examples: + mod.simulate() + mod.simulate(resultfile="a.mat") + # set runtime simulation flags, deprecated + mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10") + # using simargs + mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}}) + """ + + if resultfile is None: + # default result file generated by OM + self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" + elif isinstance(resultfile, OMPathABC): + self._result_file = resultfile + else: + self._result_file = self._session.omcpath(resultfile) + if not self._result_file.is_absolute(): + self._result_file = self.getWorkDirectory() / resultfile + + if not isinstance(self._result_file, OMPathABC): + raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") + + om_cmd = self.simulate_cmd( + result_file=self._result_file, + simflags=simflags, + simargs=simargs, + ) + + # delete resultfile ... + if self._result_file.is_file(): + self._result_file.unlink() + # ... run simulation ... + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + # and check returncode *AND* resultfile + if returncode != 0 and self._result_file.is_file(): + # check for an empty (=> 0B) result file which indicates a crash of the model executable + # see: https://github.com/OpenModelica/OMPython/issues/261 + # https://github.com/OpenModelica/OpenModelica/issues/13829 + if self._result_file.size() == 0: + self._result_file.unlink() + raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!") + + logger.warning(f"Return code = {returncode} but result file exists!") + + self._simulated = True + + @staticmethod + def _prepare_input_data( + input_args: Any, + input_kwargs: dict[str, Any], + ) -> dict[str, str]: + """ + Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. + """ + + def prepare_str(str_in: str) -> dict[str, str]: + str_in = str_in.replace(" ", "") + key_val_list: list[str] = str_in.split("=") + if len(key_val_list) != 2: + raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") + + input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} + + return input_data_from_str + + input_data: dict[str, str] = {} + + for input_arg in input_args: + if isinstance(input_arg, str): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + input_data = input_data | prepare_str(input_arg) + elif isinstance(input_arg, list): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + + for item in input_arg: + if not isinstance(item, str): + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") + input_data = input_data | prepare_str(item) + elif isinstance(input_arg, dict): + input_data = input_data | input_arg + else: + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") + + if len(input_kwargs): + for key, val in input_kwargs.items(): + # ensure all values are strings to align it on one type: dict[str, str] + if not isinstance(val, str): + # spaces have to be removed as setInput() could take list of tuples as input and spaces would + # result in an error on recreating the input data + str_val = str(val).replace(' ', '') + else: + str_val = val + if ' ' in key or ' ' in str_val: + raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") + input_data[key] = str_val + + return input_data + + def _set_method_helper( + self, + inputdata: dict[str, str], + classdata: dict[str, Any], + datatype: str, + overridedata: Optional[dict[str, str]] = None, + ) -> bool: + """ + Helper function for: + * setParameter() + * setContinuous() + * setSimulationOptions() + * setLinearizationOption() + * setOptimizationOption() + * setInputs() + + Parameters + ---------- + inputdata + string or list of string given by user + classdata + dict() containing the values of different variables (eg: parameter, continuous, simulation parameters) + datatype + type identifier (eg; continuous, parameter, simulation, linearization, optimization) + overridedata + dict() which stores the new override variables list, + """ + + for key, val in inputdata.items(): + if key not in classdata: + raise ModelicaSystemError(f"Invalid variable for type {repr(datatype)}: {repr(key)}") + + if datatype == "parameter" and not self.isParameterChangeable(key): + raise ModelicaSystemError(f"It is not possible to set the parameter {repr(key)}. It seems to be " + "structural, final, protected, evaluated or has a non-constant binding. " + "Use sendExpression(...) and rebuild the model using buildModel() API; " + "command to set the parameter before rebuilding the model: " + "sendExpression(expr=\"setParameterValue(" + f"{self._model_name}, {key}, {val if val is not None else ''}" + ")\").") + + classdata[key] = val + if overridedata is not None: + overridedata[key] = val + + return True + + def isParameterChangeable( + self, + name: str, + ) -> bool: + """ + Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to + recompile the model). + """ + q = self.getQuantities(name) + if q[0]["changeable"] == "false": + return False + return True + + def setContinuous( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set continuous values. It can be called: + with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: + usage + >>> setContinuous("Name=value") # depreciated + >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated + + >>> setContinuous(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setContinuous(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._continuous, + datatype="continuous", + overridedata=self._override_variables) + + def setParameters( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set parameter values. It can be called: + with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: + usage + >>> setParameters("Name=value") # depreciated + >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated + + >>> setParameters(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setParameters(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._params, + datatype="parameter", + overridedata=self._override_variables) + + def setSimulationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set simulation options. It can be called: + with a sequence of simulation options name and assigning corresponding values as arguments as show in the + example below: + usage + >>> setSimulationOptions("Name=value") # depreciated + >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setSimulationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setSimulationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._simulate_options, + datatype="simulation-option", + overridedata=self._simulate_options_override) + + def setLinearizationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set linearization options. It can be called: + with a sequence of linearization options name and assigning corresponding value as arguments as show in the + example below + usage + >>> setLinearizationOptions("Name=value") # depreciated + >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setLinearizationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setLinearizationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._linearization_options, + datatype="Linearization-option", + overridedata=None) + + def setOptimizationOptions( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set optimization options. It can be called: + with a sequence of optimization options name and assigning corresponding values as arguments as show in the + example below: + usage + >>> setOptimizationOptions("Name=value") # depreciated + >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated + + >>> setOptimizationOptions(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setOptimizationOptions(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + return self._set_method_helper( + inputdata=inputdata, + classdata=self._optimization_options, + datatype="optimization-option", + overridedata=None) + + def setInputs( + self, + *args: Any, + **kwargs: dict[str, Any], + ) -> bool: + """ + This method is used to set input values. It can be called with a sequence of input name and assigning + corresponding values as arguments as show in the example below. Compared to other set*() methods this is a + special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() + and restored here via ast.literal_eval(). + + >>> setInputs("Name=value") # depreciated + >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated + + >>> setInputs(Name1="value1", Name2="value2") + >>> param = {"Name1": "value1", "Name2": "value2"} + >>> setInputs(**param) + """ + inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + + for key, val in inputdata.items(): + if key not in self._inputs: + raise ModelicaSystemError(f"{key} is not an input") + + if not isinstance(val, str): + raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") + + val_evaluated = ast.literal_eval(val) + + if isinstance(val_evaluated, (int, float)): + self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), + (float(self._simulate_options["stopTime"]), float(val))] + elif isinstance(val_evaluated, list): + if not all([isinstance(item, tuple) for item in val_evaluated]): + raise ModelicaSystemError("Value for setInput() must be in tuple format; " + f"got {repr(val_evaluated)}") + if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): + raise ModelicaSystemError("Time value should be in increasing order; " + f"got {repr(val_evaluated)}") + + for item in val_evaluated: + if item[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " + "than the simulation start time") + if len(item) != 2: + raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " + "is in incorrect format!") + + self._inputs[key] = val_evaluated + else: + raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") + + return True + + def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: + """ + Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, + this file is used; else a generic file name is created. + """ + start_time: float = float(self._simulate_options["startTime"]) + stop_time: float = float(self._simulate_options["stopTime"]) + + # Replace None inputs with a default constant zero signal + inputs: dict[str, list[tuple[float, float]]] = {} + for input_name, input_signal in self._inputs.items(): + if input_signal is None: + inputs[input_name] = [(start_time, 0.0), (stop_time, 0.0)] + else: + inputs[input_name] = input_signal + + # Collect all unique timestamps across all input signals + all_times = np.array( + sorted({t for signal in inputs.values() for t, _ in signal}), + dtype=float + ) + + # Interpolate missing values + interpolated_inputs: dict[str, np.ndarray] = {} + for signal_name, signal_values in inputs.items(): + signal = np.array(signal_values) + interpolated_inputs[signal_name] = np.interp( + x=all_times, + xp=signal[:, 0], # times + fp=signal[:, 1], # values + ) + + # Write CSV file + input_names = list(interpolated_inputs.keys()) + header = ['time'] + input_names + ['end'] + + csv_rows = [header] + for i, t in enumerate(all_times): + row = [ + t, # time + *(interpolated_inputs[name][i] for name in input_names), # input values + 0, # trailing 'end' column + ] + csv_rows.append(row) + + if csvfile is None: + csvfile = self.getWorkDirectory() / f'{self._model_name}.csv' + + # basic definition of a CSV file using csv_rows as input + csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n" + + csvfile.write_text(csv_content) + + return csvfile + + def linearize( + self, + lintime: Optional[float] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> LinearizationResult: + """Linearize the model according to linearization options. + + See setLinearizationOptions. + + Args: + lintime: Override "stopTime" value. + simflags: String of extra command line flags for the model binary. + This argument is deprecated, use simargs instead. + simargs: A dict with command line flags and possible options; example: "simargs={'csvInput': 'a.csv'}" + + Returns: + A LinearizationResult object is returned. This allows several + uses: + * `(A, B, C, D) = linearize()` to get just the matrices, + * `result = linearize(); result.A` to get everything and access the + attributes one by one, + * `result = linearize(); A = result[0]` mostly just for backwards + compatibility, because linearize() used to return `[A, B, C, D]`. + """ + if len(self._quantities) == 0: + # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() + raise ModelicaSystemError( + "Linearization cannot be performed as the model is not build, " + "use ModelicaSystemOMC() to build the model first" + ) + + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + timeout=self._session.set_timeout(), + model_name=self._model_name, + ) + + self._process_override_data( + om_cmd=om_cmd, + override_file=self.getWorkDirectory() / f'{self._model_name}_override_linear.txt', + override_var=self._override_variables, + override_sim=self._linearization_options, + ) + + if self._inputs: + for data in self._inputs.values(): + if data is not None: + for value in data: + if value[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError('Input time value is less than simulation startTime') + csvfile = self._createCSVData() + om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) + + if lintime is None: + lintime = float(self._linearization_options["stopTime"]) + if (float(self._linearization_options["startTime"]) > lintime + or float(self._linearization_options["stopTime"]) < lintime): + raise ModelicaSystemError(f"Invalid linearisation time: {lintime=}; " + f"expected value: {self._linearization_options['startTime']} " + f"<= lintime <= {self._linearization_options['stopTime']}") + om_cmd.arg_set(key="l", val=str(lintime)) + + # allow runtime simulation flags from user input + if simflags is not None: + om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) + + if simargs: + om_cmd.args_set(args=simargs) + + # the file create by the model executable which contains the matrix and linear inputs, outputs and states + linear_file = self.getWorkDirectory() / "linearized_model.py" + linear_file.unlink(missing_ok=True) + + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + if returncode != 0: + raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") + if not linear_file.is_file(): + raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") + + self._simulated = True + + # extract data from the python file with the linearized model using the ast module - this allows to get the + # needed information without executing the created code + linear_data = {} + linear_file_content = linear_file.read_text() + try: + # ignore possible typing errors below (mypy) - these are caught by the try .. except .. block + linear_file_ast = ast.parse(linear_file_content) + for body_part in linear_file_ast.body[0].body: # type: ignore + if not isinstance(body_part, ast.Assign): + continue + + target = body_part.targets[0].id # type: ignore + value_ast = ast.literal_eval(body_part.value) + + linear_data[target] = value_ast + except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: + raise ModelicaSystemError(f"Error parsing linearization file {linear_file}: {ex}") from ex + + # remove the file + linear_file.unlink() + + self._linearized_inputs = linear_data["inputVars"] + self._linearized_outputs = linear_data["outputVars"] + self._linearized_states = linear_data["stateVars"] + + return LinearizationResult( + n=linear_data["n"], + m=linear_data["m"], + p=linear_data["p"], + x0=linear_data["x0"], + u0=linear_data["u0"], + A=linear_data["A"], + B=linear_data["B"], + C=linear_data["C"], + D=linear_data["D"], + stateVars=linear_data["stateVars"], + inputVars=linear_data["inputVars"], + outputVars=linear_data["outputVars"], + ) + + def getLinearInputs(self) -> list[str]: + """Get names of input variables of the linearized model.""" + return self._linearized_inputs + + def getLinearOutputs(self) -> list[str]: + """Get names of output variables of the linearized model.""" + return self._linearized_outputs + + def getLinearStates(self) -> list[str]: + """Get names of state variables of the linearized model.""" + return self._linearized_states diff --git a/OMPython/modelica_system_omc.py b/OMPython/modelica_system_omc.py new file mode 100644 index 00000000..34805e0f --- /dev/null +++ b/OMPython/modelica_system_omc.py @@ -0,0 +1,648 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import logging +import os +import pathlib +import textwrap +from typing import Any, cast, Optional + +import numpy as np + +from OMPython.om_session_abc import ( + OMPathABC, + OMSessionABC, + OMSessionException, +) +from OMPython.om_session_omc import ( + OMCSessionLocal, +) +from OMPython.modelica_system_abc import ( + ModelicaSystemABC, + ModelicaSystemError, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelicaSystemOMC(ModelicaSystemABC): + """ + Class to simulate a Modelica model using OpenModelica via OMCSession. + """ + + 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. + """ + + if session is None: + session = OMCSessionLocal(omhome=omhome) + + super().__init__( + session=session, + work_directory=work_directory, + ) + + # 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 = ModelicaSystemOMC() + # and then one of the lines below + mod.model(name="modelName", file="ModelicaModel.mo", ) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) + """ + + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + if libraries is None: + libraries = [] + + if not isinstance(libraries, list): + raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") + + # set variables + self._model_name = model_name # Model class name + self._libraries = libraries # may be needed if model is derived from other model + self._variable_filter = variable_filter + + if self._libraries: + self._loadLibrary(libraries=self._libraries) + + self._file_name = None + if model_file is not None: + file_path = pathlib.Path(model_file) + # special handling for OMCProcessLocal - consider a relative path + if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): + file_path = pathlib.Path.cwd() / file_path + if not file_path.is_file(): + raise IOError(f"Model file {file_path} does not exist!") + + self._file_name = self.getWorkDirectory() / file_path.name + if (isinstance(self._session, OMCSessionLocal) + and file_path.as_posix() == self._file_name.as_posix()): + pass + elif self._file_name.is_file(): + raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") + else: + content = file_path.read_text(encoding='utf-8') + self._file_name.write_text(content) + + if self._file_name is not None: + self._loadFile(fileName=self._file_name) + + if build: + self.buildModel(variable_filter) + + def set_command_line_options(self, command_line_option: str): + """ + Set the provided command line option via OMC setCommandLineOptions(). + """ + expr = f'setCommandLineOptions("{command_line_option}")' + self.sendExpression(expr=expr, parsed=False) + + def _loadFile(self, fileName: OMPathABC): + # load file + self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') + + # for loading file/package, loading model and building model + def _loadLibrary(self, libraries: list): + # load Modelica standard libraries or Modelica files if needed + for element in libraries: + if element is not None: + if isinstance(element, str): + if element.endswith(".mo"): + api_call = "loadFile" + else: + api_call = "loadModel" + self._requestApi(apiName=api_call, entity=element) + elif isinstance(element, tuple): + if not element[1]: + expr_load_lib = f"loadModel({element[0]})" + else: + expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' + self.sendExpression(expr=expr_load_lib) + else: + raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " + f"{element} is of type {type(element)}, " + "The following patterns are supported:\n" + '1)["Modelica"]\n' + '2)[("Modelica","3.2.3"), "PowerSystems"]\n') + + def buildModel(self, variableFilter: Optional[str] = None): + filter_def: Optional[str] = None + if variableFilter is not None: + filter_def = variableFilter + elif self._variable_filter is not None: + filter_def = self._variable_filter + + if filter_def is not None: + var_filter = f'variableFilter="{filter_def}"' + else: + var_filter = 'variableFilter=".*"' + + build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) + logger.debug("OM model build result: %s", build_model_result) + + # check if the executable exists ... + self.check_model_executable() + + xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] + self._xmlparse(xml_file=xml_file) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Wrapper for OMCSession.sendExpression(). + """ + try: + retval = self._session.sendExpression(expr=expr, parsed=parsed) + except OMSessionException as ex: + raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex + + logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") + + return retval + + # request to OMC + def _requestApi( + self, + apiName: str, + entity: Optional[str] = None, + properties: Optional[str] = None, + ) -> Any: + if entity is not None and properties is not None: + expr = f'{apiName}({entity}, {properties})' + elif entity is not None and properties is None: + if apiName in ("loadFile", "importFMU"): + expr = f'{apiName}("{entity}")' + else: + expr = f'{apiName}({entity})' + else: + expr = f'{apiName}()' + + return self.sendExpression(expr=expr) + + def getContinuousFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (final) values of continuous signals (at stopTime). + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + >>> mod.getContinuousFinal() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuousFinal("x") + [np.float64(0.68)] + >>> mod.getContinuousFinal(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] + """ + if not self._simulated: + raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") + + def get_continuous_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._continuous: + value = self.getSolutions(name) + self._continuous[name] = np.float64(value[0][-1]) + else: + raise KeyError(f"{names} is not continuous") + + if names is None: + get_continuous_solution(name_list=list(self._continuous.keys())) + return self._continuous + + if isinstance(names, str): + get_continuous_solution(name_list=[names]) + return [self._continuous[names]] + + if isinstance(names, list): + get_continuous_solution(name_list=names) + values = [] + for name in names: + values.append(self._continuous[name]) + return values + + raise ModelicaSystemError("Unhandled input for getContinousFinal()") + + def getContinuous( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of continuous signals. + + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + Before simulate(): + >>> mod.getContinuous() + {'x': '1.0', 'der(x)': None, 'y': '-0.4'} + >>> mod.getContinuous("y") + ['-0.4'] + >>> mod.getContinuous(["y","x"]) + ['-0.4', '1.0'] + + After simulate(): + >>> mod.getContinuous() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuous("x") + [np.float64(0.68)] + >>> mod.getContinuous(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] + """ + if not self._simulated: + return self.getContinuousInitial(names=names) + + return self.getContinuousFinal(names=names) + + def getOutputsFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get (final) values of output signals (at stopTime). + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + >>> mod.getOutputsFinal() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputsFinal("out1") + [np.float64(-0.1234)] + >>> mod.getOutputsFinal(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] + """ + if not self._simulated: + raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") + + def get_outputs_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._outputs: + value = self.getSolutions(name) + self._outputs[name] = np.float64(value[0][-1]) + else: + raise KeyError(f"{names} is not a valid output") + + if names is None: + get_outputs_solution(name_list=list(self._outputs.keys())) + return self._outputs + + if isinstance(names, str): + get_outputs_solution(name_list=[names]) + return [self._outputs[names]] + + if isinstance(names, list): + get_outputs_solution(name_list=names) + values = [] + for name in names: + values.append(self._outputs[name]) + return values + + raise ModelicaSystemError("Unhandled input for getOutputs()") + + def getOutputs( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of output signals. + + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + Before simulate(): + >>> mod.getOutputs() + {'out1': '-0.4', 'out2': '1.2'} + >>> mod.getOutputs("out1") + ['-0.4'] + >>> mod.getOutputs(["out1","out2"]) + ['-0.4', '1.2'] + + After simulate(): + >>> mod.getOutputs() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputs("out1") + [np.float64(-0.1234)] + >>> mod.getOutputs(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] + """ + if not self._simulated: + return self.getOutputsInitial(names=names) + + return self.getOutputsFinal(names=names) + + def plot( + self, + plotdata: str, + resultfile: Optional[str | os.PathLike] = None, + ) -> None: + """ + Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the + plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. + """ + + if not isinstance(self._session, OMCSessionLocal): + raise ModelicaSystemError("Plot is using the OMC plot functionality; " + "thus, it is only working if OMC is running locally!") + + if resultfile is not None: + plot_result_file = self._session.omcpath(resultfile) + elif self._result_file is not None: + plot_result_file = self._result_file + else: + raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " + "or provide a result file!") + + if not plot_result_file.is_file(): + raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") + + expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' + self.sendExpression(expr=expr) + + def getSolutions( + self, + varList: Optional[str | list[str]] = None, + resultfile: Optional[str | os.PathLike] = None, + ) -> tuple[str, ...] | np.ndarray: + """Extract simulation results from a result data file. + + Args: + varList: Names of variables to be extracted. Either unspecified to + get names of available variables, or a single variable name + as a string, or a list of variable names. + resultfile: Path to the result file. If unspecified, the result + file created by simulate() is used. + + Returns: + If varList is None, a tuple with names of all variables + is returned. + If varList is a string, a 1D numpy array is returned. + If varList is a list, a 2D numpy array is returned. + + Examples: + >>> mod.getSolutions() + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"]) + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + >>> mod.getSolutions(resultfile="c:/a.mat") + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x", resultfile="c:/a.mat") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + """ + if resultfile is None: + if self._result_file is None: + raise ModelicaSystemError("No result file found. Run simulate() first.") + result_file = self._result_file + else: + result_file = self._session.omcpath(resultfile) + + # check if the result file exits + if not result_file.is_file(): + raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") + + # get absolute path + result_file = result_file.absolute() + + result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') + self.sendExpression(expr="closeSimulationResultFile()") + if varList is None: + 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(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') + np_res = np.array(res) + self.sendExpression(expr="closeSimulationResultFile()") + return np_res + + def convertMo2Fmu( + self, + version: str = "2.0", + fmuType: str = "me_cs", + fileNamePrefix: Optional[str] = None, + includeResources: bool = True, + ) -> OMPathABC: + """Translate the model into a Functional Mockup Unit. + + Args: + See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html + + Returns: + str: Path to the created '*.fmu' file. + + Examples: + >>> mod.convertMo2Fmu() + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", + includeResources=True) + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + """ + + if fileNamePrefix is None: + if self._model_name is None: + fileNamePrefix = "" + else: + fileNamePrefix = self._model_name + include_resources_str = "true" if includeResources else "false" + + properties = (f'version="{version}", fmuType="{fmuType}", ' + f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') + fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) + fmu_path = self._session.omcpath(fmu) + + # report proper error message + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + return fmu_path + + # to convert FMU to Modelica model + def convertFmu2Mo( + self, + fmu: os.PathLike, + ) -> OMPathABC: + """ + In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate + Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". + Currently, it only supports Model Exchange conversion. + usage + >>> convertFmu2Mo("c:/BouncingBall.Fmu") + """ + + fmu_path = self._session.omcpath(fmu) + + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) + 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 diff --git a/OMPython/modelica_system_runner.py b/OMPython/modelica_system_runner.py new file mode 100644 index 00000000..6eb753ae --- /dev/null +++ b/OMPython/modelica_system_runner.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +""" +Definition of main class to run Modelica simulations - ModelicaSystem. +""" + +import logging +import os +from typing import Optional + +from OMPython.om_session_abc import ( + OMSessionABC, +) +from OMPython.om_session_runner import ( + OMSessionRunner, +) +from OMPython.modelica_system_abc import ( + ModelicaSystemABC, + ModelicaSystemError, +) + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +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 OMCsessionRunner is used!") + + super().__init__( + work_directory=work_directory, + session=session, + ) + + def setup( + self, + model_name: Optional[str] = None, + variable_filter: Optional[str] = None, + ) -> None: + """ + Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists + within the working directory. At least two files are needed: + + * model executable (as '' or '.exe'; in case of Windows additional '.bat' + is expected to evaluate the path to needed dlls + * the model initialization file (as '_init.xml') + """ + + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + # set variables + self._model_name = model_name # Model class name + self._variable_filter = variable_filter + + # test if the model can be executed + self.check_model_executable() + + # read XML file + xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" + self._xmlparse(xml_file=xml_file) From 15d2686540887077eb05327bc9b46184db9b1dee Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 14 Feb 2026 11:37:37 +0100 Subject: [PATCH 03/20] (F001) cleanup after restructure [README.md] small updates [__init__] small updates --- OMPython/__init__.py | 7 ++++--- README.md | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 282923a7..78c8959e 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -6,7 +6,7 @@ ``` import OMPython omc = OMPython.OMCSessionLocal() -omc.sendExpression("command") +omc.sendExpression("getVersion()") ``` """ @@ -58,15 +58,16 @@ ModelicaDoERunner, ) +# the imports below are compatibility functionality (OMPython v4.0.0) from OMPython.ModelicaSystem import ( ModelicaSystem, - ModelicaSystemDoE, ModelicaSystemCmd, + ModelicaSystemDoE, ) from OMPython.OMCSession import ( OMCSessionCmd, - OMCSessionZMQ, OMCSessionException, + OMCSessionZMQ, OMCProcessLocal, OMCProcessPort, diff --git a/README.md b/README.md index a9cf3bdc..56730349 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ OMPython is a Python interface that uses ZeroMQ to communicate with OpenModelica ## Dependencies -- Python 3.x supported -- PyZMQ is required + - Python >= 3.10 supported with complete functionality for Python >= 3.12 + - Additional packages: numpy, psutil, pyparsing and pyzmq ## Installation @@ -49,8 +49,8 @@ help(OMPython) ``` ```python -from OMPython import OMCSessionLocal -omc = OMCSessionLocal() +import OMPython +omc = OMPython.OMCSessionLocal() omc.sendExpression("getVersion()") ``` From 055dda2d971a97124405bed551e6ceb9d6b65cf9 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 27 Jun 2026 19:02:33 +0200 Subject: [PATCH 04/20] rename classes * ModelExecutionData => ModelExecutionRun * ModelExecutionCmd => ModelExecutionConfig --- OMPython/ModelicaSystem.py | 4 ++-- OMPython/__init__.py | 8 ++++---- OMPython/model_execution.py | 8 ++++---- OMPython/modelica_doe_abc.py | 6 +++--- OMPython/modelica_system_abc.py | 12 ++++++------ tests/test_ModelExecutionCmd.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 17678bb0..96fbfaf6 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -12,7 +12,7 @@ import numpy as np from OMPython.model_execution import ( - ModelExecutionCmd, + ModelExecutionConfig, ModelExecutionException, ) from OMPython.om_session_omc import ( @@ -176,7 +176,7 @@ class ModelicaSystemDoE(ModelicaDoEOMC): """ -class ModelicaSystemCmd(ModelExecutionCmd): +class ModelicaSystemCmd(ModelExecutionConfig): """ Compatibility class; in the new version it is renamed as ModelExecutionCmd. """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 78c8959e..1ea0ed8a 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -12,8 +12,8 @@ """ from OMPython.model_execution import ( - ModelExecutionCmd, - ModelExecutionData, + ModelExecutionConfig, + ModelExecutionRun, ModelExecutionException, ) from OMPython.om_session_abc import ( @@ -81,8 +81,8 @@ 'LinearizationResult', - 'ModelExecutionCmd', - 'ModelExecutionData', + 'ModelExecutionConfig', + 'ModelExecutionRun', 'ModelExecutionException', 'ModelicaDoEABC', diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py index ebd4c011..ae1664ae 100644 --- a/OMPython/model_execution.py +++ b/OMPython/model_execution.py @@ -27,7 +27,7 @@ class ModelExecutionException(Exception): @dataclasses.dataclass -class ModelExecutionData: +class ModelExecutionRun: """ Data class to store the command line data for running a model executable in the OMC environment. @@ -105,7 +105,7 @@ def run(self) -> int: return returncode -class ModelExecutionCmd: +class ModelExecutionConfig: """ 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 @@ -261,7 +261,7 @@ def get_cmd_args(self) -> list[str]: return cmdl - def definition(self) -> ModelExecutionData: + def definition(self) -> ModelExecutionRun: """ Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. """ @@ -301,7 +301,7 @@ def definition(self) -> ModelExecutionData: if self._cmd_local: cmd_cwd_local = cmd_path.as_posix() - omc_run_data = ModelExecutionData( + omc_run_data = ModelExecutionRun( cmd_path=cmd_path.as_posix(), cmd_model_name=self._model_name, cmd_args=self.get_cmd_args(), diff --git a/OMPython/modelica_doe_abc.py b/OMPython/modelica_doe_abc.py index e3ab8403..0ab3add9 100644 --- a/OMPython/modelica_doe_abc.py +++ b/OMPython/modelica_doe_abc.py @@ -13,7 +13,7 @@ from typing import Any, cast, Optional, Tuple from OMPython.model_execution import ( - ModelExecutionData, + ModelExecutionRun, ) from OMPython.om_session_abc import ( OMPathABC, @@ -138,7 +138,7 @@ def __init__( self._parameters = {} self._doe_def: Optional[dict[str, dict[str, Any]]] = None - self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None + self._doe_cmd: Optional[dict[str, ModelExecutionRun]] = None def get_session(self) -> OMSessionABC: """ @@ -255,7 +255,7 @@ def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: """ return self._doe_def - def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: + def get_doe_command(self) -> Optional[dict[str, ModelExecutionRun]]: """ Get the definitions of simulations commands to run for this DoE. """ diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index fcc31deb..d37b0f44 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -17,7 +17,7 @@ import numpy as np from OMPython.model_execution import ( - ModelExecutionCmd, + ModelExecutionConfig, ) from OMPython.om_session_abc import ( OMPathABC, @@ -189,7 +189,7 @@ def check_model_executable(self): Check if the model executable is working """ # check if the executable exists ... - om_cmd = ModelExecutionCmd( + om_cmd = ModelExecutionConfig( runpath=self.getWorkDirectory(), cmd_local=self._session.model_execution_local, cmd_windows=self._session.model_execution_windows, @@ -579,7 +579,7 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, - om_cmd: ModelExecutionCmd, + om_cmd: ModelExecutionConfig, override_file: OMPathABC, override_var: dict[str, str], override_sim: dict[str, str], @@ -619,7 +619,7 @@ def simulate_cmd( result_file: OMPathABC, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelExecutionCmd: + ) -> ModelExecutionConfig: """ 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. @@ -641,7 +641,7 @@ def simulate_cmd( An instance if ModelicaSystemCmd to run the requested simulation. """ - om_cmd = ModelExecutionCmd( + om_cmd = ModelExecutionConfig( runpath=self.getWorkDirectory(), cmd_local=self._session.model_execution_local, cmd_windows=self._session.model_execution_windows, @@ -1134,7 +1134,7 @@ def linearize( "use ModelicaSystemOMC() to build the model first" ) - om_cmd = ModelExecutionCmd( + om_cmd = ModelExecutionConfig( runpath=self.getWorkDirectory(), cmd_local=self._session.model_execution_local, cmd_windows=self._session.model_execution_windows, diff --git a/tests/test_ModelExecutionCmd.py b/tests/test_ModelExecutionCmd.py index db5aadeb..71e96fc1 100644 --- a/tests/test_ModelExecutionCmd.py +++ b/tests/test_ModelExecutionCmd.py @@ -24,7 +24,7 @@ def mscmd_firstorder(model_firstorder): model_name="M", ) - mscmd = OMPython.ModelExecutionCmd( + mscmd = OMPython.ModelExecutionConfig( runpath=mod.getWorkDirectory(), cmd_local=mod.get_session().model_execution_local, cmd_windows=mod.get_session().model_execution_windows, From e30e2a8f3c7de396daa4d88178d47b8cd5a04b23 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 27 Jun 2026 19:03:00 +0200 Subject: [PATCH 05/20] update of docstrings for ModelExecutionRun and ModelExecutionConfig --- OMPython/model_execution.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py index ae1664ae..87fc6bdf 100644 --- a/OMPython/model_execution.py +++ b/OMPython/model_execution.py @@ -29,12 +29,11 @@ class ModelExecutionException(Exception): @dataclasses.dataclass class ModelExecutionRun: """ - Data class to store the command line data for running a model executable in the OMC environment. + Data class to store the command line data for running a model executable. This definition is independent of the OMC + environment as only the executable is needed. - All data should be defined for the environment, where OMC is running (local, docker or WSL) - - To use this as a definition of an OMC simulation run, it has to be processed within - OMCProcess*.self_update(). This defines the attribute cmd_model_executable. + All data should be defined for the environment, where the executable was defined / is located. This is especially + important if OMPython and the executable are defined in different environments (docker or WSL). """ # cmd_path is the expected working directory cmd_path: str @@ -107,9 +106,10 @@ def run(self) -> int: class ModelExecutionConfig: """ - All information about a compiled model executable. This should include data about all structured parameters, i.e. - parameters which need a recompilation of the model. All non-structured parameters can be easily changed without - the need for recompilation. + This class collects all information about a compiled model executable. This includes data about all structured + parameters, i.e. parameters which need a recompilation of the model. All non-structured parameters can be easily + changed without the need for recompilation. The final result is an instance of class ModelExecutionRun - a + definition to run one simulation based on the compiled model executable. """ def __init__( From aa4c437b4d29631a9b971399829fbb31945743a6 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 14:25:52 +0100 Subject: [PATCH 06/20] G001-pylint [pylint] fix 'R1729: Use a generator instead 'all(isinstance(item, tuple) for item in val_evaluated)' (use-a-generator)' [pylint] fix 'W0237: Parameter 'expr' has been renamed to 'command' in overriding 'OMCSessionZMQ.sendExpression' method (arguments-renamed)' [pylint] [OM*Path*] fix pylint messags about incompatible definitions --- OMPython/OMCSession.py | 8 ++++--- OMPython/modelica_system_abc.py | 2 +- OMPython/om_session_abc.py | 12 +++++----- OMPython/om_session_omc.py | 22 +++++++++++++----- OMPython/om_session_runner.py | 41 +++++++++++++++++++++++---------- 5 files changed, 57 insertions(+), 28 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index c5511923..ecc033f1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -282,12 +282,14 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC def execute(self, command: str): return self.omc_process.execute(command=command) - def sendExpression(self, command: str, parsed: bool = True) -> Any: + def sendExpression(self, command: str, parsed: bool = True) -> Any: # pylint: disable=W0237 """ Send an expression to the OMC server and return the result. - The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. - Caller should only check for OMSessionException. + The complete error handling of the OMC result is done within this method using 'getMessagesStringInternal()'. + Caller should only check for OMCSessionException. + + Compatibility: 'command' was renamed to 'expr' """ return self.omc_process.sendExpression(expr=command, parsed=parsed) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index d37b0f44..0f04e4df 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -1026,7 +1026,7 @@ def setInputs( self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), (float(self._simulate_options["stopTime"]), float(val))] elif isinstance(val_evaluated, list): - if not all([isinstance(item, tuple) for item in val_evaluated]): + if not all(isinstance(item, tuple) for item in val_evaluated): raise ModelicaSystemError("Value for setInput() must be in tuple format; " f"got {repr(val_evaluated)}") if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py index 70e897d7..fdfa5491 100644 --- a/OMPython/om_session_abc.py +++ b/OMPython/om_session_abc.py @@ -97,13 +97,13 @@ def with_segments(self, *pathsegments) -> OMPathABC: return type(self)(*pathsegments, session=self._session) @abc.abstractmethod - def is_file(self) -> bool: + def is_file(self, *, follow_symlinks=True) -> bool: """ Check if the path is a regular file. """ @abc.abstractmethod - def is_dir(self) -> bool: + def is_dir(self, *, follow_symlinks: bool = True) -> bool: """ Check if the path is a directory. """ @@ -115,19 +115,19 @@ def is_absolute(self) -> bool: """ @abc.abstractmethod - def read_text(self) -> str: + def read_text(self, encoding=None, errors=None, newline=None) -> str: """ Read the content of the file represented by this path as text. """ @abc.abstractmethod - def write_text(self, data: str) -> int: + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: """ Write text data to the file represented by this path. """ @abc.abstractmethod - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -137,7 +137,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: """ @abc.abstractmethod - def cwd(self) -> OMPathABC: + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase """ Returns the current working directory as an OMPathABC object. """ diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 6626cd17..4fafc3a1 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -52,19 +52,23 @@ class _OMCPath(OMPathABC): OMCSession* classes. """ - def is_file(self) -> bool: + def is_file(self, *, follow_symlinks=True) -> bool: """ Check if the path is a regular file. """ + del follow_symlinks + retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') if not isinstance(retval, bool): raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") return retval - def is_dir(self) -> bool: + def is_dir(self, *, follow_symlinks: bool = True) -> bool: """ Check if the path is a directory. """ + del follow_symlinks + retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') if not isinstance(retval, bool): raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") @@ -78,19 +82,23 @@ def is_absolute(self) -> bool: return pathlib.PureWindowsPath(self.as_posix()).is_absolute() return pathlib.PurePosixPath(self.as_posix()).is_absolute() - def read_text(self) -> str: + def read_text(self, encoding=None, errors=None, newline=None) -> str: """ Read the content of the file represented by this path as text. """ + del encoding, errors, newline + retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') if not isinstance(retval, str): raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") return retval - def write_text(self, data: str) -> int: + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: """ Write text data to the file represented by this path. """ + del encoding, errors, newline + if not isinstance(data, str): raise TypeError(f"data must be str, not {data.__class__.__name__}") @@ -99,7 +107,7 @@ def write_text(self, data: str) -> int: return len(data) - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -107,13 +115,15 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent directories are also created. """ + del mode + if self.is_dir() and not exist_ok: raise FileExistsError(f"Directory {self.as_posix()} already exists!") if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") - def cwd(self) -> OMPathABC: + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase """ Returns the current working directory as an OMPathABC object. """ diff --git a/OMPython/om_session_runner.py b/OMPython/om_session_runner.py index fc8e5ac8..b81c3ae1 100644 --- a/OMPython/om_session_runner.py +++ b/OMPython/om_session_runner.py @@ -49,16 +49,20 @@ class _OMPathRunnerLocal(OMPathRunnerABC): conversion via pathlib.Path(.as_posix()). """ - def is_file(self) -> bool: + def is_file(self, *, follow_symlinks=True) -> bool: """ Check if the path is a regular file. """ + del follow_symlinks + return self._path().is_file() - def is_dir(self) -> bool: + def is_dir(self, *, follow_symlinks: bool = True) -> bool: """ Check if the path is a directory. """ + del follow_symlinks + return self._path().is_dir() def is_absolute(self) -> bool: @@ -67,22 +71,26 @@ def is_absolute(self) -> bool: """ return self._path().is_absolute() - def read_text(self) -> str: + def read_text(self, encoding=None, errors=None, newline=None) -> str: """ Read the content of the file represented by this path as text. """ + del encoding, errors, newline + return self._path().read_text(encoding='utf-8') - def write_text(self, data: str): + def write_text(self, data: str, encoding=None, errors=None, newline=None): """ Write text data to the file represented by this path. """ + del encoding, errors, newline + 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: + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -90,9 +98,11 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent directories are also created. """ + del mode + self._path().mkdir(parents=parents, exist_ok=exist_ok) - def cwd(self) -> OMPathABC: + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase """ Returns the current working directory as an OMPathABC object. """ @@ -132,10 +142,12 @@ class _OMPathRunnerBash(OMPathRunnerABC): conversion via pathlib.Path(.as_posix()). """ - def is_file(self) -> bool: + def is_file(self, *, follow_symlinks=True) -> bool: """ Check if the path is a regular file. """ + del follow_symlinks + cmdl = self.get_session().get_cmd_prefix() cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] @@ -145,7 +157,7 @@ def is_file(self) -> bool: except subprocess.CalledProcessError: return False - def is_dir(self) -> bool: + def is_dir(self, *, follow_symlinks: bool = True) -> bool: """ Check if the path is a directory. """ @@ -172,10 +184,12 @@ def is_absolute(self) -> bool: except subprocess.CalledProcessError: return False - def read_text(self) -> str: + def read_text(self, encoding=None, errors=None, newline=None) -> str: """ Read the content of the file represented by this path as text. """ + del encoding, errors, newline + cmdl = self.get_session().get_cmd_prefix() cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] @@ -184,10 +198,12 @@ def read_text(self) -> str: return result.stdout.decode('utf-8') raise FileNotFoundError(f"Cannot read file: {self.as_posix()}") - def write_text(self, data: str) -> int: + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: """ Write text data to the file represented by this path. """ + del encoding, errors, newline + if not isinstance(data, str): raise TypeError(f"data must be str, not {data.__class__.__name__}") @@ -202,7 +218,7 @@ def write_text(self, data: str) -> int: except subprocess.CalledProcessError as exc: raise IOError(f"Error writing data to file {self.as_posix()}!") from exc - def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -210,6 +226,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent directories are also created. """ + del mode if self.is_file(): raise OSError(f"The given path {self.as_posix()} exists and is a file!") @@ -226,7 +243,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: except subprocess.CalledProcessError as exc: raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") from exc - def cwd(self) -> OMPathABC: + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase """ Returns the current working directory as an OMPathABC object. """ From 1a173d4344cc60a5e374a529859f094ebe58bd10 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 12:54:43 +0100 Subject: [PATCH 07/20] G002-bugfix [ModelExecutionException] catch exception if ModelExecutionCmd.run() is used [bugfix] [ModelicaSystem] fix exception; use ModelicaSystemError (instead of wrong ModelExecutionException) [bugfix] [ModelicaSystemABC] fix _prepare_input_data() - ensure returned data is dict[str, str] --- OMPython/ModelicaSystem.py | 15 ++++++++++----- OMPython/modelica_doe_abc.py | 5 +++-- OMPython/modelica_system_abc.py | 27 ++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 96fbfaf6..12028fb1 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -140,7 +140,7 @@ def getContinuous( retval3.append(str(val)) return retval3 - raise ModelExecutionException("Invalid data!") + raise ModelicaSystemError("Invalid data!") def getOutputs( self, @@ -167,7 +167,7 @@ def getOutputs( retval3.append(str(val)) return retval3 - raise ModelExecutionException("Invalid data!") + raise ModelicaSystemError("Invalid data!") class ModelicaSystemDoE(ModelicaDoEOMC): @@ -209,7 +209,8 @@ def get_exe(self) -> pathlib.Path: return path_exe def get_cmd(self) -> list: - """Get a list with the path to the executable and all command line args. + """ + 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(). """ @@ -218,6 +219,10 @@ def get_cmd(self) -> list: return cmdl - def run(self): + def run(self) -> int: cmd_definition = self.definition() - return cmd_definition.run() + try: + returncode = cmd_definition.run() + except ModelExecutionException as exc: + raise ModelicaSystemError(f"Cannot execute model: {exc}") from exc + return returncode diff --git a/OMPython/modelica_doe_abc.py b/OMPython/modelica_doe_abc.py index 0ab3add9..392253f0 100644 --- a/OMPython/modelica_doe_abc.py +++ b/OMPython/modelica_doe_abc.py @@ -14,6 +14,7 @@ from OMPython.model_execution import ( ModelExecutionRun, + ModelExecutionException, ) from OMPython.om_session_abc import ( OMPathABC, @@ -310,8 +311,8 @@ def worker(worker_id, task_queue): returncode = cmd_definition.run() logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " f"finished with return code: {returncode}") - except ModelicaSystemError as ex: - logger.warning(f"Simulation error for {resultpath.name}: {ex}") + except ModelExecutionException as exc: + logger.warning(f"Simulation error for {resultpath.name}: {exc}") # Mark the task as done task_queue.task_done() diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 0f04e4df..44bac274 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -18,6 +18,7 @@ from OMPython.model_execution import ( ModelExecutionConfig, + ModelExecutionException, ) from OMPython.om_session_abc import ( OMPathABC, @@ -200,7 +201,10 @@ def check_model_executable(self): # ... by running it - output help for command help om_cmd.arg_set(key="help", val="help") cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() + try: + returncode = cmd_definition.run() + except ModelExecutionException as exc: + raise ModelicaSystemError(f"Cannot execute model: {exc}") from exc if returncode != 0: raise ModelicaSystemError("Model executable not working!") @@ -736,7 +740,10 @@ def simulate( self._result_file.unlink() # ... run simulation ... cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() + try: + returncode = cmd_definition.run() + except ModelExecutionException as exc: + raise ModelicaSystemError(f"Cannot execute model: {exc}") from exc # 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 @@ -764,8 +771,10 @@ def prepare_str(str_in: str) -> dict[str, str]: key_val_list: list[str] = str_in.split("=") if len(key_val_list) != 2: raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") + if len(key_val_list[0]) == 0: + raise ModelicaSystemError(f"Empty key: {str_in}") - input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} + input_data_from_str: dict[str, str] = {str(key_val_list[0]): str(key_val_list[1])} return input_data_from_str @@ -791,7 +800,12 @@ def prepare_str(str_in: str) -> dict[str, str]: raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") input_data = input_data | prepare_str(item) elif isinstance(input_arg, dict): - input_data = input_data | input_arg + input_arg_str: dict[str, str] = {} + for key, val in input_arg.items(): + if not isinstance(key, str) or len(key) == 0: + raise ModelicaSystemError(f"Invalid key for set*() functions: {repr(key)}") + input_arg_str[key] = str(val) + input_data = input_data | input_arg_str else: raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") @@ -1180,7 +1194,10 @@ def linearize( linear_file.unlink(missing_ok=True) cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() + try: + returncode = cmd_definition.run() + except ModelExecutionException as exc: + raise ModelicaSystemError(f"Cannot execute model: {exc}") from exc if returncode != 0: raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") if not linear_file.is_file(): From 958b106e08e22540b911c08da8f8699a094e39ba Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 14:02:16 +0100 Subject: [PATCH 08/20] G003-compatibility [compatibility] add class wrapper to provide the depreciation message [ModelicaSystem] fix / improve wrapper functions for v4.0.0 compatibility [ModelicaSystemABC] additional checks for setInputs() [test_ModelicaSystemOMC] add tests for setInputs() [__init__] define ModelicaSystemDoE at the right point (=> compatibility layer) [__init__] remove duplicate 'OMCSessionABC' in __all__ --- OMPython/ModelicaSystem.py | 183 ++++++++++++++++++++++++++------ OMPython/OMCSession.py | 59 ++++++---- OMPython/__init__.py | 3 +- OMPython/compatibility_v400.py | 39 +++++++ OMPython/modelica_system_abc.py | 26 +++-- tests/test_ModelicaSystemOMC.py | 8 ++ 6 files changed, 257 insertions(+), 61 deletions(-) create mode 100644 OMPython/compatibility_v400.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 12028fb1..846f75ce 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -28,10 +28,15 @@ ModelicaDoEOMC, ) +from OMPython.compatibility_v400 import ( + depreciated_class, +) + # define logger using the current module name as ID logger = logging.getLogger(__name__) +@depreciated_class(msg="Please use class ModelicaSystemOMC instead!") class ModelicaSystem(ModelicaSystemOMC): """ Compatibility class. @@ -67,58 +72,167 @@ def __init__( def setCommandLineOptions(self, commandLineOptions: str): super().set_command_line_options(command_line_option=commandLineOptions) - def setContinuous( # type: ignore[override] + def _set_compatibility_helper( + self, + pkey: str, + args: Any, + kwargs: dict[str, Any], + ) -> Any: + param = None + if len(args) == 1: + param = args[0] + if param is None and pkey in kwargs: + param = kwargs[pkey] + + return param + + def setContinuous( self, - cvals: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(cvals, dict): - return super().setContinuous(**cvals) - raise ModelicaSystemError("Only dict input supported for setContinuous()") + """ + Compatibility wrapper for setContinuous() from OMPython v4.0.0 + + Original definition: - def setParameters( # type: ignore[override] + ``` + def setContinuous( + self, + cvals: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='cvals', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setContinuous() (v4.0.0 compatibility mode).") + + return super().setContinuous(param) + + def setParameters( self, - pvals: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(pvals, dict): - return super().setParameters(**pvals) - raise ModelicaSystemError("Only dict input supported for setParameters()") + """ + Compatibility wrapper for setParameters() from OMPython v4.0.0 + + Original definition: - def setOptimizationOptions( # type: ignore[override] + ``` + def setParameters( + self, + pvals: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='pvals', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setParameters() (v4.0.0 compatibility mode).") + + return super().setParameters(param) + + def setOptimizationOptions( self, - optimizationOptions: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(optimizationOptions, dict): - return super().setOptimizationOptions(**optimizationOptions) - raise ModelicaSystemError("Only dict input supported for setOptimizationOptions()") + """ + Compatibility wrapper for setOptimizationOptions() from OMPython v4.0.0 + + Original definition: - def setInputs( # type: ignore[override] + ``` + def setOptimizationOptions( + self, + optimizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='optimizationOptions', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setOptimizationOptions() (v4.0.0 compatibility mode).") + + return super().setOptimizationOptions(param) + + def setInputs( self, - name: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(name, dict): - return super().setInputs(**name) - raise ModelicaSystemError("Only dict input supported for setInputs()") + """ + Compatibility wrapper for setInputs() from OMPython v4.0.0 + + Original definition: - def setSimulationOptions( # type: ignore[override] + ``` + def setInputs( + self, + name: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='name', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setInputs() (v4.0.0 compatibility mode).") + + return super().setInputs(param) + + def setSimulationOptions( self, - simOptions: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(simOptions, dict): - return super().setSimulationOptions(**simOptions) - raise ModelicaSystemError("Only dict input supported for setSimulationOptions()") + """ + Compatibility wrapper for setSimulationOptions() from OMPython v4.0.0 + + Original definition: - def setLinearizationOptions( # type: ignore[override] + ``` + def setSimulationOptions( + self, + simOptions: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='simOptions', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setSimulationOptions() (v4.0.0 compatibility mode).") + + return super().setSimulationOptions(param) + + def setLinearizationOptions( self, - linearizationOptions: str | list[str] | dict[str, Any], + *args: Any, + **kwargs: dict[str, Any], ) -> bool: - if isinstance(linearizationOptions, dict): - return super().setLinearizationOptions(**linearizationOptions) - raise ModelicaSystemError("Only dict input supported for setLinearizationOptions()") + """ + Compatibility wrapper for setLinearizationOptions() from OMPython v4.0.0 + + Original definition: + + ``` + def setLinearizationOptions( + self, + linearizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + ``` + """ + param = self._set_compatibility_helper(pkey='linearizationOptions', args=args, kwargs=kwargs) + if param is None: + raise ModelicaSystemError("Invalid input for setLinearizationOptions() (v4.0.0 compatibility mode).") + + return super().setLinearizationOptions(param) def getContinuous( self, names: Optional[str | list[str]] = None, ): + """ + Compatibility wrapper for getContinuous() from OMPython v4.0.0 + + If no model simulation was run (self._simulated == False), the return value should be converted to str. + """ retval = super().getContinuous(names=names) if self._simulated: return retval @@ -146,6 +260,11 @@ def getOutputs( self, names: Optional[str | list[str]] = None, ): + """ + Compatibility wrapper for getOutputs() from OMPython v4.0.0 + + If no model simulation was run (self._simulated == False), the return value should be converted to str. + """ retval = super().getOutputs(names=names) if self._simulated: return retval @@ -170,15 +289,17 @@ def getOutputs( raise ModelicaSystemError("Invalid data!") +@depreciated_class(msg="Please use class ModelicaDoEOMC instead!") class ModelicaSystemDoE(ModelicaDoEOMC): """ Compatibility class. """ +@depreciated_class(msg="Please use class ModelExecutionConfig instead!") class ModelicaSystemCmd(ModelExecutionConfig): """ - Compatibility class; in the new version it is renamed as ModelExecutionCmd. + Compatibility class; in the new version it is renamed as ModelExecutionConfig. """ def __init__( diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index ecc033f1..b7a4c1dd 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -7,7 +7,6 @@ import logging from typing import Any, Optional -import warnings import pyparsing @@ -17,7 +16,6 @@ OMSessionException, ) from OMPython.om_session_omc import ( - DockerPopen, OMCSessionABC, OMCSessionDocker, OMCSessionDockerContainer, @@ -26,30 +24,28 @@ OMCSessionWSL, ) +from OMPython.compatibility_v400 import ( + depreciated_class, +) # define logger using the current module name as ID logger = logging.getLogger(__name__) +@depreciated_class(msg="Please use class OMSessionException instead!") class OMCSessionException(OMSessionException): """ Just a compatibility layer ... """ +@depreciated_class(msg="Please use OMCSession*.sendExpression(...) instead!") class OMCSessionCmd: """ Implementation of Open Modelica Compiler API functions. Depreciated! """ def __init__(self, session: OMSessionABC, readonly: bool = False): - warnings.warn( - message="The class OMCSessionCMD is depreciated and will be removed in future versions; " - "please use OMCSession*.sendExpression(...) instead!", - category=DeprecationWarning, - stacklevel=2, - ) - if not isinstance(session, OMSessionABC): raise OMCSessionException("Invalid OMC process definition!") self._session = session @@ -228,6 +224,7 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) +@depreciated_class(msg="Please use OMCSession* classes instead!") class OMCSessionZMQ(OMSessionABC): """ This class is a compatibility layer for the new schema using OMCSession* classes. @@ -242,11 +239,6 @@ def __init__( """ Initialisation for OMCSessionZMQ """ - 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): @@ -303,9 +295,36 @@ def set_workdir(self, workdir: OMPathABC) -> None: return self.omc_process.set_workdir(workdir=workdir) -DummyPopen = DockerPopen -OMCProcessLocal = OMCSessionLocal -OMCProcessPort = OMCSessionPort -OMCProcessDocker = OMCSessionDocker -OMCProcessDockerContainer = OMCSessionDockerContainer -OMCProcessWSL = OMCSessionWSL +@depreciated_class(msg="Please use class OMCSessionLocal instead!") +class OMCProcessLocal(OMCSessionLocal): + """ + Just a wrapper class; OMCProcessLocal => OMCSessionLocal + """ + + +@depreciated_class(msg="Please use class OMCSessionPort instead!") +class OMCProcessPort(OMCSessionPort): + """ + Just a wrapper class; OMCProcessPort => OMCSessionPort + """ + + +@depreciated_class(msg="Please use class OMCSessionDocker instead!") +class OMCProcessDocker(OMCSessionDocker): + """ + Just a wrapper class; OMCProcessDocker => OMCSessionDocker + """ + + +@depreciated_class(msg="Please use class OMCSessionDockerContainer instead!") +class OMCProcessDockerContainer(OMCSessionDockerContainer): + """ + Just a wrapper class; OMCProcessDockerContainer => OMCSessionDockerContainer + """ + + +@depreciated_class(msg="Please use class OMCSessionWSL instead!") +class OMCProcessWSL(OMCSessionWSL): + """ + Just a wrapper class; OMCProcessWSL => OMCSessionWSL + """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 1ea0ed8a..f3526da9 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -89,7 +89,6 @@ 'ModelicaDoEOMC', 'ModelicaDoERunner', 'ModelicaSystemABC', - 'ModelicaSystemDoE', 'ModelicaSystemError', 'ModelicaSystemOMC', 'ModelicaSystemRunner', @@ -112,8 +111,8 @@ 'ModelicaSystemCmd', 'ModelicaSystem', + 'ModelicaSystemDoE', - 'OMCSessionABC', 'OMCSessionCmd', 'OMCSessionException', diff --git a/OMPython/compatibility_v400.py b/OMPython/compatibility_v400.py new file mode 100644 index 00000000..61fa27a8 --- /dev/null +++ b/OMPython/compatibility_v400.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +Helper functions for compatibility with OMPython v4.0.0 +""" +import warnings +from typing import Optional + + +def depreciated_class(msg: Optional[str] = None): + """ + Decorator for depreciated / compatibility classes. + """ + + def depreciated(cls): + """ + Helper functions to do the decoration part. + """ + + class Wrapper(cls): + """ + Wrapper to define the depreciation message. + """ + + def __init__(self, *args, **kwargs): + message = f"The class {cls.__name__} is depreciated and will be removed in future versions!" + if msg is not None: + message += f" {msg}" + + warnings.warn( + message=message, + category=DeprecationWarning, + stacklevel=3, + ) + + super().__init__(*args, **kwargs) + + return Wrapper + + return depreciated diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 44bac274..4bfbb0b6 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -1035,7 +1035,6 @@ def setInputs( raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") val_evaluated = ast.literal_eval(val) - if isinstance(val_evaluated, (int, float)): self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), (float(self._simulate_options["stopTime"]), float(val))] @@ -1043,19 +1042,30 @@ def setInputs( if not all(isinstance(item, tuple) for item in val_evaluated): raise ModelicaSystemError("Value for setInput() must be in tuple format; " f"got {repr(val_evaluated)}") - if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): - raise ModelicaSystemError("Time value should be in increasing order; " - f"got {repr(val_evaluated)}") + val_evaluated_checked: list[tuple[float, float]] = [] for item in val_evaluated: - if item[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " - "than the simulation start time") if len(item) != 2: raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " "is in incorrect format!") - self._inputs[key] = val_evaluated + try: + val_evaluated_checked.append((float(item[0]), float(item[1]))) + except (ValueError, TypeError) as exc: + raise ModelicaSystemError("All elements of the input for setInput() should be convertible to " + "type Tuple[float, float] - " + f"found [{repr(item[0])}, {repr(item[1])}] with types " + f"[{type(item[0])}, {type(item[1])}]!") from exc + + if item[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " + "than the simulation start time") + + if val_evaluated_checked != sorted(val_evaluated_checked, key=lambda x: x[0]): + raise ModelicaSystemError("Time value should be in increasing order; " + f"got {repr(val_evaluated_checked)}") + + self._inputs[key] = val_evaluated_checked else: raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index c63b92e1..0b642089 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -439,6 +439,14 @@ def test_simulate_inputs(tmp_path): simOptions = {"stopTime": 1.0} mod.setSimulationOptions(**simOptions) + # check invalid inputs + # * 'None' cannot be converted to float + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(u1=[(0.0, None), (0.5, 1)]) + # * 'abc' cannot be converted to float + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(u1=[(0.0, 0.0), ("abc", 1)]) + # integrate zero (no setInputs call) - it should default to None -> 0 assert mod.getInputs() == { "u1": None, From 17a19b345cbb9baefbc5085ea04e3a8f11805010 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 18 Feb 2026 20:54:13 +0100 Subject: [PATCH 09/20] G004-remove_deprecated-ModelicaSystem_rewrite_set_functions2 [ModelicaSystemABC] remove code for (depreciated) arguments in set*() methods * define code in the compatibility layer in class ModelicaSystem [test_ModelicaSystem(OMC)] update tests * for new version: remove usage of old definition * for compatibility version: test old definition --- OMPython/ModelicaSystem.py | 92 ++++++++++++++++--------- OMPython/modelica_doe_abc.py | 2 +- OMPython/modelica_system_abc.py | 109 ++++++------------------------ tests/test_ModelicaSystemOMC.py | 6 +- tests_v400/test_ModelicaSystem.py | 2 +- 5 files changed, 85 insertions(+), 126 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 846f75ce..ce4f76ba 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -77,14 +77,62 @@ def _set_compatibility_helper( pkey: str, args: Any, kwargs: dict[str, Any], - ) -> Any: - param = None + ) -> dict[str, Any]: + input_args = [] if len(args) == 1: - param = args[0] - if param is None and pkey in kwargs: - param = kwargs[pkey] - - return param + input_args.append(args[0]) + elif pkey in kwargs: + input_args.append(kwargs[pkey]) + + # the code below is based on _prepare_input_data2() + + def prepare_str(str_in: str) -> dict[str, str]: + str_in = str_in.replace(" ", "") + key_val_list: list[str] = str_in.split("=") + if len(key_val_list) != 2: + raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") + if len(key_val_list[0]) == 0: + raise ModelicaSystemError(f"Empty key: {str_in}") + + input_data_from_str: dict[str, str] = {str(key_val_list[0]): str(key_val_list[1])} + + return input_data_from_str + + input_data: dict[str, str] = {} + + if input_args is None: + return input_data + + for input_arg in input_args: + if isinstance(input_arg, str): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + input_data = input_data | prepare_str(input_arg) + elif isinstance(input_arg, list): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + + for item in input_arg: + if not isinstance(item, str): + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") + input_data = input_data | prepare_str(item) + elif isinstance(input_arg, dict): + input_arg_str: dict[str, str] = {} + for key, val in input_arg.items(): + if not isinstance(key, str) or len(key) == 0: + raise ModelicaSystemError(f"Invalid key for set*() functions: {repr(key)}") + input_arg_str[key] = str(val).replace(' ', '') + input_data = input_data | input_arg_str + else: + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") + + return input_data def setContinuous( self, @@ -104,10 +152,7 @@ def setContinuous( ``` """ param = self._set_compatibility_helper(pkey='cvals', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setContinuous() (v4.0.0 compatibility mode).") - - return super().setContinuous(param) + return super().setContinuous(**param) def setParameters( self, @@ -127,10 +172,7 @@ def setParameters( ``` """ param = self._set_compatibility_helper(pkey='pvals', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setParameters() (v4.0.0 compatibility mode).") - - return super().setParameters(param) + return super().setParameters(**param) def setOptimizationOptions( self, @@ -150,10 +192,7 @@ def setOptimizationOptions( ``` """ param = self._set_compatibility_helper(pkey='optimizationOptions', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setOptimizationOptions() (v4.0.0 compatibility mode).") - - return super().setOptimizationOptions(param) + return super().setOptimizationOptions(**param) def setInputs( self, @@ -173,10 +212,7 @@ def setInputs( ``` """ param = self._set_compatibility_helper(pkey='name', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setInputs() (v4.0.0 compatibility mode).") - - return super().setInputs(param) + return super().setInputs(**param) def setSimulationOptions( self, @@ -196,10 +232,7 @@ def setSimulationOptions( ``` """ param = self._set_compatibility_helper(pkey='simOptions', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setSimulationOptions() (v4.0.0 compatibility mode).") - - return super().setSimulationOptions(param) + return super().setSimulationOptions(**param) def setLinearizationOptions( self, @@ -219,10 +252,7 @@ def setLinearizationOptions( ``` """ param = self._set_compatibility_helper(pkey='linearizationOptions', args=args, kwargs=kwargs) - if param is None: - raise ModelicaSystemError("Invalid input for setLinearizationOptions() (v4.0.0 compatibility mode).") - - return super().setLinearizationOptions(param) + return super().setLinearizationOptions(**param) def getContinuous( self, diff --git a/OMPython/modelica_doe_abc.py b/OMPython/modelica_doe_abc.py index 392253f0..062f8833 100644 --- a/OMPython/modelica_doe_abc.py +++ b/OMPython/modelica_doe_abc.py @@ -210,7 +210,7 @@ def prepare(self) -> int: } ) - self._mod.setParameters(sim_param_non_structural) + self._mod.setParameters(**sim_param_non_structural) mscmd = self._mod.simulate_cmd( result_file=resultfile, ) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 4bfbb0b6..f3e02ddf 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -11,7 +11,6 @@ import os import re from typing import Any, Optional -import warnings import xml.etree.ElementTree as ET import numpy as np @@ -759,56 +758,13 @@ def simulate( @staticmethod def _prepare_input_data( - input_args: Any, input_kwargs: dict[str, Any], ) -> dict[str, str]: """ Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. """ - - def prepare_str(str_in: str) -> dict[str, str]: - str_in = str_in.replace(" ", "") - key_val_list: list[str] = str_in.split("=") - if len(key_val_list) != 2: - raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - if len(key_val_list[0]) == 0: - raise ModelicaSystemError(f"Empty key: {str_in}") - - input_data_from_str: dict[str, str] = {str(key_val_list[0]): str(key_val_list[1])} - - return input_data_from_str - input_data: dict[str, str] = {} - for input_arg in input_args: - if isinstance(input_arg, str): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - input_data = input_data | prepare_str(input_arg) - elif isinstance(input_arg, list): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - - for item in input_arg: - if not isinstance(item, str): - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") - input_data = input_data | prepare_str(item) - elif isinstance(input_arg, dict): - input_arg_str: dict[str, str] = {} - for key, val in input_arg.items(): - if not isinstance(key, str) or len(key) == 0: - raise ModelicaSystemError(f"Invalid key for set*() functions: {repr(key)}") - input_arg_str[key] = str(val) - input_data = input_data | input_arg_str - else: - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") - if len(input_kwargs): for key, val in input_kwargs.items(): # ensure all values are strings to align it on one type: dict[str, str] @@ -886,21 +842,17 @@ def isParameterChangeable( def setContinuous( self, - *args: Any, **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set continuous values. It can be called: - with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: - usage - >>> setContinuous("Name=value") # depreciated - >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated + This method is used to set continuous values. + usage: >>> setContinuous(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setContinuous(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) return self._set_method_helper( inputdata=inputdata, @@ -910,21 +862,17 @@ def setContinuous( def setParameters( self, - *args: Any, **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set parameter values. It can be called: - with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: - usage - >>> setParameters("Name=value") # depreciated - >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated + This method is used to set parameter values + usage: >>> setParameters(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setParameters(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) return self._set_method_helper( inputdata=inputdata, @@ -934,22 +882,17 @@ def setParameters( def setSimulationOptions( self, - *args: Any, **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set simulation options. It can be called: - with a sequence of simulation options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setSimulationOptions("Name=value") # depreciated - >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated + This method is used to set simulation options. + usage: >>> setSimulationOptions(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setSimulationOptions(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) return self._set_method_helper( inputdata=inputdata, @@ -959,22 +902,17 @@ def setSimulationOptions( def setLinearizationOptions( self, - *args: Any, **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set linearization options. It can be called: - with a sequence of linearization options name and assigning corresponding value as arguments as show in the - example below - usage - >>> setLinearizationOptions("Name=value") # depreciated - >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated + This method is used to set linearization options. + usage: >>> setLinearizationOptions(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setLinearizationOptions(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) return self._set_method_helper( inputdata=inputdata, @@ -984,22 +922,17 @@ def setLinearizationOptions( def setOptimizationOptions( self, - *args: Any, **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set optimization options. It can be called: - with a sequence of optimization options name and assigning corresponding values as arguments as show in the - example below: - usage - >>> setOptimizationOptions("Name=value") # depreciated - >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated + This method is used to set optimization options. + usage: >>> setOptimizationOptions(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setOptimizationOptions(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) return self._set_method_helper( inputdata=inputdata, @@ -1013,19 +946,17 @@ def setInputs( **kwargs: dict[str, Any], ) -> bool: """ - This method is used to set input values. It can be called with a sequence of input name and assigning - corresponding values as arguments as show in the example below. Compared to other set*() methods this is a - special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() - and restored here via ast.literal_eval(). + This method is used to set input values. - >>> setInputs("Name=value") # depreciated - >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated + Compared to other set*() methods this is a special case as value could be a list of tuples - these are + converted to a string in _prepare_input_data() and restored here via ast.literal_eval(). + usage: >>> setInputs(Name1="value1", Name2="value2") >>> param = {"Name1": "value1", "Name2": "value2"} >>> setInputs(**param) """ - inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs) + inputdata = self._prepare_input_data(input_kwargs=kwargs) for key, val in inputdata.items(): if key not in self._inputs: diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index 0b642089..bff63315 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -64,9 +64,8 @@ def test_setParameters(): model_name="BouncingBall", ) - # method 1 (test depreciated variants) - mod.setParameters("e=1.234") - mod.setParameters(["g=321.0"]) + mod.setParameters(e=1.234) + mod.setParameters(g=321.0) assert mod.getParameters("e") == ["1.234"] assert mod.getParameters("g") == ["321.0"] assert mod.getParameters() == { @@ -76,7 +75,6 @@ def test_setParameters(): with pytest.raises(KeyError): mod.getParameters("thisParameterDoesNotExist") - # method 2 (new style) pvals = {"e": 21.3, "g": 0.12} mod.setParameters(**pvals) assert mod.getParameters() == { diff --git a/tests_v400/test_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py index c55e95fc..aa713af0 100644 --- a/tests_v400/test_ModelicaSystem.py +++ b/tests_v400/test_ModelicaSystem.py @@ -35,7 +35,7 @@ def test_setParameters(): mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") # method 1 - mod.setParameters(pvals={"e": 1.234}) + mod.setParameters(pvals="e=1.234") mod.setParameters(pvals={"g": 321.0}) assert mod.getParameters("e") == ["1.234"] assert mod.getParameters("g") == ["321.0"] From 17e049ebefcef0f1ccb8ff704ffe4974a41637e4 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 11 May 2026 21:11:55 +0200 Subject: [PATCH 10/20] add missing import for warnings --- OMPython/ModelicaSystem.py | 1 + 1 file changed, 1 insertion(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index ce4f76ba..067fed24 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -8,6 +8,7 @@ import pathlib import platform from typing import Any, Optional +import warnings import numpy as np From 47f0d274a4cffcbbb8193ee7e2f6a9d33a3aa4ac Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 16 Feb 2026 19:22:40 +0100 Subject: [PATCH 11/20] G005-remove_depreciated_functionality2 [OMCSessionABC] remove execute(); still available in compatibility v4.0.0 [ModelicaSystem] define _set_compatibility_helper() as static [ModelExecutionCmd] remove depreciated simflags [test_ModelSystemCmd/ModelExecutionCmd] fix test due to changes [ModelicaSystemCmd] cleanup - do not define (unused / not useable) class --- OMPython/ModelicaSystem.py | 149 +++++++++++++++++++++------ OMPython/OMCSession.py | 9 +- OMPython/__init__.py | 4 +- OMPython/model_execution.py | 43 -------- OMPython/modelica_system_abc.py | 20 ---- OMPython/om_session_omc.py | 11 -- tests/test_ModelExecutionCmd.py | 14 +-- tests/test_ZMQ.py | 8 +- tests_v400/test_ModelicaSystemCmd.py | 14 +-- 9 files changed, 145 insertions(+), 127 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 067fed24..514043d7 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -4,9 +4,9 @@ """ import logging +import numbers import os import pathlib -import platform from typing import Any, Optional import warnings @@ -16,10 +16,14 @@ ModelExecutionConfig, ModelExecutionException, ) +from OMPython.om_session_abc import ( + OMPathABC, +) from OMPython.om_session_omc import ( OMCSessionLocal, ) from OMPython.modelica_system_abc import ( + LinearizationResult, ModelicaSystemError, ) from OMPython.modelica_system_omc import ( @@ -73,8 +77,73 @@ def __init__( def setCommandLineOptions(self, commandLineOptions: str): super().set_command_line_options(command_line_option=commandLineOptions) - def _set_compatibility_helper( + def simulate_cmd( # type: ignore[override] + self, + result_file: OMPathABC, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> ModelExecutionCmd: + """ + Compatibility layer for OMPython v4.0.0 - keep simflags available and use ModelicaSystemCmd! + """ + + if simargs is None: + simargs = {} + + if simflags is not None: + simargs_extra = parse_simflags(simflags=simflags) + simargs = simargs | simargs_extra + + return super().simulate_cmd( + result_file=result_file, + simargs=simargs, + ) + + def simulate( # type: ignore[override] + self, + resultfile: Optional[str | os.PathLike] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> None: + """ + Compatibility layer for OMPython v4.0.0 - keep simflags available and use ModelicaSystemCmd! + """ + + if simargs is None: + simargs = {} + + if simflags is not None: + simargs_extra = parse_simflags(simflags=simflags) + simargs = simargs | simargs_extra + + return super().simulate( + resultfile=resultfile, + simargs=simargs, + ) + + def linearize( # type: ignore[override] self, + lintime: Optional[float] = None, + simflags: Optional[str] = None, + simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, + ) -> LinearizationResult: + """ + Compatibility layer for OMPython v4.0.0 - keep simflags available and use ModelicaSystemCmd! + """ + if simargs is None: + simargs = {} + + if simflags is not None: + simargs_extra = parse_simflags(simflags=simflags) + simargs = simargs | simargs_extra + + return super().linearize( + lintime=lintime, + simargs=simargs, + ) + + @staticmethod + def _set_compatibility_helper( pkey: str, args: Any, kwargs: dict[str, Any], @@ -330,7 +399,12 @@ class ModelicaSystemDoE(ModelicaDoEOMC): @depreciated_class(msg="Please use class ModelExecutionConfig instead!") class ModelicaSystemCmd(ModelExecutionConfig): """ - Compatibility class; in the new version it is renamed as ModelExecutionConfig. + Compatibility class; not much content. + + Missing definitions: + * get_exe() - see self.definition.cmd_model_executable + * get_cmd() - use self.get_cmd_args() or self.definition().get_cmd() + * run() - use self.definition().run() """ def __init__( @@ -346,35 +420,44 @@ def __init__( model_name=modelname, ) - def get_exe(self) -> pathlib.Path: - """Get the path to the compiled model executable.""" - - 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 parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: + """ + Parse a simflag definition; this is deprecated! - def run(self) -> int: - cmd_definition = self.definition() - try: - returncode = cmd_definition.run() - except ModelExecutionException as exc: - raise ModelicaSystemError(f"Cannot execute model: {exc}") from exc - return returncode + The return data can be used as input for self.args_set(). + """ + warnings.warn( + message="The argument 'simflags' is depreciated and will be removed in future versions; " + "please use 'simargs' instead", + category=DeprecationWarning, + stacklevel=2, + ) + + simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} + + args = [s for s in simflags.split(' ') if s] + for arg in args: + if arg[0] != '-': + raise ModelExecutionException(f"Invalid simulation flag: {arg}") + arg = arg[1:] + parts = arg.split('=') + if len(parts) == 1: + simargs[parts[0]] = None + elif parts[0] == 'override': + override = '='.join(parts[1:]) + + override_dict = {} + for item in override.split(','): + kv = item.split('=') + if not 0 < len(kv) < 3: + raise ModelExecutionException(f"Invalid value for '-override': {override}") + if kv[0]: + try: + override_dict[kv[0]] = kv[1] + except (KeyError, IndexError) as ex: + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex + + simargs[parts[0]] = override_dict + + return simargs diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index b7a4c1dd..35e1271a 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -7,6 +7,7 @@ import logging from typing import Any, Optional +import warnings import pyparsing @@ -272,7 +273,13 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) def execute(self, command: str): - return self.omc_process.execute(command=command) + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) + return self.omc_process.sendExpression(expr=command, parsed=False) def sendExpression(self, command: str, parsed: bool = True) -> Any: # pylint: disable=W0237 """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index f3526da9..848421c5 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -61,8 +61,8 @@ # the imports below are compatibility functionality (OMPython v4.0.0) from OMPython.ModelicaSystem import ( ModelicaSystem, - ModelicaSystemCmd, ModelicaSystemDoE, + parse_simflags, ) from OMPython.OMCSession import ( OMCSessionCmd, @@ -109,9 +109,9 @@ 'OMPathRunnerLocal', 'OMSessionRunner', - 'ModelicaSystemCmd', 'ModelicaSystem', 'ModelicaSystemDoE', + 'parse_simflags', 'OMCSessionCmd', diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py index 87fc6bdf..d3b344d1 100644 --- a/OMPython/model_execution.py +++ b/OMPython/model_execution.py @@ -12,7 +12,6 @@ import re import subprocess from typing import Any, Optional -import warnings # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -314,45 +313,3 @@ def definition(self) -> ModelExecutionRun: ) return omc_run_data - - @staticmethod - def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: - """ - Parse a simflag definition; this is deprecated! - - The return data can be used as input for self.args_set(). - """ - warnings.warn( - message="The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", - category=DeprecationWarning, - stacklevel=2, - ) - - simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} - - args = [s for s in simflags.split(' ') if s] - for arg in args: - if arg[0] != '-': - raise ModelExecutionException(f"Invalid simulation flag: {arg}") - arg = arg[1:] - parts = arg.split('=') - if len(parts) == 1: - simargs[parts[0]] = None - elif parts[0] == 'override': - override = '='.join(parts[1:]) - - override_dict = {} - for item in override.split(','): - kv = item.split('=') - if not 0 < len(kv) < 3: - raise ModelExecutionException(f"Invalid value for '-override': {override}") - if kv[0]: - try: - override_dict[kv[0]] = kv[1] - except (KeyError, IndexError) as ex: - raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex - - simargs[parts[0]] = override_dict - - return simargs diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index f3e02ddf..6ddb8f5d 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -620,7 +620,6 @@ def _process_override_data( def simulate_cmd( self, result_file: OMPathABC, - simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> ModelExecutionConfig: """ @@ -636,7 +635,6 @@ def simulate_cmd( Parameters ---------- result_file - simflags simargs Returns @@ -656,10 +654,6 @@ def simulate_cmd( # always define the result file to use om_cmd.arg_set(key="r", val=result_file.as_posix()) - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - if simargs: om_cmd.args_set(args=simargs) @@ -693,7 +687,6 @@ def simulate_cmd( def simulate( self, resultfile: Optional[str | os.PathLike] = None, - simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> None: """Simulate the model according to simulation options. @@ -702,16 +695,11 @@ def simulate( Args: resultfile: Path to a custom result file - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. simargs: Dict with simulation runtime flags. Examples: mod.simulate() mod.simulate(resultfile="a.mat") - # set runtime simulation flags, deprecated - mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10") - # using simargs mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}}) """ @@ -730,7 +718,6 @@ def simulate( om_cmd = self.simulate_cmd( result_file=self._result_file, - simflags=simflags, simargs=simargs, ) @@ -1060,7 +1047,6 @@ def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: def linearize( self, lintime: Optional[float] = None, - simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> LinearizationResult: """Linearize the model according to linearization options. @@ -1069,8 +1055,6 @@ def linearize( Args: lintime: Override "stopTime" value. - simflags: String of extra command line flags for the model binary. - This argument is deprecated, use simargs instead. simargs: A dict with command line flags and possible options; example: "simargs={'csvInput': 'a.csv'}" Returns: @@ -1123,10 +1107,6 @@ def linearize( f"<= lintime <= {self._linearization_options['stopTime']}") om_cmd.arg_set(key="l", val=str(lintime)) - # allow runtime simulation flags from user input - if simflags is not None: - om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags)) - if simargs: om_cmd.args_set(args=simargs) diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 4fafc3a1..52d63b55 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -21,7 +21,6 @@ import time from typing import Any, Optional, Tuple import uuid -import warnings import psutil import pyparsing @@ -387,16 +386,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self._tempdir(tempdir_base=tempdir_base) - def execute(self, command: str): - warnings.warn( - message="This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", - category=DeprecationWarning, - stacklevel=2, - ) - - return self.sendExpression(command, parsed=False) - def sendExpression(self, expr: str, parsed: bool = True) -> Any: """ Send an expression to the OMC server and return the result. diff --git a/tests/test_ModelExecutionCmd.py b/tests/test_ModelExecutionCmd.py index 71e96fc1..19111070 100644 --- a/tests/test_ModelExecutionCmd.py +++ b/tests/test_ModelExecutionCmd.py @@ -38,17 +38,19 @@ def mscmd_firstorder(model_firstorder): def test_simflags(mscmd_firstorder): mscmd = mscmd_firstorder - mscmd.args_set({ + mscmd.args_set(args={ + "override": { + 'b': 2, + 'a': 4, + }, + "noRestart": None, "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_args() == [ '-noEventEmit', '-noRestart', - '-override=a=1,b=2,x=3', + '-override=a=4,b=2', ] mscmd.args_set({ @@ -58,5 +60,5 @@ def test_simflags(mscmd_firstorder): assert mscmd.get_cmd_args() == [ '-noEventEmit', '-noRestart', - '-override=a=1,x=3', + '-override=a=4', ] diff --git a/tests/test_ZMQ.py b/tests/test_ZMQ.py index 89a8387b..1ba62cb9 100644 --- a/tests/test_ZMQ.py +++ b/tests/test_ZMQ.py @@ -38,14 +38,12 @@ def test_Simulate(omcs, model_time_str): assert omcs.sendExpression('res.resultFile') -def test_execute(omcs): - with pytest.deprecated_call(): - assert omcs.execute('"HelloWorld!"') == '"HelloWorld!"\n' +def test_sendExpression(omcs): assert omcs.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' assert omcs.sendExpression('"HelloWorld!"', parsed=True) == 'HelloWorld!' -def test_omcprocessport_execute(omcs): +def test_sendExpression_port(omcs): port = omcs.get_port() omcs2 = OMPython.OMCSessionPort(omc_port=port) @@ -58,7 +56,7 @@ def test_omcprocessport_execute(omcs): del omcs2 -def test_omcprocessport_simulate(omcs, model_time_str): +def test_Simulate_port(omcs, model_time_str): port = omcs.get_port() omcs2 = OMPython.OMCSessionPort(omc_port=port) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py index 3544a1bd..75116894 100644 --- a/tests_v400/test_ModelicaSystemCmd.py +++ b/tests_v400/test_ModelicaSystemCmd.py @@ -18,7 +18,11 @@ def model_firstorder(tmp_path): @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) + mscmd = OMPython.ModelExecutionCmd( + runpath=mod.getWorkDirectory(), + model_name=mod._model_name, + cmd_prefix=[], + ) return mscmd @@ -30,10 +34,9 @@ def test_simflags(mscmd_firstorder): "override": {'b': 2} }) with pytest.deprecated_call(): - mscmd.args_set(args=mscmd.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) + mscmd.args_set(args=OMPython.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) - assert mscmd.get_cmd() == [ - mscmd.get_exe().as_posix(), + assert mscmd.get_cmd_args() == [ '-noEventEmit', '-noRestart', '-override=a=1,b=2,x=3', @@ -43,8 +46,7 @@ def test_simflags(mscmd_firstorder): "override": {'b': None}, }) - assert mscmd.get_cmd() == [ - mscmd.get_exe().as_posix(), + assert mscmd.get_cmd_args() == [ '-noEventEmit', '-noRestart', '-override=a=1,x=3', From ac0c31a54988731a8de7c8ab12b887d270e5dbdf Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 27 Jun 2026 19:03:00 +0200 Subject: [PATCH 12/20] fix missing class rename (ModelExecutionCmd => ModelExecutionConfig) --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 514043d7..3f15c372 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -82,7 +82,7 @@ def simulate_cmd( # type: ignore[override] result_file: OMPathABC, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelExecutionCmd: + ) -> ModelExecutionConfig: """ Compatibility layer for OMPython v4.0.0 - keep simflags available and use ModelicaSystemCmd! """ From 4892088057df350b3811433d33d4df0df6a396b5 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 27 Jun 2026 20:28:41 +0200 Subject: [PATCH 13/20] another fix - missing class rename (ModelExecutionCmd => ModelExecutionConfig) --- tests_v400/test_ModelicaSystemCmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py index 75116894..0f4535fd 100644 --- a/tests_v400/test_ModelicaSystemCmd.py +++ b/tests_v400/test_ModelicaSystemCmd.py @@ -18,7 +18,7 @@ def model_firstorder(tmp_path): @pytest.fixture def mscmd_firstorder(model_firstorder): mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") - mscmd = OMPython.ModelExecutionCmd( + mscmd = OMPython.ModelExecutionConfig( runpath=mod.getWorkDirectory(), model_name=mod._model_name, cmd_prefix=[], From 2107aff24d6f211c3af7164205a9b069a5d2e910 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 6 Mar 2026 19:27:38 +0100 Subject: [PATCH 14/20] [ModelicaSystemABC] check OM version - force the version used by the model executable --- OMPython/modelica_system_abc.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 6ddb8f5d..ea0a90a8 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -216,6 +216,15 @@ def _xmlparse(self, xml_file: OMPathABC): root = tree.getroot() if root is None: raise ModelicaSystemError(f"Cannot read XML file: {xml_file}") + # check OM version - force the version used by the model executable + if 'generationTool' in root.attrib: + generation_tool_version = self._parse_om_version(version=root.attrib['generationTool']) + if self._version != generation_tool_version: + logger.warning(f"Mismatch in OpenModelica version: {self._version!r} (OMSession) " + f"vs. {generation_tool_version!r} (model executable) " + f"- using {generation_tool_version!r}!") + self._version = generation_tool_version + for attr in root.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", "solver", "outputFormat"): From 2e01584f32940539d1e94749df5bbabea6eaf68e Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 6 Mar 2026 20:13:57 +0100 Subject: [PATCH 15/20] [ModelicaSystemABC] define setInputCSV() - function to define input based on the content of a CSV file --- OMPython/modelica_system_abc.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index ea0a90a8..72665fd4 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -5,6 +5,7 @@ import abc import ast +import csv from dataclasses import dataclass import logging import numbers @@ -998,6 +999,44 @@ def setInputs( return True + def setInputsCSV( + self, + csvfile: os.PathLike, + ) -> None: + """ + Read content from a CSV file and use it to define the time based input data. + """ + + # real type is 'dict[str, list[tuple[float, float]]]' - 'dict[str, Any]' is used to make setInputs() happy + inputs: dict[str, Any] = {} + try: + with open(csvfile, newline='') as csvfh: + dialect = csv.Sniffer().sniff(csvfh.read(1024)) + csvfh.seek(0) + reader = csv.DictReader(csvfh, dialect=dialect) + + keys: list[str] = [] + for idx, line in enumerate(reader): + if not keys: + keys = list(line.keys()) + for var in keys[1:]: + if var in inputs: + raise ModelicaSystemError(f"Error reading {csvfile}: duplicated column {var}!") + inputs[var] = [] + try: + # use key[0] as time; all other columns use the header as name + for var in keys[1:]: + inputs[var].append((float(line[keys[0]]), float(line[var]))) + except (ValueError, TypeError) as exc2: + raise ModelicaSystemError(f"Invalid value reading {csvfile} line {idx}/{var}: " + f"{line}!") from exc2 + + except IOError as exc1: + raise ModelicaSystemError(f"Error reading {csvfile}: {exc1}") from exc1 + + if inputs: + self.setInputs(**inputs) + def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: """ Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, From f7d2e6821132a325c0deb2d94f14b2fb625bc06a Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 1 Apr 2026 22:55:31 +0200 Subject: [PATCH 16/20] add toInputs() - convert pandas DataFrame.to_dict(orient='list') output to OMPython input based on code written by joewa (see https://github.com/OpenModelica/OMPython/pull/447#issuecomment-4101449288) --- OMPython/modelica_system_abc.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 72665fd4..15261141 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -937,6 +937,29 @@ def setOptimizationOptions( datatype="optimization-option", overridedata=None) + @staticmethod + def toInputs(data: dict[str, list[float]]) -> dict[str, list[tuple[float, float]]]: + """ + Converts a dictionary of lists (from pandas DataFrame.to_dict(orient='list')) + into the OMPython setInputs input format. + + Example: mod.setInputs(**toInputs(pdf.to_dict(orient='list'))) + + Assumes the dictionary contains a key named 'time'. + """ + if "time" not in data: + raise ValueError("The provided data must contain a 'time' key.") + + time_series = data["time"] + + inputs = { + var_name: list(zip(time_series, values)) + for var_name, values in data.items() + if var_name != "time" + } + + return inputs + def setInputs( self, *args: Any, From 1610f3f52104d6e087e58035c8fe0c0dfc91b800 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:18:23 +0200 Subject: [PATCH 17/20] update handling of variable_filter * use public function `ModelicaSystemABC.set_variable_filter()` to define * process it in `ModelicaSystemABC._process_override_data()` as command line argument --- OMPython/modelica_system_abc.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 15261141..23d40abc 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -596,12 +596,20 @@ def _process_override_data( override_file: OMPathABC, override_var: dict[str, str], override_sim: dict[str, str], + variable_filter: Optional[str] = None, ) -> None: """ Define the override parameters. As the definition of simulation specific override parameter changes with OM 1.26.0, version specific code is needed. Please keep in mind, that this will fail if OMC is not used to run the model executable. + + Including also override of variable filter settings. """ + + # define variable filter if defined (override any original setting) + if variable_filter is not None: + om_cmd.arg_set(key="variableFilter", val=variable_filter) + if len(override_var) == 0 and len(override_sim) == 0: return @@ -937,6 +945,29 @@ def setOptimizationOptions( datatype="optimization-option", overridedata=None) + def set_variable_filter( + self, + variable_filter: Optional[str] = None, + escape: bool = False, + ) -> None: + """ + This method is used to set variable filters. If escape is True, all regex special characters are escaped. + """ + if variable_filter is None: + self._variable_filter = None + return + + if escape: + variable_filter = re.escape(variable_filter) + + # Validate filter_val as a regular expression + try: + re.compile(variable_filter) + except re.error as exc: + raise ModelicaSystemError(f"Invalid variable_filter regular expression: {variable_filter!r} ({exc})") + + self._variable_filter = variable_filter + @staticmethod def toInputs(data: dict[str, list[float]]) -> dict[str, list[tuple[float, float]]]: """ From ee8a3cf8eb375b02025e77c0030cacf6cd27caa4 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:19:26 +0200 Subject: [PATCH 18/20] use new processing for variable_filter --- OMPython/modelica_system_abc.py | 2 ++ OMPython/modelica_system_omc.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/OMPython/modelica_system_abc.py b/OMPython/modelica_system_abc.py index 23d40abc..41a67205 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -680,6 +680,7 @@ def simulate_cmd( override_file=result_file.parent / f"{result_file.stem}_override.txt", override_var=self._override_variables, override_sim=self._simulate_options_override, + variable_filter=self._variable_filter, ) if self._inputs: # if model has input quantities @@ -1189,6 +1190,7 @@ def linearize( override_file=self.getWorkDirectory() / f'{self._model_name}_override_linear.txt', override_var=self._override_variables, override_sim=self._linearization_options, + variable_filter=self._variable_filter, ) if self._inputs: diff --git a/OMPython/modelica_system_omc.py b/OMPython/modelica_system_omc.py index 34805e0f..4e0f14ce 100644 --- a/OMPython/modelica_system_omc.py +++ b/OMPython/modelica_system_omc.py @@ -126,7 +126,7 @@ def model( # 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 + self.set_variable_filter(variable_filter=variable_filter, escape=True) if self._libraries: self._loadLibrary(libraries=self._libraries) From 715765ed0b874c3c5b1e94946ce2a0df12766e80 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:19:39 +0200 Subject: [PATCH 19/20] add unittest test_variable_filter() --- tests/test_ModelicaSystemOMC.py | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index bff63315..a5e996c2 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -206,6 +206,53 @@ def _run_getSolutions(mod): assert np.isclose(x, x_analytical, rtol=1e-4).all() +def test_variable_filter(model_firstorder): + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + a = -1 + tau = -1 / a + stopTime = 5 * tau + + simOptions = {"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8} + mod.setSimulationOptions(**simOptions) + mod.simulate() + sol_names1 = mod.getSolutions() + assert isinstance(sol_names1, tuple) + assert sol_names1 == ('a', 'der(x)', 'time', 'x') + + mod.set_variable_filter(variable_filter='x') + mod.setSimulationOptions(stopTime=2.0) + mod.simulate() + sol_names2 = mod.getSolutions() + assert isinstance(sol_names2, tuple) + assert sol_names2 == ('a', 'time', 'x') + + mod.set_variable_filter(variable_filter='der(x)') + mod.setSimulationOptions(stopTime=3.0) + mod.simulate() + sol_names3 = mod.getSolutions() + assert isinstance(sol_names3, tuple) + assert sol_names3 == ('a', 'time') + + mod.set_variable_filter(variable_filter='der(x)', escape=True) + mod.setSimulationOptions(stopTime=3.0) + mod.simulate() + sol_names4 = mod.getSolutions() + assert isinstance(sol_names4, tuple) + assert sol_names4 == ('a', 'der(x)', 'time') + + mod.set_variable_filter(variable_filter='a') + mod.setSimulationOptions(stopTime=2.0) + mod.simulate() + sol_names5 = mod.getSolutions() + assert isinstance(sol_names5, tuple) + assert sol_names5 == ('a', 'time') + + def test_getters(tmp_path): model_file = tmp_path / "M_getters.mo" model_file.write_text(""" From 3112e32e794f730de54484bee4af1388bb872246 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 22:32:41 +0100 Subject: [PATCH 20/20] remove OMPathCompatibility - update needed Python version to 3.12 --- .github/workflows/Test.yml | 12 +- OMPython/om_session_abc.py | 227 +++++++---------- OMPython/om_session_omc.py | 287 +++++++++++---------- OMPython/om_session_runner.py | 456 +++++++++++++++++----------------- README.md | 2 +- 5 files changed, 462 insertions(+), 522 deletions(-) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index bf12fff7..ea65c9ba 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -2,7 +2,7 @@ name: Test-Publish on: push: - branches: ['master'] + branches: [ 'master' ] tags: - 'v*' # only publish when pushing version tags (e.g., v1.0.0) pull_request: @@ -22,13 +22,13 @@ jobs: # test for: # * oldest supported version # * latest available Python version - python-version: ['3.10', '3.14'] + python-version: [ '3.12', '3.14' ] # * Linux using ubuntu-latest # * Windows using windows-latest - os: ['ubuntu-latest', 'windows-latest'] + os: [ 'ubuntu-latest', 'windows-latest' ] # * OM stable - latest stable version # * OM nightly - latest nightly build - omc-version: ['stable', 'nightly'] + omc-version: [ 'stable', 'nightly' ] steps: - uses: actions/checkout@v6 @@ -98,8 +98,8 @@ jobs: needs: test strategy: matrix: - python-version: ['3.10'] - os: ['ubuntu-latest'] + python-version: [ '3.12' ] + os: [ 'ubuntu-latest' ] if: startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v6 diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py index fdfa5491..385ab3be 100644 --- a/OMPython/om_session_abc.py +++ b/OMPython/om_session_abc.py @@ -7,10 +7,8 @@ import abc import logging -import os import pathlib import platform -import sys from typing import Any, Optional import uuid @@ -26,151 +24,110 @@ class OMSessionException(Exception): """ -# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if -# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. -# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible -if sys.version_info < (3, 12): - class _OMPathCompatibility(pathlib.Path): +class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): + """ + 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. + + 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. + """ + + def __init__(self, *path, session: OMSessionABC) -> None: + super().__init__(*path) + self._session = session + + def get_session(self) -> OMSessionABC: + """ + Get session definition used for this instance of OMPath. + """ + return self._session + + def with_segments(self, *pathsegments) -> OMPathABC: """ - Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly - ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + Create a new OMCPath object with the given path segments. + + The original definition of Path is overridden to ensure the session data is set. """ + return type(self)(*pathsegments, session=self._session) - # modified copy of pathlib.Path.__new__() definition - def __new__(cls, *args, **kwargs): - logger.warning("Python < 3.12 - using a version of class OMCPath " - "based on pathlib.Path for local usage only.") + @abc.abstractmethod + def is_file(self, *, follow_symlinks=True) -> bool: + """ + Check if the path is a regular file. + """ - if cls is _OMPathCompatibility: - cls = _OMPathCompatibilityWindows if os.name == 'nt' else _OMPathCompatibilityPosix - self = cls._from_parts(args) - if not self._flavour.is_supported: - raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") - return self + @abc.abstractmethod + def is_dir(self, *, follow_symlinks: bool = True) -> bool: + """ + Check if the path is a directory. + """ - def size(self) -> int: - """ - Needed compatibility function to have the same interface as OMCPathReal - """ - return self.stat().st_size + @abc.abstractmethod + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ - class _OMPathCompatibilityPosix(pathlib.PosixPath, _OMPathCompatibility): + @abc.abstractmethod + def read_text(self, encoding=None, errors=None, newline=None) -> str: """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) + Read the content of the file represented by this path as text. """ - class _OMPathCompatibilityWindows(pathlib.WindowsPath, _OMPathCompatibility): + @abc.abstractmethod + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) + Write text data to the file represented by this path. + """ + + @abc.abstractmethod + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ + Create a directory at the path represented by this class. - OMPathABC = _OMPathCompatibility - -else: - class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): - """ - 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. - - 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. - """ - - def __init__(self, *path, session: OMSessionABC) -> None: - super().__init__(*path) - self._session = session - - def get_session(self) -> OMSessionABC: - """ - Get session definition used for this instance of OMPath. - """ - return self._session - - def with_segments(self, *pathsegments) -> OMPathABC: - """ - Create a new OMCPath object with the given path segments. - - The original definition of Path is overridden to ensure the session data is set. - """ - return type(self)(*pathsegments, session=self._session) - - @abc.abstractmethod - def is_file(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a regular file. - """ - - @abc.abstractmethod - def is_dir(self, *, follow_symlinks: bool = True) -> bool: - """ - Check if the path is a directory. - """ - - @abc.abstractmethod - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - - @abc.abstractmethod - def read_text(self, encoding=None, errors=None, newline=None) -> str: - """ - Read the content of the file represented by this path as text. - """ - - @abc.abstractmethod - def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: - """ - Write text data to the file represented by this path. - """ - - @abc.abstractmethod - def mkdir(self, mode=0o777, parents: bool = False, 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. - """ - - @abc.abstractmethod - def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase - """ - Returns the current working directory as an OMPathABC object. - """ - - @abc.abstractmethod - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - - @abc.abstractmethod - def resolve(self, strict: bool = False) -> 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. - """ + 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. + """ + + @abc.abstractmethod + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase + """ + Returns the current working directory as an OMPathABC object. + """ + + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + @abc.abstractmethod + def resolve(self, strict: bool = False) -> 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. + """ class PostInitCaller(type): diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py index 52d63b55..b2698181 100644 --- a/OMPython/om_session_omc.py +++ b/OMPython/om_session_omc.py @@ -38,161 +38,154 @@ # define logger using the current module name as ID logger = logging.getLogger(__name__) -# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if -# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. -# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible -if sys.version_info < (3, 12): - OMCPath = OMPathABC - -else: - class _OMCPath(OMPathABC): + + +class OMCPath(OMPathABC): + """ + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. + """ + + def is_file(self, *, follow_symlinks=True) -> bool: + """ + Check if the path is a regular file. + """ + del follow_symlinks + + retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + return retval + + def is_dir(self, *, follow_symlinks: bool = True) -> bool: + """ + Check if the path is a directory. + """ + del follow_symlinks + + retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") + return retval + + def is_absolute(self) -> bool: """ - Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an - OMCSession* classes. + Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. + """ + if self._session.model_execution_windows and self._session.model_execution_local: + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return pathlib.PurePosixPath(self.as_posix()).is_absolute() + + def read_text(self, encoding=None, errors=None, newline=None) -> str: + """ + Read the content of the file represented by this path as text. + """ + del encoding, errors, newline + + retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') + if not isinstance(retval, str): + raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") + return retval + + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: + """ + Write text data to the file represented by this path. + """ + del encoding, errors, newline + + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + data_omc = self._session.escape_str(data) + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') + + return len(data) + + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: """ + Create a directory at the path represented by this class. - def is_file(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a regular file. - """ - del follow_symlinks - - retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") - return retval - - def is_dir(self, *, follow_symlinks: bool = True) -> bool: - """ - Check if the path is a directory. - """ - del follow_symlinks - - retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') - if not isinstance(retval, bool): - raise OMSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") - return retval - - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. - """ - if self._session.model_execution_windows and self._session.model_execution_local: - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return pathlib.PurePosixPath(self.as_posix()).is_absolute() - - def read_text(self, encoding=None, errors=None, newline=None) -> str: - """ - Read the content of the file represented by this path as text. - """ - del encoding, errors, newline - - retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') + 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. + """ + del mode + + if self.is_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") + + if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") + + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return type(self)(cwd_str, session=self._session) + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') + if not res and not missing_ok: + raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + + def resolve(self, strict: bool = False) -> 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 OMSessionException(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 OMSessionException(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 OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + + return omcpath_resolved + + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + + try: + retval = self.get_session().sendExpression(expr=expr, parsed=False) if not isinstance(retval, str): - raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") - return retval - - def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: - """ - Write text data to the file represented by this path. - """ - del encoding, errors, newline - - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") - - data_omc = self._session.escape_str(data) - self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') - - return len(data) - - def mkdir(self, mode=0o777, parents: bool = False, 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. - """ - del mode - - if self.is_dir() and not exist_ok: - raise FileExistsError(f"Directory {self.as_posix()} already exists!") - - if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): - raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") - - def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase - """ - Returns the current working directory as an OMPathABC object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return type(self)(cwd_str, session=self._session) - - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') - if not res and not missing_ok: - raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") - - def resolve(self, strict: bool = False) -> 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 OMSessionException(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 OMSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + raise OMSessionException(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 OMSessionException as ex: + raise OMSessionException(f"OMCPath resolve failed for {pathstr}!") from ex - if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): - raise OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + return pathstr_resolved - return omcpath_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 OMSessionException(f"Path {self.as_posix()} is not a file!") - def _omc_resolve(self, pathstr: str) -> str: - """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. - """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) - try: - retval = self.get_session().sendExpression(expr=expr, parsed=False) - if not isinstance(retval, str): - raise OMSessionException(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 OMSessionException as ex: - raise OMSessionException(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 OMSessionException(f"Path {self.as_posix()} is not a file!") - - res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) - - raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") - - OMCPath = _OMCPath + raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): diff --git a/OMPython/om_session_runner.py b/OMPython/om_session_runner.py index b81c3ae1..3bd3abeb 100644 --- a/OMPython/om_session_runner.py +++ b/OMPython/om_session_runner.py @@ -9,7 +9,6 @@ import logging import pathlib import subprocess -import sys import tempfile from typing import Any, Optional, Type @@ -22,292 +21,283 @@ # define logger using the current module name as ID logger = logging.getLogger(__name__) -# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if -# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. -# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible -if sys.version_info < (3, 12): - OMPathRunnerABC = OMPathABC - OMPathRunnerLocal = OMPathABC - OMPathRunnerBash = OMPathABC -else: - class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): +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 is_file(self, *, follow_symlinks=True) -> bool: """ - Base function for OMPath definitions *without* OMC server + Check if the path is a regular file. """ + del follow_symlinks - def _path(self) -> pathlib.Path: - return pathlib.Path(self.as_posix()) + return self._path().is_file() - class _OMPathRunnerLocal(OMPathRunnerABC): + def is_dir(self, *, follow_symlinks: bool = True) -> bool: + """ + Check if the path is a directory. """ - Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run - locally without any usage of OMC. + del follow_symlinks - 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()). + return self._path().is_dir() + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. """ + return self._path().is_absolute() - def is_file(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a regular file. - """ - del follow_symlinks + def read_text(self, encoding=None, errors=None, newline=None) -> str: + """ + Read the content of the file represented by this path as text. + """ + del encoding, errors, newline - return self._path().is_file() + return self._path().read_text(encoding='utf-8') - def is_dir(self, *, follow_symlinks: bool = True) -> bool: - """ - Check if the path is a directory. - """ - del follow_symlinks + def write_text(self, data: str, encoding=None, errors=None, newline=None): + """ + Write text data to the file represented by this path. + """ + del encoding, errors, newline - return self._path().is_dir() + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ - return self._path().is_absolute() + return self._path().write_text(data=data, encoding='utf-8') - def read_text(self, encoding=None, errors=None, newline=None) -> str: - """ - Read the content of the file represented by this path as text. - """ - del encoding, errors, newline + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. - return self._path().read_text(encoding='utf-8') + 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. + """ + del mode - def write_text(self, data: str, encoding=None, errors=None, newline=None): - """ - Write text data to the file represented by this path. - """ - del encoding, errors, newline + self._path().mkdir(parents=parents, exist_ok=exist_ok) - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase + """ + Returns the current working directory as an OMPathABC object. + """ + return type(self)(self._path().cwd().as_posix(), session=self._session) - return self._path().write_text(data=data, encoding='utf-8') + 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 mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: - """ - Create a directory at the path represented by this class. + 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) - 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. - """ - del mode + def size(self) -> int: + """ + Get the size of the file in bytes - implementation based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") - self._path().mkdir(parents=parents, exist_ok=exist_ok) + path = self._path() + return path.stat().st_size - def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase - """ - 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) +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. - 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) + 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 size(self) -> int: - """ - Get the size of the file in bytes - implementation based on pathlib.Path. - """ - if not self.is_file(): - raise OMSessionException(f"Path {self.as_posix()} is not a file!") + def is_file(self, *, follow_symlinks=True) -> bool: + """ + Check if the path is a regular file. + """ + del follow_symlinks + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] - path = self._path() - return path.stat().st_size + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False - class _OMPathRunnerBash(OMPathRunnerABC): + def is_dir(self, *, follow_symlinks: bool = True) -> bool: """ - 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. + 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 - 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_absolute(self) -> bool: + """ + Check if the path is an absolute path. """ - def is_file(self, *, follow_symlinks=True) -> bool: - """ - Check if the path is a regular file. - """ - del follow_symlinks + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] + try: + subprocess.check_call(cmdl) + return True + except subprocess.CalledProcessError: + return False - try: - subprocess.run(cmdl, check=True) - return True - except subprocess.CalledProcessError: - return False - - def is_dir(self, *, follow_symlinks: bool = True) -> bool: - """ - Check if the path is a directory. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'test -d "{self.as_posix()}"'] + def read_text(self, encoding=None, errors=None, newline=None) -> str: + """ + Read the content of the file represented by this path as text. + """ + del encoding, errors, newline - try: - subprocess.run(cmdl, check=True) - return True - except subprocess.CalledProcessError: - return False + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] - def is_absolute(self) -> bool: - """ - Check if the path is an absolute path. - """ + 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()}") - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: + """ + Write text data to the file represented by this path. + """ + del encoding, errors, newline - try: - subprocess.check_call(cmdl) - return True - except subprocess.CalledProcessError: - return False + 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()}"'] - def read_text(self, encoding=None, errors=None, newline=None) -> str: - """ - Read the content of the file represented by this path as text. - """ - del encoding, errors, newline + try: + subprocess.run(cmdl, check=True) + return len(data) + except subprocess.CalledProcessError as exc: + raise IOError(f"Error writing data to file {self.as_posix()}!") from exc - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] + def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False) -> None: + """ + Create a directory at the path represented by this class. - 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()}") + 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. + """ + del mode - def write_text(self, data: str, encoding=None, errors=None, newline=None) -> int: - """ - Write text data to the file represented by this path. - """ - del encoding, errors, newline + 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!") - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'mkdir -p "{self.as_posix()}"'] - data_escape = self._session.escape_str(data) + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") from exc - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'printf %s "{data_escape}" > "{self.as_posix()}"'] + def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase + """ + Returns the current working directory as an OMPathABC object. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', 'pwd'] - try: - subprocess.run(cmdl, check=True) - return len(data) - except subprocess.CalledProcessError as exc: - raise IOError(f"Error writing data to file {self.as_posix()}!") from exc - - def mkdir(self, mode=0o777, parents: bool = False, 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. - """ - del mode - - 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()}"'] + 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 ...") - try: - subprocess.run(cmdl, check=True) - except subprocess.CalledProcessError as exc: - raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") from exc + 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()}!") - def cwd(self) -> OMPathABC: # pylint: disable=W0221 # is @classmethod in the original; see pathlib.PathBase - """ - Returns the current working directory as an OMPathABC object. - """ - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', 'pwd'] + if not self.is_file(): + return - 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 ...") + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OSError(f"Cannot unlink file {self.as_posix()}: {exc}") from exc - if not self.is_file(): - raise OSError(f"Can not unlink a directory: {self.as_posix()}!") + 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()}"'] - if not self.is_file(): - return + 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 based on pathlib.Path. + """ + if not self.is_file(): + raise OMSessionException(f"Path {self.as_posix()} is not a file!") - cmdl = self.get_session().get_cmd_prefix() - cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] + 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: - 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 based on pathlib.Path. - """ - if not self.is_file(): - raise OMSessionException(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 file size ({self.as_posix()}): {stdout}") from exc - else: - raise OSError(f"Cannot get size for file {self.as_posix()}") - - OMPathRunnerLocal = _OMPathRunnerLocal - OMPathRunnerBash = _OMPathRunnerBash + return int(stdout) + except ValueError as exc: + raise OSError(f"Invalid return value for file size ({self.as_posix()}): {stdout}") from exc + else: + raise OSError(f"Cannot get size for file {self.as_posix()}") class OMSessionRunnerABC(OMSessionABC, metaclass=abc.ABCMeta): diff --git a/README.md b/README.md index 56730349..d65528fc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ OMPython is a Python interface that uses ZeroMQ to communicate with OpenModelica ## Dependencies - - Python >= 3.10 supported with complete functionality for Python >= 3.12 + - Python >= 3.12 - Additional packages: numpy, psutil, pyparsing and pyzmq ## Installation