From 25446a65e914cd6525231f1755bdbe65302cb028 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 19:17:48 +0100 Subject: [PATCH 01/34] (A014) [ModelExecution] update code for execution of a compiled model [ModelExecution*] create classes to handle model execution * rename ModelicaSystemCmd => ModelExecutionCmd * rename OMCSessionRunData => ModelExecutionData * create class ModelExecutionException * move some code: * OMCSession.omc_run_data_update() => merge into ModelExecutionCmd.define() * OMCSession.run_model_executable() => ModelExecutionData.run() [test_ModelicaSystemCmd] update unittest [ModelExecutionData] include the original exception if reraised as ModelExecutionException [ModelicaSystem] fix usage of ModelicaSystemCmd --- OMPython/ModelicaSystem.py | 154 +++++++++++------- OMPython/OMCSession.py | 273 +++++++++++--------------------- OMPython/__init__.py | 13 +- tests/test_ModelicaSystemCmd.py | 10 +- 4 files changed, 211 insertions(+), 239 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index f79f72a7..cbe23036 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -21,8 +21,10 @@ import numpy as np from OMPython.OMCSession import ( + ModelExecutionData, + ModelExecutionException, + OMCSessionException, - OMCSessionRunData, OMCSession, OMCSessionLocal, OMCPath, @@ -34,7 +36,7 @@ class ModelicaSystemError(Exception): """ - Exception used in ModelicaSystem and ModelicaSystemCmd classes. + Exception used in ModelicaSystem classes. """ @@ -89,7 +91,7 @@ def __getitem__(self, index: int): return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] -class ModelicaSystemCmd: +class ModelExecutionCmd: """ All information about a compiled model executable. This should include data about all structured parameters, i.e. parameters which need a recompilation of the model. All non-structured parameters can be easily changed without @@ -98,16 +100,22 @@ class ModelicaSystemCmd: def __init__( self, - session: OMCSession, - runpath: OMCPath, - modelname: Optional[str] = None, + runpath: os.PathLike, + cmd_prefix: list[str], + cmd_local: bool = False, + cmd_windows: bool = False, + timeout: float = 10.0, + model_name: Optional[str] = None, ) -> None: - if modelname is None: - raise ModelicaSystemError("Missing model name!") + if model_name is None: + raise ModelExecutionException("Missing model name!") - self._session = session - self._runpath = runpath - self._model_name = modelname + self._cmd_local = cmd_local + self._cmd_windows = cmd_windows + self._cmd_prefix = cmd_prefix + self._runpath = pathlib.PurePosixPath(runpath) + self._model_name = model_name + self._timeout = timeout # dictionaries of command line arguments for the model executable self._args: dict[str, str | None] = {} @@ -152,26 +160,26 @@ def override2str( elif isinstance(orval, numbers.Number): val_str = str(orval) else: - raise ModelicaSystemError(f"Invalid value for override key {orkey}: {type(orval)}") + raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") return f"{orkey}={val_str}" if not isinstance(key, str): - raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})") + raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") key = key.strip() if isinstance(val, dict): if key != 'override': - raise ModelicaSystemError("Dictionary input only possible for key 'override'!") + raise ModelExecutionException("Dictionary input only possible for key 'override'!") for okey, oval in val.items(): if not isinstance(okey, str): - raise ModelicaSystemError("Invalid key for argument 'override': " - f"{repr(okey)} (type: {type(okey)})") + raise ModelExecutionException("Invalid key for argument 'override': " + f"{repr(okey)} (type: {type(okey)})") if not isinstance(oval, (str, bool, numbers.Number, type(None))): - raise ModelicaSystemError(f"Invalid input for 'override'.{repr(okey)}: " - f"{repr(oval)} (type: {type(oval)})") + raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " + f"{repr(oval)} (type: {type(oval)})") if okey in self._arg_override: if oval is None: @@ -193,7 +201,7 @@ def override2str( elif isinstance(val, numbers.Number): argval = str(val) else: - raise ModelicaSystemError(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") + raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") if key in self._args: logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " @@ -233,7 +241,7 @@ def get_cmd_args(self) -> list[str]: return cmdl - def definition(self) -> OMCSessionRunData: + def definition(self) -> ModelExecutionData: """ Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. """ @@ -242,18 +250,50 @@ def definition(self) -> OMCSessionRunData: if not isinstance(result_file, str): result_file = (self._runpath / f"{self._model_name}.mat").as_posix() - omc_run_data = OMCSessionRunData( - cmd_path=self._runpath.as_posix(), + # as this is the local implementation, pathlib.Path can be used + cmd_path = self._runpath + + cmd_library_path = None + if self._cmd_local and self._cmd_windows: + cmd_library_path = "" + + # set the process environment from the generated .bat file in windows which should have all the dependencies + # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath + path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" + if not path_bat.is_file(): + raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) + + content = path_bat.read_text(encoding='utf-8') + for line in content.splitlines(): + match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) + if match: + cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons + my_env = os.environ.copy() + my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] + + cmd_model_executable = cmd_path / f"{self._model_name}.exe" + else: + # for Linux the paths to the needed libraries should be included in the executable (using rpath) + cmd_model_executable = cmd_path / self._model_name + + # define local(!) working directory + cmd_cwd_local = None + if self._cmd_local: + cmd_cwd_local = cmd_path.as_posix() + + omc_run_data = ModelExecutionData( + cmd_path=cmd_path.as_posix(), cmd_model_name=self._model_name, cmd_args=self.get_cmd_args(), - cmd_result_path=result_file, + cmd_result_file=result_file, + cmd_prefix=self._cmd_prefix, + cmd_library_path=cmd_library_path, + cmd_model_executable=cmd_model_executable.as_posix(), + cmd_cwd_local=cmd_cwd_local, + cmd_timeout=self._timeout, ) - omc_run_data_updated = self._session.omc_run_data_update( - omc_run_data=omc_run_data, - ) - - return omc_run_data_updated + return omc_run_data @staticmethod def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: @@ -262,17 +302,19 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | n The return data can be used as input for self.args_set(). """ - warnings.warn(message="The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", - category=DeprecationWarning, - stacklevel=2) + warnings.warn( + message="The argument 'simflags' is depreciated and will be removed in future versions; " + "please use 'simargs' instead", + category=DeprecationWarning, + stacklevel=2, + ) simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} args = [s for s in simflags.split(' ') if s] for arg in args: if arg[0] != '-': - raise ModelicaSystemError(f"Invalid simulation flag: {arg}") + raise ModelExecutionException(f"Invalid simulation flag: {arg}") arg = arg[1:] parts = arg.split('=') if len(parts) == 1: @@ -284,12 +326,12 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | n for item in override.split(','): kv = item.split('=') if not 0 < len(kv) < 3: - raise ModelicaSystemError(f"Invalid value for '-override': {override}") + raise ModelExecutionException(f"Invalid value for '-override': {override}") if kv[0]: try: override_dict[kv[0]] = kv[1] except (KeyError, IndexError) as ex: - raise ModelicaSystemError(f"Invalid value for '-override': {override}") from ex + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex simargs[parts[0]] = override_dict @@ -549,15 +591,17 @@ def buildModel(self, variableFilter: Optional[str] = None): logger.debug("OM model build result: %s", build_model_result) # check if the executable exists ... - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, ) # ... by running it - output help for command help om_cmd.arg_set(key="help", val="help") cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() if returncode != 0: raise ModelicaSystemError("Model executable not working!") @@ -1162,7 +1206,7 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, - om_cmd: ModelicaSystemCmd, + om_cmd: ModelExecutionCmd, override_file: OMCPath, override_var: dict[str, str], override_sim: dict[str, str], @@ -1198,7 +1242,7 @@ def simulate_cmd( result_file: OMCPath, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, - ) -> ModelicaSystemCmd: + ) -> ModelExecutionCmd: """ This method prepares the simulates model according to the simulation options. It returns an instance of ModelicaSystemCmd which can be used to run the simulation. @@ -1220,10 +1264,12 @@ def simulate_cmd( An instance if ModelicaSystemCmd to run the requested simulation. """ - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, ) # always define the result file to use @@ -1312,7 +1358,7 @@ def simulate( self._result_file.unlink() # ... run simulation ... cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() # and check returncode *AND* resultfile if returncode != 0 and self._result_file.is_file(): # check for an empty (=> 0B) result file which indicates a crash of the model executable @@ -1915,10 +1961,12 @@ def linearize( "use ModelicaSystem() to build the model first" ) - om_cmd = ModelicaSystemCmd( - session=self._session, + om_cmd = ModelExecutionCmd( runpath=self.getWorkDirectory(), - modelname=self._model_name, + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, ) self._process_override_data( @@ -1958,7 +2006,7 @@ def linearize( linear_file.unlink(missing_ok=True) cmd_definition = om_cmd.definition() - returncode = self._session.run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() if returncode != 0: raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") if not linear_file.is_file(): @@ -2129,7 +2177,7 @@ def __init__( self._parameters = {} self._doe_def: Optional[dict[str, dict[str, Any]]] = None - self._doe_cmd: Optional[dict[str, OMCSessionRunData]] = None + self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None def get_session(self) -> OMCSession: """ @@ -2248,7 +2296,7 @@ def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: """ return self._doe_def - def get_doe_command(self) -> Optional[dict[str, OMCSessionRunData]]: + def get_doe_command(self) -> Optional[dict[str, ModelExecutionData]]: """ Get the definitions of simulations commands to run for this DoE. """ @@ -2294,13 +2342,13 @@ def worker(worker_id, task_queue): if cmd_definition is None: raise ModelicaSystemError("Missing simulation definition!") - resultfile = cmd_definition.cmd_result_path + resultfile = cmd_definition.cmd_result_file resultpath = self.get_session().omcpath(resultfile) logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}") try: - returncode = self.get_session().run_model_executable(cmd_run_data=cmd_definition) + returncode = cmd_definition.run() logger.info(f"[Worker {worker_id}] Simulation {resultpath.name} " f"finished with return code: {returncode}") except ModelicaSystemError as ex: diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 4f4f81a8..b95f36c1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -451,31 +451,38 @@ class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): OMCPath = OMCPathReal +class ModelExecutionException(Exception): + """ + Exception which is raised by ModelException* classes. + """ + + @dataclasses.dataclass -class OMCSessionRunData: +class ModelExecutionData: """ Data class to store the command line data for running a model executable in the OMC environment. All data should be defined for the environment, where OMC is running (local, docker or WSL) To use this as a definition of an OMC simulation run, it has to be processed within - OMCProcess*.omc_run_data_update(). This defines the attribute cmd_model_executable. + OMCProcess*.self_update(). This defines the attribute cmd_model_executable. """ # cmd_path is the expected working directory cmd_path: str cmd_model_name: str + # command prefix data (as list of strings); needed for docker or WSL + cmd_prefix: list[str] + # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) + cmd_model_executable: str # command line arguments for the model executable cmd_args: list[str] # result file with the simulation output - cmd_result_path: str + cmd_result_file: str + # command timeout + cmd_timeout: float - # command prefix data (as list of strings); needed for docker or WSL - cmd_prefix: Optional[list[str]] = None - # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) - cmd_model_executable: Optional[str] = None # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows cmd_library_path: Optional[str] = None - # working directory to be used on the *local* system cmd_cwd_local: Optional[str] = None @@ -484,14 +491,49 @@ def get_cmd(self) -> list[str]: Get the command line to run the model executable in the environment defined by the OMCProcess definition. """ - if self.cmd_model_executable is None: - raise OMCSessionException("No model file defined for the model executable!") - - cmdl = [] if self.cmd_prefix is None else self.cmd_prefix - cmdl += [self.cmd_model_executable] + self.cmd_args + cmdl = self.cmd_prefix + cmdl += [self.cmd_model_executable] + cmdl += self.cmd_args return cmdl + def run(self) -> int: + """ + Run the model execution defined in this class. + """ + + my_env = os.environ.copy() + if isinstance(self.cmd_library_path, str): + my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] + + cmdl = self.get_cmd() + + logger.debug("Run OM command %s in %s", repr(cmdl), self.cmd_path) + try: + cmdres = subprocess.run( + cmdl, + capture_output=True, + text=True, + env=my_env, + cwd=self.cmd_cwd_local, + timeout=self.cmd_timeout, + check=True, + ) + stdout = cmdres.stdout.strip() + stderr = cmdres.stderr.strip() + returncode = cmdres.returncode + + logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) + + if stderr: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") + except subprocess.TimeoutExpired as ex: + raise ModelExecutionException(f"Timeout running model executable {repr(cmdl)}: {ex}") from ex + except subprocess.CalledProcessError as ex: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex + + return returncode + class OMCSessionZMQ: """ @@ -541,21 +583,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: """ return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Modify data based on the selected OMCProcess implementation. - - Needs to be implemented in the subclasses. - """ - return self.omc_process.omc_run_data_update(omc_run_data=omc_run_data) - - def run_model_executable(self, cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - return self.omc_process.run_model_executable(cmd_run_data=cmd_run_data) - def execute(self, command: str): return self.omc_process.execute(command=command) @@ -634,6 +661,10 @@ def __init__( Initialisation for OMCSession """ + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + # store variables self._timeout = timeout # generate a random string for this instance of OMC @@ -772,6 +803,13 @@ def set_workdir(self, workdir: OMCPath) -> None: exp = f'cd("{workdir.as_posix()}")' self.sendExpression(exp) + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ + + return [] + def omcpath(self, *path) -> OMCPath: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. @@ -790,7 +828,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. """ - names = [str(uuid.uuid4()) for _ in range(100)] if tempdir_base is None: # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement @@ -800,6 +837,12 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") tempdir_base = self.omcpath(tempdir_str) + return self._tempdir(tempdir_base=tempdir_base) + + @staticmethod + def _tempdir(tempdir_base: OMCPath) -> OMCPath: + names = [str(uuid.uuid4()) for _ in range(100)] + tempdir: Optional[OMCPath] = None for name in names: # create a unique temporary directory name @@ -816,43 +859,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: return tempdir - def run_model_executable(self, cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. - """ - - my_env = os.environ.copy() - if isinstance(cmd_run_data.cmd_library_path, str): - my_env["PATH"] = cmd_run_data.cmd_library_path + os.pathsep + my_env["PATH"] - - cmdl = cmd_run_data.get_cmd() - - logger.debug("Run OM command %s in %s", repr(cmdl), cmd_run_data.cmd_path) - try: - cmdres = subprocess.run( - cmdl, - capture_output=True, - text=True, - env=my_env, - cwd=cmd_run_data.cmd_cwd_local, - timeout=self._timeout, - check=True, - ) - stdout = cmdres.stdout.strip() - stderr = cmdres.stderr.strip() - returncode = cmdres.returncode - - logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - - if stderr: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}: {stderr}") - except subprocess.TimeoutExpired as ex: - raise OMCSessionException(f"Timeout running model executable {repr(cmdl)}") from ex - except subprocess.CalledProcessError as ex: - raise OMCSessionException(f"Error running model executable {repr(cmdl)}") from ex - - return returncode - def execute(self, command: str): warnings.warn( message="This function is depreciated and will be removed in future versions; " @@ -1031,18 +1037,6 @@ def _get_portfile_path(self) -> Optional[pathlib.Path]: return portfile_path - @abc.abstractmethod - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - - The main point is the definition of OMCSessionRunData.cmd_model_executable which contains the specific command - to run depending on the selected system. - - Needs to be implemented in the subclasses. - """ - raise NotImplementedError("This method must be implemented in subclasses!") - class OMCSessionPort(OMCSession): """ @@ -1056,28 +1050,6 @@ def __init__( super().__init__() self._omc_port = omc_port - @staticmethod - def run_model_executable(cmd_run_data: OMCSessionRunData) -> int: - """ - Run the command defined in cmd_run_data. This class is defined as static method such that there is no need to - keep instances of over classes around. - """ - raise OMCSessionException("OMCSessionPort does not support run_model_executable()!") - - def get_log(self) -> str: - """ - Get the log file content of the OMC session. - """ - log = f"No log available if OMC session is defined by port ({self.__class__.__name__})" - - return log - - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - raise OMCSessionException(f"({self.__class__.__name__}) does not support omc_run_data_update()!") - class OMCSessionLocal(OMCSession): """ @@ -1092,6 +1064,8 @@ def __init__( super().__init__(timeout=timeout) + self.model_execution_local = True + # where to find OpenModelica self._omhome = self._omc_home_get(omhome=omhome) # start up omc executable, which is waiting for the ZMQ connection @@ -1157,48 +1131,6 @@ def _omc_port_get(self) -> str: return port - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - # create a copy of the data - omc_run_data_copy = dataclasses.replace(omc_run_data) - - # as this is the local implementation, pathlib.Path can be used - cmd_path = pathlib.Path(omc_run_data_copy.cmd_path) - - if platform.system() == "Windows": - path_dll = "" - - # set the process environment from the generated .bat file in windows which should have all the dependencies - path_bat = cmd_path / f"{omc_run_data.cmd_model_name}.bat" - if not path_bat.is_file(): - raise OMCSessionException("Batch file (*.bat) does not exist " + str(path_bat)) - - content = path_bat.read_text(encoding='utf-8') - for line in content.splitlines(): - match = re.match(r"^SET PATH=([^%]*)", line, re.IGNORECASE) - if match: - path_dll = match.group(1).strip(';') # Remove any trailing semicolons - my_env = os.environ.copy() - my_env["PATH"] = path_dll + os.pathsep + my_env["PATH"] - - omc_run_data_copy.cmd_library_path = path_dll - - cmd_model_executable = cmd_path / f"{omc_run_data_copy.cmd_model_name}.exe" - else: - # for Linux the paths to the needed libraries should be included in the executable (using rpath) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - - if not cmd_model_executable.is_file(): - raise OMCSessionException(f"Application file path not found: {cmd_model_executable}") - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() - - # define local(!) working directory - omc_run_data_copy.cmd_cwd_local = omc_run_data.cmd_path - - return omc_run_data_copy - class OMCSessionDockerHelper(OMCSession): """ @@ -1311,27 +1243,21 @@ def get_docker_container_id(self) -> str: return self._docker_container_id - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. """ - omc_run_data_copy = dataclasses.replace(omc_run_data) - - omc_run_data_copy.cmd_prefix = ( - [ - "docker", "exec", - "--user", str(self._getuid()), - "--workdir", omc_run_data_copy.cmd_path, - ] - + self._docker_extra_args - + [self._docker_container_id] - ) - - cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() + docker_cmd = [ + "docker", "exec", + "--user", str(self._getuid()), + ] + if isinstance(cwd, OMCPath): + docker_cmd += ["--workdir", cwd.as_posix()] + docker_cmd += self._docker_extra_args + if isinstance(self._docker_container_id, str): + docker_cmd += [self._docker_container_id] - return omc_run_data_copy + return docker_cmd class OMCSessionDocker(OMCSessionDockerHelper): @@ -1594,15 +1520,18 @@ def __init__( # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get() - def _wsl_cmd(self, wsl_cwd: Optional[str] = None) -> list[str]: + def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + """ + Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. + """ # get wsl base command wsl_cmd = ['wsl'] if isinstance(self._wsl_distribution, str): wsl_cmd += ['--distribution', self._wsl_distribution] if isinstance(self._wsl_user, str): wsl_cmd += ['--user', self._wsl_user] - if isinstance(wsl_cwd, str): - wsl_cmd += ['--cd', wsl_cwd] + if isinstance(cwd, OMCPath): + wsl_cmd += ['--cd', cwd.as_posix()] wsl_cmd += ['--'] return wsl_cmd @@ -1610,7 +1539,7 @@ def _wsl_cmd(self, wsl_cwd: Optional[str] = None) -> list[str]: def _omc_process_get(self) -> subprocess.Popen: my_env = os.environ.copy() - omc_command = self._wsl_cmd() + [ + omc_command = self.model_execution_prefix() + [ self._wsl_omc, "--locale=C", "--interactive=zmq", @@ -1632,7 +1561,7 @@ def _omc_port_get(self) -> str: omc_portfile_path = self._get_portfile_path() if omc_portfile_path is not None: output = subprocess.check_output( - args=self._wsl_cmd() + ["cat", omc_portfile_path.as_posix()], + args=self.model_execution_prefix() + ["cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL, ) port = output.decode().strip() @@ -1649,17 +1578,3 @@ def _omc_port_get(self) -> str: f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") return port - - def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: - """ - Update the OMCSessionRunData object based on the selected OMCSession implementation. - """ - omc_run_data_copy = dataclasses.replace(omc_run_data) - - omc_run_data_copy.cmd_prefix = self._wsl_cmd(wsl_cwd=omc_run_data.cmd_path) - - cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) - cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name - omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() - - return omc_run_data_copy diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 6c07920b..7c199ef3 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -14,20 +14,23 @@ from OMPython.ModelicaSystem import ( LinearizationResult, ModelicaSystem, - ModelicaSystemCmd, + ModelExecutionCmd, ModelicaSystemDoE, ModelicaSystemError, ) from OMPython.OMCSession import ( OMCPath, OMCSession, + + ModelExecutionData, + ModelExecutionException, + OMCSessionCmd, OMCSessionDocker, OMCSessionDockerContainer, OMCSessionException, OMCSessionLocal, OMCSessionPort, - OMCSessionRunData, OMCSessionWSL, OMCSessionZMQ, ) @@ -36,8 +39,11 @@ __all__ = [ 'LinearizationResult', + 'ModelExecutionData', + 'ModelExecutionException', + 'ModelicaSystem', - 'ModelicaSystemCmd', + 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaSystemError', @@ -50,7 +56,6 @@ 'OMCSessionException', 'OMCSessionPort', 'OMCSessionLocal', - 'OMCSessionRunData', 'OMCSessionWSL', 'OMCSessionZMQ', ] diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 2480aad9..6fa2658f 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -23,11 +23,15 @@ def mscmd_firstorder(model_firstorder): model_file=model_firstorder, model_name="M", ) - mscmd = OMPython.ModelicaSystemCmd( - session=mod.get_session(), + + mscmd = OMPython.ModelExecutionCmd( runpath=mod.getWorkDirectory(), - modelname=mod._model_name, + cmd_local=mod.get_session().model_execution_local, + cmd_windows=mod.get_session().model_execution_windows, + cmd_prefix=mod.get_session().model_execution_prefix(cwd=mod.getWorkDirectory()), + model_name=mod._model_name, ) + return mscmd From 6c794cba710abd55550d8c1311b45b2b7b5327bb Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 20 Jan 2026 21:35:55 +0100 Subject: [PATCH 02/34] [ModelicaSystem] define check_model_executable() - test if the model existable exists and can be executed --- OMPython/ModelicaSystem.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index cbe23036..3af9970c 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -575,6 +575,25 @@ def getWorkDirectory(self) -> OMCPath: """ return self._work_dir + def check_model_executable(self): + """ + Check if the model executable is working + """ + # check if the executable exists ... + om_cmd = ModelExecutionCmd( + runpath=self.getWorkDirectory(), + cmd_local=self._session.model_execution_local, + cmd_windows=self._session.model_execution_windows, + cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), + model_name=self._model_name, + ) + # ... by running it - output help for command help + om_cmd.arg_set(key="help", val="help") + cmd_definition = om_cmd.definition() + returncode = cmd_definition.run() + if returncode != 0: + raise ModelicaSystemError("Model executable not working!") + def buildModel(self, variableFilter: Optional[str] = None): filter_def: Optional[str] = None if variableFilter is not None: @@ -591,19 +610,7 @@ def buildModel(self, variableFilter: Optional[str] = None): logger.debug("OM model build result: %s", build_model_result) # check if the executable exists ... - om_cmd = ModelExecutionCmd( - runpath=self.getWorkDirectory(), - cmd_local=self._session.model_execution_local, - cmd_windows=self._session.model_execution_windows, - cmd_prefix=self._session.model_execution_prefix(cwd=self.getWorkDirectory()), - model_name=self._model_name, - ) - # ... by running it - output help for command help - om_cmd.arg_set(key="help", val="help") - cmd_definition = om_cmd.definition() - returncode = cmd_definition.run() - if returncode != 0: - raise ModelicaSystemError("Model executable not working!") + self.check_model_executable() xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] self._xmlparse(xml_file=xml_file) From 59e6c9d9c85a880cdce939755afee4748b80febc Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 20:45:40 +0100 Subject: [PATCH 03/34] (B001) split ModelicaSystem [ModelicaSystem] split ModelicaSystem into ModelicaSystemABC and ModelicaSystem [ModelicaSystem] rename ModelicaSystem => ModelicaSystemOMC * add compatibility variable for ModelicaSystem [test_ModelicaSystemOMC] rename from ModelicaSystem and update [test_*] use ModelicaSystemOMC [ModelicaSystem*] fix last usages of ModelicaSystem() in comments & docstrings --- OMPython/ModelicaSystem.py | 1344 +++++++++-------- OMPython/__init__.py | 2 + tests/test_FMIExport.py | 4 +- tests/test_FMIImport.py | 4 +- tests/test_ModelicaSystemCmd.py | 2 +- tests/test_ModelicaSystemDoE.py | 6 +- ...icaSystem.py => test_ModelicaSystemOMC.py} | 22 +- tests/test_OMSessionCmd.py | 2 +- tests/test_linearization.py | 4 +- tests/test_optimization.py | 2 +- 10 files changed, 716 insertions(+), 676 deletions(-) rename tests/{test_ModelicaSystem.py => test_ModelicaSystemOMC.py} (96%) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3af9970c..83a6fd25 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -3,6 +3,7 @@ Definition of main class to run Modelica simulations - ModelicaSystem. """ +import abc import ast from dataclasses import dataclass import itertools @@ -338,28 +339,22 @@ def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | n return simargs -class ModelicaSystem: +class ModelicaSystemABC(metaclass=abc.ABCMeta): """ - Class to simulate a Modelica model using OpenModelica via OMCSession. + Base class to simulate a Modelica models. """ def __init__( self, - command_line_options: Optional[list[str]] = None, + session: OMCSession, work_directory: Optional[str | os.PathLike] = None, - omhome: Optional[str] = None, - session: Optional[OMCSession] = None, ) -> None: """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). Args: - command_line_options: List with extra command line options as elements. The list elements are - provided to omc via setCommandLineOptions(). If set, the default values will be overridden. - To disable any command line options, use an empty list. work_directory: Path to a directory to be used for temporary files like the model executable. If left unspecified, a tmp directory will be created. - omhome: path to OMC to be used when creating the OMC session (see OMCSession). session: definition of a (local) OMC session to be used. If unspecified, a new local session will be created. """ @@ -385,24 +380,11 @@ def __init__( self._linearized_outputs: list[str] = [] # linearization output list self._linearized_states: list[str] = [] # linearization states list - if session is not None: - self._session = session - else: - self._session = OMCSessionLocal(omhome=omhome) + self._session = session # get OpenModelica version version_str = self._session.get_version() self._version = self._parse_om_version(version=version_str) - # set commandLineOptions using default values or the user defined list - if command_line_options is None: - # set default command line options to improve the performance of linearization and to avoid recompilation if - # the simulation executable is reused in linearize() via the runtime flag '-l' - command_line_options = [ - "--linearizationDumpLanguage=python", - "--generateSymbolicLinearization", - ] - for opt in command_line_options: - self.set_command_line_options(command_line_option=opt) self._simulated = False # True if the model has already been simulated self._result_file: Optional[OMCPath] = None # for storing result file @@ -414,89 +396,6 @@ def __init__( self._file_name: Optional[OMCPath] = None self._variable_filter: Optional[str] = None - def model( - self, - model_name: Optional[str] = None, - model_file: Optional[str | os.PathLike] = None, - libraries: Optional[list[str | tuple[str, str]]] = None, - variable_filter: Optional[str] = None, - build: bool = True, - ) -> None: - """Load and build a Modelica model. - - This method loads the model file and builds it if requested (build == True). - - Args: - model_file: Path to the model file. Either absolute or relative to - the current working directory. - model_name: The name of the model class. If it is contained within - a package, "PackageName.ModelName" should be used. - libraries: List of libraries to be loaded before the model itself is - loaded. Two formats are supported for the list elements: - lmodel=["Modelica"] for just the library name - and lmodel=[("Modelica","3.2.3")] for specifying both the name - and the version. - variable_filter: A regular expression. Only variables fully - matching the regexp will be stored in the result file. - Leaving it unspecified is equivalent to ".*". - build: Boolean controlling whether the model should be - built when constructor is called. If False, the constructor - simply loads the model without compiling. - - Examples: - mod = ModelicaSystem() - # and then one of the lines below - mod.model(name="modelName", file="ModelicaModel.mo", ) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) - mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) - """ - - if self._model_name is not None: - raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " - f"defined for {repr(self._model_name)}!") - - if model_name is None or not isinstance(model_name, str): - raise ModelicaSystemError("A model name must be provided!") - - if libraries is None: - libraries = [] - - if not isinstance(libraries, list): - raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") - - # set variables - self._model_name = model_name # Model class name - self._libraries = libraries # may be needed if model is derived from other model - self._variable_filter = variable_filter - - if self._libraries: - self._loadLibrary(libraries=self._libraries) - - self._file_name = None - if model_file is not None: - file_path = pathlib.Path(model_file) - # special handling for OMCProcessLocal - consider a relative path - if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): - file_path = pathlib.Path.cwd() / file_path - if not file_path.is_file(): - raise IOError(f"Model file {file_path} does not exist!") - - self._file_name = self.getWorkDirectory() / file_path.name - if (isinstance(self._session, OMCSessionLocal) - and file_path.as_posix() == self._file_name.as_posix()): - pass - elif self._file_name.is_file(): - raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") - else: - content = file_path.read_text(encoding='utf-8') - self._file_name.write_text(content) - - if self._file_name is not None: - self._loadFile(fileName=self._file_name) - - if build: - self.buildModel(variable_filter) - def get_session(self) -> OMCSession: """ Return the OMC session used for this class. @@ -512,41 +411,6 @@ def get_model_name(self) -> str: return self._model_name - def set_command_line_options(self, command_line_option: str): - """ - Set the provided command line option via OMC setCommandLineOptions(). - """ - expr = f'setCommandLineOptions("{command_line_option}")' - self.sendExpression(expr=expr) - - def _loadFile(self, fileName: OMCPath): - # 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 setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath: """ Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this @@ -594,59 +458,6 @@ def check_model_executable(self): if returncode != 0: raise ModelicaSystemError("Model executable not working!") - def buildModel(self, variableFilter: Optional[str] = None): - filter_def: Optional[str] = None - if variableFilter is not None: - filter_def = variableFilter - elif self._variable_filter is not None: - filter_def = self._variable_filter - - if filter_def is not None: - var_filter = f'variableFilter="{filter_def}"' - else: - var_filter = 'variableFilter=".*"' - - build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) - logger.debug("OM model build result: %s", build_model_result) - - # check if the executable exists ... - self.check_model_executable() - - xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] - self._xmlparse(xml_file=xml_file) - - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Wrapper for OMCSession.sendExpression(). - """ - try: - retval = self._session.sendExpression(expr=expr, parsed=parsed) - except OMCSessionException as ex: - raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex - - logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") - - return retval - - # request to OMC - def _requestApi( - self, - apiName: str, - entity: Optional[str] = None, - properties: Optional[str] = None, - ) -> Any: - if entity is not None and properties is not None: - expr = f'{apiName}({entity}, {properties})' - elif entity is not None and properties is None: - if apiName in ("loadFile", "importFMU"): - expr = f'{apiName}("{entity}")' - else: - expr = f'{apiName}({entity})' - else: - expr = f'{apiName}()' - - return self.sendExpression(expr=expr) - def _xmlparse(self, xml_file: OMCPath): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -789,142 +600,45 @@ def getContinuousInitial( raise ModelicaSystemError("Unhandled input for getContinousInitial()") - def getContinuousFinal( + def getParameters( self, names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """ - Get (final) values of continuous signals (at stopTime). + ) -> dict[str, str] | list[str]: + """Get parameter values. Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. + names: Either None (default), a string with the parameter name, + or a list of parameter name strings. Returns: If `names` is None, a dict in the format - {signal_name: signal_value} is returned. - If `names` is a string, a single element list [signal_value] is - returned. - If `names` is a list, a list with one value for each signal name - in names is returned: [signal1_value, signal2_value, ...]. + {parameter_name: parameter_value} is returned. + If `names` is a string, a single element list is returned. + If `names` is a list, a list with one value for each parameter name + in names is returned. + In all cases, parameter values are returned as strings. Examples: - >>> mod.getContinuousFinal() - {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} - >>> mod.getContinuousFinal("x") - [np.float64(0.68)] - >>> mod.getContinuousFinal(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] + >>> mod.getParameters() + {'Name1': '1.23', 'Name2': '4.56'} + >>> mod.getParameters("Name1") + ['1.23'] + >>> mod.getParameters(["Name1","Name2"]) + ['1.23', '4.56'] """ - if not self._simulated: - raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") - - def get_continuous_solution(name_list: list[str]) -> None: - for name in name_list: - if name in self._continuous: - value = self.getSolutions(name) - self._continuous[name] = np.float64(value[0][-1]) - else: - raise KeyError(f"{names} is not continuous") - if names is None: - get_continuous_solution(name_list=list(self._continuous.keys())) - return self._continuous - + return self._params if isinstance(names, str): - get_continuous_solution(name_list=[names]) - return [self._continuous[names]] - + return [self._params[names]] if isinstance(names, list): - get_continuous_solution(name_list=names) - values = [] - for name in names: - values.append(self._continuous[name]) - return values + return [self._params[x] for x in names] - raise ModelicaSystemError("Unhandled input for getContinousFinal()") + raise ModelicaSystemError("Unhandled input for getParameters()") - def getContinuous( + def getInputs( self, names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of continuous signals. - - If called before simulate(), the initial values are returned. - If called after simulate(), the final values (at stopTime) are returned. - The return format is always numpy.float64. - - Args: - names: Either None (default), a string with the continuous signal - name, or a list of signal name strings. - Returns: - If `names` is None, a dict in the format - {signal_name: signal_value} is returned. - If `names` is a string, a single element list [signal_value] is - returned. - If `names` is a list, a list with one value for each signal name - in names is returned: [signal1_value, signal2_value, ...]. - - Examples: - Before simulate(): - >>> mod.getContinuous() - {'x': '1.0', 'der(x)': None, 'y': '-0.4'} - >>> mod.getContinuous("y") - ['-0.4'] - >>> mod.getContinuous(["y","x"]) - ['-0.4', '1.0'] - - After simulate(): - >>> mod.getContinuous() - {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} - >>> mod.getContinuous("x") - [np.float64(0.68)] - >>> mod.getContinuous(["y","x"]) - [np.float64(-0.24), np.float64(0.68)] - """ - if not self._simulated: - return self.getContinuousInitial(names=names) - - return self.getContinuousFinal(names=names) - - def getParameters( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, str] | list[str]: - """Get parameter values. - - Args: - names: Either None (default), a string with the parameter name, - or a list of parameter name strings. - Returns: - If `names` is None, a dict in the format - {parameter_name: parameter_value} is returned. - If `names` is a string, a single element list is returned. - If `names` is a list, a list with one value for each parameter name - in names is returned. - In all cases, parameter values are returned as strings. - - Examples: - >>> mod.getParameters() - {'Name1': '1.23', 'Name2': '4.56'} - >>> mod.getParameters("Name1") - ['1.23'] - >>> mod.getParameters(["Name1","Name2"]) - ['1.23', '4.56'] - """ - if names is None: - return self._params - if isinstance(names, str): - return [self._params[names]] - 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. + ) -> 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, @@ -992,102 +706,6 @@ def getOutputsInitial( raise ModelicaSystemError("Unhandled input for getOutputsInitial()") - def getOutputsFinal( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get (final) values of output signals (at stopTime). - - Args: - names: Either None (default), a string with the output name, - or a list of output name strings. - Returns: - If `names` is None, a dict in the format - {output_name: output_value} is returned. - If `names` is a string, a single element list [output_value] is - returned. - If `names` is a list, a list with one value for each output name - in names is returned: [output1_value, output2_value, ...]. - - Examples: - >>> mod.getOutputsFinal() - {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} - >>> mod.getOutputsFinal("out1") - [np.float64(-0.1234)] - >>> mod.getOutputsFinal(["out1","out2"]) - [np.float64(-0.1234), np.float64(2.1)] - """ - if not self._simulated: - raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") - - def get_outputs_solution(name_list: list[str]) -> None: - for name in name_list: - if name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = np.float64(value[0][-1]) - else: - raise KeyError(f"{names} is not a valid output") - - if names is None: - get_outputs_solution(name_list=list(self._outputs.keys())) - return self._outputs - - if isinstance(names, str): - get_outputs_solution(name_list=[names]) - return [self._outputs[names]] - - if isinstance(names, list): - get_outputs_solution(name_list=names) - values = [] - for name in names: - values.append(self._outputs[name]) - return values - - raise ModelicaSystemError("Unhandled input for getOutputs()") - - def getOutputs( - self, - names: Optional[str | list[str]] = None, - ) -> dict[str, np.float64] | list[np.float64]: - """Get values of output signals. - - If called before simulate(), the initial values are returned. - If called after simulate(), the final values (at stopTime) are returned. - The return format is always numpy.float64. - - Args: - names: Either None (default), a string with the output name, - or a list of output name strings. - Returns: - If `names` is None, a dict in the format - {output_name: output_value} is returned. - If `names` is a string, a single element list [output_value] is - returned. - If `names` is a list, a list with one value for each output name - in names is returned: [output1_value, output2_value, ...]. - - Examples: - Before simulate(): - >>> mod.getOutputs() - {'out1': '-0.4', 'out2': '1.2'} - >>> mod.getOutputs("out1") - ['-0.4'] - >>> mod.getOutputs(["out1","out2"]) - ['-0.4', '1.2'] - - After simulate(): - >>> mod.getOutputs() - {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} - >>> mod.getOutputs("out1") - [np.float64(-0.1234)] - >>> mod.getOutputs(["out1","out2"]) - [np.float64(-0.1234), np.float64(2.1)] - """ - if not self._simulated: - return self.getOutputsInitial(names=names) - - return self.getOutputsFinal(names=names) - def getSimulationOptions( self, names: Optional[str | list[str]] = None, @@ -1379,151 +997,50 @@ def simulate( self._simulated = True - def plot( - self, - plotdata: str, - resultfile: Optional[str | os.PathLike] = None, - ) -> None: + @staticmethod + def _prepare_input_data( + input_args: Any, + input_kwargs: dict[str, Any], + ) -> dict[str, str]: """ - Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the - plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. + Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. """ - if not isinstance(self._session, OMCSessionLocal): - raise ModelicaSystemError("Plot is using the OMC plot functionality; " - "thus, it is only working if OMC is running locally!") - - if resultfile is not None: - plot_result_file = self._session.omcpath(resultfile) - elif self._result_file is not None: - plot_result_file = self._result_file - else: - raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " - "or provide a result file!") + def prepare_str(str_in: str) -> dict[str, str]: + str_in = str_in.replace(" ", "") + key_val_list: list[str] = str_in.split("=") + if len(key_val_list) != 2: + raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - if not plot_result_file.is_file(): - raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") + input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' - self.sendExpression(expr=expr) + return input_data_from_str - def getSolutions( - self, - varList: Optional[str | list[str]] = None, - resultfile: Optional[str | os.PathLike] = None, - ) -> tuple[str] | np.ndarray: - """Extract simulation results from a result data file. + input_data: dict[str, str] = {} - Args: - varList: Names of variables to be extracted. Either unspecified to - get names of available variables, or a single variable name - as a string, or a list of variable names. - resultfile: Path to the result file. If unspecified, the result - file created by simulate() is used. + for input_arg in input_args: + if isinstance(input_arg, str): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) + input_data = input_data | prepare_str(input_arg) + elif isinstance(input_arg, list): + warnings.warn(message="The definition of values to set should use a dictionary, " + "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " + "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", + category=DeprecationWarning, + stacklevel=3) - Returns: - If varList is None, a tuple with names of all variables - is returned. - If varList is a string, a 1D numpy array is returned. - If varList is a list, a 2D numpy array is returned. - - Examples: - >>> mod.getSolutions() - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"]) - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - >>> mod.getSolutions(resultfile="c:/a.mat") - ('a', 'der(x)', 'time', 'x') - >>> mod.getSolutions("x", resultfile="c:/a.mat") - np.array([[1. , 0.90483742, 0.81873075]]) - >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") - np.array([[1. , 0.90483742 , 0.81873075], - [-1. , -0.90483742, -0.81873075]]) - """ - if resultfile is None: - if self._result_file is None: - raise ModelicaSystemError("No result file found. Run simulate() first.") - result_file = self._result_file - else: - result_file = self._session.omcpath(resultfile) - - # check if the result file exits - if not result_file.is_file(): - raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") - - # get absolute path - result_file = result_file.absolute() - - result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') - self.sendExpression(expr="closeSimulationResultFile()") - if varList is None: - return result_vars - - if isinstance(varList, str): - var_list_checked = [varList] - elif isinstance(varList, list): - var_list_checked = varList - else: - raise ModelicaSystemError("Unhandled input for getSolutions()") - - for var in var_list_checked: - if var == "time": - continue - if var not in result_vars: - raise ModelicaSystemError(f"Requested data {repr(var)} does not exist") - variables = ",".join(var_list_checked) - res = self.sendExpression(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') - np_res = np.array(res) - self.sendExpression(expr="closeSimulationResultFile()") - return np_res - - @staticmethod - def _prepare_input_data( - input_args: Any, - input_kwargs: dict[str, Any], - ) -> dict[str, str]: - """ - Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. - """ - - def prepare_str(str_in: str) -> dict[str, str]: - str_in = str_in.replace(" ", "") - key_val_list: list[str] = str_in.split("=") - if len(key_val_list) != 2: - raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - - input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - - return input_data_from_str - - input_data: dict[str, str] = {} - - for input_arg in input_args: - if isinstance(input_arg, str): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - input_data = input_data | prepare_str(input_arg) - elif isinstance(input_arg, list): - warnings.warn(message="The definition of values to set should use a dictionary, " - "i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which " - "use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]", - category=DeprecationWarning, - stacklevel=3) - - for item in input_arg: - if not isinstance(item, str): - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") - input_data = input_data | prepare_str(item) - elif isinstance(input_arg, dict): - input_data = input_data | input_arg - else: - raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") + for item in input_arg: + if not isinstance(item, str): + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!") + input_data = input_data | prepare_str(item) + elif isinstance(input_arg, dict): + input_data = input_data | input_arg + else: + raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!") if len(input_kwargs): for key, val in input_kwargs.items(): @@ -1832,110 +1349,6 @@ def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: return csvfile - def convertMo2Fmu( - self, - version: str = "2.0", - fmuType: str = "me_cs", - fileNamePrefix: Optional[str] = None, - includeResources: bool = True, - ) -> OMCPath: - """Translate the model into a Functional Mockup Unit. - - Args: - See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html - - Returns: - str: Path to the created '*.fmu' file. - - Examples: - >>> mod.convertMo2Fmu() - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", - includeResources=True) - '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' - """ - - if fileNamePrefix is None: - if self._model_name is None: - fileNamePrefix = "" - else: - fileNamePrefix = self._model_name - include_resources_str = "true" if includeResources else "false" - - properties = (f'version="{version}", fmuType="{fmuType}", ' - f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') - fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) - fmu_path = self._session.omcpath(fmu) - - # report proper error message - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - return fmu_path - - # to convert FMU to Modelica model - def convertFmu2Mo( - self, - fmu: os.PathLike, - ) -> OMCPath: - """ - In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate - Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". - Currently, it only supports Model Exchange conversion. - usage - >>> convertFmu2Mo("c:/BouncingBall.Fmu") - """ - - fmu_path = self._session.omcpath(fmu) - - if not fmu_path.is_file(): - raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") - - filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) - filepath = self.getWorkDirectory() / filename - - # report proper error message - if not filepath.is_file(): - raise ModelicaSystemError(f"Missing file {filepath.as_posix()}") - - self.model( - model_name=f"{fmu_path.stem}_me_FMU", - model_file=filepath, - ) - - return filepath - - def optimize(self) -> dict[str, Any]: - """Perform model-based optimization. - - Optimization options set by setOptimizationOptions() are used. - - Returns: - A dict with various values is returned. One of these values is the - path to the result file. - - Examples: - >>> mod.optimize() - {'messages': 'LOG_SUCCESS | info | The initialization finished successfully without homotopy method. ...' - 'resultFile': '/tmp/tmp68guvjhs/BangBang2021_res.mat', - 'simulationOptions': 'startTime = 0.0, stopTime = 1.0, numberOfIntervals = ' - "1000, tolerance = 1e-8, method = 'optimization', " - "fileNamePrefix = 'BangBang2021', options = '', " - "outputFormat = 'mat', variableFilter = '.*', cflags = " - "'', simflags = '-s=\\'optimization\\' " - "-optimizerNP=\\'1\\''", - 'timeBackend': 0.008684897, - 'timeCompile': 0.7546678929999999, - 'timeFrontend': 0.045438053000000006, - 'timeSimCode': 0.0018537170000000002, - 'timeSimulation': 0.266354356, - 'timeTemplates': 0.002007785, - 'timeTotal': 1.079097854} - """ - properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) - self.set_command_line_options("-g=Optimica") - return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) - def linearize( self, lintime: Optional[float] = None, @@ -1965,7 +1378,7 @@ def linearize( # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() raise ModelicaSystemError( "Linearization cannot be performed as the model is not build, " - "use ModelicaSystem() to build the model first" + "use ModelicaSystemOMC() to build the model first" ) om_cmd = ModelExecutionCmd( @@ -2074,6 +1487,627 @@ def getLinearStates(self) -> list[str]: 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[OMCSession] = None, + ) -> None: + """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). + + Args: + command_line_options: List with extra command line options as elements. The list elements are + provided to omc via setCommandLineOptions(). If set, the default values will be overridden. + To disable any command line options, use an empty list. + work_directory: Path to a directory to be used for temporary + files like the model executable. If left unspecified, a tmp + directory will be created. + omhome: path to OMC to be used when creating the OMC session (see OMCSession). + session: definition of a (local) OMC session to be used. If + unspecified, a new local session will be created. + """ + + 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) + + def _loadFile(self, fileName: OMCPath): + # load file + self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') + + # for loading file/package, loading model and building model + def _loadLibrary(self, libraries: list): + # load Modelica standard libraries or Modelica files if needed + for element in libraries: + if element is not None: + if isinstance(element, str): + if element.endswith(".mo"): + api_call = "loadFile" + else: + api_call = "loadModel" + self._requestApi(apiName=api_call, entity=element) + elif isinstance(element, tuple): + if not element[1]: + expr_load_lib = f"loadModel({element[0]})" + else: + expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' + self.sendExpression(expr=expr_load_lib) + else: + raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " + f"{element} is of type {type(element)}, " + "The following patterns are supported:\n" + '1)["Modelica"]\n' + '2)[("Modelica","3.2.3"), "PowerSystems"]\n') + + def buildModel(self, variableFilter: Optional[str] = None): + filter_def: Optional[str] = None + if variableFilter is not None: + filter_def = variableFilter + elif self._variable_filter is not None: + filter_def = self._variable_filter + + if filter_def is not None: + var_filter = f'variableFilter="{filter_def}"' + else: + var_filter = 'variableFilter=".*"' + + build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) + logger.debug("OM model build result: %s", build_model_result) + + # check if the executable exists ... + self.check_model_executable() + + xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1] + self._xmlparse(xml_file=xml_file) + + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Wrapper for OMCSession.sendExpression(). + """ + try: + retval = self._session.sendExpression(expr=expr, parsed=parsed) + except OMCSessionException as ex: + raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex + + logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}") + + return retval + + # request to OMC + def _requestApi( + self, + apiName: str, + entity: Optional[str] = None, + properties: Optional[str] = None, + ) -> Any: + if entity is not None and properties is not None: + expr = f'{apiName}({entity}, {properties})' + elif entity is not None and properties is None: + if apiName in ("loadFile", "importFMU"): + expr = f'{apiName}("{entity}")' + else: + expr = f'{apiName}({entity})' + else: + expr = f'{apiName}()' + + return self.sendExpression(expr=expr) + + def getContinuousFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """ + Get (final) values of continuous signals (at stopTime). + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + >>> mod.getContinuousFinal() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuousFinal("x") + [np.float64(0.68)] + >>> mod.getContinuousFinal(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] + """ + if not self._simulated: + raise ModelicaSystemError("Please use getContinuousInitial() before the simulation was started!") + + def get_continuous_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._continuous: + value = self.getSolutions(name) + self._continuous[name] = np.float64(value[0][-1]) + else: + raise KeyError(f"{names} is not continuous") + + if names is None: + get_continuous_solution(name_list=list(self._continuous.keys())) + return self._continuous + + if isinstance(names, str): + get_continuous_solution(name_list=[names]) + return [self._continuous[names]] + + if isinstance(names, list): + get_continuous_solution(name_list=names) + values = [] + for name in names: + values.append(self._continuous[name]) + return values + + raise ModelicaSystemError("Unhandled input for getContinousFinal()") + + def getContinuous( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of continuous signals. + + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. + + Args: + names: Either None (default), a string with the continuous signal + name, or a list of signal name strings. + Returns: + If `names` is None, a dict in the format + {signal_name: signal_value} is returned. + If `names` is a string, a single element list [signal_value] is + returned. + If `names` is a list, a list with one value for each signal name + in names is returned: [signal1_value, signal2_value, ...]. + + Examples: + Before simulate(): + >>> mod.getContinuous() + {'x': '1.0', 'der(x)': None, 'y': '-0.4'} + >>> mod.getContinuous("y") + ['-0.4'] + >>> mod.getContinuous(["y","x"]) + ['-0.4', '1.0'] + + After simulate(): + >>> mod.getContinuous() + {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} + >>> mod.getContinuous("x") + [np.float64(0.68)] + >>> mod.getContinuous(["y","x"]) + [np.float64(-0.24), np.float64(0.68)] + """ + if not self._simulated: + return self.getContinuousInitial(names=names) + + return self.getContinuousFinal(names=names) + + def getOutputsFinal( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get (final) values of output signals (at stopTime). + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + >>> mod.getOutputsFinal() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputsFinal("out1") + [np.float64(-0.1234)] + >>> mod.getOutputsFinal(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] + """ + if not self._simulated: + raise ModelicaSystemError("Please use getOuputsInitial() before the simulation was started!") + + def get_outputs_solution(name_list: list[str]) -> None: + for name in name_list: + if name in self._outputs: + value = self.getSolutions(name) + self._outputs[name] = np.float64(value[0][-1]) + else: + raise KeyError(f"{names} is not a valid output") + + if names is None: + get_outputs_solution(name_list=list(self._outputs.keys())) + return self._outputs + + if isinstance(names, str): + get_outputs_solution(name_list=[names]) + return [self._outputs[names]] + + if isinstance(names, list): + get_outputs_solution(name_list=names) + values = [] + for name in names: + values.append(self._outputs[name]) + return values + + raise ModelicaSystemError("Unhandled input for getOutputs()") + + def getOutputs( + self, + names: Optional[str | list[str]] = None, + ) -> dict[str, np.float64] | list[np.float64]: + """Get values of output signals. + + If called before simulate(), the initial values are returned. + If called after simulate(), the final values (at stopTime) are returned. + The return format is always numpy.float64. + + Args: + names: Either None (default), a string with the output name, + or a list of output name strings. + Returns: + If `names` is None, a dict in the format + {output_name: output_value} is returned. + If `names` is a string, a single element list [output_value] is + returned. + If `names` is a list, a list with one value for each output name + in names is returned: [output1_value, output2_value, ...]. + + Examples: + Before simulate(): + >>> mod.getOutputs() + {'out1': '-0.4', 'out2': '1.2'} + >>> mod.getOutputs("out1") + ['-0.4'] + >>> mod.getOutputs(["out1","out2"]) + ['-0.4', '1.2'] + + After simulate(): + >>> mod.getOutputs() + {'out1': np.float64(-0.1234), 'out2': np.float64(2.1)} + >>> mod.getOutputs("out1") + [np.float64(-0.1234)] + >>> mod.getOutputs(["out1","out2"]) + [np.float64(-0.1234), np.float64(2.1)] + """ + if not self._simulated: + return self.getOutputsInitial(names=names) + + return self.getOutputsFinal(names=names) + + def plot( + self, + plotdata: str, + resultfile: Optional[str | os.PathLike] = None, + ) -> None: + """ + Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the + plot is created by OMC which needs access to the local display. This is not the case for docker and WSL. + """ + + if not isinstance(self._session, OMCSessionLocal): + raise ModelicaSystemError("Plot is using the OMC plot functionality; " + "thus, it is only working if OMC is running locally!") + + if resultfile is not None: + plot_result_file = self._session.omcpath(resultfile) + elif self._result_file is not None: + plot_result_file = self._result_file + else: + raise ModelicaSystemError("No resultfile available - either run simulate() before plotting " + "or provide a result file!") + + if not plot_result_file.is_file(): + raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!") + + expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")' + self.sendExpression(expr=expr) + + def getSolutions( + self, + varList: Optional[str | list[str]] = None, + resultfile: Optional[str | os.PathLike] = None, + ) -> tuple[str] | np.ndarray: + """Extract simulation results from a result data file. + + Args: + varList: Names of variables to be extracted. Either unspecified to + get names of available variables, or a single variable name + as a string, or a list of variable names. + resultfile: Path to the result file. If unspecified, the result + file created by simulate() is used. + + Returns: + If varList is None, a tuple with names of all variables + is returned. + If varList is a string, a 1D numpy array is returned. + If varList is a list, a 2D numpy array is returned. + + Examples: + >>> mod.getSolutions() + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"]) + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + >>> mod.getSolutions(resultfile="c:/a.mat") + ('a', 'der(x)', 'time', 'x') + >>> mod.getSolutions("x", resultfile="c:/a.mat") + np.array([[1. , 0.90483742, 0.81873075]]) + >>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat") + np.array([[1. , 0.90483742 , 0.81873075], + [-1. , -0.90483742, -0.81873075]]) + """ + if resultfile is None: + if self._result_file is None: + raise ModelicaSystemError("No result file found. Run simulate() first.") + result_file = self._result_file + else: + result_file = self._session.omcpath(resultfile) + + # check if the result file exits + if not result_file.is_file(): + raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}") + + # get absolute path + result_file = result_file.absolute() + + result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') + self.sendExpression(expr="closeSimulationResultFile()") + if varList is None: + return result_vars + + if isinstance(varList, str): + var_list_checked = [varList] + elif isinstance(varList, list): + var_list_checked = varList + else: + raise ModelicaSystemError("Unhandled input for getSolutions()") + + for var in var_list_checked: + if var == "time": + continue + if var not in result_vars: + raise ModelicaSystemError(f"Requested data {repr(var)} does not exist") + variables = ",".join(var_list_checked) + res = self.sendExpression(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') + np_res = np.array(res) + self.sendExpression(expr="closeSimulationResultFile()") + return np_res + + def convertMo2Fmu( + self, + version: str = "2.0", + fmuType: str = "me_cs", + fileNamePrefix: Optional[str] = None, + includeResources: bool = True, + ) -> OMCPath: + """Translate the model into a Functional Mockup Unit. + + Args: + See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html + + Returns: + str: Path to the created '*.fmu' file. + + Examples: + >>> mod.convertMo2Fmu() + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + >>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="", + includeResources=True) + '/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu' + """ + + if fileNamePrefix is None: + if self._model_name is None: + fileNamePrefix = "" + else: + fileNamePrefix = self._model_name + include_resources_str = "true" if includeResources else "false" + + properties = (f'version="{version}", fmuType="{fmuType}", ' + f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') + fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) + fmu_path = self._session.omcpath(fmu) + + # report proper error message + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + return fmu_path + + # to convert FMU to Modelica model + def convertFmu2Mo( + self, + fmu: os.PathLike, + ) -> OMCPath: + """ + In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate + Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". + Currently, it only supports Model Exchange conversion. + usage + >>> convertFmu2Mo("c:/BouncingBall.Fmu") + """ + + fmu_path = self._session.omcpath(fmu) + + if not fmu_path.is_file(): + raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") + + filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) + filepath = self.getWorkDirectory() / filename + + # report proper error message + if not filepath.is_file(): + raise ModelicaSystemError(f"Missing file {filepath.as_posix()}") + + self.model( + model_name=f"{fmu_path.stem}_me_FMU", + model_file=filepath, + ) + + return filepath + + def optimize(self) -> dict[str, Any]: + """Perform model-based optimization. + + Optimization options set by setOptimizationOptions() are used. + + Returns: + A dict with various values is returned. One of these values is the + path to the result file. + + Examples: + >>> mod.optimize() + {'messages': 'LOG_SUCCESS | info | The initialization finished successfully without homotopy method. ...' + 'resultFile': '/tmp/tmp68guvjhs/BangBang2021_res.mat', + 'simulationOptions': 'startTime = 0.0, stopTime = 1.0, numberOfIntervals = ' + "1000, tolerance = 1e-8, method = 'optimization', " + "fileNamePrefix = 'BangBang2021', options = '', " + "outputFormat = 'mat', variableFilter = '.*', cflags = " + "'', simflags = '-s=\\'optimization\\' " + "-optimizerNP=\\'1\\''", + 'timeBackend': 0.008684897, + 'timeCompile': 0.7546678929999999, + 'timeFrontend': 0.045438053000000006, + 'timeSimCode': 0.0018537170000000002, + 'timeSimulation': 0.266354356, + 'timeTemplates': 0.002007785, + 'timeTotal': 1.079097854} + """ + properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) + self.set_command_line_options("-g=Optimica") + return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + + +class ModelicaSystem(ModelicaSystemOMC): + """ + Compatibility class. + """ + + class ModelicaSystemDoE: """ Class to run DoEs based on a (Open)Modelica model using ModelicaSystem @@ -2115,7 +2149,7 @@ def run_doe(): resdir = mypath / 'DoE' resdir.mkdir(exist_ok=True) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="M", model_file=model.as_posix(), @@ -2149,7 +2183,7 @@ def run_doe(): def __init__( self, # ModelicaSystem definition to use - mod: ModelicaSystem, + 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, @@ -2162,7 +2196,7 @@ def __init__( ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. """ - if not isinstance(mod, ModelicaSystem): + if not isinstance(mod, ModelicaSystemOMC): raise ModelicaSystemError("Missing definition of ModelicaSystem!") self._mod = mod diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 7c199ef3..1f086293 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -14,6 +14,7 @@ from OMPython.ModelicaSystem import ( LinearizationResult, ModelicaSystem, + ModelicaSystemOMC, ModelExecutionCmd, ModelicaSystemDoE, ModelicaSystemError, @@ -43,6 +44,7 @@ 'ModelExecutionException', 'ModelicaSystem', + 'ModelicaSystemOMC', 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaSystemError', diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index 006d2d17..c7ab038a 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -6,7 +6,7 @@ def test_CauerLowPassAnalog(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", libraries=["Modelica"], @@ -20,7 +20,7 @@ def test_CauerLowPassAnalog(): def test_DrumBoiler(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", libraries=["Modelica"], diff --git a/tests/test_FMIImport.py b/tests/test_FMIImport.py index cb43e0ae..bb3a1201 100644 --- a/tests/test_FMIImport.py +++ b/tests/test_FMIImport.py @@ -22,7 +22,7 @@ def model_firstorder(tmp_path): def test_FMIImport(model_firstorder): # create model & simulate it - mod1 = OMPython.ModelicaSystem() + mod1 = OMPython.ModelicaSystemOMC() mod1.model( model_file=model_firstorder, model_name="M", @@ -35,7 +35,7 @@ def test_FMIImport(model_firstorder): # import FMU & check & simulate # TODO: why is '--allowNonStandardModelica=reinitInAlgorithms' needed? any example without this possible? - mod2 = OMPython.ModelicaSystem(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) + mod2 = OMPython.ModelicaSystemOMC(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) mo = mod2.convertFmu2Mo(fmu=fmu) assert os.path.exists(mo) diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 6fa2658f..3d35376b 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -18,7 +18,7 @@ def model_firstorder(tmp_path): @pytest.fixture def mscmd_firstorder(model_firstorder): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index 86c43ce7..8b1d1a09 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -55,7 +55,7 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_doe, model_name="M", @@ -78,7 +78,7 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( @@ -102,7 +102,7 @@ def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystemOMC.py similarity index 96% rename from tests/test_ModelicaSystem.py rename to tests/test_ModelicaSystemOMC.py index 9bf0a7b9..8dd17ef0 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystemOMC.py @@ -40,7 +40,7 @@ def model_firstorder(tmp_path, model_firstorder_content): def test_ModelicaSystem_loop(model_firstorder): def worker(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", @@ -56,7 +56,9 @@ def test_setParameters(): omcs = OMPython.OMCSessionLocal() model_path_str = omcs.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omcs.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -91,7 +93,9 @@ def test_setSimulationOptions(): omcs = OMPython.OMCSessionLocal() model_path_str = omcs.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels" model_path = omcs.omcpath(model_path_str) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) mod.model( model_file=model_path / "BouncingBall.mo", model_name="BouncingBall", @@ -128,7 +132,7 @@ def test_relative_path(model_firstorder): model_relative = str(model_file) assert "/" not in model_relative - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_relative, model_name="M", @@ -141,7 +145,7 @@ def test_relative_path(model_firstorder): def test_customBuildDirectory(tmp_path, model_firstorder): tmpdir = tmp_path / "tmpdir1" tmpdir.mkdir() - mod = OMPython.ModelicaSystem(work_directory=tmpdir) + mod = OMPython.ModelicaSystemOMC(work_directory=tmpdir) mod.model( model_file=model_firstorder, model_name="M", @@ -157,7 +161,7 @@ def test_customBuildDirectory(tmp_path, model_firstorder): @skip_python_older_312 def test_getSolutions_docker(model_firstorder): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( @@ -169,7 +173,7 @@ def test_getSolutions_docker(model_firstorder): def test_getSolutions(model_firstorder): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", @@ -217,7 +221,7 @@ def test_getters(tmp_path): y = der(x); end M_getters; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="M_getters", @@ -426,7 +430,7 @@ def test_simulate_inputs(tmp_path): y = x; end M_input; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="M_input", diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py index d3997ecf..7dbb9705 100644 --- a/tests/test_OMSessionCmd.py +++ b/tests/test_OMSessionCmd.py @@ -8,7 +8,7 @@ def test_isPackage(): def test_isPackage2(): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", libraries=["Modelica"], diff --git a/tests/test_linearization.py b/tests/test_linearization.py index c61462bb..7070a45b 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -25,7 +25,7 @@ def model_linearTest(tmp_path): def test_example(model_linearTest): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_linearTest, model_name="linearTest", @@ -60,7 +60,7 @@ def test_getters(tmp_path): y2 = phi + u1; end Pendulum; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="Pendulum", diff --git a/tests/test_optimization.py b/tests/test_optimization.py index d7494281..823ba1e3 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -34,7 +34,7 @@ def test_optimization_example(tmp_path): end BangBang2021; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="BangBang2021", From 65043415eeb7f595bb385f235ce1955137056860 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:23:10 +0100 Subject: [PATCH 04/34] (B002) split ModelicaSystemDoE [ModelicaSystem] split ModelicaSystemDoE into ModelicaDoEABC and ModelicaDoE [ModelicaSystem] rename ModelicaSystemDoE => ModelicaDoEOMC * add compatibility variable for ModelicaSystemDoE [test_ModelicaDoEOMC] rename from ModelicaSystemDoE and update [ModelicaSystem] update ModelicaDoEABC to use ModelicaSystemABC [ModelicaSystem] define doe_get_solutions() as separate method --- OMPython/ModelicaSystem.py | 234 ++++++++++++------ OMPython/__init__.py | 7 + ...icaSystemDoE.py => test_ModelicaDoEOMC.py} | 20 +- 3 files changed, 176 insertions(+), 85 deletions(-) rename tests/{test_ModelicaSystemDoE.py => test_ModelicaDoEOMC.py} (88%) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 83a6fd25..b3f55eef 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -15,7 +15,7 @@ import re import textwrap import threading -from typing import Any, cast, Optional +from typing import Any, cast, Optional, Tuple import warnings import xml.etree.ElementTree as ET @@ -2108,9 +2108,9 @@ class ModelicaSystem(ModelicaSystemOMC): """ -class ModelicaSystemDoE: +class ModelicaDoEABC(metaclass=abc.ABCMeta): """ - Class to run DoEs based on a (Open)Modelica model using ModelicaSystem + Base class to run DoEs based on a (Open)Modelica model using ModelicaSystem Example ------- @@ -2183,7 +2183,7 @@ def run_doe(): def __init__( self, # ModelicaSystem definition to use - mod: ModelicaSystemOMC, + 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, @@ -2196,7 +2196,7 @@ def __init__( ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. """ - if not isinstance(mod, ModelicaSystemOMC): + if not isinstance(mod, ModelicaSystemABC): raise ModelicaSystemError("Missing definition of ModelicaSystem!") self._mod = mod @@ -2252,30 +2252,11 @@ def prepare(self) -> int: param_non_structural_combinations = list(itertools.product(*param_non_structure.values())) for idx_pc_structure, pc_structure in enumerate(param_structure_combinations): - - build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" - build_dir.mkdir() - self._mod.setWorkDirectory(work_directory=build_dir) - - sim_param_structure = {} - for idx_structure, pk_structure in enumerate(param_structure.keys()): - sim_param_structure[pk_structure] = pc_structure[idx_structure] - - pk_value = pc_structure[idx_structure] - if isinstance(pk_value, str): - pk_value_str = self.get_session().escape_str(pk_value) - expr = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" - elif isinstance(pk_value, bool): - pk_value_bool_str = "true" if pk_value else "false" - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" - else: - expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expr=expr) - if not res: - raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " - f"to {pk_value} using {repr(expr)}") - - self._mod.buildModel() + sim_param_structure = self._prepare_structure_parameters( + idx_pc_structure=idx_pc_structure, + pc_structure=pc_structure, + param_structure=param_structure, + ) for idx_non_structural, pk_non_structural in enumerate(param_non_structural_combinations): sim_param_non_structural = {} @@ -2320,6 +2301,17 @@ def prepare(self) -> int: return len(doe_sim) + @abc.abstractmethod + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + """ + Handle structural parameters. This should be implemented by the derived class + """ + def get_doe_definition(self) -> Optional[dict[str, dict[str, Any]]]: """ Get the defined DoE as a dict, where each key is the result filename and the value is a dict of simulation @@ -2431,65 +2423,157 @@ def worker(worker_id, task_queue): return doe_def_total == doe_def_done + +class ModelicaDoEOMC(ModelicaDoEABC): + """ + Class to run DoEs based on a (Open)Modelica model using ModelicaSystemOMC + + The example is the same as defined for ModelicaDoEABC + """ + + def __init__( + self, + # ModelicaSystem definition to use + mod: ModelicaSystemOMC, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + + if not isinstance(mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + build_dir = self._resultpath / f"DOE_{idx_pc_structure:09d}" + build_dir.mkdir() + self._mod.setWorkDirectory(work_directory=build_dir) + + # need to repeat this check to make the linters happy + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + + sim_param_structure = {} + for idx_structure, pk_structure in enumerate(param_structure.keys()): + sim_param_structure[pk_structure] = pc_structure[idx_structure] + + pk_value = pc_structure[idx_structure] + if isinstance(pk_value, str): + pk_value_str = self.get_session().escape_str(pk_value) + expr = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" + elif isinstance(pk_value, bool): + pk_value_bool_str = "true" if pk_value else "false" + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + else: + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" + res = self._mod.sendExpression(expr=expr) + if not res: + raise ModelicaSystemError(f"Cannot set structural parameter {self._model_name}.{pk_structure} " + f"to {pk_value} using {repr(expr)}") + + self._mod.buildModel() + + return sim_param_structure + def get_doe_solutions( self, var_list: Optional[list] = None, ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: """ - Get all solutions of the DoE run. The following return values are possible: + Wrapper for doe_get_solutions() + """ + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") - * A list of variables if val_list == None + return doe_get_solutions( + msomc=self._mod, + resultpath=self._resultpath, + doe_def=self.get_doe_definition(), + var_list=var_list, + ) - * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - The following code snippet can be used to convert the solution data for each run to a pandas dataframe: +def doe_get_solutions( + msomc: ModelicaSystemOMC, + resultpath: OMCPath, + doe_def: Optional[dict] = None, + var_list: Optional[list] = None, +) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: + """ + Get all solutions of the DoE run. The following return values are possible: - ``` - import pandas as pd + * A list of variables if val_list == None - doe_sol = doe_mod.get_doe_solutions() - for key in doe_sol: - data = doe_sol[key]['data'] - if data: - doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data) - else: - doe_sol[key]['df'] = None - ``` + * The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined. - """ - if not isinstance(self._doe_def, dict): - return None + The following code snippet can be used to convert the solution data for each run to a pandas dataframe: - if len(self._doe_def) == 0: - raise ModelicaSystemError("No result files available - all simulations did fail?") + ``` + import pandas as pd - sol_dict: dict[str, dict[str, Any]] = {} - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename + 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 + ``` - sol_dict[resultfilename] = {} + """ + if not isinstance(doe_def, dict): + return None - if not self._doe_def[resultfilename][self.DICT_RESULT_AVAILABLE]: - msg = f"No result file available for {resultfilename}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - continue + if len(doe_def) == 0: + raise ModelicaSystemError("No result files available - all simulations did fail?") - if var_list is None: - var_list_row = list(self._mod.getSolutions(resultfile=resultfile)) - else: - var_list_row = var_list - - try: - sol = self._mod.getSolutions(varList=var_list_row, resultfile=resultfile) - sol_data = {var: sol[idx] for idx, var in enumerate(var_list_row)} - sol_dict[resultfilename]['msg'] = 'Simulation available' - sol_dict[resultfilename]['data'] = sol_data - except ModelicaSystemError as ex: - msg = f"Error reading solution for {resultfilename}: {ex}" - logger.warning(msg) - sol_dict[resultfilename]['msg'] = msg - sol_dict[resultfilename]['data'] = {} - - return sol_dict + 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. + """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 1f086293..9f4408d5 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -17,7 +17,10 @@ ModelicaSystemOMC, ModelExecutionCmd, ModelicaSystemDoE, + ModelicaDoEOMC, ModelicaSystemError, + + doe_get_solutions, ) from OMPython.OMCSession import ( OMCPath, @@ -47,11 +50,15 @@ 'ModelicaSystemOMC', 'ModelExecutionCmd', 'ModelicaSystemDoE', + 'ModelicaDoEOMC', 'ModelicaSystemError', 'OMCPath', 'OMCSession', + + 'doe_get_solutions', + 'OMCSessionCmd', 'OMCSessionDocker', 'OMCSessionDockerContainer', diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaDoEOMC.py similarity index 88% rename from tests/test_ModelicaSystemDoE.py rename to tests/test_ModelicaDoEOMC.py index 8b1d1a09..143932fc 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaDoEOMC.py @@ -51,7 +51,7 @@ def param_doe() -> dict[str, list]: return param -def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): +def test_ModelicaDoEOMC_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) @@ -61,19 +61,19 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): model_name="M", ) - doe_mod = OMPython.ModelicaSystemDoE( + doe_mod = OMPython.ModelicaDoEOMC( mod=mod, parameters=param_doe, resultpath=tmpdir, simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) @skip_on_windows @skip_python_older_312 -def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): +def test_ModelicaDoEOMC_docker(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") @@ -86,18 +86,18 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): model_name="M", ) - doe_mod = OMPython.ModelicaSystemDoE( + doe_mod = OMPython.ModelicaDoEOMC( mod=mod, parameters=param_doe, simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 -def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): +def test_ModelicaDoEOMC_WSL(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionWSL() omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") @@ -110,16 +110,16 @@ def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): model_name="M", ) - doe_mod = OMPython.ModelicaSystemDoE( + doe_mod = OMPython.ModelicaDoEOMC( mod=mod, parameters=param_doe, simargs={"override": {'stopTime': '1.0'}}, ) - _run_ModelicaSystemDoe(doe_mod=doe_mod) + _run_ModelicaDoEOMC(doe_mod=doe_mod) -def _run_ModelicaSystemDoe(doe_mod): +def _run_ModelicaDoEOMC(doe_mod): doe_count = doe_mod.prepare() assert doe_count == 16 From ef8a3aa986096b7a9ac209b3b4be3727fce44917 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 25 Jan 2026 22:45:49 +0100 Subject: [PATCH 05/34] (B003) update OMCSession:OMPathABC [OMCSession] update OMCPath to use OMPathABC as baseline and further cleanup [ModelicaSystem] shortcut to use OMCPath = OMPathABC for now [ModelicaSystem] fix usage of OMCPath; replace by OMPathABC [OMCSession] move OM(C)Path classes into the if cause [OMCSession] define and use OMPathBase [OMCSession] align on OMPathABC; replace usage of OMPathBase --- OMPython/ModelicaSystem.py | 33 ++-- OMPython/OMCSession.py | 394 ++++++++++++++++++++++--------------- OMPython/__init__.py | 3 + 3 files changed, 250 insertions(+), 180 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index b3f55eef..2a0cdf64 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -28,7 +28,8 @@ OMCSessionException, OMCSession, OMCSessionLocal, - OMCPath, + + OMPathABC, ) # define logger using the current module name as ID @@ -387,13 +388,13 @@ def __init__( self._version = self._parse_om_version(version=version_str) self._simulated = False # True if the model has already been simulated - self._result_file: Optional[OMCPath] = None # for storing result file + self._result_file: Optional[OMPathABC] = None # for storing result file - self._work_dir: OMCPath = self.setWorkDirectory(work_directory) + self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) self._model_name: Optional[str] = None self._libraries: Optional[list[str | tuple[str, str]]] = None - self._file_name: Optional[OMCPath] = None + self._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None def get_session(self) -> OMCSession: @@ -411,7 +412,7 @@ def get_model_name(self) -> str: return self._model_name - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath: + def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathABC: """ Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this directory. If no directory is defined a unique temporary directory is created. @@ -433,7 +434,7 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - # ... and also return the defined path return workdir - def getWorkDirectory(self) -> OMCPath: + def getWorkDirectory(self) -> OMPathABC: """ Return the defined working directory for this ModelicaSystem / OpenModelica session. """ @@ -458,7 +459,7 @@ def check_model_executable(self): if returncode != 0: raise ModelicaSystemError("Model executable not working!") - def _xmlparse(self, xml_file: OMCPath): + def _xmlparse(self, xml_file: OMPathABC): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -832,7 +833,7 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, om_cmd: ModelExecutionCmd, - override_file: OMCPath, + override_file: OMPathABC, override_var: dict[str, str], override_sim: dict[str, str], ) -> None: @@ -864,7 +865,7 @@ def _process_override_data( def simulate_cmd( self, - result_file: OMCPath, + result_file: OMPathABC, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> ModelExecutionCmd: @@ -962,14 +963,14 @@ def simulate( if resultfile is None: # default result file generated by OM self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" - elif isinstance(resultfile, OMCPath): + elif isinstance(resultfile, OMPathABC): self._result_file = resultfile else: self._result_file = self._session.omcpath(resultfile) if not self._result_file.is_absolute(): self._result_file = self.getWorkDirectory() / resultfile - if not isinstance(self._result_file, OMCPath): + if not isinstance(self._result_file, OMPathABC): raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") om_cmd = self.simulate_cmd( @@ -1294,7 +1295,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: + def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: """ Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, this file is used; else a generic file name is created. @@ -1622,7 +1623,7 @@ def set_command_line_options(self, command_line_option: str): expr = f'setCommandLineOptions("{command_line_option}")' self.sendExpression(expr=expr) - def _loadFile(self, fileName: OMCPath): + def _loadFile(self, fileName: OMPathABC): # load file self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') @@ -2003,7 +2004,7 @@ def convertMo2Fmu( fmuType: str = "me_cs", fileNamePrefix: Optional[str] = None, includeResources: bool = True, - ) -> OMCPath: + ) -> OMPathABC: """Translate the model into a Functional Mockup Unit. Args: @@ -2042,7 +2043,7 @@ def convertMo2Fmu( def convertFmu2Mo( self, fmu: os.PathLike, - ) -> OMCPath: + ) -> 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". @@ -2509,7 +2510,7 @@ def get_doe_solutions( def doe_get_solutions( msomc: ModelicaSystemOMC, - resultpath: OMCPath, + resultpath: OMPathABC, doe_def: Optional[dict] = None, var_list: Optional[list] = None, ) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]: diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index b95f36c1..242febf0 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -249,206 +249,272 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -class OMCPathReal(pathlib.PurePosixPath): - """ - Implementation of a basic (PurePosix)Path object which uses OMC as backend. The connection to OMC is provided via an - instances of OMCSession* classes. - - PurePosixPath is selected to cover usage of OMC in docker or via WSL. Usage of specialised function could result in - errors as well as usage on a Windows system due to slightly different definitions (PureWindowsPath). - """ +# due to the compatibility layer to Python < 3.12, the OM(C)Path classes must be hidden behind the following if +# conditions. This is also the reason for OMPathABC, a simple base class to be used in ModelicaSystem* classes. +# Reason: before Python 3.12, pathlib.PurePosixPath can not be derived from; therefore, OMPathABC is not possible +if sys.version_info < (3, 12): + class OMPathCompatibility(pathlib.Path): + """ + Compatibility class for OMPathABC in Python < 3.12. This allows to run all code which uses OMPathABC (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + """ - def __init__(self, *path, session: OMCSession) -> None: - super().__init__(*path) - self._session = session + # modified copy of pathlib.Path.__new__() definition + def __new__(cls, *args, **kwargs): + logger.warning("Python < 3.12 - using a version of class OMCPath " + "based on pathlib.Path for local usage only.") - def with_segments(self, *pathsegments): - """ - Create a new OMCPath object with the given path segments. + if cls is OMPathCompatibility: + cls = OMPathCompatibilityWindows if os.name == 'nt' else OMPathCompatibilityPosix + self = cls._from_parts(args) + if not self._flavour.is_supported: + raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") + return self - The original definition of Path is overridden to ensure the OMC session is set. - """ - return type(self)(*pathsegments, session=self._session) + def size(self) -> int: + """ + Needed compatibility function to have the same interface as OMCPathReal + """ + return self.stat().st_size - def is_file(self, *, follow_symlinks=True) -> bool: + class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): """ - Check if the path is a regular file. + Compatibility class for OMCPath on Posix systems (Python < 3.12) """ - return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - def is_dir(self, *, follow_symlinks=True) -> bool: + class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): """ - Check if the path is a directory. + Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') - def is_absolute(self): - """ - Check if the path is an absolute path considering the possibility that we are running locally on Windows. This - case needs special handling as the definition of is_absolute() differs. + OMPathABC = OMPathCompatibility + OMCPath = OMPathCompatibility +else: + class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ - if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': - return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return super().is_absolute() + Implementation of a basic (PurePosix)Path object to be used within OMPython. The derived classes can use OMC as + backend and - thus - work on different configurations like docker or WSL. The connection to OMC is provided via + an instances of classes derived from BaseSession. - def read_text(self, encoding=None, errors=None, newline=None) -> str: + PurePosixPath is selected as it covers all but Windows systems (Linux, docker, WSL). However, the code is + written such that possible Windows system are taken into account. Nevertheless, the overall functionality is + limited compared to standard pathlib.Path objects. """ - Read the content of the file represented by this path as text. - The additional arguments `encoding`, `errors` and `newline` are only defined for compatibility with Path() - definition. - """ - return self._session.sendExpression(expr=f'readFile("{self.as_posix()}")') + def __init__(self, *path, session: OMCSession) -> None: + super().__init__(*path) + self._session = session - def write_text(self, data: str, encoding=None, errors=None, newline=None): - """ - Write text data to the file represented by this path. + def with_segments(self, *pathsegments): + """ + Create a new OMCPath object with the given path segments. - The additional arguments `encoding`, `errors`, and `newline` are only defined for compatibility with Path() - definitions. - """ - if not isinstance(data, str): - raise TypeError(f"data must be str, not {data.__class__.__name__}") + The original definition of Path is overridden to ensure the session data is set. + """ + return type(self)(*pathsegments, session=self._session) - data_omc = self._session.escape_str(data) - self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') + @abc.abstractmethod + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ - return len(data) + @abc.abstractmethod + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ - def mkdir(self, mode=0o777, parents=False, exist_ok=False): - """ - Create a directory at the path represented by this OMCPath object. + @abc.abstractmethod + def is_absolute(self): + """ + Check if the path is an absolute path. + """ - The additional arguments `mode`, and `parents` are only defined for compatibility with Path() definitions. - """ - if self.is_dir() and not exist_ok: - raise FileExistsError(f"Directory {self.as_posix()} already exists!") + @abc.abstractmethod + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ - return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') + @abc.abstractmethod + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ - def cwd(self): - """ - Returns the current working directory as an OMCPath object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return OMCPath(cwd_str, session=self._session) + @abc.abstractmethod + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. - def unlink(self, missing_ok: bool = False) -> None: - """ - Unlink (delete) the file or directory represented by this path. - """ - res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') - if not res and not missing_ok: - raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ - def resolve(self, strict: bool = False): - """ - Resolve the path to an absolute path. This is done based on available OMC functions. - """ - if strict and not (self.is_file() or self.is_dir()): - raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + @abc.abstractmethod + def cwd(self): + """ + Returns the current working directory as an OMPathABC object. + """ - if self.is_file(): - pathstr_resolved = self._omc_resolve(self.parent.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name - elif self.is_dir(): - pathstr_resolved = self._omc_resolve(self.as_posix()) - omcpath_resolved = self._session.omcpath(pathstr_resolved) - else: - raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + @abc.abstractmethod + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + @abc.abstractmethod + def resolve(self, strict: bool = False): + """ + Resolve the path to an absolute path. + """ + + def absolute(self): + """ + Resolve the path to an absolute path. Just a wrapper for resolve(). + """ + return self.resolve() - if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): - raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + def exists(self) -> bool: + """ + Semi replacement for pathlib.Path.exists(). + """ + return self.is_file() or self.is_dir() - return omcpath_resolved + @abc.abstractmethod + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + """ - def _omc_resolve(self, pathstr: str) -> str: + class _OMCPath(OMPathABC): """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an + OMCSession* classes. """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') - try: - result = self._session.sendExpression(expr=expr, parsed=False) - result_parts = result.split('\n') - pathstr_resolved = result_parts[1] - pathstr_resolved = pathstr_resolved[1:-1] # remove quotes - except OMCSessionException as ex: - raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - return pathstr_resolved + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') - def absolute(self): - """ - Resolve the path to an absolute path. This is done by calling resolve() as it is the best we can do - using OMC functions. - """ - return self.resolve(strict=True) + def is_absolute(self): + """ + Check if the path is an absolute path. + """ + if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': + return pathlib.PureWindowsPath(self.as_posix()).is_absolute() + return super().is_absolute() - def exists(self, follow_symlinks=True) -> bool: - """ - Semi replacement for pathlib.Path.exists(). - """ - return self.is_file() or self.is_dir() + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + return self._session.sendExpression(expr=f'readFile("{self.as_posix()}")') - def size(self) -> int: - """ - Get the size of the file in bytes - this is an extra function and the best we can do using OMC. - """ - if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") - res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') - if res[0]: - return int(res[1]) + data_omc = self._session.escape_str(data) + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') - raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + return len(data) + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. -if sys.version_info < (3, 12): + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + if self.is_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") - class OMCPathCompatibility(pathlib.Path): - """ - Compatibility class for OMCPath in Python < 3.12. This allows to run all code which uses OMCPath (mainly - ModelicaSystem) on these Python versions. There is one remaining limitation: only OMCProcessLocal will work as - OMCPathCompatibility is based on the standard pathlib.Path implementation. - """ + return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') - # modified copy of pathlib.Path.__new__() definition - def __new__(cls, *args, **kwargs): - logger.warning("Python < 3.12 - using a version of class OMCPath " - "based on pathlib.Path for local usage only.") + def cwd(self): + """ + Returns the current working directory as an OMPathABC object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return OMCPath(cwd_str, session=self._session) - if cls is OMCPathCompatibility: - cls = OMCPathCompatibilityWindows if os.name == 'nt' else OMCPathCompatibilityPosix - self = cls._from_parts(args) - if not self._flavour.is_supported: - raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") - return self + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(expr=f'deleteFile("{self.as_posix()}")') + if not res and not missing_ok: + raise FileNotFoundError(f"Cannot delete file {self.as_posix()} - it does not exists!") - def size(self) -> int: + def resolve(self, strict: bool = False): """ - Needed compatibility function to have the same interface as OMCPathReal + Resolve the path to an absolute path. This is done based on available OMC functions. """ - return self.stat().st_size + if strict and not (self.is_file() or self.is_dir()): + raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + + if self.is_file(): + pathstr_resolved = self._omc_resolve(self.parent.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name + elif self.is_dir(): + pathstr_resolved = self._omc_resolve(self.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) + else: + raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") - class OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): - """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) - """ + if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): + raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") - class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): - """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) - """ + return omcpath_resolved + + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') - OMCPath = OMCPathCompatibility + try: + result = self._session.sendExpression(expr=expr, parsed=False) + result_parts = result.split('\n') + pathstr_resolved = result_parts[1] + pathstr_resolved = pathstr_resolved[1:-1] # remove quotes + except OMCSessionException as ex: + raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex -else: - OMCPath = OMCPathReal + return pathstr_resolved + + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + + OMCPath = _OMCPath class ModelExecutionException(Exception): @@ -570,13 +636,13 @@ def escape_str(value: str) -> str: """ return OMCSession.escape_str(value=value) - def omcpath(self, *path) -> OMCPath: + def omcpath(self, *path) -> OMPathABC: """ Create an OMCPath object based on the given path segments and the current OMC process definition. """ return self.omc_process.omcpath(*path) - def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: """ Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. @@ -796,21 +862,21 @@ def get_version(self) -> str: """ return self.sendExpression("getVersion()", parsed=True) - def set_workdir(self, workdir: OMCPath) -> None: + def set_workdir(self, workdir: OMPathABC) -> None: """ Set the workdir for this session. """ exp = f'cd("{workdir.as_posix()}")' self.sendExpression(exp) - def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: """ Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. """ return [] - def omcpath(self, *path) -> OMCPath: + def omcpath(self, *path) -> OMPathABC: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. """ @@ -823,7 +889,7 @@ def omcpath(self, *path) -> OMCPath: raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") return OMCPath(*path, session=self) - def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: """ Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all filesystem related access. @@ -840,10 +906,10 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: return self._tempdir(tempdir_base=tempdir_base) @staticmethod - def _tempdir(tempdir_base: OMCPath) -> OMCPath: + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: names = [str(uuid.uuid4()) for _ in range(100)] - tempdir: Optional[OMCPath] = None + tempdir: Optional[OMPathABC] = None for name in names: # create a unique temporary directory name tempdir = tempdir_base / name @@ -1243,15 +1309,15 @@ def get_docker_container_id(self) -> str: return self._docker_container_id - def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: """ Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. """ docker_cmd = [ "docker", "exec", "--user", str(self._getuid()), - ] - if isinstance(cwd, OMCPath): + ] + if isinstance(cwd, OMPathABC): docker_cmd += ["--workdir", cwd.as_posix()] docker_cmd += self._docker_extra_args if isinstance(self._docker_container_id, str): @@ -1520,7 +1586,7 @@ def __init__( # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get() - def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: """ Helper function which returns a command prefix needed for docker and WSL. It defaults to an empty list. """ @@ -1530,7 +1596,7 @@ def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: wsl_cmd += ['--distribution', self._wsl_distribution] if isinstance(self._wsl_user, str): wsl_cmd += ['--user', self._wsl_user] - if isinstance(cwd, OMCPath): + if isinstance(cwd, OMPathABC): wsl_cmd += ['--cd', cwd.as_posix()] wsl_cmd += ['--'] diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 9f4408d5..ae47e747 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -23,7 +23,9 @@ doe_get_solutions, ) from OMPython.OMCSession import ( + OMPathABC, OMCPath, + OMCSession, ModelExecutionData, @@ -53,6 +55,7 @@ 'ModelicaDoEOMC', 'ModelicaSystemError', + 'OMPathABC', 'OMCPath', 'OMCSession', From 7626599f9c98445c92052f7fb566034350ade775 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 26 Jan 2026 21:45:46 +0100 Subject: [PATCH 06/34] (B004) define OMCSession:OMSessionABC [OMCSession] update OMCSession* to use OMSessionABC as baseline and further cleanup [ModelicaSystem] shortcut to use OMCSession = OMSessionABC for now [ModelicaSystem] fix usage of OMCSession; replace by OMSessionABC fix usage of OMCSession [OMSessionABC] fix OMCPath; rename to OMPathABC --- OMPython/ModelicaSystem.py | 11 ++-- OMPython/OMCSession.py | 119 ++++++++++++++++++++++++++++++++----- OMPython/__init__.py | 4 +- tests/test_OMCPath.py | 2 +- 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 2a0cdf64..f4583955 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -26,10 +26,11 @@ ModelExecutionException, OMCSessionException, - OMCSession, OMCSessionLocal, OMPathABC, + + OMSessionABC, ) # define logger using the current module name as ID @@ -347,7 +348,7 @@ class ModelicaSystemABC(metaclass=abc.ABCMeta): def __init__( self, - session: OMCSession, + session: OMSessionABC, work_directory: Optional[str | os.PathLike] = None, ) -> None: """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). @@ -397,7 +398,7 @@ def __init__( self._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None - def get_session(self) -> OMCSession: + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. """ @@ -1498,7 +1499,7 @@ def __init__( command_line_options: Optional[list[str]] = None, work_directory: Optional[str | os.PathLike] = None, omhome: Optional[str] = None, - session: Optional[OMCSession] = None, + session: Optional[OMSessionABC] = None, ) -> None: """Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo(). @@ -2221,7 +2222,7 @@ def __init__( self._doe_def: Optional[dict[str, dict[str, Any]]] = None self._doe_cmd: Optional[dict[str, ModelExecutionData]] = None - def get_session(self) -> OMCSession: + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. """ diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 242febf0..91115061 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -70,8 +70,8 @@ class OMCSessionCmd: Implementation of Open Modelica Compiler API functions. Depreciated! """ - def __init__(self, session: OMCSession, readonly: bool = False): - if not isinstance(session, OMCSession): + def __init__(self, session: OMSessionABC, readonly: bool = False): + if not isinstance(session, OMSessionABC): raise OMCSessionException("Invalid OMC process definition!") self._session = session self._readonly = readonly @@ -301,7 +301,7 @@ class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): limited compared to standard pathlib.Path objects. """ - def __init__(self, *path, session: OMCSession) -> None: + def __init__(self, *path, session: OMSessionABC) -> None: super().__init__(*path) self._session = session @@ -610,7 +610,7 @@ def __init__( self, timeout: float = 10.00, omhome: Optional[str] = None, - omc_process: Optional[OMCSession] = None, + omc_process: Optional[OMCSessionABC] = None, ) -> None: """ Initialisation for OMCSessionZMQ @@ -622,7 +622,7 @@ def __init__( if omc_process is None: omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) - elif not isinstance(omc_process, OMCSession): + elif not isinstance(omc_process, OMCSessionABC): raise OMCSessionException("Invalid definition of the OMC process!") self.omc_process = omc_process @@ -634,7 +634,7 @@ def escape_str(value: str) -> str: """ Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. """ - return OMCSession.escape_str(value=value) + return OMCSessionABC.escape_str(value=value) def omcpath(self, *path) -> OMPathABC: """ @@ -689,7 +689,7 @@ def __call__(cls, *args, **kwargs): return obj -class OMCSessionMeta(abc.ABCMeta, PostInitCaller): +class OMSessionMeta(abc.ABCMeta, PostInitCaller): """ Helper class to get a combined metaclass of ABCMeta and PostInitCaller. @@ -698,7 +698,98 @@ class OMCSessionMeta(abc.ABCMeta, PostInitCaller): """ -class OMCSession(metaclass=OMCSessionMeta): +class OMSessionABC(metaclass=OMSessionMeta): + """ + This class implements the basic structure a OMPython session definition needs. It provides the structure for an + implementation using OMC as backend (via ZMQ) or a dummy implementation which just runs a model executable. + """ + + def __init__( + self, + timeout: float = 10.00, + **kwargs, + ) -> None: + """ + Initialisation for OMSessionBase + """ + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # store variables + self._timeout = timeout + + def __post_init__(self) -> None: + """ + Post initialisation method. + """ + + @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 OMPathBase 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. @@ -1104,7 +1195,7 @@ def _get_portfile_path(self) -> Optional[pathlib.Path]: return portfile_path -class OMCSessionPort(OMCSession): +class OMCSessionPort(OMCSessionABC): """ OMCSession implementation which uses a port to connect to an already running OMC server. """ @@ -1117,7 +1208,7 @@ def __init__( self._omc_port = omc_port -class OMCSessionLocal(OMCSession): +class OMCSessionLocal(OMCSessionABC): """ OMCSession implementation which runs the OMC server locally on the machine (Linux / Windows). """ @@ -1198,7 +1289,7 @@ def _omc_port_get(self) -> str: return port -class OMCSessionDockerHelper(OMCSession): +class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): """ Base class for OMCSession implementations which run the OMC server in a Docker container. """ @@ -1326,7 +1417,7 @@ def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: return docker_cmd -class OMCSessionDocker(OMCSessionDockerHelper): +class OMCSessionDocker(OMCSessionDockerABC): """ OMC process running in a Docker container. """ @@ -1468,7 +1559,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: return omc_process, docker_process, docker_cid -class OMCSessionDockerContainer(OMCSessionDockerHelper): +class OMCSessionDockerContainer(OMCSessionDockerABC): """ OMC process running in a Docker container (by container ID). """ @@ -1561,7 +1652,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: return omc_process, docker_process -class OMCSessionWSL(OMCSession): +class OMCSessionWSL(OMCSessionABC): """ OMC process running in Windows Subsystem for Linux (WSL). """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index ae47e747..b04db846 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -26,7 +26,7 @@ OMPathABC, OMCPath, - OMCSession, + OMCSessionABC, ModelExecutionData, ModelExecutionException, @@ -58,7 +58,7 @@ 'OMPathABC', 'OMCPath', - 'OMCSession', + 'OMCSessionABC', 'doe_get_solutions', diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index f4a32eae..df01b86a 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -49,7 +49,7 @@ def test_OMCPath_OMCProcessWSL(): del omcs -def _run_OMCPath_checks(omcs: OMPython.OMCSession): +def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): p1 = omcs.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() From ae77187bb1dff16da1e099db859bb4e14be1a6f7 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:09:02 +0100 Subject: [PATCH 07/34] (C001) Runner definition [OMCSession] add *Runner related classes for OMPath and OMSession [ModelicaSystem] add ModelicaSystemRunner [test_ModelicaSystemRunner] add test case for ModelicaSystemRunner [ModelicaSystem] add ModelicaDoERunner [test_ModelicaDoERunner] add test case for ModelicaDoERunner [OMCSession] move OMCPathRunner* into the if clause [OMSessionRunner] fix usage of sendExpression() [__init__] add missing definitions for *Runner classes [ModelicaDoERunner] fix definition; allow all variations of ModelicaSystem* [test_ModelicaDoERunner] fix definition; test ModelicaSystem(OCM|Runner) [ModelicaDoEABC] add get_resultpath() --- OMPython/ModelicaSystem.py | 102 +++++++++++++++++++ OMPython/OMCSession.py | 148 +++++++++++++++++++++++++++ OMPython/__init__.py | 9 ++ tests/test_ModelicaDoERunner.py | 158 +++++++++++++++++++++++++++++ tests/test_ModelicaSystemRunner.py | 96 ++++++++++++++++++ 5 files changed, 513 insertions(+) create mode 100644 tests/test_ModelicaDoERunner.py create mode 100644 tests/test_ModelicaSystemRunner.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index f4583955..0c49baef 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -31,6 +31,7 @@ OMPathABC, OMSessionABC, + OMSessionRunner, ) # define logger using the current module name as ID @@ -2228,6 +2229,12 @@ def get_session(self) -> OMSessionABC: """ 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 @@ -2579,3 +2586,98 @@ class ModelicaSystemDoE(ModelicaDoEOMC): """ Compatibility class. """ + + +class ModelicaSystemRunner(ModelicaSystemABC): + """ + Class to simulate a Modelica model using a pre-compiled model binary. + """ + + def __init__( + self, + work_directory: Optional[str | os.PathLike] = None, + session: Optional[OMSessionABC] = None, + ) -> None: + if session is None: + session = OMSessionRunner() + + if not isinstance(session, OMSessionRunner): + raise ModelicaSystemError("Only working if OMCsessionDummy is used!") + + super().__init__( + work_directory=work_directory, + session=session, + ) + + def setup( + self, + model_name: Optional[str] = None, + variable_filter: Optional[str] = None, + ) -> None: + """ + 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 {} diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 91115061..84293746 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -289,6 +289,8 @@ class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): OMPathABC = OMPathCompatibility OMCPath = OMPathCompatibility + OMPathRunnerABC = OMPathCompatibility + OMPathRunnerLocal = OMPathCompatibility else: class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ @@ -514,7 +516,95 @@ def size(self) -> int: raise OMCSessionException(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 OMPathBase which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. + + This class is based on OMPathBase and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + the correct implementation on Windows systems. To get a valid Windows representation of the path, use the + conversion via pathlib.Path(.as_posix()). + """ + + 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): + """ + Check if the path is an absolute path. + """ + return self._path().is_absolute() + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + return self._path().read_text(encoding='utf-8') + + def write_text(self, data: str): + """ + Write text data to the file represented by this path. + """ + return self._path().write_text(data=data, encoding='utf-8') + + def mkdir(self, parents: bool = True, exist_ok: bool = False): + """ + Create a directory at the path represented by this class. + + The argument parents with default value True exists to ensure compatibility with the fallback solution for + Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent + directories are also created. + """ + return self._path().mkdir(parents=parents, exist_ok=exist_ok) + + def cwd(self): + """ + Returns the current working directory as an OMPathBase object. + """ + return self._path().cwd() + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + return self._path().unlink(missing_ok=missing_ok) + + def resolve(self, strict: bool = False): + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + path_resolved = self._path().resolve(strict=strict) + return type(self)(path_resolved, session=self._session) + + def size(self) -> int: + """ + Get the size of the file in bytes - implementation baseon on pathlib.Path. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + + path = self._path() + return path.stat().st_size + OMCPath = _OMCPath + OMPathRunnerLocal = _OMPathRunnerLocal class ModelExecutionException(Exception): @@ -1735,3 +1825,61 @@ def _omc_port_get(self) -> str: f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") return port + + +class OMSessionRunner(OMSessionABC): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + timeout: float = 10.00, + version: str = "1.27.0" + ) -> None: + super().__init__(timeout=timeout) + self.model_execution_local = True + self._version = version + + def __post_init__(self) -> None: + """ + No connection to an OMC server is created by this class! + """ + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + return [] + + def get_version(self) -> str: + """ + We can not provide an OM version as we are not link to an OMC server. Thus, the provided version string is used + directly. + """ + return self._version + + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + os.chdir(workdir.as_posix()) + + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMCPath object based on the given path segments and the current OMCSession* class. + """ + return OMPathRunnerLocal(*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 OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") diff --git a/OMPython/__init__.py b/OMPython/__init__.py index b04db846..d6016e53 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -19,6 +19,8 @@ ModelicaSystemDoE, ModelicaDoEOMC, ModelicaSystemError, + ModelicaSystemRunner, + ModelicaDoERunner, doe_get_solutions, ) @@ -26,6 +28,8 @@ OMPathABC, OMCPath, + OMSessionRunner, + OMCSessionABC, ModelExecutionData, @@ -55,9 +59,14 @@ 'ModelicaDoEOMC', 'ModelicaSystemError', + 'ModelicaSystemRunner', + 'ModelicaDoERunner', + 'OMPathABC', 'OMCPath', + 'OMSessionRunner', + 'OMCSessionABC', 'doe_get_solutions', diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py new file mode 100644 index 00000000..2d41315f --- /dev/null +++ b/tests/test_ModelicaDoERunner.py @@ -0,0 +1,158 @@ +import pathlib +import sys + +import numpy as np +import pytest + +import OMPython + +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath(non-local) only working for Python >= 3.12.", +) + + +@pytest.fixture +def model_doe(tmp_path: pathlib.Path) -> pathlib.Path: + # see: https://trac.openmodelica.org/OpenModelica/ticket/4052 + mod = tmp_path / "M.mo" + # TODO: update for bool and string parameters; check if these can be used in DoE + mod.write_text(""" +model M + parameter Integer p=1; + parameter Integer q=1; + parameter Real a = -1; + parameter Real b = -1; + Real x[p]; + Real y[q]; +equation + der(x) = a * fill(1.0, p); + der(y) = b * fill(1.0, q); +end M; +""") + return mod + + +@pytest.fixture +def param_doe() -> dict[str, list]: + param = { + # simple + 'a': [5, 6], + 'b': [7, 8], + } + return param + + +def test_ModelicaDoERunner_ModelicaSystemOMC(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param_doe) + + doe_mod = OMPython.ModelicaDoERunner( + mod=mod, + parameters=param_doe, + resultpath=tmpdir, + ) + + _run_ModelicaDoERunner(doe_mod=doe_mod) + + _check_runner_result(mod=mod, doe_mod=doe_mod) + + +def test_ModelicaDoERunner_ModelicaSystemRunner(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param_doe) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + doe_mod = OMPython.ModelicaDoERunner( + mod=modr, + parameters=param_doe, + resultpath=tmpdir, + ) + + _run_ModelicaDoERunner(doe_mod=doe_mod) + + _check_runner_result(mod=mod, doe_mod=doe_mod) + + +def _run_simulation(mod, resultfile, param): + simOptions = {"stopTime": 1.0, "stepSize": 0.1, "tolerance": 1e-8} + mod.setSimulationOptions(**simOptions) + mod.simulate(resultfile=resultfile) + + assert resultfile.exists() + + +def _run_ModelicaDoERunner(doe_mod): + doe_count = doe_mod.prepare() + assert doe_count == 4 + + doe_def = doe_mod.get_doe_definition() + assert isinstance(doe_def, dict) + assert len(doe_def.keys()) == doe_count + + doe_cmd = doe_mod.get_doe_command() + assert isinstance(doe_cmd, dict) + assert len(doe_cmd.keys()) == doe_count + + doe_status = doe_mod.simulate() + assert doe_status is True + + +def _check_runner_result(mod, doe_mod): + doe_cmd = doe_mod.get_doe_command() + doe_def = doe_mod.get_doe_definition() + + doe_sol = OMPython.doe_get_solutions( + msomc=mod, + resultpath=doe_mod.get_resultpath(), + doe_def=doe_def, + ) + assert isinstance(doe_sol, dict) + assert len(doe_sol.keys()) == len(doe_cmd.keys()) + + assert sorted(doe_def.keys()) == sorted(doe_cmd.keys()) + assert sorted(doe_cmd.keys()) == sorted(doe_sol.keys()) + + for resultfilename in doe_def: + row = doe_def[resultfilename] + + assert resultfilename in doe_sol + sol = doe_sol[resultfilename] + + var_dict = { + # simple / non-structural parameters + 'a': float(row['a']), + 'b': float(row['b']), + } + + for var in var_dict: + assert var in sol['data'] + assert np.isclose(sol['data'][var][-1], var_dict[var]) diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py new file mode 100644 index 00000000..35541c99 --- /dev/null +++ b/tests/test_ModelicaSystemRunner.py @@ -0,0 +1,96 @@ +import numpy as np +import pytest + +import OMPython + + +@pytest.fixture +def model_firstorder_content(): + return """ +model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""" + + +@pytest.fixture +def model_firstorder(tmp_path, model_firstorder_content): + mod = tmp_path / "M.mo" + mod.write_text(model_firstorder_content) + return mod + + +@pytest.fixture +def param(): + x0 = 1 + a = -1 + tau = -1 / a + stopTime = 5*tau + + return { + 'x0': x0, + 'a': a, + 'stopTime': stopTime, + } + + +def test_runner(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystem() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +def _run_simulation(mod, resultfile, param): + simOptions = {"stopTime": param['stopTime'], "stepSize": 0.1, "tolerance": 1e-8} + mod.setSimulationOptions(**simOptions) + mod.simulate(resultfile=resultfile) + + assert resultfile.exists() + + +def _check_result(mod, resultfile, param): + x = mod.getSolutions(resultfile=resultfile, varList="x") + t, x2 = mod.getSolutions(resultfile=resultfile, varList=["time", "x"]) + assert (x2 == x).all() + sol_names = mod.getSolutions(resultfile=resultfile) + assert isinstance(sol_names, tuple) + assert "time" in sol_names + assert "x" in sol_names + assert "der(x)" in sol_names + with pytest.raises(OMPython.ModelicaSystemError): + mod.getSolutions(resultfile=resultfile, varList="thisVariableDoesNotExist") + assert np.isclose(t[0], 0), "time does not start at 0" + assert np.isclose(t[-1], param['stopTime']), "time does not end at stopTime" + x_analytical = param['x0'] * np.exp(param['a']*t) + assert np.isclose(x, x_analytical, rtol=1e-4).all() From 0067e1b25d5026ff6f6a57945e306582fd01cf83 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:36:03 +0100 Subject: [PATCH 08/34] [OM(C)SessionABC] small fixes * comments * prepare cmd_prefix handling within OMSession * fix timeout handling --- OMPython/OMCSession.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 84293746..406e1e76 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -809,12 +809,20 @@ def __init__( # store variables self._timeout = timeout + # command prefix (to be used for docker or WSL) + self._cmd_prefix: list[str] = [] def __post_init__(self) -> None: """ Post initialisation method. """ + def get_cmd_prefix(self) -> list[str]: + """ + Get session definition used for this instance of OMPath. + """ + return self._cmd_prefix.copy() + @staticmethod def escape_str(value: str) -> str: """ @@ -843,7 +851,7 @@ def set_workdir(self, workdir: OMPathABC) -> None: @abc.abstractmethod def omcpath(self, *path) -> OMPathABC: """ - Create an OMPathBase object based on the given path segments and the current class. + Create an OMPathABC object based on the given path segments and the current class. """ @abc.abstractmethod @@ -907,13 +915,12 @@ def __init__( """ Initialisation for OMCSession """ + super().__init__(timeout=timeout) # some helper data self.model_execution_windows = platform.system() == "Windows" self.model_execution_local = False - # store variables - self._timeout = timeout # generate a random string for this instance of OMC self._random_string = uuid.uuid4().hex # get a temporary directory @@ -990,6 +997,7 @@ def __del__(self): self._omc_process.kill() self._omc_process.wait() finally: + self._omc_process = None def _timeout_loop( From c8095c85f36b07dba0c65f7f83a4b27b5ac8b29f Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:41:37 +0100 Subject: [PATCH 09/34] (D002) move OMCSessionZMQ [__init__] define OMSessionABC in the public interface [OMCSessionZMQ] move class definition such that it can be derived from OMSessionABC * needed for the compatibility layer --- OMPython/OMCSession.py | 132 ++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 406e1e76..dd3b2858 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -691,67 +691,6 @@ def run(self) -> int: return returncode -class OMCSessionZMQ: - """ - This class is a compatibility layer for the new schema using OMCSession* classes. - """ - - def __init__( - self, - timeout: float = 10.00, - omhome: Optional[str] = None, - omc_process: Optional[OMCSessionABC] = None, - ) -> None: - """ - 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): - raise OMCSessionException("Invalid definition of the OMC process!") - self.omc_process = omc_process - - def __del__(self): - del self.omc_process - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return OMCSessionABC.escape_str(value=value) - - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMCPath object based on the given path segments and the current OMC process definition. - """ - return self.omc_process.omcpath(*path) - - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all - filesystem related access. - """ - return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) - - def execute(self, command: str): - return self.omc_process.execute(command=command) - - def sendExpression(self, command: 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 OMCSessionException. - """ - return self.omc_process.sendExpression(expr=command, parsed=parsed) - - class PostInitCaller(type): """ Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where @@ -1387,6 +1326,77 @@ def _omc_port_get(self) -> str: return port +class OMCSessionZMQ(OMSessionABC): + """ + This class is a compatibility layer for the new schema using OMCSession* classes. + """ + + def __init__( + self, + timeout: float = 10.00, + omhome: Optional[str] = None, + omc_process: Optional[OMCSessionABC] = None, + ) -> None: + """ + 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): + raise OMCSessionException("Invalid definition of the OMC process!") + self.omc_process = omc_process + + def __del__(self): + if hasattr(self, 'omc_process'): + del self.omc_process + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return OMCSessionABC.escape_str(value=value) + + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMCPath object based on the given path segments and the current OMC process definition. + """ + return self.omc_process.omcpath(*path) + + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all + filesystem related access. + """ + return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) + + def execute(self, command: str): + return self.omc_process.execute(command=command) + + def sendExpression(self, command: 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 OMCSessionException. + """ + return self.omc_process.sendExpression(expr=command, parsed=parsed) + + def get_version(self) -> str: + return self.omc_process.get_version() + + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + return self.omc_process.model_execution_prefix(cwd=cwd) + + 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. From 7f52092509502c2e6d2a273bbe6605840d114fdf Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:27:05 +0100 Subject: [PATCH 10/34] (D003) improve OMCPath [OMPathABC] improve definition * add get_session() * fix return values [(_)OMCPath] improve definition * check return value from OMC * define return value for methods --- OMPython/OMCSession.py | 60 +++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index dd3b2858..79f8d16b 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -307,7 +307,13 @@ def __init__(self, *path, session: OMSessionABC) -> None: super().__init__(*path) self._session = session - def with_segments(self, *pathsegments): + 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. @@ -328,7 +334,7 @@ def is_dir(self) -> bool: """ @abc.abstractmethod - def is_absolute(self): + def is_absolute(self) -> bool: """ Check if the path is an absolute path. """ @@ -340,13 +346,13 @@ def read_text(self) -> str: """ @abc.abstractmethod - def write_text(self, data: str): + 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): + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -356,7 +362,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False): """ @abc.abstractmethod - def cwd(self): + def cwd(self) -> OMPathABC: """ Returns the current working directory as an OMPathABC object. """ @@ -368,12 +374,12 @@ def unlink(self, missing_ok: bool = False) -> None: """ @abc.abstractmethod - def resolve(self, strict: bool = False): + def resolve(self, strict: bool = False) -> OMPathABC: """ Resolve the path to an absolute path. """ - def absolute(self): + def absolute(self) -> OMPathABC: """ Resolve the path to an absolute path. Just a wrapper for resolve(). """ @@ -401,29 +407,38 @@ def is_file(self) -> bool: """ Check if the path is a regular file. """ - return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMCSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + return retval def is_dir(self) -> bool: """ Check if the path is a directory. """ - return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') + retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') + if not isinstance(retval, bool): + raise OMCSessionException(f"Invalid return value for is_dir(): {retval} - expect bool") + return retval - def is_absolute(self): + def is_absolute(self) -> bool: """ - Check if the path is an absolute path. + Check if the path is an absolute path. Special handling to differentiate Windows and Posix definitions. """ if isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': return pathlib.PureWindowsPath(self.as_posix()).is_absolute() - return super().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. """ - return self._session.sendExpression(expr=f'readFile("{self.as_posix()}")') + retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') + if not isinstance(retval, str): + raise OMCSessionException(f"Invalid return value for read_text(): {retval} - expect str") + return retval - def write_text(self, data: str): + def write_text(self, data: str) -> int: """ Write text data to the file represented by this path. """ @@ -435,7 +450,7 @@ def write_text(self, data: str): return len(data) - def mkdir(self, parents: bool = True, exist_ok: bool = False): + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -446,14 +461,15 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False): if self.is_dir() and not exist_ok: raise FileExistsError(f"Directory {self.as_posix()} already exists!") - return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') + if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): + raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") - def cwd(self): + def cwd(self) -> OMPathABC: """ Returns the current working directory as an OMPathABC object. """ cwd_str = self._session.sendExpression(expr='cd()') - return OMCPath(cwd_str, session=self._session) + return type(self)(cwd_str, session=self._session) def unlink(self, missing_ok: bool = False) -> None: """ @@ -463,7 +479,7 @@ def unlink(self, missing_ok: bool = False) -> None: 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): + def resolve(self, strict: bool = False) -> OMPathABC: """ Resolve the path to an absolute path. This is done based on available OMC functions. """ @@ -494,8 +510,10 @@ def _omc_resolve(self, pathstr: str) -> str: 'cd(omcpath_cwd)') try: - result = self._session.sendExpression(expr=expr, parsed=False) - result_parts = result.split('\n') + retval = self.get_session().sendExpression(expr=expr, parsed=False) + if not isinstance(retval, str): + raise OMCSessionException(f"Invalid return value for _omc_resolve(): {retval} - expect str") + result_parts = retval.split('\n') pathstr_resolved = result_parts[1] pathstr_resolved = pathstr_resolved[1:-1] # remove quotes except OMCSessionException as ex: From 7f2a02b07e5891041099273bab18bf2cc533c519 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:30:02 +0100 Subject: [PATCH 11/34] (D004) define OMSessionRunner [(_)OMPathRunnerLocal] improve definition * fix return values * additional cleanups [__init__] define OMPathRunnerLocal for public interface [_OMPathRunnerBash] define class [__init__] define OMPathRunnerBash for public interface [OMSessionRunner] update code such that it can be used by OMPathRunnerLocal and OMPathRunner Bash --- OMPython/OMCSession.py | 223 +++++++++++++++++++++++++++++++++++++---- OMPython/__init__.py | 8 ++ 2 files changed, 213 insertions(+), 18 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 79f8d16b..2151f99f 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -20,7 +20,7 @@ import sys import tempfile import time -from typing import Any, Optional, Tuple +from typing import Any, Optional, Tuple, Type import uuid import warnings @@ -291,6 +291,8 @@ class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): OMCPath = OMPathCompatibility OMPathRunnerABC = OMPathCompatibility OMPathRunnerLocal = OMPathCompatibility + OMPathRunnerBash = OMPathCompatibility + else: class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ @@ -544,10 +546,10 @@ def _path(self) -> pathlib.Path: class _OMPathRunnerLocal(OMPathRunnerABC): """ - Implementation of OMPathBase which does not use the session data at all. Thus, this implementation can run + 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 OMPathBase and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + 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()). """ @@ -564,7 +566,7 @@ def is_dir(self) -> bool: """ return self._path().is_dir() - def is_absolute(self): + def is_absolute(self) -> bool: """ Check if the path is an absolute path. """ @@ -580,9 +582,12 @@ 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): + def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: """ Create a directory at the path represented by this class. @@ -590,21 +595,21 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False): Python < 3.12. In this case, pathlib.Path is used directly and this option ensures, that missing parent directories are also created. """ - return self._path().mkdir(parents=parents, exist_ok=exist_ok) + self._path().mkdir(parents=parents, exist_ok=exist_ok) - def cwd(self): + def cwd(self) -> OMPathABC: """ - Returns the current working directory as an OMPathBase object. + Returns the current working directory as an OMPathABC object. """ - return self._path().cwd() + 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. """ - return self._path().unlink(missing_ok=missing_ok) + self._path().unlink(missing_ok=missing_ok) - def resolve(self, strict: bool = False): + def resolve(self, strict: bool = False) -> OMPathABC: """ Resolve the path to an absolute path. This is done based on available OMC functions. """ @@ -621,8 +626,177 @@ def size(self) -> int: 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 OMCSessionException(f"Error on directory creation for {self.as_posix()}!") from exc + + def cwd(self) -> OMPathABC: + """ + Returns the current working directory as an OMPathABC object. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', 'pwd'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise OSError("Can not get current work directory ...") + + def unlink(self, missing_ok: bool = False) -> None: + """ + Unlink (delete) the file or directory represented by this path. + """ + + if not self.is_file(): + raise OSError(f"Can not unlink a directory: {self.as_posix()}!") + + if not self.is_file(): + return + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'rm "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + except subprocess.CalledProcessError as exc: + raise OSError(f"Cannot unlink file {self.as_posix()}: {exc}") from exc + + def resolve(self, strict: bool = False) -> OMPathABC: + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'readlink -f "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + if result.returncode == 0: + return type(self)(result.stdout.strip(), session=self._session) + raise FileNotFoundError(f"Cannot resolve path: {self.as_posix()}") + + def size(self) -> int: + """ + Get the size of the file in bytes - implementation baseon on pathlib.Path. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'stat -c %s "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, text=True, check=True) + stdout = result.stdout.strip() + if result.returncode == 0: + try: + return int(stdout) + except ValueError as exc: + raise OSError(f"Invalid return value for filesize ({self.as_posix()}): {stdout}") from exc + else: + raise OSError(f"Cannot get size for file {self.as_posix()}") + OMCPath = _OMCPath OMPathRunnerLocal = _OMPathRunnerLocal + OMPathRunnerBash = _OMPathRunnerBash class ModelExecutionException(Exception): @@ -1870,13 +2044,26 @@ class OMSessionRunner(OMSessionABC): def __init__( self, - timeout: float = 10.00, - version: str = "1.27.0" + timeout: float = 10.0, + version: str = "1.27.0", + ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, + cmd_prefix: Optional[list[str]] = None, + model_execution_local: bool = True, ) -> None: super().__init__(timeout=timeout) - self.model_execution_local = True self._version = version + if not issubclass(ompath_runner, OMPathRunnerABC): + raise OMCSessionException(f"Invalid OMPathRunner class: {type(ompath_runner)}!") + self._ompath_runner = ompath_runner + + self.model_execution_local = model_execution_local + if cmd_prefix is not None: + self._cmd_prefix = cmd_prefix + + # TODO: some checking?! + # if ompath_runner == Type[OMPathRunnerBash]: + def __post_init__(self) -> None: """ No connection to an OMC server is created by this class! @@ -1886,7 +2073,7 @@ def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: """ Helper function which returns a command prefix. """ - return [] + return self.get_cmd_prefix() def get_version(self) -> str: """ @@ -1897,15 +2084,15 @@ def get_version(self) -> str: def set_workdir(self, workdir: OMPathABC) -> None: """ - Set the workdir for this session. + Set the workdir for this session. For OMSessionRunner this is a nop. The workdir must be defined within the + definition of cmd_prefix. """ - os.chdir(workdir.as_posix()) def omcpath(self, *path) -> OMPathABC: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. """ - return OMPathRunnerLocal(*path, session=self) + return self._ompath_runner(*path, session=self) def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index d6016e53..4dc2f974 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -41,6 +41,10 @@ OMCSessionException, OMCSessionLocal, OMCSessionPort, + + OMPathRunnerBash, + OMPathRunnerLocal, + OMCSessionWSL, OMCSessionZMQ, ) @@ -77,6 +81,10 @@ 'OMCSessionException', 'OMCSessionPort', 'OMCSessionLocal', + + 'OMPathRunnerBash', + 'OMPathRunnerLocal', + 'OMCSessionWSL', 'OMCSessionZMQ', ] From 8361414852eb93a37e06e7b836793faf4f3e6ac4 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:36:24 +0100 Subject: [PATCH 12/34] (D005) update classes in OMCSession [OMCSessionPort] fix timeout handling [OMCSessionDocker*] improve data handling * move more code to OMCSessionDockerHelper * use _docker_omc_start() to differentiate classes * define cmd_prefix [OMCSessionWSL] define cmd_prefix [OMCSessionWSL] layout fix --- OMPython/OMCSession.py | 171 ++++++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 61 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 2151f99f..0d295e86 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1432,8 +1432,9 @@ class OMCSessionPort(OMCSessionABC): def __init__( self, omc_port: str, + timeout: float = 10.0, ) -> None: - super().__init__() + super().__init__(timeout=timeout) self._omc_port = omc_port @@ -1596,7 +1597,9 @@ class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): def __init__( self, - timeout: float = 10.00, + timeout: float = 10.0, + docker: Optional[str] = None, + dockerContainer: Optional[str] = None, dockerExtraArgs: Optional[list] = None, dockerOpenModelicaPath: str | os.PathLike = "omc", dockerNetwork: Optional[str] = None, @@ -1610,11 +1613,21 @@ def __init__( self._docker_extra_args = dockerExtraArgs self._docker_open_modelica_path = pathlib.PurePosixPath(dockerOpenModelicaPath) self._docker_network = dockerNetwork + self._docker_container_id: str + self._docker_process: Optional[DockerPopen] - self._interactive_port = port + # start up omc executable in docker container waiting for the ZMQ connection + self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start( + docker_image=docker, + docker_cid=dockerContainer, + omc_port=port, + ) + # connect to the running omc instance using ZMQ + self._omc_port = self._omc_port_get(docker_cid=self._docker_container_id) + if port is not None and not self._omc_port.endswith(f":{port}"): + raise OMCSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") - self._docker_container_id: Optional[str] = None - self._docker_process: Optional[DockerPopen] = None + self._cmd_prefix = self.model_execution_prefix() def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: if sys.platform == 'win32': @@ -1640,6 +1653,15 @@ def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: return docker_process + @abc.abstractmethod + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + pass + @staticmethod def _getuid() -> int: """ @@ -1651,11 +1673,14 @@ def _getuid() -> int: # Windows, hence the type: ignore comment. return 1000 if sys.platform == 'win32' else os.getuid() # type: ignore - def _omc_port_get(self) -> str: + def _omc_port_get( + self, + docker_cid: str, + ) -> str: port = None - if not isinstance(self._docker_container_id, str): - raise OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}") + if not isinstance(docker_cid, str): + raise OMCSessionException(f"Invalid docker container ID: {docker_cid}") # See if the omc server is running loop = self._timeout_loop(timestep=0.1) @@ -1664,7 +1689,7 @@ def _omc_port_get(self) -> str: if omc_portfile_path is not None: try: output = subprocess.check_output(args=["docker", - "exec", self._docker_container_id, + "exec", docker_cid, "cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL) port = output.decode().strip() @@ -1687,7 +1712,10 @@ def get_server_address(self) -> Optional[str]: """ if self._docker_network == "separate" and isinstance(self._docker_container_id, str): output = subprocess.check_output(["docker", "inspect", self._docker_container_id]).decode().strip() - return json.loads(output)[0]["NetworkSettings"]["IPAddress"] + address = json.loads(output)[0]["NetworkSettings"]["IPAddress"] + if not isinstance(address, str): + raise OMCSessionException(f"Invalid docker server address: {address}!") + return address return None @@ -1734,27 +1762,16 @@ def __init__( super().__init__( timeout=timeout, + docker=docker, dockerExtraArgs=dockerExtraArgs, dockerOpenModelicaPath=dockerOpenModelicaPath, dockerNetwork=dockerNetwork, port=port, ) - if docker is None: - raise OMCSessionException("Argument docker must be set!") - - self._docker = docker - - # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process, self._docker_container_id = self._docker_omc_start() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - def __del__(self) -> None: - super().__del__() - - if isinstance(self._docker_process, DockerPopen): + if hasattr(self, '_docker_process') and isinstance(self._docker_process, DockerPopen): try: self._docker_process.wait(timeout=2.0) except subprocess.TimeoutExpired: @@ -1766,29 +1783,37 @@ def __del__(self) -> None: finally: self._docker_process = None + super().__del__() + def _docker_omc_cmd( self, - omc_path_and_args_list: list[str], + docker_image: str, docker_cid_file: pathlib.Path, + omc_path_and_args_list: list[str], + omc_port: Optional[int | str] = None, ) -> list: """ Define the command that will be called by the subprocess module. """ + extra_flags = [] if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactive_port: - raise OMCSessionException("docker on Windows requires knowing which port to connect to - " + if not self._omc_port: + raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " "please set the interactivePort argument") + port: Optional[int] = None + if isinstance(omc_port, str): + port = int(omc_port) + elif isinstance(omc_port, int): + port = omc_port + if sys.platform == "win32": - if isinstance(self._interactive_port, str): - port = int(self._interactive_port) - elif isinstance(self._interactive_port, int): - port = self._interactive_port - else: - raise OMCSessionException("Missing or invalid interactive port!") + if not isinstance(port, int): + raise OMCSessionException("OMC on Windows needs the interactive port - " + f"missing or invalid value: {repr(omc_port)}!") docker_network_str = ["-p", f"127.0.0.1:{port}:{port}"] elif self._docker_network == "host" or self._docker_network is None: docker_network_str = ["--network=host"] @@ -1799,8 +1824,8 @@ def _docker_omc_cmd( raise OMCSessionException(f'dockerNetwork was set to {self._docker_network}, ' 'but only \"host\" or \"separate\" is allowed') - if isinstance(self._interactive_port, int): - extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] + if isinstance(port, int): + extra_flags = extra_flags + [f"--interactivePort={port}"] omc_command = ([ "docker", "run", @@ -1810,22 +1835,33 @@ def _docker_omc_cmd( ] + self._docker_extra_args + docker_network_str - + [self._docker, self._docker_open_modelica_path.as_posix()] + + [docker_image, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_image, str): + raise OMCSessionException("A docker image name must be provided!") + my_env = os.environ.copy() docker_cid_file = self._temp_dir / (self._omc_filebase + ".docker.cid") omc_command = self._docker_omc_cmd( + docker_image=docker_image, + docker_cid_file=docker_cid_file, omc_path_and_args_list=["--locale=C", "--interactive=zmq", f"-z={self._random_string}"], - docker_cid_file=docker_cid_file, + omc_port=omc_port, ) omc_process = subprocess.Popen(omc_command, @@ -1836,6 +1872,7 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: if not isinstance(docker_cid_file, pathlib.Path): raise OMCSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") + # the provided value for docker_cid is not used docker_cid = None loop = self._timeout_loop(timestep=0.1) while next(loop): @@ -1846,10 +1883,12 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: pass if docker_cid is not None: break - else: - logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") + time.sleep(self._timeout / 40.0) + + if docker_cid is None: raise OMCSessionException(f"Docker did not start (timeout={self._timeout} might be too short " - "especially if you did not docker pull the image before this command).") + "especially if you did not docker pull the image before this command). " + f"Log-file says:\n{self.get_log()}") docker_process = self._docker_process_get(docker_cid=docker_cid) if docker_process is None: @@ -1876,22 +1915,13 @@ def __init__( super().__init__( timeout=timeout, + dockerContainer=dockerContainer, dockerExtraArgs=dockerExtraArgs, dockerOpenModelicaPath=dockerOpenModelicaPath, dockerNetwork=dockerNetwork, port=port, ) - if not isinstance(dockerContainer, str): - raise OMCSessionException("Argument dockerContainer must be set!") - - self._docker_container_id = dockerContainer - - # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process = self._docker_omc_start() - # connect to the running omc instance using ZMQ - self._omc_port = self._omc_port_get() - def __del__(self) -> None: super().__del__() @@ -1899,7 +1929,12 @@ def __del__(self) -> None: # docker container ID was provided - do NOT kill the docker process! self._docker_process = None - def _docker_omc_cmd(self, omc_path_and_args_list) -> list: + def _docker_omc_cmd( + self, + docker_cid: str, + omc_path_and_args_list: list[str], + omc_port: Optional[int] = None, + ) -> list: """ Define the command that will be called by the subprocess module. """ @@ -1907,33 +1942,44 @@ def _docker_omc_cmd(self, omc_path_and_args_list) -> list: if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactive_port: + if not isinstance(omc_port, int): raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " "Please set the interactivePort argument. Furthermore, the container needs " "to have already manually exposed this port when it was started " "(-p 127.0.0.1:n:n) or you get an error later.") - if isinstance(self._interactive_port, int): - extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] + if isinstance(omc_port, int): + extra_flags = extra_flags + [f"--interactivePort={omc_port}"] omc_command = ([ "docker", "exec", "--user", str(self._getuid()), ] + self._docker_extra_args - + [self._docker_container_id, self._docker_open_modelica_path.as_posix()] + + [docker_cid, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: + def _docker_omc_start( + self, + docker_image: Optional[str] = None, + docker_cid: Optional[str] = None, + omc_port: Optional[int] = None, + ) -> Tuple[subprocess.Popen, DockerPopen, str]: + + if not isinstance(docker_cid, str): + raise OMCSessionException("A docker container ID must be provided!") + my_env = os.environ.copy() omc_command = self._docker_omc_cmd( + docker_cid=docker_cid, omc_path_and_args_list=["--locale=C", "--interactive=zmq", f"-z={self._random_string}"], + omc_port=omc_port, ) omc_process = subprocess.Popen(omc_command, @@ -1942,14 +1988,14 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: env=my_env) docker_process = None - if isinstance(self._docker_container_id, str): - docker_process = self._docker_process_get(docker_cid=self._docker_container_id) + if isinstance(docker_cid, str): + docker_process = self._docker_process_get(docker_cid=docker_cid) if docker_process is None: raise OMCSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {self._docker_container_id}. Log-file says:\n{self.get_log()}") + f"/ {docker_cid}. Log-file says:\n{self.get_log()}") - return omc_process, docker_process + return omc_process, docker_process, docker_cid class OMCSessionWSL(OMCSessionABC): @@ -1977,6 +2023,8 @@ def __init__( # 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. @@ -2000,7 +2048,8 @@ def _omc_process_get(self) -> subprocess.Popen: self._wsl_omc, "--locale=C", "--interactive=zmq", - f"-z={self._random_string}"] + f"-z={self._random_string}", + ] omc_process = subprocess.Popen(omc_command, stdout=self._omc_loghandle, From 97c3e4b18a9fbc22182af5e408aabb3052a7b23d Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 21:05:21 +0100 Subject: [PATCH 13/34] (D006) small fixes in ModelicaSystem [ModelicaSystemABC] reorder code in __init__() [ModelicaSystem*] linter fixes --- OMPython/ModelicaSystem.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 0c49baef..8c213357 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -383,22 +383,21 @@ def __init__( self._linearized_outputs: list[str] = [] # linearization output list self._linearized_states: list[str] = [] # linearization states list - self._session = session - - # get OpenModelica version - version_str = self._session.get_version() - self._version = self._parse_om_version(version=version_str) - self._simulated = False # True if the model has already been simulated self._result_file: Optional[OMPathABC] = None # for storing result file - self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) - self._model_name: Optional[str] = None self._libraries: Optional[list[str | tuple[str, str]]] = None self._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None + self._session = session + # get OpenModelica version + version_str = self._session.get_version() + self._version = self._parse_om_version(version=version_str) + + self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) + def get_session(self) -> OMSessionABC: """ Return the OMC session used for this class. @@ -468,6 +467,8 @@ def _xmlparse(self, xml_file: OMPathABC): xml_content = xml_file.read_text() tree = ET.ElementTree(ET.fromstring(xml_content)) root = tree.getroot() + if root is None: + raise ModelicaSystemError(f"Cannot read XML file: {xml_file}") for attr in root.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", "solver", "outputFormat"): @@ -1931,7 +1932,7 @@ def getSolutions( self, varList: Optional[str | list[str]] = None, resultfile: Optional[str | os.PathLike] = None, - ) -> tuple[str] | np.ndarray: + ) -> tuple[str, ...] | np.ndarray: """Extract simulation results from a result data file. Args: @@ -1980,7 +1981,8 @@ def getSolutions( result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') self.sendExpression(expr="closeSimulationResultFile()") if varList is None: - return result_vars + var_list = [str(var) for var in result_vars] + return tuple(var_list) if isinstance(varList, str): var_list_checked = [varList] @@ -2060,6 +2062,8 @@ def convertFmu2Mo( raise ModelicaSystemError(f"Missing FMU file: {fmu_path.as_posix()}") filename = self._requestApi(apiName='importFMU', entity=fmu_path.as_posix()) + if not isinstance(filename, str): + raise ModelicaSystemError(f"Invalid return value for the FMU filename: {filename}") filepath = self.getWorkDirectory() / filename # report proper error message @@ -2102,7 +2106,9 @@ def optimize(self) -> dict[str, Any]: """ properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) self.set_command_line_options("-g=Optimica") - return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + retval = self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) + retval = cast(dict, retval) + return retval class ModelicaSystem(ModelicaSystemOMC): From e3984e7e49c12d0fc42a6e55cf2b92259328829c Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:23:45 +0100 Subject: [PATCH 14/34] (D008) add v4.0.0 compatibility layer [OMTypedParser] compatibility layer [__init__/OMCSession] prepare compatibility layer [ModelicaSystem] define as compatibility layer [ModelicaSystemCmd] define as compatibility layer --- OMPython/ModelicaSystem.py | 179 +++++++++++++++++++++++++++++ OMPython/OMCSession.py | 8 ++ OMPython/OMTypedParser.py | 3 + OMPython/__init__.py | 13 +++ tests/test_ModelicaSystemRunner.py | 2 +- 5 files changed, 204 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 8c213357..832eeeec 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2116,6 +2116,138 @@ class ModelicaSystem(ModelicaSystemOMC): Compatibility class. """ + def __init__( + self, + fileName: Optional[str | os.PathLike | pathlib.Path] = None, + modelName: Optional[str] = None, + lmodel: Optional[list[str | tuple[str, str]]] = None, + commandLineOptions: Optional[list[str]] = None, + variableFilter: Optional[str] = None, + customBuildDirectory: Optional[str | os.PathLike] = None, + omhome: Optional[str] = None, + omc_process: Optional[OMCSessionLocal] = None, + build: bool = True, + ) -> None: + super().__init__( + command_line_options=commandLineOptions, + work_directory=customBuildDirectory, + omhome=omhome, + session=omc_process, + ) + self.model( + model_name=modelName, + model_file=fileName, + libraries=lmodel, + variable_filter=variableFilter, + build=build, + ) + self._getconn = self._session + + def setCommandLineOptions(self, commandLineOptions: str): + super().set_command_line_options(command_line_option=commandLineOptions) + + def setContinuous( # type: ignore[override] + self, + cvals: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(cvals, dict): + return super().setContinuous(**cvals) + raise ModelicaSystemError("Only dict input supported for setContinuous()") + + def setParameters( # type: ignore[override] + self, + pvals: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(pvals, dict): + return super().setParameters(**pvals) + raise ModelicaSystemError("Only dict input supported for setParameters()") + + def setOptimizationOptions( # type: ignore[override] + self, + optimizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(optimizationOptions, dict): + return super().setOptimizationOptions(**optimizationOptions) + raise ModelicaSystemError("Only dict input supported for setOptimizationOptions()") + + def setInputs( # type: ignore[override] + self, + name: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(name, dict): + return super().setInputs(**name) + raise ModelicaSystemError("Only dict input supported for setInputs()") + + def setSimulationOptions( # type: ignore[override] + self, + simOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(simOptions, dict): + return super().setSimulationOptions(**simOptions) + raise ModelicaSystemError("Only dict input supported for setSimulationOptions()") + + def setLinearizationOptions( # type: ignore[override] + self, + linearizationOptions: str | list[str] | dict[str, Any], + ) -> bool: + if isinstance(linearizationOptions, dict): + return super().setLinearizationOptions(**linearizationOptions) + raise ModelicaSystemError("Only dict input supported for setLinearizationOptions()") + + def getContinuous( + self, + names: Optional[str | list[str]] = None, + ): + retval = super().getContinuous(names=names) + if self._simulated: + return retval + + if isinstance(retval, dict): + retval2: dict = {} + for key, val in retval.items(): + if np.isnan(val): + retval2[key] = None + else: + retval2[key] = str(val) + return retval2 + if isinstance(retval, list): + retval3: list[str | None] = [] + for val in retval: + if np.isnan(val): + retval3.append(None) + else: + retval3.append(str(val)) + return retval3 + + raise ModelExecutionException("Invalid data!") + + def getOutputs( + self, + names: Optional[str | list[str]] = None, + ): + retval = super().getOutputs(names=names) + if self._simulated: + return retval + + if isinstance(retval, dict): + retval2: dict = {} + for key, val in retval.items(): + if np.isnan(val): + retval2[key] = None + else: + retval2[key] = str(val) + return retval2 + if isinstance(retval, list): + retval3: list[str | None] = [] + for val in retval: + if np.isnan(val): + retval3.append(None) + else: + retval3.append(str(val)) + return retval3 + + raise ModelExecutionException("Invalid data!") + class ModelicaDoEABC(metaclass=abc.ABCMeta): """ @@ -2687,3 +2819,50 @@ def _prepare_structure_parameters( "pre-compiled binary of model.") return {} + + +class ModelicaSystemCmd(ModelExecutionCmd): + # TODO: docstring + + def __init__( + self, + runpath: pathlib.Path, + modelname: str, + timeout: float = 10.0, + ) -> None: + super().__init__( + runpath=runpath, + timeout=timeout, + cmd_prefix=[], + model_name=modelname, + ) + + def get_exe(self) -> pathlib.Path: + """Get the path to the compiled model executable.""" + # TODO: move to the top + import platform + + path_run = pathlib.Path(self._runpath) + if platform.system() == "Windows": + path_exe = path_run / f"{self._model_name}.exe" + else: + path_exe = path_run / self._model_name + + if not path_exe.exists(): + raise ModelicaSystemError(f"Application file path not found: {path_exe}") + + return path_exe + + def get_cmd(self) -> list: + """Get a list with the path to the executable and all command line args. + + This can later be used as an argument for subprocess.run(). + """ + + cmdl = [self.get_exe().as_posix()] + self.get_cmd_args() + + return cmdl + + def run(self): + cmd_definition = self.definition() + return cmd_definition.run() diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 0d295e86..c25ee162 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -2155,3 +2155,11 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC def sendExpression(self, expr: str, parsed: bool = True) -> Any: raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") + + +DummyPopen = DockerPopen +OMCProcessLocal = OMCSessionLocal +OMCProcessPort = OMCSessionPort +OMCProcessDocker = OMCSessionDocker +OMCProcessDockerContainer = OMCSessionDockerContainer +OMCProcessWSL = OMCSessionWSL diff --git a/OMPython/OMTypedParser.py b/OMPython/OMTypedParser.py index 06912221..9fe810e0 100644 --- a/OMPython/OMTypedParser.py +++ b/OMPython/OMTypedParser.py @@ -161,3 +161,6 @@ def om_parser_typed(string) -> Any: if len(res) == 0: return None return res[0] + + +parseString = om_parser_typed diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 4dc2f974..c12f8524 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -23,6 +23,8 @@ ModelicaDoERunner, doe_get_solutions, + + ModelicaSystemCmd, ) from OMPython.OMCSession import ( OMPathABC, @@ -47,6 +49,11 @@ OMCSessionWSL, OMCSessionZMQ, + + OMCProcessLocal, + OMCProcessPort, + OMCProcessDocker, + OMCProcessDockerContainer, ) # global names imported if import 'from OMPython import *' is used @@ -58,6 +65,7 @@ 'ModelicaSystem', 'ModelicaSystemOMC', + 'ModelicaSystemCmd', 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaDoEOMC', @@ -87,4 +95,9 @@ 'OMCSessionWSL', 'OMCSessionZMQ', + + 'OMCProcessLocal', + 'OMCProcessPort', + 'OMCProcessDocker', + 'OMCProcessDockerContainer', ] diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py index 35541c99..ec9d734d 100644 --- a/tests/test_ModelicaSystemRunner.py +++ b/tests/test_ModelicaSystemRunner.py @@ -39,7 +39,7 @@ def param(): def test_runner(model_firstorder, param): # create a model using ModelicaSystem - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_firstorder, model_name="M", From 1fb36813938d03065c2cdc439c42dd6fd8c94626 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 20:48:16 +0100 Subject: [PATCH 15/34] (D007) define unittest / workflow for v4.0.0 add workflow to run unittests in ./tests tests from v4.0.0 fix test_linearization from v4.0.0 flake8 error: test_linearization.py:71:5: E741 ambiguous variable name 'l' this was fixed in: 'update usage of flake8 (#357)' (SHA1: 70cb446f537345c33f024aa44bc107548970ebc4) fix test_ModelicaSystem - needed adaptions: * convert OMCPath to pathlib.Path * use correct exceptions define test workflows for v400 --- .github/workflows/Test_v400.yml | 73 +++++ .github/workflows/Test_v400_py310.yml | 70 +++++ .github/workflows/Test_v4xx.yml | 73 +++++ .pre-commit-config.yaml | 2 +- tests_v400/__init__.py | 0 tests_v400/test_ArrayDimension.py | 19 ++ tests_v400/test_FMIExport.py | 24 ++ tests_v400/test_ModelicaSystem.py | 411 ++++++++++++++++++++++++++ tests_v400/test_ModelicaSystemCmd.py | 51 ++++ tests_v400/test_OMParser.py | 43 +++ tests_v400/test_OMSessionCmd.py | 17 ++ tests_v400/test_ZMQ.py | 70 +++++ tests_v400/test_docker.py | 32 ++ tests_v400/test_linearization.py | 102 +++++++ tests_v400/test_optimization.py | 67 +++++ tests_v400/test_typedParser.py | 53 ++++ 16 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/Test_v400.yml create mode 100644 .github/workflows/Test_v400_py310.yml create mode 100644 .github/workflows/Test_v4xx.yml create mode 100644 tests_v400/__init__.py create mode 100644 tests_v400/test_ArrayDimension.py create mode 100644 tests_v400/test_FMIExport.py create mode 100644 tests_v400/test_ModelicaSystem.py create mode 100644 tests_v400/test_ModelicaSystemCmd.py create mode 100644 tests_v400/test_OMParser.py create mode 100644 tests_v400/test_OMSessionCmd.py create mode 100644 tests_v400/test_ZMQ.py create mode 100644 tests_v400/test_docker.py create mode 100644 tests_v400/test_linearization.py create mode 100644 tests_v400/test_optimization.py create mode 100644 tests_v400/test_typedParser.py diff --git a/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml new file mode 100644 index 00000000..2407e060 --- /dev/null +++ b/.github/workflows/Test_v400.yml @@ -0,0 +1,73 @@ +name: Test-v4.0.0 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + # * latest available Python version + python-version: ['3.10', '3.14'] + # * Linux using ubuntu-latest + # * Windows using windows-latest + os: ['ubuntu-latest', 'windows-latest'] + # * OM stable - latest stable version + # * OM nightly - latest nightly build + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.6 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v400_py310.yml b/.github/workflows/Test_v400_py310.yml new file mode 100644 index 00000000..dbe635be --- /dev/null +++ b/.github/workflows/Test_v400_py310.yml @@ -0,0 +1,70 @@ +name: Test-v4.0.0-py310 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + python-version: ['3.10'] + # * Linux using ubuntu-latest + os: ['ubuntu-latest'] + # * OM stable - latest stable version + omc-version: ['stable'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.6 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v4xx.yml b/.github/workflows/Test_v4xx.yml new file mode 100644 index 00000000..cc662ff9 --- /dev/null +++ b/.github/workflows/Test_v4xx.yml @@ -0,0 +1,73 @@ +name: Test-v4.x.x + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + # test for: + # * oldest supported version + # * latest available Python version + python-version: ['3.10', '3.14'] + # * Linux using ubuntu-latest + # * Windows using windows-latest + os: ['ubuntu-latest', 'windows-latest'] + # * OM stable - latest stable version + # * OM nightly - latest nightly build + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.6 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests' + click-to-expand: true + report-title: 'Test Report' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 484570b6..dd477775 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: hooks: - id: mypy args: [] - exclude: tests/ + exclude: 'test|test_v400' additional_dependencies: - pyparsing - types-psutil diff --git a/tests_v400/__init__.py b/tests_v400/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests_v400/test_ArrayDimension.py b/tests_v400/test_ArrayDimension.py new file mode 100644 index 00000000..13b3c11b --- /dev/null +++ b/tests_v400/test_ArrayDimension.py @@ -0,0 +1,19 @@ +import OMPython + + +def test_ArrayDimension(tmp_path): + omc = OMPython.OMCSessionZMQ() + + omc.sendExpression(f'cd("{tmp_path.as_posix()}")') + + omc.sendExpression('loadString("model A Integer x[5+1,1+6]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[0][-1] == (6, 7), "array dimension does not match" + + omc.sendExpression('loadString("model A Integer y = 5; Integer x[y+1,1+9]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[-1][-1] == ('y+1', 10), "array dimension does not match" diff --git a/tests_v400/test_FMIExport.py b/tests_v400/test_FMIExport.py new file mode 100644 index 00000000..f47b87ae --- /dev/null +++ b/tests_v400/test_FMIExport.py @@ -0,0 +1,24 @@ +import OMPython +import shutil +import os + + +def test_CauerLowPassAnalog(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="CauerLowPassAnalog") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def test_DrumBoiler(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="DrumBoiler") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests_v400/test_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py new file mode 100644 index 00000000..c55e95fc --- /dev/null +++ b/tests_v400/test_ModelicaSystem.py @@ -0,0 +1,411 @@ +import OMPython +import os +import pathlib +import pytest +import tempfile +import numpy as np + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +def test_ModelicaSystem_loop(model_firstorder): + def worker(): + filePath = model_firstorder.as_posix() + m = OMPython.ModelicaSystem(filePath, "M") + m.simulate() + m.convertMo2Fmu(fmuType="me") + for _ in range(10): + worker() + + +def test_setParameters(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") + + # method 1 + mod.setParameters(pvals={"e": 1.234}) + mod.setParameters(pvals={"g": 321.0}) + assert mod.getParameters("e") == ["1.234"] + assert mod.getParameters("g") == ["321.0"] + assert mod.getParameters() == { + "e": "1.234", + "g": "321.0", + } + with pytest.raises(KeyError): + mod.getParameters("thisParameterDoesNotExist") + + # method 2 + mod.setParameters(pvals={"e": 21.3, "g": 0.12}) + assert mod.getParameters() == { + "e": "21.3", + "g": "0.12", + } + assert mod.getParameters(["e", "g"]) == ["21.3", "0.12"] + assert mod.getParameters(["g", "e"]) == ["0.12", "21.3"] + with pytest.raises(KeyError): + mod.getParameters(["g", "thisParameterDoesNotExist"]) + + +def test_setSimulationOptions(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(fileName=model_path + "BouncingBall.mo", modelName="BouncingBall") + + # method 1 + mod.setSimulationOptions(simOptions={"stopTime": 1.234}) + mod.setSimulationOptions(simOptions={"tolerance": 1.1e-08}) + assert mod.getSimulationOptions("stopTime") == ["1.234"] + assert mod.getSimulationOptions("tolerance") == ["1.1e-08"] + assert mod.getSimulationOptions(["tolerance", "stopTime"]) == ["1.1e-08", "1.234"] + d = mod.getSimulationOptions() + assert isinstance(d, dict) + assert d["stopTime"] == "1.234" + assert d["tolerance"] == "1.1e-08" + with pytest.raises(KeyError): + mod.getSimulationOptions("thisOptionDoesNotExist") + + # method 2 + mod.setSimulationOptions(simOptions={"stopTime": 2.1, "tolerance": "1.2e-08"}) + d = mod.getSimulationOptions() + assert d["stopTime"] == "2.1" + assert d["tolerance"] == "1.2e-08" + + +def test_relative_path(model_firstorder): + cwd = pathlib.Path.cwd() + (fd, name) = tempfile.mkstemp(prefix='tmpOMPython.tests', dir=cwd, text=True) + try: + with os.fdopen(fd, 'w') as f: + f.write(model_firstorder.read_text()) + + model_file = pathlib.Path(name).relative_to(cwd) + model_relative = str(model_file) + assert "/" not in model_relative + + mod = OMPython.ModelicaSystem(fileName=model_relative, modelName="M") + assert float(mod.getParameters("a")[0]) == -1 + finally: + model_file.unlink() # clean up the temporary file + + +def test_customBuildDirectory(tmp_path, model_firstorder): + filePath = model_firstorder.as_posix() + tmpdir = tmp_path / "tmpdir1" + tmpdir.mkdir() + m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) + assert pathlib.Path(m.getWorkDirectory().resolve()) == tmpdir.resolve() + result_file = tmpdir / "a.mat" + assert not result_file.exists() + m.simulate(resultfile="a.mat") + assert result_file.is_file() + + +def test_getSolutions(model_firstorder): + filePath = model_firstorder.as_posix() + mod = OMPython.ModelicaSystem(filePath, "M") + x0 = 1 + a = -1 + tau = -1 / a + stopTime = 5*tau + mod.setSimulationOptions(simOptions={"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8}) + mod.simulate() + + x = mod.getSolutions("x") + t, x2 = mod.getSolutions(["time", "x"]) + assert (x2 == x).all() + sol_names = mod.getSolutions() + assert isinstance(sol_names, tuple) + assert "time" in sol_names + assert "x" in sol_names + assert "der(x)" in sol_names + with pytest.raises(OMPython.ModelicaSystemError): + mod.getSolutions("thisVariableDoesNotExist") + assert np.isclose(t[0], 0), "time does not start at 0" + assert np.isclose(t[-1], stopTime), "time does not end at stopTime" + x_analytical = x0 * np.exp(a*t) + assert np.isclose(x, x_analytical, rtol=1e-4).all() + + +def test_getters(tmp_path): + model_file = tmp_path / "M_getters.mo" + model_file.write_text(""" +model M_getters +Real x(start = 1, fixed = true); +output Real y "the derivative"; +parameter Real a = -0.5; +parameter Real b = 0.1; +equation +der(x) = x*a + b; +y = der(x); +end M_getters; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_getters") + + q = mod.getQuantities() + assert isinstance(q, list) + assert sorted(q, key=lambda d: d["name"]) == sorted([ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'false', + 'description': None, + 'max': None, + 'min': None, + 'name': 'der(x)', + 'start': None, + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'a', + 'start': '-0.5', + 'unit': None, + 'variability': 'parameter', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'b', + 'start': '0.1', + 'unit': None, + 'variability': 'parameter', + } + ], key=lambda d: d["name"]) + + assert mod.getQuantities("y") == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + } + ] + + assert mod.getQuantities(["y", "x"]) == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + ] + + with pytest.raises(KeyError): + mod.getQuantities("thisQuantityDoesNotExist") + + assert mod.getInputs() == {} + with pytest.raises(KeyError): + mod.getInputs("thisInputDoesNotExist") + # getOutputs before simulate() + assert mod.getOutputs() == {'y': '-0.4'} + assert mod.getOutputs("y") == ["-0.4"] + assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous before simulate(): + assert mod.getContinuous() == { + 'x': '1.0', + 'der(x)': None, + 'y': '-0.4' + } + assert mod.getContinuous("y") == ['-0.4'] + assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + stopTime = 1.0 + a = -0.5 + b = 0.1 + x0 = 1.0 + x_analytical = -b/a + (x0 + b/a) * np.exp(a * stopTime) + dx_analytical = (x0 + b/a) * a * np.exp(a * stopTime) + mod.setSimulationOptions(simOptions={"stopTime": stopTime}) + mod.simulate() + + # getOutputs after simulate() + d = mod.getOutputs() + assert d.keys() == {"y"} + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getOutputs("y") == [d["y"]] + assert mod.getOutputs(["y", "y"]) == [d["y"], d["y"]] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous after simulate() should return values at end of simulation: + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + with pytest.raises(KeyError): + mod.getContinuous(["x", "a", "y"]) # a is a parameter + d = mod.getContinuous() + assert d.keys() == {"x", "der(x)", "y"} + assert np.isclose(d["x"], x_analytical, 1e-4) + assert np.isclose(d["der(x)"], dx_analytical, 1e-4) + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getContinuous("x") == [d["x"]] + assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] + + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + with pytest.raises(OMPython.ModelicaSystemError): + mod.setSimulationOptions(simOptions={"thisOptionDoesNotExist": 3}) + + +def test_simulate_inputs(tmp_path): + model_file = tmp_path / "M_input.mo" + model_file.write_text(""" +model M_input +Real x(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y; +equation +der(x) = u1 + u2; +y = x; +end M_input; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_input") + + mod.setSimulationOptions(simOptions={"stopTime": 1.0}) + + # integrate zero (no setInputs call) - it should default to None -> 0 + assert mod.getInputs() == { + "u1": None, + "u2": None, + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 0.0) + + # integrate a constant + mod.setInputs(name={"u1": 2.5}) + assert mod.getInputs() == { + "u1": [ + (0.0, 2.5), + (1.0, 2.5), + ], + # u2 is set due to the call to simulate() above + "u2": [ + (0.0, 0.0), + (1.0, 0.0), + ], + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 2.5) + + # now let's integrate the sum of two ramps + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 2), (1.0, 0)]}) + assert mod.getInputs("u1") == [[ + (0.0, 0.0), + (0.5, 2.0), + (1.0, 0.0), + ]] + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) + + # let's try some edge cases + # unmatched startTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(-0.5, 0.0), (1.0, 1)]}) + mod.simulate() + # unmatched stopTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 1)]}) + mod.simulate() + + # Let's use both inputs, but each one with different number of + # samples. This has an effect when generating the csv file. + mod.setInputs(name={"u1": [(0.0, 0), (1.0, 1)], + "u2": [(0.0, 0), (0.25, 0.5), (0.5, 1.0), (1.0, 0)]}) + csv_file = mod._createCSVData() + assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end +0.0,0.0,0.0,0 +0.25,0.25,0.5,0 +0.5,0.5,1.0,0 +1.0,1.0,0.0,0 +""" + + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py new file mode 100644 index 00000000..3544a1bd --- /dev/null +++ b/tests_v400/test_ModelicaSystemCmd.py @@ -0,0 +1,51 @@ +import OMPython +import pytest + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +@pytest.fixture +def mscmd_firstorder(model_firstorder): + mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") + mscmd = OMPython.ModelicaSystemCmd(runpath=mod.getWorkDirectory(), modelname=mod._model_name) + return mscmd + + +def test_simflags(mscmd_firstorder): + mscmd = mscmd_firstorder + + mscmd.args_set({ + "noEventEmit": None, + "override": {'b': 2} + }) + with pytest.deprecated_call(): + mscmd.args_set(args=mscmd.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,b=2,x=3', + ] + + mscmd.args_set({ + "override": {'b': None}, + }) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,x=3', + ] diff --git a/tests_v400/test_OMParser.py b/tests_v400/test_OMParser.py new file mode 100644 index 00000000..875604e5 --- /dev/null +++ b/tests_v400/test_OMParser.py @@ -0,0 +1,43 @@ +from OMPython import OMParser + +typeCheck = OMParser.typeCheck + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('TRUE') is True + assert typeCheck('True') is True + assert typeCheck('true') is True + assert typeCheck('FALSE') is False + assert typeCheck('False') is False + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +# def test_dict(): +# assert type(typeCheck('{"a": "b"}')) == dict + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_str(): + pass + + +def test_UnStringable(): + pass diff --git a/tests_v400/test_OMSessionCmd.py b/tests_v400/test_OMSessionCmd.py new file mode 100644 index 00000000..1588fac8 --- /dev/null +++ b/tests_v400/test_OMSessionCmd.py @@ -0,0 +1,17 @@ +import OMPython + + +def test_isPackage(): + omczmq = OMPython.OMCSessionZMQ() + omccmd = OMPython.OMCSessionCmd(session=omczmq) + assert not omccmd.isPackage('Modelica') + + +def test_isPackage2(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + omccmd = OMPython.OMCSessionCmd(session=mod._getconn) + assert omccmd.isPackage('Modelica') + + +# TODO: add more checks ... diff --git a/tests_v400/test_ZMQ.py b/tests_v400/test_ZMQ.py new file mode 100644 index 00000000..30bf78e7 --- /dev/null +++ b/tests_v400/test_ZMQ.py @@ -0,0 +1,70 @@ +import OMPython +import pathlib +import os +import pytest + + +@pytest.fixture +def model_time_str(): + return """model M + Real r = time; +end M; +""" + + +@pytest.fixture +def om(tmp_path): + origDir = pathlib.Path.cwd() + os.chdir(tmp_path) + om = OMPython.OMCSessionZMQ() + os.chdir(origDir) + return om + + +def testHelloWorld(om): + assert om.sendExpression('"HelloWorld!"') == "HelloWorld!" + + +def test_Translate(om, model_time_str): + assert om.sendExpression(model_time_str) == ("M",) + assert om.sendExpression('translateModel(M)') is True + + +def test_Simulate(om, model_time_str): + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') + + +def test_execute(om): + with pytest.deprecated_call(): + assert om.execute('"HelloWorld!"') == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=True) == 'HelloWorld!' + + +def test_omcprocessport_execute(om): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + # run 1 + om1 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om1.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + # run 2 + om2 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om2.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + del om1 + del om2 + + +def test_omcprocessport_simulate(om, model_time_str): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') != "" + del om diff --git a/tests_v400/test_docker.py b/tests_v400/test_docker.py new file mode 100644 index 00000000..8d68f11f --- /dev/null +++ b/tests_v400/test_docker.py @@ -0,0 +1,32 @@ +import sys +import pytest +import OMPython + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + + +@skip_on_windows +def test_docker(): + omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcpInner = OMPython.OMCProcessDockerContainer(dockerContainer=omcp.get_docker_container_id()) + omInner = OMPython.OMCSessionZMQ(omc_process=omcpInner) + assert omInner.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcp2 = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal", port=11111) + om2 = OMPython.OMCSessionZMQ(omc_process=omcp2) + assert om2.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + del omcp2 + del om2 + + del omcpInner + del omInner + + del omcp + del om diff --git a/tests_v400/test_linearization.py b/tests_v400/test_linearization.py new file mode 100644 index 00000000..bccbc40b --- /dev/null +++ b/tests_v400/test_linearization.py @@ -0,0 +1,102 @@ +import OMPython +import pytest +import numpy as np + + +@pytest.fixture +def model_linearTest(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text(""" +model linearTest + Real x1(start=1); + Real x2(start=-2); + Real x3(start=3); + Real x4(start=-5); + parameter Real a=3,b=2,c=5,d=7,e=1,f=4; +equation + a*x1 = b*x2 -der(x1); + der(x2) + c*x3 + d*x1 = x4; + f*x4 - e*x3 - der(x3) = x1; + der(x4) = x1 + x2 + der(x3) + x4; +end linearTest; +""") + return mod + + +def test_example(model_linearTest): + mod = OMPython.ModelicaSystem(model_linearTest, "linearTest") + [A, B, C, D] = mod.linearize() + expected_matrixA = [[-3, 2, 0, 0], [-7, 0, -5, 1], [-1, 0, -1, 4], [0, 1, -1, 5]] + assert A == expected_matrixA, f"Matrix does not match the expected value. Got: {A}, Expected: {expected_matrixA}" + assert B == [], f"Matrix does not match the expected value. Got: {B}, Expected: {[]}" + assert C == [], f"Matrix does not match the expected value. Got: {C}, Expected: {[]}" + assert D == [], f"Matrix does not match the expected value. Got: {D}, Expected: {[]}" + assert mod.getLinearInputs() == [] + assert mod.getLinearOutputs() == [] + assert mod.getLinearStates() == ["x1", "x2", "x3", "x4"] + + +def test_getters(tmp_path): + model_file = tmp_path / "pendulum.mo" + model_file.write_text(""" +model Pendulum +Real phi(start=Modelica.Constants.pi, fixed=true); +Real omega(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y1; +output Real y2; +parameter Real l = 1.2; +parameter Real g = 9.81; +equation +der(phi) = omega + u2; +der(omega) = -g/l * sin(phi); +y1 = y2 + 0.5*omega; +y2 = phi + u1; +end Pendulum; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="Pendulum", lmodel=["Modelica"]) + + d = mod.getLinearizationOptions() + assert isinstance(d, dict) + assert "startTime" in d + assert "stopTime" in d + assert mod.getLinearizationOptions(["stopTime", "startTime"]) == [d["stopTime"], d["startTime"]] + mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) + assert mod.getLinearizationOptions("stopTime") == ["0.02"] + + mod.setInputs(name={"u1": 10, "u2": 0}) + [A, B, C, D] = mod.linearize() + param_g = float(mod.getParameters("g")[0]) + param_l = float(mod.getParameters("l")[0]) + assert mod.getLinearInputs() == ["u1", "u2"] + assert mod.getLinearStates() == ["omega", "phi"] + assert mod.getLinearOutputs() == ["y1", "y2"] + assert np.isclose(A, [[0, param_g/param_l], [1, 0]]).all() + assert np.isclose(B, [[0, 0], [0, 1]]).all() + assert np.isclose(C, [[0.5, 1], [0, 1]]).all() + assert np.isclose(D, [[1, 0], [1, 0]]).all() + + # test LinearizationResult + result = mod.linearize() + assert result[0] == A + assert result[1] == B + assert result[2] == C + assert result[3] == D + with pytest.raises(KeyError): + result[4] + + A2, B2, C2, D2 = result + assert A2 == A + assert B2 == B + assert C2 == C + assert D2 == D + + assert result.n == 2 + assert result.m == 2 + assert result.p == 2 + assert np.isclose(result.x0, [0, np.pi]).all() + assert np.isclose(result.u0, [10, 0]).all() + assert result.stateVars == ["omega", "phi"] + assert result.inputVars == ["u1", "u2"] + assert result.outputVars == ["y1", "y2"] diff --git a/tests_v400/test_optimization.py b/tests_v400/test_optimization.py new file mode 100644 index 00000000..b4164397 --- /dev/null +++ b/tests_v400/test_optimization.py @@ -0,0 +1,67 @@ +import OMPython +import numpy as np + + +def test_optimization_example(tmp_path): + model_file = tmp_path / "BangBang2021.mo" + model_file.write_text(""" +model BangBang2021 "Model to verify that optimization gives bang-bang optimal control" +parameter Real m = 1; +parameter Real p = 1 "needed for final constraints"; + +Real a; +Real v(start = 0, fixed = true); +Real pos(start = 0, fixed = true); +Real pow(min = -30, max = 30) = f * v annotation(isConstraint = true); + +input Real f(min = -10, max = 10); + +Real costPos(nominal = 1) = -pos "minimize -pos(tf)" annotation(isMayer=true); + +Real conSpeed(min = 0, max = 0) = p * v " 0<= p*v(tf) <=0" annotation(isFinalConstraint = true); + +equation + +der(pos) = v; +der(v) = a; +f = m * a; + +annotation(experiment(StartTime = 0, StopTime = 1, Tolerance = 1e-07, Interval = 0.01), +__OpenModelica_simulationFlags(s="optimization", optimizerNP="1"), +__OpenModelica_commandLineOptions="+g=Optimica"); + +end BangBang2021; +""") + + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="BangBang2021") + + mod.setOptimizationOptions(optimizationOptions={"numberOfIntervals": 16, + "stopTime": 1, + "stepSize": 0.001, + "tolerance": 1e-8}) + + # test the getter + assert mod.getOptimizationOptions()["stopTime"] == "1" + assert mod.getOptimizationOptions("stopTime") == ["1"] + assert mod.getOptimizationOptions(["tolerance", "stopTime"]) == ["1e-08", "1"] + + r = mod.optimize() + # it is necessary to specify resultfile, otherwise it wouldn't find it. + time, f, v = mod.getSolutions(["time", "f", "v"], resultfile=r["resultFile"]) + assert np.isclose(f[0], 10) + assert np.isclose(f[-1], -10) + + def f_fcn(time, v): + if time < 0.3: + return 10 + if time <= 0.5: + return 30 / v + if time < 0.7: + return -30 / v + return -10 + f_expected = [f_fcn(t, v) for t, v in zip(time, v)] + + # The sharp edge at time=0.5 probably won't match, let's leave that out. + matches = np.isclose(f, f_expected, 1e-3) + assert matches[:498].all() + assert matches[502:].all() diff --git a/tests_v400/test_typedParser.py b/tests_v400/test_typedParser.py new file mode 100644 index 00000000..60daedec --- /dev/null +++ b/tests_v400/test_typedParser.py @@ -0,0 +1,53 @@ +from OMPython import OMTypedParser + +typeCheck = OMTypedParser.parseString + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('true') is True + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_empty(): + assert typeCheck('') is None + + +def test_str(): + pass + + +def test_UnStringable(): + pass + + +def test_everything(): + # this test used to be in OMTypedParser.py's main() + testdata = """ + (1.0,{{1,true,3},{"4\\" +",5.9,6,NONE ( )},record ABC + startTime = ErrorLevel.warning, + 'stop*Time' = SOME(1.0) +end ABC;}) + """ + expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) + results = typeCheck(testdata) + assert results == expected From a73e331ae569bd6bc831d51029264b8095e13bee Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 20:52:18 +0100 Subject: [PATCH 16/34] (E001) update tests (v4.x.x) [test_*] reorder imports [tests_ModelicaDoE*] fix pylint hint * use .items() [tests_*] use OMSessionABC.get_version() [test_ModelicaSystemCmd] use get_model_name() instead of access to private variable _model_name [test_ModelicaSystemOMC] read file using utf-8 encoding / linter fix [test_ModelicaSystemRunner] update test case * ModelicaSystemRunner & OMCPath * ModelicaSystemRunner & OMPathRunnerLocal * ModelicaSystemRunner & OMPathRunnerBash * ModelicaSystemRunner & OMPathRunnerBash using docker * ModelicaSystemRunner & OMPathRunnerBash using WSL (not tested!) [test_OMCPath] update test case * OMCPath & OMCSessionZMQ * OMCPath & OMCSessionLocal * OMCPath & OMCSessionDocker * OMCPath & OMCSessionWSL (not tested!) * OMPathLocal & OMCSessionRunner * OMPathBash & OMCSessionRunner * OMPathBash & OMCSessionRunner in docker * OMPathBash & OMCSessionRunner in WSL (not tested!) add workflow to run unittests in ./tests [test_OMParser] use only the public interface => om_parser_basic() [test_OMTypedParser] rename file / use om_parser_typed() update tests - do NOT run test_FMIRegression.py reason: * it is only a test for OMC / not OMPython specific * furthermore, it is run automatically via cron job (= FMITest) [test_ModelExecutionCmd] rename from test_ModelicaSystemCmd --- OMPython/__init__.py | 2 + tests/test_FMIExport.py | 2 +- ...SystemCmd.py => test_ModelExecutionCmd.py} | 2 +- tests/test_ModelicaDoEOMC.py | 6 +- tests/test_ModelicaDoERunner.py | 6 +- tests/test_ModelicaSystemOMC.py | 2 +- tests/test_ModelicaSystemRunner.py | 176 +++++++++++++++++- tests/test_OMCPath.py | 96 +++++++--- tests/test_OMParser.py | 53 ++++-- tests/test_OMTypedParser.py | 65 +++++++ tests/test_ZMQ.py | 1 + tests/test_docker.py | 2 + tests/test_typedParser.py | 53 ------ 13 files changed, 365 insertions(+), 101 deletions(-) rename tests/{test_ModelicaSystemCmd.py => test_ModelExecutionCmd.py} (97%) create mode 100644 tests/test_OMTypedParser.py delete mode 100644 tests/test_typedParser.py diff --git a/OMPython/__init__.py b/OMPython/__init__.py index c12f8524..22c88137 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -30,6 +30,7 @@ OMPathABC, OMCPath, + OMSessionABC, OMSessionRunner, OMCSessionABC, @@ -77,6 +78,7 @@ 'OMPathABC', 'OMCPath', + 'OMSessionABC', 'OMSessionRunner', 'OMCSessionABC', diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index c7ab038a..65ac2766 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -1,6 +1,6 @@ -import shutil import os import pathlib +import shutil import OMPython diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelExecutionCmd.py similarity index 97% rename from tests/test_ModelicaSystemCmd.py rename to tests/test_ModelExecutionCmd.py index 3d35376b..db5aadeb 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelExecutionCmd.py @@ -29,7 +29,7 @@ def mscmd_firstorder(model_firstorder): cmd_local=mod.get_session().model_execution_local, cmd_windows=mod.get_session().model_execution_windows, cmd_prefix=mod.get_session().model_execution_prefix(cwd=mod.getWorkDirectory()), - model_name=mod._model_name, + model_name=mod.get_model_name(), ) return mscmd diff --git a/tests/test_ModelicaDoEOMC.py b/tests/test_ModelicaDoEOMC.py index 143932fc..9d6afc63 100644 --- a/tests/test_ModelicaDoEOMC.py +++ b/tests/test_ModelicaDoEOMC.py @@ -159,6 +159,6 @@ def _run_ModelicaDoEOMC(doe_mod): f"y[{row['p']}]": float(row['b']), } - for var in var_dict: - assert var in sol['data'] - assert np.isclose(sol['data'][var][-1], var_dict[var]) + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py index 2d41315f..e29e7e05 100644 --- a/tests/test_ModelicaDoERunner.py +++ b/tests/test_ModelicaDoERunner.py @@ -153,6 +153,6 @@ def _check_runner_result(mod, doe_mod): 'b': float(row['b']), } - for var in var_dict: - assert var in sol['data'] - assert np.isclose(sol['data'][var][-1], var_dict[var]) + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index 8dd17ef0..c63b92e1 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -495,7 +495,7 @@ def test_simulate_inputs(tmp_path): } mod.setInputs(**inputs) csv_file = mod._createCSVData() - assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end + assert pathlib.Path(csv_file).read_text(encoding='utf-8') == """time,u1,u2,end 0.0,0.0,0.0,0 0.25,0.25,0.5,0 0.5,0.5,1.0,0 diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py index ec9d734d..a207368c 100644 --- a/tests/test_ModelicaSystemRunner.py +++ b/tests/test_ModelicaSystemRunner.py @@ -1,9 +1,22 @@ +import sys + import numpy as np import pytest import OMPython +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath(non-local) only working for Python >= 3.12.", +) + + @pytest.fixture def model_firstorder_content(): return """ @@ -37,7 +50,7 @@ def param(): } -def test_runner(model_firstorder, param): +def test_ModelicaSystemRunner_OMC(model_firstorder, param): # create a model using ModelicaSystem mod = OMPython.ModelicaSystemOMC() mod.model( @@ -71,6 +84,167 @@ def test_runner(model_firstorder, param): _check_result(mod=mod, resultfile=resultfile_modr, param=param) +def test_ModelicaSystemRunner_local(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerLocal, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +def test_ModelicaSystemRunner_bash(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaSystemRunner_bash_docker(model_firstorder, param): + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omversion = omcs.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + cmd_prefix=omcs.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@pytest.mark.skip(reason="Not able to run WSL on github") +@skip_python_older_312 +def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionWSL() + omversion = omcs.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcsr = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + cmd_prefix=omcs.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcsr, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + def _run_simulation(mod, resultfile, param): simOptions = {"stopTime": param['stopTime'], "stepSize": 0.1, "tolerance": 1e-8} mod.setSimulationOptions(**simOptions) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index df01b86a..e15c75ff 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -15,42 +15,98 @@ ) -def test_OMCPath_OMCProcessLocal(): - omcs = OMPython.OMCSessionLocal() +# TODO: based on compatibility layer +def test_OMCPath_OMCSessionZMQ(): + om = OMPython.OMCSessionZMQ() - _run_OMCPath_checks(omcs) + _run_OMPath_checks(om) + _run_OMPath_write_file(om) - del omcs + +def test_OMCPath_OMCSessionLocal(): + oms = OMPython.OMCSessionLocal() + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) @skip_on_windows @skip_python_older_312 -def test_OMCPath_OMCProcessDocker(): +def test_OMCPath_OMCSessionDocker(): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") omversion = omcs.sendExpression("getVersion()") assert isinstance(omversion, str) and omversion.startswith("OpenModelica") - _run_OMCPath_checks(omcs) - - del omcs + _run_OMPath_checks(omcs) + _run_OMPath_write_file(omcs) @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 -def test_OMCPath_OMCProcessWSL(): - omcs = OMPython.OMCSessionWSL( +def test_OMCPath_OMCSessionWSL(): + oms = OMPython.OMCSessionWSL( wsl_omc='omc', wsl_user='omc', timeout=30.0, ) - _run_OMCPath_checks(omcs) + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_python_older_312 +def test_OMPathLocal_OMSessionRunner(): + oms = OMPython.OMSessionRunner() + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner(): + oms = OMPython.OMSessionRunner( + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner_Docker(): + oms_docker = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omversion = oms_docker.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) - del omcs +@pytest.mark.skip(reason="Not able to run WSL on github") +@skip_python_older_312 +def test_OMPathBash_OMSessionRunner_WSL(): + oms_docker = OMPython.OMCSessionWSL() + omversion = oms_docker.sendExpression("getVersion()") + assert isinstance(omversion, str) and omversion.startswith("OpenModelica") + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) -def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): - p1 = omcs.omcpath_tempdir() + +def _run_OMPath_checks(om: OMPython.OMSessionABC): + p1 = om.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() assert p2.is_dir() @@ -59,8 +115,8 @@ def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): assert p3.write_text('test') assert p3.is_file() assert p3.size() > 0 - p3 = p3.resolve().absolute() - assert str(p3) == str((p2 / 'test.txt').resolve().absolute()) + p3 = p3.resolve() + assert str(p3) == str((p2 / 'test.txt').resolve()) assert p3.read_text() == "test" assert p3.is_file() assert p3.parent.is_dir() @@ -68,15 +124,11 @@ def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): assert p3.is_file() is False -def test_OMCPath_write_file(tmpdir): - omcs = OMPython.OMCSessionLocal() - +def _run_OMPath_write_file(om: OMPython.OMSessionABC): data = "abc # \\t # \" # \\n # xyz" - p1 = omcs.omcpath_tempdir() + p1 = om.omcpath_tempdir() p2 = p1 / 'test.txt' p2.write_text(data=data) assert data == p2.read_text() - - del omcs diff --git a/tests/test_OMParser.py b/tests/test_OMParser.py index 875604e5..9dca784d 100644 --- a/tests/test_OMParser.py +++ b/tests/test_OMParser.py @@ -1,6 +1,6 @@ -from OMPython import OMParser +import OMPython -typeCheck = OMParser.typeCheck +parser = OMPython.OMParser.om_parser_basic def test_newline_behaviour(): @@ -8,31 +8,38 @@ def test_newline_behaviour(): def test_boolean(): - assert typeCheck('TRUE') is True - assert typeCheck('True') is True - assert typeCheck('true') is True - assert typeCheck('FALSE') is False - assert typeCheck('False') is False - assert typeCheck('false') is False + assert parser('TRUE') is True + assert parser('True') is True + assert parser('true') is True + assert parser('FALSE') is False + assert parser('False') is False + assert parser('false') is False def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int + assert parser('2') == 2 + assert type(parser('1')) == int + assert type(parser('123123123123123123232323')) == int + assert type(parser('9223372036854775808')) == int def test_float(): - assert type(typeCheck('1.2e3')) == float + assert type(parser('1.2e3')) == float -# def test_dict(): -# assert type(typeCheck('{"a": "b"}')) == dict +def test_dict(): + # TODO: why does it fail? + # assert type(parser('{"a": "b"}')) == dict + pass def test_ident(): - assert typeCheck('blabla2') == "blabla2" + assert parser('blabla2') == "blabla2" + + +def test_empty(): + # TODO: this differs from OMTypedParser + assert parser('') == {} def test_str(): @@ -41,3 +48,17 @@ def test_str(): def test_UnStringable(): pass + + +# def test_everything(): +# # this test used to be in OMTypedParser.py's main() +# testdata = """ +# (1.0,{{1,true,3},{"4\\" +# ",5.9,6,NONE ( )},record ABC +# startTime = ErrorLevel.warning, +# 'stop*Time' = SOME(1.0) +# end ABC;}) +# """ +# expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) +# results = parser(testdata) +# assert results == expected diff --git a/tests/test_OMTypedParser.py b/tests/test_OMTypedParser.py new file mode 100644 index 00000000..94a14210 --- /dev/null +++ b/tests/test_OMTypedParser.py @@ -0,0 +1,65 @@ +import OMPython + +parser = OMPython.OMTypedParser.om_parser_typed + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + # TODO: why does these fail? + # assert parser('TRUE') is True + # assert parser('True') is True + assert parser('true') is True + # TODO: why does these fail? + # assert parser('FALSE') is False + # assert parser('False') is False + assert parser('false') is False + + +def test_int(): + assert parser('2') == 2 + assert type(parser('1')) == int + assert type(parser('123123123123123123232323')) == int + assert type(parser('9223372036854775808')) == int + + +def test_float(): + assert type(parser('1.2e3')) == float + + +def test_dict(): + # TODO: why does it fail? + # assert type(parser('{"a": "b"}')) == dict + pass + + +def test_ident(): + assert parser('blabla2') == "blabla2" + + +def test_empty(): + assert parser('') is None + + +def test_str(): + pass + + +def test_UnStringable(): + pass + + +def test_everything(): + # this test used to be in OMTypedParser.py's main() + testdata = """ + (1.0,{{1,true,3},{"4\\" +",5.9,6,NONE ( )},record ABC + startTime = ErrorLevel.warning, + 'stop*Time' = SOME(1.0) +end ABC;}) + """ + expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) + results = parser(testdata) + assert results == expected diff --git a/tests/test_ZMQ.py b/tests/test_ZMQ.py index 1302a79d..89a8387b 100644 --- a/tests/test_ZMQ.py +++ b/tests/test_ZMQ.py @@ -1,5 +1,6 @@ import pathlib import os + import pytest import OMPython diff --git a/tests/test_docker.py b/tests/test_docker.py index a1acfbe1..50d2763a 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -1,5 +1,7 @@ import sys + import pytest + import OMPython skip_on_windows = pytest.mark.skipif( diff --git a/tests/test_typedParser.py b/tests/test_typedParser.py deleted file mode 100644 index 8e74a556..00000000 --- a/tests/test_typedParser.py +++ /dev/null @@ -1,53 +0,0 @@ -from OMPython import OMTypedParser - -typeCheck = OMTypedParser.om_parser_typed - - -def test_newline_behaviour(): - pass - - -def test_boolean(): - assert typeCheck('true') is True - assert typeCheck('false') is False - - -def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int - - -def test_float(): - assert type(typeCheck('1.2e3')) == float - - -def test_ident(): - assert typeCheck('blabla2') == "blabla2" - - -def test_empty(): - assert typeCheck('') is None - - -def test_str(): - pass - - -def test_UnStringable(): - pass - - -def test_everything(): - # this test used to be in OMTypedParser.py's main() - testdata = """ - (1.0,{{1,true,3},{"4\\" -",5.9,6,NONE ( )},record ABC - startTime = ErrorLevel.warning, - 'stop*Time' = SOME(1.0) -end ABC;}) - """ - expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) - results = typeCheck(testdata) - assert results == expected From 5624b6c3d1dd7f42c2149e6d33f57f0c63aed481 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 13 Feb 2026 21:16:47 +0100 Subject: [PATCH 17/34] (E002) prepare restructure [ModelicaSystemCmd] add missing docstring [OMCSession] spelling fixes [OMCSessionCmd] add warning about depreciated class [OMCSessionABC] remove duplicated code; see OMSessionABC [OMSessionRunnerABC] define class [OMCSessionZMQ] call super()__init__() [OMCPath] fix forward dependency on OMCSessionLocal [OMSessionException] rename from OMCSessionException [__init__] fix imports --- OMPython/ModelicaSystem.py | 8 +- OMPython/OMCSession.py | 214 ++++++++++++++++++++----------------- OMPython/__init__.py | 6 +- 3 files changed, 121 insertions(+), 107 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 832eeeec..fc852d98 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -25,7 +25,7 @@ ModelExecutionData, ModelExecutionException, - OMCSessionException, + OMSessionException, OMCSessionLocal, OMPathABC, @@ -1681,7 +1681,7 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: """ try: retval = self._session.sendExpression(expr=expr, parsed=parsed) - except OMCSessionException as ex: + 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)}") @@ -2822,7 +2822,9 @@ def _prepare_structure_parameters( class ModelicaSystemCmd(ModelExecutionCmd): - # TODO: docstring + """ + Compatibility class; in the new version it is renamed as MOdelExecutionCmd. + """ def __init__( self, diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index c25ee162..dabfca92 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -59,20 +59,33 @@ def wait(self, timeout): pass -class OMCSessionException(Exception): +class OMSessionException(Exception): """ Exception which is raised by any OMC* class. """ +class OMCSessionException(OMSessionException): + """ + Just a compatibility layer ... + """ + + 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!") + raise OMSessionException("Invalid OMC process definition!") self._session = session self._readonly = readonly self._omc_cache: dict[tuple[str, bool], Any] = {} @@ -84,7 +97,7 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr elif isinstance(opt, list): expression = f"{question}({','.join([str(x) for x in opt])})" else: - raise OMCSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") + raise OMSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") p = (expression, parsed) @@ -95,8 +108,8 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr try: res = self._session.sendExpression(expression, parsed=parsed) - except OMCSessionException as ex: - raise OMCSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex + except OMSessionException as ex: + raise OMSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex # save response self._omc_cache[p] = res @@ -411,7 +424,7 @@ def is_file(self) -> bool: """ retval = self.get_session().sendExpression(expr=f'regularFileExists("{self.as_posix()}")') if not isinstance(retval, bool): - raise OMCSessionException(f"Invalid return value for is_file(): {retval} - expect bool") + raise OMSessionException(f"Invalid return value for is_file(): {retval} - expect bool") return retval def is_dir(self) -> bool: @@ -420,14 +433,14 @@ def is_dir(self) -> bool: """ retval = self.get_session().sendExpression(expr=f'directoryExists("{self.as_posix()}")') if not isinstance(retval, bool): - raise OMCSessionException(f"Invalid return value for is_dir(): {retval} - expect 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 isinstance(self._session, OMCSessionLocal) and platform.system() == 'Windows': + 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() @@ -437,7 +450,7 @@ def read_text(self) -> str: """ retval = self.get_session().sendExpression(expr=f'readFile("{self.as_posix()}")') if not isinstance(retval, str): - raise OMCSessionException(f"Invalid return value for read_text(): {retval} - expect str") + raise OMSessionException(f"Invalid return value for read_text(): {retval} - expect str") return retval def write_text(self, data: str) -> int: @@ -464,7 +477,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: raise FileExistsError(f"Directory {self.as_posix()} already exists!") if not self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")'): - raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") def cwd(self) -> OMPathABC: """ @@ -486,7 +499,7 @@ def resolve(self, strict: bool = False) -> OMPathABC: Resolve the path to an absolute path. This is done based on available OMC functions. """ if strict and not (self.is_file() or self.is_dir()): - raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + raise OMSessionException(f"Path {self.as_posix()} does not exist!") if self.is_file(): pathstr_resolved = self._omc_resolve(self.parent.as_posix()) @@ -495,10 +508,10 @@ def resolve(self, strict: bool = False) -> OMPathABC: pathstr_resolved = self._omc_resolve(self.as_posix()) omcpath_resolved = self._session.omcpath(pathstr_resolved) else: - raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + 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 OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + raise OMSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") return omcpath_resolved @@ -514,12 +527,12 @@ def _omc_resolve(self, pathstr: str) -> str: try: retval = self.get_session().sendExpression(expr=expr, parsed=False) if not isinstance(retval, str): - raise OMCSessionException(f"Invalid return value for _omc_resolve(): {retval} - expect 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 OMCSessionException as ex: - raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + except OMSessionException as ex: + raise OMSessionException(f"OMCPath resolve failed for {pathstr}!") from ex return pathstr_resolved @@ -528,13 +541,13 @@ def size(self) -> int: Get the size of the file in bytes - this is an extra function and the best we can do using OMC. """ if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + 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 OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + raise OMSessionException(f"Error reading file size for path {self.as_posix()}!") class OMPathRunnerABC(OMPathABC, metaclass=abc.ABCMeta): """ @@ -618,10 +631,10 @@ def resolve(self, strict: bool = False) -> OMPathABC: def size(self) -> int: """ - Get the size of the file in bytes - implementation baseon on pathlib.Path. + Get the size of the file in bytes - implementation based on pathlib.Path. """ if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + raise OMSessionException(f"Path {self.as_posix()} is not a file!") path = self._path() return path.stat().st_size @@ -729,7 +742,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False) -> None: try: subprocess.run(cmdl, check=True) except subprocess.CalledProcessError as exc: - raise OMCSessionException(f"Error on directory creation for {self.as_posix()}!") from exc + raise OMSessionException(f"Error on directory creation for {self.as_posix()}!") from exc def cwd(self) -> OMPathABC: """ @@ -776,10 +789,10 @@ def resolve(self, strict: bool = False) -> OMPathABC: def size(self) -> int: """ - Get the size of the file in bytes - implementation baseon on pathlib.Path. + Get the size of the file in bytes - implementation based on pathlib.Path. """ if not self.is_file(): - raise OMCSessionException(f"Path {self.as_posix()} is not a 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()}"'] @@ -790,7 +803,7 @@ def size(self) -> int: try: return int(stdout) except ValueError as exc: - raise OSError(f"Invalid return value for filesize ({self.as_posix()}): {stdout}") from 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()}") @@ -1072,7 +1085,7 @@ def __init__( try: self._omc_loghandle = open(file=self._omc_logfile, mode="w+", encoding="utf-8") except OSError as ex: - raise OMCSessionException(f"Cannot open log file {self._omc_logfile}.") from 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 @@ -1090,7 +1103,7 @@ def __post_init__(self) -> None: port = self.get_port() if not isinstance(port, str): - raise OMCSessionException(f"Invalid content for port: {port}") + raise OMSessionException(f"Invalid content for port: {port}") # Create the ZeroMQ socket and connect to OMC server context = zmq.Context.instance() @@ -1105,7 +1118,7 @@ def __del__(self): if isinstance(self._omc_zmq, zmq.Socket): try: self.sendExpression(expr="quit()") - except OMCSessionException as exc: + except OMSessionException as exc: logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") finally: self._omc_zmq = None @@ -1144,7 +1157,7 @@ def _timeout_loop( if timeout is None: timeout = self._timeout if timeout <= 0: - raise OMCSessionException(f"Invalid timeout: {timeout}") + raise OMSessionException(f"Invalid timeout: {timeout}") timer = 0.0 yield True @@ -1165,7 +1178,7 @@ def set_timeout(self, timeout: Optional[float] = None) -> float: retval = self._timeout if timeout is not None: if timeout <= 0.0: - raise OMCSessionException(f"Invalid timeout value: {timeout}!") + raise OMSessionException(f"Invalid timeout value: {timeout}!") self._timeout = timeout return retval @@ -1206,7 +1219,7 @@ def omcpath(self, *path) -> OMPathABC: if isinstance(self, OMCSessionLocal): # noinspection PyArgumentList return OMCPath(*path) - raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCSessionLocal is used!") + 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: @@ -1225,26 +1238,6 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self._tempdir(tempdir_base=tempdir_base) - @staticmethod - def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: - names = [str(uuid.uuid4()) for _ in range(100)] - - tempdir: Optional[OMPathABC] = None - for name in names: - # create a unique temporary directory name - tempdir = tempdir_base / name - - if tempdir.exists(): - continue - - tempdir.mkdir(parents=True, exist_ok=False) - break - - if tempdir is None or not tempdir.is_dir(): - raise OMCSessionException("Cannot create a temporary directory!") - - return tempdir - def execute(self, command: str): warnings.warn( message="This function is depreciated and will be removed in future versions; " @@ -1259,12 +1252,12 @@ 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()'. + The complete error handling of the OMC result is done within this method using 'getMessagesStringInternal()'. Caller should only check for OMCSessionException. """ if self._omc_zmq is None: - raise OMCSessionException("No OMC running. Please create a new instance of OMCSession!") + raise OMSessionException("No OMC running. Please create a new instance of OMCSession!") logger.debug("sendExpression(expr='%r', parsed=%r)", str(expr), parsed) @@ -1279,11 +1272,11 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: # 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 OMCSessionException: + except OMSessionException: log_content = 'log not available' logger.error(f"OMC did not start. Log-file says:\n{log_content}") - raise OMCSessionException(f"No connection with OMC (timeout={self._timeout}).") + raise OMSessionException(f"No connection with OMC (timeout={self._timeout}).") if expr == "quit()": self._omc_zmq.close() @@ -1293,7 +1286,7 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: result = self._omc_zmq.recv_string() if result.startswith('Error occurred building AST'): - raise OMCSessionException(f"OMC error: {result}") + raise OMSessionException(f"OMC error: {result}") if expr == "getErrorString()": # no error handling if 'getErrorString()' is called @@ -1377,8 +1370,8 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: 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 OMCSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" - f"{msg_long_str}") + raise OMSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" + f"{msg_long_str}") if not parsed: return result @@ -1390,14 +1383,14 @@ def sendExpression(self, expr: str, parsed: bool = True) -> Any: try: return om_parser_basic(result) except (TypeError, UnboundLocalError) as ex2: - raise OMCSessionException("Cannot parse OMC result") from 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 OMCSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") + raise OMSessionException(f"Invalid port to connect to OMC process: {self._omc_port}") return self._omc_port def get_log(self) -> str: @@ -1405,7 +1398,7 @@ def get_log(self) -> str: Get the log file content of the OMC session. """ if self._omc_loghandle is None: - raise OMCSessionException("Log file not available!") + raise OMSessionException("Log file not available!") self._omc_loghandle.seek(0) log = self._omc_loghandle.read() @@ -1476,7 +1469,7 @@ def _omc_home_get(omhome: Optional[str | os.PathLike] = None) -> pathlib.Path: if path_to_omc is not None: return pathlib.Path(path_to_omc).parents[1] - raise OMCSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") + raise OMSessionException("Cannot find OpenModelica executable, please install from openmodelica.org") def _omc_process_get(self) -> subprocess.Popen: my_env = os.environ.copy() @@ -1510,8 +1503,8 @@ def _omc_port_get(self) -> str: break else: logger.error(f"OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"OMC Server did not start (timeout={self._timeout}, " - f"logfile={repr(self._omc_logfile)}).") + raise OMSessionException(f"OMC Server did not start (timeout={self._timeout}, " + 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 '?'}") @@ -1541,9 +1534,11 @@ def __init__( if omc_process is None: omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) elif not isinstance(omc_process, OMCSessionABC): - raise OMCSessionException("Invalid definition of the OMC process!") + raise OMSessionException("Invalid definition of the OMC process!") self.omc_process = omc_process + super().__init__(timeout=timeout) + def __del__(self): if hasattr(self, 'omc_process'): del self.omc_process @@ -1625,7 +1620,7 @@ def __init__( # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get(docker_cid=self._docker_container_id) if port is not None and not self._omc_port.endswith(f":{port}"): - raise OMCSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") + raise OMSessionException(f"Port mismatch: {self._omc_port} is not using the defined port {port}!") self._cmd_prefix = self.model_execution_prefix() @@ -1643,13 +1638,13 @@ def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: try: docker_process = DockerPopen(int(columns[1])) except psutil.NoSuchProcess as ex: - raise OMCSessionException(f"Could not find PID {docker_top} - " - "is this a docker instance spawned without --pid=host?") from 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 OMCSessionException(f"Docker based OMC Server did not start (timeout={self._timeout}).") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout}).") return docker_process @@ -1680,7 +1675,7 @@ def _omc_port_get( port = None if not isinstance(docker_cid, str): - raise OMCSessionException(f"Invalid docker container ID: {docker_cid}") + raise OMSessionException(f"Invalid docker container ID: {docker_cid}") # See if the omc server is running loop = self._timeout_loop(timestep=0.1) @@ -1699,8 +1694,8 @@ def _omc_port_get( break else: logger.error(f"Docker did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"Docker based OMC Server did not start (timeout={self._timeout}, " - f"logfile={repr(self._omc_logfile)}).") + raise OMSessionException(f"Docker based OMC Server did not start (timeout={self._timeout}, " + f"logfile={repr(self._omc_logfile)}).") logger.info(f"Docker based OMC Server is up and running at port {port}") @@ -1714,7 +1709,7 @@ def get_server_address(self) -> Optional[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 OMCSessionException(f"Invalid docker server address: {address}!") + raise OMSessionException(f"Invalid docker server address: {address}!") return address return None @@ -1724,7 +1719,7 @@ 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 OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}!") + raise OMSessionException(f"Invalid docker container ID: {self._docker_container_id}!") return self._docker_container_id @@ -1801,8 +1796,8 @@ def _docker_omc_cmd( if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] if not self._omc_port: - raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " - "please set the interactivePort argument") + 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): @@ -1812,8 +1807,8 @@ def _docker_omc_cmd( if sys.platform == "win32": if not isinstance(port, int): - raise OMCSessionException("OMC on Windows needs the interactive port - " - f"missing or invalid value: {repr(omc_port)}!") + 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"] @@ -1821,8 +1816,8 @@ def _docker_omc_cmd( docker_network_str = [] extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] else: - raise OMCSessionException(f'dockerNetwork was set to {self._docker_network}, ' - 'but only \"host\" or \"separate\" is allowed') + 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}"] @@ -1849,7 +1844,7 @@ def _docker_omc_start( ) -> Tuple[subprocess.Popen, DockerPopen, str]: if not isinstance(docker_image, str): - raise OMCSessionException("A docker image name must be provided!") + raise OMSessionException("A docker image name must be provided!") my_env = os.environ.copy() @@ -1870,7 +1865,7 @@ def _docker_omc_start( env=my_env) if not isinstance(docker_cid_file, pathlib.Path): - raise OMCSessionException(f"Invalid content for docker container ID file path: {docker_cid_file}") + 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 @@ -1886,14 +1881,14 @@ def _docker_omc_start( time.sleep(self._timeout / 40.0) if docker_cid is None: - raise OMCSessionException(f"Docker did not start (timeout={self._timeout} might be too short " - "especially if you did not docker pull the image before this command). " - f"Log-file says:\n{self.get_log()}") + raise OMSessionException(f"Docker did not start (timeout={self._timeout} 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 OMCSessionException(f"Docker top did not contain omc process {self._random_string}.") + raise OMSessionException(f"Docker top did not contain omc process {self._random_string}.") return omc_process, docker_process, docker_cid @@ -1943,10 +1938,10 @@ def _docker_omc_cmd( if sys.platform == "win32": extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] if not isinstance(omc_port, int): - raise OMCSessionException("Docker on Windows requires knowing which port to connect to - " - "Please set the interactivePort argument. Furthermore, the container needs " - "to have already manually exposed this port when it was started " - "(-p 127.0.0.1:n:n) or you get an error later.") + 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}"] @@ -1970,7 +1965,7 @@ def _docker_omc_start( ) -> Tuple[subprocess.Popen, DockerPopen, str]: if not isinstance(docker_cid, str): - raise OMCSessionException("A docker container ID must be provided!") + raise OMSessionException("A docker container ID must be provided!") my_env = os.environ.copy() @@ -1992,8 +1987,8 @@ def _docker_omc_start( docker_process = self._docker_process_get(docker_cid=docker_cid) if docker_process is None: - raise OMCSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {docker_cid}. Log-file says:\n{self.get_log()}") + 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 @@ -2077,8 +2072,8 @@ def _omc_port_get(self) -> str: break else: logger.error(f"WSL based OMC server did not start. Log-file says:\n{self.get_log()}") - raise OMCSessionException(f"WSL based OMC Server did not start (timeout={self._timeout}, " - f"logfile={repr(self._omc_logfile)}).") + raise OMSessionException(f"WSL based OMC Server did not start (timeout={self._timeout}, " + 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 '?'}") @@ -2086,16 +2081,16 @@ def _omc_port_get(self) -> str: return port -class OMSessionRunner(OMSessionABC): +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: float = 10.0, version: str = "1.27.0", - ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, cmd_prefix: Optional[list[str]] = None, model_execution_local: bool = True, ) -> None: @@ -2103,15 +2098,34 @@ def __init__( self._version = version if not issubclass(ompath_runner, OMPathRunnerABC): - raise OMCSessionException(f"Invalid OMPathRunner class: {type(ompath_runner)}!") + 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 - # TODO: some checking?! - # if ompath_runner == Type[OMPathRunnerBash]: + +class OMSessionRunner(OMSessionRunnerABC): + """ + Implementation based on OMSessionABC without any use of an OMC server. + """ + + def __init__( + self, + ompath_runner: Type[OMPathRunnerABC] = OMPathRunnerLocal, + timeout: float = 10.0, + 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: """ @@ -2154,7 +2168,7 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self._tempdir(tempdir_base=tempdir_base) def sendExpression(self, expr: str, parsed: bool = True) -> Any: - raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") + raise OMSessionException(f"{self.__class__.__name__} does not uses an OMC server!") DummyPopen = DockerPopen diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 22c88137..96f5fb7c 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -33,11 +33,10 @@ OMSessionABC, OMSessionRunner, - OMCSessionABC, - ModelExecutionData, ModelExecutionException, + OMCSessionABC, OMCSessionCmd, OMCSessionDocker, OMCSessionDockerContainer, @@ -81,10 +80,9 @@ 'OMSessionABC', 'OMSessionRunner', - 'OMCSessionABC', - 'doe_get_solutions', + 'OMCSessionABC', 'OMCSessionCmd', 'OMCSessionDocker', 'OMCSessionDockerContainer', From 4d9336224a24d72579eba0078fbf7047109ff1b7 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 13 Feb 2026 21:29:02 +0100 Subject: [PATCH 18/34] [ModelExecution*] move classes into model_execution.py --- OMPython/ModelicaSystem.py | 253 +------------------------- OMPython/OMCSession.py | 85 --------- OMPython/__init__.py | 12 +- OMPython/model_execution.py | 349 ++++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 339 deletions(-) create mode 100644 OMPython/model_execution.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index fc852d98..d0a80c9d 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -21,10 +21,12 @@ import numpy as np -from OMPython.OMCSession import ( +from OMPython.model_execution import ( + ModelExecutionCmd, ModelExecutionData, ModelExecutionException, - +) +from OMPython.OMCSession import ( OMSessionException, OMCSessionLocal, @@ -95,253 +97,6 @@ def __getitem__(self, index: int): return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index] -class ModelExecutionCmd: - """ - All information about a compiled model executable. This should include data about all structured parameters, i.e. - parameters which need a recompilation of the model. All non-structured parameters can be easily changed without - the need for recompilation. - """ - - def __init__( - self, - runpath: os.PathLike, - cmd_prefix: list[str], - cmd_local: bool = False, - cmd_windows: bool = False, - timeout: float = 10.0, - model_name: Optional[str] = None, - ) -> None: - if model_name is None: - raise ModelExecutionException("Missing model name!") - - self._cmd_local = cmd_local - self._cmd_windows = cmd_windows - self._cmd_prefix = cmd_prefix - self._runpath = pathlib.PurePosixPath(runpath) - self._model_name = model_name - self._timeout = timeout - - # dictionaries of command line arguments for the model executable - self._args: dict[str, str | None] = {} - # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the - # structure: 'key' => 'key=value' - self._arg_override: dict[str, str] = {} - - def arg_set( - self, - key: str, - val: Optional[str | dict[str, Any] | numbers.Number] = None, - ) -> None: - """ - Set one argument for the executable model. - - Args: - key: identifier / argument name to be used for the call of the model executable. - val: value for the given key; None for no value and for key == 'override' a dictionary can be used which - indicates variables to override - """ - - def override2str( - orkey: str, - orval: str | bool | numbers.Number, - ) -> str: - """ - Convert a value for 'override' to a string taking into account differences between Modelica and Python. - """ - # check oval for any string representations of numbers (or bool) and convert these to Python representations - if isinstance(orval, str): - try: - val_evaluated = ast.literal_eval(orval) - if isinstance(val_evaluated, (numbers.Number, bool)): - orval = val_evaluated - except (ValueError, SyntaxError): - pass - - if isinstance(orval, str): - val_str = orval.strip() - elif isinstance(orval, bool): - val_str = 'true' if orval else 'false' - elif isinstance(orval, numbers.Number): - val_str = str(orval) - else: - raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") - - return f"{orkey}={val_str}" - - if not isinstance(key, str): - raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") - key = key.strip() - - if isinstance(val, dict): - if key != 'override': - raise ModelExecutionException("Dictionary input only possible for key 'override'!") - - for okey, oval in val.items(): - if not isinstance(okey, str): - raise ModelExecutionException("Invalid key for argument 'override': " - f"{repr(okey)} (type: {type(okey)})") - - if not isinstance(oval, (str, bool, numbers.Number, type(None))): - raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " - f"{repr(oval)} (type: {type(oval)})") - - if okey in self._arg_override: - if oval is None: - logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") - del self._arg_override[okey] - continue - - logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " - f"(was: {repr(self._arg_override[okey])})") - - if oval is not None: - self._arg_override[okey] = override2str(orkey=okey, orval=oval) - - argval = ','.join(sorted(self._arg_override.values())) - elif val is None: - argval = None - elif isinstance(val, str): - argval = val.strip() - elif isinstance(val, numbers.Number): - argval = str(val) - else: - raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") - - if key in self._args: - logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " - f"(was: {repr(self._args[key])})") - self._args[key] = argval - - def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: - """ - Return the value for the given key - """ - if key in self._args: - return self._args[key] - - return None - - def args_set( - self, - args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], - ) -> None: - """ - Define arguments for the model executable. - """ - for arg in args: - self.arg_set(key=arg, val=args[arg]) - - def get_cmd_args(self) -> list[str]: - """ - Get a list with the command arguments for the model executable. - """ - - cmdl = [] - for key in sorted(self._args): - if self._args[key] is None: - cmdl.append(f"-{key}") - else: - cmdl.append(f"-{key}={self._args[key]}") - - return cmdl - - def definition(self) -> ModelExecutionData: - """ - Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. - """ - # ensure that a result filename is provided - result_file = self.arg_get('r') - if not isinstance(result_file, str): - result_file = (self._runpath / f"{self._model_name}.mat").as_posix() - - # as this is the local implementation, pathlib.Path can be used - cmd_path = self._runpath - - cmd_library_path = None - if self._cmd_local and self._cmd_windows: - cmd_library_path = "" - - # set the process environment from the generated .bat file in windows which should have all the dependencies - # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath - path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" - if not path_bat.is_file(): - raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) - - content = path_bat.read_text(encoding='utf-8') - for line in content.splitlines(): - match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) - if match: - cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons - my_env = os.environ.copy() - my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] - - cmd_model_executable = cmd_path / f"{self._model_name}.exe" - else: - # for Linux the paths to the needed libraries should be included in the executable (using rpath) - cmd_model_executable = cmd_path / self._model_name - - # define local(!) working directory - cmd_cwd_local = None - if self._cmd_local: - cmd_cwd_local = cmd_path.as_posix() - - omc_run_data = ModelExecutionData( - cmd_path=cmd_path.as_posix(), - cmd_model_name=self._model_name, - cmd_args=self.get_cmd_args(), - cmd_result_file=result_file, - cmd_prefix=self._cmd_prefix, - cmd_library_path=cmd_library_path, - cmd_model_executable=cmd_model_executable.as_posix(), - cmd_cwd_local=cmd_cwd_local, - cmd_timeout=self._timeout, - ) - - return omc_run_data - - @staticmethod - def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: - """ - Parse a simflag definition; this is deprecated! - - The return data can be used as input for self.args_set(). - """ - warnings.warn( - message="The argument 'simflags' is depreciated and will be removed in future versions; " - "please use 'simargs' instead", - category=DeprecationWarning, - stacklevel=2, - ) - - simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} - - args = [s for s in simflags.split(' ') if s] - for arg in args: - if arg[0] != '-': - raise ModelExecutionException(f"Invalid simulation flag: {arg}") - arg = arg[1:] - parts = arg.split('=') - if len(parts) == 1: - simargs[parts[0]] = None - elif parts[0] == 'override': - override = '='.join(parts[1:]) - - override_dict = {} - for item in override.split(','): - kv = item.split('=') - if not 0 < len(kv) < 3: - raise ModelExecutionException(f"Invalid value for '-override': {override}") - if kv[0]: - try: - override_dict[kv[0]] = kv[1] - except (KeyError, IndexError) as ex: - raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex - - simargs[parts[0]] = override_dict - - return simargs - - class ModelicaSystemABC(metaclass=abc.ABCMeta): """ Base class to simulate a Modelica models. diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index dabfca92..fa9c2911 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -6,7 +6,6 @@ from __future__ import annotations import abc -import dataclasses import io import json import logging @@ -812,90 +811,6 @@ def size(self) -> int: OMPathRunnerBash = _OMPathRunnerBash -class ModelExecutionException(Exception): - """ - Exception which is raised by ModelException* classes. - """ - - -@dataclasses.dataclass -class ModelExecutionData: - """ - Data class to store the command line data for running a model executable in the OMC environment. - - All data should be defined for the environment, where OMC is running (local, docker or WSL) - - To use this as a definition of an OMC simulation run, it has to be processed within - OMCProcess*.self_update(). This defines the attribute cmd_model_executable. - """ - # cmd_path is the expected working directory - cmd_path: str - cmd_model_name: str - # command prefix data (as list of strings); needed for docker or WSL - cmd_prefix: list[str] - # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) - cmd_model_executable: str - # command line arguments for the model executable - cmd_args: list[str] - # result file with the simulation output - cmd_result_file: str - # command timeout - cmd_timeout: float - - # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows - cmd_library_path: Optional[str] = None - # working directory to be used on the *local* system - cmd_cwd_local: Optional[str] = None - - def get_cmd(self) -> list[str]: - """ - Get the command line to run the model executable in the environment defined by the OMCProcess definition. - """ - - cmdl = self.cmd_prefix - cmdl += [self.cmd_model_executable] - cmdl += self.cmd_args - - return cmdl - - def run(self) -> int: - """ - Run the model execution defined in this class. - """ - - my_env = os.environ.copy() - if isinstance(self.cmd_library_path, str): - my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] - - cmdl = self.get_cmd() - - logger.debug("Run OM command %s in %s", repr(cmdl), self.cmd_path) - try: - cmdres = subprocess.run( - cmdl, - capture_output=True, - text=True, - env=my_env, - cwd=self.cmd_cwd_local, - timeout=self.cmd_timeout, - check=True, - ) - stdout = cmdres.stdout.strip() - stderr = cmdres.stderr.strip() - returncode = cmdres.returncode - - logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) - - if stderr: - raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") - except subprocess.TimeoutExpired as ex: - raise ModelExecutionException(f"Timeout running model executable {repr(cmdl)}: {ex}") from ex - except subprocess.CalledProcessError as ex: - raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex - - return returncode - - class PostInitCaller(type): """ Metaclass definition to define a new function __post_init__() which is called after all __init__() functions where diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 96f5fb7c..3401585d 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -11,11 +11,16 @@ """ +from OMPython.model_execution import ( + ModelExecutionCmd, + ModelExecutionData, + ModelExecutionException, +) + from OMPython.ModelicaSystem import ( LinearizationResult, ModelicaSystem, ModelicaSystemOMC, - ModelExecutionCmd, ModelicaSystemDoE, ModelicaDoEOMC, ModelicaSystemError, @@ -33,9 +38,6 @@ OMSessionABC, OMSessionRunner, - ModelExecutionData, - ModelExecutionException, - OMCSessionABC, OMCSessionCmd, OMCSessionDocker, @@ -60,13 +62,13 @@ __all__ = [ 'LinearizationResult', + 'ModelExecutionCmd', 'ModelExecutionData', 'ModelExecutionException', 'ModelicaSystem', 'ModelicaSystemOMC', 'ModelicaSystemCmd', - 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaDoEOMC', 'ModelicaSystemError', diff --git a/OMPython/model_execution.py b/OMPython/model_execution.py new file mode 100644 index 00000000..0b950278 --- /dev/null +++ b/OMPython/model_execution.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +""" +Definition of needed tools to execute a compiled (binary) OpenModelica model. +""" + +import ast +import dataclasses +import logging +import numbers +import os +import pathlib +import re +import subprocess +from typing import Any, Optional +import warnings + +# define logger using the current module name as ID +logger = logging.getLogger(__name__) + + +class ModelExecutionException(Exception): + """ + Exception which is raised by ModelException* classes. + """ + + +@dataclasses.dataclass +class ModelExecutionData: + """ + Data class to store the command line data for running a model executable in the OMC environment. + + All data should be defined for the environment, where OMC is running (local, docker or WSL) + + To use this as a definition of an OMC simulation run, it has to be processed within + OMCProcess*.self_update(). This defines the attribute cmd_model_executable. + """ + # cmd_path is the expected working directory + cmd_path: str + cmd_model_name: str + # command prefix data (as list of strings); needed for docker or WSL + cmd_prefix: list[str] + # cmd_model_executable is build out of cmd_path and cmd_model_name; this is mainly needed on Windows (add *.exe) + cmd_model_executable: str + # command line arguments for the model executable + cmd_args: list[str] + # result file with the simulation output + cmd_result_file: str + # command timeout + cmd_timeout: float + + # additional library search path; this is mainly needed if OMCProcessLocal is run on Windows + cmd_library_path: Optional[str] = None + # working directory to be used on the *local* system + cmd_cwd_local: Optional[str] = None + + def get_cmd(self) -> list[str]: + """ + Get the command line to run the model executable in the environment defined by the OMCProcess definition. + """ + + cmdl = self.cmd_prefix + cmdl += [self.cmd_model_executable] + cmdl += self.cmd_args + + return cmdl + + def run(self) -> int: + """ + Run the model execution defined in this class. + """ + + my_env = os.environ.copy() + if isinstance(self.cmd_library_path, str): + my_env["PATH"] = self.cmd_library_path + os.pathsep + my_env["PATH"] + + cmdl = self.get_cmd() + + logger.debug("Run OM command %s in %s", repr(cmdl), self.cmd_path) + try: + cmdres = subprocess.run( + cmdl, + capture_output=True, + text=True, + env=my_env, + cwd=self.cmd_cwd_local, + timeout=self.cmd_timeout, + check=True, + ) + stdout = cmdres.stdout.strip() + stderr = cmdres.stderr.strip() + returncode = cmdres.returncode + + logger.debug("OM output for command %s:\n%s", repr(cmdl), stdout) + + if stderr: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {stderr}") + except subprocess.TimeoutExpired as ex: + raise ModelExecutionException(f"Timeout running model executable {repr(cmdl)}: {ex}") from ex + except subprocess.CalledProcessError as ex: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex + + return returncode + + +class ModelExecutionCmd: + """ + All information about a compiled model executable. This should include data about all structured parameters, i.e. + parameters which need a recompilation of the model. All non-structured parameters can be easily changed without + the need for recompilation. + """ + + def __init__( + self, + runpath: os.PathLike, + cmd_prefix: list[str], + cmd_local: bool = False, + cmd_windows: bool = False, + timeout: float = 10.0, + model_name: Optional[str] = None, + ) -> None: + if model_name is None: + raise ModelExecutionException("Missing model name!") + + self._cmd_local = cmd_local + self._cmd_windows = cmd_windows + self._cmd_prefix = cmd_prefix + self._runpath = pathlib.PurePosixPath(runpath) + self._model_name = model_name + self._timeout = timeout + + # dictionaries of command line arguments for the model executable + self._args: dict[str, str | None] = {} + # 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the + # structure: 'key' => 'key=value' + self._arg_override: dict[str, str] = {} + + def arg_set( + self, + key: str, + val: Optional[str | dict[str, Any] | numbers.Number] = None, + ) -> None: + """ + Set one argument for the executable model. + + Args: + key: identifier / argument name to be used for the call of the model executable. + val: value for the given key; None for no value and for key == 'override' a dictionary can be used which + indicates variables to override + """ + + def override2str( + orkey: str, + orval: str | bool | numbers.Number, + ) -> str: + """ + Convert a value for 'override' to a string taking into account differences between Modelica and Python. + """ + # check oval for any string representations of numbers (or bool) and convert these to Python representations + if isinstance(orval, str): + try: + val_evaluated = ast.literal_eval(orval) + if isinstance(val_evaluated, (numbers.Number, bool)): + orval = val_evaluated + except (ValueError, SyntaxError): + pass + + if isinstance(orval, str): + val_str = orval.strip() + elif isinstance(orval, bool): + val_str = 'true' if orval else 'false' + elif isinstance(orval, numbers.Number): + val_str = str(orval) + else: + raise ModelExecutionException(f"Invalid value for override key {orkey}: {type(orval)}") + + return f"{orkey}={val_str}" + + if not isinstance(key, str): + raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") + key = key.strip() + + if isinstance(val, dict): + if key != 'override': + raise ModelExecutionException("Dictionary input only possible for key 'override'!") + + for okey, oval in val.items(): + if not isinstance(okey, str): + raise ModelExecutionException("Invalid key for argument 'override': " + f"{repr(okey)} (type: {type(okey)})") + + if not isinstance(oval, (str, bool, numbers.Number, type(None))): + raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " + f"{repr(oval)} (type: {type(oval)})") + + if okey in self._arg_override: + if oval is None: + logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}") + del self._arg_override[okey] + continue + + logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} " + f"(was: {repr(self._arg_override[okey])})") + + if oval is not None: + self._arg_override[okey] = override2str(orkey=okey, orval=oval) + + argval = ','.join(sorted(self._arg_override.values())) + elif val is None: + argval = None + elif isinstance(val, str): + argval = val.strip() + elif isinstance(val, numbers.Number): + argval = str(val) + else: + raise ModelExecutionException(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})") + + if key in self._args: + logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} " + f"(was: {repr(self._args[key])})") + self._args[key] = argval + + def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]: + """ + Return the value for the given key + """ + if key in self._args: + return self._args[key] + + return None + + def args_set( + self, + args: dict[str, Optional[str | dict[str, Any] | numbers.Number]], + ) -> None: + """ + Define arguments for the model executable. + """ + for arg in args: + self.arg_set(key=arg, val=args[arg]) + + def get_cmd_args(self) -> list[str]: + """ + Get a list with the command arguments for the model executable. + """ + + cmdl = [] + for key in sorted(self._args): + if self._args[key] is None: + cmdl.append(f"-{key}") + else: + cmdl.append(f"-{key}={self._args[key]}") + + return cmdl + + def definition(self) -> ModelExecutionData: + """ + Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. + """ + # ensure that a result filename is provided + result_file = self.arg_get('r') + if not isinstance(result_file, str): + result_file = (self._runpath / f"{self._model_name}.mat").as_posix() + + # as this is the local implementation, pathlib.Path can be used + cmd_path = self._runpath + + cmd_library_path = None + if self._cmd_local and self._cmd_windows: + cmd_library_path = "" + + # set the process environment from the generated .bat file in windows which should have all the dependencies + # for this pathlib.PurePosixPath() must be converted to a pathlib.Path() object, i.e. WindowsPath + path_bat = pathlib.Path(cmd_path) / f"{self._model_name}.bat" + if not path_bat.is_file(): + raise ModelExecutionException("Batch file (*.bat) does not exist " + str(path_bat)) + + content = path_bat.read_text(encoding='utf-8') + for line in content.splitlines(): + match = re.match(pattern=r"^SET PATH=([^%]*)", string=line, flags=re.IGNORECASE) + if match: + cmd_library_path = match.group(1).strip(';') # Remove any trailing semicolons + my_env = os.environ.copy() + my_env["PATH"] = cmd_library_path + os.pathsep + my_env["PATH"] + + cmd_model_executable = cmd_path / f"{self._model_name}.exe" + else: + # for Linux the paths to the needed libraries should be included in the executable (using rpath) + cmd_model_executable = cmd_path / self._model_name + + # define local(!) working directory + cmd_cwd_local = None + if self._cmd_local: + cmd_cwd_local = cmd_path.as_posix() + + omc_run_data = ModelExecutionData( + cmd_path=cmd_path.as_posix(), + cmd_model_name=self._model_name, + cmd_args=self.get_cmd_args(), + cmd_result_file=result_file, + cmd_prefix=self._cmd_prefix, + cmd_library_path=cmd_library_path, + cmd_model_executable=cmd_model_executable.as_posix(), + cmd_cwd_local=cmd_cwd_local, + cmd_timeout=self._timeout, + ) + + return omc_run_data + + @staticmethod + def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]: + """ + Parse a simflag definition; this is deprecated! + + The return data can be used as input for self.args_set(). + """ + warnings.warn( + message="The argument 'simflags' is depreciated and will be removed in future versions; " + "please use 'simargs' instead", + category=DeprecationWarning, + stacklevel=2, + ) + + simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {} + + args = [s for s in simflags.split(' ') if s] + for arg in args: + if arg[0] != '-': + raise ModelExecutionException(f"Invalid simulation flag: {arg}") + arg = arg[1:] + parts = arg.split('=') + if len(parts) == 1: + simargs[parts[0]] = None + elif parts[0] == 'override': + override = '='.join(parts[1:]) + + override_dict = {} + for item in override.split(','): + kv = item.split('=') + if not 0 < len(kv) < 3: + raise ModelExecutionException(f"Invalid value for '-override': {override}") + if kv[0]: + try: + override_dict[kv[0]] = kv[1] + except (KeyError, IndexError) as ex: + raise ModelExecutionException(f"Invalid value for '-override': {override}") from ex + + simargs[parts[0]] = override_dict + + return simargs From b7e40af550a95b8db5727aaa222cc7e0f04f80c3 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 9 Feb 2026 21:41:37 +0100 Subject: [PATCH 19/34] [OMCSession] split file --- OMPython/ModelicaSystem.py | 12 +- OMPython/OMCSession.py | 1817 +-------------------------------- OMPython/__init__.py | 68 +- OMPython/om_session_abc.py | 306 ++++++ OMPython/om_session_omc.py | 1186 +++++++++++++++++++++ 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 d0a80c9d..089dcdd6 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 fa9c2911..c4edd9e5 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -5,65 +5,32 @@ 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__) -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): """ Just a compatibility layer ... @@ -261,1172 +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: float = 10.00, - **kwargs, - ) -> None: - """ - Initialisation for OMSessionBase - """ - - # some helper data - self.model_execution_windows = platform.system() == "Windows" - self.model_execution_local = False - - # store variables - self._timeout = timeout - # command prefix (to be used for docker or WSL) - self._cmd_prefix: list[str] = [] - - def __post_init__(self) -> None: - """ - Post initialisation method. - """ - - def get_cmd_prefix(self) -> list[str]: - """ - Get session definition used for this instance of OMPath. - """ - return self._cmd_prefix.copy() - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return value.replace("\\", "\\\\").replace('"', '\\"') - - @abc.abstractmethod - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - """ - Helper function which returns a command prefix. - """ - - @abc.abstractmethod - def get_version(self) -> str: - """ - Get the OM version. - """ - - @abc.abstractmethod - def set_workdir(self, workdir: OMPathABC) -> None: - """ - Set the workdir for this session. - """ - - @abc.abstractmethod - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMPathABC object based on the given path segments and the current class. - """ - - @abc.abstractmethod - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory based on the specific definition for this session. - """ - - @staticmethod - def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: - names = [str(uuid.uuid4()) for _ in range(100)] - - tempdir: Optional[OMPathABC] = None - for name in names: - # create a unique temporary directory name - tempdir = tempdir_base / name - - if tempdir.exists(): - continue - - tempdir.mkdir(parents=True, exist_ok=False) - break - - if tempdir is None or not tempdir.is_dir(): - raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") - - return tempdir - - @abc.abstractmethod - def sendExpression(self, expr: str, parsed: bool = True) -> Any: - """ - Function needed to send expressions to the OMC server via ZMQ. - """ - - -class OMCSessionABC(OMSessionABC, metaclass=abc.ABCMeta): - """ - Base class for an OMC session started via ZMQ. This class contains common functionality for all variants of an - OMC session definition. - - 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: float = 10.00, - **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. - """ - # set_timeout() is used to define the value of _timeout as it includes additional checks - self.set_timeout(timeout=self._timeout) - - 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 - - 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}!") - self._timeout = timeout - return retval - - @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 OMCSessionException. - """ - - 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}).") - - 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: float = 10.0, - ) -> 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: float = 10.00, - 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}, " - 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. @@ -1500,592 +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: float = 10.0, - 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}).") - - 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}, " - 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: float = 10.00, - 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 - time.sleep(self._timeout / 40.0) - - if docker_cid is None: - raise OMSessionException(f"Docker did not start (timeout={self._timeout} 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: float = 10.00, - 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: float = 10.00, - 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}, " - 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: float = 10.0, - 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: float = 10.0, - 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..04ede3ba --- /dev/null +++ b/OMPython/om_session_abc.py @@ -0,0 +1,306 @@ +# -*- 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__) + + +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: float = 10.00, + **kwargs, + ) -> None: + """ + Initialisation for OMSessionBase + """ + + # some helper data + self.model_execution_windows = platform.system() == "Windows" + self.model_execution_local = False + + # store variables + self._timeout = timeout + # command prefix (to be used for docker or WSL) + self._cmd_prefix: list[str] = [] + + def __post_init__(self) -> None: + """ + Post initialisation method. + """ + + def get_cmd_prefix(self) -> list[str]: + """ + Get session definition used for this instance of OMPath. + """ + return self._cmd_prefix.copy() + + @staticmethod + def escape_str(value: str) -> str: + """ + Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + @abc.abstractmethod + def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: + """ + Helper function which returns a command prefix. + """ + + @abc.abstractmethod + def get_version(self) -> str: + """ + Get the OM version. + """ + + @abc.abstractmethod + def set_workdir(self, workdir: OMPathABC) -> None: + """ + Set the workdir for this session. + """ + + @abc.abstractmethod + def omcpath(self, *path) -> OMPathABC: + """ + Create an OMPathABC object based on the given path segments and the current class. + """ + + @abc.abstractmethod + def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: + """ + Get a temporary directory based on the specific definition for this session. + """ + + @staticmethod + def _tempdir(tempdir_base: OMPathABC) -> OMPathABC: + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir: Optional[OMPathABC] = None + for name in names: + # create a unique temporary directory name + tempdir = tempdir_base / name + + if tempdir.exists(): + continue + + tempdir.mkdir(parents=True, exist_ok=False) + break + + if tempdir is None or not tempdir.is_dir(): + raise FileNotFoundError(f"Cannot create a temporary directory in {tempdir_base}!") + + return tempdir + + @abc.abstractmethod + def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Function needed to send expressions to the OMC server via ZMQ. + """ diff --git a/OMPython/om_session_omc.py b/OMPython/om_session_omc.py new file mode 100644 index 00000000..705c445b --- /dev/null +++ b/OMPython/om_session_omc.py @@ -0,0 +1,1186 @@ +# -*- 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: float = 10.00, + **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. + """ + # set_timeout() is used to define the value of _timeout as it includes additional checks + self.set_timeout(timeout=self._timeout) + + 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 + + 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}!") + self._timeout = timeout + return retval + + @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 OMCSessionException. + """ + + 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}).") + + 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: float = 10.0, + 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}).") + + 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}, " + 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: float = 10.00, + 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 + time.sleep(self._timeout / 40.0) + + if docker_cid is None: + raise OMSessionException(f"Docker did not start (timeout={self._timeout} 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: float = 10.00, + 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: float = 10.00, + 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}, " + 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: float = 10.0, + ) -> 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: float = 10.00, + 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}, " + 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..a5aeb156 --- /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: float = 10.0, + 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: float = 10.0, + 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 129d64646573bcbf41facc68757004b1edf9f1d2 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 13 Feb 2026 22:17:53 +0100 Subject: [PATCH 20/34] [ModelicaSystem] split file --- OMPython/ModelicaSystem.py | 2438 +--------------------------- 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 | 1234 ++++++++++++++ OMPython/modelica_system_omc.py | 648 ++++++++ OMPython/modelica_system_runner.py | 76 + 8 files changed, 2595 insertions(+), 2439 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 089dcdd6..70618678 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -3,1869 +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()), - 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(): - om_cmd.arg_set(key=key, 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()), - 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()), - 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) - - 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): @@ -2006,581 +170,15 @@ 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 OMCsessionDummy is used!") - - super().__init__( - work_directory=work_directory, - session=session, - ) - - def setup( - self, - model_name: Optional[str] = None, - variable_filter: Optional[str] = None, - ) -> None: - """ - Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists - within the working directory. At least two files are needed: - - * model executable (as '' or '.exe'; in case of Windows additional '.bat' - is expected to evaluate the path to needed dlls - * the model initialization file (as '_init.xml') - """ - - if self._model_name is not None: - raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " - f"defined for {repr(self._model_name)}!") - - if model_name is None or not isinstance(model_name, str): - raise ModelicaSystemError("A model name must be provided!") - - # set variables - self._model_name = model_name # Model class name - self._variable_filter = variable_filter - - # test if the model can be executed - self.check_model_executable() - - # read XML file - xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" - self._xmlparse(xml_file=xml_file) - - -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. + Compatibility class; in the new version it is renamed as ModelExecutionCmd. """ def __init__( @@ -2598,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..da29c390 --- /dev/null +++ b/OMPython/modelica_system_abc.py @@ -0,0 +1,1234 @@ +# -*- 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()), + 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(): + om_cmd.arg_set(key=key, 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()), + 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()), + 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..e067a462 --- /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) + + 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..4e7ea9d8 --- /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 OMCsessionDummy is used!") + + super().__init__( + work_directory=work_directory, + session=session, + ) + + def setup( + self, + model_name: Optional[str] = None, + variable_filter: Optional[str] = None, + ) -> None: + """ + Needed definitions to set up the runner class. This class expects the model (defined by model_name) to exists + within the working directory. At least two files are needed: + + * model executable (as '' or '.exe'; in case of Windows additional '.bat' + is expected to evaluate the path to needed dlls + * the model initialization file (as '_init.xml') + """ + + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + # set variables + self._model_name = model_name # Model class name + self._variable_filter = variable_filter + + # test if the model can be executed + self.check_model_executable() + + # read XML file + xml_file = self._session.omcpath(self.getWorkDirectory()) / f"{self._model_name}_init.xml" + self._xmlparse(xml_file=xml_file) From 674bb368e1baffee21a7446f537824830fb688fa Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 14 Feb 2026 11:37:37 +0100 Subject: [PATCH 21/34] (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 a35d360c..8a6642ac 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ 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 @@ -39,8 +39,8 @@ help(OMPython) ``` ```python -from OMPython import OMCSessionLocal -omc = OMCSessionLocal() +import OMPython +omc = OMPython.OMCSessionLocal() omc.sendExpression("getVersion()") ``` From 27a49e5cd0e9f37be28a7dfdbf87f8749cff8d33 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 14:25:52 +0100 Subject: [PATCH 22/34] 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 | 6 +++-- 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, 56 insertions(+), 27 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index c4edd9e5..1b47536b 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()'. + 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 da29c390..b0525d4c 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -1020,7 +1020,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 04ede3ba..b79d70fb 100644 --- a/OMPython/om_session_abc.py +++ b/OMPython/om_session_abc.py @@ -95,13 +95,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. """ @@ -113,19 +113,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. @@ -135,7 +135,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 705c445b..64a4456b 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 a5aeb156..62bf26e5 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 c7b54c862e8d4240a43df2512dbbe2d37e7c9bf2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 12:54:43 +0100 Subject: [PATCH 23/34] 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 70618678..76d63175 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 e3ab8403..dde6e50d 100644 --- a/OMPython/modelica_doe_abc.py +++ b/OMPython/modelica_doe_abc.py @@ -14,6 +14,7 @@ from OMPython.model_execution import ( ModelExecutionData, + 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 b0525d4c..b50990df 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -18,6 +18,7 @@ from OMPython.model_execution import ( ModelExecutionCmd, + ModelExecutionException, ) from OMPython.om_session_abc import ( OMPathABC, @@ -199,7 +200,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!") @@ -730,7 +734,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 @@ -758,8 +765,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 @@ -785,7 +794,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)}!") @@ -1173,7 +1187,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 6d738a49471369f801973af054bce10e8cd8780d Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 14:02:16 +0100 Subject: [PATCH 24/34] 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 | 181 ++++++++++++++++++++++++++------ 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, 256 insertions(+), 60 deletions(-) create mode 100644 OMPython/compatibility_v400.py diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 76d63175..52405269 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,12 +289,14 @@ 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 ModelExecutionCmd instead!") class ModelicaSystemCmd(ModelExecutionCmd): """ Compatibility class; in the new version it is renamed as ModelExecutionCmd. diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 1b47536b..0e26080d 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 OMSessionException("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 78c8959e..8ee24430 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 b50990df..8afa3fab 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -1029,7 +1029,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))] @@ -1037,19 +1036,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 a9206d3d811fb5cffa1c9780562a475634cdf2db Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 18 Feb 2026 20:54:13 +0100 Subject: [PATCH 25/34] 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 52405269..13e06d17 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 dde6e50d..a6989516 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 8afa3fab..683ac29a 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 @@ -753,56 +752,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] @@ -880,21 +836,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, @@ -904,21 +856,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, @@ -928,22 +876,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, @@ -953,22 +896,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, @@ -978,22 +916,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, @@ -1007,19 +940,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 eedf25019f987c29d80eef430ea94593b904f64d Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 16 Feb 2026 19:22:40 +0100 Subject: [PATCH 26/34] 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 | 163 +++++++++++++++++++-------- 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, 146 insertions(+), 140 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 13e06d17..b30a88cb 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -4,10 +4,11 @@ """ import logging +import numbers import os import pathlib -import platform from typing import Any, Optional +import warnings import numpy as np @@ -15,10 +16,14 @@ ModelExecutionCmd, 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 ( @@ -72,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], @@ -329,51 +399,52 @@ class ModelicaSystemDoE(ModelicaDoEOMC): @depreciated_class(msg="Please use class ModelExecutionCmd instead!") class ModelicaSystemCmd(ModelExecutionCmd): """ - Compatibility class; in the new version it is renamed as ModelExecutionCmd. - """ - - def __init__( - self, - runpath: pathlib.Path, - modelname: str, - timeout: float = 10.0, - ) -> None: - super().__init__( - runpath=runpath, - timeout=timeout, - cmd_prefix=[], - model_name=modelname, - ) + Compatibility class; not much content. - 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(). - """ + 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() + """ - 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 0e26080d..1febfcbf 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 8ee24430..eb449fd2 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 0b950278..bf28c3f9 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__) @@ -305,45 +304,3 @@ def definition(self) -> ModelExecutionData: ) 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 683ac29a..0202cdfa 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -615,7 +615,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, ) -> ModelExecutionCmd: """ @@ -631,7 +630,6 @@ def simulate_cmd( Parameters ---------- result_file - simflags simargs Returns @@ -650,10 +648,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) @@ -687,7 +681,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. @@ -696,16 +689,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}}) """ @@ -724,7 +712,6 @@ def simulate( om_cmd = self.simulate_cmd( result_file=self._result_file, - simflags=simflags, simargs=simargs, ) @@ -1054,7 +1041,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. @@ -1063,8 +1049,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: @@ -1116,10 +1100,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 64a4456b..f197930b 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 @@ -403,16 +402,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 db5aadeb..ab3c9a63 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 1b9af03ffe1ea799b1387e0c1a9c5758add4a3f8 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 22:32:41 +0100 Subject: [PATCH 27/34] remove OMPathCompatibility - update needed Python version to 3.12 --- .github/workflows/Test.yml | 12 +- .github/workflows/Test_v400.yml | 6 +- .github/workflows/Test_v400_py310.yml | 70 ---- .github/workflows/Test_v4xx.yml | 6 +- OMPython/om_session_abc.py | 227 ++++++------- OMPython/om_session_omc.py | 287 ++++++++-------- OMPython/om_session_runner.py | 456 +++++++++++++------------- README.md | 2 +- 8 files changed, 468 insertions(+), 598 deletions(-) delete mode 100644 .github/workflows/Test_v400_py310.yml diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index b6306a5b..5d3717cf 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: @@ -17,13 +17,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 @@ -83,8 +83,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/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml index 2407e060..af55fcf3 100644 --- a/.github/workflows/Test_v400.yml +++ b/.github/workflows/Test_v400.yml @@ -12,13 +12,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 diff --git a/.github/workflows/Test_v400_py310.yml b/.github/workflows/Test_v400_py310.yml deleted file mode 100644 index dbe635be..00000000 --- a/.github/workflows/Test_v400_py310.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Test-v4.0.0-py310 - -on: - workflow_dispatch: - -jobs: - test: - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - matrix: - # test for: - # * oldest supported version - python-version: ['3.10'] - # * Linux using ubuntu-latest - os: ['ubuntu-latest'] - # * OM stable - latest stable version - omc-version: ['stable'] - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - architecture: 'x64' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip build setuptools wheel twine - pip install . pytest pytest-md pytest-emoji pre-commit - - - name: Set timezone - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: 'Europe/Berlin' - - - name: Run pre-commit linters - run: 'pre-commit run --all-files' - - - name: "Set up OpenModelica Compiler" - uses: OpenModelica/setup-openmodelica@v1.0.6 - with: - version: ${{ matrix.omc-version }} - packages: | - omc - libraries: | - 'Modelica 4.0.0' - - run: "omc --version" - - - name: Pull OpenModelica docker image - if: runner.os != 'Windows' - run: docker pull openmodelica/openmodelica:v1.25.0-minimal - - - name: Build wheel and sdist packages - run: python -m build --wheel --sdist --outdir dist - - - name: Check twine - run: python -m twine check dist/* - - - name: Run pytest - uses: pavelzw/pytest-action@v2 - with: - verbose: true - emoji: true - job-summary: true - custom-arguments: '-v ./tests_v400' - click-to-expand: true - report-title: 'Test Report' diff --git a/.github/workflows/Test_v4xx.yml b/.github/workflows/Test_v4xx.yml index cc662ff9..4d63080d 100644 --- a/.github/workflows/Test_v4xx.yml +++ b/.github/workflows/Test_v4xx.yml @@ -12,13 +12,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 diff --git a/OMPython/om_session_abc.py b/OMPython/om_session_abc.py index b79d70fb..23f4c5c0 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 @@ -24,151 +22,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 f197930b..edd9ffc6 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: + """ + 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. + + 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. """ - Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an - OMCSession* classes. + 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!") - 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()}")') + 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 62bf26e5..eaeba3b8 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 8a6642ac..0c42212e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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 From a23279ecc75e16717436721762ad7667464aba86 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 15 Feb 2026 23:34:47 +0100 Subject: [PATCH 28/34] remove all compatibility code (v4.0.0) --- .github/workflows/Test_v400.yml | 73 ----- .pre-commit-config.yaml | 2 +- OMPython/ModelicaSystem.py | 450 --------------------------- OMPython/OMCSession.py | 337 -------------------- OMPython/__init__.py | 32 -- OMPython/compatibility_v400.py | 39 --- tests/test_OMCPath.py | 8 - tests/test_OMSessionCmd.py | 20 -- tests_v400/__init__.py | 0 tests_v400/test_ArrayDimension.py | 19 -- tests_v400/test_FMIExport.py | 24 -- tests_v400/test_ModelicaSystem.py | 411 ------------------------ tests_v400/test_ModelicaSystemCmd.py | 53 ---- tests_v400/test_OMParser.py | 43 --- tests_v400/test_OMSessionCmd.py | 17 - tests_v400/test_ZMQ.py | 70 ----- tests_v400/test_docker.py | 32 -- tests_v400/test_linearization.py | 102 ------ tests_v400/test_optimization.py | 67 ---- tests_v400/test_typedParser.py | 53 ---- 20 files changed, 1 insertion(+), 1851 deletions(-) delete mode 100644 .github/workflows/Test_v400.yml delete mode 100644 OMPython/ModelicaSystem.py delete mode 100644 OMPython/OMCSession.py delete mode 100644 OMPython/compatibility_v400.py delete mode 100644 tests/test_OMSessionCmd.py delete mode 100644 tests_v400/__init__.py delete mode 100644 tests_v400/test_ArrayDimension.py delete mode 100644 tests_v400/test_FMIExport.py delete mode 100644 tests_v400/test_ModelicaSystem.py delete mode 100644 tests_v400/test_ModelicaSystemCmd.py delete mode 100644 tests_v400/test_OMParser.py delete mode 100644 tests_v400/test_OMSessionCmd.py delete mode 100644 tests_v400/test_ZMQ.py delete mode 100644 tests_v400/test_docker.py delete mode 100644 tests_v400/test_linearization.py delete mode 100644 tests_v400/test_optimization.py delete mode 100644 tests_v400/test_typedParser.py diff --git a/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml deleted file mode 100644 index af55fcf3..00000000 --- a/.github/workflows/Test_v400.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Test-v4.0.0 - -on: - workflow_dispatch: - -jobs: - test: - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - matrix: - # test for: - # * oldest supported version - # * latest available Python version - python-version: [ '3.12', '3.14' ] - # * Linux using ubuntu-latest - # * Windows using windows-latest - os: [ 'ubuntu-latest', 'windows-latest' ] - # * OM stable - latest stable version - # * OM nightly - latest nightly build - omc-version: [ 'stable', 'nightly' ] - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - architecture: 'x64' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip build setuptools wheel twine - pip install . pytest pytest-md pytest-emoji pre-commit - - - name: Set timezone - uses: szenius/set-timezone@v2.0 - with: - timezoneLinux: 'Europe/Berlin' - - - name: Run pre-commit linters - run: 'pre-commit run --all-files' - - - name: "Set up OpenModelica Compiler" - uses: OpenModelica/setup-openmodelica@v1.0.6 - with: - version: ${{ matrix.omc-version }} - packages: | - omc - libraries: | - 'Modelica 4.0.0' - - run: "omc --version" - - - name: Pull OpenModelica docker image - if: runner.os != 'Windows' - run: docker pull openmodelica/openmodelica:v1.25.0-minimal - - - name: Build wheel and sdist packages - run: python -m build --wheel --sdist --outdir dist - - - name: Check twine - run: python -m twine check dist/* - - - name: Run pytest - uses: pavelzw/pytest-action@v2 - with: - verbose: true - emoji: true - job-summary: true - custom-arguments: '-v ./tests_v400' - click-to-expand: true - report-title: 'Test Report' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd477775..46e1d477 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: hooks: - id: mypy args: [] - exclude: 'test|test_v400' + exclude: 'test' additional_dependencies: - pyparsing - types-psutil diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py deleted file mode 100644 index b30a88cb..00000000 --- a/OMPython/ModelicaSystem.py +++ /dev/null @@ -1,450 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Definition of main class to run Modelica simulations - ModelicaSystem. -""" - -import logging -import numbers -import os -import pathlib -from typing import Any, Optional -import warnings - -import numpy as np - -from OMPython.model_execution import ( - ModelExecutionCmd, - 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 ( - ModelicaSystemOMC, -) -from OMPython.modelica_doe_omc import ( - 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. - """ - - def __init__( - self, - fileName: Optional[str | os.PathLike | pathlib.Path] = None, - modelName: Optional[str] = None, - lmodel: Optional[list[str | tuple[str, str]]] = None, - commandLineOptions: Optional[list[str]] = None, - variableFilter: Optional[str] = None, - customBuildDirectory: Optional[str | os.PathLike] = None, - omhome: Optional[str] = None, - omc_process: Optional[OMCSessionLocal] = None, - build: bool = True, - ) -> None: - super().__init__( - command_line_options=commandLineOptions, - work_directory=customBuildDirectory, - omhome=omhome, - session=omc_process, - ) - self.model( - model_name=modelName, - model_file=fileName, - libraries=lmodel, - variable_filter=variableFilter, - build=build, - ) - self._getconn = self._session - - def setCommandLineOptions(self, commandLineOptions: str): - super().set_command_line_options(command_line_option=commandLineOptions) - - def 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], - ) -> dict[str, Any]: - input_args = [] - if len(args) == 1: - 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, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - Compatibility wrapper for setContinuous() from OMPython v4.0.0 - - Original definition: - - ``` - def setContinuous( - self, - cvals: str | list[str] | dict[str, Any], - ) -> bool: - ``` - """ - param = self._set_compatibility_helper(pkey='cvals', args=args, kwargs=kwargs) - return super().setContinuous(**param) - - def setParameters( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - Compatibility wrapper for setParameters() from OMPython v4.0.0 - - Original definition: - - ``` - def setParameters( - self, - pvals: str | list[str] | dict[str, Any], - ) -> bool: - ``` - """ - param = self._set_compatibility_helper(pkey='pvals', args=args, kwargs=kwargs) - return super().setParameters(**param) - - def setOptimizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - Compatibility wrapper for setOptimizationOptions() from OMPython v4.0.0 - - Original definition: - - ``` - def setOptimizationOptions( - self, - optimizationOptions: str | list[str] | dict[str, Any], - ) -> bool: - ``` - """ - param = self._set_compatibility_helper(pkey='optimizationOptions', args=args, kwargs=kwargs) - return super().setOptimizationOptions(**param) - - def setInputs( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - Compatibility wrapper for setInputs() from OMPython v4.0.0 - - Original definition: - - ``` - def setInputs( - self, - name: str | list[str] | dict[str, Any], - ) -> bool: - ``` - """ - param = self._set_compatibility_helper(pkey='name', args=args, kwargs=kwargs) - return super().setInputs(**param) - - def setSimulationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - Compatibility wrapper for setSimulationOptions() from OMPython v4.0.0 - - Original definition: - - ``` - def setSimulationOptions( - self, - simOptions: str | list[str] | dict[str, Any], - ) -> bool: - ``` - """ - param = self._set_compatibility_helper(pkey='simOptions', args=args, kwargs=kwargs) - return super().setSimulationOptions(**param) - - def setLinearizationOptions( - self, - *args: Any, - **kwargs: dict[str, Any], - ) -> bool: - """ - 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) - 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 - - if isinstance(retval, dict): - retval2: dict = {} - for key, val in retval.items(): - if np.isnan(val): - retval2[key] = None - else: - retval2[key] = str(val) - return retval2 - if isinstance(retval, list): - retval3: list[str | None] = [] - for val in retval: - if np.isnan(val): - retval3.append(None) - else: - retval3.append(str(val)) - return retval3 - - raise ModelicaSystemError("Invalid data!") - - 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 - - if isinstance(retval, dict): - retval2: dict = {} - for key, val in retval.items(): - if np.isnan(val): - retval2[key] = None - else: - retval2[key] = str(val) - return retval2 - if isinstance(retval, list): - retval3: list[str | None] = [] - for val in retval: - if np.isnan(val): - retval3.append(None) - else: - retval3.append(str(val)) - return retval3 - - raise ModelicaSystemError("Invalid data!") - - -@depreciated_class(msg="Please use class ModelicaDoEOMC instead!") -class ModelicaSystemDoE(ModelicaDoEOMC): - """ - Compatibility class. - """ - - -@depreciated_class(msg="Please use class ModelExecutionCmd instead!") -class ModelicaSystemCmd(ModelExecutionCmd): - """ - 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 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/OMCSession.py b/OMPython/OMCSession.py deleted file mode 100644 index 1febfcbf..00000000 --- a/OMPython/OMCSession.py +++ /dev/null @@ -1,337 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Definition of an OMC session. -""" - -from __future__ import annotations - -import logging -from typing import Any, Optional -import warnings - -import pyparsing - -from OMPython.om_session_abc import ( - OMPathABC, - OMSessionABC, - OMSessionException, -) -from OMPython.om_session_omc import ( - OMCSessionABC, - OMCSessionDocker, - OMCSessionDockerContainer, - OMCSessionLocal, - OMCSessionPort, - 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): - if not isinstance(session, OMSessionABC): - raise OMSessionException("Invalid OMC process definition!") - self._session = session - self._readonly = readonly - self._omc_cache: dict[tuple[str, bool], Any] = {} - - def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = True): - - if opt is None: - expression = question - elif isinstance(opt, list): - expression = f"{question}({','.join([str(x) for x in opt])})" - else: - raise OMSessionException(f"Invalid definition of options for {repr(question)}: {repr(opt)}") - - p = (expression, parsed) - - if self._readonly and question != 'getErrorString': - # can use cache if readonly - if p in self._omc_cache: - return self._omc_cache[p] - - try: - res = self._session.sendExpression(expression, parsed=parsed) - except OMSessionException as ex: - raise OMSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex - - # save response - self._omc_cache[p] = res - - return res - - # TODO: Open Modelica Compiler API functions. Would be nice to generate these. - def loadFile(self, filename): - return self._ask(question='loadFile', opt=[f'"{filename}"']) - - def loadModel(self, className): - return self._ask(question='loadModel', opt=[className]) - - def isModel(self, className): - return self._ask(question='isModel', opt=[className]) - - def isPackage(self, className): - return self._ask(question='isPackage', opt=[className]) - - def isPrimitive(self, className): - return self._ask(question='isPrimitive', opt=[className]) - - def isConnector(self, className): - return self._ask(question='isConnector', opt=[className]) - - def isRecord(self, className): - return self._ask(question='isRecord', opt=[className]) - - def isBlock(self, className): - return self._ask(question='isBlock', opt=[className]) - - def isType(self, className): - return self._ask(question='isType', opt=[className]) - - def isFunction(self, className): - return self._ask(question='isFunction', opt=[className]) - - def isClass(self, className): - return self._ask(question='isClass', opt=[className]) - - def isParameter(self, className): - return self._ask(question='isParameter', opt=[className]) - - def isConstant(self, className): - return self._ask(question='isConstant', opt=[className]) - - def isProtected(self, className): - return self._ask(question='isProtected', opt=[className]) - - def getPackages(self, className="AllLoadedClasses"): - return self._ask(question='getPackages', opt=[className]) - - def getClassRestriction(self, className): - return self._ask(question='getClassRestriction', opt=[className]) - - def getDerivedClassModifierNames(self, className): - return self._ask(question='getDerivedClassModifierNames', opt=[className]) - - def getDerivedClassModifierValue(self, className, modifierName): - return self._ask(question='getDerivedClassModifierValue', opt=[className, modifierName]) - - def typeNameStrings(self, className): - return self._ask(question='typeNameStrings', opt=[className]) - - def getComponents(self, className): - return self._ask(question='getComponents', opt=[className]) - - def getClassComment(self, className): - try: - return self._ask(question='getClassComment', opt=[className]) - except pyparsing.ParseException as ex: - logger.warning("Method 'getClassComment(%s)' failed; OMTypedParser error: %s", - className, ex.msg) - return 'No description available' - - def getNthComponent(self, className, comp_id): - """ returns with (type, name, description) """ - return self._ask(question='getNthComponent', opt=[className, comp_id]) - - def getNthComponentAnnotation(self, className, comp_id): - return self._ask(question='getNthComponentAnnotation', opt=[className, comp_id]) - - def getImportCount(self, className): - return self._ask(question='getImportCount', opt=[className]) - - def getNthImport(self, className, importNumber): - # [Path, id, kind] - return self._ask(question='getNthImport', opt=[className, importNumber]) - - def getInheritanceCount(self, className): - return self._ask(question='getInheritanceCount', opt=[className]) - - def getNthInheritedClass(self, className, inheritanceDepth): - return self._ask(question='getNthInheritedClass', opt=[className, inheritanceDepth]) - - def getParameterNames(self, className): - try: - return self._ask(question='getParameterNames', opt=[className]) - except KeyError as ex: - logger.warning('OMPython error: %s', ex) - # FIXME: OMC returns with a different structure for empty parameter set - return [] - - def getParameterValue(self, className, parameterName): - try: - return self._ask(question='getParameterValue', opt=[className, parameterName]) - except pyparsing.ParseException as ex: - logger.warning("Method 'getParameterValue(%s, %s)' failed; OMTypedParser error: %s", - className, parameterName, ex.msg) - return "" - - def getComponentModifierNames(self, className, componentName): - return self._ask(question='getComponentModifierNames', opt=[className, componentName]) - - def getComponentModifierValue(self, className, componentName): - return self._ask(question='getComponentModifierValue', opt=[className, componentName]) - - def getExtendsModifierNames(self, className, componentName): - return self._ask(question='getExtendsModifierNames', opt=[className, componentName]) - - def getExtendsModifierValue(self, className, extendsName, modifierName): - return self._ask(question='getExtendsModifierValue', opt=[className, extendsName, modifierName]) - - def getNthComponentModification(self, className, comp_id): - # FIXME: OMPython exception Results KeyError exception - - # get {$Code(....)} field - # \{\$Code\((\S*\s*)*\)\} - value = self._ask(question='getNthComponentModification', opt=[className, comp_id], parsed=False) - value = value.replace("{$Code(", "") - return value[:-3] - # return self.re_Code.findall(value) - - # function getClassNames - # input TypeName class_ = $Code(AllLoadedClasses); - # input Boolean recursive = false; - # input Boolean qualified = false; - # input Boolean sort = false; - # input Boolean builtin = false "List also builtin classes if true"; - # input Boolean showProtected = false "List also protected classes if true"; - # output TypeName classNames[:]; - # end getClassNames; - def getClassNames(self, className=None, recursive=False, qualified=False, sort=False, builtin=False, - showProtected=False): - opt = [className] if className else [] + [f'recursive={str(recursive).lower()}', - f'qualified={str(qualified).lower()}', - f'sort={str(sort).lower()}', - f'builtin={str(builtin).lower()}', - f'showProtected={str(showProtected).lower()}'] - return self._ask(question='getClassNames', opt=opt) - - -@depreciated_class(msg="Please use OMCSession* classes instead!") -class OMCSessionZMQ(OMSessionABC): - """ - This class is a compatibility layer for the new schema using OMCSession* classes. - """ - - def __init__( - self, - timeout: float = 10.00, - omhome: Optional[str] = None, - omc_process: Optional[OMCSessionABC] = None, - ) -> None: - """ - Initialisation for OMCSessionZMQ - """ - if omc_process is None: - omc_process = OMCSessionLocal(omhome=omhome, timeout=timeout) - elif not isinstance(omc_process, OMCSessionABC): - raise OMSessionException("Invalid definition of the OMC process!") - self.omc_process = omc_process - - super().__init__(timeout=timeout) - - def __del__(self): - if hasattr(self, 'omc_process'): - del self.omc_process - - @staticmethod - def escape_str(value: str) -> str: - """ - Escape a string such that it can be used as string within OMC expressions, i.e. escape all double quotes. - """ - return OMCSessionABC.escape_str(value=value) - - def omcpath(self, *path) -> OMPathABC: - """ - Create an OMCPath object based on the given path segments and the current OMC process definition. - """ - return self.omc_process.omcpath(*path) - - def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC: - """ - Get a temporary directory using OMC. It is our own implementation as non-local usage relies on OMC to run all - filesystem related access. - """ - return self.omc_process.omcpath_tempdir(tempdir_base=tempdir_base) - - def 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.omc_process.sendExpression(expr=command, parsed=False) - - 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 OMCSessionException. - - Compatibility: 'command' was renamed to 'expr' - """ - return self.omc_process.sendExpression(expr=command, parsed=parsed) - - def get_version(self) -> str: - return self.omc_process.get_version() - - def model_execution_prefix(self, cwd: Optional[OMPathABC] = None) -> list[str]: - return self.omc_process.model_execution_prefix(cwd=cwd) - - def set_workdir(self, workdir: OMPathABC) -> None: - return self.omc_process.set_workdir(workdir=workdir) - - -@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 eb449fd2..6e3544b2 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -58,23 +58,6 @@ ModelicaDoERunner, ) -# the imports below are compatibility functionality (OMPython v4.0.0) -from OMPython.ModelicaSystem import ( - ModelicaSystem, - ModelicaSystemDoE, - parse_simflags, -) -from OMPython.OMCSession import ( - OMCSessionCmd, - OMCSessionException, - OMCSessionZMQ, - - OMCProcessLocal, - OMCProcessPort, - OMCProcessDocker, - OMCProcessDockerContainer, -) - # global names imported if import 'from OMPython import *' is used __all__ = [ 'doe_get_solutions', @@ -108,19 +91,4 @@ 'OMPathRunnerBash', 'OMPathRunnerLocal', 'OMSessionRunner', - - 'ModelicaSystem', - 'ModelicaSystemDoE', - 'parse_simflags', - - 'OMCSessionCmd', - - 'OMCSessionException', - - 'OMCSessionZMQ', - - 'OMCProcessLocal', - 'OMCProcessPort', - 'OMCProcessDocker', - 'OMCProcessDockerContainer', ] diff --git a/OMPython/compatibility_v400.py b/OMPython/compatibility_v400.py deleted file mode 100644 index 61fa27a8..00000000 --- a/OMPython/compatibility_v400.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- 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/tests/test_OMCPath.py b/tests/test_OMCPath.py index e15c75ff..dcbb2573 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -15,14 +15,6 @@ ) -# TODO: based on compatibility layer -def test_OMCPath_OMCSessionZMQ(): - om = OMPython.OMCSessionZMQ() - - _run_OMPath_checks(om) - _run_OMPath_write_file(om) - - def test_OMCPath_OMCSessionLocal(): oms = OMPython.OMCSessionLocal() diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py deleted file mode 100644 index 7dbb9705..00000000 --- a/tests/test_OMSessionCmd.py +++ /dev/null @@ -1,20 +0,0 @@ -import OMPython - - -def test_isPackage(): - omcs = OMPython.OMCSessionLocal() - omccmd = OMPython.OMCSessionCmd(session=omcs) - assert not omccmd.isPackage('Modelica') - - -def test_isPackage2(): - mod = OMPython.ModelicaSystemOMC() - mod.model( - model_name="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", - libraries=["Modelica"], - ) - omccmd = OMPython.OMCSessionCmd(session=mod.get_session()) - assert omccmd.isPackage('Modelica') - - -# TODO: add more checks ... diff --git a/tests_v400/__init__.py b/tests_v400/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests_v400/test_ArrayDimension.py b/tests_v400/test_ArrayDimension.py deleted file mode 100644 index 13b3c11b..00000000 --- a/tests_v400/test_ArrayDimension.py +++ /dev/null @@ -1,19 +0,0 @@ -import OMPython - - -def test_ArrayDimension(tmp_path): - omc = OMPython.OMCSessionZMQ() - - omc.sendExpression(f'cd("{tmp_path.as_posix()}")') - - omc.sendExpression('loadString("model A Integer x[5+1,1+6]; end A;")') - omc.sendExpression("getErrorString()") - - result = omc.sendExpression("getComponents(A)") - assert result[0][-1] == (6, 7), "array dimension does not match" - - omc.sendExpression('loadString("model A Integer y = 5; Integer x[y+1,1+9]; end A;")') - omc.sendExpression("getErrorString()") - - result = omc.sendExpression("getComponents(A)") - assert result[-1][-1] == ('y+1', 10), "array dimension does not match" diff --git a/tests_v400/test_FMIExport.py b/tests_v400/test_FMIExport.py deleted file mode 100644 index f47b87ae..00000000 --- a/tests_v400/test_FMIExport.py +++ /dev/null @@ -1,24 +0,0 @@ -import OMPython -import shutil -import os - - -def test_CauerLowPassAnalog(): - mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", - lmodel=["Modelica"]) - tmp = mod.getWorkDirectory() - try: - fmu = mod.convertMo2Fmu(fileNamePrefix="CauerLowPassAnalog") - assert os.path.exists(fmu) - finally: - shutil.rmtree(tmp, ignore_errors=True) - - -def test_DrumBoiler(): - mod = OMPython.ModelicaSystem(modelName="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", lmodel=["Modelica"]) - tmp = mod.getWorkDirectory() - try: - fmu = mod.convertMo2Fmu(fileNamePrefix="DrumBoiler") - assert os.path.exists(fmu) - finally: - shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests_v400/test_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py deleted file mode 100644 index aa713af0..00000000 --- a/tests_v400/test_ModelicaSystem.py +++ /dev/null @@ -1,411 +0,0 @@ -import OMPython -import os -import pathlib -import pytest -import tempfile -import numpy as np - - -@pytest.fixture -def model_firstorder(tmp_path): - mod = tmp_path / "M.mo" - mod.write_text("""model M - Real x(start = 1, fixed = true); - parameter Real a = -1; -equation - der(x) = x*a; -end M; -""") - return mod - - -def test_ModelicaSystem_loop(model_firstorder): - def worker(): - filePath = model_firstorder.as_posix() - m = OMPython.ModelicaSystem(filePath, "M") - m.simulate() - m.convertMo2Fmu(fmuType="me") - for _ in range(10): - worker() - - -def test_setParameters(): - omc = OMPython.OMCSessionZMQ() - model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" - mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") - - # method 1 - mod.setParameters(pvals="e=1.234") - mod.setParameters(pvals={"g": 321.0}) - assert mod.getParameters("e") == ["1.234"] - assert mod.getParameters("g") == ["321.0"] - assert mod.getParameters() == { - "e": "1.234", - "g": "321.0", - } - with pytest.raises(KeyError): - mod.getParameters("thisParameterDoesNotExist") - - # method 2 - mod.setParameters(pvals={"e": 21.3, "g": 0.12}) - assert mod.getParameters() == { - "e": "21.3", - "g": "0.12", - } - assert mod.getParameters(["e", "g"]) == ["21.3", "0.12"] - assert mod.getParameters(["g", "e"]) == ["0.12", "21.3"] - with pytest.raises(KeyError): - mod.getParameters(["g", "thisParameterDoesNotExist"]) - - -def test_setSimulationOptions(): - omc = OMPython.OMCSessionZMQ() - model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" - mod = OMPython.ModelicaSystem(fileName=model_path + "BouncingBall.mo", modelName="BouncingBall") - - # method 1 - mod.setSimulationOptions(simOptions={"stopTime": 1.234}) - mod.setSimulationOptions(simOptions={"tolerance": 1.1e-08}) - assert mod.getSimulationOptions("stopTime") == ["1.234"] - assert mod.getSimulationOptions("tolerance") == ["1.1e-08"] - assert mod.getSimulationOptions(["tolerance", "stopTime"]) == ["1.1e-08", "1.234"] - d = mod.getSimulationOptions() - assert isinstance(d, dict) - assert d["stopTime"] == "1.234" - assert d["tolerance"] == "1.1e-08" - with pytest.raises(KeyError): - mod.getSimulationOptions("thisOptionDoesNotExist") - - # method 2 - mod.setSimulationOptions(simOptions={"stopTime": 2.1, "tolerance": "1.2e-08"}) - d = mod.getSimulationOptions() - assert d["stopTime"] == "2.1" - assert d["tolerance"] == "1.2e-08" - - -def test_relative_path(model_firstorder): - cwd = pathlib.Path.cwd() - (fd, name) = tempfile.mkstemp(prefix='tmpOMPython.tests', dir=cwd, text=True) - try: - with os.fdopen(fd, 'w') as f: - f.write(model_firstorder.read_text()) - - model_file = pathlib.Path(name).relative_to(cwd) - model_relative = str(model_file) - assert "/" not in model_relative - - mod = OMPython.ModelicaSystem(fileName=model_relative, modelName="M") - assert float(mod.getParameters("a")[0]) == -1 - finally: - model_file.unlink() # clean up the temporary file - - -def test_customBuildDirectory(tmp_path, model_firstorder): - filePath = model_firstorder.as_posix() - tmpdir = tmp_path / "tmpdir1" - tmpdir.mkdir() - m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) - assert pathlib.Path(m.getWorkDirectory().resolve()) == tmpdir.resolve() - result_file = tmpdir / "a.mat" - assert not result_file.exists() - m.simulate(resultfile="a.mat") - assert result_file.is_file() - - -def test_getSolutions(model_firstorder): - filePath = model_firstorder.as_posix() - mod = OMPython.ModelicaSystem(filePath, "M") - x0 = 1 - a = -1 - tau = -1 / a - stopTime = 5*tau - mod.setSimulationOptions(simOptions={"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8}) - mod.simulate() - - x = mod.getSolutions("x") - t, x2 = mod.getSolutions(["time", "x"]) - assert (x2 == x).all() - sol_names = mod.getSolutions() - assert isinstance(sol_names, tuple) - assert "time" in sol_names - assert "x" in sol_names - assert "der(x)" in sol_names - with pytest.raises(OMPython.ModelicaSystemError): - mod.getSolutions("thisVariableDoesNotExist") - assert np.isclose(t[0], 0), "time does not start at 0" - assert np.isclose(t[-1], stopTime), "time does not end at stopTime" - x_analytical = x0 * np.exp(a*t) - assert np.isclose(x, x_analytical, rtol=1e-4).all() - - -def test_getters(tmp_path): - model_file = tmp_path / "M_getters.mo" - model_file.write_text(""" -model M_getters -Real x(start = 1, fixed = true); -output Real y "the derivative"; -parameter Real a = -0.5; -parameter Real b = 0.1; -equation -der(x) = x*a + b; -y = der(x); -end M_getters; -""") - mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_getters") - - q = mod.getQuantities() - assert isinstance(q, list) - assert sorted(q, key=lambda d: d["name"]) == sorted([ - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'local', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'x', - 'start': '1.0', - 'unit': None, - 'variability': 'continuous', - }, - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'local', - 'changeable': 'false', - 'description': None, - 'max': None, - 'min': None, - 'name': 'der(x)', - 'start': None, - 'unit': None, - 'variability': 'continuous', - }, - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'output', - 'changeable': 'false', - 'description': 'the derivative', - 'max': None, - 'min': None, - 'name': 'y', - 'start': '-0.4', - 'unit': None, - 'variability': 'continuous', - }, - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'parameter', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'a', - 'start': '-0.5', - 'unit': None, - 'variability': 'parameter', - }, - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'parameter', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'b', - 'start': '0.1', - 'unit': None, - 'variability': 'parameter', - } - ], key=lambda d: d["name"]) - - assert mod.getQuantities("y") == [ - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'output', - 'changeable': 'false', - 'description': 'the derivative', - 'max': None, - 'min': None, - 'name': 'y', - 'start': '-0.4', - 'unit': None, - 'variability': 'continuous', - } - ] - - assert mod.getQuantities(["y", "x"]) == [ - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'output', - 'changeable': 'false', - 'description': 'the derivative', - 'max': None, - 'min': None, - 'name': 'y', - 'start': '-0.4', - 'unit': None, - 'variability': 'continuous', - }, - { - 'alias': 'noAlias', - 'aliasvariable': None, - 'causality': 'local', - 'changeable': 'true', - 'description': None, - 'max': None, - 'min': None, - 'name': 'x', - 'start': '1.0', - 'unit': None, - 'variability': 'continuous', - }, - ] - - with pytest.raises(KeyError): - mod.getQuantities("thisQuantityDoesNotExist") - - assert mod.getInputs() == {} - with pytest.raises(KeyError): - mod.getInputs("thisInputDoesNotExist") - # getOutputs before simulate() - assert mod.getOutputs() == {'y': '-0.4'} - assert mod.getOutputs("y") == ["-0.4"] - assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] - with pytest.raises(KeyError): - mod.getOutputs("thisOutputDoesNotExist") - - # getContinuous before simulate(): - assert mod.getContinuous() == { - 'x': '1.0', - 'der(x)': None, - 'y': '-0.4' - } - assert mod.getContinuous("y") == ['-0.4'] - assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] - with pytest.raises(KeyError): - mod.getContinuous("a") # a is a parameter - - stopTime = 1.0 - a = -0.5 - b = 0.1 - x0 = 1.0 - x_analytical = -b/a + (x0 + b/a) * np.exp(a * stopTime) - dx_analytical = (x0 + b/a) * a * np.exp(a * stopTime) - mod.setSimulationOptions(simOptions={"stopTime": stopTime}) - mod.simulate() - - # getOutputs after simulate() - d = mod.getOutputs() - assert d.keys() == {"y"} - assert np.isclose(d["y"], dx_analytical, 1e-4) - assert mod.getOutputs("y") == [d["y"]] - assert mod.getOutputs(["y", "y"]) == [d["y"], d["y"]] - with pytest.raises(KeyError): - mod.getOutputs("thisOutputDoesNotExist") - - # getContinuous after simulate() should return values at end of simulation: - with pytest.raises(KeyError): - mod.getContinuous("a") # a is a parameter - with pytest.raises(KeyError): - mod.getContinuous(["x", "a", "y"]) # a is a parameter - d = mod.getContinuous() - assert d.keys() == {"x", "der(x)", "y"} - assert np.isclose(d["x"], x_analytical, 1e-4) - assert np.isclose(d["der(x)"], dx_analytical, 1e-4) - assert np.isclose(d["y"], dx_analytical, 1e-4) - assert mod.getContinuous("x") == [d["x"]] - assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] - - with pytest.raises(KeyError): - mod.getContinuous("a") # a is a parameter - - with pytest.raises(OMPython.ModelicaSystemError): - mod.setSimulationOptions(simOptions={"thisOptionDoesNotExist": 3}) - - -def test_simulate_inputs(tmp_path): - model_file = tmp_path / "M_input.mo" - model_file.write_text(""" -model M_input -Real x(start=0, fixed=true); -input Real u1; -input Real u2; -output Real y; -equation -der(x) = u1 + u2; -y = x; -end M_input; -""") - mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_input") - - mod.setSimulationOptions(simOptions={"stopTime": 1.0}) - - # integrate zero (no setInputs call) - it should default to None -> 0 - assert mod.getInputs() == { - "u1": None, - "u2": None, - } - mod.simulate() - y = mod.getSolutions("y")[0] - assert np.isclose(y[-1], 0.0) - - # integrate a constant - mod.setInputs(name={"u1": 2.5}) - assert mod.getInputs() == { - "u1": [ - (0.0, 2.5), - (1.0, 2.5), - ], - # u2 is set due to the call to simulate() above - "u2": [ - (0.0, 0.0), - (1.0, 0.0), - ], - } - mod.simulate() - y = mod.getSolutions("y")[0] - assert np.isclose(y[-1], 2.5) - - # now let's integrate the sum of two ramps - mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 2), (1.0, 0)]}) - assert mod.getInputs("u1") == [[ - (0.0, 0.0), - (0.5, 2.0), - (1.0, 0.0), - ]] - mod.simulate() - y = mod.getSolutions("y")[0] - assert np.isclose(y[-1], 1.0) - - # let's try some edge cases - # unmatched startTime - with pytest.raises(OMPython.ModelicaSystemError): - mod.setInputs(name={"u1": [(-0.5, 0.0), (1.0, 1)]}) - mod.simulate() - # unmatched stopTime - with pytest.raises(OMPython.ModelicaSystemError): - mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 1)]}) - mod.simulate() - - # Let's use both inputs, but each one with different number of - # samples. This has an effect when generating the csv file. - mod.setInputs(name={"u1": [(0.0, 0), (1.0, 1)], - "u2": [(0.0, 0), (0.25, 0.5), (0.5, 1.0), (1.0, 0)]}) - csv_file = mod._createCSVData() - assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end -0.0,0.0,0.0,0 -0.25,0.25,0.5,0 -0.5,0.5,1.0,0 -1.0,1.0,0.0,0 -""" - - mod.simulate() - y = mod.getSolutions("y")[0] - assert np.isclose(y[-1], 1.0) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py deleted file mode 100644 index 75116894..00000000 --- a/tests_v400/test_ModelicaSystemCmd.py +++ /dev/null @@ -1,53 +0,0 @@ -import OMPython -import pytest - - -@pytest.fixture -def model_firstorder(tmp_path): - mod = tmp_path / "M.mo" - mod.write_text("""model M - Real x(start = 1, fixed = true); - parameter Real a = -1; -equation - der(x) = x*a; -end M; -""") - return mod - - -@pytest.fixture -def mscmd_firstorder(model_firstorder): - mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") - mscmd = OMPython.ModelExecutionCmd( - runpath=mod.getWorkDirectory(), - model_name=mod._model_name, - cmd_prefix=[], - ) - return mscmd - - -def test_simflags(mscmd_firstorder): - mscmd = mscmd_firstorder - - mscmd.args_set({ - "noEventEmit": None, - "override": {'b': 2} - }) - with pytest.deprecated_call(): - mscmd.args_set(args=OMPython.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) - - assert mscmd.get_cmd_args() == [ - '-noEventEmit', - '-noRestart', - '-override=a=1,b=2,x=3', - ] - - mscmd.args_set({ - "override": {'b': None}, - }) - - assert mscmd.get_cmd_args() == [ - '-noEventEmit', - '-noRestart', - '-override=a=1,x=3', - ] diff --git a/tests_v400/test_OMParser.py b/tests_v400/test_OMParser.py deleted file mode 100644 index 875604e5..00000000 --- a/tests_v400/test_OMParser.py +++ /dev/null @@ -1,43 +0,0 @@ -from OMPython import OMParser - -typeCheck = OMParser.typeCheck - - -def test_newline_behaviour(): - pass - - -def test_boolean(): - assert typeCheck('TRUE') is True - assert typeCheck('True') is True - assert typeCheck('true') is True - assert typeCheck('FALSE') is False - assert typeCheck('False') is False - assert typeCheck('false') is False - - -def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int - - -def test_float(): - assert type(typeCheck('1.2e3')) == float - - -# def test_dict(): -# assert type(typeCheck('{"a": "b"}')) == dict - - -def test_ident(): - assert typeCheck('blabla2') == "blabla2" - - -def test_str(): - pass - - -def test_UnStringable(): - pass diff --git a/tests_v400/test_OMSessionCmd.py b/tests_v400/test_OMSessionCmd.py deleted file mode 100644 index 1588fac8..00000000 --- a/tests_v400/test_OMSessionCmd.py +++ /dev/null @@ -1,17 +0,0 @@ -import OMPython - - -def test_isPackage(): - omczmq = OMPython.OMCSessionZMQ() - omccmd = OMPython.OMCSessionCmd(session=omczmq) - assert not omccmd.isPackage('Modelica') - - -def test_isPackage2(): - mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", - lmodel=["Modelica"]) - omccmd = OMPython.OMCSessionCmd(session=mod._getconn) - assert omccmd.isPackage('Modelica') - - -# TODO: add more checks ... diff --git a/tests_v400/test_ZMQ.py b/tests_v400/test_ZMQ.py deleted file mode 100644 index 30bf78e7..00000000 --- a/tests_v400/test_ZMQ.py +++ /dev/null @@ -1,70 +0,0 @@ -import OMPython -import pathlib -import os -import pytest - - -@pytest.fixture -def model_time_str(): - return """model M - Real r = time; -end M; -""" - - -@pytest.fixture -def om(tmp_path): - origDir = pathlib.Path.cwd() - os.chdir(tmp_path) - om = OMPython.OMCSessionZMQ() - os.chdir(origDir) - return om - - -def testHelloWorld(om): - assert om.sendExpression('"HelloWorld!"') == "HelloWorld!" - - -def test_Translate(om, model_time_str): - assert om.sendExpression(model_time_str) == ("M",) - assert om.sendExpression('translateModel(M)') is True - - -def test_Simulate(om, model_time_str): - assert om.sendExpression(f'loadString("{model_time_str}")') is True - om.sendExpression('res:=simulate(M, stopTime=2.0)') - assert om.sendExpression('res.resultFile') - - -def test_execute(om): - with pytest.deprecated_call(): - assert om.execute('"HelloWorld!"') == '"HelloWorld!"\n' - assert om.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' - assert om.sendExpression('"HelloWorld!"', parsed=True) == 'HelloWorld!' - - -def test_omcprocessport_execute(om): - port = om.omc_process.get_port() - omcp = OMPython.OMCProcessPort(omc_port=port) - - # run 1 - om1 = OMPython.OMCSessionZMQ(omc_process=omcp) - assert om1.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' - - # run 2 - om2 = OMPython.OMCSessionZMQ(omc_process=omcp) - assert om2.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' - - del om1 - del om2 - - -def test_omcprocessport_simulate(om, model_time_str): - port = om.omc_process.get_port() - omcp = OMPython.OMCProcessPort(omc_port=port) - - om = OMPython.OMCSessionZMQ(omc_process=omcp) - assert om.sendExpression(f'loadString("{model_time_str}")') is True - om.sendExpression('res:=simulate(M, stopTime=2.0)') - assert om.sendExpression('res.resultFile') != "" - del om diff --git a/tests_v400/test_docker.py b/tests_v400/test_docker.py deleted file mode 100644 index 8d68f11f..00000000 --- a/tests_v400/test_docker.py +++ /dev/null @@ -1,32 +0,0 @@ -import sys -import pytest -import OMPython - -skip_on_windows = pytest.mark.skipif( - sys.platform.startswith("win"), - reason="OpenModelica Docker image is Linux-only; skipping on Windows.", -) - - -@skip_on_windows -def test_docker(): - omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - om = OMPython.OMCSessionZMQ(omc_process=omcp) - assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" - - omcpInner = OMPython.OMCProcessDockerContainer(dockerContainer=omcp.get_docker_container_id()) - omInner = OMPython.OMCSessionZMQ(omc_process=omcpInner) - assert omInner.sendExpression("getVersion()") == "OpenModelica 1.25.0" - - omcp2 = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal", port=11111) - om2 = OMPython.OMCSessionZMQ(omc_process=omcp2) - assert om2.sendExpression("getVersion()") == "OpenModelica 1.25.0" - - del omcp2 - del om2 - - del omcpInner - del omInner - - del omcp - del om diff --git a/tests_v400/test_linearization.py b/tests_v400/test_linearization.py deleted file mode 100644 index bccbc40b..00000000 --- a/tests_v400/test_linearization.py +++ /dev/null @@ -1,102 +0,0 @@ -import OMPython -import pytest -import numpy as np - - -@pytest.fixture -def model_linearTest(tmp_path): - mod = tmp_path / "M.mo" - mod.write_text(""" -model linearTest - Real x1(start=1); - Real x2(start=-2); - Real x3(start=3); - Real x4(start=-5); - parameter Real a=3,b=2,c=5,d=7,e=1,f=4; -equation - a*x1 = b*x2 -der(x1); - der(x2) + c*x3 + d*x1 = x4; - f*x4 - e*x3 - der(x3) = x1; - der(x4) = x1 + x2 + der(x3) + x4; -end linearTest; -""") - return mod - - -def test_example(model_linearTest): - mod = OMPython.ModelicaSystem(model_linearTest, "linearTest") - [A, B, C, D] = mod.linearize() - expected_matrixA = [[-3, 2, 0, 0], [-7, 0, -5, 1], [-1, 0, -1, 4], [0, 1, -1, 5]] - assert A == expected_matrixA, f"Matrix does not match the expected value. Got: {A}, Expected: {expected_matrixA}" - assert B == [], f"Matrix does not match the expected value. Got: {B}, Expected: {[]}" - assert C == [], f"Matrix does not match the expected value. Got: {C}, Expected: {[]}" - assert D == [], f"Matrix does not match the expected value. Got: {D}, Expected: {[]}" - assert mod.getLinearInputs() == [] - assert mod.getLinearOutputs() == [] - assert mod.getLinearStates() == ["x1", "x2", "x3", "x4"] - - -def test_getters(tmp_path): - model_file = tmp_path / "pendulum.mo" - model_file.write_text(""" -model Pendulum -Real phi(start=Modelica.Constants.pi, fixed=true); -Real omega(start=0, fixed=true); -input Real u1; -input Real u2; -output Real y1; -output Real y2; -parameter Real l = 1.2; -parameter Real g = 9.81; -equation -der(phi) = omega + u2; -der(omega) = -g/l * sin(phi); -y1 = y2 + 0.5*omega; -y2 = phi + u1; -end Pendulum; -""") - mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="Pendulum", lmodel=["Modelica"]) - - d = mod.getLinearizationOptions() - assert isinstance(d, dict) - assert "startTime" in d - assert "stopTime" in d - assert mod.getLinearizationOptions(["stopTime", "startTime"]) == [d["stopTime"], d["startTime"]] - mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) - assert mod.getLinearizationOptions("stopTime") == ["0.02"] - - mod.setInputs(name={"u1": 10, "u2": 0}) - [A, B, C, D] = mod.linearize() - param_g = float(mod.getParameters("g")[0]) - param_l = float(mod.getParameters("l")[0]) - assert mod.getLinearInputs() == ["u1", "u2"] - assert mod.getLinearStates() == ["omega", "phi"] - assert mod.getLinearOutputs() == ["y1", "y2"] - assert np.isclose(A, [[0, param_g/param_l], [1, 0]]).all() - assert np.isclose(B, [[0, 0], [0, 1]]).all() - assert np.isclose(C, [[0.5, 1], [0, 1]]).all() - assert np.isclose(D, [[1, 0], [1, 0]]).all() - - # test LinearizationResult - result = mod.linearize() - assert result[0] == A - assert result[1] == B - assert result[2] == C - assert result[3] == D - with pytest.raises(KeyError): - result[4] - - A2, B2, C2, D2 = result - assert A2 == A - assert B2 == B - assert C2 == C - assert D2 == D - - assert result.n == 2 - assert result.m == 2 - assert result.p == 2 - assert np.isclose(result.x0, [0, np.pi]).all() - assert np.isclose(result.u0, [10, 0]).all() - assert result.stateVars == ["omega", "phi"] - assert result.inputVars == ["u1", "u2"] - assert result.outputVars == ["y1", "y2"] diff --git a/tests_v400/test_optimization.py b/tests_v400/test_optimization.py deleted file mode 100644 index b4164397..00000000 --- a/tests_v400/test_optimization.py +++ /dev/null @@ -1,67 +0,0 @@ -import OMPython -import numpy as np - - -def test_optimization_example(tmp_path): - model_file = tmp_path / "BangBang2021.mo" - model_file.write_text(""" -model BangBang2021 "Model to verify that optimization gives bang-bang optimal control" -parameter Real m = 1; -parameter Real p = 1 "needed for final constraints"; - -Real a; -Real v(start = 0, fixed = true); -Real pos(start = 0, fixed = true); -Real pow(min = -30, max = 30) = f * v annotation(isConstraint = true); - -input Real f(min = -10, max = 10); - -Real costPos(nominal = 1) = -pos "minimize -pos(tf)" annotation(isMayer=true); - -Real conSpeed(min = 0, max = 0) = p * v " 0<= p*v(tf) <=0" annotation(isFinalConstraint = true); - -equation - -der(pos) = v; -der(v) = a; -f = m * a; - -annotation(experiment(StartTime = 0, StopTime = 1, Tolerance = 1e-07, Interval = 0.01), -__OpenModelica_simulationFlags(s="optimization", optimizerNP="1"), -__OpenModelica_commandLineOptions="+g=Optimica"); - -end BangBang2021; -""") - - mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="BangBang2021") - - mod.setOptimizationOptions(optimizationOptions={"numberOfIntervals": 16, - "stopTime": 1, - "stepSize": 0.001, - "tolerance": 1e-8}) - - # test the getter - assert mod.getOptimizationOptions()["stopTime"] == "1" - assert mod.getOptimizationOptions("stopTime") == ["1"] - assert mod.getOptimizationOptions(["tolerance", "stopTime"]) == ["1e-08", "1"] - - r = mod.optimize() - # it is necessary to specify resultfile, otherwise it wouldn't find it. - time, f, v = mod.getSolutions(["time", "f", "v"], resultfile=r["resultFile"]) - assert np.isclose(f[0], 10) - assert np.isclose(f[-1], -10) - - def f_fcn(time, v): - if time < 0.3: - return 10 - if time <= 0.5: - return 30 / v - if time < 0.7: - return -30 / v - return -10 - f_expected = [f_fcn(t, v) for t, v in zip(time, v)] - - # The sharp edge at time=0.5 probably won't match, let's leave that out. - matches = np.isclose(f, f_expected, 1e-3) - assert matches[:498].all() - assert matches[502:].all() diff --git a/tests_v400/test_typedParser.py b/tests_v400/test_typedParser.py deleted file mode 100644 index 60daedec..00000000 --- a/tests_v400/test_typedParser.py +++ /dev/null @@ -1,53 +0,0 @@ -from OMPython import OMTypedParser - -typeCheck = OMTypedParser.parseString - - -def test_newline_behaviour(): - pass - - -def test_boolean(): - assert typeCheck('true') is True - assert typeCheck('false') is False - - -def test_int(): - assert typeCheck('2') == 2 - assert type(typeCheck('1')) == int - assert type(typeCheck('123123123123123123232323')) == int - assert type(typeCheck('9223372036854775808')) == int - - -def test_float(): - assert type(typeCheck('1.2e3')) == float - - -def test_ident(): - assert typeCheck('blabla2') == "blabla2" - - -def test_empty(): - assert typeCheck('') is None - - -def test_str(): - pass - - -def test_UnStringable(): - pass - - -def test_everything(): - # this test used to be in OMTypedParser.py's main() - testdata = """ - (1.0,{{1,true,3},{"4\\" -",5.9,6,NONE ( )},record ABC - startTime = ErrorLevel.warning, - 'stop*Time' = SOME(1.0) -end ABC;}) - """ - expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) - results = typeCheck(testdata) - assert results == expected From 6d8ecd98c4548b87a808c1ad8d87a466d01476bb Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 6 Mar 2026 19:27:38 +0100 Subject: [PATCH 29/34] [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 0202cdfa..db592c8d 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -215,6 +215,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 56a787cc226ef084a0a21dbb52de5a5dc3049f60 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 6 Mar 2026 20:13:57 +0100 Subject: [PATCH 30/34] [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 db592c8d..5ebaaeac 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 @@ -992,6 +993,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 03a50ab3db14501d1daf4eb689eb12b4af0cd758 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 1 Apr 2026 22:55:31 +0200 Subject: [PATCH 31/34] 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 5ebaaeac..963f4cd3 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -931,6 +931,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 bd717877129ec3540543d0714edce1625544aee5 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:18:23 +0200 Subject: [PATCH 32/34] 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 963f4cd3..5ca10664 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -595,12 +595,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 @@ -931,6 +939,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 89cca8d4f4aacdde1e5afa8b677644508bc210db Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:19:26 +0200 Subject: [PATCH 33/34] 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 5ca10664..4cb997a4 100644 --- a/OMPython/modelica_system_abc.py +++ b/OMPython/modelica_system_abc.py @@ -674,6 +674,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 @@ -1182,6 +1183,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 e067a462..dbf2ea8f 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 705a8ff5a0ac354c8ff73b61fa5833d77f08f6e8 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 23 Apr 2026 22:19:39 +0200 Subject: [PATCH 34/34] add unittest test_variable_filter() --- tests/test_ModelicaSystemOMC.py | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index bff63315..a1071c2a 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -206,6 +206,55 @@ def _run_getSolutions(mod): assert np.isclose(x, x_analytical, rtol=1e-4).all() +def test_variable_filter(model_firstorder): + mod = OMPython.ModelicaSystemOMC() + mod.setWorkDirectory(pathlib.Path(__file__).parent.parent / 'xyz') + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + x0 = 1 + 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("""