diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 776a641d..ccec499b 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -119,7 +119,11 @@ def __getitem__(self, index: int): class ModelicaSystemCmd: - """A compiled model executable.""" + """ + All information about a compiled model executable. This should include data about all structured parameters, i.e. + parameters which need a recompilation of the model. All non-structured parameters can be easily changed without + the need for recompilation. + """ def __init__( self, @@ -505,10 +509,10 @@ def _loadLibrary(self, libraries: list): if element is not None: if isinstance(element, str): if element.endswith(".mo"): - apiCall = "loadFile" + api_call = "loadFile" else: - apiCall = "loadModel" - self._requestApi(apiName=apiCall, entity=element) + 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]})" @@ -563,8 +567,8 @@ def buildModel(self, variableFilter: Optional[str] = None): else: var_filter = 'variableFilter=".*"' - buildModelResult = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter) - logger.debug("OM model build result: %s", buildModelResult) + 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 ... om_cmd = ModelicaSystemCmd( @@ -580,7 +584,7 @@ def buildModel(self, variableFilter: Optional[str] = None): if returncode != 0: raise ModelicaSystemError("Model executable not working!") - xml_file = self._session.omcpath(buildModelResult[0]).parent / buildModelResult[1] + 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: @@ -618,13 +622,13 @@ def _xmlparse(self, xml_file: OMCPath): xml_content = xml_file.read_text() tree = ET.ElementTree(ET.fromstring(xml_content)) - rootCQ = tree.getroot() - for attr in rootCQ.iter('DefaultExperiment'): + root = tree.getroot() + for attr in root.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", "solver", "outputFormat"): self._simulate_options[key] = str(attr.get(key)) - for sv in rootCQ.iter('ScalarVariable'): + for sv in root.iter('ScalarVariable'): translations = { "alias": "alias", "aliasvariable": "aliasVariable", @@ -1405,6 +1409,10 @@ def isParameterChangeable( self, name: str, ) -> bool: + """ + Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to + recompile the model). + """ q = self.getQuantities(name) if q[0]["changeable"] == "false": return False @@ -1670,10 +1678,10 @@ def convertMo2Fmu( fileNamePrefix = "" else: fileNamePrefix = self._model_name - includeResourcesStr = "true" if includeResources else "false" + include_resources_str = "true" if includeResources else "false" properties = (f'version="{version}", fmuType="{fmuType}", ' - f'fileNamePrefix="{fileNamePrefix}", includeResources={includeResourcesStr}') + f'fileNamePrefix="{fileNamePrefix}", includeResources={include_resources_str}') fmu = self._requestApi(apiName='buildModelFMU', entity=self._model_name, properties=properties) fmu_path = self._session.omcpath(fmu) @@ -1742,12 +1750,9 @@ def optimize(self) -> dict[str, Any]: 'timeTemplates': 0.002007785, 'timeTotal': 1.079097854} """ - cName = self._model_name properties = ','.join(f"{key}={val}" for key, val in self._optimization_options.items()) self.set_command_line_options("-g=Optimica") - optimizeResult = self._requestApi(apiName='optimize', entity=cName, properties=properties) - - return optimizeResult + return self._requestApi(apiName='optimize', entity=self._model_name, properties=properties) def linearize( self, diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 61e7605a..73d4b0c4 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -65,7 +65,11 @@ logger = logging.getLogger(__name__) -class DummyPopen: +class DockerPopen: + """ + Dummy implementation of Popen for a (running) docker process. The process is identified by its process ID (pid). + """ + def __init__(self, pid): self.pid = pid self.process = psutil.Process(pid) @@ -85,10 +89,15 @@ def wait(self, timeout): class OMCSessionException(Exception): - pass + """ + Exception which is raised by any OMC* class. + """ class OMCSessionCmd: + """ + Implementation of Open Modelica Compiler API functions. Depreciated! + """ def __init__(self, session: OMCSessionZMQ, readonly: bool = False): if not isinstance(session, OMCSessionZMQ): @@ -116,7 +125,7 @@ def _ask(self, question: str, opt: Optional[list[str]] = None, parsed: bool = Tr try: res = self._session.sendExpression(expression, parsed=parsed) except OMCSessionException as ex: - raise OMCSessionException("OMC _ask() failed: %s (parsed=%s)", (expression, parsed)) from ex + raise OMCSessionException(f"OMC _ask() failed: {expression} (parsed={parsed})") from ex # save response self._omc_cache[p] = res @@ -459,8 +468,7 @@ def __new__(cls, *args, **kwargs): cls = OMCPathCompatibilityWindows if os.name == 'nt' else OMCPathCompatibilityPosix self = cls._from_parts(args) if not self._flavour.is_supported: - raise NotImplementedError("cannot instantiate %r on your system" - % (cls.__name__,)) + raise NotImplementedError(f"cannot instantiate {cls.__name__} on your system") return self def size(self) -> int: @@ -470,10 +478,14 @@ def size(self) -> int: return self.stat().st_size class OMCPathCompatibilityPosix(pathlib.PosixPath, OMCPathCompatibility): - pass + """ + Compatibility class for OMCPath on Posix systems (Python < 3.12) + """ class OMCPathCompatibilityWindows(pathlib.WindowsPath, OMCPathCompatibility): - pass + """ + Compatibility class for OMCPath on Windows systems (Python < 3.12) + """ OMCPath = OMCPathCompatibility @@ -487,6 +499,9 @@ class OMCSessionRunData: 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. """ # cmd_path is the expected working directory cmd_path: str @@ -523,6 +538,25 @@ def get_cmd(self) -> list[str]: class OMCSessionZMQ: + """ + This class is handling an OMC session. + + The main method is sendExpression() which is used to send commands to the OMC process. + + The class expects an OMCProcess* on initialisation. It defines the type of OMC process to use: + + * OMCProcessLocal + + * OMCProcessPort + + * OMCProcessDocker + + * OMCProcessDockerContainer + + * OMCProcessWSL + + If no OMC process is defined, a local OMC process is initialized. + """ def __init__( self, @@ -532,12 +566,6 @@ def __init__( ) -> None: """ Initialisation for OMCSessionZMQ - - Parameters - ---------- - timeout - omhome - omc_process """ self._timeout = timeout @@ -593,10 +621,8 @@ def omcpath(self, *path) -> OMCPath: if isinstance(self.omc_process, OMCProcessLocal): # noinspection PyArgumentList return OMCPath(*path) - else: - raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCProcessLocal is used!") - else: - return OMCPath(*path, session=self) + raise OMCSessionException("OMCPath is supported for Python < 3.12 only if OMCProcessLocal is used!") + return OMCPath(*path, session=self) def omcpath_tempdir(self, tempdir_base: Optional[OMCPath] = None) -> OMCPath: """ @@ -685,6 +711,9 @@ def execute(self, command: str): 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. """ if self.omc_zmq is None: raise OMCSessionException("No OMC running. Create a new instance of OMCProcess!") @@ -803,15 +832,20 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: try: return om_parser_typed(result) - except pyparsing.ParseException as ex: - logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex.msg) + except pyparsing.ParseException as ex1: + logger.warning('OMTypedParser error: %s. Returning the basic parser result.', ex1.msg) try: return om_parser_basic(result) - except (TypeError, UnboundLocalError) as ex: - raise OMCSessionException("Cannot parse OMC result") from ex + except (TypeError, UnboundLocalError) as ex2: + raise OMCSessionException("Cannot parse OMC result") from ex2 class OMCProcess(metaclass=abc.ABCMeta): + """ + Metaclass to be used by all OMCProcess* implementations. The main task is the evaluation of the port to be used to + connect to the selected OMC process (method get_port()). Besides that, any implementation should define the method + omc_run_data_update() to finalize the definition of an OMC simulation. + """ def __init__( self, @@ -904,6 +938,9 @@ def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunD """ Update the OMCSessionRunData object based on the selected OMCProcess 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!") @@ -1072,30 +1109,30 @@ def __init__( if dockerExtraArgs is None: dockerExtraArgs = [] - self._dockerExtraArgs = dockerExtraArgs - self._dockerOpenModelicaPath = pathlib.PurePosixPath(dockerOpenModelicaPath) - self._dockerNetwork = dockerNetwork + self._docker_extra_args = dockerExtraArgs + self._docker_open_modelica_path = pathlib.PurePosixPath(dockerOpenModelicaPath) + self._docker_network = dockerNetwork - self._interactivePort = port + self._interactive_port = port - self._dockerCid: Optional[str] = None - self._docker_process: Optional[DummyPopen] = None + self._docker_container_id: Optional[str] = None + self._docker_process: Optional[DockerPopen] = None - def _docker_process_get(self, docker_cid: str) -> Optional[DummyPopen]: + def _docker_process_get(self, docker_cid: str) -> Optional[DockerPopen]: if sys.platform == 'win32': raise NotImplementedError("Docker not supported on win32!") docker_process = None for _ in range(0, 40): - dockerTop = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() + docker_top = subprocess.check_output(["docker", "top", docker_cid]).decode().strip() docker_process = None - for line in dockerTop.split("\n"): + for line in docker_top.split("\n"): columns = line.split() if self._random_string in line: try: - docker_process = DummyPopen(int(columns[1])) + docker_process = DockerPopen(int(columns[1])) except psutil.NoSuchProcess as ex: - raise OMCSessionException(f"Could not find PID {dockerTop} - " + raise OMCSessionException(f"Could not find PID {docker_top} - " "is this a docker instance spawned without --pid=host?") from ex if docker_process is not None: @@ -1118,8 +1155,8 @@ def _getuid() -> int: def _omc_port_get(self) -> str: port = None - if not isinstance(self._dockerCid, str): - raise OMCSessionException(f"Invalid docker container ID: {self._dockerCid}") + if not isinstance(self._docker_container_id, str): + raise OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}") # See if the omc server is running attempts = 0 @@ -1128,7 +1165,7 @@ def _omc_port_get(self) -> str: if omc_portfile_path is not None: try: output = subprocess.check_output(args=["docker", - "exec", self._dockerCid, + "exec", self._docker_container_id, "cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL) port = output.decode().strip() @@ -1153,8 +1190,8 @@ def get_server_address(self) -> Optional[str]: """ Get the server address of the OMC server running in a Docker container. """ - if self._dockerNetwork == "separate" and isinstance(self._dockerCid, str): - output = subprocess.check_output(["docker", "inspect", self._dockerCid]).decode().strip() + 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"] return None @@ -1163,10 +1200,10 @@ def get_docker_container_id(self) -> str: """ Get the Docker container ID of the Docker container with the OMC server. """ - if not isinstance(self._dockerCid, str): - raise OMCSessionException(f"Invalid docker container ID: {self._dockerCid}!") + if not isinstance(self._docker_container_id, str): + raise OMCSessionException(f"Invalid docker container ID: {self._docker_container_id}!") - return self._dockerCid + return self._docker_container_id def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunData: """ @@ -1180,8 +1217,8 @@ def omc_run_data_update(self, omc_run_data: OMCSessionRunData) -> OMCSessionRunD "--user", str(self._getuid()), "--workdir", omc_run_data_copy.cmd_path, ] - + self._dockerExtraArgs - + [self._dockerCid] + + self._docker_extra_args + + [self._docker_container_id] ) cmd_path = pathlib.PurePosixPath(omc_run_data_copy.cmd_path) @@ -1220,7 +1257,7 @@ def __init__( self._docker = docker # start up omc executable in docker container waiting for the ZMQ connection - self._omc_process, self._docker_process, self._dockerCid = self._docker_omc_start() + 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() @@ -1228,7 +1265,7 @@ def __del__(self) -> None: super().__del__() - if isinstance(self._docker_process, DummyPopen): + if isinstance(self._docker_process, DockerPopen): try: self._docker_process.wait(timeout=2.0) except subprocess.TimeoutExpired: @@ -1248,33 +1285,33 @@ def _docker_omc_cmd( """ Define the command that will be called by the subprocess module. """ - extraFlags = [] + extra_flags = [] if sys.platform == "win32": - extraFlags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactivePort: + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + if not self._interactive_port: raise OMCSessionException("docker on Windows requires knowing which port to connect to - " "please set the interactivePort argument") if sys.platform == "win32": - if isinstance(self._interactivePort, str): - port = int(self._interactivePort) - elif isinstance(self._interactivePort, int): - port = self._interactivePort + 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!") - dockerNetworkStr = ["-p", f"127.0.0.1:{port}:{port}"] - elif self._dockerNetwork == "host" or self._dockerNetwork is None: - dockerNetworkStr = ["--network=host"] - elif self._dockerNetwork == "separate": - dockerNetworkStr = [] - extraFlags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + docker_network_str = ["-p", f"127.0.0.1:{port}:{port}"] + elif self._docker_network == "host" or self._docker_network is None: + docker_network_str = ["--network=host"] + elif self._docker_network == "separate": + docker_network_str = [] + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] else: - raise OMCSessionException(f'dockerNetwork was set to {self._dockerNetwork}, ' + raise OMCSessionException(f'dockerNetwork was set to {self._docker_network}, ' 'but only \"host\" or \"separate\" is allowed') - if isinstance(self._interactivePort, int): - extraFlags = extraFlags + [f"--interactivePort={int(self._interactivePort)}"] + if isinstance(self._interactive_port, int): + extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] omc_command = ([ "docker", "run", @@ -1282,15 +1319,15 @@ def _docker_omc_cmd( "--rm", "--user", str(self._getuid()), ] - + self._dockerExtraArgs - + dockerNetworkStr - + [self._docker, self._dockerOpenModelicaPath.as_posix()] + + self._docker_extra_args + + docker_network_str + + [self._docker, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list - + extraFlags) + + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DummyPopen, str]: + def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen, str]: my_env = os.environ.copy() docker_cid_file = self._temp_dir / (self._omc_filebase + ".docker.cid") @@ -1360,7 +1397,7 @@ def __init__( if not isinstance(dockerContainer, str): raise OMCSessionException("Argument dockerContainer must be set!") - self._dockerCid = dockerContainer + 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() @@ -1378,31 +1415,31 @@ def _docker_omc_cmd(self, omc_path_and_args_list) -> list: """ Define the command that will be called by the subprocess module. """ - extraFlags: list[str] = [] + extra_flags: list[str] = [] if sys.platform == "win32": - extraFlags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] - if not self._interactivePort: + extra_flags = ["-d=zmqDangerousAcceptConnectionsFromAnywhere"] + if not self._interactive_port: 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._interactivePort, int): - extraFlags = extraFlags + [f"--interactivePort={int(self._interactivePort)}"] + if isinstance(self._interactive_port, int): + extra_flags = extra_flags + [f"--interactivePort={int(self._interactive_port)}"] omc_command = ([ "docker", "exec", "--user", str(self._getuid()), ] - + self._dockerExtraArgs - + [self._dockerCid, self._dockerOpenModelicaPath.as_posix()] + + self._docker_extra_args + + [self._docker_container_id, self._docker_open_modelica_path.as_posix()] + omc_path_and_args_list - + extraFlags) + + extra_flags) return omc_command - def _docker_omc_start(self) -> Tuple[subprocess.Popen, DummyPopen]: + def _docker_omc_start(self) -> Tuple[subprocess.Popen, DockerPopen]: my_env = os.environ.copy() omc_command = self._docker_omc_cmd( @@ -1417,12 +1454,12 @@ def _docker_omc_start(self) -> Tuple[subprocess.Popen, DummyPopen]: env=my_env) docker_process = None - if isinstance(self._dockerCid, str): - docker_process = self._docker_process_get(docker_cid=self._dockerCid) + if isinstance(self._docker_container_id, str): + docker_process = self._docker_process_get(docker_cid=self._docker_container_id) if docker_process is None: raise OMCSessionException(f"Docker top did not contain omc process {self._random_string} " - f"/ {self._dockerCid}. Log-file says:\n{self.get_log()}") + f"/ {self._docker_container_id}. Log-file says:\n{self.get_log()}") return omc_process, docker_process diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index 0c504135..006d2d17 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -1,8 +1,9 @@ -import OMPython import shutil import os import pathlib +import OMPython + def test_CauerLowPassAnalog(): mod = OMPython.ModelicaSystem() diff --git a/tests/test_FMIImport.py b/tests/test_FMIImport.py index 561352f8..44249f5c 100644 --- a/tests/test_FMIImport.py +++ b/tests/test_FMIImport.py @@ -1,8 +1,9 @@ -import numpy as np import os -import pytest import shutil +import numpy as np +import pytest + import OMPython diff --git a/tests/test_FMIRegression.py b/tests/test_FMIRegression.py index 60c23e07..b61b8d49 100644 --- a/tests/test_FMIRegression.py +++ b/tests/test_FMIRegression.py @@ -1,9 +1,10 @@ -import OMPython import tempfile import pathlib import shutil import os +import OMPython + def buildModelFMU(modelName): omc = OMPython.OMCSessionZMQ() diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index d4cb155e..f49fb20c 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -1,10 +1,12 @@ -import OMPython import os import pathlib -import pytest import sys import tempfile + import numpy as np +import pytest + +import OMPython skip_on_windows = pytest.mark.skipif( sys.platform.startswith("win"), @@ -19,13 +21,14 @@ @pytest.fixture def model_firstorder_content(): - return ("""model M + return """ +model M Real x(start = 1, fixed = true); parameter Real a = -1; equation der(x) = x*a; end M; -""") +""" @pytest.fixture diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index f1c25ab3..d736d5ca 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -1,6 +1,7 @@ -import OMPython import pytest +import OMPython + @pytest.fixture def model_firstorder(tmp_path): diff --git a/tests/test_ModelicaSystemDoE.py b/tests/test_ModelicaSystemDoE.py index b028daae..d290c715 100644 --- a/tests/test_ModelicaSystemDoE.py +++ b/tests/test_ModelicaSystemDoE.py @@ -1,9 +1,11 @@ -import numpy as np -import OMPython import pathlib -import pytest 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.", diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 00844905..4a053287 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -1,7 +1,9 @@ import sys -import OMPython + import pytest +import OMPython + skip_on_windows = pytest.mark.skipif( sys.platform.startswith("win"), reason="OpenModelica Docker image is Linux-only; skipping on Windows.", diff --git a/tests/test_ZMQ.py b/tests/test_ZMQ.py index 30bf78e7..45d517cd 100644 --- a/tests/test_ZMQ.py +++ b/tests/test_ZMQ.py @@ -1,8 +1,9 @@ -import OMPython import pathlib import os import pytest +import OMPython + @pytest.fixture def model_time_str(): diff --git a/tests/test_linearization.py b/tests/test_linearization.py index 7d596b03..ebfbc100 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -1,6 +1,7 @@ -import OMPython -import pytest import numpy as np +import pytest + +import OMPython @pytest.fixture diff --git a/tests/test_optimization.py b/tests/test_optimization.py index ccc4011c..96c6fdbd 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -1,6 +1,7 @@ -import OMPython import numpy as np +import OMPython + def test_optimization_example(tmp_path): model_file = tmp_path / "BangBang2021.mo"