From 8f3a5fca67e61ded2e1ebacdf3e57eeb961aa259 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 2 Dec 2025 21:36:38 +0100 Subject: [PATCH 01/62] [OMParser] cleanup usage of Dict --- OMPython/OMParser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index 8b3474066..3ccda1099 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -33,9 +33,9 @@ """ import sys -from typing import Dict, Any +from typing import Any -result: Dict[str, Any] = dict() +result: dict[str, Any] = {} inner_sets = [] next_set_list = [] From 9a210433c1d2751c4d31b64ff8c5b922ae3978b9 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 7 Feb 2026 15:26:36 +0100 Subject: [PATCH 02/62] [OMParser] remove import sys --- OMPython/OMParser.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index 3ccda1099..f7b1ff689 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -32,7 +32,6 @@ Version: 1.0 """ -import sys from typing import Any result: dict[str, Any] = {} @@ -566,8 +565,8 @@ def skip_all_inner_sets(position): break pos += 1 if count != 0: - print("\nParser Error: Are you missing one or more '}'s? \n") - sys.exit(1) + raise ValueError("Parser Error: Are you missing one or more '}}'s in string? " + f"(string value: {repr(string)}") if max_count >= 2: while position < end_of_main_set: @@ -745,8 +744,7 @@ def skip_all_inner_sets(position): else: return current_set, next_set[0] else: - print("\nThe following String has no {}s to proceed\n") - print(string) + raise ValueError(f"The following String has no {{}}s to proceed: {repr(string)}!") # End of get_the_string() From 0ae0a4b64e126581ba26c07f96d280fb98c7e5e4 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 2 Dec 2025 21:44:17 +0100 Subject: [PATCH 03/62] [OMParser] basic pylint fixes --- OMPython/OMParser.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index f7b1ff689..39168e302 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -46,10 +46,9 @@ def bool_from_string(string): """Attempt conversion of string to a boolean """ if string in {'true', 'True', 'TRUE'}: return True - elif string in {'false', 'False', 'FALSE'}: + if string in {'false', 'False', 'FALSE'}: return False - else: - raise ValueError + raise ValueError def typeCheck(string): @@ -66,9 +65,7 @@ def typeCheck(string): return t(string) except ValueError: continue - else: - print("String contains un-handled datatype") - return string + raise ValueError(f"String contains un-handled datatype: {repr(string)}!") def make_values(strings, name): @@ -186,12 +183,12 @@ def delete_elements(strings): char = strings[pos] if char == "": break - elif char == ",": + if char == ",": break - elif char == " ": + if char == " ": pos = pos + 1 break - elif char == "{": + if char == "{": break pos = pos - 1 delStr = strings[pos: strings.rfind(")")] @@ -682,15 +679,14 @@ def skip_all_inner_sets(position): position += 1 else: next_set[0] = "" - return (len(string) - 1) + return len(string) - 1 max_of_sets = max(last_set, last_subset) max_of_main_set = max(max_of_sets, last_subset) if max_of_main_set != 0: return max_of_main_set - else: - return (len(string) - 1) + return len(string) - 1 # Main entry of get_the_string() index = 0 @@ -833,7 +829,7 @@ def check_for_values(string): if "record SimulationResult" in string: formatSimRes(string) return result - elif "record " in string: + if "record " in string: formatRecords(string) return result @@ -841,7 +837,7 @@ def check_for_values(string): if not isinstance(string, str): return string - elif string.find("{") == -1: + if string.find("{") == -1: return string current_set, next_set = get_the_set(string) From e9c5b07882fde636e4a7d254629241762cff3d56 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 2 Dec 2025 21:44:59 +0100 Subject: [PATCH 04/62] [OMParser] optimise code in make_values() --- OMPython/OMParser.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/OMPython/OMParser.py b/OMPython/OMParser.py index 39168e302..73f38d7b7 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -157,14 +157,9 @@ def make_values(strings, name): varValue = (varValue.replace('{', '').strip()).replace('}', '').strip() multiple_values = varValue.split(",") - for n in range(len(multiple_values)): - each_v = multiple_values[n] - multiple_values.pop(n) - each_v = typeCheck(each_v) - multiple_values.append(each_v) - if len(multiple_values) != 0: - result[main_set_name]['Elements'][name]['Properties']['Results'][varName] = multiple_values + multiple_values_type_checked = [typeCheck(val) for val in multiple_values] + result[main_set_name]['Elements'][name]['Properties']['Results'][varName] = multiple_values_type_checked elif varName != "" and varValue != "": result[main_set_name]['Elements'][name]['Properties']['Results'][varName] = varValue else: From 52e522d1f172dbc5cd244daff4a1c2c4b138ceae Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 19:17:48 +0100 Subject: [PATCH 05/62] [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() --- OMPython/ModelicaSystem.py | 152 ++++++++++++++------- OMPython/OMCSession.py | 273 +++++++++++++------------------------ OMPython/__init__.py | 14 +- 3 files changed, 204 insertions(+), 235 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..e38dd90eb 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(oval, numbers.Number): oval_str = str(oval) else: - raise ModelicaSystemError(f"Invalid value for override key {okey}: {type(oval)}") + raise ModelExecutionException(f"Invalid value for override key {okey}: {type(oval)}") return f"{okey}={oval_str}" if not isinstance(key, str): - raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})") + raise ModelExecutionException(f"Invalid argument key: {repr(key)} (type: {type(key)})") key = key.strip() if isinstance(val, dict): if key != 'override': - raise ModelicaSystemError("Dictionary input only possible for key 'override'!") + raise ModelExecutionException("Dictionary input only possible for key 'override'!") for okey, oval in val.items(): if not isinstance(okey, str): - raise ModelicaSystemError("Invalid key for argument 'override': " - f"{repr(okey)} (type: {type(okey)})") + raise ModelExecutionException("Invalid key for argument 'override': " + f"{repr(okey)} (type: {type(okey)})") if not isinstance(oval, (str, bool, numbers.Number, type(None))): - raise ModelicaSystemError(f"Invalid input for 'override'.{repr(okey)}: " - f"{repr(oval)} (type: {type(oval)})") + raise ModelExecutionException(f"Invalid input for 'override'.{repr(okey)}: " + f"{repr(oval)} (type: {type(oval)})") if okey in self._arg_override: if oval is None: @@ -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 @@ -544,15 +586,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!") @@ -1069,7 +1113,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. @@ -1091,10 +1135,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 @@ -1183,7 +1229,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 @@ -1786,10 +1832,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( @@ -1829,7 +1877,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(): @@ -2015,7 +2063,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: """ @@ -2134,7 +2182,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. """ @@ -2180,13 +2228,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 cd1789c8f..b8f90266a 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -458,31 +458,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 @@ -491,14 +498,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)}") from ex + except subprocess.CalledProcessError as ex: + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}") from ex + + return returncode + class OMCSessionZMQ: """ @@ -548,21 +590,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) @@ -641,6 +668,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 @@ -766,6 +797,13 @@ def escape_str(value: str) -> str: """ return value.replace("\\", "\\\\").replace('"', '\\"') + 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. @@ -784,7 +822,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 @@ -794,6 +831,12 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: tempdir_str = self.sendExpression("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 @@ -810,43 +853,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; " "please use sendExpression() instead", @@ -1023,18 +1029,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): """ @@ -1048,28 +1042,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): """ @@ -1084,6 +1056,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 @@ -1149,48 +1123,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): """ @@ -1303,27 +1235,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): @@ -1586,15 +1512,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 @@ -1602,7 +1531,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", @@ -1624,7 +1553,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() @@ -1641,17 +1570,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 59a0ad107..0e85ed1a5 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -10,16 +10,19 @@ from OMPython.ModelicaSystem import ( LinearizationResult, ModelicaSystem, - ModelicaSystemCmd, + ModelExecutionCmd, ModelicaSystemDoE, ModelicaSystemError, ) from OMPython.OMCSession import ( OMCPath, OMCSession, + + ModelExecutionData, + ModelExecutionException, + OMCSessionCmd, OMCSessionException, - OMCSessionRunData, OMCSessionZMQ, OMCSessionPort, OMCSessionLocal, @@ -31,8 +34,12 @@ # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', + + 'ModelExecutionData', + 'ModelExecutionException', + 'ModelicaSystem', - 'ModelicaSystemCmd', + 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaSystemError', @@ -41,7 +48,6 @@ 'OMCSession', 'OMCSessionCmd', 'OMCSessionException', - 'OMCSessionRunData', 'OMCSessionZMQ', 'OMCSessionPort', 'OMCSessionLocal', From c592e39ba281405e0df39c429a37e6aa0004acbd Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 19:19:06 +0100 Subject: [PATCH 06/62] [test_ModelicaSystemCmd] update unittest --- tests/test_ModelicaSystemCmd.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 2480aad97..6fa2658f9 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 598aabb73fdcb6aca9b413e5891463b0677a6331 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 14:54:56 +0100 Subject: [PATCH 07/62] [ModelExecutionData] include the original exception if reraised as ModelExecutionException --- OMPython/OMCSession.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index b8f90266a..d44a59f9b 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -535,9 +535,9 @@ def run(self) -> int: 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)}") from 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)}") from ex + raise ModelExecutionException(f"Error running model executable {repr(cmdl)}: {ex}") from ex return returncode From 6f692e4335b9f15665f691e012c22b48f876e9a2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 20:43:02 +0100 Subject: [PATCH 08/62] [OMCPathReal] remove dummy function stat() --- OMPython/OMCSession.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index cd1789c8f..fc036e0cb 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -407,13 +407,6 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") - def stat(self): - """ - The function stat() cannot be implemented using OMC. - """ - raise NotImplementedError("The function stat() cannot be implemented using OMC; " - "use size() to get the file size.") - if sys.version_info < (3, 12): From b694ec0f9dffe762f65143573e79a06e4fe85833 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 20:31:51 +0100 Subject: [PATCH 09/62] [OMCSession] add get_version() and get_workdir() * this prepares a version of OMCSession which is independend of OMC as these functions where the last two which needed sendExpression() in basic ModelicaSystem functionality --- OMPython/ModelicaSystem.py | 5 ++--- OMPython/OMCSession.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..313118608 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -352,7 +352,7 @@ def __init__( self._session = OMCSessionLocal(omhome=omhome) # get OpenModelica version - version_str = self.sendExpression(expr="getVersion()") + 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: @@ -514,8 +514,7 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - raise IOError(f"{workdir} could not be created") logger.info("Define work dir as %s", workdir) - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) + self._session.set_workdir(workdir=workdir) # set the class variable _work_dir ... self._work_dir = workdir diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index cd1789c8f..5d3652214 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -766,6 +766,19 @@ def escape_str(value: str) -> str: """ return value.replace("\\", "\\\\").replace('"', '\\"') + def get_version(self) -> str: + """ + Get the OM version. + """ + return self.sendExpression("getVersion()", parsed=True) + + def set_workdir(self, workdir: OMCPath) -> None: + """ + Set the workdir for this session. + """ + exp = f'cd("{workdir.as_posix()}")' + self.sendExpression(exp) + def omcpath(self, *path) -> OMCPath: """ Create an OMCPath object based on the given path segments and the current OMCSession* class. From 57d976847ce5ee800cef87062e0a027db050ad18 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 20 Jan 2026 21:29:41 +0100 Subject: [PATCH 10/62] [ModelicaSystem] update handling of outputs and continuous data * store data as numpy.float64 - allows to define None values * split get*() function into Initial values and Final values --- OMPython/ModelicaSystem.py | 272 ++++++++++++++++++++++++++----------- 1 file changed, 193 insertions(+), 79 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..3676c4328 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -325,11 +325,8 @@ def __init__( self._quantities: list[dict[str, Any]] = [] self._params: dict[str, str] = {} # even numerical values are stored as str self._inputs: dict[str, list[tuple[float, float]]] = {} - # _outputs values are str before simulate(), but they can be - # np.float64 after simulate(). - self._outputs: dict[str, Any] = {} - # same for _continuous - self._continuous: dict[str, Any] = {} + self._outputs: dict[str, np.float64] = {} # numpy.float64 as it allows to define None values + self._continuous: dict[str, np.float64] = {} # numpy.float64 as it allows to define None values self._simulate_options: dict[str, str] = {} self._override_variables: dict[str, str] = {} self._simulate_options_override: dict[str, str] = {} @@ -630,11 +627,11 @@ def _xmlparse(self, xml_file: OMCPath): else: self._params[scalar["name"]] = scalar["start"] if scalar["variability"] == "continuous": - self._continuous[scalar["name"]] = scalar["start"] + self._continuous[scalar["name"]] = np.float64(scalar["start"]) if scalar["causality"] == "input": self._inputs[scalar["name"]] = scalar["start"] if scalar["causality"] == "output": - self._outputs[scalar["name"]] = scalar["start"] + self._outputs[scalar["name"]] = np.float64(scalar["start"]) self._quantities.append(scalar) @@ -695,15 +692,104 @@ def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]: 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 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 ModelicaSystemError(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, str | numbers.Real] | list[str | numbers.Real]: + ) -> dict[str, np.float64] | list[np.float64]: """Get values of continuous signals. - If called before simulate(), the initial values are returned as - strings (or None). If called after simulate(), the final values (at - stopTime) are returned as numpy.float64. + 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 @@ -734,41 +820,9 @@ def getContinuous( [np.float64(-0.24), np.float64(0.68)] """ if not self._simulated: - if names is None: - return self._continuous - if isinstance(names, str): - return [self._continuous[names]] - if isinstance(names, list): - return [self._continuous[x] for x in names] - - if names is None: - for name in self._continuous: - try: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - except (OMCSessionException, ModelicaSystemError) as ex: - raise ModelicaSystemError(f"{name} could not be computed") from ex - return self._continuous + return self.getContinuousInitial(names=names) - if isinstance(names, str): - if names in self._continuous: - value = self.getSolutions(names) - self._continuous[names] = value[0][-1] - return [self._continuous[names]] - raise ModelicaSystemError(f"{names} is not continuous") - - if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._continuous: - value = self.getSolutions(name) - self._continuous[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise ModelicaSystemError(f"{name} is not continuous") - return valuelist - - raise ModelicaSystemError("Unhandled input for getContinous()") + return self.getContinuousFinal(names=names) def getParameters( self, @@ -841,15 +895,103 @@ def getInputs( 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 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 ModelicaSystemError(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, str | numbers.Real] | list[str | numbers.Real]: + ) -> dict[str, np.float64] | list[np.float64]: """Get values of output signals. - If called before simulate(), the initial values are returned as - strings. If called after simulate(), the final values (at stopTime) - are returned as numpy.float64. + 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, @@ -880,37 +1022,9 @@ def getOutputs( [np.float64(-0.1234), np.float64(2.1)] """ if not self._simulated: - if names is None: - return self._outputs - if isinstance(names, str): - return [self._outputs[names]] - return [self._outputs[x] for x in names] - - if names is None: - for name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] - return self._outputs + return self.getOutputsInitial(names=names) - if isinstance(names, str): - if names in self._outputs: - value = self.getSolutions(names) - self._outputs[names] = value[0][-1] - return [self._outputs[names]] - raise KeyError(names) - - if isinstance(names, list): - valuelist = [] - for name in names: - if name in self._outputs: - value = self.getSolutions(name) - self._outputs[name] = value[0][-1] - valuelist.append(value[0][-1]) - else: - raise KeyError(name) - return valuelist - - raise ModelicaSystemError("Unhandled input for getOutputs()") + return self.getOutputsFinal(names=names) def getSimulationOptions( self, From 931405c758ae61bca166285d9f0aea63ee4795f1 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 21 Jan 2026 19:09:07 +0100 Subject: [PATCH 11/62] [ModelicaSystem] use KeyError in getOutputsFinal() --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3676c4328..36dd33e7b 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -964,7 +964,7 @@ def get_outputs_solution(name_list: list[str]) -> None: value = self.getSolutions(name) self._outputs[name] = np.float64(value[0][-1]) else: - raise ModelicaSystemError(f"{names} is not a valid output") + raise KeyError(f"{names} is not a valid output") if names is None: get_outputs_solution(name_list=list(self._outputs.keys())) From 56a1d092449b57bfe63a4fd0706a60e60d47e037 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 21 Jan 2026 19:12:28 +0100 Subject: [PATCH 12/62] [ModelicaSystem] use KeyError in getContinuousFinal() --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 36dd33e7b..37cb41d77 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -762,7 +762,7 @@ def get_continuous_solution(name_list: list[str]) -> None: value = self.getSolutions(name) self._continuous[name] = np.float64(value[0][-1]) else: - raise ModelicaSystemError(f"{names} is not continuous") + raise KeyError(f"{names} is not continuous") if names is None: get_continuous_solution(name_list=list(self._continuous.keys())) From bf7ac803cd59c5f6525187f128549d2412c695dd Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 21 Jan 2026 19:09:50 +0100 Subject: [PATCH 13/62] [ModelicaSystem] fix docstring of getContinuous() --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 37cb41d77..598934fc2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -816,7 +816,7 @@ def getContinuous( {'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)} >>> mod.getContinuous("x") [np.float64(0.68)] - >>> mod.getOutputs(["y","x"]) + >>> mod.getContinuous(["y","x"]) [np.float64(-0.24), np.float64(0.68)] """ if not self._simulated: From 1cf9d37145c7c5e1125264ab87ca67ab4c87ec8b Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 20 Jan 2026 21:30:43 +0100 Subject: [PATCH 14/62] [test_ModelicaSystem.py] needed changes due to update of output / continuous data handling --- tests/test_ModelicaSystem.py | 39 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index dd0321ec8..9bf0a7b93 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -345,20 +345,33 @@ def test_getters(tmp_path): with pytest.raises(KeyError): mod.getInputs("thisInputDoesNotExist") # getOutputs before simulate() - assert mod.getOutputs() == {'y': '-0.4'} - assert mod.getOutputs("y") == ["-0.4"] - assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + output = mod.getOutputs() + assert len(output) == 1 + assert 'y' in output.keys() + assert np.isclose(output['y'], -0.4) + assert np.isclose(mod.getOutputs("y"), -0.4) + output = mod.getOutputs(["y", "y"]) + assert len(output) == 2 + assert np.isclose(output[0], -0.4) + assert np.isclose(output[1], -0.4) with pytest.raises(KeyError): mod.getOutputs("thisOutputDoesNotExist") # getContinuous before simulate(): - assert mod.getContinuous() == { - 'x': '1.0', - 'der(x)': None, - 'y': '-0.4' - } - assert mod.getContinuous("y") == ['-0.4'] - assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + continuous = mod.getContinuous() + assert len(continuous) == 3 + assert 'x' in continuous.keys() + assert np.isclose(continuous['x'], 1.0) + assert 'der(x)' in continuous.keys() + assert np.isnan(continuous['der(x)']) + assert 'y' in continuous.keys() + assert np.isclose(continuous['y'], -0.4) + continuous = mod.getContinuous('y') + assert np.isclose(continuous, -0.4) + continuous = mod.getContinuous(['y', 'x']) + assert np.isclose(continuous[0], -0.4) + assert np.isclose(continuous[1], 1.0) + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter @@ -381,9 +394,9 @@ def test_getters(tmp_path): mod.getOutputs("thisOutputDoesNotExist") # getContinuous after simulate() should return values at end of simulation: - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous(["x", "a", "y"]) # a is a parameter d = mod.getContinuous() assert d.keys() == {"x", "der(x)", "y"} @@ -393,7 +406,7 @@ def test_getters(tmp_path): assert mod.getContinuous("x") == [d["x"]] assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] - with pytest.raises(OMPython.ModelicaSystemError): + with pytest.raises(KeyError): mod.getContinuous("a") # a is a parameter with pytest.raises(OMPython.ModelicaSystemError): From 578130ff3b42da35534f310ff61693d25ab71a76 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 20 Jan 2026 21:35:55 +0100 Subject: [PATCH 15/62] [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 e38dd90eb..655d7ff3e 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -570,6 +570,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: @@ -586,19 +605,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 9641ad48498eb90c5148173b14ea94b9b7b9af0b Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 18:06:16 +0100 Subject: [PATCH 16/62] [ModelicaSystemDoE] simplify definition; use a Modelicasystem instance as argument --- OMPython/ModelicaSystem.py | 40 ++++++++++++++------------------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..d27af4c26 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -464,6 +464,15 @@ def get_session(self) -> OMCSession: """ 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 set_command_line_options(self, command_line_option: str): """ Set the provided command line option via OMC setCommandLineOptions(). @@ -1960,15 +1969,8 @@ def run_doe(): def __init__( self, - # data to be used for ModelicaSystem - model_file: Optional[str | os.PathLike] = None, - model_name: Optional[str] = None, - libraries: Optional[list[str | tuple[str, str]]] = None, - command_line_options: Optional[list[str]] = None, - variable_filter: Optional[str] = None, - work_directory: Optional[str | os.PathLike] = None, - omhome: Optional[str] = None, - session: Optional[OMCSession] = None, + # ModelicaSystem definition to use + mod: ModelicaSystem, # simulation specific input # TODO: add more settings (simulation options, input options, ...) simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, @@ -1981,23 +1983,11 @@ def __init__( ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as a list of parameters to vary for the Doe (= parameters). All possible combinations are considered. """ - if model_name is None: - raise ModelicaSystemError("No model name provided!") - - self._mod = ModelicaSystem( - command_line_options=command_line_options, - work_directory=work_directory, - omhome=omhome, - session=session, - ) - self._mod.model( - model_file=model_file, - model_name=model_name, - libraries=libraries, - variable_filter=variable_filter, - ) + if not isinstance(mod, ModelicaSystem): + raise ModelicaSystemError("Missing definition of ModelicaSystem!") - self._model_name = model_name + self._mod = mod + self._model_name = mod.get_model_name() self._simargs = simargs From 9599b3ff2f5793387c078baef1a4650ed31a60a5 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 18:07:59 +0100 Subject: [PATCH 17/62] [ModelicaSystemDoE] update docstring --- OMPython/ModelicaSystem.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d27af4c26..3d6e86d06 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1940,9 +1940,13 @@ def run_doe(): resdir = mypath / 'DoE' resdir.mkdir(exist_ok=True) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem() + mod.model( model_name="M", model_file=model.as_posix(), + ) + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param, resultpath=resdir, simargs={"override": {'stopTime': 1.0}}, From 4ebe762dfb66283de985328e908ac3887d69d099 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 18:08:31 +0100 Subject: [PATCH 18/62] [ModelicaSystemDoE] fix for relative paths --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3d6e86d06..cd1cc1e67 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1998,7 +1998,7 @@ def __init__( if resultpath is None: self._resultpath = self.get_session().omcpath_tempdir() else: - self._resultpath = self.get_session().omcpath(resultpath) + self._resultpath = self.get_session().omcpath(resultpath).resolve() if not self._resultpath.is_dir(): raise ModelicaSystemError("Argument resultpath must be set to a valid path within the environment used " f"for the OpenModelica session: {resultpath}!") From 96d9ad1ad7acb224ffff0a621fc613d20f7f9695 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 18:09:53 +0100 Subject: [PATCH 19/62] [ModelicaSystemDoE] fix unittest * test_ModelicaSystemDoE_local is tested * test_ModelicaSystemDoE_docker should work * test_ModelicaSystemDoE_WSL is untested --- tests/test_ModelicaSystemDoE.py | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index 0e8d6caae..c1d2ab90b 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -55,12 +55,17 @@ def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem() + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -72,12 +77,19 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, session=omcs, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) @@ -86,15 +98,21 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): - tmpdir = tmp_path / 'DoE' - tmpdir.mkdir(exist_ok=True) + omcs = OMPython.OMCSessionWSL() + assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - doe_mod = OMPython.ModelicaSystemDoE( + mod = OMPython.ModelicaSystem( + session=omcs, + ) + mod.model( model_file=model_doe, model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, parameters=param_doe, - resultpath=tmpdir, - simargs={"override": {'stopTime': 1.0}}, + simargs={"override": {'stopTime': '1.0'}}, ) _run_ModelicaSystemDoe(doe_mod=doe_mod) From 6f894596620ff576d218c4822cf0175154078bc6 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 15:14:31 +0100 Subject: [PATCH 20/62] [ModelicaSystemOMC] add docstring for sendExpression() --- OMPython/ModelicaSystem.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..198d4e158 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -560,6 +560,9 @@ def buildModel(self, variableFilter: Optional[str] = None): self._xmlparse(xml_file=xml_file) def sendExpression(self, expr: str, parsed: bool = True) -> Any: + """ + Wrapper for OMCSession.sendExpression(). + """ try: retval = self._session.sendExpression(command=expr, parsed=parsed) except OMCSessionException as ex: From 5439eb3725e643675fc73653129523fa256eda88 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 21 Jan 2026 20:23:21 +0100 Subject: [PATCH 21/62] [ModelicaSystemBase] define parse_om_version() as staticmethod --- OMPython/ModelicaSystem.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 198d4e158..aba6380bd 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1026,8 +1026,12 @@ def getOptimizationOptions( raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") - def _parse_om_version(self, version: str) -> tuple[int, int, int]: - match = re.search(r"v?(\d+)\.(\d+)\.(\d+)", version) + @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()) From e1f5a635ba61ca439aa70ff68e2de35c5bc4ee47 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 15:16:44 +0100 Subject: [PATCH 22/62] [ModelicaSystemBase] include the original exception if reraised as ModelExecutionException --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index aba6380bd..2b83916d2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1860,7 +1860,7 @@ def linearize( linear_data[target] = value_ast except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: - raise ModelicaSystemError(f"Error parsing linearization file {linear_file}!") from ex + raise ModelicaSystemError(f"Error parsing linearization file {linear_file}: {ex}") from ex # remove the file linear_file.unlink() From f70c37ce8fd03230369fe62f4dc22aacf7181315 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 21 Jan 2026 20:50:25 +0100 Subject: [PATCH 23/62] [OMCSession] fix import order; zmq is a 3rd party package --- OMPython/OMCSession.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index cd1789c8f..9cfe58e0f 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -23,10 +23,10 @@ from typing import Any, Optional, Tuple import uuid import warnings -import zmq import psutil import pyparsing +import zmq # TODO: replace this with the new parser from OMPython.OMTypedParser import om_parser_typed From 7be0970e43b2a92138595fe2141053eaae9ca334 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 20:37:24 +0100 Subject: [PATCH 24/62] [OMCSession] use function keyword arguments if possible --- OMPython/OMCSession.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index cd1789c8f..58724fb3d 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -50,7 +50,7 @@ def poll(self): return None if self.process.is_running() else True def kill(self): - return os.kill(self.pid, signal.SIGKILL) + return os.kill(pid=self.pid, signal=signal.SIGKILL) def wait(self, timeout): try: @@ -848,10 +848,12 @@ def run_model_executable(self, cmd_run_data: OMCSessionRunData) -> int: return returncode def execute(self, command: str): - warnings.warn(message="This function is depreciated and will be removed in future versions; " - "please use sendExpression() instead", - category=DeprecationWarning, - stacklevel=2) + warnings.warn( + message="This function is depreciated and will be removed in future versions; " + "please use sendExpression() instead", + category=DeprecationWarning, + stacklevel=2, + ) return self.sendExpression(command, parsed=False) From 2b1290f3dd6f4f854260713e5e624715b712bff2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 18 Jan 2026 20:37:43 +0100 Subject: [PATCH 25/62] [ModelicaSystem] use function keyword arguments if possible --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..6a197556f 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1024,7 +1024,7 @@ def getOptimizationOptions( raise ModelicaSystemError("Unhandled input for getOptimizationOptions()") def _parse_om_version(self, version: str) -> tuple[int, int, int]: - match = re.search(r"v?(\d+)\.(\d+)\.(\d+)", version) + 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()) From ea6fad5b70a95eb0eabac0d33e4665c4f8926b21 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 9 Jan 2026 09:55:23 +0100 Subject: [PATCH 26/62] update docstring of __init__.py - do not promote depreciated OMCSessionZMQ --- OMPython/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 59a0ad107..624ee64f0 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- """ OMPython is a Python interface to OpenModelica. -To get started, create an OMCSessionZMQ object: -from OMPython import OMCSessionZMQ -omc = OMCSessionZMQ() +To get started on a local OMC server, create an OMCSessionLocal object: + +``` +import OMPython +omc = OMPython.OMCSessionLocal() omc.sendExpression("command") +``` + """ from OMPython.ModelicaSystem import ( From 7c949d68efcba2b4d37a48c4d7462387e3a21533 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 8 Nov 2025 11:36:27 +0100 Subject: [PATCH 27/62] reorder imports in __init__.py --- OMPython/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 59a0ad107..ff43e2e4c 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -18,19 +18,20 @@ OMCPath, OMCSession, OMCSessionCmd, - OMCSessionException, - OMCSessionRunData, - OMCSessionZMQ, - OMCSessionPort, - OMCSessionLocal, OMCSessionDocker, OMCSessionDockerContainer, + OMCSessionException, + OMCSessionLocal, + OMCSessionPort, + OMCSessionRunData, OMCSessionWSL, + OMCSessionZMQ, ) # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', + 'ModelicaSystem', 'ModelicaSystemCmd', 'ModelicaSystemDoE', @@ -40,12 +41,12 @@ 'OMCSession', 'OMCSessionCmd', + 'OMCSessionDocker', + 'OMCSessionDockerContainer', 'OMCSessionException', - 'OMCSessionRunData', - 'OMCSessionZMQ', 'OMCSessionPort', 'OMCSessionLocal', - 'OMCSessionDocker', - 'OMCSessionDockerContainer', + 'OMCSessionRunData', 'OMCSessionWSL', + 'OMCSessionZMQ', ] From e078fa3adba7f85cfe5d79c7bb0fe29dd046aed1 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 14:30:27 +0100 Subject: [PATCH 28/62] [OMCSession] align definition of sendExpression() - use expr (was: command) the following classes are not changed - these are obsolete: - OMCSessionZMQ - OMCSessionCmd --- OMPython/ModelicaSystem.py | 44 ++++++++++++++++++------------------ OMPython/OMCSession.py | 46 +++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index dfc70fd6d..03c24b51d 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -468,12 +468,12 @@ def set_command_line_options(self, command_line_option: str): """ Set the provided command line option via OMC setCommandLineOptions(). """ - exp = f'setCommandLineOptions("{command_line_option}")' - self.sendExpression(exp) + expr = f'setCommandLineOptions("{command_line_option}")' + self.sendExpression(expr=expr) def _loadFile(self, fileName: OMCPath): # load file - self.sendExpression(f'loadFile("{fileName.as_posix()}")') + self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') # for loading file/package, loading model and building model def _loadLibrary(self, libraries: list): @@ -491,7 +491,7 @@ def _loadLibrary(self, libraries: list): expr_load_lib = f"loadModel({element[0]})" else: expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})' - self.sendExpression(expr_load_lib) + self.sendExpression(expr=expr_load_lib) else: raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: " f"{element} is of type {type(element)}, " @@ -514,8 +514,8 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - raise IOError(f"{workdir} could not be created") logger.info("Define work dir as %s", workdir) - exp = f'cd("{workdir.as_posix()}")' - self.sendExpression(exp) + expr = f'cd("{workdir.as_posix()}")' + self.sendExpression(expr=expr) # set the class variable _work_dir ... self._work_dir = workdir @@ -561,7 +561,7 @@ def buildModel(self, variableFilter: Optional[str] = None): def sendExpression(self, expr: str, parsed: bool = True) -> Any: try: - retval = self._session.sendExpression(command=expr, parsed=parsed) + retval = self._session.sendExpression(expr=expr, parsed=parsed) except OMCSessionException as ex: raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex @@ -577,16 +577,16 @@ def _requestApi( properties: Optional[str] = None, ) -> Any: if entity is not None and properties is not None: - exp = f'{apiName}({entity}, {properties})' + expr = f'{apiName}({entity}, {properties})' elif entity is not None and properties is None: if apiName in ("loadFile", "importFMU"): - exp = f'{apiName}("{entity}")' + expr = f'{apiName}("{entity}")' else: - exp = f'{apiName}({entity})' + expr = f'{apiName}({entity})' else: - exp = f'{apiName}()' + expr = f'{apiName}()' - return self.sendExpression(exp) + return self.sendExpression(expr=expr) def _xmlparse(self, xml_file: OMCPath): if not xml_file.is_file(): @@ -1275,8 +1275,8 @@ def getSolutions( # get absolute path result_file = result_file.absolute() - result_vars = self.sendExpression(f'readSimulationResultVars("{result_file.as_posix()}")') - self.sendExpression("closeSimulationResultFile()") + result_vars = self.sendExpression(expr=f'readSimulationResultVars("{result_file.as_posix()}")') + self.sendExpression(expr="closeSimulationResultFile()") if varList is None: return result_vars @@ -1293,9 +1293,9 @@ def getSolutions( if var not in result_vars: raise ModelicaSystemError(f"Requested data {repr(var)} does not exist") variables = ",".join(var_list_checked) - res = self.sendExpression(f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') + res = self.sendExpression(expr=f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})') np_res = np.array(res) - self.sendExpression("closeSimulationResultFile()") + self.sendExpression(expr="closeSimulationResultFile()") return np_res @staticmethod @@ -1395,7 +1395,7 @@ def _set_method_helper( "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(\"setParameterValue(" + "sendExpression(expr=\"setParameterValue(" f"{self._model_name}, {key}, {val if val is not None else ''}" ")\").") @@ -2061,16 +2061,16 @@ def prepare(self) -> int: pk_value = pc_structure[idx_structure] if isinstance(pk_value, str): pk_value_str = self.get_session().escape_str(pk_value) - expression = f"setParameterValue({self._model_name}, {pk_structure}, \"{pk_value_str}\")" + 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" - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" + expr = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value_bool_str});" else: - expression = f"setParameterValue({self._model_name}, {pk_structure}, {pk_value})" - res = self._mod.sendExpression(expression) + 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(expression)}") + f"to {pk_value} using {repr(expr)}") self._mod.buildModel() diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index cd1789c8f..6b5b2b3da 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -274,13 +274,13 @@ def is_file(self, *, follow_symlinks=True) -> bool: """ Check if the path is a regular file. """ - return self._session.sendExpression(f'regularFileExists("{self.as_posix()}")') + return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') def is_dir(self, *, follow_symlinks=True) -> bool: """ Check if the path is a directory. """ - return self._session.sendExpression(f'directoryExists("{self.as_posix()}")') + return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') def is_absolute(self): """ @@ -298,7 +298,7 @@ def read_text(self, encoding=None, errors=None, newline=None) -> str: The additional arguments `encoding`, `errors` and `newline` are only defined for compatibility with Path() definition. """ - return self._session.sendExpression(f'readFile("{self.as_posix()}")') + return self._session.sendExpression(expr=f'readFile("{self.as_posix()}")') def write_text(self, data: str, encoding=None, errors=None, newline=None): """ @@ -311,7 +311,7 @@ def write_text(self, data: str, encoding=None, errors=None, newline=None): raise TypeError(f"data must be str, not {data.__class__.__name__}") data_omc = self._session.escape_str(data) - self._session.sendExpression(f'writeFile("{self.as_posix()}", "{data_omc}", false);') + self._session.sendExpression(expr=f'writeFile("{self.as_posix()}", "{data_omc}", false);') return len(data) @@ -324,20 +324,20 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): if self.is_dir() and not exist_ok: raise FileExistsError(f"Directory {self.as_posix()} already exists!") - return self._session.sendExpression(f'mkdir("{self.as_posix()}")') + return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') def cwd(self): """ Returns the current working directory as an OMCPath object. """ - cwd_str = self._session.sendExpression('cd()') + cwd_str = self._session.sendExpression(expr='cd()') return OMCPath(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(f'deleteFile("{self.as_posix()}")') + 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!") @@ -367,12 +367,12 @@ def _omc_resolve(self, pathstr: str) -> str: Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd within OMC. """ - expression = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') try: - result = self._session.sendExpression(command=expression, parsed=False) + 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 @@ -401,7 +401,7 @@ def size(self) -> int: if not self.is_file(): raise OMCSessionException(f"Path {self.as_posix()} is not a file!") - res = self._session.sendExpression(f'stat("{self.as_posix()}")') + res = self._session.sendExpression(expr=f'stat("{self.as_posix()}")') if res[0]: return int(res[1]) @@ -573,7 +573,7 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: The complete error handling of the OMC result is done within this method using '"getMessagesStringInternal()'. Caller should only check for OMCSessionException. """ - return self.omc_process.sendExpression(command=command, parsed=parsed) + return self.omc_process.sendExpression(expr=command, parsed=parsed) class PostInitCaller(type): @@ -695,7 +695,7 @@ def __post_init__(self) -> None: def __del__(self): if isinstance(self._omc_zmq, zmq.Socket): try: - self.sendExpression("quit()") + self.sendExpression(expr="quit()") except OMCSessionException as exc: logger.warning(f"Exception on sending 'quit()' to OMC: {exc}! Continue nevertheless ...") finally: @@ -791,7 +791,7 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: if sys.version_info < (3, 12): tempdir_str = tempfile.gettempdir() else: - tempdir_str = self.sendExpression("getTempDirectoryPath()") + tempdir_str = self.sendExpression(expr="getTempDirectoryPath()") tempdir_base = self.omcpath(tempdir_str) tempdir: Optional[OMCPath] = None @@ -855,7 +855,7 @@ def execute(self, command: str): return self.sendExpression(command, parsed=False) - def sendExpression(self, command: str, parsed: bool = True) -> Any: + def sendExpression(self, expr: str, parsed: bool = True) -> Any: """ Send an expression to the OMC server and return the result. @@ -866,12 +866,12 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: if self._omc_zmq is None: raise OMCSessionException("No OMC running. Please create a new instance of OMCSession!") - logger.debug("sendExpression(%r, parsed=%r)", command, parsed) + 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(command), flags=zmq.NOBLOCK) + self._omc_zmq.send_string(str(expr), flags=zmq.NOBLOCK) break except zmq.error.Again: pass @@ -885,7 +885,7 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: logger.error(f"OMC did not start. Log-file says:\n{log_content}") raise OMCSessionException(f"No connection with OMC (timeout={self._timeout}).") - if command == "quit()": + if expr == "quit()": self._omc_zmq.close() self._omc_zmq = None return None @@ -895,13 +895,13 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: if result.startswith('Error occurred building AST'): raise OMCSessionException(f"OMC error: {result}") - if command == "getErrorString()": + if expr == "getErrorString()": # no error handling if 'getErrorString()' is called if parsed: logger.warning("Result of 'getErrorString()' cannot be parsed!") return result - if command == "getMessagesStringInternal()": + if expr == "getMessagesStringInternal()": # no error handling if 'getMessagesStringInternal()' is called if parsed: logger.warning("Result of 'getMessagesStringInternal()' cannot be parsed!") @@ -955,7 +955,7 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: log_level = log_raw[0][8] log_id = log_raw[0][9] - msg_short = (f"[OMC log for 'sendExpression({command}, {parsed})']: " + 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 @@ -977,7 +977,7 @@ def sendExpression(self, command: 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({command}, {parsed}):\n" + raise OMCSessionException(f"OMC error occurred for 'sendExpression(expr={expr}, parsed={parsed}):\n" f"{msg_long_str}") if not parsed: From ef53f7440a459cc0d95c2b5695d14e25b3f9cdff Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 7 Feb 2026 16:30:45 +0100 Subject: [PATCH 29/62] [merge fix] ModelicaSystemCmd => ModelExecutionCmd; see: [ModelExecution*] create classes to handle model execution --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 6f9121c37..718e764dc 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1213,7 +1213,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], From a9be4b35dae2f6480abd7634640a5cce898713ff Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 7 Feb 2026 16:41:21 +0100 Subject: [PATCH 30/62] ??? fix --- tests/test_ModelicaSystemDoE.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index c1d2ab90b..6b2b0993e 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -88,7 +88,6 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): doe_mod = OMPython.ModelicaSystemDoE( mod=mod, parameters=param_doe, - session=omcs, simargs={"override": {'stopTime': '1.0'}}, ) From 57977a65b229bf586bec888391b0ef7e5e1c0c28 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 20:45:40 +0100 Subject: [PATCH 31/62] [ModelicaSystem] split ModelicaSystem into ModelicaSystemABC and ModelicaSystem --- OMPython/ModelicaSystem.py | 1330 ++++++++++++++++++------------------ 1 file changed, 679 insertions(+), 651 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 718e764dc..d171be5c8 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, @@ -2074,6 +1487,621 @@ def getLinearStates(self) -> list[str]: return self._linearized_states +class ModelicaSystem(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 = ModelicaSystem() + # and then one of the lines below + mod.model(name="modelName", file="ModelicaModel.mo", ) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"]) + mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"]) + """ + + if self._model_name is not None: + raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem " + f"defined for {repr(self._model_name)}!") + + if model_name is None or not isinstance(model_name, str): + raise ModelicaSystemError("A model name must be provided!") + + if libraries is None: + libraries = [] + + if not isinstance(libraries, list): + raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!") + + # set variables + self._model_name = model_name # Model class name + self._libraries = libraries # may be needed if model is derived from other model + self._variable_filter = variable_filter + + if self._libraries: + self._loadLibrary(libraries=self._libraries) + + self._file_name = None + if model_file is not None: + file_path = pathlib.Path(model_file) + # special handling for OMCProcessLocal - consider a relative path + if isinstance(self._session, OMCSessionLocal) and not file_path.is_absolute(): + file_path = pathlib.Path.cwd() / file_path + if not file_path.is_file(): + raise IOError(f"Model file {file_path} does not exist!") + + self._file_name = self.getWorkDirectory() / file_path.name + if (isinstance(self._session, OMCSessionLocal) + and file_path.as_posix() == self._file_name.as_posix()): + pass + elif self._file_name.is_file(): + raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!") + else: + content = file_path.read_text(encoding='utf-8') + self._file_name.write_text(content) + + if self._file_name is not None: + self._loadFile(fileName=self._file_name) + + if build: + self.buildModel(variable_filter) + + def set_command_line_options(self, command_line_option: str): + """ + Set the provided command line option via OMC setCommandLineOptions(). + """ + 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 ModelicaSystemDoE: """ Class to run DoEs based on a (Open)Modelica model using ModelicaSystem From 0b43d24f5846899efeea5af4a65788ed8b3be380 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 20:51:35 +0100 Subject: [PATCH 32/62] [ModelicaSystem] rename ModelicaSystem => ModelicaSystemOMC * add compatibility variable for ModelicaSystem --- OMPython/ModelicaSystem.py | 12 +++++++++--- OMPython/__init__.py | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d171be5c8..e07f1e6df 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1487,7 +1487,7 @@ def getLinearStates(self) -> list[str]: return self._linearized_states -class ModelicaSystem(ModelicaSystemABC): +class ModelicaSystemOMC(ModelicaSystemABC): """ Class to simulate a Modelica model using OpenModelica via OMCSession. """ @@ -2102,6 +2102,12 @@ def optimize(self) -> dict[str, Any]: 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 @@ -2177,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, @@ -2190,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 7c199ef32..1f086293e 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', From f0f87a28303c7b8fb73cea41c0cb6e901d157900 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:31:13 +0100 Subject: [PATCH 33/62] [test_ModelicaSystemOMC] rename from ModelicaSystem and update --- ...icaSystem.py => test_ModelicaSystemOMC.py} | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) rename tests/{test_ModelicaSystem.py => test_ModelicaSystemOMC.py} (96%) 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 9bf0a7b93..8dd17ef02 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", From df8f8ba76b915a1e16e8c2c38a1f0a95e19be33b Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:31:32 +0100 Subject: [PATCH 34/62] [test_*] use ModelicaSystemOMC --- tests/test_FMIExport.py | 4 ++-- tests/test_FMIImport.py | 4 ++-- tests/test_ModelicaSystemCmd.py | 2 +- tests/test_ModelicaSystemDoE.py | 6 +++--- tests/test_OMSessionCmd.py | 2 +- tests/test_linearization.py | 4 ++-- tests/test_optimization.py | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index 006d2d177..c7ab038a4 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 cb43e0aea..bb3a12017 100644 --- a/tests/test_FMIImport.py +++ b/tests/test_FMIImport.py @@ -22,7 +22,7 @@ def model_firstorder(tmp_path): def test_FMIImport(model_firstorder): # create model & simulate it - mod1 = OMPython.ModelicaSystem() + mod1 = OMPython.ModelicaSystemOMC() mod1.model( model_file=model_firstorder, model_name="M", @@ -35,7 +35,7 @@ def test_FMIImport(model_firstorder): # import FMU & check & simulate # TODO: why is '--allowNonStandardModelica=reinitInAlgorithms' needed? any example without this possible? - mod2 = OMPython.ModelicaSystem(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) + mod2 = OMPython.ModelicaSystemOMC(command_line_options=['--allowNonStandardModelica=reinitInAlgorithms']) mo = mod2.convertFmu2Mo(fmu=fmu) assert os.path.exists(mo) diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 6fa2658f9..3d35376b4 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 6b2b0993e..e1ba852a5 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", @@ -77,7 +77,7 @@ def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( @@ -100,7 +100,7 @@ def test_ModelicaSystemDoE_WSL(tmp_path, model_doe, param_doe): omcs = OMPython.OMCSessionWSL() assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - mod = OMPython.ModelicaSystem( + mod = OMPython.ModelicaSystemOMC( session=omcs, ) mod.model( diff --git a/tests/test_OMSessionCmd.py b/tests/test_OMSessionCmd.py index d3997ecf3..7dbb97053 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 c61462bba..7070a45bd 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -25,7 +25,7 @@ def model_linearTest(tmp_path): def test_example(model_linearTest): - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_linearTest, model_name="linearTest", @@ -60,7 +60,7 @@ def test_getters(tmp_path): y2 = phi + u1; end Pendulum; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="Pendulum", diff --git a/tests/test_optimization.py b/tests/test_optimization.py index d74942817..823ba1e39 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -34,7 +34,7 @@ def test_optimization_example(tmp_path): end BangBang2021; """) - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() mod.model( model_file=model_file, model_name="BangBang2021", From ca5ef4eb2f721ee153a6c55d26a0d335b1275c29 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 20:45:40 +0100 Subject: [PATCH 35/62] [ModelicaSystem*] fix last usages of ModelicaSystem() in comments & docstrings --- OMPython/ModelicaSystem.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e07f1e6df..3e08c4126 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1378,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( @@ -1562,7 +1562,7 @@ def model( simply loads the model without compiling. Examples: - mod = ModelicaSystem() + 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"]) @@ -2149,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(), From 53f4ec41df14e2e5256e82f9280bf664c3bdb3da Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:23:10 +0100 Subject: [PATCH 36/62] [ModelicaSystem] split ModelicaSystemDoE into ModelicaDoEABC and ModelicaDoE --- OMPython/ModelicaSystem.py | 104 +++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3e08c4126..a1643bdfb 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 ------- @@ -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,6 +2423,64 @@ def worker(worker_id, task_queue): return doe_def_total == doe_def_done + +class ModelicaSystemDoE(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: + 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) + + 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, From 07658f5f9ff48133ef46f1b69db1ee06f68bd4b2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:25:12 +0100 Subject: [PATCH 37/62] [ModelicaSystem] rename ModelicaSystemDoE => ModelicaDoEOMC * add compatibility variable for ModelicaSystemDoE --- OMPython/ModelicaSystem.py | 8 +++++++- OMPython/__init__.py | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index a1643bdfb..0862bea5b 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2424,7 +2424,7 @@ def worker(worker_id, task_queue): return doe_def_total == doe_def_done -class ModelicaSystemDoE(ModelicaDoEABC): +class ModelicaDoEOMC(ModelicaDoEABC): """ Class to run DoEs based on a (Open)Modelica model using ModelicaSystemOMC @@ -2543,3 +2543,9 @@ def get_doe_solutions( sol_dict[resultfilename]['data'] = {} return sol_dict + + +class ModelicaSystemDoE(ModelicaDoEOMC): + """ + Compatibility class. + """ diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 1f086293e..1eb355f80 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -17,6 +17,7 @@ ModelicaSystemOMC, ModelExecutionCmd, ModelicaSystemDoE, + ModelicaDoEOMC, ModelicaSystemError, ) from OMPython.OMCSession import ( @@ -47,6 +48,7 @@ 'ModelicaSystemOMC', 'ModelExecutionCmd', 'ModelicaSystemDoE', + 'ModelicaDoEOMC', 'ModelicaSystemError', 'OMCPath', From 2ca3f1654ebc7cd05289f38393b6d92d576b620a Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:25:55 +0100 Subject: [PATCH 38/62] [test_ModelicaDoEOMC] rename from ModelicaSystemDoE and update --- ...icaSystemDoE.py => test_ModelicaDoEOMC.py} | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename tests/{test_ModelicaSystemDoE.py => test_ModelicaDoEOMC.py} (87%) diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaDoEOMC.py similarity index 87% rename from tests/test_ModelicaSystemDoE.py rename to tests/test_ModelicaDoEOMC.py index e1ba852a5..c0b9fda39 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") assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" @@ -85,18 +85,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() assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" @@ -108,16 +108,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 88a228b8cf7ce3b9353bd635367d6530229c84de Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 21:47:17 +0100 Subject: [PATCH 39/62] [ModelicaSystem] update ModelicaDoEABC to use ModelicaSystemABC --- OMPython/ModelicaSystem.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 0862bea5b..75ae7eb57 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -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 @@ -2442,6 +2442,10 @@ def __init__( 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, @@ -2459,6 +2463,10 @@ def _prepare_structure_parameters( 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] @@ -2507,6 +2515,9 @@ def get_doe_solutions( ``` """ + if not isinstance(self._mod, ModelicaSystemOMC): + raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + if not isinstance(self._doe_def, dict): return None From 7d131ff18facde1ded22fb9492244f9046416e59 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 24 Jan 2026 22:00:55 +0100 Subject: [PATCH 40/62] [ModelicaSystem] define doe_get_solutions() as separate method --- OMPython/ModelicaSystem.py | 113 +++++++++++++++++++++---------------- OMPython/__init__.py | 5 ++ 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 75ae7eb57..e4d9ae39a 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2494,66 +2494,83 @@ def get_doe_solutions( 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._mod, ModelicaSystemOMC): - raise ModelicaSystemError(f"Invalid definition for mod: {type(self._mod)} - expect ModelicaSystemOMC!") + The following code snippet can be used to convert the solution data for each run to a pandas dataframe: - if not isinstance(self._doe_def, dict): - return None + ``` + import pandas as pd - if len(self._doe_def) == 0: - raise ModelicaSystemError("No result files available - all simulations did fail?") + 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: dict[str, dict[str, Any]] = {} - for resultfilename in self._doe_def: - resultfile = self._resultpath / resultfilename + """ + if not isinstance(doe_def, dict): + return None - sol_dict[resultfilename] = {} + if len(doe_def) == 0: + raise ModelicaSystemError("No result files available - all simulations did fail?") - 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 + sol_dict: dict[str, dict[str, Any]] = {} + for resultfilename in doe_def: + resultfile = resultpath / resultfilename - 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[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): diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 1eb355f80..9f4408d52 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -19,6 +19,8 @@ ModelicaSystemDoE, ModelicaDoEOMC, ModelicaSystemError, + + doe_get_solutions, ) from OMPython.OMCSession import ( OMCPath, @@ -54,6 +56,9 @@ 'OMCPath', 'OMCSession', + + 'doe_get_solutions', + 'OMCSessionCmd', 'OMCSessionDocker', 'OMCSessionDockerContainer', From f406c478b1af6f828857ddbb1ecd1b8e19a33a21 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 25 Jan 2026 22:45:49 +0100 Subject: [PATCH 41/62] [OMCSession] update OMCPath to use OMPathABC as baseline and further cleanup --- OMPython/OMCSession.py | 223 +++++++++++++++++++++++++++-------------- 1 file changed, 145 insertions(+), 78 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index b95f36c16..aa457eece 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -249,13 +249,15 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -class OMCPathReal(pathlib.PurePosixPath): +class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ - Implementation of a basic (PurePosix)Path object which uses OMC as backend. The connection to OMC is provided via an - instances of OMCSession* classes. + 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 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). + 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: OMCSession) -> None: @@ -266,17 +268,100 @@ def with_segments(self, *pathsegments): """ Create a new OMCPath object with the given path segments. - The original definition of Path is overridden to ensure the OMC session is set. + The original definition of Path is overridden to ensure the session data is set. """ return type(self)(*pathsegments, session=self._session) - def is_file(self, *, follow_symlinks=True) -> bool: + @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): + """ + 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): + """ + Write text data to the file represented by this path. + """ + + @abc.abstractmethod + 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. + """ + + @abc.abstractmethod + def cwd(self): + """ + Returns the current working directory as an OMPathBase 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): + """ + 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() + + 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 OMPathBase 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. """ return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - def is_dir(self, *, follow_symlinks=True) -> bool: + def is_dir(self) -> bool: """ Check if the path is a directory. """ @@ -284,28 +369,21 @@ def is_dir(self, *, follow_symlinks=True) -> bool: 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. + 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 read_text(self, encoding=None, errors=None, newline=None) -> str: + def read_text(self) -> str: """ 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 write_text(self, data: str, encoding=None, errors=None, newline=None): + def write_text(self, data: str): """ Write text data to the file represented by this path. - - 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__}") @@ -315,11 +393,13 @@ def write_text(self, data: str, encoding=None, errors=None, newline=None): return len(data) - def mkdir(self, mode=0o777, parents=False, exist_ok=False): + def mkdir(self, parents: bool = True, exist_ok: bool = False): """ - Create a directory at the path represented by this OMCPath object. + Create a directory at the path represented by this class. - The additional arguments `mode`, and `parents` are only defined for compatibility with Path() definitions. + 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!") @@ -328,7 +408,7 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): def cwd(self): """ - Returns the current working directory as an OMCPath object. + Returns the current working directory as an OMPathBase object. """ cwd_str = self._session.sendExpression(expr='cd()') return OMCPath(cwd_str, session=self._session) @@ -381,19 +461,6 @@ def _omc_resolve(self, pathstr: str) -> str: return pathstr_resolved - 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 exists(self, follow_symlinks=True) -> bool: - """ - Semi replacement for pathlib.Path.exists(). - """ - return self.is_file() or self.is_dir() - 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. @@ -408,47 +475,47 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") -if sys.version_info < (3, 12): +class OMPathCompatibility(pathlib.Path): + """ + Compatibility class for OMPathBase in Python < 3.12. This allows to run all code which uses OMPathBase (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. + """ - class OMCPathCompatibility(pathlib.Path): + # 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: """ - 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. + Needed compatibility function to have the same interface as OMCPathReal """ + return self.stat().st_size - # 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 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 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 OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): - """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) - """ - class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): - """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) - """ +class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): + """ + Compatibility class for OMCPath on Windows systems (Python < 3.12) + """ - OMCPath = OMCPathCompatibility +if sys.version_info < (3, 12): + OMCPath = OMPathCompatibility else: - OMCPath = OMCPathReal + OMCPath = _OMCPath class ModelExecutionException(Exception): @@ -570,13 +637,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,7 +863,7 @@ 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. """ @@ -810,7 +877,7 @@ def model_execution_prefix(self, cwd: Optional[OMCPath] = None) -> list[str]: 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 +890,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 +907,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 +1310,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 +1587,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 +1597,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 += ['--'] From f462a46004dcbd977cb3a21e26765e6b5314f30c Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 25 Jan 2026 22:50:37 +0100 Subject: [PATCH 42/62] [ModelicaSystem] shortcut to use OMCPath = OMPathABC for now --- OMPython/ModelicaSystem.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e4d9ae39a..8274d6c4c 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -28,9 +28,12 @@ OMCSessionException, OMCSession, OMCSessionLocal, - OMCPath, + + OMPathABC, ) +OMCPath = OMPathABC + # define logger using the current module name as ID logger = logging.getLogger(__name__) From b9d3cc576759a44ba645b032d81506e3ec4b96f0 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 26 Jan 2026 21:44:56 +0100 Subject: [PATCH 43/62] [ModelicaSystem] fix usage of OMCPath; replace by OMPathABC --- OMPython/ModelicaSystem.py | 32 +++++++++++++++----------------- OMPython/OMCSession.py | 2 +- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 8274d6c4c..2e98724cd 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -32,8 +32,6 @@ OMPathABC, ) -OMCPath = OMPathABC - # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -390,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: @@ -414,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. @@ -436,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. """ @@ -461,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}") @@ -835,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: @@ -867,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: @@ -965,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( @@ -1297,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. @@ -1625,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()}")') @@ -2006,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: @@ -2045,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". @@ -2512,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 aa457eece..584d13c48 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -870,7 +870,7 @@ def set_workdir(self, workdir: OMPathABC) -> None: 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. """ From 4eaa243d9587be6508d5d3fc2fb02c2c4a524599 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 15:44:53 +0100 Subject: [PATCH 44/62] [OMCSession] move OM(C)Path classes into the if cause --- OMPython/OMCSession.py | 488 ++++++++++++++++++++--------------------- 1 file changed, 243 insertions(+), 245 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 584d13c48..95d871d69 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -249,272 +249,270 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -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: OMCSession) -> None: - super().__init__(*path) - self._session = session - - def with_segments(self, *pathsegments): - """ - 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): - """ - 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): - """ - Write text data to the file represented by this path. - """ - - @abc.abstractmethod - 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. - """ - - @abc.abstractmethod - def cwd(self): - """ - Returns the current working directory as an OMPathBase 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): - """ - 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() - - 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 OMPathBase 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. - """ - return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') - - def is_dir(self) -> bool: - """ - Check if the path is a directory. - """ - return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') - - 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 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 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__}") - - 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): - """ - 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!") - - return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') - - def cwd(self): - """ - Returns the current working directory as an OMPathBase object. - """ - cwd_str = self._session.sendExpression(expr='cd()') - return OMCPath(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): +# 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 OMPathBase, 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): """ - Resolve the path to an absolute path. This is done based on available OMC functions. + Compatibility class for OMPathBase in Python < 3.12. This allows to run all code which uses OMPathBase (mainly + ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. """ - 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!") + # 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 not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): - raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") + if 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 - return omcpath_resolved + def size(self) -> int: + """ + Needed compatibility function to have the same interface as OMCPathReal + """ + return self.stat().st_size - def _omc_resolve(self, pathstr: str) -> str: + class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): """ - Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd - within OMC. + Compatibility class for OMCPath on Posix systems (Python < 3.12) """ - expr = ('omcpath_cwd := cd(); ' - f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring - 'cd(omcpath_cwd)') - - try: - result = self._session.sendExpression(expr=expr, parsed=False) - result_parts = result.split('\n') - pathstr_resolved = result_parts[1] - pathstr_resolved = pathstr_resolved[1:-1] # remove quotes - except OMCSessionException as ex: - raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex - - return pathstr_resolved - def size(self) -> int: + class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): """ - Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - 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()}!") - - -class OMPathCompatibility(pathlib.Path): - """ - Compatibility class for OMPathBase in Python < 3.12. This allows to run all code which uses OMPathBase (mainly - ModelicaSystem) on these Python versions. There are remaining limitation as only local execution is possible. - """ + OMCPath = 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: OMCSession) -> None: + super().__init__(*path) + self._session = session + + def with_segments(self, *pathsegments): + """ + 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): + """ + 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): + """ + Write text data to the file represented by this path. + """ + + @abc.abstractmethod + 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. + """ + + @abc.abstractmethod + def cwd(self): + """ + Returns the current working directory as an OMPathBase 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): + """ + 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() + + 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 OMPathBase 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. + """ + return self._session.sendExpression(expr=f'regularFileExists("{self.as_posix()}")') + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + return self._session.sendExpression(expr=f'directoryExists("{self.as_posix()}")') + + 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 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 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__}") + + 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): + """ + 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!") + + return self._session.sendExpression(expr=f'mkdir("{self.as_posix()}")') + + def cwd(self): + """ + Returns the current working directory as an OMPathBase object. + """ + cwd_str = self._session.sendExpression(expr='cd()') + return OMCPath(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): + """ + Resolve the path to an absolute path. This is done based on available OMC functions. + """ + if strict and not (self.is_file() or self.is_dir()): + raise OMCSessionException(f"Path {self.as_posix()} does not exist!") + + if self.is_file(): + pathstr_resolved = self._omc_resolve(self.parent.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) / self.name + elif self.is_dir(): + pathstr_resolved = self._omc_resolve(self.as_posix()) + omcpath_resolved = self._session.omcpath(pathstr_resolved) + else: + raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") - # 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 not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): + raise OMCSessionException(f"OMCPath resolve failed for {self.as_posix()} - path does not exist!") - if 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 + return omcpath_resolved - def size(self) -> int: - """ - Needed compatibility function to have the same interface as OMCPathReal - """ - return self.stat().st_size + def _omc_resolve(self, pathstr: str) -> str: + """ + Internal function to resolve the path of the OMCPath object using OMC functions *WITHOUT* changing the cwd + within OMC. + """ + expr = ('omcpath_cwd := cd(); ' + f'omcpath_check := cd("{pathstr}"); ' # check requested pathstring + 'cd(omcpath_cwd)') + try: + result = self._session.sendExpression(expr=expr, parsed=False) + result_parts = result.split('\n') + pathstr_resolved = result_parts[1] + pathstr_resolved = pathstr_resolved[1:-1] # remove quotes + except OMCSessionException as ex: + raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex -class OMPathCompatibilityPosix(pathlib.PosixPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Posix systems (Python < 3.12) - """ + return pathstr_resolved + def size(self) -> int: + """ + Get the size of the file in bytes - this is an extra function and the best we can do using OMC. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") -class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): - """ - Compatibility class for OMCPath on Windows systems (Python < 3.12) - """ + 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()}!") -if sys.version_info < (3, 12): - OMCPath = OMPathCompatibility -else: OMCPath = _OMCPath From 221789b4010a95d63316fc1a14c426efb7c2db08 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 15:45:21 +0100 Subject: [PATCH 45/62] [OMCSession] define and use OMPathBase --- OMPython/ModelicaSystem.py | 32 ++++++++++++++++---------------- OMPython/OMCSession.py | 2 ++ OMPython/__init__.py | 3 +++ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 2e98724cd..594e9fff2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -29,7 +29,7 @@ OMCSession, OMCSessionLocal, - OMPathABC, + OMPathBase, ) # define logger using the current module name as ID @@ -388,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[OMPathABC] = None # for storing result file + self._result_file: Optional[OMPathBase] = None # for storing result file - self._work_dir: OMPathABC = self.setWorkDirectory(work_directory) + self._work_dir: OMPathBase = self.setWorkDirectory(work_directory) self._model_name: Optional[str] = None self._libraries: Optional[list[str | tuple[str, str]]] = None - self._file_name: Optional[OMPathABC] = None + self._file_name: Optional[OMPathBase] = None self._variable_filter: Optional[str] = None def get_session(self) -> OMCSession: @@ -412,7 +412,7 @@ def get_model_name(self) -> str: return self._model_name - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathABC: + def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathBase: """ Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this directory. If no directory is defined a unique temporary directory is created. @@ -434,7 +434,7 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - # ... and also return the defined path return workdir - def getWorkDirectory(self) -> OMPathABC: + def getWorkDirectory(self) -> OMPathBase: """ Return the defined working directory for this ModelicaSystem / OpenModelica session. """ @@ -459,7 +459,7 @@ def check_model_executable(self): if returncode != 0: raise ModelicaSystemError("Model executable not working!") - def _xmlparse(self, xml_file: OMPathABC): + def _xmlparse(self, xml_file: OMPathBase): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -833,7 +833,7 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, om_cmd: ModelExecutionCmd, - override_file: OMPathABC, + override_file: OMPathBase, override_var: dict[str, str], override_sim: dict[str, str], ) -> None: @@ -865,7 +865,7 @@ def _process_override_data( def simulate_cmd( self, - result_file: OMPathABC, + result_file: OMPathBase, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> ModelExecutionCmd: @@ -963,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, OMPathABC): + elif isinstance(resultfile, OMPathBase): self._result_file = resultfile else: self._result_file = self._session.omcpath(resultfile) if not self._result_file.is_absolute(): self._result_file = self.getWorkDirectory() / resultfile - if not isinstance(self._result_file, OMPathABC): + if not isinstance(self._result_file, OMPathBase): raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") om_cmd = self.simulate_cmd( @@ -1295,7 +1295,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[OMPathABC] = None) -> OMPathABC: + def _createCSVData(self, csvfile: Optional[OMPathBase] = None) -> OMPathBase: """ Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, this file is used; else a generic file name is created. @@ -1623,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: OMPathABC): + def _loadFile(self, fileName: OMPathBase): # load file self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') @@ -2004,7 +2004,7 @@ def convertMo2Fmu( fmuType: str = "me_cs", fileNamePrefix: Optional[str] = None, includeResources: bool = True, - ) -> OMPathABC: + ) -> OMPathBase: """Translate the model into a Functional Mockup Unit. Args: @@ -2043,7 +2043,7 @@ def convertMo2Fmu( def convertFmu2Mo( self, fmu: os.PathLike, - ) -> OMPathABC: + ) -> OMPathBase: """ In order to load FMU, at first it needs to be translated into Modelica model. This method is used to generate Modelica model from the given FMU. It generates "fmuName_me_FMU.mo". @@ -2510,7 +2510,7 @@ def get_doe_solutions( def doe_get_solutions( msomc: ModelicaSystemOMC, - resultpath: OMPathABC, + resultpath: OMPathBase, 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 95d871d69..3aca99e93 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -287,6 +287,7 @@ class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): Compatibility class for OMCPath on Windows systems (Python < 3.12) """ + OMPathBase = OMPathCompatibility OMCPath = OMPathCompatibility else: class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): @@ -513,6 +514,7 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") + OMPathBase = OMPathABC OMCPath = _OMCPath diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 9f4408d52..7eed366f4 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -23,7 +23,9 @@ doe_get_solutions, ) from OMPython.OMCSession import ( + OMPathBase, OMCPath, + OMCSession, ModelExecutionData, @@ -53,6 +55,7 @@ 'ModelicaDoEOMC', 'ModelicaSystemError', + 'OMPathBase', 'OMCPath', 'OMCSession', From 360b99bc1f37f44914c18136307315c0c246ce66 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 16:00:37 +0100 Subject: [PATCH 46/62] [OMCSession] align on OMPathABC; replace usage of OMPathBase --- OMPython/ModelicaSystem.py | 32 ++++++++++++++++---------------- OMPython/OMCSession.py | 13 ++++++------- OMPython/__init__.py | 4 ++-- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 594e9fff2..2e98724cd 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -29,7 +29,7 @@ OMCSession, OMCSessionLocal, - OMPathBase, + OMPathABC, ) # define logger using the current module name as ID @@ -388,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[OMPathBase] = None # for storing result file + self._result_file: Optional[OMPathABC] = None # for storing result file - self._work_dir: OMPathBase = 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[OMPathBase] = None + self._file_name: Optional[OMPathABC] = None self._variable_filter: Optional[str] = None def get_session(self) -> OMCSession: @@ -412,7 +412,7 @@ def get_model_name(self) -> str: return self._model_name - def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMPathBase: + 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. @@ -434,7 +434,7 @@ def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) - # ... and also return the defined path return workdir - def getWorkDirectory(self) -> OMPathBase: + def getWorkDirectory(self) -> OMPathABC: """ Return the defined working directory for this ModelicaSystem / OpenModelica session. """ @@ -459,7 +459,7 @@ def check_model_executable(self): if returncode != 0: raise ModelicaSystemError("Model executable not working!") - def _xmlparse(self, xml_file: OMPathBase): + def _xmlparse(self, xml_file: OMPathABC): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -833,7 +833,7 @@ def _parse_om_version(version: str) -> tuple[int, int, int]: def _process_override_data( self, om_cmd: ModelExecutionCmd, - override_file: OMPathBase, + override_file: OMPathABC, override_var: dict[str, str], override_sim: dict[str, str], ) -> None: @@ -865,7 +865,7 @@ def _process_override_data( def simulate_cmd( self, - result_file: OMPathBase, + result_file: OMPathABC, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None, ) -> ModelExecutionCmd: @@ -963,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, OMPathBase): + 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, OMPathBase): + 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( @@ -1295,7 +1295,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[OMPathBase] = None) -> OMPathBase: + 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. @@ -1623,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: OMPathBase): + def _loadFile(self, fileName: OMPathABC): # load file self.sendExpression(expr=f'loadFile("{fileName.as_posix()}")') @@ -2004,7 +2004,7 @@ def convertMo2Fmu( fmuType: str = "me_cs", fileNamePrefix: Optional[str] = None, includeResources: bool = True, - ) -> OMPathBase: + ) -> OMPathABC: """Translate the model into a Functional Mockup Unit. Args: @@ -2043,7 +2043,7 @@ def convertMo2Fmu( def convertFmu2Mo( self, fmu: os.PathLike, - ) -> OMPathBase: + ) -> 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". @@ -2510,7 +2510,7 @@ def get_doe_solutions( def doe_get_solutions( msomc: ModelicaSystemOMC, - resultpath: OMPathBase, + 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 3aca99e93..242febf0f 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -250,12 +250,12 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F # 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 OMPathBase, a simple base class to be used in ModelicaSystem* classes. +# 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 OMPathBase in Python < 3.12. This allows to run all code which uses OMPathBase (mainly + 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. """ @@ -287,7 +287,7 @@ class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): Compatibility class for OMCPath on Windows systems (Python < 3.12) """ - OMPathBase = OMPathCompatibility + OMPathABC = OMPathCompatibility OMCPath = OMPathCompatibility else: class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): @@ -356,7 +356,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False): @abc.abstractmethod def cwd(self): """ - Returns the current working directory as an OMPathBase object. + Returns the current working directory as an OMPathABC object. """ @abc.abstractmethod @@ -391,7 +391,7 @@ def size(self) -> int: class _OMCPath(OMPathABC): """ - Implementation of a OMPathBase using OMC as backend. The connection to OMC is provided via an instances of an + Implementation of a OMPathABC using OMC as backend. The connection to OMC is provided via an instances of an OMCSession* classes. """ @@ -448,7 +448,7 @@ def mkdir(self, parents: bool = True, exist_ok: bool = False): def cwd(self): """ - Returns the current working directory as an OMPathBase object. + Returns the current working directory as an OMPathABC object. """ cwd_str = self._session.sendExpression(expr='cd()') return OMCPath(cwd_str, session=self._session) @@ -514,7 +514,6 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") - OMPathBase = OMPathABC OMCPath = _OMCPath diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 7eed366f4..ae47e7478 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -23,7 +23,7 @@ doe_get_solutions, ) from OMPython.OMCSession import ( - OMPathBase, + OMPathABC, OMCPath, OMCSession, @@ -55,7 +55,7 @@ 'ModelicaDoEOMC', 'ModelicaSystemError', - 'OMPathBase', + 'OMPathABC', 'OMCPath', 'OMCSession', From eb7cd3cdd3a9ac61db6e904cce7b4abc15054e93 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 26 Jan 2026 21:45:46 +0100 Subject: [PATCH 47/62] [OMCSession] update OMCSession* to use OMSessionABC as baseline and further cleanup --- OMPython/OMCSession.py | 119 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 242febf0f..14199af05 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[OMCPath] = 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). """ From 4ddeee1919bb31ed3b9e35106b3f6aa10e8a6911 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 26 Jan 2026 21:59:22 +0100 Subject: [PATCH 48/62] [ModelicaSystem] shortcut to use OMCSession = OMSessionABC for now --- OMPython/ModelicaSystem.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 2e98724cd..baec6640d 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -26,12 +26,15 @@ ModelExecutionException, OMCSessionException, - OMCSession, OMCSessionLocal, OMPathABC, + + OMSessionABC, ) +OMCSession = OMSessionABC + # define logger using the current module name as ID logger = logging.getLogger(__name__) From 52a94ab7eb77cc5f41cad3d942503c0de63390c6 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 26 Jan 2026 22:00:31 +0100 Subject: [PATCH 49/62] [ModelicaSystem] fix usage of OMCSession; replace by OMSessionABC --- OMPython/ModelicaSystem.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index baec6640d..651400220 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -33,8 +33,6 @@ OMSessionABC, ) -OMCSession = OMSessionABC - # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -350,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(). @@ -400,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. """ @@ -1501,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(). @@ -2224,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. """ From 843c5ae9b6c04d5a4d966024afeff10e94f91588 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 15:33:10 +0100 Subject: [PATCH 50/62] fix usage of OMCSession --- OMPython/__init__.py | 4 ++-- tests/test_OMCPath.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OMPython/__init__.py b/OMPython/__init__.py index ae47e7478..b04db846d 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 2ea8b8c8d..9a69b7385 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -48,7 +48,7 @@ def test_OMCPath_OMCProcessWSL(): del omcs -def _run_OMCPath_checks(omcs: OMPython.OMCSession): +def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): p1 = omcs.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() From c0990ab8731762ee150029fb69b614d819d1936d Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:09:02 +0100 Subject: [PATCH 51/62] [OMCSession] add *Runner related classes for OMPath and OMSession --- OMPython/OMCSession.py | 153 +++++++++++++++++++++++++++++++++++++++++ OMPython/__init__.py | 4 ++ 2 files changed, 157 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 14199af05..6daf716d1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1735,3 +1735,156 @@ def _omc_port_get(self) -> str: f"pid={self._omc_process.pid if isinstance(self._omc_process, subprocess.Popen) else '?'}") return port + + +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 + + +if sys.version_info < (3, 12): + OMPathRunnerLocal = OMPathCompatibility +else: + OMPathRunnerLocal = _OMPathRunnerLocal # OMPathRunnerLocal + + +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, command: 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 b04db846d..dddf513f1 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -26,6 +26,8 @@ OMPathABC, OMCPath, + OMSessionRunner, + OMCSessionABC, ModelExecutionData, @@ -58,6 +60,8 @@ 'OMPathABC', 'OMCPath', + 'OMSessionRunner', + 'OMCSessionABC', 'doe_get_solutions', From 59a1707251631c3591cea2a09f315e7363458fb0 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:19:22 +0100 Subject: [PATCH 52/62] [ModelicaSystem] add ModelicaSystemRunner --- OMPython/ModelicaSystem.py | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 651400220..3e1f20f70 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 @@ -2579,3 +2580,57 @@ 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) From e301ddf4d22220581dd0754f710b6ee03190c79d Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:19:37 +0100 Subject: [PATCH 53/62] [test_ModelicaSystemRunner] add test case for ModelicaSystemRunner --- tests/test_ModelicaSystemRunner.py | 96 ++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_ModelicaSystemRunner.py diff --git a/tests/test_ModelicaSystemRunner.py b/tests/test_ModelicaSystemRunner.py new file mode 100644 index 000000000..35541c992 --- /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 edc14085e26637c5cf816dc54633bd1144357624 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:21:47 +0100 Subject: [PATCH 54/62] [ModelicaSystem] add ModelicaDoERunner --- OMPython/ModelicaSystem.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 3e1f20f70..54cbd1214 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2634,3 +2634,41 @@ def setup( # 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: ModelicaSystemOMC, + # simulation specific input + # TODO: add more settings (simulation options, input options, ...) + simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None, + # DoE specific inputs + resultpath: Optional[str | os.PathLike] = None, + parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, + ) -> None: + if not isinstance(mod, ModelicaSystemRunner): + raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") + + super().__init__( + mod=mod, + simargs=simargs, + resultpath=resultpath, + parameters=parameters, + ) + + def _prepare_structure_parameters( + self, + idx_pc_structure: int, + pc_structure: Tuple, + param_structure: dict[str, list[str] | list[int] | list[float]], + ) -> dict[str, str | int | float]: + raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " + "pre-compiled binary of model.") From 4f352304af8836053587243f08134dee125018d4 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 27 Jan 2026 21:33:28 +0100 Subject: [PATCH 55/62] [test_ModelicaDoERunner] add test case for ModelicaDoERunner --- tests/test_ModelicaDoERunner.py | 123 ++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/test_ModelicaDoERunner.py diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py new file mode 100644 index 000000000..cd9393019 --- /dev/null +++ b/tests/test_ModelicaDoERunner.py @@ -0,0 +1,123 @@ +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_ModelicaDoEOMC_local(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + 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 _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 == 16 + + doe_def = doe_mod.get_doe_definition() + assert isinstance(doe_def, dict) + assert len(doe_def.keys()) == doe_count + + doe_cmd = doe_mod.get_doe_command() + assert isinstance(doe_cmd, dict) + assert len(doe_cmd.keys()) == doe_count + + doe_status = doe_mod.simulate() + assert doe_status is True + + +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]) From d368e64f5f1dd5b65007ca22ccd73a701b6b03c6 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 16:10:08 +0100 Subject: [PATCH 56/62] [OMCSession] move OMCPathRunner* into the if clause --- OMPython/OMCSession.py | 185 ++++++++++++++++++++--------------------- 1 file changed, 90 insertions(+), 95 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 6daf716d1..2531d4711 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): @@ -1737,101 +1827,6 @@ def _omc_port_get(self) -> str: return port -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 - - -if sys.version_info < (3, 12): - OMPathRunnerLocal = OMPathCompatibility -else: - OMPathRunnerLocal = _OMPathRunnerLocal # OMPathRunnerLocal - - class OMSessionRunner(OMSessionABC): """ Implementation based on OMSessionABC without any use of an OMC server. From d8e70d81ec8c58ffbb7d0d5412d76eaf77a94611 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 16:10:49 +0100 Subject: [PATCH 57/62] [OMSessionRunner] fix usage of sendExpression() --- OMPython/OMCSession.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 2531d4711..f8dd55ddf 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1881,5 +1881,5 @@ def omcpath_tempdir(self, tempdir_base: Optional[OMPathABC] = None) -> OMPathABC return self._tempdir(tempdir_base=tempdir_base) - def sendExpression(self, command: str, parsed: bool = True) -> Any: + def sendExpression(self, expr: str, parsed: bool = True) -> Any: raise OMCSessionException(f"{self.__class__.__name__} does not uses an OMC server!") From 1aa09906f5b9df28e073f5c1fade5748216b05ce Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 16:52:13 +0100 Subject: [PATCH 58/62] [__init__] add missing definitions for *Runner classes --- OMPython/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/OMPython/__init__.py b/OMPython/__init__.py index dddf513f1..d6016e53c 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -19,6 +19,8 @@ ModelicaSystemDoE, ModelicaDoEOMC, ModelicaSystemError, + ModelicaSystemRunner, + ModelicaDoERunner, doe_get_solutions, ) @@ -57,6 +59,9 @@ 'ModelicaDoEOMC', 'ModelicaSystemError', + 'ModelicaSystemRunner', + 'ModelicaDoERunner', + 'OMPathABC', 'OMCPath', From 43530b9c882524d61b3bc94ab46184f7c9789224 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 17:30:03 +0100 Subject: [PATCH 59/62] [ModelicaDoERunner] fix definition; allow all variations of ModelicaSystem* --- OMPython/ModelicaSystem.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 54cbd1214..e7d86d650 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2646,7 +2646,7 @@ class ModelicaDoERunner(ModelicaDoEABC): 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, @@ -2654,8 +2654,8 @@ def __init__( resultpath: Optional[str | os.PathLike] = None, parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None, ) -> None: - if not isinstance(mod, ModelicaSystemRunner): - raise ModelicaSystemError(f"Invalid definition for mod: {type(mod)} - expect ModelicaSystemOMC!") + if not isinstance(mod, ModelicaSystemABC): + raise ModelicaSystemError(f"Invalid definition for ModelicaSystem*: {type(mod)}!") super().__init__( mod=mod, @@ -2670,5 +2670,8 @@ def _prepare_structure_parameters( pc_structure: Tuple, param_structure: dict[str, list[str] | list[int] | list[float]], ) -> dict[str, str | int | float]: - raise ModelicaSystemError(f"{self.__class__.__name__} can not handle structure parameters as it uses a " - "pre-compiled binary of model.") + 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 {} From 74f304505636bc2c60189568841fa3f8d7e5d87f Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 18:54:15 +0100 Subject: [PATCH 60/62] [test_ModelicaDoERunner] fix definition; test ModelicaSystem(OCM|Runner) --- tests/test_ModelicaDoERunner.py | 39 +++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_ModelicaDoERunner.py b/tests/test_ModelicaDoERunner.py index cd9393019..2d41315f8 100644 --- a/tests/test_ModelicaDoERunner.py +++ b/tests/test_ModelicaDoERunner.py @@ -43,7 +43,7 @@ def param_doe() -> dict[str, list]: return param -def test_ModelicaDoEOMC_local(tmp_path, model_doe, param_doe): +def test_ModelicaDoERunner_ModelicaSystemOMC(tmp_path, model_doe, param_doe): tmpdir = tmp_path / 'DoE' tmpdir.mkdir(exist_ok=True) @@ -67,6 +67,41 @@ def test_ModelicaDoEOMC_local(tmp_path, model_doe, param_doe): _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) @@ -77,7 +112,7 @@ def _run_simulation(mod, resultfile, param): def _run_ModelicaDoERunner(doe_mod): doe_count = doe_mod.prepare() - assert doe_count == 16 + assert doe_count == 4 doe_def = doe_mod.get_doe_definition() assert isinstance(doe_def, dict) From f4804c0bdedb18c5a6f5527b89c7da4ca8ac9cee Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 19:04:44 +0100 Subject: [PATCH 61/62] [ModelicaDoEABC] add get_resultpath() --- OMPython/ModelicaSystem.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e7d86d650..f871fc7bb 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -2229,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 From 6ef0900745e4448d52f4c706b3a8204603a71ca9 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 8 Feb 2026 18:04:08 +0100 Subject: [PATCH 62/62] abc --- .github/workflows/Test.yml | 9 +- .github/workflows/Test_v400.yml | 66 +++ .github/workflows/Test_v400_py310.yml | 66 +++ .github/workflows/Test_v4xx.yml | 66 +++ .pre-commit-config.yaml | 7 +- OMPython/ModelicaSystem.py | 220 ++++++++- OMPython/OMCSession.py | 633 +++++++++++++++++++------- OMPython/OMParser.py | 2 - OMPython/OMTypedParser.py | 37 +- OMPython/__init__.py | 53 ++- pyproject.toml | 7 +- tests/test_FMIExport.py | 2 +- tests/test_FMIRegression.py | 17 +- tests/test_ModelicaDoEOMC.py | 6 +- tests/test_ModelicaSystemCmd.py | 2 +- tests/test_ModelicaSystemDoE.py | 164 +++++++ tests/test_ModelicaSystemOMC.py | 3 +- tests/test_ModelicaSystemRunner.py | 133 +++++- tests/test_OMCPath.py | 83 +++- tests/test_ZMQ.py | 1 + tests/test_docker.py | 2 + tests_v400/__init__.py | 0 tests_v400/test_ArrayDimension.py | 19 + tests_v400/test_FMIExport.py | 24 + tests_v400/test_FMIRegression.py | 68 +++ 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 +++ 34 files changed, 2283 insertions(+), 253 deletions(-) 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/test_ModelicaSystemDoE.py 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_FMIRegression.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.yml b/.github/workflows/Test.yml index 3d87cac21..e399fa00d 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -1,11 +1,6 @@ name: Test-Publish on: - push: - branches: ['master'] - tags: - - 'v*' # only publish when pushing version tags (e.g., v1.0.0) - pull_request: workflow_dispatch: jobs: @@ -17,13 +12,13 @@ jobs: # test for: # * oldest supported version # * latest available Python version - python-version: ['3.10', '3.14'] + python-version: ['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'] + omc-version: ['stable'] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/Test_v400.yml b/.github/workflows/Test_v400.yml new file mode 100644 index 000000000..3fc1cce44 --- /dev/null +++ b/.github/workflows/Test_v400.yml @@ -0,0 +1,66 @@ +name: Test-v4.0.0 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10', '3.14'] + os: ['ubuntu-latest', 'windows-latest'] + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400 ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v400_py310.yml b/.github/workflows/Test_v400_py310.yml new file mode 100644 index 000000000..d980ec051 --- /dev/null +++ b/.github/workflows/Test_v400_py310.yml @@ -0,0 +1,66 @@ +name: Test-v4.0.0-py310 + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10'] + os: ['ubuntu-latest'] + omc-version: ['stable'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests_v400 ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.github/workflows/Test_v4xx.yml b/.github/workflows/Test_v4xx.yml new file mode 100644 index 000000000..020d5c871 --- /dev/null +++ b/.github/workflows/Test_v4xx.yml @@ -0,0 +1,66 @@ +name: Test-v4.x.x + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + python-version: ['3.10', '3.14'] + os: ['ubuntu-latest', 'windows-latest'] + omc-version: ['stable', 'nightly'] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools wheel twine + pip install . pytest pytest-md pytest-emoji pre-commit + + - name: Set timezone + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: 'Europe/Berlin' + + - name: Run pre-commit linters + run: 'pre-commit run --all-files' + + - name: "Set up OpenModelica Compiler" + uses: OpenModelica/setup-openmodelica@v1.0.5 + with: + version: ${{ matrix.omc-version }} + packages: | + omc + libraries: | + 'Modelica 4.0.0' + - run: "omc --version" + + - name: Pull OpenModelica docker image + if: runner.os != 'Windows' + run: docker pull openmodelica/openmodelica:v1.25.0-minimal + + - name: Build wheel and sdist packages + run: python -m build --wheel --sdist --outdir dist + + - name: Check twine + run: python -m twine check dist/* + + - name: Run pytest + uses: pavelzw/pytest-action@v2 + with: + verbose: true + emoji: true + job-summary: true + custom-arguments: '-v ./tests ' + click-to-expand: true + report-title: 'Test Report' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 484570b61..349813e14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,12 +28,11 @@ repos: hooks: - id: codespell - - repo: https://github.com/pre-commit/mirrors-mypy.git - rev: "v1.15.0" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 hooks: - id: mypy - args: [] - exclude: tests/ + exclude: 'test|test_v400' additional_dependencies: - pyparsing - types-psutil diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index f871fc7bb..499595c06 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -22,18 +22,21 @@ import numpy as np from OMPython.OMCSession import ( - ModelExecutionData, ModelExecutionException, OMCSessionException, - OMCSessionLocal, OMPathABC, + ModelExecutionData, OMSessionABC, OMSessionRunner, + OMCSessionLocal, ) +# check OMPathABC usage +# check OMSessionABC usage + # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -383,22 +386,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 +470,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 +1935,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 +1984,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 +2065,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 +2109,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): @@ -2110,6 +2119,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): """ @@ -2588,6 +2729,7 @@ class ModelicaSystemDoE(ModelicaDoEOMC): """ +# TODO: rename ModelicaRunner class ModelicaSystemRunner(ModelicaSystemABC): """ Class to simulate a Modelica model using a pre-compiled model binary. @@ -2681,3 +2823,55 @@ 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() + + +# TODO: class ModelicaRunnerLocal / ModelicaRunnerBashLocal / MOdelicaRunnerBashWSL / MOdelicaRunnerBashDocker +# TODO: error for < 3.12? (@docker / WSL / ...) +# TODO: move classes diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index f8dd55ddf..26f12d517 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 @@ -59,6 +59,7 @@ def wait(self, timeout): pass +# TODO: rename OMSessionException class OMCSessionException(Exception): """ Exception which is raised by any OMC* class. @@ -291,6 +292,8 @@ class OMPathCompatibilityWindows(pathlib.WindowsPath, OMPathCompatibility): OMCPath = OMPathCompatibility OMPathRunnerABC = OMPathCompatibility OMPathRunnerLocal = OMPathCompatibility + OMPathRunnerBash = OMPathCompatibility + else: class OMPathABC(pathlib.PurePosixPath, metaclass=abc.ABCMeta): """ @@ -307,7 +310,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 +337,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 +349,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 +365,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 +377,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 +410,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 +453,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 +464,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 +482,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 +513,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: @@ -526,14 +547,17 @@ 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()). """ + def _path(self) -> pathlib.Path: + return pathlib.Path(self.as_posix()) + def is_file(self) -> bool: """ Check if the path is a regular file. @@ -546,7 +570,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. """ @@ -562,9 +586,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. @@ -572,21 +599,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. """ @@ -603,8 +630,181 @@ def size(self) -> int: path = self._path() return path.stat().st_size + # TODO get_cmd_prefix() + # TODO: used by WSL / docker + # TODO: get cmd_prefix via OMSession.model_execution_prefix() + + class _OMPathRunnerBash(OMPathRunnerABC): + """ + Implementation of OMPathABC which does not use the session data at all. Thus, this implementation can run + locally without any usage of OMC. The special case of this class is the usage of POSIX bash to run all the + commands. Thus, it can be used in WSL or docker. + + This class is based on OMPathABC and, therefore, on pathlib.PurePosixPath. This is working well, but it is not + the correct implementation on Windows systems. To get a valid Windows representation of the path, use the + conversion via pathlib.Path(.as_posix()). + """ + + def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -f "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'test -d "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return True + except subprocess.CalledProcessError: + return False + + def is_absolute(self) -> bool: + """ + Check if the path is an absolute path. + """ + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'case "{self.as_posix()}" in /*) exit 0;; *) exit 1;; esac'] + + try: + subprocess.check_call(cmdl) + return True + except subprocess.CalledProcessError: + return False + + def read_text(self) -> str: + """ + Read the content of the file represented by this path as text. + """ + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'cat "{self.as_posix()}"'] + + result = subprocess.run(cmdl, capture_output=True, check=True) + if result.returncode == 0: + return result.stdout.decode('utf-8') + raise FileNotFoundError(f"Cannot read file: {self.as_posix()}") + + def write_text(self, data: str) -> int: + """ + Write text data to the file represented by this path. + """ + if not isinstance(data, str): + raise TypeError(f"data must be str, not {data.__class__.__name__}") + + data_escape = self._session.escape_str(data) + + cmdl = self.get_session().get_cmd_prefix() + cmdl += ['bash', '-c', f'printf %s "{data_escape}" > "{self.as_posix()}"'] + + try: + subprocess.run(cmdl, check=True) + return len(data) + except subprocess.CalledProcessError 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): @@ -691,67 +891,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 @@ -779,6 +918,8 @@ def __call__(cls, *args, **kwargs): return obj +# TODO: define BaseSession / RunnerSession(BaseSession) +# TODO: define OMCSession(BaseSession) class OMSessionMeta(abc.ABCMeta, PostInitCaller): """ Helper class to get a combined metaclass of ABCMeta and PostInitCaller. @@ -788,6 +929,8 @@ class OMSessionMeta(abc.ABCMeta, PostInitCaller): """ +# TODO: check in __post_init__() if the OMPathABC instance is valid (OMC exist / bash exists / pathlib.Path is correct) +# define a OMPathABC.verify() function for this class OMSessionABC(metaclass=OMSessionMeta): """ This class implements the basic structure a OMPython session definition needs. It provides the structure for an @@ -809,12 +952,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 +994,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 +1058,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 @@ -963,6 +1113,9 @@ def __post_init__(self) -> None: self._omc_zmq = omc + # TODO: define as abc.abstractmethod; fix in *Port + # def _omc_port_get(self) -> str: + def __del__(self): if isinstance(self._omc_zmq, zmq.Socket): try: @@ -990,6 +1143,7 @@ def __del__(self): self._omc_process.kill() self._omc_process.wait() finally: + self._omc_process = None def _timeout_loop( @@ -1293,10 +1447,14 @@ 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 + # TODO: add _omc_port_get() + # TODO: add _omc_port_test in OMCSessionABC => test / get version using sendExpression() + class OMCSessionLocal(OMCSessionABC): """ @@ -1379,6 +1537,78 @@ def _omc_port_get(self) -> str: return port +# TODO: OMCSessionCMd => separate file; based on OMCSessionZMQ? +class OMCSessionZMQ(OMSessionABC): + """ + This class is a compatibility layer for the new schema using OMCSession* classes. + """ + + def __init__( + self, + timeout: float = 10.00, + omhome: Optional[str] = None, + omc_process: Optional[OMCSessionABC] = None, + ) -> None: + """ + 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. @@ -1386,10 +1616,18 @@ class OMCSessionDockerABC(OMCSessionABC, metaclass=abc.ABCMeta): def __init__( self, - timeout: float = 10.00, + timeout: float = 10.0, + # TODO: rename docker_image + docker: Optional[str] = None, + # TODO: rename docker_* + dockerContainer: Optional[str] = None, + # TODO: rename docker_* dockerExtraArgs: Optional[list] = None, + # TODO: rename docker_* dockerOpenModelicaPath: str | os.PathLike = "omc", + # TODO: rename docker_* dockerNetwork: Optional[str] = None, + # TODO: rename omc_port port: Optional[int] = None, ) -> None: super().__init__(timeout=timeout) @@ -1400,11 +1638,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': @@ -1430,6 +1678,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: """ @@ -1441,11 +1698,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) @@ -1454,7 +1714,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() @@ -1477,7 +1737,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 @@ -1524,27 +1787,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: @@ -1556,29 +1808,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"] @@ -1589,8 +1849,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", @@ -1600,22 +1860,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, @@ -1626,6 +1897,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): @@ -1636,10 +1908,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: @@ -1666,22 +1940,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__() @@ -1689,7 +1954,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. """ @@ -1697,33 +1967,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, @@ -1732,14 +2013,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): @@ -1767,6 +2048,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. @@ -1790,7 +2073,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, @@ -1834,13 +2118,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! @@ -1850,7 +2147,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: """ @@ -1861,15 +2158,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: """ @@ -1883,3 +2180,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/OMParser.py b/OMPython/OMParser.py index 73f38d7b7..a82a9ca06 100644 --- a/OMPython/OMParser.py +++ b/OMPython/OMParser.py @@ -36,8 +36,6 @@ result: dict[str, Any] = {} -inner_sets = [] -next_set_list = [] next_set = [] next_set.append('') diff --git a/OMPython/OMTypedParser.py b/OMPython/OMTypedParser.py index de6148145..9fe810e06 100644 --- a/OMPython/OMTypedParser.py +++ b/OMPython/OMTypedParser.py @@ -34,23 +34,23 @@ from typing import Any from pyparsing import ( + alphanums, + alphas, Combine, + DelimitedList, Dict, + infix_notation, Forward, Group, Keyword, + nums, + opAssoc, Optional, QuotedString, + replace_with, StringEnd, Suppress, Word, - alphanums, - alphas, - delimitedList, - nums, - replaceWith, - infixNotation, - opAssoc, ) @@ -97,7 +97,7 @@ def evaluate_expression(s, loc, toks): # Number parsing (supports arithmetic expressions in dimensions) (e.g., {1 + 1, 1}) -arrayDimension = infixNotation( +arrayDimension = infix_notation( Word(alphas + "_", alphanums + "_") | Word(nums), [ (Word("+-", exact=1), 1, opAssoc.RIGHT), @@ -109,28 +109,28 @@ def evaluate_expression(s, loc, toks): omcRecord = Forward() omcValue = Forward() -# pyparsing's replace_with (and thus replaceWith) has incorrect type +# pyparsing's replace_with (and thus replace_with) has incorrect type # annotation: https://github.com/pyparsing/pyparsing/issues/602 -TRUE = Keyword("true").set_parse_action(replaceWith(True)) # type: ignore -FALSE = Keyword("false").set_parse_action(replaceWith(False)) # type: ignore -NONE = (Keyword("NONE") + Suppress("(") + Suppress(")")).set_parse_action(replaceWith(None)) # type: ignore +TRUE = Keyword("true").set_parse_action(replace_with(True)) # type: ignore +FALSE = Keyword("false").set_parse_action(replace_with(False)) # type: ignore +NONE = (Keyword("NONE") + Suppress("(") + Suppress(")")).set_parse_action(replace_with(None)) # type: ignore SOME = (Suppress(Keyword("SOME")) + Suppress("(") + omcValue + Suppress(")")) -omcString = QuotedString(quoteChar='"', escChar='\\', multiline=True).set_parse_action(convert_string) +omcString = QuotedString(quote_char='"', esc_char='\\', multiline=True).set_parse_action(convert_string) omcNumber = Combine(Optional('-') + ('0' | Word('123456789', nums)) + Optional('.' + Word(nums)) + Optional(Word('eE', exact=1) + Word(nums + '+-', nums))) # ident = Word(alphas + "_", alphanums + "_") | Combine("'" + Word(alphanums + "!#$%&()*+,-./:;<>=?@[]^{}|~ ") + "'") ident = (Word(alphas + "_", alphanums + "_") - | QuotedString(quoteChar='\'', escChar='\\').set_parse_action(convert_string2)) + | QuotedString(quote_char='\'', esc_char='\\').set_parse_action(convert_string2)) fqident = Forward() fqident << ((ident + "." + fqident) | ident) -omcValues = delimitedList(omcValue) +omcValues = DelimitedList(omcValue) omcTuple = Group(Suppress('(') + Optional(omcValues) + Suppress(')')).set_parse_action(convert_tuple) omcArray = Group(Suppress('{') + Optional(omcValues) + Suppress('}')).set_parse_action(convert_tuple) omcArraySpecialTypes = Group(Suppress('{') - + delimitedList(arrayDimension) + + DelimitedList(arrayDimension) + Suppress('}')).set_parse_action(convert_tuple) omcValue << (omcString | omcNumber @@ -143,7 +143,7 @@ def evaluate_expression(s, loc, toks): | FALSE | NONE | Combine(fqident)) -recordMember = delimitedList(Group(ident + Suppress('=') + omcValue)) +recordMember = DelimitedList(Group(ident + Suppress('=') + omcValue)) omcRecord << Group(Suppress('record') + Suppress(fqident) + Dict(recordMember) @@ -161,3 +161,6 @@ def om_parser_typed(string) -> Any: if len(res) == 0: return None return res[0] + + +parseString = om_parser_typed diff --git a/OMPython/__init__.py b/OMPython/__init__.py index d6016e53c..f88fc4b99 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -13,6 +13,7 @@ from OMPython.ModelicaSystem import ( LinearizationResult, + ModelicaSystemABC, ModelicaSystem, ModelicaSystemOMC, ModelExecutionCmd, @@ -23,41 +24,55 @@ ModelicaDoERunner, doe_get_solutions, + + ModelicaSystemCmd, ) from OMPython.OMCSession import ( OMPathABC, OMCPath, + OMSessionABC, OMSessionRunner, - OMCSessionABC, - ModelExecutionData, ModelExecutionException, OMCSessionCmd, - OMCSessionDocker, - OMCSessionDockerContainer, OMCSessionException, - OMCSessionLocal, + OMCSessionZMQ, OMCSessionPort, + OMCSessionLocal, + + OMPathRunnerBash, + OMPathRunnerLocal, + + OMCSessionABC, + OMCSessionDocker, + OMCSessionDockerContainer, OMCSessionWSL, - OMCSessionZMQ, + + OMCProcessLocal, + OMCProcessPort, + OMCProcessDocker, + OMCProcessDockerContainer, ) # global names imported if import 'from OMPython import *' is used __all__ = [ 'LinearizationResult', + 'ModelicaSystemABC', 'ModelExecutionData', 'ModelExecutionException', - 'ModelicaSystem', + 'ModelicaSystem', # compatibility layer 'ModelicaSystemOMC', + 'ModelicaSystemCmd', 'ModelExecutionCmd', 'ModelicaSystemDoE', 'ModelicaDoEOMC', 'ModelicaSystemError', + 'ModelicaSystemRunner', 'ModelicaSystemRunner', 'ModelicaDoERunner', @@ -65,18 +80,34 @@ 'OMPathABC', 'OMCPath', + 'OMSessionABC', 'OMSessionRunner', 'OMCSessionABC', 'doe_get_solutions', - 'OMCSessionCmd', - 'OMCSessionDocker', - 'OMCSessionDockerContainer', + 'OMSessionABC', + + 'OMCSessionCmd', # obsolete 'OMCSessionException', + 'OMCSessionZMQ', # compatibility layer 'OMCSessionPort', 'OMCSessionLocal', + + 'OMCPath', + 'OMPathRunnerBash', + 'OMPathRunnerLocal', + + 'OMSessionRunner', + + 'OMCSessionABC', + 'OMCSessionDocker', + 'OMCSessionDockerContainer', 'OMCSessionWSL', - 'OMCSessionZMQ', + + 'OMCProcessLocal', + 'OMCProcessPort', + 'OMCProcessDocker', + 'OMCProcessDockerContainer', ] diff --git a/pyproject.toml b/pyproject.toml index e9636ab57..5dbb7f252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,11 @@ disable = [ # TODO: the items below should be checked and corrected 'C0103', # Variable name doesn't conform to snake_case naming style (invalid-name) 'C0116', # Missing function or method docstring (missing-function-docstring) - 'W0511', # TODO / fixme (fixme) + 'W0221', # Variadics removed in overriding '???' method (arguments-differ) + 'W0511', # Warning for TODO, FIXME, or similar comments left in the code (fixme) 'W1203', # Use lazy % formatting in logging functions (logging-fstring-interpolation) + + 'C0114', # Missing module docstring (missing-module-docstring) + 'C0123', # Use isinstance() rather than type() for a typecheck. (unidiomatic-typecheck) + 'W0621', # Redefining name '???' from outer scope (line ???) (redefined-outer-name) ] diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index c7ab038a4..65ac27664 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_FMIRegression.py b/tests/test_FMIRegression.py index 8a91c5143..a0e41d80e 100644 --- a/tests/test_FMIRegression.py +++ b/tests/test_FMIRegression.py @@ -1,11 +1,14 @@ -import tempfile +import os import pathlib import shutil -import os +import tempfile + +import pytest import OMPython +# TODO: this checks OMC not OMPython, or? - needed, takes too long; move to separate workflow? def buildModelFMU(modelName): omcs = OMPython.OMCSessionLocal() @@ -29,41 +32,51 @@ def test_Modelica_Blocks_Examples_Filter(): buildModelFMU("Modelica.Blocks.Examples.Filter") +@pytest.mark.skip("takes too long") def test_Modelica_Blocks_Examples_RealNetwork1(): buildModelFMU("Modelica.Blocks.Examples.RealNetwork1") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Analog_Examples_CauerLowPassAnalog(): buildModelFMU("Modelica.Electrical.Analog.Examples.CauerLowPassAnalog") +@pytest.mark.skip("takes too long") def test_Modelica_Electrical_Digital_Examples_FlipFlop(): buildModelFMU("Modelica.Electrical.Digital.Examples.FlipFlop") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_FirstGrounded(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.FirstGrounded") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_Rotational_Examples_CoupledClutches(): buildModelFMU("Modelica.Mechanics.Rotational.Examples.CoupledClutches") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_DoublePendulum(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.DoublePendulum") +@pytest.mark.skip("takes too long") def test_Modelica_Mechanics_MultiBody_Examples_Elementary_FreeBody(): buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.FreeBody") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_PumpingSystem(): buildModelFMU("Modelica.Fluid.Examples.PumpingSystem") +@pytest.mark.skip("takes too long") def test_Modelica_Fluid_Examples_TraceSubstances_RoomCO2WithControls(): buildModelFMU("Modelica.Fluid.Examples.TraceSubstances.RoomCO2WithControls") +@pytest.mark.skip("takes too long") def test_Modelica_Clocked_Examples_SimpleControlledDrive_ClockedWithDiscreteTextbookController(): buildModelFMU("Modelica.Clocked.Examples.SimpleControlledDrive.ClockedWithDiscreteTextbookController") diff --git a/tests/test_ModelicaDoEOMC.py b/tests/test_ModelicaDoEOMC.py index c0b9fda39..e9add8581 100644 --- a/tests/test_ModelicaDoEOMC.py +++ b/tests/test_ModelicaDoEOMC.py @@ -157,6 +157,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_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 3d35376b4..db5aadeb4 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.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_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py new file mode 100644 index 000000000..65df95f81 --- /dev/null +++ b/tests/test_ModelicaSystemDoE.py @@ -0,0 +1,164 @@ +import pathlib +import sys + +import numpy as np +import pytest + +import OMPython + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath(non-local) only working for Python >= 3.12.", +) + + +@pytest.fixture +def model_doe(tmp_path: pathlib.Path) -> pathlib.Path: + # see: https://trac.openmodelica.org/OpenModelica/ticket/4052 + mod = tmp_path / "M.mo" + # TODO: update for bool and string parameters; check if these can be used in DoE + mod.write_text(""" +model M + parameter Integer p=1; + parameter Integer q=1; + parameter Real a = -1; + parameter Real b = -1; + Real x[p]; + Real y[q]; +equation + der(x) = a * fill(1.0, p); + der(y) = b * fill(1.0, q); +end M; +""") + return mod + + +@pytest.fixture +def param_doe() -> dict[str, list]: + param = { + # structural + 'p': [1, 2], + 'q': [3, 4], + # simple + 'a': [5, 6], + 'b': [7, 8], + } + return param + + +def test_ModelicaSystemDoE_local(tmp_path, model_doe, param_doe): + tmpdir = tmp_path / 'DoE' + tmpdir.mkdir(exist_ok=True) + + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_doe, + model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, + parameters=param_doe, + resultpath=tmpdir, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaSystemDoe(doe_mod=doe_mod) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaSystemDoE_docker(tmp_path, model_doe, param_doe): + omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_doe, + model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, + parameters=param_doe, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaSystemDoe(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): + omcs = OMPython.OMCSessionWSL() + omc = OMPython.OMCSessionZMQ(omc_process=omcs) + assert omc.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + mod = OMPython.ModelicaSystemOMC( + session=omcs, + ) + mod.model( + model_file=model_doe, + model_name="M", + ) + + doe_mod = OMPython.ModelicaSystemDoE( + mod=mod, + parameters=param_doe, + simargs={"override": {'stopTime': '1.0'}}, + ) + + _run_ModelicaSystemDoe(doe_mod=doe_mod) + + +def _run_ModelicaSystemDoe(doe_mod): + doe_count = doe_mod.prepare() + assert doe_count == 16 + + doe_def = doe_mod.get_doe_definition() + assert isinstance(doe_def, dict) + assert len(doe_def.keys()) == doe_count + + doe_cmd = doe_mod.get_doe_command() + assert isinstance(doe_cmd, dict) + assert len(doe_cmd.keys()) == doe_count + + doe_status = doe_mod.simulate() + assert doe_status is True + + doe_sol = doe_mod.get_doe_solutions() + assert isinstance(doe_sol, dict) + assert len(doe_sol.keys()) == doe_count + + assert sorted(doe_def.keys()) == sorted(doe_cmd.keys()) + assert sorted(doe_cmd.keys()) == sorted(doe_sol.keys()) + + for resultfilename in doe_def: + row = doe_def[resultfilename] + + assert resultfilename in doe_sol + sol = doe_sol[resultfilename] + + var_dict = { + # simple / non-structural parameters + 'a': float(row['a']), + 'b': float(row['b']), + # structural parameters + 'p': float(row['p']), + 'q': float(row['q']), + # variables using the structural parameters + f"x[{row['p']}]": float(row['a']), + f"y[{row['p']}]": float(row['b']), + } + + for key, val in var_dict.items(): + assert key in sol['data'] + assert np.isclose(sol['data'][key][-1], val) diff --git a/tests/test_ModelicaSystemOMC.py b/tests/test_ModelicaSystemOMC.py index 8dd17ef02..c002d212d 100644 --- a/tests/test_ModelicaSystemOMC.py +++ b/tests/test_ModelicaSystemOMC.py @@ -38,6 +38,7 @@ def model_firstorder(tmp_path, model_firstorder_content): return mod +@pytest.mark.skip("takes too long") def test_ModelicaSystem_loop(model_firstorder): def worker(): mod = OMPython.ModelicaSystemOMC() @@ -495,7 +496,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 35541c992..199a136a0 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,9 +50,122 @@ def param(): } -def test_runner(model_firstorder, param): +def test_ModelicaSystemRunner(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +def test_ModelicaSystemRunner_local(model_firstorder, param): + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerLocal, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +def test_ModelicaSystemRunner_bash(model_firstorder, param): # create a model using ModelicaSystem - mod = OMPython.ModelicaSystem() + mod = OMPython.ModelicaSystemOMC() + mod.model( + model_file=model_firstorder, + model_name="M", + ) + + resultfile_mod = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_mod.mat" + _run_simulation(mod=mod, resultfile=resultfile_mod, param=param) + + # run the model using only the runner class + omcs = OMPython.OMSessionRunner( + version=mod.get_session().get_version(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + modr = OMPython.ModelicaSystemRunner( + session=omcs, + work_directory=mod.getWorkDirectory(), + ) + modr.setup( + model_name="M", + ) + + resultfile_modr = mod.getWorkDirectory() / f"{mod.get_model_name()}_res_modr.mat" + _run_simulation(mod=modr, resultfile=resultfile_modr, param=param) + + # cannot check the content as runner does not have the capability to open a result file + assert resultfile_mod.size() == resultfile_modr.size() + + # check results + _check_result(mod=mod, resultfile=resultfile_mod, param=param) + _check_result(mod=mod, resultfile=resultfile_modr, param=param) + + +@skip_on_windows +@skip_python_older_312 +def test_ModelicaSystemRunner_bash_docker(model_firstorder, param): + omcp = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + # create a model using ModelicaSystem + mod = OMPython.ModelicaSystemOMC( + session=omcp, + ) mod.model( model_file=model_firstorder, model_name="M", @@ -51,6 +177,9 @@ def test_runner(model_firstorder, param): # run the model using only the runner class omcs = OMPython.OMSessionRunner( version=mod.get_session().get_version(), + cmd_prefix=omcp.model_execution_prefix(cwd=mod.getWorkDirectory()), + ompath_runner=OMPython.OMPathRunnerBash, + model_execution_local=False, ) modr = OMPython.ModelicaSystemRunner( session=omcs, diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 9a69b7385..ace2c16d9 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -15,41 +15,80 @@ ) -def test_OMCPath_OMCProcessLocal(): - omcs = OMPython.OMCSessionLocal() +def test_OMCPath_OMCSessionZMQ(): + om = OMPython.OMCSessionZMQ() - _run_OMCPath_checks(omcs) + # OMCSessionZMQ is NOT derived from OMSessionABC + _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(): - omcs = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") - assert omcs.sendExpression("getVersion()") == "OpenModelica 1.25.0" - - _run_OMCPath_checks(omcs) +def test_OMCPath_OMCSessionDocker(): + oms = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + assert oms.sendExpression("getVersion()") == "OpenModelica 1.25.0" - del omcs + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) @pytest.mark.skip(reason="Not able to run WSL on github") @skip_python_older_312 -def test_OMCPath_OMCProcessWSL(): - 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_OMPath_OMPathRunnerLocal(): + oms = OMPython.OMSessionRunner() - del omcs + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +@skip_on_windows +@skip_python_older_312 +def test_OMPath_OMSessionRunnerBash(): + oms = OMPython.OMSessionRunner( + ompath_runner=OMPython.OMPathRunnerBash, + ) + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) -def _run_OMCPath_checks(omcs: OMPython.OMCSessionABC): - p1 = omcs.omcpath_tempdir() + +@skip_on_windows +@skip_python_older_312 +def test_OMPath_OMPathRunnerBash_Docker(): + oms_docker = OMPython.OMCSessionDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + assert oms_docker.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + oms = OMPython.OMSessionRunner( + cmd_prefix=oms_docker.get_cmd_prefix(), + ompath_runner=OMPython.OMPathRunnerBash, + ) + + _run_OMPath_checks(oms) + _run_OMPath_write_file(oms) + + +def _run_OMPath_checks(om: OMPython.OMSessionABC): + p1 = om.omcpath_tempdir() p2 = p1 / 'test' p2.mkdir() assert p2.is_dir() @@ -58,8 +97,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() @@ -67,15 +106,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_ZMQ.py b/tests/test_ZMQ.py index 1302a79da..89a8387bc 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 f19735990..fee58ff5c 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_v400/__init__.py b/tests_v400/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests_v400/test_ArrayDimension.py b/tests_v400/test_ArrayDimension.py new file mode 100644 index 000000000..13b3c11b9 --- /dev/null +++ b/tests_v400/test_ArrayDimension.py @@ -0,0 +1,19 @@ +import OMPython + + +def test_ArrayDimension(tmp_path): + omc = OMPython.OMCSessionZMQ() + + omc.sendExpression(f'cd("{tmp_path.as_posix()}")') + + omc.sendExpression('loadString("model A Integer x[5+1,1+6]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[0][-1] == (6, 7), "array dimension does not match" + + omc.sendExpression('loadString("model A Integer y = 5; Integer x[y+1,1+9]; end A;")') + omc.sendExpression("getErrorString()") + + result = omc.sendExpression("getComponents(A)") + assert result[-1][-1] == ('y+1', 10), "array dimension does not match" diff --git a/tests_v400/test_FMIExport.py b/tests_v400/test_FMIExport.py new file mode 100644 index 000000000..f47b87ae2 --- /dev/null +++ b/tests_v400/test_FMIExport.py @@ -0,0 +1,24 @@ +import OMPython +import shutil +import os + + +def test_CauerLowPassAnalog(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="CauerLowPassAnalog") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def test_DrumBoiler(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", lmodel=["Modelica"]) + tmp = mod.getWorkDirectory() + try: + fmu = mod.convertMo2Fmu(fileNamePrefix="DrumBoiler") + assert os.path.exists(fmu) + finally: + shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests_v400/test_FMIRegression.py b/tests_v400/test_FMIRegression.py new file mode 100644 index 000000000..60c23e076 --- /dev/null +++ b/tests_v400/test_FMIRegression.py @@ -0,0 +1,68 @@ +import OMPython +import tempfile +import pathlib +import shutil +import os + + +def buildModelFMU(modelName): + omc = OMPython.OMCSessionZMQ() + + tempdir = pathlib.Path(tempfile.mkdtemp()) + try: + omc.sendExpression(f'cd("{tempdir.as_posix()}")') + + omc.sendExpression("loadModel(Modelica)") + omc.sendExpression("getErrorString()") + + fileNamePrefix = modelName.split(".")[-1] + exp = f'buildModelFMU({modelName}, fileNamePrefix="{fileNamePrefix}")' + fmu = omc.sendExpression(exp) + assert os.path.exists(fmu) + finally: + del omc + shutil.rmtree(tempdir, ignore_errors=True) + + +def test_Modelica_Blocks_Examples_Filter(): + buildModelFMU("Modelica.Blocks.Examples.Filter") + + +def test_Modelica_Blocks_Examples_RealNetwork1(): + buildModelFMU("Modelica.Blocks.Examples.RealNetwork1") + + +def test_Modelica_Electrical_Analog_Examples_CauerLowPassAnalog(): + buildModelFMU("Modelica.Electrical.Analog.Examples.CauerLowPassAnalog") + + +def test_Modelica_Electrical_Digital_Examples_FlipFlop(): + buildModelFMU("Modelica.Electrical.Digital.Examples.FlipFlop") + + +def test_Modelica_Mechanics_Rotational_Examples_FirstGrounded(): + buildModelFMU("Modelica.Mechanics.Rotational.Examples.FirstGrounded") + + +def test_Modelica_Mechanics_Rotational_Examples_CoupledClutches(): + buildModelFMU("Modelica.Mechanics.Rotational.Examples.CoupledClutches") + + +def test_Modelica_Mechanics_MultiBody_Examples_Elementary_DoublePendulum(): + buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.DoublePendulum") + + +def test_Modelica_Mechanics_MultiBody_Examples_Elementary_FreeBody(): + buildModelFMU("Modelica.Mechanics.MultiBody.Examples.Elementary.FreeBody") + + +def test_Modelica_Fluid_Examples_PumpingSystem(): + buildModelFMU("Modelica.Fluid.Examples.PumpingSystem") + + +def test_Modelica_Fluid_Examples_TraceSubstances_RoomCO2WithControls(): + buildModelFMU("Modelica.Fluid.Examples.TraceSubstances.RoomCO2WithControls") + + +def test_Modelica_Clocked_Examples_SimpleControlledDrive_ClockedWithDiscreteTextbookController(): + buildModelFMU("Modelica.Clocked.Examples.SimpleControlledDrive.ClockedWithDiscreteTextbookController") diff --git a/tests_v400/test_ModelicaSystem.py b/tests_v400/test_ModelicaSystem.py new file mode 100644 index 000000000..c55e95fc3 --- /dev/null +++ b/tests_v400/test_ModelicaSystem.py @@ -0,0 +1,411 @@ +import OMPython +import os +import pathlib +import pytest +import tempfile +import numpy as np + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +def test_ModelicaSystem_loop(model_firstorder): + def worker(): + filePath = model_firstorder.as_posix() + m = OMPython.ModelicaSystem(filePath, "M") + m.simulate() + m.convertMo2Fmu(fmuType="me") + for _ in range(10): + worker() + + +def test_setParameters(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") + + # method 1 + mod.setParameters(pvals={"e": 1.234}) + mod.setParameters(pvals={"g": 321.0}) + assert mod.getParameters("e") == ["1.234"] + assert mod.getParameters("g") == ["321.0"] + assert mod.getParameters() == { + "e": "1.234", + "g": "321.0", + } + with pytest.raises(KeyError): + mod.getParameters("thisParameterDoesNotExist") + + # method 2 + mod.setParameters(pvals={"e": 21.3, "g": 0.12}) + assert mod.getParameters() == { + "e": "21.3", + "g": "0.12", + } + assert mod.getParameters(["e", "g"]) == ["21.3", "0.12"] + assert mod.getParameters(["g", "e"]) == ["0.12", "21.3"] + with pytest.raises(KeyError): + mod.getParameters(["g", "thisParameterDoesNotExist"]) + + +def test_setSimulationOptions(): + omc = OMPython.OMCSessionZMQ() + model_path = omc.sendExpression("getInstallationDirectoryPath()") + "/share/doc/omc/testmodels/" + mod = OMPython.ModelicaSystem(fileName=model_path + "BouncingBall.mo", modelName="BouncingBall") + + # method 1 + mod.setSimulationOptions(simOptions={"stopTime": 1.234}) + mod.setSimulationOptions(simOptions={"tolerance": 1.1e-08}) + assert mod.getSimulationOptions("stopTime") == ["1.234"] + assert mod.getSimulationOptions("tolerance") == ["1.1e-08"] + assert mod.getSimulationOptions(["tolerance", "stopTime"]) == ["1.1e-08", "1.234"] + d = mod.getSimulationOptions() + assert isinstance(d, dict) + assert d["stopTime"] == "1.234" + assert d["tolerance"] == "1.1e-08" + with pytest.raises(KeyError): + mod.getSimulationOptions("thisOptionDoesNotExist") + + # method 2 + mod.setSimulationOptions(simOptions={"stopTime": 2.1, "tolerance": "1.2e-08"}) + d = mod.getSimulationOptions() + assert d["stopTime"] == "2.1" + assert d["tolerance"] == "1.2e-08" + + +def test_relative_path(model_firstorder): + cwd = pathlib.Path.cwd() + (fd, name) = tempfile.mkstemp(prefix='tmpOMPython.tests', dir=cwd, text=True) + try: + with os.fdopen(fd, 'w') as f: + f.write(model_firstorder.read_text()) + + model_file = pathlib.Path(name).relative_to(cwd) + model_relative = str(model_file) + assert "/" not in model_relative + + mod = OMPython.ModelicaSystem(fileName=model_relative, modelName="M") + assert float(mod.getParameters("a")[0]) == -1 + finally: + model_file.unlink() # clean up the temporary file + + +def test_customBuildDirectory(tmp_path, model_firstorder): + filePath = model_firstorder.as_posix() + tmpdir = tmp_path / "tmpdir1" + tmpdir.mkdir() + m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) + assert pathlib.Path(m.getWorkDirectory().resolve()) == tmpdir.resolve() + result_file = tmpdir / "a.mat" + assert not result_file.exists() + m.simulate(resultfile="a.mat") + assert result_file.is_file() + + +def test_getSolutions(model_firstorder): + filePath = model_firstorder.as_posix() + mod = OMPython.ModelicaSystem(filePath, "M") + x0 = 1 + a = -1 + tau = -1 / a + stopTime = 5*tau + mod.setSimulationOptions(simOptions={"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8}) + mod.simulate() + + x = mod.getSolutions("x") + t, x2 = mod.getSolutions(["time", "x"]) + assert (x2 == x).all() + sol_names = mod.getSolutions() + assert isinstance(sol_names, tuple) + assert "time" in sol_names + assert "x" in sol_names + assert "der(x)" in sol_names + with pytest.raises(OMPython.ModelicaSystemError): + mod.getSolutions("thisVariableDoesNotExist") + assert np.isclose(t[0], 0), "time does not start at 0" + assert np.isclose(t[-1], stopTime), "time does not end at stopTime" + x_analytical = x0 * np.exp(a*t) + assert np.isclose(x, x_analytical, rtol=1e-4).all() + + +def test_getters(tmp_path): + model_file = tmp_path / "M_getters.mo" + model_file.write_text(""" +model M_getters +Real x(start = 1, fixed = true); +output Real y "the derivative"; +parameter Real a = -0.5; +parameter Real b = 0.1; +equation +der(x) = x*a + b; +y = der(x); +end M_getters; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_getters") + + q = mod.getQuantities() + assert isinstance(q, list) + assert sorted(q, key=lambda d: d["name"]) == sorted([ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'false', + 'description': None, + 'max': None, + 'min': None, + 'name': 'der(x)', + 'start': None, + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'a', + 'start': '-0.5', + 'unit': None, + 'variability': 'parameter', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'parameter', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'b', + 'start': '0.1', + 'unit': None, + 'variability': 'parameter', + } + ], key=lambda d: d["name"]) + + assert mod.getQuantities("y") == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + } + ] + + assert mod.getQuantities(["y", "x"]) == [ + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'output', + 'changeable': 'false', + 'description': 'the derivative', + 'max': None, + 'min': None, + 'name': 'y', + 'start': '-0.4', + 'unit': None, + 'variability': 'continuous', + }, + { + 'alias': 'noAlias', + 'aliasvariable': None, + 'causality': 'local', + 'changeable': 'true', + 'description': None, + 'max': None, + 'min': None, + 'name': 'x', + 'start': '1.0', + 'unit': None, + 'variability': 'continuous', + }, + ] + + with pytest.raises(KeyError): + mod.getQuantities("thisQuantityDoesNotExist") + + assert mod.getInputs() == {} + with pytest.raises(KeyError): + mod.getInputs("thisInputDoesNotExist") + # getOutputs before simulate() + assert mod.getOutputs() == {'y': '-0.4'} + assert mod.getOutputs("y") == ["-0.4"] + assert mod.getOutputs(["y", "y"]) == ["-0.4", "-0.4"] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous before simulate(): + assert mod.getContinuous() == { + 'x': '1.0', + 'der(x)': None, + 'y': '-0.4' + } + assert mod.getContinuous("y") == ['-0.4'] + assert mod.getContinuous(["y", "x"]) == ['-0.4', '1.0'] + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + stopTime = 1.0 + a = -0.5 + b = 0.1 + x0 = 1.0 + x_analytical = -b/a + (x0 + b/a) * np.exp(a * stopTime) + dx_analytical = (x0 + b/a) * a * np.exp(a * stopTime) + mod.setSimulationOptions(simOptions={"stopTime": stopTime}) + mod.simulate() + + # getOutputs after simulate() + d = mod.getOutputs() + assert d.keys() == {"y"} + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getOutputs("y") == [d["y"]] + assert mod.getOutputs(["y", "y"]) == [d["y"], d["y"]] + with pytest.raises(KeyError): + mod.getOutputs("thisOutputDoesNotExist") + + # getContinuous after simulate() should return values at end of simulation: + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + with pytest.raises(KeyError): + mod.getContinuous(["x", "a", "y"]) # a is a parameter + d = mod.getContinuous() + assert d.keys() == {"x", "der(x)", "y"} + assert np.isclose(d["x"], x_analytical, 1e-4) + assert np.isclose(d["der(x)"], dx_analytical, 1e-4) + assert np.isclose(d["y"], dx_analytical, 1e-4) + assert mod.getContinuous("x") == [d["x"]] + assert mod.getContinuous(["y", "x"]) == [d["y"], d["x"]] + + with pytest.raises(KeyError): + mod.getContinuous("a") # a is a parameter + + with pytest.raises(OMPython.ModelicaSystemError): + mod.setSimulationOptions(simOptions={"thisOptionDoesNotExist": 3}) + + +def test_simulate_inputs(tmp_path): + model_file = tmp_path / "M_input.mo" + model_file.write_text(""" +model M_input +Real x(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y; +equation +der(x) = u1 + u2; +y = x; +end M_input; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_input") + + mod.setSimulationOptions(simOptions={"stopTime": 1.0}) + + # integrate zero (no setInputs call) - it should default to None -> 0 + assert mod.getInputs() == { + "u1": None, + "u2": None, + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 0.0) + + # integrate a constant + mod.setInputs(name={"u1": 2.5}) + assert mod.getInputs() == { + "u1": [ + (0.0, 2.5), + (1.0, 2.5), + ], + # u2 is set due to the call to simulate() above + "u2": [ + (0.0, 0.0), + (1.0, 0.0), + ], + } + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 2.5) + + # now let's integrate the sum of two ramps + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 2), (1.0, 0)]}) + assert mod.getInputs("u1") == [[ + (0.0, 0.0), + (0.5, 2.0), + (1.0, 0.0), + ]] + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) + + # let's try some edge cases + # unmatched startTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(-0.5, 0.0), (1.0, 1)]}) + mod.simulate() + # unmatched stopTime + with pytest.raises(OMPython.ModelicaSystemError): + mod.setInputs(name={"u1": [(0.0, 0.0), (0.5, 1)]}) + mod.simulate() + + # Let's use both inputs, but each one with different number of + # samples. This has an effect when generating the csv file. + mod.setInputs(name={"u1": [(0.0, 0), (1.0, 1)], + "u2": [(0.0, 0), (0.25, 0.5), (0.5, 1.0), (1.0, 0)]}) + csv_file = mod._createCSVData() + assert pathlib.Path(csv_file).read_text() == """time,u1,u2,end +0.0,0.0,0.0,0 +0.25,0.25,0.5,0 +0.5,0.5,1.0,0 +1.0,1.0,0.0,0 +""" + + mod.simulate() + y = mod.getSolutions("y")[0] + assert np.isclose(y[-1], 1.0) diff --git a/tests_v400/test_ModelicaSystemCmd.py b/tests_v400/test_ModelicaSystemCmd.py new file mode 100644 index 000000000..3544a1bd2 --- /dev/null +++ b/tests_v400/test_ModelicaSystemCmd.py @@ -0,0 +1,51 @@ +import OMPython +import pytest + + +@pytest.fixture +def model_firstorder(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text("""model M + Real x(start = 1, fixed = true); + parameter Real a = -1; +equation + der(x) = x*a; +end M; +""") + return mod + + +@pytest.fixture +def mscmd_firstorder(model_firstorder): + mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") + mscmd = OMPython.ModelicaSystemCmd(runpath=mod.getWorkDirectory(), modelname=mod._model_name) + return mscmd + + +def test_simflags(mscmd_firstorder): + mscmd = mscmd_firstorder + + mscmd.args_set({ + "noEventEmit": None, + "override": {'b': 2} + }) + with pytest.deprecated_call(): + mscmd.args_set(args=mscmd.parse_simflags(simflags="-noEventEmit -noRestart -override=a=1,x=3")) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,b=2,x=3', + ] + + mscmd.args_set({ + "override": {'b': None}, + }) + + assert mscmd.get_cmd() == [ + mscmd.get_exe().as_posix(), + '-noEventEmit', + '-noRestart', + '-override=a=1,x=3', + ] diff --git a/tests_v400/test_OMParser.py b/tests_v400/test_OMParser.py new file mode 100644 index 000000000..875604e52 --- /dev/null +++ b/tests_v400/test_OMParser.py @@ -0,0 +1,43 @@ +from OMPython import OMParser + +typeCheck = OMParser.typeCheck + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('TRUE') is True + assert typeCheck('True') is True + assert typeCheck('true') is True + assert typeCheck('FALSE') is False + assert typeCheck('False') is False + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +# def test_dict(): +# assert type(typeCheck('{"a": "b"}')) == dict + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_str(): + pass + + +def test_UnStringable(): + pass diff --git a/tests_v400/test_OMSessionCmd.py b/tests_v400/test_OMSessionCmd.py new file mode 100644 index 000000000..1588fac8d --- /dev/null +++ b/tests_v400/test_OMSessionCmd.py @@ -0,0 +1,17 @@ +import OMPython + + +def test_isPackage(): + omczmq = OMPython.OMCSessionZMQ() + omccmd = OMPython.OMCSessionCmd(session=omczmq) + assert not omccmd.isPackage('Modelica') + + +def test_isPackage2(): + mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", + lmodel=["Modelica"]) + omccmd = OMPython.OMCSessionCmd(session=mod._getconn) + assert omccmd.isPackage('Modelica') + + +# TODO: add more checks ... diff --git a/tests_v400/test_ZMQ.py b/tests_v400/test_ZMQ.py new file mode 100644 index 000000000..30bf78e7b --- /dev/null +++ b/tests_v400/test_ZMQ.py @@ -0,0 +1,70 @@ +import OMPython +import pathlib +import os +import pytest + + +@pytest.fixture +def model_time_str(): + return """model M + Real r = time; +end M; +""" + + +@pytest.fixture +def om(tmp_path): + origDir = pathlib.Path.cwd() + os.chdir(tmp_path) + om = OMPython.OMCSessionZMQ() + os.chdir(origDir) + return om + + +def testHelloWorld(om): + assert om.sendExpression('"HelloWorld!"') == "HelloWorld!" + + +def test_Translate(om, model_time_str): + assert om.sendExpression(model_time_str) == ("M",) + assert om.sendExpression('translateModel(M)') is True + + +def test_Simulate(om, model_time_str): + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') + + +def test_execute(om): + with pytest.deprecated_call(): + assert om.execute('"HelloWorld!"') == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + assert om.sendExpression('"HelloWorld!"', parsed=True) == 'HelloWorld!' + + +def test_omcprocessport_execute(om): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + # run 1 + om1 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om1.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + # run 2 + om2 = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om2.sendExpression('"HelloWorld!"', parsed=False) == '"HelloWorld!"\n' + + del om1 + del om2 + + +def test_omcprocessport_simulate(om, model_time_str): + port = om.omc_process.get_port() + omcp = OMPython.OMCProcessPort(omc_port=port) + + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression(f'loadString("{model_time_str}")') is True + om.sendExpression('res:=simulate(M, stopTime=2.0)') + assert om.sendExpression('res.resultFile') != "" + del om diff --git a/tests_v400/test_docker.py b/tests_v400/test_docker.py new file mode 100644 index 000000000..8d68f11f0 --- /dev/null +++ b/tests_v400/test_docker.py @@ -0,0 +1,32 @@ +import sys +import pytest +import OMPython + +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + + +@skip_on_windows +def test_docker(): + omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + om = OMPython.OMCSessionZMQ(omc_process=omcp) + assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcpInner = OMPython.OMCProcessDockerContainer(dockerContainer=omcp.get_docker_container_id()) + omInner = OMPython.OMCSessionZMQ(omc_process=omcpInner) + assert omInner.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + omcp2 = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal", port=11111) + om2 = OMPython.OMCSessionZMQ(omc_process=omcp2) + assert om2.sendExpression("getVersion()") == "OpenModelica 1.25.0" + + del omcp2 + del om2 + + del omcpInner + del omInner + + del omcp + del om diff --git a/tests_v400/test_linearization.py b/tests_v400/test_linearization.py new file mode 100644 index 000000000..bccbc40b5 --- /dev/null +++ b/tests_v400/test_linearization.py @@ -0,0 +1,102 @@ +import OMPython +import pytest +import numpy as np + + +@pytest.fixture +def model_linearTest(tmp_path): + mod = tmp_path / "M.mo" + mod.write_text(""" +model linearTest + Real x1(start=1); + Real x2(start=-2); + Real x3(start=3); + Real x4(start=-5); + parameter Real a=3,b=2,c=5,d=7,e=1,f=4; +equation + a*x1 = b*x2 -der(x1); + der(x2) + c*x3 + d*x1 = x4; + f*x4 - e*x3 - der(x3) = x1; + der(x4) = x1 + x2 + der(x3) + x4; +end linearTest; +""") + return mod + + +def test_example(model_linearTest): + mod = OMPython.ModelicaSystem(model_linearTest, "linearTest") + [A, B, C, D] = mod.linearize() + expected_matrixA = [[-3, 2, 0, 0], [-7, 0, -5, 1], [-1, 0, -1, 4], [0, 1, -1, 5]] + assert A == expected_matrixA, f"Matrix does not match the expected value. Got: {A}, Expected: {expected_matrixA}" + assert B == [], f"Matrix does not match the expected value. Got: {B}, Expected: {[]}" + assert C == [], f"Matrix does not match the expected value. Got: {C}, Expected: {[]}" + assert D == [], f"Matrix does not match the expected value. Got: {D}, Expected: {[]}" + assert mod.getLinearInputs() == [] + assert mod.getLinearOutputs() == [] + assert mod.getLinearStates() == ["x1", "x2", "x3", "x4"] + + +def test_getters(tmp_path): + model_file = tmp_path / "pendulum.mo" + model_file.write_text(""" +model Pendulum +Real phi(start=Modelica.Constants.pi, fixed=true); +Real omega(start=0, fixed=true); +input Real u1; +input Real u2; +output Real y1; +output Real y2; +parameter Real l = 1.2; +parameter Real g = 9.81; +equation +der(phi) = omega + u2; +der(omega) = -g/l * sin(phi); +y1 = y2 + 0.5*omega; +y2 = phi + u1; +end Pendulum; +""") + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="Pendulum", lmodel=["Modelica"]) + + d = mod.getLinearizationOptions() + assert isinstance(d, dict) + assert "startTime" in d + assert "stopTime" in d + assert mod.getLinearizationOptions(["stopTime", "startTime"]) == [d["stopTime"], d["startTime"]] + mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) + assert mod.getLinearizationOptions("stopTime") == ["0.02"] + + mod.setInputs(name={"u1": 10, "u2": 0}) + [A, B, C, D] = mod.linearize() + param_g = float(mod.getParameters("g")[0]) + param_l = float(mod.getParameters("l")[0]) + assert mod.getLinearInputs() == ["u1", "u2"] + assert mod.getLinearStates() == ["omega", "phi"] + assert mod.getLinearOutputs() == ["y1", "y2"] + assert np.isclose(A, [[0, param_g/param_l], [1, 0]]).all() + assert np.isclose(B, [[0, 0], [0, 1]]).all() + assert np.isclose(C, [[0.5, 1], [0, 1]]).all() + assert np.isclose(D, [[1, 0], [1, 0]]).all() + + # test LinearizationResult + result = mod.linearize() + assert result[0] == A + assert result[1] == B + assert result[2] == C + assert result[3] == D + with pytest.raises(KeyError): + result[4] + + A2, B2, C2, D2 = result + assert A2 == A + assert B2 == B + assert C2 == C + assert D2 == D + + assert result.n == 2 + assert result.m == 2 + assert result.p == 2 + assert np.isclose(result.x0, [0, np.pi]).all() + assert np.isclose(result.u0, [10, 0]).all() + assert result.stateVars == ["omega", "phi"] + assert result.inputVars == ["u1", "u2"] + assert result.outputVars == ["y1", "y2"] diff --git a/tests_v400/test_optimization.py b/tests_v400/test_optimization.py new file mode 100644 index 000000000..b41643971 --- /dev/null +++ b/tests_v400/test_optimization.py @@ -0,0 +1,67 @@ +import OMPython +import numpy as np + + +def test_optimization_example(tmp_path): + model_file = tmp_path / "BangBang2021.mo" + model_file.write_text(""" +model BangBang2021 "Model to verify that optimization gives bang-bang optimal control" +parameter Real m = 1; +parameter Real p = 1 "needed for final constraints"; + +Real a; +Real v(start = 0, fixed = true); +Real pos(start = 0, fixed = true); +Real pow(min = -30, max = 30) = f * v annotation(isConstraint = true); + +input Real f(min = -10, max = 10); + +Real costPos(nominal = 1) = -pos "minimize -pos(tf)" annotation(isMayer=true); + +Real conSpeed(min = 0, max = 0) = p * v " 0<= p*v(tf) <=0" annotation(isFinalConstraint = true); + +equation + +der(pos) = v; +der(v) = a; +f = m * a; + +annotation(experiment(StartTime = 0, StopTime = 1, Tolerance = 1e-07, Interval = 0.01), +__OpenModelica_simulationFlags(s="optimization", optimizerNP="1"), +__OpenModelica_commandLineOptions="+g=Optimica"); + +end BangBang2021; +""") + + mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="BangBang2021") + + mod.setOptimizationOptions(optimizationOptions={"numberOfIntervals": 16, + "stopTime": 1, + "stepSize": 0.001, + "tolerance": 1e-8}) + + # test the getter + assert mod.getOptimizationOptions()["stopTime"] == "1" + assert mod.getOptimizationOptions("stopTime") == ["1"] + assert mod.getOptimizationOptions(["tolerance", "stopTime"]) == ["1e-08", "1"] + + r = mod.optimize() + # it is necessary to specify resultfile, otherwise it wouldn't find it. + time, f, v = mod.getSolutions(["time", "f", "v"], resultfile=r["resultFile"]) + assert np.isclose(f[0], 10) + assert np.isclose(f[-1], -10) + + def f_fcn(time, v): + if time < 0.3: + return 10 + if time <= 0.5: + return 30 / v + if time < 0.7: + return -30 / v + return -10 + f_expected = [f_fcn(t, v) for t, v in zip(time, v)] + + # The sharp edge at time=0.5 probably won't match, let's leave that out. + matches = np.isclose(f, f_expected, 1e-3) + assert matches[:498].all() + assert matches[502:].all() diff --git a/tests_v400/test_typedParser.py b/tests_v400/test_typedParser.py new file mode 100644 index 000000000..60daedec5 --- /dev/null +++ b/tests_v400/test_typedParser.py @@ -0,0 +1,53 @@ +from OMPython import OMTypedParser + +typeCheck = OMTypedParser.parseString + + +def test_newline_behaviour(): + pass + + +def test_boolean(): + assert typeCheck('true') is True + assert typeCheck('false') is False + + +def test_int(): + assert typeCheck('2') == 2 + assert type(typeCheck('1')) == int + assert type(typeCheck('123123123123123123232323')) == int + assert type(typeCheck('9223372036854775808')) == int + + +def test_float(): + assert type(typeCheck('1.2e3')) == float + + +def test_ident(): + assert typeCheck('blabla2') == "blabla2" + + +def test_empty(): + assert typeCheck('') is None + + +def test_str(): + pass + + +def test_UnStringable(): + pass + + +def test_everything(): + # this test used to be in OMTypedParser.py's main() + testdata = """ + (1.0,{{1,true,3},{"4\\" +",5.9,6,NONE ( )},record ABC + startTime = ErrorLevel.warning, + 'stop*Time' = SOME(1.0) +end ABC;}) + """ + expected = (1.0, ((1, True, 3), ('4"\n', 5.9, 6, None), {"'stop*Time'": 1.0, 'startTime': 'ErrorLevel.warning'})) + results = typeCheck(testdata) + assert results == expected