# -*- coding: utf-8 -*-
"""
Definition of main class to run Modelica simulations - ModelicaSystem.
"""
import ast
from dataclasses import dataclass
import itertools
import logging
import numbers
import os
import pathlib
import queue
import textwrap
import threading
from typing import Any, cast, Optional
import warnings
import xml.etree.ElementTree as ET
import numpy as np
from OMPython.OMCSession import (
OMCSessionException,
OMCSessionRunData,
OMCSessionZMQ,
OMCSession,
OMCSessionLocal,
OMCPath,
)
# define logger using the current module name as ID
logger = logging.getLogger(__name__)
class ModelicaSystemError(Exception):
"""
Exception used in ModelicaSystem and ModelicaSystemCmd classes.
"""
@dataclass
class LinearizationResult:
"""Modelica model linearization results.
Attributes:
n: number of states
m: number of inputs
p: number of outputs
A: state matrix (n x n)
B: input matrix (n x m)
C: output matrix (p x n)
D: feedthrough matrix (p x m)
x0: fixed point
u0: input corresponding to the fixed point
stateVars: names of state variables
inputVars: names of inputs
outputVars: names of outputs
"""
n: int
m: int
p: int
A: list
B: list
C: list
D: list
x0: list[float]
u0: list[float]
stateVars: list[str]
inputVars: list[str]
outputVars: list[str]
def __iter__(self):
"""Allow unpacking A, B, C, D = result."""
yield self.A
yield self.B
yield self.C
yield self.D
def __getitem__(self, index: int):
"""Allow accessing A, B, C, D via result[0] through result[3].
This is needed for backwards compatibility, because
ModelicaSystem.linearize() used to return [A, B, C, D].
"""
return {0: self.A, 1: self.B, 2: self.C, 3: self.D}[index]
class ModelicaSystemCmd:
"""
All information about a compiled model executable. This should include data about all structured parameters, i.e.
parameters which need a recompilation of the model. All non-structured parameters can be easily changed without
the need for recompilation.
"""
def __init__(
self,
session: OMCSessionZMQ,
runpath: OMCPath,
modelname: Optional[str] = None,
) -> None:
if modelname is None:
raise ModelicaSystemError("Missing model name!")
self._session = session
self._runpath = runpath
self._model_name = modelname
# dictionaries of command line arguments for the model executable
self._args: dict[str, str | None] = {}
# 'override' argument needs special handling, as it is a dict on its own saved as dict elements following the
# structure: 'key' => 'key=value'
self._arg_override: dict[str, str] = {}
def arg_set(
self,
key: str,
val: Optional[str | dict[str, Any] | numbers.Number] = None,
) -> None:
"""
Set one argument for the executable model.
Args:
key: identifier / argument name to be used for the call of the model executable.
val: value for the given key; None for no value and for key == 'override' a dictionary can be used which
indicates variables to override
"""
def override2str(
okey: str,
oval: str | bool | numbers.Number,
) -> str:
"""
Convert a value for 'override' to a string taking into account differences between Modelica and Python.
"""
# check oval for any string representations of numbers (or bool) and convert these to Python representations
if isinstance(oval, str):
try:
oval_evaluated = ast.literal_eval(oval)
if isinstance(oval_evaluated, (numbers.Number, bool)):
oval = oval_evaluated
except (ValueError, SyntaxError):
pass
if isinstance(oval, str):
oval_str = oval.strip()
elif isinstance(oval, bool):
oval_str = 'true' if oval else 'false'
elif isinstance(oval, numbers.Number):
oval_str = str(oval)
else:
raise ModelicaSystemError(f"Invalid value for override key {okey}: {type(oval)}")
return f"{okey}={oval_str}"
if not isinstance(key, str):
raise ModelicaSystemError(f"Invalid argument key: {repr(key)} (type: {type(key)})")
key = key.strip()
if isinstance(val, dict):
if key != 'override':
raise ModelicaSystemError("Dictionary input only possible for key 'override'!")
for okey, oval in val.items():
if not isinstance(okey, str):
raise ModelicaSystemError("Invalid key for argument 'override': "
f"{repr(okey)} (type: {type(okey)})")
if not isinstance(oval, (str, bool, numbers.Number, type(None))):
raise ModelicaSystemError(f"Invalid input for 'override'.{repr(okey)}: "
f"{repr(oval)} (type: {type(oval)})")
if okey in self._arg_override:
if oval is None:
logger.info(f"Remove model executable override argument: {repr(self._arg_override[okey])}")
del self._arg_override[okey]
continue
logger.info(f"Update model executable override argument: {repr(okey)} = {repr(oval)} "
f"(was: {repr(self._arg_override[okey])})")
if oval is not None:
self._arg_override[okey] = override2str(okey=okey, oval=oval)
argval = ','.join(sorted(self._arg_override.values()))
elif val is None:
argval = None
elif isinstance(val, str):
argval = val.strip()
elif isinstance(val, numbers.Number):
argval = str(val)
else:
raise ModelicaSystemError(f"Invalid argument value for {repr(key)}: {repr(val)} (type: {type(val)})")
if key in self._args:
logger.warning(f"Override model executable argument: {repr(key)} = {repr(argval)} "
f"(was: {repr(self._args[key])})")
self._args[key] = argval
def arg_get(self, key: str) -> Optional[str | dict[str, str | bool | numbers.Number]]:
"""
Return the value for the given key
"""
if key in self._args:
return self._args[key]
return None
def args_set(
self,
args: dict[str, Optional[str | dict[str, Any] | numbers.Number]],
) -> None:
"""
Define arguments for the model executable.
"""
for arg in args:
self.arg_set(key=arg, val=args[arg])
def get_cmd_args(self) -> list[str]:
"""
Get a list with the command arguments for the model executable.
"""
cmdl = []
for key in sorted(self._args):
if self._args[key] is None:
cmdl.append(f"-{key}")
else:
cmdl.append(f"-{key}={self._args[key]}")
return cmdl
def definition(self) -> 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,
)
omc_run_data_updated = self._session.omc_run_data_update(
omc_run_data=omc_run_data,
)
return omc_run_data_updated
@staticmethod
def parse_simflags(simflags: str) -> dict[str, Optional[str | dict[str, Any] | numbers.Number]]:
"""
Parse a simflag definition; this is deprecated!
The return data can be used as input for self.args_set().
"""
warnings.warn("The argument 'simflags' is depreciated and will be removed in future versions; "
"please use 'simargs' instead", DeprecationWarning, stacklevel=2)
simargs: dict[str, Optional[str | dict[str, Any] | numbers.Number]] = {}
args = [s for s in simflags.split(' ') if s]
for arg in args:
if arg[0] != '-':
raise ModelicaSystemError(f"Invalid simulation flag: {arg}")
arg = arg[1:]
parts = arg.split('=')
if len(parts) == 1:
simargs[parts[0]] = None
elif parts[0] == 'override':
override = '='.join(parts[1:])
override_dict = {}
for item in override.split(','):
kv = item.split('=')
if not 0 < len(kv) < 3:
raise ModelicaSystemError(f"Invalid value for '-override': {override}")
if kv[0]:
try:
override_dict[kv[0]] = kv[1]
except (KeyError, IndexError) as ex:
raise ModelicaSystemError(f"Invalid value for '-override': {override}") from ex
simargs[parts[0]] = override_dict
return simargs
class ModelicaSystem:
"""
Class to simulate a Modelica model using OpenModelica via OMCSessionZMQ.
"""
def __init__(
self,
command_line_options: Optional[list[str]] = None,
work_directory: Optional[str | os.PathLike] = None,
omhome: Optional[str] = None,
session: Optional[OMCSession] = None,
) -> None:
"""Create a ModelicaSystem instance. To define the model use model() or convertFmu2Mo().
Args:
command_line_options: List with extra command line options as elements. The list elements are
provided to omc via setCommandLineOptions(). If set, the default values will be overridden.
To disable any command line options, use an empty list.
work_directory: Path to a directory to be used for temporary
files like the model executable. If left unspecified, a tmp
directory will be created.
omhome: path to OMC to be used when creating the OMC session (see OMCSessionZMQ).
session: definition of a (local) OMC session to be used. If
unspecified, a new local session will be created.
"""
self._quantities: list[dict[str, Any]] = []
self._params: dict[str, str] = {} # even numerical values are stored as str
self._inputs: dict[str, list[tuple[float, float]]] = {}
# _outputs values are str before simulate(), but they can be
# np.float64 after simulate().
self._outputs: dict[str, Any] = {}
# same for _continuous
self._continuous: dict[str, Any] = {}
self._simulate_options: dict[str, str] = {}
self._override_variables: dict[str, str] = {}
self._simulate_options_override: dict[str, str] = {}
self._linearization_options: dict[str, str | float] = {
'startTime': 0.0,
'stopTime': 1.0,
'stepSize': 0.002,
'tolerance': 1e-8,
}
self._optimization_options = self._linearization_options | {
'numberOfIntervals': 500,
}
self._linearized_inputs: list[str] = [] # linearization input list
self._linearized_outputs: list[str] = [] # linearization output list
self._linearized_states: list[str] = [] # linearization states list
if session is not None:
self._session = OMCSessionZMQ(omc_process=session)
else:
self._session = OMCSessionZMQ(omhome=omhome)
# set commandLineOptions using default values or the user defined list
if command_line_options is None:
# set default command line options to improve the performance of linearization and to avoid recompilation if
# the simulation executable is reused in linearize() via the runtime flag '-l'
command_line_options = [
"--linearizationDumpLanguage=python",
"--generateSymbolicLinearization",
]
for opt in command_line_options:
self.set_command_line_options(command_line_option=opt)
self._simulated = False # True if the model has already been simulated
self._result_file: Optional[OMCPath] = None # for storing result file
self._work_dir: OMCPath = self.setWorkDirectory(work_directory)
self._model_name: Optional[str] = None
self._libraries: Optional[list[str | tuple[str, str]]] = None
self._file_name: Optional[OMCPath] = None
self._variable_filter: Optional[str] = None
def model(
self,
model_name: Optional[str] = None,
model_file: Optional[str | os.PathLike] = None,
libraries: Optional[list[str | tuple[str, str]]] = None,
variable_filter: Optional[str] = None,
build: bool = True,
) -> None:
"""Load and build a Modelica model.
This method loads the model file and builds it if requested (build == True).
Args:
model_file: Path to the model file. Either absolute or relative to
the current working directory.
model_name: The name of the model class. If it is contained within
a package, "PackageName.ModelName" should be used.
libraries: List of libraries to be loaded before the model itself is
loaded. Two formats are supported for the list elements:
lmodel=["Modelica"] for just the library name
and lmodel=[("Modelica","3.2.3")] for specifying both the name
and the version.
variable_filter: A regular expression. Only variables fully
matching the regexp will be stored in the result file.
Leaving it unspecified is equivalent to ".*".
build: Boolean controlling whether the model should be
built when constructor is called. If False, the constructor
simply loads the model without compiling.
Examples:
mod = ModelicaSystem()
# and then one of the lines below
mod.model(name="modelName", file="ModelicaModel.mo", )
mod.model(name="modelName", file="ModelicaModel.mo", libraries=["Modelica"])
mod.model(name="modelName", file="ModelicaModel.mo", libraries=[("Modelica","3.2.3"), "PowerSystems"])
"""
if self._model_name is not None:
raise ModelicaSystemError("Can not reuse this instance of ModelicaSystem "
f"defined for {repr(self._model_name)}!")
if model_name is None or not isinstance(model_name, str):
raise ModelicaSystemError("A model name must be provided!")
if libraries is None:
libraries = []
if not isinstance(libraries, list):
raise ModelicaSystemError(f"Invalid input type for libraries: {type(libraries)} - list expected!")
# set variables
self._model_name = model_name # Model class name
self._libraries = libraries # may be needed if model is derived from other model
self._variable_filter = variable_filter
if self._libraries:
self._loadLibrary(libraries=self._libraries)
self._file_name = None
if model_file is not None:
file_path = pathlib.Path(model_file)
# special handling for OMCProcessLocal - consider a relative path
if isinstance(self._session.omc_process, OMCSessionLocal) and not file_path.is_absolute():
file_path = pathlib.Path.cwd() / file_path
if not file_path.is_file():
raise IOError(f"Model file {file_path} does not exist!")
self._file_name = self.getWorkDirectory() / file_path.name
if (isinstance(self._session.omc_process, OMCSessionLocal)
and file_path.as_posix() == self._file_name.as_posix()):
pass
elif self._file_name.is_file():
raise IOError(f"Simulation model file {self._file_name} exist - not overwriting!")
else:
content = file_path.read_text(encoding='utf-8')
self._file_name.write_text(content)
if self._file_name is not None:
self._loadFile(fileName=self._file_name)
if build:
self.buildModel(variable_filter)
def get_session(self) -> OMCSessionZMQ:
"""
Return the OMC session used for this class.
"""
return self._session
def set_command_line_options(self, command_line_option: str):
"""
Set the provided command line option via OMC setCommandLineOptions().
"""
exp = f'setCommandLineOptions("{command_line_option}")'
self.sendExpression(exp)
def _loadFile(self, fileName: OMCPath):
# load file
self.sendExpression(f'loadFile("{fileName.as_posix()}")')
# for loading file/package, loading model and building model
def _loadLibrary(self, libraries: list):
# load Modelica standard libraries or Modelica files if needed
for element in libraries:
if element is not None:
if isinstance(element, str):
if element.endswith(".mo"):
api_call = "loadFile"
else:
api_call = "loadModel"
self._requestApi(apiName=api_call, entity=element)
elif isinstance(element, tuple):
if not element[1]:
expr_load_lib = f"loadModel({element[0]})"
else:
expr_load_lib = f'loadModel({element[0]}, {{"{element[1]}"}})'
self.sendExpression(expr_load_lib)
else:
raise ModelicaSystemError("loadLibrary() failed, Unknown type detected: "
f"{element} is of type {type(element)}, "
"The following patterns are supported:\n"
'1)["Modelica"]\n'
'2)[("Modelica","3.2.3"), "PowerSystems"]\n')
def setWorkDirectory(self, work_directory: Optional[str | os.PathLike] = None) -> OMCPath:
"""
Define the work directory for the ModelicaSystem / OpenModelica session. The model is build within this
directory. If no directory is defined a unique temporary directory is created.
"""
if work_directory is not None:
workdir = self._session.omcpath(work_directory).absolute()
if not workdir.is_dir():
raise IOError(f"Provided work directory does not exists: {work_directory}!")
else:
workdir = self._session.omcpath_tempdir().absolute()
if not workdir.is_dir():
raise IOError(f"{workdir} could not be created")
logger.info("Define work dir as %s", workdir)
exp = f'cd("{workdir.as_posix()}")'
self.sendExpression(exp)
# set the class variable _work_dir ...
self._work_dir = workdir
# ... and also return the defined path
return workdir
def getWorkDirectory(self) -> OMCPath:
"""
Return the defined working directory for this ModelicaSystem / OpenModelica session.
"""
return self._work_dir
def buildModel(self, variableFilter: Optional[str] = None):
filter_def: Optional[str] = None
if variableFilter is not None:
filter_def = variableFilter
elif self._variable_filter is not None:
filter_def = self._variable_filter
if filter_def is not None:
var_filter = f'variableFilter="{filter_def}"'
else:
var_filter = 'variableFilter=".*"'
build_model_result = self._requestApi(apiName="buildModel", entity=self._model_name, properties=var_filter)
logger.debug("OM model build result: %s", build_model_result)
# check if the executable exists ...
om_cmd = ModelicaSystemCmd(
session=self._session,
runpath=self.getWorkDirectory(),
modelname=self._model_name,
)
# ... by running it - output help for command help
om_cmd.arg_set(key="help", val="help")
cmd_definition = om_cmd.definition()
returncode = self._session.run_model_executable(cmd_run_data=cmd_definition)
if returncode != 0:
raise ModelicaSystemError("Model executable not working!")
xml_file = self._session.omcpath(build_model_result[0]).parent / build_model_result[1]
self._xmlparse(xml_file=xml_file)
def sendExpression(self, expr: str, parsed: bool = True) -> Any:
try:
retval = self._session.sendExpression(expr, parsed)
except OMCSessionException as ex:
raise ModelicaSystemError(f"Error executing {repr(expr)}: {ex}") from ex
logger.debug(f"Result of executing {repr(expr)}: {textwrap.shorten(repr(retval), width=100)}")
return retval
# request to OMC
def _requestApi(
self,
apiName: str,
entity: Optional[str] = None,
properties: Optional[str] = None,
) -> Any:
if entity is not None and properties is not None:
exp = f'{apiName}({entity}, {properties})'
elif entity is not None and properties is None:
if apiName in ("loadFile", "importFMU"):
exp = f'{apiName}("{entity}")'
else:
exp = f'{apiName}({entity})'
else:
exp = f'{apiName}()'
return self.sendExpression(exp)
def _xmlparse(self, xml_file: OMCPath):
if not xml_file.is_file():
raise ModelicaSystemError(f"XML file not generated: {xml_file}")
xml_content = xml_file.read_text()
tree = ET.ElementTree(ET.fromstring(xml_content))
root = tree.getroot()
for attr in root.iter('DefaultExperiment'):
for key in ("startTime", "stopTime", "stepSize", "tolerance",
"solver", "outputFormat"):
self._simulate_options[key] = str(attr.get(key))
for sv in root.iter('ScalarVariable'):
translations = {
"alias": "alias",
"aliasvariable": "aliasVariable",
"causality": "causality",
"changeable": "isValueChangeable",
"description": "description",
"name": "name",
"variability": "variability",
}
scalar: dict[str, Any] = {}
for key_dst, key_src in translations.items():
val = sv.get(key_src)
scalar[key_dst] = None if val is None else str(val)
ch = list(sv)
for att in ch:
scalar["start"] = att.get('start')
scalar["min"] = att.get('min')
scalar["max"] = att.get('max')
scalar["unit"] = att.get('unit')
# save parameters in the corresponding class variables
if scalar["variability"] == "parameter":
if scalar["name"] in self._override_variables:
self._params[scalar["name"]] = self._override_variables[scalar["name"]]
else:
self._params[scalar["name"]] = scalar["start"]
if scalar["variability"] == "continuous":
self._continuous[scalar["name"]] = scalar["start"]
if scalar["causality"] == "input":
self._inputs[scalar["name"]] = scalar["start"]
if scalar["causality"] == "output":
self._outputs[scalar["name"]] = scalar["start"]
self._quantities.append(scalar)
def getQuantities(self, names: Optional[str | list[str]] = None) -> list[dict]:
"""
This method returns list of dictionaries. It displays details of
quantities such as name, value, changeable, and description.
Examples:
>>> mod.getQuantities()
[
{
'alias': 'noAlias',
'aliasvariable': None,
'causality': 'local',
'changeable': 'true',
'description': None,
'max': None,
'min': None,
'name': 'x',
'start': '1.0',
'unit': None,
'variability': 'continuous',
},
{
'name': 'der(x)',
# ...
},
# ...
]
>>> getQuantities("y")
[{
'name': 'y', # ...
}]
>>> getQuantities(["y","x"])
[
{
'name': 'y', # ...
},
{
'name': 'x', # ...
}
]
"""
if names is None:
return self._quantities
if isinstance(names, str):
r = [x for x in self._quantities if x["name"] == names]
if r == []:
raise KeyError(names)
return r
if isinstance(names, list):
return [x for y in names for x in self._quantities if x["name"] == y]
raise ModelicaSystemError("Unhandled input for getQuantities()")
def getContinuous(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str | numbers.Real] | list[str | numbers.Real]:
"""Get values of continuous signals.
If called before simulate(), the initial values are returned as
strings (or None). If called after simulate(), the final values (at
stopTime) are returned as numpy.float64.
Args:
names: Either None (default), a string with the continuous signal
name, or a list of signal name strings.
Returns:
If `names` is None, a dict in the format
{signal_name: signal_value} is returned.
If `names` is a string, a single element list [signal_value] is
returned.
If `names` is a list, a list with one value for each signal name
in names is returned: [signal1_value, signal2_value, ...].
Examples:
Before simulate():
>>> mod.getContinuous()
{'x': '1.0', 'der(x)': None, 'y': '-0.4'}
>>> mod.getContinuous("y")
['-0.4']
>>> mod.getContinuous(["y","x"])
['-0.4', '1.0']
After simulate():
>>> mod.getContinuous()
{'x': np.float64(0.68), 'der(x)': np.float64(-0.24), 'y': np.float64(-0.24)}
>>> mod.getContinuous("x")
[np.float64(0.68)]
>>> mod.getOutputs(["y","x"])
[np.float64(-0.24), np.float64(0.68)]
"""
if not self._simulated:
if names is None:
return self._continuous
if isinstance(names, str):
return [self._continuous[names]]
if isinstance(names, list):
return [self._continuous[x] for x in names]
if names is None:
for name in self._continuous:
try:
value = self.getSolutions(name)
self._continuous[name] = value[0][-1]
except (OMCSessionException, ModelicaSystemError) as ex:
raise ModelicaSystemError(f"{name} could not be computed") from ex
return self._continuous
if isinstance(names, str):
if names in self._continuous:
value = self.getSolutions(names)
self._continuous[names] = value[0][-1]
return [self._continuous[names]]
raise ModelicaSystemError(f"{names} is not continuous")
if isinstance(names, list):
valuelist = []
for name in names:
if name in self._continuous:
value = self.getSolutions(name)
self._continuous[name] = value[0][-1]
valuelist.append(value[0][-1])
else:
raise ModelicaSystemError(f"{name} is not continuous")
return valuelist
raise ModelicaSystemError("Unhandled input for getContinous()")
def getParameters(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str] | list[str]:
"""Get parameter values.
Args:
names: Either None (default), a string with the parameter name,
or a list of parameter name strings.
Returns:
If `names` is None, a dict in the format
{parameter_name: parameter_value} is returned.
If `names` is a string, a single element list is returned.
If `names` is a list, a list with one value for each parameter name
in names is returned.
In all cases, parameter values are returned as strings.
Examples:
>>> mod.getParameters()
{'Name1': '1.23', 'Name2': '4.56'}
>>> mod.getParameters("Name1")
['1.23']
>>> mod.getParameters(["Name1","Name2"])
['1.23', '4.56']
"""
if names is None:
return self._params
if isinstance(names, str):
return [self._params[names]]
if isinstance(names, list):
return [self._params[x] for x in names]
raise ModelicaSystemError("Unhandled input for getParameters()")
def getInputs(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, list[tuple[float, float]]] | list[list[tuple[float, float]]]:
"""Get values of input signals.
Args:
names: Either None (default), a string with the input name,
or a list of input name strings.
Returns:
If `names` is None, a dict in the format
{input_name: input_value} is returned.
If `names` is a string, a single element list [input_value] is
returned.
If `names` is a list, a list with one value for each input name
in names is returned: [input1_values, input2_values, ...].
In all cases, input values are returned as a list of tuples,
where the first element in the tuple is the time and the second
element is the input value.
Examples:
>>> mod.getInputs()
{'Name1': [(0.0, 0.0), (1.0, 1.0)], 'Name2': None}
>>> mod.getInputs("Name1")
[[(0.0, 0.0), (1.0, 1.0)]]
>>> mod.getInputs(["Name1","Name2"])
[[(0.0, 0.0), (1.0, 1.0)], None]
"""
if names is None:
return self._inputs
if isinstance(names, str):
return [self._inputs[names]]
if isinstance(names, list):
return [self._inputs[x] for x in names]
raise ModelicaSystemError("Unhandled input for getInputs()")
def getOutputs(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str | numbers.Real] | list[str | numbers.Real]:
"""Get values of output signals.
If called before simulate(), the initial values are returned as
strings. If called after simulate(), the final values (at stopTime)
are returned as numpy.float64.
Args:
names: Either None (default), a string with the output name,
or a list of output name strings.
Returns:
If `names` is None, a dict in the format
{output_name: output_value} is returned.
If `names` is a string, a single element list [output_value] is
returned.
If `names` is a list, a list with one value for each output name
in names is returned: [output1_value, output2_value, ...].
Examples:
Before simulate():
>>> mod.getOutputs()
{'out1': '-0.4', 'out2': '1.2'}
>>> mod.getOutputs("out1")
['-0.4']
>>> mod.getOutputs(["out1","out2"])
['-0.4', '1.2']
After simulate():
>>> mod.getOutputs()
{'out1': np.float64(-0.1234), 'out2': np.float64(2.1)}
>>> mod.getOutputs("out1")
[np.float64(-0.1234)]
>>> mod.getOutputs(["out1","out2"])
[np.float64(-0.1234), np.float64(2.1)]
"""
if not self._simulated:
if names is None:
return self._outputs
if isinstance(names, str):
return [self._outputs[names]]
return [self._outputs[x] for x in names]
if names is None:
for name in self._outputs:
value = self.getSolutions(name)
self._outputs[name] = value[0][-1]
return self._outputs
if isinstance(names, str):
if names in self._outputs:
value = self.getSolutions(names)
self._outputs[names] = value[0][-1]
return [self._outputs[names]]
raise KeyError(names)
if isinstance(names, list):
valuelist = []
for name in names:
if name in self._outputs:
value = self.getSolutions(name)
self._outputs[name] = value[0][-1]
valuelist.append(value[0][-1])
else:
raise KeyError(name)
return valuelist
raise ModelicaSystemError("Unhandled input for getOutputs()")
def getSimulationOptions(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str] | list[str]:
"""Get simulation options such as stopTime and tolerance.
Args:
names: Either None (default), a string with the simulation option
name, or a list of option name strings.
Returns:
If `names` is None, a dict in the format
{option_name: option_value} is returned.
If `names` is a string, a single element list [option_value] is
returned.
If `names` is a list, a list with one value for each option name
in names is returned: [option1_value, option2_value, ...].
Option values are always returned as strings.
Examples:
>>> mod.getSimulationOptions()
{'startTime': '0', 'stopTime': '1.234',
'stepSize': '0.002', 'tolerance': '1.1e-08', 'solver': 'dassl', 'outputFormat': 'mat'}
>>> mod.getSimulationOptions("stopTime")
['1.234']
>>> mod.getSimulationOptions(["tolerance", "stopTime"])
['1.1e-08', '1.234']
"""
if names is None:
return self._simulate_options
if isinstance(names, str):
return [self._simulate_options[names]]
if isinstance(names, list):
return [self._simulate_options[x] for x in names]
raise ModelicaSystemError("Unhandled input for getSimulationOptions()")
def getLinearizationOptions(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str | float] | list[str | float]:
"""Get simulation options used for linearization.
Args:
names: Either None (default), a string with the linearization option
name, or a list of option name strings.
Returns:
If `names` is None, a dict in the format
{option_name: option_value} is returned.
If `names` is a string, a single element list [option_value] is
returned.
If `names` is a list, a list with one value for each option name
in names is returned: [option1_value, option2_value, ...].
Some option values are returned as float when first initialized,
but always as strings after setLinearizationOptions is used to
change them.
Examples:
>>> mod.getLinearizationOptions()
{'startTime': 0.0, 'stopTime': 1.0, 'stepSize': 0.002, 'tolerance': 1e-08}
>>> mod.getLinearizationOptions("stopTime")
[1.0]
>>> mod.getLinearizationOptions(["tolerance", "stopTime"])
[1e-08, 1.0]
"""
if names is None:
return self._linearization_options
if isinstance(names, str):
return [self._linearization_options[names]]
if isinstance(names, list):
return [self._linearization_options[x] for x in names]
raise ModelicaSystemError("Unhandled input for getLinearizationOptions()")
def getOptimizationOptions(
self,
names: Optional[str | list[str]] = None,
) -> dict[str, str | float] | list[str | float]:
"""Get simulation options used for optimization.
Args:
names: Either None (default), a string with the optimization option
name, or a list of option name strings.
Returns:
If `names` is None, a dict in the format
{option_name: option_value} is returned.
If `names` is a string, a single element list [option_value] is
returned.
If `names` is a list, a list with one value for each option name
in names is returned: [option1_value, option2_value, ...].
Some option values are returned as float when first initialized,
but always as strings after setOptimizationOptions is used to
change them.
Examples:
>>> mod.getOptimizationOptions()
{'startTime': 0.0, 'stopTime': 1.0, 'numberOfIntervals': 500, 'stepSize': 0.002, 'tolerance': 1e-08}
>>> mod.getOptimizationOptions("stopTime")
[1.0]
>>> mod.getOptimizationOptions(["tolerance", "stopTime"])
[1e-08, 1.0]
"""
if names is None:
return self._optimization_options
if isinstance(names, str):
return [self._optimization_options[names]]
if isinstance(names, list):
return [self._optimization_options[x] for x in names]
raise ModelicaSystemError("Unhandled input for getOptimizationOptions()")
def simulate_cmd(
self,
result_file: OMCPath,
simflags: Optional[str] = None,
simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None,
) -> ModelicaSystemCmd:
"""
This method prepares the simulates model according to the simulation options. It returns an instance of
ModelicaSystemCmd which can be used to run the simulation.
Due to the tempdir being unique for the ModelicaSystem instance, *NEVER* use this to create several simulations
with the same instance of ModelicaSystem! Restart each simulation process with a new instance of ModelicaSystem.
However, if only non-structural parameters are used, it is possible to reuse an existing instance of
ModelicaSystem to create several version ModelicaSystemCmd to run the model using different settings.
Parameters
----------
result_file
simflags
simargs
Returns
-------
An instance if ModelicaSystemCmd to run the requested simulation.
"""
om_cmd = ModelicaSystemCmd(
session=self._session,
runpath=self.getWorkDirectory(),
modelname=self._model_name,
)
# always define the result file to use
om_cmd.arg_set(key="r", val=result_file.as_posix())
# allow runtime simulation flags from user input
if simflags is not None:
om_cmd.args_set(args=om_cmd.parse_simflags(simflags=simflags))
if simargs:
om_cmd.args_set(args=simargs)
if self._override_variables or self._simulate_options_override:
override_file = result_file.parent / f"{result_file.stem}_override.txt"
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._simulate_options_override.items()])
+ "\n"
)
override_file.write_text(override_content)
om_cmd.arg_set(key="overrideFile", val=override_file.as_posix())
if self._inputs: # if model has input quantities
for key, val in self._inputs.items():
if val is None:
val = [(float(self._simulate_options["startTime"]), 0.0),
(float(self._simulate_options["stopTime"]), 0.0)]
self._inputs[key] = val
if float(self._simulate_options["startTime"]) != val[0][0]:
raise ModelicaSystemError(f"startTime not matched for Input {key}!")
if float(self._simulate_options["stopTime"]) != val[-1][0]:
raise ModelicaSystemError(f"stopTime not matched for Input {key}!")
# csvfile is based on name used for result file
csvfile = result_file.parent / f"{result_file.stem}.csv"
# write csv file and store the name
csvfile = self._createCSVData(csvfile=csvfile)
om_cmd.arg_set(key="csvInput", val=csvfile.as_posix())
return om_cmd
def simulate(
self,
resultfile: Optional[str | os.PathLike] = None,
simflags: Optional[str] = None,
simargs: Optional[dict[str, Optional[str | dict[str, Any] | numbers.Number]]] = None,
) -> None:
"""Simulate the model according to simulation options.
See setSimulationOptions().
Args:
resultfile: Path to a custom result file
simflags: String of extra command line flags for the model binary.
This argument is deprecated, use simargs instead.
simargs: Dict with simulation runtime flags.
Examples:
mod.simulate()
mod.simulate(resultfile="a.mat")
# set runtime simulation flags, deprecated
mod.simulate(simflags="-noEventEmit -noRestart -override=e=0.3,g=10")
# using simargs
mod.simulate(simargs={"noEventEmit": None, "noRestart": None, "override": "override": {"e": 0.3, "g": 10}})
"""
if resultfile is None:
# default result file generated by OM
self._result_file = self.getWorkDirectory() / f"{self._model_name}_res.mat"
elif isinstance(resultfile, OMCPath):
self._result_file = resultfile
else:
self._result_file = self._session.omcpath(resultfile)
if not self._result_file.is_absolute():
self._result_file = self.getWorkDirectory() / resultfile
if not isinstance(self._result_file, OMCPath):
raise ModelicaSystemError(f"Invalid result file path: {self._result_file} - must be an OMCPath object!")
om_cmd = self.simulate_cmd(
result_file=self._result_file,
simflags=simflags,
simargs=simargs,
)
# delete resultfile ...
if self._result_file.is_file():
self._result_file.unlink()
# ... run simulation ...
cmd_definition = om_cmd.definition()
returncode = self._session.run_model_executable(cmd_run_data=cmd_definition)
# and check returncode *AND* resultfile
if returncode != 0 and self._result_file.is_file():
# check for an empty (=> 0B) result file which indicates a crash of the model executable
# see: https://github.com/OpenModelica/OMPython/issues/261
# https://github.com/OpenModelica/OpenModelica/issues/13829
if self._result_file.size() == 0:
self._result_file.unlink()
raise ModelicaSystemError("Empty result file - this indicates a crash of the model executable!")
logger.warning(f"Return code = {returncode} but result file exists!")
self._simulated = True
def plot(
self,
plotdata: str,
resultfile: Optional[str | os.PathLike] = None,
) -> None:
"""
Plot a variable using OMC; this will work for local OMC usage only (OMCProcessLocal). The reason is that the
plot is created by OMC which needs access to the local display. This is not the case for docker and WSL.
"""
if not isinstance(self._session.omc_process, OMCSessionLocal):
raise ModelicaSystemError("Plot is using the OMC plot functionality; "
"thus, it is only working if OMC is running locally!")
if resultfile is not None:
plot_result_file = self._session.omcpath(resultfile)
elif self._result_file is not None:
plot_result_file = self._result_file
else:
raise ModelicaSystemError("No resultfile available - either run simulate() before plotting "
"or provide a result file!")
if not plot_result_file.is_file():
raise ModelicaSystemError(f"Provided resultfile {repr(plot_result_file.as_posix())} does not exists!")
expr = f'plot({plotdata}, fileName="{plot_result_file.as_posix()}")'
self.sendExpression(expr=expr)
def getSolutions(
self,
varList: Optional[str | list[str]] = None,
resultfile: Optional[str | os.PathLike] = None,
) -> tuple[str] | np.ndarray:
"""Extract simulation results from a result data file.
Args:
varList: Names of variables to be extracted. Either unspecified to
get names of available variables, or a single variable name
as a string, or a list of variable names.
resultfile: Path to the result file. If unspecified, the result
file created by simulate() is used.
Returns:
If varList is None, a tuple with names of all variables
is returned.
If varList is a string, a 1D numpy array is returned.
If varList is a list, a 2D numpy array is returned.
Examples:
>>> mod.getSolutions()
('a', 'der(x)', 'time', 'x')
>>> mod.getSolutions("x")
np.array([[1. , 0.90483742, 0.81873075]])
>>> mod.getSolutions(["x", "der(x)"])
np.array([[1. , 0.90483742 , 0.81873075],
[-1. , -0.90483742, -0.81873075]])
>>> mod.getSolutions(resultfile="c:/a.mat")
('a', 'der(x)', 'time', 'x')
>>> mod.getSolutions("x", resultfile="c:/a.mat")
np.array([[1. , 0.90483742, 0.81873075]])
>>> mod.getSolutions(["x", "der(x)"], resultfile="c:/a.mat")
np.array([[1. , 0.90483742 , 0.81873075],
[-1. , -0.90483742, -0.81873075]])
"""
if resultfile is None:
if self._result_file is None:
raise ModelicaSystemError("No result file found. Run simulate() first.")
result_file = self._result_file
else:
result_file = self._session.omcpath(resultfile)
# check if the result file exits
if not result_file.is_file():
raise ModelicaSystemError(f"Result file does not exist {result_file.as_posix()}")
# get absolute path
result_file = result_file.absolute()
result_vars = self.sendExpression(f'readSimulationResultVars("{result_file.as_posix()}")')
self.sendExpression("closeSimulationResultFile()")
if varList is None:
return result_vars
if isinstance(varList, str):
var_list_checked = [varList]
elif isinstance(varList, list):
var_list_checked = varList
else:
raise ModelicaSystemError("Unhandled input for getSolutions()")
for var in var_list_checked:
if var == "time":
continue
if var not in result_vars:
raise ModelicaSystemError(f"Requested data {repr(var)} does not exist")
variables = ",".join(var_list_checked)
res = self.sendExpression(f'readSimulationResult("{result_file.as_posix()}",{{{variables}}})')
np_res = np.array(res)
self.sendExpression("closeSimulationResultFile()")
return np_res
@staticmethod
def _prepare_input_data(
input_args: Any,
input_kwargs: dict[str, Any],
) -> dict[str, str]:
"""
Convert raw input to a structured dictionary {'key1': 'value1', 'key2': 'value2'}.
"""
def prepare_str(str_in: str) -> dict[str, str]:
str_in = str_in.replace(" ", "")
key_val_list: list[str] = str_in.split("=")
if len(key_val_list) != 2:
raise ModelicaSystemError(f"Invalid 'key=value' pair: {str_in}")
input_data_from_str: dict[str, str] = {key_val_list[0]: key_val_list[1]}
return input_data_from_str
input_data: dict[str, str] = {}
for input_arg in input_args:
if isinstance(input_arg, str):
warnings.warn(message="The definition of values to set should use a dictionary, "
"i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which "
"use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]",
category=DeprecationWarning,
stacklevel=3)
input_data = input_data | prepare_str(input_arg)
elif isinstance(input_arg, list):
warnings.warn(message="The definition of values to set should use a dictionary, "
"i.e. {'key1': 'val1', 'key2': 'val2', ...}. Please convert all cases which "
"use a string ('key=val') or list ['key1=val1', 'key2=val2', ...]",
category=DeprecationWarning,
stacklevel=3)
for item in input_arg:
if not isinstance(item, str):
raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(item)}!")
input_data = input_data | prepare_str(item)
elif isinstance(input_arg, dict):
input_data = input_data | input_arg
else:
raise ModelicaSystemError(f"Invalid input data type for set*() function: {type(input_arg)}!")
if len(input_kwargs):
for key, val in input_kwargs.items():
# ensure all values are strings to align it on one type: dict[str, str]
if not isinstance(val, str):
# spaces have to be removed as setInput() could take list of tuples as input and spaces would
# result in an error on recreating the input data
str_val = str(val).replace(' ', '')
else:
str_val = val
if ' ' in key or ' ' in str_val:
raise ModelicaSystemError(f"Spaces not allowed in key/value pairs: {repr(key)} = {repr(val)}!")
input_data[key] = str_val
return input_data
def _set_method_helper(
self,
inputdata: dict[str, str],
classdata: dict[str, Any],
datatype: str,
overridedata: Optional[dict[str, str]] = None,
) -> bool:
"""
Helper function for:
* setParameter()
* setContinuous()
* setSimulationOptions()
* setLinearizationOption()
* setOptimizationOption()
* setInputs()
Parameters
----------
inputdata
string or list of string given by user
classdata
dict() containing the values of different variables (eg: parameter, continuous, simulation parameters)
datatype
type identifier (eg; continuous, parameter, simulation, linearization, optimization)
overridedata
dict() which stores the new override variables list,
"""
for key, val in inputdata.items():
if key not in classdata:
raise ModelicaSystemError(f"Invalid variable for type {repr(datatype)}: {repr(key)}")
if datatype == "parameter" and not self.isParameterChangeable(key):
raise ModelicaSystemError(f"It is not possible to set the parameter {repr(key)}. It seems to be "
"structural, final, protected, evaluated or has a non-constant binding. "
"Use sendExpression(...) and rebuild the model using buildModel() API; "
"command to set the parameter before rebuilding the model: "
"sendExpression(\"setParameterValue("
f"{self._model_name}, {key}, {val if val is not None else ''}"
")\").")
classdata[key] = val
if overridedata is not None:
overridedata[key] = val
return True
def isParameterChangeable(
self,
name: str,
) -> bool:
"""
Return if the parameter defined by name is changeable (= non-structural; can be modified without the need to
recompile the model).
"""
q = self.getQuantities(name)
if q[0]["changeable"] == "false":
return False
return True
def setContinuous(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set continuous values. It can be called:
with a sequence of continuous name and assigning corresponding values as arguments as show in the example below:
usage
>>> setContinuous("Name=value") # depreciated
>>> setContinuous(["Name1=value1","Name2=value2"]) # depreciated
>>> setContinuous(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setContinuous(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
return self._set_method_helper(
inputdata=inputdata,
classdata=self._continuous,
datatype="continuous",
overridedata=self._override_variables)
def setParameters(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set parameter values. It can be called:
with a sequence of parameter name and assigning corresponding value as arguments as show in the example below:
usage
>>> setParameters("Name=value") # depreciated
>>> setParameters(["Name1=value1","Name2=value2"]) # depreciated
>>> setParameters(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setParameters(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
return self._set_method_helper(
inputdata=inputdata,
classdata=self._params,
datatype="parameter",
overridedata=self._override_variables)
def setSimulationOptions(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set simulation options. It can be called:
with a sequence of simulation options name and assigning corresponding values as arguments as show in the
example below:
usage
>>> setSimulationOptions("Name=value") # depreciated
>>> setSimulationOptions(["Name1=value1","Name2=value2"]) # depreciated
>>> setSimulationOptions(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setSimulationOptions(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
return self._set_method_helper(
inputdata=inputdata,
classdata=self._simulate_options,
datatype="simulation-option",
overridedata=self._simulate_options_override)
def setLinearizationOptions(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set linearization options. It can be called:
with a sequence of linearization options name and assigning corresponding value as arguments as show in the
example below
usage
>>> setLinearizationOptions("Name=value") # depreciated
>>> setLinearizationOptions(["Name1=value1","Name2=value2"]) # depreciated
>>> setLinearizationOptions(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setLinearizationOptions(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
return self._set_method_helper(
inputdata=inputdata,
classdata=self._linearization_options,
datatype="Linearization-option",
overridedata=None)
def setOptimizationOptions(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set optimization options. It can be called:
with a sequence of optimization options name and assigning corresponding values as arguments as show in the
example below:
usage
>>> setOptimizationOptions("Name=value") # depreciated
>>> setOptimizationOptions(["Name1=value1","Name2=value2"]) # depreciated
>>> setOptimizationOptions(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setOptimizationOptions(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
return self._set_method_helper(
inputdata=inputdata,
classdata=self._optimization_options,
datatype="optimization-option",
overridedata=None)
def setInputs(
self,
*args: Any,
**kwargs: dict[str, Any],
) -> bool:
"""
This method is used to set input values. It can be called with a sequence of input name and assigning
corresponding values as arguments as show in the example below. Compared to other set*() methods this is a
special case as value could be a list of tuples - these are converted to a string in _prepare_input_data()
and restored here via ast.literal_eval().
>>> setInputs("Name=value") # depreciated
>>> setInputs(["Name1=value1","Name2=value2"]) # depreciated
>>> setInputs(Name1="value1", Name2="value2")
>>> param = {"Name1": "value1", "Name2": "value2"}
>>> setInputs(**param)
"""
inputdata = self._prepare_input_data(input_args=args, input_kwargs=kwargs)
for key, val in inputdata.items():
if key not in self._inputs:
raise ModelicaSystemError(f"{key} is not an input")
if not isinstance(val, str):
raise ModelicaSystemError(f"Invalid data in input for {repr(key)}: {repr(val)}")
val_evaluated = ast.literal_eval(val)
if isinstance(val_evaluated, (int, float)):
self._inputs[key] = [(float(self._simulate_options["startTime"]), float(val)),
(float(self._simulate_options["stopTime"]), float(val))]
elif isinstance(val_evaluated, list):
if not all([isinstance(item, tuple) for item in val_evaluated]):
raise ModelicaSystemError("Value for setInput() must be in tuple format; "
f"got {repr(val_evaluated)}")
if val_evaluated != sorted(val_evaluated, key=lambda x: x[0]):
raise ModelicaSystemError("Time value should be in increasing order; "
f"got {repr(val_evaluated)}")
for item in val_evaluated:
if item[0] < float(self._simulate_options["startTime"]):
raise ModelicaSystemError(f"Time value in {repr(item)} of {repr(val_evaluated)} is less "
"than the simulation start time")
if len(item) != 2:
raise ModelicaSystemError(f"Value {repr(item)} of {repr(val_evaluated)} "
"is in incorrect format!")
self._inputs[key] = val_evaluated
else:
raise ModelicaSystemError(f"Data cannot be evaluated for {repr(key)}: {repr(val)}")
return True
def _createCSVData(self, csvfile: Optional[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.
"""
start_time: float = float(self._simulate_options["startTime"])
stop_time: float = float(self._simulate_options["stopTime"])
# Replace None inputs with a default constant zero signal
inputs: dict[str, list[tuple[float, float]]] = {}
for input_name, input_signal in self._inputs.items():
if input_signal is None:
inputs[input_name] = [(start_time, 0.0), (stop_time, 0.0)]
else:
inputs[input_name] = input_signal
# Collect all unique timestamps across all input signals
all_times = np.array(
sorted({t for signal in inputs.values() for t, _ in signal}),
dtype=float
)
# Interpolate missing values
interpolated_inputs: dict[str, np.ndarray] = {}
for signal_name, signal_values in inputs.items():
signal = np.array(signal_values)
interpolated_inputs[signal_name] = np.interp(
all_times,
signal[:, 0], # times
signal[:, 1], # values
)
# Write CSV file
input_names = list(interpolated_inputs.keys())
header = ['time'] + input_names + ['end']
csv_rows = [header]
for i, t in enumerate(all_times):
row = [
t, # time
*(interpolated_inputs[name][i] for name in input_names), # input values
0, # trailing 'end' column
]
csv_rows.append(row)
if csvfile is None:
csvfile = self.getWorkDirectory() / f'{self._model_name}.csv'
# basic definition of a CSV file using csv_rows as input
csv_content = "\n".join([",".join(map(str, row)) for row in csv_rows]) + "\n"
csvfile.write_text(csv_content)
return csvfile
def convertMo2Fmu(
self,
version: str = "2.0",
fmuType: str = "me_cs",
fileNamePrefix: Optional[str] = None,
includeResources: bool = True,
) -> OMCPath:
"""Translate the model into a Functional Mockup Unit.
Args:
See https://build.openmodelica.org/Documentation/OpenModelica.Scripting.translateModelFMU.html
Returns:
str: Path to the created '*.fmu' file.
Examples:
>>> mod.convertMo2Fmu()
'/tmp/tmpmhfx9umo/CauerLowPassAnalog.fmu'
>>> mod.convertMo2Fmu(version="2.0", fmuType="me|cs|me_cs", fileNamePrefix="