From 8d19d261d898e79405e2bcd507a46a91b099f2f1 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 09:51:50 +0200 Subject: [PATCH 01/64] [ModelicaSystem] add type hints for set*() functions and rename arguments * fix some type hint issues in setInput() * prepare for definition via dictionary replacing 'a=b' and '[a=b, c=d]' style --- OMPython/ModelicaSystem.py | 186 +++++++++++++++++++++++++------------ 1 file changed, 125 insertions(+), 61 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d0178cf2..fae8b75e 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1126,46 +1126,82 @@ def _strip_space(name): raise ModelicaSystemError("Unhandled input for strip_space()") - def _setMethodHelper(self, args1, args2, args3, args4=None): - """Helper function for setters. + def _setMethodHelper( + self, + inputdata: str | list[str] | dict[str, str | int | float], + classdata: dict[str, Any], + datatype: str, + overwritedata: Optional[dict[str, str]] = None, + ) -> bool: + """ + Helper function for setters. args1 - string or list of string given by user args2 - dict() containing the values of different variables(eg:, parameter,continuous,simulation parameters) args3 - function name (eg; continuous, parameter, simulation, linearization,optimization) args4 - dict() which stores the new override variables list, """ - def apply_single(args1): - args1 = self._strip_space(args1) - value = args1.split("=") - if value[0] in args2: - if args3 == "parameter" and self.isParameterChangeable(value[0], value[1]): - args2[value[0]] = value[1] - if args4 is not None: - args4[value[0]] = value[1] - elif args3 != "parameter": - args2[value[0]] = value[1] - if args4 is not None: - args4[value[0]] = value[1] + + # TODO: cleanup / data handling / ... + # (1) args1: Optional[str | list[str]] -> convert to dict + # (2) work on dict! inputs: dict[str, str | int | float | ?numbers.number?] + # (3) handle function + # (4) use it also for other functions with such an input, i.e. 'key=value' | ['key=value'] + # (5) include setInputs() + + def apply_single(key_val: str): + key_val_list = key_val.split("=") + if len(key_val_list) != 2: + raise ModelicaSystemError(f"Invalid key = value pair: {key_val}") + + name = self._strip_space(key_val_list[0]) + value = self._strip_space(key_val_list[1]) + + if name in classdata: + if datatype == "parameter" and not self.isParameterChangeable(name): + logger.debug(f"It is not possible to set the parameter {repr(name)}. It seems to be " + "structural, final, protected, evaluated or has a non-constant binding. " + "Use sendExpression(...) and rebuild the model using buildModel() API; example: " + "sendExpression(\"setParameterValue(" + f"{self.modelName}, {name}, {value if value is not None else ''}" + ")\") ") + return False + + classdata[name] = value + if overwritedata is not None: + overwritedata[name] = value return True else: - raise ModelicaSystemError("Unhandled case in _setMethodHelper.apply_single() - " - f"{repr(value[0])} is not a {repr(args3)} variable") + raise ModelicaSystemError("Unhandled case in setMethodHelper.apply_single() - " + f"{repr(name)} is not a {repr(datatype)} variable") result = [] - if isinstance(args1, str): - result = [apply_single(args1)] + if isinstance(inputdata, str): + result = [apply_single(inputdata)] - elif isinstance(args1, list): + elif isinstance(inputdata, list): result = [] - args1 = self._strip_space(args1) - for var in args1: + inputdata = self._strip_space(inputdata) + for var in inputdata: result.append(apply_single(var)) return all(result) - def setContinuous(self, cvals): # 13 + def isParameterChangeable( + self, + name: str, + ): + q = self.getQuantities(name) + if q[0]["changeable"] == "false": + return False + return True + + def setContinuous( + self, + cvals: str | list[str] | dict[str, str | int | float], + ) -> bool: """ This method is used to set continuous values. It can be called: with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: @@ -1173,9 +1209,16 @@ def setContinuous(self, cvals): # 13 >>> setContinuous("Name=value") >>> setContinuous(["Name1=value1","Name2=value2"]) """ - return self._setMethodHelper(cvals, self._continuous, "continuous", self._override_variables) + return self._setMethodHelper( + inputdata=cvals, + classdata=self.continuouslist, + datatype="continuous", + overwritedata=self.overridevariables) - def setParameters(self, pvals): # 14 + def setParameters( + self, + pvals: str | list[str] | dict[str, str | int | float], + ) -> bool: """ This method is used to set parameter values. It can be called: with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: @@ -1183,19 +1226,16 @@ def setParameters(self, pvals): # 14 >>> setParameters("Name=value") >>> setParameters(["Name1=value1","Name2=value2"]) """ - return self._setMethodHelper(pvals, self._params, "parameter", self._override_variables) + return self._setMethodHelper( + inputdata=pvals, + classdata=self.paramlist, + datatype="parameter", + overwritedata=self.overridevariables) - def isParameterChangeable(self, name, value): - q = self.getQuantities(name) - if q[0]["changeable"] == "false": - logger.debug(f"setParameters() failed : It is not possible to set the following signal {repr(name)}. " - "It seems to be structural, final, protected or evaluated or has a non-constant binding, " - f"use sendExpression(\"setParameterValue({self._model_name}, {name}, {value})\") " - "and rebuild the model using buildModel() API") - return False - return True - - def setSimulationOptions(self, simOptions): # 16 + def setSimulationOptions( + self, + simOptions: str | list[str] | dict[str, str | int | float], + ) -> bool: """ This method is used to set simulation options. It can be called: with a sequence of simulation options name and assigning corresponding values as arguments as show in the example below: @@ -1203,9 +1243,16 @@ def setSimulationOptions(self, simOptions): # 16 >>> setSimulationOptions("Name=value") >>> setSimulationOptions(["Name1=value1","Name2=value2"]) """ - return self._setMethodHelper(simOptions, self._simulate_options, "simulation-option", self._simulate_options_override) + return _self.setMethodHelper( + inputdata=simOptions, + classdata=self.simulateOptions, + datatype="simulation-option", + overwritedata=self.simoptionsoverride) - def setLinearizationOptions(self, linearizationOptions): # 18 + def setLinearizationOptions( + self, + linearizationOptions: str | list[str] | dict[str, str | int | float], + ) -> bool: """ This method is used to set linearization options. It can be called: with a sequence of linearization options name and assigning corresponding value as arguments as show in the example below @@ -1213,9 +1260,13 @@ def setLinearizationOptions(self, linearizationOptions): # 18 >>> setLinearizationOptions("Name=value") >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) """ - return self._setMethodHelper(linearizationOptions, self._linearization_options, "Linearization-option", None) + return self._setMethodHelper( + inputdata=linearizationOptions, + classdata=self.linearOptions, + datatype="Linearization-option", + overwritedata=None) - def setOptimizationOptions(self, optimizationOptions): # 17 + def setOptimizationOptions(self, optimizationOptions: str | list[str] | dict[str, str | int | float]) -> bool: """ This method is used to set optimization options. It can be called: with a sequence of optimization options name and assigning corresponding values as arguments as show in the example below: @@ -1223,9 +1274,16 @@ def setOptimizationOptions(self, optimizationOptions): # 17 >>> setOptimizationOptions("Name=value") >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) """ - return self._setMethodHelper(optimizationOptions, self._optimization_options, "optimization-option", None) + return self._setMethodHelper( + inputdata=optimizationOptions, + classdata=self.optimizeOptions, + datatype="optimization-option", + overwritedata=None) - def setInputs(self, name): # 15 + def setInputs( + self, + name: str | list[str] | dict[str, str | int | float], + ) -> bool: """ This method is used to set input values. It can be called: with a sequence of input name and assigning corresponding values as arguments as show in the example below: @@ -1234,34 +1292,40 @@ def setInputs(self, name): # 15 >>> setInputs(["Name1=value1","Name2=value2"]) """ if isinstance(name, str): - name = self._strip_space(name) - value = name.split("=") - if value[0] in self._inputs: - tmpvalue = eval(value[1]) + name1: str = name + name1 = name1.replace(" ", "") + value1 = name1.split("=") + if value1[0] in self.inputlist: + tmpvalue = eval(value1[1]) if isinstance(tmpvalue, (int, float)): - self._inputs[value[0]] = [(float(self._simulate_options["startTime"]), float(value[1])), - (float(self._simulate_options["stopTime"]), float(value[1]))] + self.inputlist[value1[0]] = [(float(self._simulateOptions["startTime"]), float(value1[1])), + (float(self._simulateOptions["stopTime"]), float(value1[1]))] elif isinstance(tmpvalue, list): self._checkValidInputs(tmpvalue) - self._inputs[value[0]] = tmpvalue - self._has_inputs = True + self._inputs[value1[0]] = tmpvalue + self._inputFlag = True else: - raise ModelicaSystemError(f"{value[0]} is not an input") + raise ModelicaSystemError(f"{value1[0]} is not an input") elif isinstance(name, list): - name = self._strip_space(name) - for var in name: - value = var.split("=") - if value[0] in self._inputs: - tmpvalue = eval(value[1]) + name_list: list[str] = name + for name2 in name_list: + name2 = name2.replace(" ", "") + value2 = name2.split("=") + if value2[0] in self.inputlist: + tmpvalue = eval(value2[1]) if isinstance(tmpvalue, (int, float)): - self._inputs[value[0]] = [(float(self._simulate_options["startTime"]), float(value[1])), - (float(self._simulate_options["stopTime"]), float(value[1]))] + self.inputlist[value2[0]] = [(float(self._simulateOptions["startTime"]), float(value2[1])), + (float(self.:simulateOptions["stopTime"]), float(value2[1]))] elif isinstance(tmpvalue, list): self._checkValidInputs(tmpvalue) - self._inputs[value[0]] = tmpvalue - self._has_inputs = True + self._inputs[value2[0]] = tmpvalue + self._inputFlag = True else: - raise ModelicaSystemError(f"{value[0]} is not an input!") + raise ModelicaSystemError(f"{value2[0]} is not an input!") + elif isinstance(name, dict): + raise NotImplementedError("Must be defined!") + + return True def _checkValidInputs(self, name): if name != sorted(name, key=lambda x: x[0]): From 9ac6a0e4562e9b2302bf26ad6070e6c6ecc99462 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 10:38:58 +0200 Subject: [PATCH 02/64] [ModelicaSystem] add _prepare_inputdata() --- OMPython/ModelicaSystem.py | 46 +++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index fae8b75e..d7b347e4 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1126,7 +1126,51 @@ def _strip_space(name): raise ModelicaSystemError("Unhandled input for strip_space()") - def _setMethodHelper( + def _prepare_inputdata( + self, + rawinput: str | list[str] | dict[str, str | int | float], + ) -> 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}") + + inputdata = {key_val_list[0]: key_val_list[1]} + + return inputdata + + if isinstance(rawinput, 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) + return prepare_str(rawinput) + + if isinstance(rawinput, 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) + + inputdata: dict[str, str] = {} + for item in rawinput: + inputdata |= prepare_str(item) + + return inputdata + + if isinstance(rawinput, dict): + inputdata = {key: str(val) for key, val in rawinput.items()} + + return inputdata + + def setMethodHelper( self, inputdata: str | list[str] | dict[str, str | int | float], classdata: dict[str, Any], From 980ac33021dfdce48426ed5c203ea6b0c639d12a Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 10:39:55 +0200 Subject: [PATCH 03/64] [ModelicaSystem] update _set_method_helper() * rename from setMethodHelper() * use _prepare_inputdata() * cleanup code to align with new input as dict[str, str] * setInput() is a special case --- OMPython/ModelicaSystem.py | 157 +++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 78 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d7b347e4..5cbd5fa2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1117,17 +1117,7 @@ def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Op return np_res @staticmethod - def _strip_space(name): - if isinstance(name, str): - return name.replace(" ", "") - - if isinstance(name, list): - return [x.replace(" ", "") for x in name] - - raise ModelicaSystemError("Unhandled input for strip_space()") - def _prepare_inputdata( - self, rawinput: str | list[str] | dict[str, str | int | float], ) -> dict[str, str]: """ @@ -1166,72 +1156,65 @@ def prepare_str(str_in: str) -> dict[str, str]: return inputdata if isinstance(rawinput, dict): - inputdata = {key: str(val) for key, val in rawinput.items()} + for key, val in rawinput.items(): + str_val = str(val) + if ' ' in key or ' ' in str_val: + raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") + inputdata[key] = str_val return inputdata - def setMethodHelper( + def _set_method_helper( self, - inputdata: str | list[str] | dict[str, str | int | float], + inputdata: dict[str, str], classdata: dict[str, Any], datatype: str, overwritedata: Optional[dict[str, str]] = None, ) -> bool: """ - Helper function for setters. + Helper function for: + * setParameter() + * setContinuous() + * setSimulationOptions() + * setLinearizationOption() + * setOptimizationOption() + * setInputs() - args1 - string or list of string given by user - args2 - dict() containing the values of different variables(eg:, parameter,continuous,simulation parameters) - args3 - function name (eg; continuous, parameter, simulation, linearization,optimization) - args4 - dict() which stores the new override variables list, + Parameters + ---------- + inputdata + string or list of string given by user + classdata + dict() containing the values of different variables (eg: parameter, continuous, simulation parameters) + datatype + type identifier (eg; continuous, parameter, simulation, linearization, optimization) + overwritedata + dict() which stores the new override variables list, """ - # TODO: cleanup / data handling / ... - # (1) args1: Optional[str | list[str]] -> convert to dict - # (2) work on dict! inputs: dict[str, str | int | float | ?numbers.number?] - # (3) handle function - # (4) use it also for other functions with such an input, i.e. 'key=value' | ['key=value'] - # (5) include setInputs() - - def apply_single(key_val: str): - key_val_list = key_val.split("=") - if len(key_val_list) != 2: - raise ModelicaSystemError(f"Invalid key = value pair: {key_val}") - - name = self._strip_space(key_val_list[0]) - value = self._strip_space(key_val_list[1]) - - if name in classdata: - if datatype == "parameter" and not self.isParameterChangeable(name): - logger.debug(f"It is not possible to set the parameter {repr(name)}. It seems to be " + inputdata_status: dict[str, bool] = {} + for key, val in inputdata.items(): + status = False + if key in classdata: + if datatype == "parameter" and not self.isParameterChangeable(key): + logger.debug(f"It is not possible to set the parameter {repr(key)}. It seems to be " "structural, final, protected, evaluated or has a non-constant binding. " "Use sendExpression(...) and rebuild the model using buildModel() API; example: " "sendExpression(\"setParameterValue(" - f"{self.modelName}, {name}, {value if value is not None else ''}" + f"{self.modelName}, {key}, {val if val is not None else ''}" ")\") ") - return False - - classdata[name] = value - if overwritedata is not None: - overwritedata[name] = value - - return True - + else: + classdata[key] = val + if overwritedata is not None: + overwritedata[key] = val + status = True else: raise ModelicaSystemError("Unhandled case in setMethodHelper.apply_single() - " - f"{repr(name)} is not a {repr(datatype)} variable") - - result = [] - if isinstance(inputdata, str): - result = [apply_single(inputdata)] + f"{repr(key)} is not a {repr(datatype)} variable") - elif isinstance(inputdata, list): - result = [] - inputdata = self._strip_space(inputdata) - for var in inputdata: - result.append(apply_single(var)) + inputdata_status[key] = status - return all(result) + return all(inputdata_status.values()) def isParameterChangeable( self, @@ -1250,11 +1233,14 @@ def setContinuous( This method is used to set continuous values. It can be called: with a sequence of continuous name and assigning corresponding values as arguments as show in the example below: usage - >>> setContinuous("Name=value") - >>> setContinuous(["Name1=value1","Name2=value2"]) + >>> setContinuous("Name=value") # depreciated + >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated + >>> setContinuous(cvals={"Name1": "value1", "Name2": "value2"}) """ - return self._setMethodHelper( - inputdata=cvals, + inputdata = self._prepare_inputdata(rawinput=cvals) + + return self._set_method_helper( + inputdata=inputdata, classdata=self.continuouslist, datatype="continuous", overwritedata=self.overridevariables) @@ -1267,11 +1253,14 @@ def setParameters( This method is used to set parameter values. It can be called: with a sequence of parameter name and assigning corresponding value as arguments as show in the example below: usage - >>> setParameters("Name=value") - >>> setParameters(["Name1=value1","Name2=value2"]) + >>> setParameters("Name=value") # depreciated + >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated + >>> setParameters(pvals={"Name1": "value1", "Name2": "value2"}) """ - return self._setMethodHelper( - inputdata=pvals, + inputdata = self._prepare_inputdata(rawinput=pvals) + + return self._set_method_helper( + inputdata=inputdata, classdata=self.paramlist, datatype="parameter", overwritedata=self.overridevariables) @@ -1284,11 +1273,14 @@ def setSimulationOptions( This method is used to set simulation options. It can be called: with a sequence of simulation options name and assigning corresponding values as arguments as show in the example below: usage - >>> setSimulationOptions("Name=value") - >>> setSimulationOptions(["Name1=value1","Name2=value2"]) + >>> setSimulationOptions("Name=value") # depreciated + >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated + >>> setSimulationOptions(simOptions={"Name1": "value1", "Name2": "value2"}) """ - return _self.setMethodHelper( - inputdata=simOptions, + inputdata = self._prepare_inputdata(rawinput=simOptions) + + return self._set_method_helper( + inputdata=inputdata, classdata=self.simulateOptions, datatype="simulation-option", overwritedata=self.simoptionsoverride) @@ -1301,11 +1293,14 @@ def setLinearizationOptions( This method is used to set linearization options. It can be called: with a sequence of linearization options name and assigning corresponding value as arguments as show in the example below usage - >>> setLinearizationOptions("Name=value") - >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) + >>> setLinearizationOptions("Name=value") # depreciated + >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated + >>> setLinearizationOptions(linearizationOtions={"Name1": "value1", "Name2": "value2"}) """ - return self._setMethodHelper( - inputdata=linearizationOptions, + inputdata = self._prepare_inputdata(rawinput=linearizationOptions) + + return self._set_method_helper( + inputdata=inputdata, classdata=self.linearOptions, datatype="Linearization-option", overwritedata=None) @@ -1315,11 +1310,14 @@ def setOptimizationOptions(self, optimizationOptions: str | list[str] | dict[str This method is used to set optimization options. It can be called: with a sequence of optimization options name and assigning corresponding values as arguments as show in the example below: usage - >>> setOptimizationOptions("Name=value") - >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) + >>> setOptimizationOptions("Name=value") # depreciated + >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated + >>> setOptimizationOptions(optimizationOptions={"Name1": "value1", "Name2": "value2"}) """ - return self._setMethodHelper( - inputdata=optimizationOptions, + inputdata = self._prepare_inputdata(rawinput=optimizationOptions) + + return self._set_method_helper( + inputdata=inputdata, classdata=self.optimizeOptions, datatype="optimization-option", overwritedata=None) @@ -1332,9 +1330,12 @@ def setInputs( This method is used to set input values. It can be called: with a sequence of input name and assigning corresponding values as arguments as show in the example below: usage - >>> setInputs("Name=value") - >>> setInputs(["Name1=value1","Name2=value2"]) + >>> setInputs("Name=value") # depreciated + >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated + >>> setInputs(name={"Name1": "value1", "Name2": "value2"}) """ + # inputdata = self._prepare_inputdata(rawinput=name) + if isinstance(name, str): name1: str = name name1 = name1.replace(" ", "") From 7c24f10d071cee499bbce2b33eb3168b0150cb60 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 16:15:29 +0200 Subject: [PATCH 04/64] [ModelicaSystem] improve definition of _prepare_inputdata() --- OMPython/ModelicaSystem.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 5cbd5fa2..fa63f882 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1130,9 +1130,11 @@ def prepare_str(str_in: str) -> dict[str, str]: if len(key_val_list) != 2: raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}") - inputdata = {key_val_list[0]: key_val_list[1]} + input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]} - return inputdata + return input_data_from_str + + input_data: dict[str, str] = {} if isinstance(rawinput, str): warnings.warn(message="The definition of values to set should use a dictionary, " @@ -1149,20 +1151,21 @@ def prepare_str(str_in: str) -> dict[str, str]: category=DeprecationWarning, stacklevel=3) - inputdata: dict[str, str] = {} for item in rawinput: - inputdata |= prepare_str(item) + input_data |= prepare_str(item) - return inputdata + return input_data if isinstance(rawinput, dict): for key, val in rawinput.items(): str_val = str(val) if ' ' in key or ' ' in str_val: raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") - inputdata[key] = str_val + input_data[key] = str_val + + return input_data - return inputdata + raise ModelicaSystemError(f"Invalid type of input: {type(rawinput)}") def _set_method_helper( self, From b864a6c1d4b2699cd27bb92d7b4fe9308190ab32 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 16:16:02 +0200 Subject: [PATCH 05/64] [ModelicaSystem] rename _prepare_inputdata() => _prepare_input_data() --- OMPython/ModelicaSystem.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index fa63f882..e2cb064a 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1117,8 +1117,8 @@ def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Op return np_res @staticmethod - def _prepare_inputdata( - rawinput: str | list[str] | dict[str, str | int | float], + def _prepare_input_data( + raw_input: str | list[str] | dict[str, str | int | float], ) -> dict[str, str]: """ Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. @@ -1136,28 +1136,28 @@ def prepare_str(str_in: str) -> dict[str, str]: input_data: dict[str, str] = {} - if isinstance(rawinput, str): + if isinstance(raw_input, 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) - return prepare_str(rawinput) + return prepare_str(raw_input) - if isinstance(rawinput, list): + if isinstance(raw_input, 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 rawinput: + for item in raw_input: input_data |= prepare_str(item) return input_data - if isinstance(rawinput, dict): - for key, val in rawinput.items(): + if isinstance(raw_input, dict): + for key, val in raw_input.items(): str_val = str(val) if ' ' in key or ' ' in str_val: raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") @@ -1165,7 +1165,7 @@ def prepare_str(str_in: str) -> dict[str, str]: return input_data - raise ModelicaSystemError(f"Invalid type of input: {type(rawinput)}") + raise ModelicaSystemError(f"Invalid type of input: {type(raw_input)}") def _set_method_helper( self, @@ -1240,7 +1240,7 @@ def setContinuous( >>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated >>> setContinuous(cvals={"Name1": "value1", "Name2": "value2"}) """ - inputdata = self._prepare_inputdata(rawinput=cvals) + inputdata = self._prepare_input_data(raw_input=cvals) return self._set_method_helper( inputdata=inputdata, @@ -1260,7 +1260,7 @@ def setParameters( >>> setParameters(["Name1=value1","Name2=value2"]) # depreciated >>> setParameters(pvals={"Name1": "value1", "Name2": "value2"}) """ - inputdata = self._prepare_inputdata(rawinput=pvals) + inputdata = self._prepare_input_data(raw_input=pvals) return self._set_method_helper( inputdata=inputdata, @@ -1280,7 +1280,7 @@ def setSimulationOptions( >>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated >>> setSimulationOptions(simOptions={"Name1": "value1", "Name2": "value2"}) """ - inputdata = self._prepare_inputdata(rawinput=simOptions) + inputdata = self._prepare_input_data(raw_input=simOptions) return self._set_method_helper( inputdata=inputdata, @@ -1300,7 +1300,7 @@ def setLinearizationOptions( >>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated >>> setLinearizationOptions(linearizationOtions={"Name1": "value1", "Name2": "value2"}) """ - inputdata = self._prepare_inputdata(rawinput=linearizationOptions) + inputdata = self._prepare_input_data(raw_input=linearizationOptions) return self._set_method_helper( inputdata=inputdata, @@ -1317,7 +1317,7 @@ def setOptimizationOptions(self, optimizationOptions: str | list[str] | dict[str >>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated >>> setOptimizationOptions(optimizationOptions={"Name1": "value1", "Name2": "value2"}) """ - inputdata = self._prepare_inputdata(rawinput=optimizationOptions) + inputdata = self._prepare_input_data(raw_input=optimizationOptions) return self._set_method_helper( inputdata=inputdata, @@ -1337,7 +1337,7 @@ def setInputs( >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated >>> setInputs(name={"Name1": "value1", "Name2": "value2"}) """ - # inputdata = self._prepare_inputdata(rawinput=name) + # inputdata = self._prepare_input_data(raw_input=name) if isinstance(name, str): name1: str = name From 3f180a6cf0ce2d4498308b3c87856dfcecafda63 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 16:33:58 +0200 Subject: [PATCH 06/64] [ModelicaSystem] update setInput() * replace eval() with ast.literal_eval() as a saver version * use _prepare_input_data() * simplify code --- OMPython/ModelicaSystem.py | 79 ++++++++++++++------------------------ 1 file changed, 28 insertions(+), 51 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e2cb064a..985ddeba 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -32,6 +32,7 @@ CONDITIONS OF OSMC-PL. """ +import ast import csv from dataclasses import dataclass import importlib @@ -1337,62 +1338,38 @@ def setInputs( >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated >>> setInputs(name={"Name1": "value1", "Name2": "value2"}) """ - # inputdata = self._prepare_input_data(raw_input=name) - - if isinstance(name, str): - name1: str = name - name1 = name1.replace(" ", "") - value1 = name1.split("=") - if value1[0] in self.inputlist: - tmpvalue = eval(value1[1]) - if isinstance(tmpvalue, (int, float)): - self.inputlist[value1[0]] = [(float(self._simulateOptions["startTime"]), float(value1[1])), - (float(self._simulateOptions["stopTime"]), float(value1[1]))] - elif isinstance(tmpvalue, list): - self._checkValidInputs(tmpvalue) - self._inputs[value1[0]] = tmpvalue + inputdata = self._prepare_input_data(raw_input=name) + + for key, val in inputdata.items(): + if key in self._inputs: + val_evaluated = ast.literal_eval(val) + if isinstance(val_evaluated, (int, float)): + self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), + (float(self._simulate_options["stopTime"]), float(val))] + elif isinstance(val_evaluated, list): + if not all([isinstance(item, tuple) for item in val_evaluated]): + raise ModelicaSystemError("Value for setInput() must be in tuple format; " + f"got {repr(val_evaluated)}") + if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): + raise ModelicaSystemError("Time value should be in increasing order; " + f"got {repr(val_evaluated)}") + + for item in val_evaluated: + if item[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " + "than the simulation start time") + if len(item) != 2: + raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " + "is in incorrect format!") + + self._inputs[key] = val_evaluated self._inputFlag = True else: - raise ModelicaSystemError(f"{value1[0]} is not an input") - elif isinstance(name, list): - name_list: list[str] = name - for name2 in name_list: - name2 = name2.replace(" ", "") - value2 = name2.split("=") - if value2[0] in self.inputlist: - tmpvalue = eval(value2[1]) - if isinstance(tmpvalue, (int, float)): - self.inputlist[value2[0]] = [(float(self._simulateOptions["startTime"]), float(value2[1])), - (float(self.:simulateOptions["stopTime"]), float(value2[1]))] - elif isinstance(tmpvalue, list): - self._checkValidInputs(tmpvalue) - self._inputs[value2[0]] = tmpvalue - self._inputFlag = True - else: - raise ModelicaSystemError(f"{value2[0]} is not an input!") - elif isinstance(name, dict): - raise NotImplementedError("Must be defined!") + raise ModelicaSystemError(f"{key} is not an input") return True - def _checkValidInputs(self, name): - if name != sorted(name, key=lambda x: x[0]): - raise ModelicaSystemError('Time value should be in increasing order') - for l in name: - if isinstance(l, tuple): - # if l[0] < float(self.simValuesList[0]): - if l[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError('Input time value is less than simulation startTime') - if len(l) != 2: - raise ModelicaSystemError(f'Value for {l} is in incorrect format!') - else: - raise ModelicaSystemError('Error!!! Value must be in tuple format') - - def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path: - """ - 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. - """ + def _createCSVData(self) -> pathlib.Path: start_time: float = float(self._simulate_options["startTime"]) stop_time: float = float(self._simulate_options["stopTime"]) From 55051d95b67560ef977ddb50306dde40400e4ece Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 16:49:52 +0200 Subject: [PATCH 07/64] update tests - use new dict based input for set*() methods --- tests/test_ModelicaSystem.py | 20 ++++++++++---------- tests/test_linearization.py | 2 +- tests/test_optimization.py | 8 +++++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index a7a4b472..d315b627 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -35,8 +35,8 @@ def test_setParameters(): mod = OMPython.ModelicaSystem(model_path + "BouncingBall.mo", "BouncingBall") # method 1 - mod.setParameters("e=1.234") - mod.setParameters("g=321.0") + 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() == { @@ -47,7 +47,7 @@ def test_setParameters(): mod.getParameters("thisParameterDoesNotExist") # method 2 - mod.setParameters(["e=21.3", "g=0.12"]) + mod.setParameters(pvals={"e": 21.3, "g": 0.12}) assert mod.getParameters() == { "e": "21.3", "g": "0.12", @@ -64,8 +64,8 @@ def test_setSimulationOptions(): mod = OMPython.ModelicaSystem(fileName=model_path + "BouncingBall.mo", modelName="BouncingBall") # method 1 - mod.setSimulationOptions("stopTime=1.234") - mod.setSimulationOptions("tolerance=1.1e-08") + 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"] @@ -77,7 +77,7 @@ def test_setSimulationOptions(): mod.getSimulationOptions("thisOptionDoesNotExist") # method 2 - mod.setSimulationOptions(["stopTime=2.1", "tolerance=1.2e-08"]) + mod.setSimulationOptions(simOptions={"stopTime": 2.1, "tolerance": "1.2e-08"}) d = mod.getSimulationOptions() assert d["stopTime"] == "2.1" assert d["tolerance"] == "1.2e-08" @@ -119,7 +119,7 @@ def test_getSolutions(model_firstorder): a = -1 tau = -1 / a stopTime = 5*tau - mod.setSimulationOptions([f"stopTime={stopTime}", "stepSize=0.1", "tolerance=1e-8"]) + mod.setSimulationOptions(simOptions={"stopTime": stopTime, "stepSize": 0.1, "tolerance": 1e-8}) mod.simulate() x = mod.getSolutions("x") @@ -298,7 +298,7 @@ def test_getters(tmp_path): 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(f"stopTime={stopTime}") + mod.setSimulationOptions(simOptions={"stopTime": stopTime}) mod.simulate() # getOutputs after simulate() @@ -327,7 +327,7 @@ def test_getters(tmp_path): mod.getContinuous("a") # a is a parameter with pytest.raises(OMPython.ModelicaSystemError): - mod.setSimulationOptions("thisOptionDoesNotExist=3") + mod.setSimulationOptions(simOptions={"thisOptionDoesNotExist": 3}) def test_simulate_inputs(tmp_path): @@ -345,7 +345,7 @@ def test_simulate_inputs(tmp_path): """) mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="M_input") - mod.setSimulationOptions("stopTime=1.0") + mod.setSimulationOptions(simOptions={"stopTime": 1.0}) # integrate zero (no setInputs call) - it should default to None -> 0 assert mod.getInputs() == { diff --git a/tests/test_linearization.py b/tests/test_linearization.py index 2c79190c..baec6202 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -62,7 +62,7 @@ def test_getters(tmp_path): assert "startTime" in d assert "stopTime" in d assert mod.getLinearizationOptions(["stopTime", "startTime"]) == [d["stopTime"], d["startTime"]] - mod.setLinearizationOptions("stopTime=0.02") + mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) assert mod.getLinearizationOptions("stopTime") == ["0.02"] mod.setInputs(["u1=10", "u2=0"]) diff --git a/tests/test_optimization.py b/tests/test_optimization.py index aa74df79..b4164397 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -35,13 +35,15 @@ def test_optimization_example(tmp_path): mod = OMPython.ModelicaSystem(fileName=model_file.as_posix(), modelName="BangBang2021") - mod.setOptimizationOptions(["numberOfIntervals=16", "stopTime=1", - "stepSize=0.001", "tolerance=1e-8"]) + 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-8", "1"] + assert mod.getOptimizationOptions(["tolerance", "stopTime"]) == ["1e-08", "1"] r = mod.optimize() # it is necessary to specify resultfile, otherwise it wouldn't find it. From 3d8b83a4cedce55879651c3e1b5133594fe436a8 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 17:02:05 +0200 Subject: [PATCH 08/64] [ModelicaSystem] add type hint for return value of isParameterChangeable() --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 985ddeba..aa7576ef 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1223,7 +1223,7 @@ def _set_method_helper( def isParameterChangeable( self, name: str, - ): + ) -> bool: q = self.getQuantities(name) if q[0]["changeable"] == "false": return False From 1b5917c91cb21314cd2899cdc904b0119bbc0417 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 17:09:46 +0200 Subject: [PATCH 09/64] [ModelicaSystem] fix type hint for _prepare_input_data() - use dict[str, Any] --- OMPython/ModelicaSystem.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index aa7576ef..ba130df2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1119,7 +1119,7 @@ def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Op @staticmethod def _prepare_input_data( - raw_input: str | list[str] | dict[str, str | int | float], + raw_input: str | list[str] | dict[str, Any], ) -> dict[str, str]: """ Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}. @@ -1231,7 +1231,7 @@ def isParameterChangeable( def setContinuous( self, - cvals: str | list[str] | dict[str, str | int | float], + cvals: str | list[str] | dict[str, Any], ) -> bool: """ This method is used to set continuous values. It can be called: @@ -1251,7 +1251,7 @@ def setContinuous( def setParameters( self, - pvals: str | list[str] | dict[str, str | int | float], + pvals: str | list[str] | dict[str, Any], ) -> bool: """ This method is used to set parameter values. It can be called: @@ -1271,7 +1271,7 @@ def setParameters( def setSimulationOptions( self, - simOptions: str | list[str] | dict[str, str | int | float], + simOptions: str | list[str] | dict[str, Any], ) -> bool: """ This method is used to set simulation options. It can be called: @@ -1291,7 +1291,7 @@ def setSimulationOptions( def setLinearizationOptions( self, - linearizationOptions: str | list[str] | dict[str, str | int | float], + linearizationOptions: str | list[str] | dict[str, Any], ) -> bool: """ This method is used to set linearization options. It can be called: @@ -1309,7 +1309,10 @@ def setLinearizationOptions( datatype="Linearization-option", overwritedata=None) - def setOptimizationOptions(self, optimizationOptions: str | list[str] | dict[str, str | int | float]) -> bool: + def setOptimizationOptions( + self, + optimizationOptions: str | list[str] | dict[str, Any], + ) -> bool: """ This method is used to set optimization options. It can be called: with a sequence of optimization options name and assigning corresponding values as arguments as show in the example below: @@ -1328,7 +1331,7 @@ def setOptimizationOptions(self, optimizationOptions: str | list[str] | dict[str def setInputs( self, - name: str | list[str] | dict[str, str | int | float], + name: str | list[str] | dict[str, Any], ) -> bool: """ This method is used to set input values. It can be called: From b5766f450f0a0ca27609ef564e9fbf721e48d89a Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 17:14:54 +0200 Subject: [PATCH 10/64] [ModelicaSystem] setInput() - handly input data as list of tuples This method is used to set input values. It can be called with a sequence of input name and assigning corresponding values as arguments as show in the example below. Compared to other set*() methods this is a special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() and restored here via ast.literal_eval(). --- OMPython/ModelicaSystem.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index ba130df2..9a885ddb 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1159,7 +1159,9 @@ def prepare_str(str_in: str) -> dict[str, str]: if isinstance(raw_input, dict): for key, val in raw_input.items(): - str_val = str(val) + # convert all values to strings to align it on one type: dict[str, str] + # spaces have to be removed as setInput() could take list of tuples as input and spaces would + str_val = str(val).replace(' ', '') if ' ' in key or ' ' in str_val: raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!") input_data[key] = str_val @@ -1334,9 +1336,11 @@ def setInputs( name: str | list[str] | dict[str, Any], ) -> bool: """ - This method is used to set input values. It can be called: - with a sequence of input name and assigning corresponding values as arguments as show in the example below: - usage + This method is used to set input values. It can be called with a sequence of input name and assigning + corresponding values as arguments as show in the example below. Compared to other set*() methods this is a + special case as value could be a list of tuples - these are converted to a string in _prepare_input_data() + and restored here via ast.literal_eval(). + >>> setInputs("Name=value") # depreciated >>> setInputs(["Name1=value1","Name2=value2"]) # depreciated >>> setInputs(name={"Name1": "value1", "Name2": "value2"}) @@ -1345,7 +1349,11 @@ def setInputs( for key, val in inputdata.items(): if key in self._inputs: + if not isinstance(val, str): + raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") + val_evaluated = ast.literal_eval(val) + if isinstance(val_evaluated, (int, float)): self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), (float(self._simulate_options["stopTime"]), float(val))] From 9ef9d69bc1177f60a4602fd01d012bc317b27259 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 23 Jun 2025 17:15:43 +0200 Subject: [PATCH 11/64] update tests - use new dict based input for setInput() method --- tests/test_ModelicaSystem.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index d315b627..b7e115cd 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -357,7 +357,7 @@ def test_simulate_inputs(tmp_path): assert np.isclose(y[-1], 0.0) # integrate a constant - mod.setInputs("u1=2.5") + mod.setInputs(name={"u1": 2.5}) assert mod.getInputs() == { "u1": [ (0.0, 2.5), @@ -370,7 +370,7 @@ def test_simulate_inputs(tmp_path): assert np.isclose(y[-1], 2.5) # now let's integrate the sum of two ramps - mod.setInputs("u1=[(0.0, 0.0), (0.5, 2), (1.0, 0)]") + 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), @@ -383,19 +383,17 @@ def test_simulate_inputs(tmp_path): # let's try some edge cases # unmatched startTime with pytest.raises(OMPython.ModelicaSystemError): - mod.setInputs("u1=[(-0.5, 0.0), (1.0, 1)]") + mod.setInputs(name={"u1": [(-0.5, 0.0), (1.0, 1)]}) mod.simulate() # unmatched stopTime with pytest.raises(OMPython.ModelicaSystemError): - mod.setInputs("u1=[(0.0, 0.0), (0.5, 1)]") + 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 of # samples. This has an effect when generating the csv file. - mod.setInputs([ - "u1=[(0.0, 0), (1.0, 1)]", - "u2=[(0.0, 0), (0.25, 0.5), (0.5, 1.0), (1.0, 0)]", - ]) + 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 From 2fc67b921fb548fa6b53e5ddb01b08cef0d31095 Mon Sep 17 00:00:00 2001 From: syntron Date: Tue, 24 Jun 2025 21:31:29 +0200 Subject: [PATCH 12/64] [test_linearization] fix setInput() call --- tests/test_linearization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_linearization.py b/tests/test_linearization.py index baec6202..6af565c6 100644 --- a/tests/test_linearization.py +++ b/tests/test_linearization.py @@ -65,7 +65,7 @@ def test_getters(tmp_path): mod.setLinearizationOptions(linearizationOptions={"stopTime": 0.02}) assert mod.getLinearizationOptions("stopTime") == ["0.02"] - mod.setInputs(["u1=10", "u2=0"]) + mod.setInputs(name={"u1": 10, "u2": 0}) [A, B, C, D] = mod.linearize() g = float(mod.getParameters("g")[0]) l = float(mod.getParameters("l")[0]) From bbd8740ce48698a2c817172ff0df05af082dcfb0 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 26 Jun 2025 20:34:19 +0200 Subject: [PATCH 13/64] [ModelicaSystem] simplify _set_method_helper() --- OMPython/ModelicaSystem.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 9a885ddb..58f9c2a1 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1200,24 +1200,24 @@ def _set_method_helper( inputdata_status: dict[str, bool] = {} for key, val in inputdata.items(): - status = False - if key in classdata: - if datatype == "parameter" and not self.isParameterChangeable(key): - logger.debug(f"It is not possible to set the parameter {repr(key)}. It seems to be " - "structural, final, protected, evaluated or has a non-constant binding. " - "Use sendExpression(...) and rebuild the model using buildModel() API; example: " - "sendExpression(\"setParameterValue(" - f"{self.modelName}, {key}, {val if val is not None else ''}" - ")\") ") - else: - classdata[key] = val - if overwritedata is not None: - overwritedata[key] = val - status = True - else: + if key not in classdata: raise ModelicaSystemError("Unhandled case in setMethodHelper.apply_single() - " f"{repr(key)} is not a {repr(datatype)} variable") + status = False + if datatype == "parameter" and not self.isParameterChangeable(key): + logger.debug(f"It is not possible to set the parameter {repr(key)}. It seems to be " + "structural, final, protected, evaluated or has a non-constant binding. " + "Use sendExpression(...) and rebuild the model using buildModel() API; example: " + "sendExpression(\"setParameterValue(" + f"{self.modelName}, {key}, {val if val is not None else ''}" + ")\") ") + else: + classdata[key] = val + if overwritedata is not None: + overwritedata[key] = val + status = True + inputdata_status[key] = status return all(inputdata_status.values()) From 675a6480c14405e2617cca0f5395f12d2fd019cc Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 26 Jun 2025 21:14:55 +0200 Subject: [PATCH 14/64] [ModelicaSystem] improve setInputs() - reduce spaces / cleanup --- OMPython/ModelicaSystem.py | 57 +++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 58f9c2a1..377390d0 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1348,36 +1348,37 @@ def setInputs( inputdata = self._prepare_input_data(raw_input=name) for key, val in inputdata.items(): - if key in self._inputs: - if not isinstance(val, str): - raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") - - val_evaluated = ast.literal_eval(val) - - if isinstance(val_evaluated, (int, float)): - self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), - (float(self._simulate_options["stopTime"]), float(val))] - elif isinstance(val_evaluated, list): - if not all([isinstance(item, tuple) for item in val_evaluated]): - raise ModelicaSystemError("Value for setInput() must be in tuple format; " - f"got {repr(val_evaluated)}") - if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): - raise ModelicaSystemError("Time value should be in increasing order; " - f"got {repr(val_evaluated)}") - - for item in val_evaluated: - if item[0] < float(self._simulate_options["startTime"]): - raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " - "than the simulation start time") - if len(item) != 2: - raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " - "is in incorrect format!") - - self._inputs[key] = val_evaluated - self._inputFlag = True - else: + if key not in self._inputs: raise ModelicaSystemError(f"{key} is not an input") + if not isinstance(val, str): + raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}") + + val_evaluated = ast.literal_eval(val) + + if isinstance(val_evaluated, (int, float)): + self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)), + (float(self._simulate_options["stopTime"]), float(val))] + elif isinstance(val_evaluated, list): + if not all([isinstance(item, tuple) for item in val_evaluated]): + raise ModelicaSystemError("Value for setInput() must be in tuple format; " + f"got {repr(val_evaluated)}") + if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]): + raise ModelicaSystemError("Time value should be in increasing order; " + f"got {repr(val_evaluated)}") + + for item in val_evaluated: + if item[0] < float(self._simulate_options["startTime"]): + raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less " + "than the simulation start time") + if len(item) != 2: + raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} " + "is in incorrect format!") + + self._inputs[key] = val_evaluated + else: + raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") + return True def _createCSVData(self) -> pathlib.Path: From 9fcd39a5baf7aceb563a176f356a544df419dfe2 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 7 Jul 2025 20:20:02 +0200 Subject: [PATCH 15/64] [ModelicaSystem] fix rebase fallout --- OMPython/ModelicaSystem.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 377390d0..11b09daf 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1210,7 +1210,7 @@ def _set_method_helper( "structural, final, protected, evaluated or has a non-constant binding. " "Use sendExpression(...) and rebuild the model using buildModel() API; example: " "sendExpression(\"setParameterValue(" - f"{self.modelName}, {key}, {val if val is not None else ''}" + f"{self._model_name}, {key}, {val if val is not None else ''}" ")\") ") else: classdata[key] = val @@ -1247,9 +1247,9 @@ def setContinuous( return self._set_method_helper( inputdata=inputdata, - classdata=self.continuouslist, + classdata=self._continuous, datatype="continuous", - overwritedata=self.overridevariables) + overwritedata=self._override_variables) def setParameters( self, @@ -1267,9 +1267,9 @@ def setParameters( return self._set_method_helper( inputdata=inputdata, - classdata=self.paramlist, + classdata=self._params, datatype="parameter", - overwritedata=self.overridevariables) + overwritedata=self._override_variables) def setSimulationOptions( self, @@ -1287,9 +1287,9 @@ def setSimulationOptions( return self._set_method_helper( inputdata=inputdata, - classdata=self.simulateOptions, + classdata=self._simulate_options, datatype="simulation-option", - overwritedata=self.simoptionsoverride) + overwritedata=self._simulate_options_override) def setLinearizationOptions( self, @@ -1307,7 +1307,7 @@ def setLinearizationOptions( return self._set_method_helper( inputdata=inputdata, - classdata=self.linearOptions, + classdata=self._linearization_options, datatype="Linearization-option", overwritedata=None) @@ -1327,7 +1327,7 @@ def setOptimizationOptions( return self._set_method_helper( inputdata=inputdata, - classdata=self.optimizeOptions, + classdata=self._optimization_options, datatype="optimization-option", overwritedata=None) From 92651c5d853a3a3569c56cd18710df47df806338 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 7 Jul 2025 20:43:02 +0200 Subject: [PATCH 16/64] [ModelicaSystem] fix rebase fallout 2 --- OMPython/ModelicaSystem.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 11b09daf..16be70b1 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1379,6 +1379,8 @@ def setInputs( else: raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") + self._has_inputs = True + return True def _createCSVData(self) -> pathlib.Path: From 71aa67b28760c42ceb658e65dc162ed76e2c9fda Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 28 Jun 2025 20:44:15 +0200 Subject: [PATCH 17/64] [OMCPath] add class --- OMPython/OMCSession.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index ac99dc05..41a96a67 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -268,6 +268,31 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) +class OMCPath(pathlib.PurePosixPath): + """ + Implementation of a basic Path object which uses OMC as backend. The connection to OMC is provided via a + OMCSessionZMQ session object. + """ + + def __init__(self, *path, session: OMCSessionZMQ): + super().__init__(*path) + self._session = session + + def with_segments(self, *pathsegments): + # overwrite this function of PurePosixPath to ensure session is set + return type(self)(*pathsegments, session=self._session) + + # TODO: implement needed methods from pathlib._abc.PathBase: + # is_dir() + # is_file() + # read_text() + binary()? + # write_text() + binary()? + # unlink() + # resolve() + # ... more ... + # ??? test if local (write OMC => READ local and the other way) and use shortcuts ??? + + class OMCSessionZMQ: def __init__( @@ -322,6 +347,9 @@ def __del__(self): self.omc_zmq = None + def omcpath(self, *path) -> OMCPath: + return OMCPath(*path, session=self) + def execute(self, command: str): warnings.warn("This function is depreciated and will be removed in future versions; " "please use sendExpression() instead", DeprecationWarning, stacklevel=2) From 28585c7f71cad5996a6b1c1606c75963d2180093 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 27 Jun 2025 22:53:08 +0200 Subject: [PATCH 18/64] [OMCPath] add implementation using OMC via sendExpression() --- OMPython/OMCSession.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 41a96a67..fb865ba4 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -282,12 +282,27 @@ def with_segments(self, *pathsegments): # overwrite this function of PurePosixPath to ensure session is set return type(self)(*pathsegments, session=self._session) + def is_file(self) -> bool: + return self._session.sendExpression(f'regularFileExists("{self.as_posix()}")') + + def is_dir(self) -> bool: + return self._session.sendExpression(f'directoryExists("{self.as_posix()}")') + + def read_text(self) -> str: + return self._session.sendExpression(f'readFile("{self.as_posix()}")') + + def write_text(self, data: str) -> bool: + return self._session.sendExpression(f'writeFile("{self.as_posix()}", "{data}", false)') + + def unlink(self) -> bool: + return self._session.sendExpression(f'deleteFile("{self.as_posix()}")') + # TODO: implement needed methods from pathlib._abc.PathBase: - # is_dir() - # is_file() - # read_text() + binary()? - # write_text() + binary()? - # unlink() + # OK - is_dir() + # OK - is_file() + # OK - read_text() + binary()? + # OK - write_text() + binary()? + # OK - unlink() # resolve() # ... more ... # ??? test if local (write OMC => READ local and the other way) and use shortcuts ??? From 3ce71b9c7ad2b15ecfb0265c878f093ac06596be Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 28 Jun 2025 20:47:05 +0200 Subject: [PATCH 19/64] [OMCPath] add pytest (only docker at the moment) --- tests/test_OMCPath.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_OMCPath.py diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py new file mode 100644 index 00000000..106f1cc7 --- /dev/null +++ b/tests/test_OMCPath.py @@ -0,0 +1,26 @@ +import OMPython + + +def test_OMCPath_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" + + p1 = om.omcpath('/tmp') + assert str(p1) == "/tmp" + p2 = p1 / 'test.txt' + assert str(p2) == "/tmp/test.txt" + assert p2.write_text('test') + assert p2.read_text() == "test" + assert p2.is_file() + assert p2.parent.is_dir() + assert p2.unlink() + assert p2.is_file() == False + + del omcp + del om + + +if __name__ == '__main__': + test_OMCPath_docker() + print('DONE') \ No newline at end of file From e452c9817e3691cc14ed8b2fae282b79e75553d2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 28 Jun 2025 20:47:15 +0200 Subject: [PATCH 20/64] [OMCPath] TODO items --- OMPython/OMCSession.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index fb865ba4..a03b7f57 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -274,6 +274,10 @@ class OMCPath(pathlib.PurePosixPath): OMCSessionZMQ session object. """ + # TODO: need to handle PurePosixPath and PureWindowsPath + # PureOMCPath => OMCPathPosix(PureOMCPath, PurePosixPath) + # => OMCPathWindows(PureOMCPath, PureWindowsPath) + def __init__(self, *path, session: OMCSessionZMQ): super().__init__(*path) self._session = session @@ -363,6 +367,8 @@ def __del__(self): self.omc_zmq = None def omcpath(self, *path) -> OMCPath: + # TODO: need to handle PurePosixPath and PureWindowsPath + # define it here based on the backend (omc_process) used? return OMCPath(*path, session=self) def execute(self, command: str): From a26218973db5be6686700cdc23b3cb4a93fad18e Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 2 Jul 2025 22:34:46 +0200 Subject: [PATCH 21/64] [test_OMCPath] mypy fix --- tests/test_OMCPath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 106f1cc7..bae41714 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -15,7 +15,7 @@ def test_OMCPath_docker(): assert p2.is_file() assert p2.parent.is_dir() assert p2.unlink() - assert p2.is_file() == False + assert p2.is_file() is False del omcp del om From 70a9c6596867d808c2d3d1cf93efcce2301fd7a5 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 2 Jul 2025 22:52:38 +0200 Subject: [PATCH 22/64] [test_OMCPath] fix end of file --- tests/test_OMCPath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index bae41714..c0249df6 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -23,4 +23,4 @@ def test_OMCPath_docker(): if __name__ == '__main__': test_OMCPath_docker() - print('DONE') \ No newline at end of file + print('DONE') From 9240ff68c96713f82257ad30af0e5c65878a85d4 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 3 Jul 2025 09:21:35 +0200 Subject: [PATCH 23/64] [test_OMCPath] define test using OMCSessionZMQ() locally --- tests/test_OMCPath.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index c0249df6..9621b54b 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -1,6 +1,8 @@ import OMPython +import pytest +@pytest.mark.skip(reason="This test would fail (no docker on github)") def test_OMCPath_docker(): omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") om = OMPython.OMCSessionZMQ(omc_process=omcp) @@ -21,6 +23,23 @@ def test_OMCPath_docker(): del om +def test_OMCPath_local(): + om = OMPython.OMCSessionZMQ() + + p1 = om.omcpath('/tmp') + assert str(p1) == "/tmp" + p2 = p1 / 'test.txt' + assert str(p2) == "/tmp/test.txt" + assert p2.write_text('test') + assert p2.read_text() == "test" + assert p2.is_file() + assert p2.parent.is_dir() + assert p2.unlink() + assert p2.is_file() is False + + del om + + if __name__ == '__main__': test_OMCPath_docker() print('DONE') From 7af3eb72c7feef9280d2fc0988054bf7dad45dbc Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 6 Jul 2025 21:54:01 +0200 Subject: [PATCH 24/64] add TODO - need to check Python versions * not working: 3.10 * working: 3.12 --- OMPython/OMCSession.py | 1 + 1 file changed, 1 insertion(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index a03b7f57..f0505383 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -277,6 +277,7 @@ class OMCPath(pathlib.PurePosixPath): # TODO: need to handle PurePosixPath and PureWindowsPath # PureOMCPath => OMCPathPosix(PureOMCPath, PurePosixPath) # => OMCPathWindows(PureOMCPath, PureWindowsPath) + # TODO: only working for Python 3.12+ (not working for 3.10!; 3.11?) def __init__(self, *path, session: OMCSessionZMQ): super().__init__(*path) From d5445c6c64cbaa370aadd782aa49547d11571201 Mon Sep 17 00:00:00 2001 From: syntron Date: Sun, 6 Jul 2025 21:54:24 +0200 Subject: [PATCH 25/64] [test_OMCPath] activate docker based on test_docker --- tests/test_OMCPath.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 9621b54b..bef2b473 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -1,8 +1,14 @@ +import sys import OMPython import pytest +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) -@pytest.mark.skip(reason="This test would fail (no docker on github)") + +@skip_on_windows def test_OMCPath_docker(): omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") om = OMPython.OMCSessionZMQ(omc_process=omcp) From 4c9f0ca4ce3a319325e62b2c38b8f1ec7a586841 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 9 Jul 2025 21:42:19 +0200 Subject: [PATCH 26/64] [ModelicaSystem.linearize] do not execute python file but use ast to get the data --- OMPython/ModelicaSystem.py | 78 +++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d0178cf2..4ac70753 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -32,9 +32,9 @@ CONDITIONS OF OSMC-PL. """ +import ast import csv from dataclasses import dataclass -import importlib import logging import numbers import numpy as np @@ -1438,14 +1438,6 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N compatibility, because linearize() used to return `[A, B, C, D]`. """ - # replacement for depreciated importlib.load_module() - def load_module_from_path(module_name, file_path): - spec = importlib.util.spec_from_file_location(module_name, file_path) - module_def = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module_def) - - return module_def - if self._xml_file is None: raise ModelicaSystemError( "Linearization cannot be performed as the model is not build, " @@ -1484,38 +1476,62 @@ def load_module_from_path(module_name, file_path): if simargs: om_cmd.args_set(args=simargs) + # the file create by the model executable which contains the matrix and linear inputs, outputs and states + linear_file = self._tempdir / "linearized_model.py" + + linear_file.unlink(missing_ok=True) + returncode = om_cmd.run() if returncode != 0: raise ModelicaSystemError(f"Linearize failed with return code: {returncode}") self._simulated = True - # code to get the matrix and linear inputs, outputs and states - linearFile = self._tempdir / "linearized_model.py" + if not linear_file.exists(): + raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") # support older openmodelica versions before OpenModelica v1.16.2 where linearize() generates "linear_model_name.mo" file - if not linearFile.exists(): - linearFile = pathlib.Path(f'linear_{self._model_name}.py') - - if not linearFile.exists(): - raise ModelicaSystemError(f"Linearization failed: {linearFile} not found!") + if not linear_file.exists(): + linear_file = pathlib.Path(f'linear_{self._model_name}.py') - # this function is called from the generated python code linearized_model.py at runtime, - # to improve the performance by directly reading the matrices A, B, C and D from the julia code and avoid building the linearized modelica model + # extract data from the python file with the linearized model using the ast module - this allows to get the + # needed information without executing the created code + linear_data = {} + linear_file_content = linear_file.read_text() try: - # do not add the linearfile directory to path, as multiple execution of linearization will always use the first added path, instead execute the file - # https://github.com/OpenModelica/OMPython/issues/196 - module = load_module_from_path(module_name="linearized_model", file_path=linearFile.as_posix()) - - result = module.linearized_model() - (n, m, p, x0, u0, A, B, C, D, stateVars, inputVars, outputVars) = result - self._linearized_inputs = inputVars - self._linearized_outputs = outputVars - self._linearized_states = stateVars - return LinearizationResult(n, m, p, A, B, C, D, x0, u0, stateVars, - inputVars, outputVars) - except ModuleNotFoundError as ex: - raise ModelicaSystemError("No module named 'linearized_model'") from ex + linear_file_ast = ast.parse(linear_file_content) + for body_part in linear_file_ast.body[0].body: + if not isinstance(body_part, ast.Assign): + continue + + target = body_part.targets[0].id + value = ast.literal_eval(body_part.value) + + linear_data[target] = value + except (AttributeError, IndexError, ValueError, SyntaxError, TypeError) as ex: + raise ModelicaSystemError(f"Error parsing linearization file {linear_file}!") from ex + + # remove the file + linear_file.unlink() + + self._linearized_inputs = linear_data["inputVars"] + self._linearized_outputs = linear_data["outputVars"] + self._linearized_states = linear_data["stateVars"] + + return LinearizationResult( + n=linear_data["n"], + m=linear_data["m"], + p=linear_data["p"], + x0=linear_data["x0"], + u0=linear_data["u0"], + A=linear_data["A"], + B=linear_data["B"], + C=linear_data["C"], + D=linear_data["D"], + stateVars=linear_data["stateVars"], + inputVars=linear_data["inputVars"], + outputVars=linear_data["outputVars"], + ) def getLinearInputs(self) -> list[str]: """Get names of input variables of the linearized model.""" From 191cd59a0cade1a19924cc3d563e38136c3d8110 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 9 Jul 2025 21:44:17 +0200 Subject: [PATCH 27/64] [ModelicaSystem.linearize] remove old check / use of file in current dir --- OMPython/ModelicaSystem.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 4ac70753..934ae77e 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1490,10 +1490,6 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N if not linear_file.exists(): raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") - # support older openmodelica versions before OpenModelica v1.16.2 where linearize() generates "linear_model_name.mo" file - if not linear_file.exists(): - linear_file = pathlib.Path(f'linear_{self._model_name}.py') - # extract data from the python file with the linearized model using the ast module - this allows to get the # needed information without executing the created code linear_data = {} From 2802d9f988efdf6e4cd852f12fc2284f180702ab Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 10 Jul 2025 20:31:17 +0200 Subject: [PATCH 28/64] [ModelicaSystem.linearize] fix mypy --- OMPython/ModelicaSystem.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 934ae77e..c87f18be 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1495,12 +1495,13 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N linear_data = {} linear_file_content = linear_file.read_text() try: + # ignore possible typing errors below (mypy) - these are catched by the try .. except .. block linear_file_ast = ast.parse(linear_file_content) - for body_part in linear_file_ast.body[0].body: + for body_part in linear_file_ast.body[0].body: # type: ignore if not isinstance(body_part, ast.Assign): continue - target = body_part.targets[0].id + target = body_part.targets[0].id # type: ignore value = ast.literal_eval(body_part.value) linear_data[target] = value From d6958995d3b5d431df63c9035082983a3b67d454 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 7 Jul 2025 20:48:50 +0200 Subject: [PATCH 29/64] [ModelicaSystem] remove _has_inputs - is defined by _inputs empty or not --- OMPython/ModelicaSystem.py | 41 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 16be70b1..48268a28 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -388,7 +388,6 @@ def __init__( self._lmodel = lmodel # may be needed if model is derived from other model self._model_name = modelName # Model class name self._file_name = pathlib.Path(fileName).resolve() if fileName is not None else None # Model file/package name - self._has_inputs = False # for model with input quantity self._simulated = False # True if the model has already been simulated self._result_file: Optional[pathlib.Path] = None # for storing result file self._variable_filter = variableFilter @@ -967,22 +966,20 @@ def simulate_cmd( om_cmd.arg_set(key="overrideFile", val=overrideFile.as_posix()) - if self._has_inputs: # if model has input quantities - # csvfile is based on name used for result file - csvfile = result_file.parent / f"{result_file.stem}.csv" - - for i in self._inputs: - val = self._inputs[i] + if self._inputs: # if model has input quantities + for key in self._inputs: + val = self._inputs[key] if val is None: val = [(float(self._simulate_options["startTime"]), 0.0), (float(self._simulate_options["stopTime"]), 0.0)] - self._inputs[i] = [(float(self._simulate_options["startTime"]), 0.0), - (float(self._simulate_options["stopTime"]), 0.0)] + self._inputs[key] = val if float(self._simulate_options["startTime"]) != val[0][0]: - raise ModelicaSystemError(f"startTime not matched for Input {i}!") + raise ModelicaSystemError(f"startTime not matched for Input {key}!") if float(self._simulate_options["stopTime"]) != val[-1][0]: - raise ModelicaSystemError(f"stopTime not matched for Input {i}!") + raise ModelicaSystemError(f"stopTime not matched for Input {key}!") + # csvfile is based on name used for result file + csvfile = result_file.parent / f"{result_file.stem}.csv" # write csv file and store the name csvfile = self._createCSVData(csvfile=csvfile) @@ -1379,11 +1376,13 @@ def setInputs( else: raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}") - self._has_inputs = True - return True - def _createCSVData(self) -> pathlib.Path: + def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path: + """ + Create a csv file with inputs for the simulation/optimization of the model. If csvfile is provided as argument, + this file is used; else a generic file name is created. + """ start_time: float = float(self._simulate_options["startTime"]) stop_time: float = float(self._simulate_options["stopTime"]) @@ -1567,13 +1566,13 @@ def load_module_from_path(module_name, file_path): om_cmd.arg_set(key="overrideFile", val=overrideLinearFile.as_posix()) - if self._has_inputs: - nameVal = self.getInputs() - for n in nameVal: - tupleList = nameVal.get(n) - if tupleList is not None: - for l in tupleList: - if l[0] < float(self._simulate_options["startTime"]): + inputs = self.getInputs() + if inputs: + for key in inputs: + data = inputs[key] + if data is not None: + for value in data: + if value[0] < float(self._simulate_options["startTime"]): raise ModelicaSystemError('Input time value is less than simulation startTime') csvfile = self._createCSVData() om_cmd.arg_set(key="csvInput", val=csvfile.as_posix()) From 4fa05f1146036a5ea80cc8c3762714cee6fbfbe4 Mon Sep 17 00:00:00 2001 From: syntron Date: Mon, 7 Jul 2025 21:09:36 +0200 Subject: [PATCH 30/64] [test_ModelicaSystem] cleanup --- tests/test_ModelicaSystem.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index b7e115cd..8e9b8a8e 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -363,7 +363,11 @@ def test_simulate_inputs(tmp_path): (0.0, 2.5), (1.0, 2.5), ], - "u2": None, + # 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] @@ -390,7 +394,7 @@ def test_simulate_inputs(tmp_path): 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 of + # 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)]}) From ab0beae5d917024913115e991a3015977948958b Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 19:04:20 +0200 Subject: [PATCH 31/64] [ModelicaSystem] add spelling fix (fox codespell) --- OMPython/ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index c87f18be..ed387f51 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1495,7 +1495,7 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N linear_data = {} linear_file_content = linear_file.read_text() try: - # ignore possible typing errors below (mypy) - these are catched by the try .. except .. block + # ignore possible typing errors below (mypy) - these are caught by the try .. except .. block linear_file_ast = ast.parse(linear_file_content) for body_part in linear_file_ast.body[0].body: # type: ignore if not isinstance(body_part, ast.Assign): From f10e4e0c5bf42b61baeefd4927c6266a04b96ed2 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 9 Jul 2025 20:26:04 +0200 Subject: [PATCH 32/64] [ModelicaSystem] update handling of xml_file --- OMPython/ModelicaSystem.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index d0178cf2..5167d79e 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -481,8 +481,10 @@ def buildModel(self, variableFilter: Optional[str] = None): buildModelResult = self._requestApi("buildModel", self._model_name, properties=varFilter) logger.debug("OM model build result: %s", buildModelResult) - self._xml_file = pathlib.Path(buildModelResult[0]).parent / buildModelResult[1] - self._xmlparse() + xml_file = pathlib.Path(buildModelResult[0]).parent / buildModelResult[1] + self._xmlparse(xml_file=xml_file) + # TODO: remove _xml_file?! + self._xml_file = xml_file def sendExpression(self, expr: str, parsed: bool = True): try: @@ -508,11 +510,11 @@ def _requestApi(self, apiName, entity=None, properties=None): # 2 return self.sendExpression(exp) - def _xmlparse(self): - if not self._xml_file.is_file(): - raise ModelicaSystemError(f"XML file not generated: {self._xml_file}") + def _xmlparse(self, xml_file: pathlib.Path): + if not xml_file.is_file(): + raise ModelicaSystemError(f"XML file not generated: {xml_file}") - tree = ET.parse(self._xml_file) + tree = ET.parse(xml_file) rootCQ = tree.getroot() for attr in rootCQ.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", From 4ed4bc9961687fcc95a24770a5631190523a6d90 Mon Sep 17 00:00:00 2001 From: syntron Date: Wed, 9 Jul 2025 20:26:42 +0200 Subject: [PATCH 33/64] [ModelicaSystem] replace ET.parse() with ET.ElementTree(ET.fromstring()) read the file content and work on this string see: https://stackoverflow.com/questions/647071/python-xml-elementtree-from-a-string-source --- OMPython/ModelicaSystem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 5167d79e..c8476a25 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -514,7 +514,8 @@ def _xmlparse(self, xml_file: pathlib.Path): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") - tree = ET.parse(xml_file) + xml_content = xml_file.read_text() + tree = ET.ElementTree(ET.fromstring(xml_content)) rootCQ = tree.getroot() for attr in rootCQ.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", From 111326fb15516de280d766ee6a0886f16682a64e Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 10 Jul 2025 09:59:04 +0200 Subject: [PATCH 34/64] [ModelicaSystem._xmlparse] mypy fixes & cleanup --- OMPython/ModelicaSystem.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index c8476a25..e7238d66 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -520,14 +520,24 @@ def _xmlparse(self, xml_file: pathlib.Path): for attr in rootCQ.iter('DefaultExperiment'): for key in ("startTime", "stopTime", "stepSize", "tolerance", "solver", "outputFormat"): - self._simulate_options[key] = attr.get(key) + self._simulate_options[key] = str(attr.get(key)) for sv in rootCQ.iter('ScalarVariable'): - scalar = {} - for key in ("name", "description", "variability", "causality", "alias"): - scalar[key] = sv.get(key) - scalar["changeable"] = sv.get('isValueChangeable') - scalar["aliasvariable"] = sv.get('aliasVariable') + translations = { + "alias": "alias", + "aliasvariable": "aliasVariable", + "causality": "causality", + "changeable": "isValueChangeable", + "description": "description", + "name": "name", + "variability": "variability", + } + + scalar: dict[str, Any] = {} + for key_dst, key_src in translations.items(): + val = sv.get(key_src) + scalar[key_dst] = None if val is None else str(val) + ch = list(sv) for att in ch: scalar["start"] = att.get('start') @@ -535,6 +545,7 @@ def _xmlparse(self, xml_file: pathlib.Path): scalar["max"] = att.get('max') scalar["unit"] = att.get('unit') + # save parameters in the corresponding class variables if scalar["variability"] == "parameter": if scalar["name"] in self._override_variables: self._params[scalar["name"]] = self._override_variables[scalar["name"]] From f958964b754a496ef4042006133785999d7f38f8 Mon Sep 17 00:00:00 2001 From: syntron Date: Thu, 10 Jul 2025 10:03:53 +0200 Subject: [PATCH 35/64] [ModelicaSystem] remove class variable _xml_file --- OMPython/ModelicaSystem.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e7238d66..cc4a7c8b 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -383,7 +383,6 @@ def __init__( if not isinstance(lmodel, list): raise ModelicaSystemError(f"Invalid input type for lmodel: {type(lmodel)} - list expected!") - self._xml_file = None self._lmodel = lmodel # may be needed if model is derived from other model self._model_name = modelName # Model class name self._file_name = pathlib.Path(fileName).resolve() if fileName is not None else None # Model file/package name @@ -483,8 +482,6 @@ def buildModel(self, variableFilter: Optional[str] = None): xml_file = pathlib.Path(buildModelResult[0]).parent / buildModelResult[1] self._xmlparse(xml_file=xml_file) - # TODO: remove _xml_file?! - self._xml_file = xml_file def sendExpression(self, expr: str, parsed: bool = True): try: @@ -1460,7 +1457,8 @@ def load_module_from_path(module_name, file_path): return module_def - if self._xml_file is None: + if len(self._quantities) == 0: + # if self._quantities has no content, the xml file was not parsed; see self._xmlparse() raise ModelicaSystemError( "Linearization cannot be performed as the model is not build, " "use ModelicaSystem() to build the model first" From aa74b367f0fa35b81905d646bbf1beefd3a89595 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:18:56 +0200 Subject: [PATCH 36/64] [OMCPath] add more functionality and docstrings --- OMPython/OMCSession.py | 125 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index f0505383..d8a17af7 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -284,23 +284,138 @@ def __init__(self, *path, session: OMCSessionZMQ): self._session = session def with_segments(self, *pathsegments): - # overwrite this function of PurePosixPath to ensure session is set + """ + Create a new OMCPath object with the given path segments. + + The original definition of Path is overridden to ensure session is set. + """ return type(self)(*pathsegments, session=self._session) def is_file(self) -> bool: + """ + Check if the path is a regular file. + """ return self._session.sendExpression(f'regularFileExists("{self.as_posix()}")') def is_dir(self) -> bool: + """ + Check if the path is a directory. + """ return self._session.sendExpression(f'directoryExists("{self.as_posix()}")') - def read_text(self) -> str: + def read_text(self, encoding=None, errors=None) -> str: + """ + Read the content of the file represented by this path as text. + + The additional arguments `encoding` and `errors` are only defined for compatibility with Path() definitions. + """ return self._session.sendExpression(f'readFile("{self.as_posix()}")') - def write_text(self, data: str) -> bool: + def write_text(self, data: str, encoding=None, errors=None, newline=None) -> bool: + """ + 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('data must be str, not %s' % + data.__class__.__name__) + return self._session.sendExpression(f'writeFile("{self.as_posix()}", "{data}", false)') - def unlink(self) -> bool: - return self._session.sendExpression(f'deleteFile("{self.as_posix()}")') + def mkdir(self, mode=0o777, parents=False, exist_ok=False): + """ + Create a directory at the path represented by this OMCPath object. + + The additional arguments `mode`, and `parents` are only defined for compatibility with Path() definitions. + """ + if self.is_dir() and not exist_ok: + raise FileExistsError(f"Directory {self.as_posix()} already exists!") + + return self._session.sendExpression(f'mkdir("{self.as_posix()}")') + + def cwd(self): + """ + Returns the current working directory as an OMCPath object. + """ + cwd_str = self._session.sendExpression('cd()') + return OMCPath(cwd_str, session=self._session) + + def unlink(self, missing_ok: bool = False) -> bool: + """ + Unlink (delete) the file or directory represented by this path. + """ + res = self._session.sendExpression(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!") + return res + + def resolve(self, strict: bool = False) -> OMCPath: + """ + 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(): + omcpath = self._omc_resolve(self.parent.as_posix()) / self.name + elif self.is_dir(): + omcpath = self._omc_resolve(self.as_posix()) + else: + raise OMCSessionException(f"Path {self.as_posix()} is neither a file nor a directory!") + + return omcpath + + def _omc_resolve(self, pathstr: str) -> OMCPath: + """ + 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)') + + try: + result = self._session.sendExpression(expression) + result_parts = result.split('\n') + pathstr_resolved = result_parts[1] + pathstr_resolved = pathstr_resolved[1:-1] # remove quotes + + omcpath_resolved = self._session.omcpath(pathstr_resolved) + except OMCSessionException as ex: + raise OMCSessionException(f"OMCPath resolve failed for {pathstr}!") from ex + + if not omcpath_resolved.is_file() and not omcpath_resolved.is_dir(): + raise OMCSessionException(f"OMCPath resolve failed for {pathstr} - path does not exist!") + + return omcpath_resolved + + def absolute(self) -> OMCPath: + """ + 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) -> 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 a extra function and the best we can do using OMC. + """ + if not self.is_file(): + raise OMCSessionException(f"Path {self.as_posix()} is not a file!") + + res = self._session.sendExpression(f'stat("{self.as_posix()}")') + if res[0]: + return int(res[1]) + + raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") # TODO: implement needed methods from pathlib._abc.PathBase: # OK - is_dir() From 89cf543b10030d7c4e482b19dcbfa7e3592547d2 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:19:51 +0200 Subject: [PATCH 37/64] [OMCPath] remove TODO entries --- OMPython/OMCSession.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index d8a17af7..3cd38475 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -274,11 +274,6 @@ class OMCPath(pathlib.PurePosixPath): OMCSessionZMQ session object. """ - # TODO: need to handle PurePosixPath and PureWindowsPath - # PureOMCPath => OMCPathPosix(PureOMCPath, PurePosixPath) - # => OMCPathWindows(PureOMCPath, PureWindowsPath) - # TODO: only working for Python 3.12+ (not working for 3.10!; 3.11?) - def __init__(self, *path, session: OMCSessionZMQ): super().__init__(*path) self._session = session @@ -417,15 +412,6 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") - # TODO: implement needed methods from pathlib._abc.PathBase: - # OK - is_dir() - # OK - is_file() - # OK - read_text() + binary()? - # OK - write_text() + binary()? - # OK - unlink() - # resolve() - # ... more ... - # ??? test if local (write OMC => READ local and the other way) and use shortcuts ??? class OMCSessionZMQ: From 8e1745c883a57b8cae33061f708bd478a0527a1e Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:22:17 +0200 Subject: [PATCH 38/64] [OMCPath] define limited compatibility for Python < 3.12 * use modified pathlib.Path as OMCPath --- OMPython/OMCSession.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 3cd38475..966709bc 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -413,6 +413,15 @@ def size(self) -> int: raise OMCSessionException(f"Error reading file size for path {self.as_posix()}!") +if sys.version_info < (3, 12): + class OMCPathCompatibility(pathlib.Path): + + def size(self) -> int: + return self.stat().st_size + + + OMCPath = OMCPathCompatibility + class OMCSessionZMQ: From e793a93ca02eaf270ea10e23c15340d836342cc9 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:23:24 +0200 Subject: [PATCH 39/64] [OMCSEssionZMQ] use OMCpath --- OMPython/OMCSession.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 966709bc..a6541eae 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -478,8 +478,15 @@ def __del__(self): self.omc_zmq = None def omcpath(self, *path) -> OMCPath: - # TODO: need to handle PurePosixPath and PureWindowsPath - # define it here based on the backend (omc_process) used? + """ + Create an OMCPath object based on the given path segments and the current OMC session. + """ + + # fallback solution for Python < 3.12; a modified pathlib.Path object is used as OMCPath replacement + if sys.version_info < (3, 12): + # noinspection PyArgumentList + return OMCPath(*path) + return OMCPath(*path, session=self) def execute(self, command: str): From 0ceb2bf7bafc8ec6b5af9d60b9b62952f3bd9fae Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:28:31 +0200 Subject: [PATCH 40/64] [OMCSessionZMQ] create a tempdir using omcpath_tempdir() --- OMPython/OMCSession.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index a6541eae..d8a73a3b 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -489,6 +489,30 @@ def omcpath(self, *path) -> OMCPath: return OMCPath(*path, session=self) + def omcpath_tempdir(self) -> OMCPath: + """ + Get a temporary directory using OMC. + """ + names = [str(uuid.uuid4()) for _ in range(100)] + + tempdir_str = self.sendExpression("getTempDirectoryPath()") + tempdir_base = self.omcpath(tempdir_str) + tempdir: Optional[OMCPath] = None + for name in names: + # create a unique temporary directory name + tempdir = tempdir_base / name + + if tempdir.exists(): + continue + + tempdir.mkdir(parents=True, exist_ok=False) + break + + if tempdir is None or not tempdir.is_dir(): + raise OMCSessionException("Cannot create a temporary directory!") + + return tempdir + def execute(self, command: str): warnings.warn("This function is depreciated and will be removed in future versions; " "please use sendExpression() instead", DeprecationWarning, stacklevel=2) From 43288dc8a5728e4a25910b1cb6b9d70479a6892e Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:34:24 +0200 Subject: [PATCH 41/64] [OMCPath] fix mypy --- OMPython/OMCSession.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index d8a73a3b..2250d21d 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -419,8 +419,7 @@ class OMCPathCompatibility(pathlib.Path): def size(self) -> int: return self.stat().st_size - - OMCPath = OMCPathCompatibility + OMCPath = OMCPathCompatibility # noqa: F811 class OMCSessionZMQ: From 30291388c780710cb6952c5d851f3fe7244686b7 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:38:27 +0200 Subject: [PATCH 42/64] [OMCPath] add warning message for Python < 3.12 --- OMPython/OMCSession.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 2250d21d..8b3a3b28 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -414,6 +414,12 @@ def size(self) -> int: if sys.version_info < (3, 12): + warnings.warn( + message="Python < 3.12 - using a limited compatibility class as OMCPath replacement.", + category=DeprecationWarning, + stacklevel=1, + ) + class OMCPathCompatibility(pathlib.Path): def size(self) -> int: From a5f2ad63c622106b72eb19f7456743966f4fb7b2 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 22:20:23 +0200 Subject: [PATCH 43/64] [OMCPath] try to make mypy happy ... --- OMPython/OMCSession.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 8b3a3b28..3107320d 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -268,7 +268,7 @@ def getClassNames(self, className=None, recursive=False, qualified=False, sort=F return self._ask(question='getClassNames', opt=opt) -class OMCPath(pathlib.PurePosixPath): +class OMCPathReal(pathlib.PurePosixPath): """ Implementation of a basic Path object which uses OMC as backend. The connection to OMC is provided via a OMCSessionZMQ session object. @@ -420,13 +420,16 @@ def size(self) -> int: stacklevel=1, ) - class OMCPathCompatibility(pathlib.Path): + class OMCPathCompatibility(pathlib.PosixPath): def size(self) -> int: return self.stat().st_size OMCPath = OMCPathCompatibility # noqa: F811 +else: + OMCPath = OMCPathReal + class OMCSessionZMQ: @@ -491,8 +494,8 @@ def omcpath(self, *path) -> OMCPath: if sys.version_info < (3, 12): # noinspection PyArgumentList return OMCPath(*path) - - return OMCPath(*path, session=self) + else: + return OMCPath(*path, session=self) def omcpath_tempdir(self) -> OMCPath: """ From 94165971bd76dca4f5b0eb9801e189b987854510 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:17:00 +0200 Subject: [PATCH 44/64] [test_OMCPath] only for Python >= 3.12 --- tests/test_OMCPath.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index bef2b473..8cc2c88c 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -7,8 +7,14 @@ reason="OpenModelica Docker image is Linux-only; skipping on Windows.", ) +skip_python_older_312 = pytest.mark.skipif( + sys.version_info < (3, 12), + reason="OMCPath only working for Python >= 3.12 (definition of pathlib.PurePath).", +) + @skip_on_windows +@skip_python_older_312 def test_OMCPath_docker(): omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") om = OMPython.OMCSessionZMQ(omc_process=omcp) @@ -29,6 +35,7 @@ def test_OMCPath_docker(): del om +@skip_python_older_312 def test_OMCPath_local(): om = OMPython.OMCSessionZMQ() From c45b4f767a6dd3e621ecc25e580b81fd28213e6e Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 21:17:35 +0200 Subject: [PATCH 45/64] [test_OMCPath] update test --- tests/test_OMCPath.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 8cc2c88c..67f1316c 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -20,11 +20,16 @@ def test_OMCPath_docker(): om = OMPython.OMCSessionZMQ(omc_process=omcp) assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" - p1 = om.omcpath('/tmp') - assert str(p1) == "/tmp" - p2 = p1 / 'test.txt' - assert str(p2) == "/tmp/test.txt" + tempdir = '/tmp' + + p1 = om.omcpath(tempdir).resolve().absolute() + assert str(p1) == tempdir + p2 = p1 / '..' / p1.name / 'test.txt' + assert p2.is_file() is False assert p2.write_text('test') + assert p2.is_file() + p2 = p2.resolve().absolute() + assert str(p2) == f"{tempdir}/test.txt" assert p2.read_text() == "test" assert p2.is_file() assert p2.parent.is_dir() @@ -39,11 +44,20 @@ def test_OMCPath_docker(): def test_OMCPath_local(): om = OMPython.OMCSessionZMQ() - p1 = om.omcpath('/tmp') - assert str(p1) == "/tmp" - p2 = p1 / 'test.txt' - assert str(p2) == "/tmp/test.txt" + # use different tempdir for Windows and Linux + if sys.platform.startswith("win"): + tempdir = 'C:/temp' + else: + tempdir = '/tmp' + + p1 = om.omcpath(tempdir).resolve().absolute() + assert str(p1) == tempdir + p2 = p1 / '..' / p1.name / 'test.txt' + assert p2.is_file() is False assert p2.write_text('test') + assert p2.is_file() + p2 = p2.resolve().absolute() + assert str(p2) == f"{tempdir}/test.txt" assert p2.read_text() == "test" assert p2.is_file() assert p2.parent.is_dir() From 30cb70e7bc210bfc845224fafd408ed4db4d4ada Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 23:21:44 +0200 Subject: [PATCH 46/64] [ModelicaSystem] simplify handling of inputs --- OMPython/ModelicaSystem.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 48268a28..bac2d3fa 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1566,10 +1566,9 @@ def load_module_from_path(module_name, file_path): om_cmd.arg_set(key="overrideFile", val=overrideLinearFile.as_posix()) - inputs = self.getInputs() - if inputs: - for key in inputs: - data = inputs[key] + if self._inputs: + for key in self._inputs: + data = self._inputs[key] if data is not None: for value in data: if value[0] < float(self._simulate_options["startTime"]): From 3ba84ab009870a49755bb38cc4a7b8da6d134fd2 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:03:40 +0200 Subject: [PATCH 47/64] [ModelicaSystem] fix mypy warning - value can have different types in this code (int or str) --- OMPython/ModelicaSystem.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index e4a2f137..7bad2c63 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -1571,10 +1571,10 @@ def load_module_from_path(module_name, file_path): overrideLinearFile = self._tempdir / f'{self._model_name}_override_linear.txt' with open(file=overrideLinearFile, mode="w", encoding="utf-8") as fh: - for key, value in self._override_variables.items(): - fh.write(f"{key}={value}\n") - for key, value in self._linearization_options.items(): - fh.write(f"{key}={value}\n") + for key1, value1 in self._override_variables.items(): + fh.write(f"{key1}={value1}\n") + for key2, value2 in self._linearization_options.items(): + fh.write(f"{key2}={value2}\n") om_cmd.arg_set(key="overrideFile", val=overrideLinearFile.as_posix()) From 4b58c0396d1928fcb56ac82b020fa03956393ec4 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:53:28 +0200 Subject: [PATCH 48/64] [OMCPath._omc_resolve] use sendExpression() with parsed=False * this is scripting output and, thus, it cannot be parsed --- OMPython/OMCSession.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 3107320d..a012ae87 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -372,7 +372,7 @@ def _omc_resolve(self, pathstr: str) -> OMCPath: 'cd(omcpath_cwd)') try: - result = self._session.sendExpression(expression) + result = self._session.sendExpression(command=expression, parsed=False) result_parts = result.split('\n') pathstr_resolved = result_parts[1] pathstr_resolved = pathstr_resolved[1:-1] # remove quotes From c71b13710d3b946e0619dd11c442a5925c059ac5 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 15:42:45 +0200 Subject: [PATCH 49/64] [test_OMCPath] cleanup; use the same code for local OMC and docker based OMC --- tests/test_OMCPath.py | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 67f1316c..8d323ddf 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -22,19 +22,7 @@ def test_OMCPath_docker(): tempdir = '/tmp' - p1 = om.omcpath(tempdir).resolve().absolute() - assert str(p1) == tempdir - p2 = p1 / '..' / p1.name / 'test.txt' - assert p2.is_file() is False - assert p2.write_text('test') - assert p2.is_file() - p2 = p2.resolve().absolute() - assert str(p2) == f"{tempdir}/test.txt" - assert p2.read_text() == "test" - assert p2.is_file() - assert p2.parent.is_dir() - assert p2.unlink() - assert p2.is_file() is False + _run_OMCPath_checks(tempdir, om) del omcp del om @@ -50,6 +38,12 @@ def test_OMCPath_local(): else: tempdir = '/tmp' + _run_OMCPath_checks(tempdir, om) + + del om + + +def _run_OMCPath_checks(tempdir: str, om: OMPython.OMCSessionZMQ): p1 = om.omcpath(tempdir).resolve().absolute() assert str(p1) == tempdir p2 = p1 / '..' / p1.name / 'test.txt' @@ -63,10 +57,3 @@ def test_OMCPath_local(): assert p2.parent.is_dir() assert p2.unlink() assert p2.is_file() is False - - del om - - -if __name__ == '__main__': - test_OMCPath_docker() - print('DONE') From a97bdd020f05fb163b813073305861540868b351 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 15:45:00 +0200 Subject: [PATCH 50/64] [test_OMCPath] define test for WSL --- tests/test_OMCPath.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index 8d323ddf..ea87f53c 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -43,6 +43,23 @@ def test_OMCPath_local(): del om +@pytest.mark.skip(reason="Not able to run WSL on github") +def test_OMCPath_WSL(): + omcp = OMPython.OMCProcessWSL( + wsl_omc='omc', + wsl_user='omc', + timeout=30.0, + ) + om = OMPython.OMCSessionZMQ(omc_process=omcp) + + tempdir = '/tmp' + + _run_OMCPath_checks(tempdir, om) + + del omcp + del om + + def _run_OMCPath_checks(tempdir: str, om: OMPython.OMCSessionZMQ): p1 = om.omcpath(tempdir).resolve().absolute() assert str(p1) == tempdir From 2dadf3e78149cab0efb390648a40b3a77d420efd Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 15:57:02 +0200 Subject: [PATCH 51/64] [test_OMCPath] use omcpath_tempdir() instead of hard-coded tempdir definition --- tests/test_OMCPath.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/tests/test_OMCPath.py b/tests/test_OMCPath.py index ea87f53c..45689622 100644 --- a/tests/test_OMCPath.py +++ b/tests/test_OMCPath.py @@ -20,9 +20,7 @@ def test_OMCPath_docker(): om = OMPython.OMCSessionZMQ(omc_process=omcp) assert om.sendExpression("getVersion()") == "OpenModelica 1.25.0" - tempdir = '/tmp' - - _run_OMCPath_checks(tempdir, om) + _run_OMCPath_checks(om) del omcp del om @@ -32,13 +30,7 @@ def test_OMCPath_docker(): def test_OMCPath_local(): om = OMPython.OMCSessionZMQ() - # use different tempdir for Windows and Linux - if sys.platform.startswith("win"): - tempdir = 'C:/temp' - else: - tempdir = '/tmp' - - _run_OMCPath_checks(tempdir, om) + _run_OMCPath_checks(om) del om @@ -52,23 +44,20 @@ def test_OMCPath_WSL(): ) om = OMPython.OMCSessionZMQ(omc_process=omcp) - tempdir = '/tmp' - - _run_OMCPath_checks(tempdir, om) + _run_OMCPath_checks(om) del omcp del om -def _run_OMCPath_checks(tempdir: str, om: OMPython.OMCSessionZMQ): - p1 = om.omcpath(tempdir).resolve().absolute() - assert str(p1) == tempdir +def _run_OMCPath_checks(om: OMPython.OMCSessionZMQ): + p1 = om.omcpath_tempdir() p2 = p1 / '..' / p1.name / 'test.txt' assert p2.is_file() is False assert p2.write_text('test') assert p2.is_file() p2 = p2.resolve().absolute() - assert str(p2) == f"{tempdir}/test.txt" + assert str(p2) == f"{str(p1)}/test.txt" assert p2.read_text() == "test" assert p2.is_file() assert p2.parent.is_dir() From 04d09ba029e3ede8236fab7150376fac8a1fa246 Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 22:52:19 +0200 Subject: [PATCH 52/64] [ModelicaSystem] use OMCPath for nearly all file system interactions --- OMPython/ModelicaSystem.py | 90 +++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 8c6c3b5c..096e5047 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -33,7 +33,6 @@ """ import ast -import csv from dataclasses import dataclass import logging import numbers @@ -43,13 +42,12 @@ import platform import re import subprocess -import tempfile import textwrap from typing import Optional, Any import warnings import xml.etree.ElementTree as ET -from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal +from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal, OMCPath # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -385,9 +383,9 @@ def __init__( self._lmodel = lmodel # may be needed if model is derived from other model self._model_name = modelName # Model class name - self._file_name = pathlib.Path(fileName).resolve() if fileName is not None else None # Model file/package name + self._file_name: Optional[OMCPath] = self._getconn.omcpath(fileName).resolve() if fileName is not None else None # Model file/package name self._simulated = False # True if the model has already been simulated - self._result_file: Optional[pathlib.Path] = None # for storing result file + self._result_file: Optional[OMCPath] = None # for storing result file self._variable_filter = variableFilter if self._file_name is not None and not self._file_name.is_file(): # if file does not exist @@ -399,7 +397,7 @@ def __init__( self.setCommandLineOptions("--linearizationDumpLanguage=python") self.setCommandLineOptions("--generateSymbolicLinearization") - self._tempdir = self.setTempDirectory(customBuildDirectory) + self._tempdir: OMCPath = self.setTempDirectory(customBuildDirectory) if self._file_name is not None: self._loadLibrary(lmodel=self._lmodel) @@ -419,7 +417,7 @@ def setCommandLineOptions(self, commandLineOptions: Optional[str] = None): exp = f'setCommandLineOptions("{commandLineOptions}")' self.sendExpression(exp) - def _loadFile(self, fileName: pathlib.Path): + def _loadFile(self, fileName: OMCPath): # load file self.sendExpression(f'loadFile("{fileName.as_posix()}")') @@ -447,14 +445,14 @@ def _loadLibrary(self, lmodel: list): '1)["Modelica"]\n' '2)[("Modelica","3.2.3"), "PowerSystems"]\n') - def setTempDirectory(self, customBuildDirectory: Optional[str | os.PathLike | pathlib.Path] = None) -> pathlib.Path: + def setTempDirectory(self, customBuildDirectory: Optional[str | os.PathLike | pathlib.Path] = None) -> OMCPath: # create a unique temp directory for each session and build the model in that directory if customBuildDirectory is not None: if not os.path.exists(customBuildDirectory): raise IOError(f"{customBuildDirectory} does not exist") - tempdir = pathlib.Path(customBuildDirectory).absolute() + tempdir = self._getconn.omcpath(customBuildDirectory).absolute() else: - tempdir = pathlib.Path(tempfile.mkdtemp()).absolute() + tempdir = self._getconn.omcpath_tempdir().absolute() if not tempdir.is_dir(): raise IOError(f"{tempdir} could not be created") @@ -464,7 +462,7 @@ def setTempDirectory(self, customBuildDirectory: Optional[str | os.PathLike | pa return tempdir - def getWorkDirectory(self) -> pathlib.Path: + def getWorkDirectory(self) -> OMCPath: return self._tempdir def buildModel(self, variableFilter: Optional[str] = None): @@ -479,7 +477,7 @@ def buildModel(self, variableFilter: Optional[str] = None): buildModelResult = self._requestApi("buildModel", self._model_name, properties=varFilter) logger.debug("OM model build result: %s", buildModelResult) - xml_file = pathlib.Path(buildModelResult[0]).parent / buildModelResult[1] + xml_file = self._getconn.omcpath(buildModelResult[0]).parent / buildModelResult[1] self._xmlparse(xml_file=xml_file) def sendExpression(self, expr: str, parsed: bool = True): @@ -506,7 +504,7 @@ def _requestApi(self, apiName, entity=None, properties=None): # 2 return self.sendExpression(exp) - def _xmlparse(self, xml_file: pathlib.Path): + def _xmlparse(self, xml_file: OMCPath): if not xml_file.is_file(): raise ModelicaSystemError(f"XML file not generated: {xml_file}") @@ -926,7 +924,7 @@ def getOptimizationOptions(self, names: Optional[str | list[str]] = None) -> dic def simulate_cmd( self, - result_file: pathlib.Path, + result_file: OMCPath, simflags: Optional[str] = None, simargs: Optional[dict[str, Optional[str | dict[str, str]]]] = None, timeout: Optional[float] = None, @@ -953,7 +951,11 @@ def simulate_cmd( An instance if ModelicaSystemCmd to run the requested simulation. """ - om_cmd = ModelicaSystemCmd(runpath=self._tempdir, modelname=self._model_name, timeout=timeout) + om_cmd = ModelicaSystemCmd( + runpath=pathlib.Path(self.getWorkDirectory()), + modelname=self._model_name, + timeout=timeout, + ) # always define the result file to use om_cmd.arg_set(key="r", val=result_file.as_posix()) @@ -965,15 +967,13 @@ def simulate_cmd( if simargs: om_cmd.args_set(args=simargs) - overrideFile = self._tempdir / f"{self._model_name}_override.txt" + overrideFile = self.getWorkDirectory() / f"{self._model_name}_override.txt" if self._override_variables or self._simulate_options_override: tmpdict = self._override_variables.copy() tmpdict.update(self._simulate_options_override) - # write to override file - with open(file=overrideFile, mode="w", encoding="utf-8") as fh: - for key, value in tmpdict.items(): - fh.write(f"{key}={value}\n") + override_content = "\n".join([f"{key}={value}" for key, value in tmpdict.items()]) + "\n" + overrideFile.write_text(override_content) om_cmd.arg_set(key="overrideFile", val=overrideFile.as_posix()) if self._inputs: # if model has input quantities @@ -1024,11 +1024,14 @@ def simulate( if resultfile is None: # default result file generated by OM - self._result_file = self._tempdir / f"{self._model_name}_res.mat" + self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat" elif os.path.exists(resultfile): - self._result_file = pathlib.Path(resultfile) + self._result_file = self._getconn.omcpath(resultfile) else: - self._result_file = self._tempdir / resultfile + self._result_file = self.getWorkDirectory() / resultfile + + if not isinstance(self._result_file, OMCPath): + raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!") om_cmd = self.simulate_cmd( result_file=self._result_file, @@ -1047,7 +1050,7 @@ def simulate( # check for an empty (=> 0B) result file which indicates a crash of the model executable # see: https://github.com/OpenModelica/OMPython/issues/261 # https://github.com/OpenModelica/OpenModelica/issues/13829 - if self._result_file.stat().st_size == 0: + if self._result_file.size() == 0: self._result_file.unlink() raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!") @@ -1092,7 +1095,7 @@ def getSolutions(self, varList: Optional[str | list[str]] = None, resultfile: Op raise ModelicaSystemError("No result file found. Run simulate() first.") result_file = self._result_file else: - result_file = pathlib.Path(resultfile) + result_file = self._getconn.omcpath(resultfile) # check for result file exits if not result_file.is_file(): @@ -1388,7 +1391,7 @@ def setInputs( return True - def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path: + def _createCSVData(self, csvfile: Optional[OMCPath] = None) -> OMCPath: """ 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. @@ -1434,11 +1437,12 @@ def _createCSVData(self, csvfile: Optional[pathlib.Path] = None) -> pathlib.Path csv_rows.append(row) if csvfile is None: - csvfile = self._tempdir / f'{self._model_name}.csv' + csvfile = self.getWorkDirectory() / f'{self._model_name}.csv' + + # basic definition of a CSV file using csv_rows as input + csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n" - with open(file=csvfile, mode="w", encoding="utf-8", newline="") as fh: - writer = csv.writer(fh) - writer.writerows(csv_rows) + csvfile.write_text(csv_content) return csvfile @@ -1557,17 +1561,21 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N "use ModelicaSystem() to build the model first" ) - om_cmd = ModelicaSystemCmd(runpath=self._tempdir, modelname=self._model_name, timeout=timeout) - - overrideLinearFile = self._tempdir / f'{self._model_name}_override_linear.txt' + om_cmd = ModelicaSystemCmd( + runpath=pathlib.Path(self.getWorkDirectory()), + modelname=self._model_name, + timeout=timeout, + ) - with open(file=overrideLinearFile, mode="w", encoding="utf-8") as fh: - for key1, value1 in self._override_variables.items(): - fh.write(f"{key1}={value1}\n") - for key2, value2 in self._linearization_options.items(): - fh.write(f"{key2}={value2}\n") + override_content = ( + "\n".join([f"{key}={value}" for key, value in self._override_variables.items()]) + + "\n".join([f"{key}={value}" for key, value in self._linearization_options.items()]) + + "\n" + ) + override_file = self.getWorkDirectory() / f'{self._model_name}_override_linear.txt' + override_file.write_text(override_content) - om_cmd.arg_set(key="overrideFile", val=overrideLinearFile.as_posix()) + om_cmd.arg_set(key="overrideFile", val=override_file.as_posix()) if self._inputs: for key in self._inputs: @@ -1589,7 +1597,7 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N om_cmd.args_set(args=simargs) # the file create by the model executable which contains the matrix and linear inputs, outputs and states - linear_file = self._tempdir / "linearized_model.py" + linear_file = self.getWorkDirectory() / "linearized_model.py" linear_file.unlink(missing_ok=True) @@ -1599,7 +1607,7 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N self._simulated = True - if not linear_file.exists(): + if not linear_file.is_file(): raise ModelicaSystemError(f"Linearization failed: {linear_file} not found!") # extract data from the python file with the linearized model using the ast module - this allows to get the From 9c860e4e2bd91aa6c8c9313c3d7b01b3d777c87a Mon Sep 17 00:00:00 2001 From: syntron Date: Fri, 11 Jul 2025 23:51:41 +0200 Subject: [PATCH 53/64] test only for Python 3.12 for now --- .github/workflows/Test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 269a66c5..fc1305e4 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - python-version: ['3.10', '3.12'] + python-version: ['3.12'] os: ['ubuntu-latest', 'windows-latest'] omc-version: ['stable'] From 466740c7a88110897989b5d45fdedd873557cc79 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 01:03:53 +0200 Subject: [PATCH 54/64] [test_ModelicaSystem] fix test_customBuildDirectory() --- tests/test_ModelicaSystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index 8e9b8a8e..e782489e 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -105,7 +105,7 @@ def test_customBuildDirectory(tmp_path, model_firstorder): tmpdir = tmp_path / "tmpdir1" tmpdir.mkdir() m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) - assert m.getWorkDirectory().resolve() == tmpdir.resolve() + assert pathlib.Path(m.getWorkDirectory()).resolve() == tmpdir.resolve() result_file = tmpdir / "a.mat" assert not result_file.exists() m.simulate(resultfile="a.mat") From 63d2f87204506ad2712a1043b595372fff43d990 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 00:29:55 +0200 Subject: [PATCH 55/64] [ModelicaSystemCmd] use OMCPath for file system interactions --- OMPython/ModelicaSystem.py | 116 ++++++++++++++++++-------------- OMPython/OMCSession.py | 106 ++++++++++++++++++++++++++++- tests/test_FMIExport.py | 7 +- tests/test_ModelicaSystem.py | 1 + tests/test_ModelicaSystemCmd.py | 9 ++- 5 files changed, 180 insertions(+), 59 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 096e5047..9fdcdf7d 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -39,15 +39,13 @@ import numpy as np import os import pathlib -import platform -import re import subprocess import textwrap from typing import Optional, Any import warnings import xml.etree.ElementTree as ET -from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal, OMCPath +from OMPython.OMCSession import OMCSessionException, OMCSessionZMQ, OMCProcessLocal, OMCPath, OMCSessionRunData # define logger using the current module name as ID logger = logging.getLogger(__name__) @@ -113,7 +111,14 @@ def __getitem__(self, index: int): class ModelicaSystemCmd: """A compiled model executable.""" - def __init__(self, runpath: pathlib.Path, modelname: str, timeout: Optional[float] = None) -> None: + def __init__( + self, + session: OMCSessionZMQ, + runpath: OMCPath, + modelname: str, + timeout: Optional[float] = None, + ) -> None: + self._session = session self._runpath = pathlib.Path(runpath).resolve().absolute() self._model_name = modelname self._timeout = timeout @@ -174,27 +179,12 @@ def args_set(self, args: dict[str, Optional[str | dict[str, str]]]) -> None: for arg in args: self.arg_set(key=arg, val=args[arg]) - def get_exe(self) -> pathlib.Path: - """Get the path to the compiled model executable.""" - if platform.system() == "Windows": - path_exe = self._runpath / f"{self._model_name}.exe" - else: - path_exe = self._runpath / 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(). + def get_cmd_args(self) -> list: + """ + Get a list with the command arguments for the model executable. """ - path_exe = self.get_exe() - - cmdl = [path_exe.as_posix()] + cmdl = [] for key in self._args: if self._args[key] is None: cmdl.append(f"-{key}") @@ -203,40 +193,49 @@ def get_cmd(self) -> list: return cmdl - def run(self) -> int: - """Run the requested simulation. - - Returns - ------- - Subprocess return code (0 on success). + def run_def(self) -> OMCSessionRunData: """ + Define all needed data to run the model executable. The data is stored in an OMCSessionRunData object. + """ + # ensure that a result filename is provided + result_file = self.arg_get('r') + if not isinstance(result_file, str): + result_file = (self._runpath / f"{self._model_name}.mat").as_posix() + + omc_run_data = OMCSessionRunData( + cmd_path=self._runpath.as_posix(), + cmd_model_name=self._model_name, + cmd_args=self.get_cmd_args(), + cmd_result_path=result_file, + cmd_timeout=self._timeout, + ) - cmdl: list = self.get_cmd() - - logger.debug("Run OM command %s in %s", repr(cmdl), self._runpath.as_posix()) + return omc_run_data - if platform.system() == "Windows": - path_dll = "" + @staticmethod + def run_cmd(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. + """ - # set the process environment from the generated .bat file in windows which should have all the dependencies - path_bat = self._runpath / f"{self._model_name}.bat" - if not path_bat.exists(): - raise ModelicaSystemError("Batch file (*.bat) does not exist " + str(path_bat)) + 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"] - with open(file=path_bat, mode='r', encoding='utf-8') as fh: - for line in fh: - 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"] - else: - # TODO: how to handle path to resources of external libraries for any system not Windows? - my_env = None + 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=self._runpath, - timeout=self._timeout, check=True) + cmdres = subprocess.run( + cmdl, + capture_output=True, + text=True, + env=my_env, + cwd=cmd_run_data.cmd_path, + timeout=cmd_run_data.cmd_timeout, + check=True, + ) stdout = cmdres.stdout.strip() stderr = cmdres.stderr.strip() returncode = cmdres.returncode @@ -252,6 +251,17 @@ def run(self) -> int: return returncode + def run(self) -> int: + """Run the requested simulation. + + Returns + ------- + Subprocess return code (0 on success). + """ + cmd_run_data = self.run_def() + cmd_run_data = self._session.omc_run_data_update(cmd_run_data, session=self._session) + return self.run_cmd(cmd_run_data=cmd_run_data) + @staticmethod def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, str]]]: """ @@ -952,7 +962,8 @@ def simulate_cmd( """ om_cmd = ModelicaSystemCmd( - runpath=pathlib.Path(self.getWorkDirectory()), + session=self._getconn, + runpath=self.getWorkDirectory(), modelname=self._model_name, timeout=timeout, ) @@ -1562,7 +1573,8 @@ def linearize(self, lintime: Optional[float] = None, simflags: Optional[str] = N ) om_cmd = ModelicaSystemCmd( - runpath=pathlib.Path(self.getWorkDirectory()), + session=self._getconn, + runpath=self.getWorkDirectory(), modelname=self._model_name, timeout=timeout, ) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index a012ae87..e91bd1c8 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -34,11 +34,14 @@ CONDITIONS OF OSMC-PL. """ +import abc +import dataclasses import io import json import logging import os import pathlib +import platform import psutil import pyparsing import re @@ -431,6 +434,43 @@ def size(self) -> int: OMCPath = OMCPathReal +@dataclasses.dataclass +class OMCSessionRunData: + """ + Data class to store the command line data for running a model executable in the OMC environment. + """ + # cmd_path is based on the selected OMCProcess definition + cmd_path: str + cmd_model_name: str + # command line arguments for the model executable + cmd_args: list[str] + # result file with the simulation output + # cmd_result_path is based on the selected OMCProcess definition + cmd_result_path: str + + # 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 + # command timeout + cmd_timeout: Optional[float] = 10.0 + + 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 + + return cmdl + + class OMCSessionZMQ: def __init__( @@ -521,6 +561,14 @@ def omcpath_tempdir(self) -> OMCPath: return tempdir + def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> 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, session=session) + def execute(self, command: str): warnings.warn("This function is depreciated and will be removed in future versions; " "please use sendExpression() instead", DeprecationWarning, stacklevel=2) @@ -528,8 +576,11 @@ def execute(self, command: str): return self.sendExpression(command, parsed=False) def sendExpression(self, command: str, parsed: bool = True) -> Any: + """ + Send an expression to the OMC server and return the result. + """ if self.omc_zmq is None: - raise OMCSessionException("No OMC running. Create a new instance of OMCSessionZMQ!") + raise OMCSessionException("No OMC running. Create a new instance of OMCProcess!") logger.debug("sendExpression(%r, parsed=%r)", command, parsed) @@ -624,7 +675,7 @@ def sendExpression(self, command: str, parsed: bool = True) -> Any: raise OMCSessionException("Cannot parse OMC result") from ex -class OMCProcess: +class OMCProcess(metaclass=abc.ABCMeta): def __init__( self, @@ -706,6 +757,15 @@ def _get_portfile_path(self) -> Optional[pathlib.Path]: return portfile_path + @abc.abstractmethod + def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: + """ + Modify data based on the selected OMCProcess implementation. + + Needs to be implemented in the subclasses. + """ + raise NotImplementedError("This method must be implemented in subclasses!") + class OMCProcessPort(OMCProcess): @@ -716,6 +776,9 @@ def __init__( super().__init__() self._omc_port = omc_port + def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: + raise OMCSessionException("OMCProcessPort does not support omc_run_data_update()!") + class OMCProcessLocal(OMCProcess): @@ -797,6 +860,39 @@ def _omc_port_get(self) -> str: return port + def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: + omc_run_data_copy = dataclasses.replace(omc_run_data) + + cmd_path = session.omcpath(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: + 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() + + return omc_run_data_copy + class OMCProcessDockerHelper(OMCProcess): @@ -903,6 +999,9 @@ def get_docker_container_id(self) -> str: return self._dockerCid + def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: + raise OMCSessionException("OMCProcessDocker(Container) does not support omc_run_data_update()!") + class OMCProcessDocker(OMCProcessDockerHelper): @@ -1208,3 +1307,6 @@ 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, session: OMCSessionZMQ) -> OMCSessionRunData: + raise OMCSessionException("OMCProcessWSL does not support omc_run_data_update()!") diff --git a/tests/test_FMIExport.py b/tests/test_FMIExport.py index f47b87ae..788f7311 100644 --- a/tests/test_FMIExport.py +++ b/tests/test_FMIExport.py @@ -1,12 +1,14 @@ import OMPython import shutil import os +import pathlib def test_CauerLowPassAnalog(): mod = OMPython.ModelicaSystem(modelName="Modelica.Electrical.Analog.Examples.CauerLowPassAnalog", lmodel=["Modelica"]) - tmp = mod.getWorkDirectory() + # TODO [OMCPath]: need to work using OMCPath + tmp = pathlib.Path(mod.getWorkDirectory()) try: fmu = mod.convertMo2Fmu(fileNamePrefix="CauerLowPassAnalog") assert os.path.exists(fmu) @@ -16,7 +18,8 @@ def test_CauerLowPassAnalog(): def test_DrumBoiler(): mod = OMPython.ModelicaSystem(modelName="Modelica.Fluid.Examples.DrumBoiler.DrumBoiler", lmodel=["Modelica"]) - tmp = mod.getWorkDirectory() + # TODO [OMCPath]: need to work using OMCPath + tmp = pathlib.Path(mod.getWorkDirectory()) try: fmu = mod.convertMo2Fmu(fileNamePrefix="DrumBoiler") assert os.path.exists(fmu) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index e782489e..f7a8aeff 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -105,6 +105,7 @@ def test_customBuildDirectory(tmp_path, model_firstorder): tmpdir = tmp_path / "tmpdir1" tmpdir.mkdir() m = OMPython.ModelicaSystem(filePath, "M", customBuildDirectory=tmpdir) + # TODO [OMCPath]: need to work using OMCPath assert pathlib.Path(m.getWorkDirectory()).resolve() == tmpdir.resolve() result_file = tmpdir / "a.mat" assert not result_file.exists() diff --git a/tests/test_ModelicaSystemCmd.py b/tests/test_ModelicaSystemCmd.py index 3b28699c..dfb8da91 100644 --- a/tests/test_ModelicaSystemCmd.py +++ b/tests/test_ModelicaSystemCmd.py @@ -18,7 +18,11 @@ def model_firstorder(tmp_path): @pytest.fixture def mscmd_firstorder(model_firstorder): mod = OMPython.ModelicaSystem(fileName=model_firstorder.as_posix(), modelName="M") - mscmd = OMPython.ModelicaSystemCmd(runpath=mod.getWorkDirectory(), modelname=mod._model_name) + mscmd = OMPython.ModelicaSystemCmd( + session=mod._getconn, + runpath=mod.getWorkDirectory(), + modelname=mod._model_name, + ) return mscmd @@ -32,8 +36,7 @@ def test_simflags(mscmd_firstorder): 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(), + assert mscmd.get_cmd_args() == [ '-noEventEmit', '-override=b=2,a=1,x=3', '-noRestart', From 62958aab98ce7cf49d2645ff84d599635736fd5c Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 00:43:11 +0200 Subject: [PATCH 56/64] [OMCProcessDockerHelper] implement omc_run_data_update() - UNTESTED! --- OMPython/OMCSession.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index e91bd1c8..6ab3db4b 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1000,7 +1000,27 @@ def get_docker_container_id(self) -> str: return self._dockerCid def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: - raise OMCSessionException("OMCProcessDocker(Container) does not support omc_run_data_update()!") + """ + Update the OMCSessionRunData object based on the selected OMCProcess implementation. + """ + omc_run_data_copy = dataclasses.replace(omc_run_data) + + omc_run_data_copy.cmd_prefix = ( + [ + "docker", "exec", + "--user", str(self._getuid()), + ] + + self._dockerExtraArgs + + [self._dockerCid] + ) + + cmd_path = session.omcpath(omc_run_data_copy.cmd_path) + 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() + + return omc_run_data_copy class OMCProcessDocker(OMCProcessDockerHelper): From 0be67f73c950787c6a496199c68290553bc16fbc Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 00:43:25 +0200 Subject: [PATCH 57/64] [OMCProcessWSL] implement omc_run_data_update() - UNTESTED! --- OMPython/OMCSession.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 6ab3db4b..a9d485df 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1265,25 +1265,30 @@ def __init__( super().__init__(timeout=timeout) - # get wsl base command - self._wsl_cmd = ['wsl'] - if isinstance(wsl_distribution, str): - self._wsl_cmd += ['--distribution', wsl_distribution] - if isinstance(wsl_user, str): - self._wsl_cmd += ['--user', wsl_user] - self._wsl_cmd += ['--'] - # where to find OpenModelica self._wsl_omc = wsl_omc + # store WSL distribution and user + self._wsl_distribution = wsl_distribution + self._wsl_user = wsl_user # start up omc executable, which is waiting for the ZMQ connection self._omc_process = self._omc_process_get() # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get() + def _wsl_cmd(self) -> list[str]: # 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] + wsl_cmd += ['--'] + + return wsl_cmd + def _omc_process_get(self) -> subprocess.Popen: my_env = os.environ.copy() - omc_command = self._wsl_cmd + [ + omc_command = self._wsl_cmd() + [ self._wsl_omc, "--locale=C", "--interactive=zmq", @@ -1306,7 +1311,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._wsl_cmd() + ["cat", omc_portfile_path.as_posix()], stderr=subprocess.DEVNULL, ) port = output.decode().strip() @@ -1329,4 +1334,17 @@ def _omc_port_get(self) -> str: return port def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessionZMQ) -> OMCSessionRunData: - raise OMCSessionException("OMCProcessWSL does not support omc_run_data_update()!") + """ + Update the OMCSessionRunData object based on the selected OMCProcess implementation. + """ + omc_run_data_copy = dataclasses.replace(omc_run_data) + + omc_run_data_copy.cmd_prefix = self._wsl_cmd() + + cmd_path = session.omcpath(omc_run_data_copy.cmd_path) + 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() + + return omc_run_data_copy From bad7608111be517da882095916d1637a2d5c66cd Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:54:38 +0200 Subject: [PATCH 58/64] [ModelicaSystemCmd] run session.omc_run_data_update() within run_def() * the result is the ready-to-use definition of the command needed to run the model executable --- OMPython/ModelicaSystem.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 9fdcdf7d..201c357d 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -210,7 +210,9 @@ def run_def(self) -> OMCSessionRunData: cmd_timeout=self._timeout, ) - return omc_run_data + omc_run_data_updated = self._session.omc_run_data_update(omc_run_data, session=self._session) + + return omc_run_data_updated @staticmethod def run_cmd(cmd_run_data: OMCSessionRunData) -> int: @@ -259,7 +261,6 @@ def run(self) -> int: Subprocess return code (0 on success). """ cmd_run_data = self.run_def() - cmd_run_data = self._session.omc_run_data_update(cmd_run_data, session=self._session) return self.run_cmd(cmd_run_data=cmd_run_data) @staticmethod From ad3361ff0e838e75a7ab0c86f39259a23f9e89be Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:56:58 +0200 Subject: [PATCH 59/64] [OMCSessionRunData] define cmd_cwd and use it in OMCProcessLocal * one windows this defines the local working directory to be used * for docker or WSL this should NOT be set as this directory does NOT exists locally --- OMPython/ModelicaSystem.py | 2 +- OMPython/OMCSession.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 201c357d..1cf460b2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -234,7 +234,7 @@ def run_cmd(cmd_run_data: OMCSessionRunData) -> int: capture_output=True, text=True, env=my_env, - cwd=cmd_run_data.cmd_path, + cwd=cmd_run_data.cmd_cwd_local, timeout=cmd_run_data.cmd_timeout, check=True, ) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index a9d485df..f6568b82 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -457,6 +457,9 @@ class OMCSessionRunData: # command timeout cmd_timeout: Optional[float] = 10.0 + # working directory to be used on the *local* system + cmd_cwd_local: Optional[str] = None + def get_cmd(self) -> list[str]: """ Get the command line to run the model executable in the environment defined by the OMCProcess definition. @@ -891,6 +894,8 @@ def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessi raise OMCSessionException(f"Application file path not found: {cmd_model_executable}") omc_run_data_copy.cmd_model_executable = cmd_model_executable.as_posix() + omc_run_data_copy.cmd_cwd_local = omc_run_data.cmd_path + return omc_run_data_copy From a63bd500b130668f4cf10452c987233266562987 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:57:34 +0200 Subject: [PATCH 60/64] [OMCProcessDockerHelper] define work directory in docker --- OMPython/OMCSession.py | 1 + 1 file changed, 1 insertion(+) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index f6568b82..99a52c17 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1014,6 +1014,7 @@ def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessi [ "docker", "exec", "--user", str(self._getuid()), + "--workdir", omc_run_data_copy.cmd_path, ] + self._dockerExtraArgs + [self._dockerCid] From f02be6f2dd7447bcd565e0f438139e4373b19716 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:58:41 +0200 Subject: [PATCH 61/64] [OMCProcessWSL] define work directory for WSL --- OMPython/OMCSession.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 99a52c17..2d85dcd1 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -1281,12 +1281,14 @@ def __init__( # connect to the running omc instance using ZMQ self._omc_port = self._omc_port_get() - def _wsl_cmd(self) -> list[str]: # get wsl base command + def _wsl_cmd(self, wsl_cwd: Optional[str] = None) -> list[str]: # 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] wsl_cmd += ['--'] return wsl_cmd @@ -1345,7 +1347,7 @@ def omc_run_data_update(self, omc_run_data: OMCSessionRunData, session: OMCSessi """ omc_run_data_copy = dataclasses.replace(omc_run_data) - omc_run_data_copy.cmd_prefix = self._wsl_cmd() + omc_run_data_copy.cmd_prefix = self._wsl_cmd(wsl_cwd=omc_run_data.cmd_path) cmd_path = session.omcpath(omc_run_data_copy.cmd_path) cmd_model_executable = cmd_path / omc_run_data_copy.cmd_model_name From c518e380576fc742e9293bd6baddf71dc580bbde Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 12:59:57 +0200 Subject: [PATCH 62/64] [OMCSessionRunData] update docstring and comments --- OMPython/OMCSession.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OMPython/OMCSession.py b/OMPython/OMCSession.py index 2d85dcd1..4b5992e4 100644 --- a/OMPython/OMCSession.py +++ b/OMPython/OMCSession.py @@ -438,14 +438,15 @@ def size(self) -> int: 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) """ - # cmd_path is based on the selected OMCProcess definition + # cmd_path is the expected working directory cmd_path: str cmd_model_name: str # command line arguments for the model executable cmd_args: list[str] # result file with the simulation output - # cmd_result_path is based on the selected OMCProcess definition cmd_result_path: str # command prefix data (as list of strings); needed for docker or WSL From 57c21cfb6c6c7139564e2cda2b420f60163bc0c4 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 13:01:27 +0200 Subject: [PATCH 63/64] [ModelicaSystem] allow for non local execution, i.e. docker or WSL --- OMPython/ModelicaSystem.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/OMPython/ModelicaSystem.py b/OMPython/ModelicaSystem.py index 1cf460b2..6567f0e2 100644 --- a/OMPython/ModelicaSystem.py +++ b/OMPython/ModelicaSystem.py @@ -377,8 +377,6 @@ def __init__( self._linearized_states: list[str] = [] # linearization states list if omc_process is not None: - if not isinstance(omc_process, OMCProcessLocal): - raise ModelicaSystemError("Invalid (local) omc process definition provided!") self._getconn = OMCSessionZMQ(omc_process=omc_process) else: self._getconn = OMCSessionZMQ(omhome=omhome) From 80f5c9667df40c3e8b3be0a10b66e8e1b9197692 Mon Sep 17 00:00:00 2001 From: syntron Date: Sat, 12 Jul 2025 13:42:05 +0200 Subject: [PATCH 64/64] [test_ModelicaSystem] include test of ModelicaSystem using docker --- tests/test_ModelicaSystem.py | 40 +++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/test_ModelicaSystem.py b/tests/test_ModelicaSystem.py index f7a8aeff..9fadf403 100644 --- a/tests/test_ModelicaSystem.py +++ b/tests/test_ModelicaSystem.py @@ -2,20 +2,31 @@ import os import pathlib import pytest +import sys import tempfile import numpy as np +skip_on_windows = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="OpenModelica Docker image is Linux-only; skipping on Windows.", +) + @pytest.fixture -def model_firstorder(tmp_path): - mod = tmp_path / "M.mo" - mod.write_text("""model M +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 @@ -113,9 +124,32 @@ def test_customBuildDirectory(tmp_path, model_firstorder): assert result_file.is_file() +@skip_on_windows +def test_getSolutions_docker(model_firstorder_content): + omcp = OMPython.OMCProcessDocker(docker="openmodelica/openmodelica:v1.25.0-minimal") + omc = OMPython.OMCSessionZMQ(omc_process=omcp) + + modelpath = omc.omcpath_tempdir() / 'M.mo' + modelpath.write_text(model_firstorder_content) + + file_path = pathlib.Path(modelpath) + mod = OMPython.ModelicaSystem( + fileName=file_path, + modelName="M", + omc_process=omc.omc_process, + ) + + _run_getSolutions(mod) + + def test_getSolutions(model_firstorder): filePath = model_firstorder.as_posix() mod = OMPython.ModelicaSystem(filePath, "M") + + _run_getSolutions(mod) + + +def _run_getSolutions(mod): x0 = 1 a = -1 tau = -1 / a