Skip to content

Commit f0b3105

Browse files
committed
Merge branch 'ModelicaSystemDoE' into ModelicaSystemDoE_use_OMCPath
2 parents 297d2f5 + 2c0f632 commit f0b3105

3 files changed

Lines changed: 461 additions & 2 deletions

File tree

OMPython/ModelicaSystem.py

Lines changed: 375 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@
3434

3535
import ast
3636
from dataclasses import dataclass
37+
import itertools
3738
import logging
3839
import numbers
3940
import numpy as np
4041
import os
42+
import pathlib
43+
import queue
4144
import textwrap
42-
from typing import Optional, Any
45+
import threading
46+
from typing import Any, cast, Optional
4347
import warnings
4448
import xml.etree.ElementTree as ET
4549

@@ -1729,3 +1733,373 @@ def getLinearOutputs(self) -> list[str]:
17291733
def getLinearStates(self) -> list[str]:
17301734
"""Get names of state variables of the linearized model."""
17311735
return self._linearized_states
1736+
1737+
1738+
class ModelicaSystemDoE:
1739+
"""
1740+
Class to run DoEs based on a (Open)Modelica model using ModelicaSystem
1741+
1742+
Example
1743+
-------
1744+
```
1745+
import OMPython
1746+
import pathlib
1747+
1748+
1749+
def run_doe():
1750+
mypath = pathlib.Path('.')
1751+
1752+
model = mypath / "M.mo"
1753+
model.write_text(
1754+
" model M\n"
1755+
" parameter Integer p=1;\n"
1756+
" parameter Integer q=1;\n"
1757+
" parameter Real a = -1;\n"
1758+
" parameter Real b = -1;\n"
1759+
" Real x[p];\n"
1760+
" Real y[q];\n"
1761+
" equation\n"
1762+
" der(x) = a * fill(1.0, p);\n"
1763+
" der(y) = b * fill(1.0, q);\n"
1764+
" end M;\n"
1765+
)
1766+
1767+
param = {
1768+
# structural
1769+
'p': [1, 2],
1770+
'q': [3, 4],
1771+
# simple
1772+
'a': [5, 6],
1773+
'b': [7, 8],
1774+
}
1775+
1776+
resdir = mypath / 'DoE'
1777+
resdir.mkdir(exist_ok=True)
1778+
1779+
doe_mod = OMPython.ModelicaSystemDoE(
1780+
fileName=model.as_posix(),
1781+
modelName="M",
1782+
parameters=param,
1783+
resultpath=resdir,
1784+
simargs={"override": {'stopTime': 1.0}},
1785+
)
1786+
doe_mod.prepare()
1787+
doe_dict = doe_mod.get_doe()
1788+
doe_mod.simulate()
1789+
doe_sol = doe_mod.get_solutions()
1790+
1791+
# ... work with doe_df and doe_sol ...
1792+
1793+
1794+
if __name__ == "__main__":
1795+
run_doe()
1796+
```
1797+
1798+
"""
1799+
1800+
DICT_RESULT_FILENAME: str = 'result filename'
1801+
DICT_RESULT_AVAILABLE: str = 'result available'
1802+
1803+
def __init__(
1804+
self,
1805+
fileName: Optional[str | os.PathLike | pathlib.Path] = None,
1806+
modelName: Optional[str] = None,
1807+
lmodel: Optional[list[str | tuple[str, str]]] = None,
1808+
commandLineOptions: Optional[list[str]] = None,
1809+
variableFilter: Optional[str] = None,
1810+
customBuildDirectory: Optional[str | os.PathLike | pathlib.Path] = None,
1811+
omhome: Optional[str] = None,
1812+
1813+
simargs: Optional[dict[str, Optional[str | dict[str, str] | numbers.Number]]] = None,
1814+
timeout: Optional[int] = None,
1815+
1816+
resultpath: Optional[pathlib.Path] = None,
1817+
parameters: Optional[dict[str, list[str] | list[int] | list[float]]] = None,
1818+
) -> None:
1819+
"""
1820+
Initialisation of ModelicaSystemDoE. The parameters are based on: ModelicaSystem.__init__() and
1821+
ModelicaSystem.simulate(). Additionally, the path to store the result files is needed (= resultpath) as well as
1822+
a list of parameters to vary for the Doe (= parameters). All possible combinations are considered.
1823+
"""
1824+
self._lmodel = lmodel
1825+
self._modelName = modelName
1826+
self._fileName = fileName
1827+
1828+
self._CommandLineOptions = commandLineOptions
1829+
self._variableFilter = variableFilter
1830+
self._customBuildDirectory = customBuildDirectory
1831+
self._omhome = omhome
1832+
1833+
# reference for the model; not used for any simulations but to evaluate parameters, etc.
1834+
self._mod = ModelicaSystem(
1835+
fileName=self._fileName,
1836+
modelName=self._modelName,
1837+
lmodel=self._lmodel,
1838+
commandLineOptions=self._CommandLineOptions,
1839+
variableFilter=self._variableFilter,
1840+
customBuildDirectory=self._customBuildDirectory,
1841+
omhome=self._omhome,
1842+
)
1843+
1844+
self._simargs = simargs
1845+
self._timeout = timeout
1846+
1847+
if isinstance(resultpath, pathlib.Path):
1848+
self._resultpath = resultpath
1849+
else:
1850+
self._resultpath = pathlib.Path('.')
1851+
1852+
if isinstance(parameters, dict):
1853+
self._parameters = parameters
1854+
else:
1855+
self._parameters = {}
1856+
1857+
self._sim_dict: Optional[dict[str, dict[str, Any]]] = None
1858+
self._sim_task_query: queue.Queue = queue.Queue()
1859+
1860+
def prepare(self) -> int:
1861+
"""
1862+
Prepare the DoE by evaluating the parameters. Each structural parameter requires a new instance of
1863+
ModelicaSystem while the non-structural parameters can just be set on the executable.
1864+
1865+
The return value is the number of simulation defined.
1866+
"""
1867+
1868+
param_structure = {}
1869+
param_simple = {}
1870+
for param_name in self._parameters.keys():
1871+
changeable = self._mod.isParameterChangeable(name=param_name)
1872+
logger.info(f"Parameter {repr(param_name)} is changeable? {changeable}")
1873+
1874+
if changeable:
1875+
param_simple[param_name] = self._parameters[param_name]
1876+
else:
1877+
param_structure[param_name] = self._parameters[param_name]
1878+
1879+
param_structure_combinations = list(itertools.product(*param_structure.values()))
1880+
param_simple_combinations = list(itertools.product(*param_simple.values()))
1881+
1882+
self._sim_dict = {}
1883+
for idx_pc_structure, pc_structure in enumerate(param_structure_combinations):
1884+
mod_structure = ModelicaSystem(
1885+
fileName=self._fileName,
1886+
modelName=self._modelName,
1887+
lmodel=self._lmodel,
1888+
commandLineOptions=self._CommandLineOptions,
1889+
variableFilter=self._variableFilter,
1890+
customBuildDirectory=self._customBuildDirectory,
1891+
omhome=self._omhome,
1892+
build=False,
1893+
)
1894+
1895+
sim_param_structure = {}
1896+
for idx_structure, pk_structure in enumerate(param_structure.keys()):
1897+
sim_param_structure[pk_structure] = pc_structure[idx_structure]
1898+
1899+
pk_value = pc_structure[idx_structure]
1900+
if isinstance(pk_value, str):
1901+
expression = f"setParameterValue({self._modelName}, {pk_structure}, \"{pk_value}\")"
1902+
elif isinstance(pk_value, bool):
1903+
pk_value_bool_str = "true" if pk_value else "false"
1904+
expression = f"setParameterValue({self._modelName}, {pk_structure}, {pk_value_bool_str});"
1905+
else:
1906+
expression = f"setParameterValue({self._modelName}, {pk_structure}, {pk_value})"
1907+
res = mod_structure.sendExpression(expression)
1908+
if not res:
1909+
raise ModelicaSystemError(f"Cannot set structural parameter {self._modelName}.{pk_structure} "
1910+
f"to {pk_value} using {repr(expression)}")
1911+
1912+
mod_structure.buildModel(variableFilter=self._variableFilter)
1913+
1914+
for idx_pc_simple, pc_simple in enumerate(param_simple_combinations):
1915+
sim_param_simple = {}
1916+
for idx_simple, pk_simple in enumerate(param_simple.keys()):
1917+
sim_param_simple[pk_simple] = cast(Any, pc_simple[idx_simple])
1918+
1919+
resfilename = f"DOE_{idx_pc_structure:09d}_{idx_pc_simple:09d}.mat"
1920+
logger.info(f"use result file {repr(resfilename)} "
1921+
f"for structural parameters: {sim_param_structure} "
1922+
f"and simple parameters: {sim_param_simple}")
1923+
resultfile = self._resultpath / resfilename
1924+
1925+
df_data = (
1926+
{
1927+
'ID structure': idx_pc_structure,
1928+
}
1929+
| sim_param_structure
1930+
| {
1931+
'ID non-structure': idx_pc_simple,
1932+
}
1933+
| sim_param_simple
1934+
| {
1935+
self.DICT_RESULT_AVAILABLE: False,
1936+
}
1937+
)
1938+
1939+
self._sim_dict[resfilename] = df_data
1940+
1941+
mscmd = mod_structure.simulate_cmd(
1942+
result_file=resultfile.absolute().resolve(),
1943+
timeout=self._timeout,
1944+
)
1945+
if self._simargs is not None:
1946+
mscmd.args_set(args=self._simargs)
1947+
mscmd.args_set(args={"override": sim_param_simple})
1948+
1949+
self._sim_task_query.put(mscmd)
1950+
1951+
logger.info(f"Prepared {self._sim_task_query.qsize()} simulation definitions for the defined DoE.")
1952+
1953+
return self._sim_task_query.qsize()
1954+
1955+
def get_doe(self) -> Optional[dict[str, dict[str, Any]]]:
1956+
"""
1957+
Get the defined DoE as a dict, where each key is the result filename and the value is a dict of simulation
1958+
settings including structural and non-structural parameters.
1959+
1960+
The following code snippet can be used to convert the data to a pandas dataframe:
1961+
1962+
```
1963+
import pandas as pd
1964+
1965+
doe_dict = doe_mod.get_doe()
1966+
doe_df = pd.DataFrame.from_dict(data=doe_dict, orient='index')
1967+
```
1968+
1969+
"""
1970+
return self._sim_dict
1971+
1972+
def simulate(
1973+
self,
1974+
num_workers: int = 3,
1975+
) -> bool:
1976+
"""
1977+
Simulate the DoE using the defined number of workers.
1978+
1979+
Returns True if all simulations were done successfully, else False.
1980+
"""
1981+
1982+
sim_query_total = self._sim_task_query.qsize()
1983+
if not isinstance(self._sim_dict, dict) or len(self._sim_dict) == 0:
1984+
raise ModelicaSystemError("Missing Doe Summary!")
1985+
sim_dict_total = len(self._sim_dict)
1986+
1987+
def worker(worker_id, task_queue):
1988+
while True:
1989+
try:
1990+
# Get the next task from the queue
1991+
mscmd = task_queue.get(block=False)
1992+
except queue.Empty:
1993+
logger.info(f"[Worker {worker_id}] No more simulations to run.")
1994+
break
1995+
1996+
if mscmd is None:
1997+
raise ModelicaSystemError("Missing simulation definition!")
1998+
1999+
resultfile = mscmd.arg_get(key='r')
2000+
resultpath = pathlib.Path(resultfile)
2001+
2002+
logger.info(f"[Worker {worker_id}] Performing task: {resultpath.name}")
2003+
2004+
try:
2005+
mscmd.run()
2006+
except ModelicaSystemError as ex:
2007+
logger.warning(f"Simulation error for {resultpath.name}: {ex}")
2008+
2009+
# Mark the task as done
2010+
task_queue.task_done()
2011+
2012+
sim_query_done = sim_query_total - self._sim_task_query.qsize()
2013+
logger.info(f"[Worker {worker_id}] Task completed: {resultpath.name} "
2014+
f"({sim_query_total - sim_query_done}/{sim_query_total} = "
2015+
f"{(sim_query_total - sim_query_done) / sim_query_total * 100:.2f}% of tasks left)")
2016+
2017+
logger.info(f"Start simulations for DoE with {sim_query_total} simulations "
2018+
f"using {num_workers} workers ...")
2019+
2020+
# Create and start worker threads
2021+
threads = []
2022+
for i in range(num_workers):
2023+
thread = threading.Thread(target=worker, args=(i, self._sim_task_query))
2024+
thread.start()
2025+
threads.append(thread)
2026+
2027+
# Wait for all threads to complete
2028+
for thread in threads:
2029+
thread.join()
2030+
2031+
sim_dict_done = 0
2032+
for resultfilename in self._sim_dict:
2033+
resultfile = self._resultpath / resultfilename
2034+
2035+
# include check for an empty (=> 0B) result file which indicates a crash of the model executable
2036+
# see: https://github.com/OpenModelica/OMPython/issues/261
2037+
# https://github.com/OpenModelica/OpenModelica/issues/13829
2038+
if resultfile.is_file() and resultfile.stat().st_size > 0:
2039+
self._sim_dict[resultfilename][self.DICT_RESULT_AVAILABLE] = True
2040+
sim_dict_done += 1
2041+
2042+
logger.info(f"All workers finished ({sim_dict_done} of {sim_dict_total} simulations with a result file).")
2043+
2044+
return sim_dict_total == sim_dict_done
2045+
2046+
def get_solutions(
2047+
self,
2048+
var_list: Optional[list] = None,
2049+
) -> Optional[tuple[str] | dict[str, dict[str, np.ndarray]]]:
2050+
"""
2051+
Get all solutions of the DoE run. The following return values are possible:
2052+
2053+
* A list of variables if val_list == None
2054+
2055+
* The Solutions as dict[str, pd.DataFrame] if a value list (== val_list) is defined.
2056+
2057+
The following code snippet can be used to convert the solution data for each run to a pandas dataframe:
2058+
2059+
```
2060+
import pandas as pd
2061+
2062+
doe_sol = doe_mod.get_solutions()
2063+
for key in doe_sol:
2064+
data = doe_sol[key]['data']
2065+
if data:
2066+
doe_sol[key]['df'] = pd.DataFrame.from_dict(data=data)
2067+
else:
2068+
doe_sol[key]['df'] = None
2069+
```
2070+
2071+
"""
2072+
if not isinstance(self._sim_dict, dict):
2073+
return None
2074+
2075+
if len(self._sim_dict) == 0:
2076+
raise ModelicaSystemError("No result files available - all simulations did fail?")
2077+
2078+
sol_dict: dict[str, dict[str, Any]] = {}
2079+
for resultfilename in self._sim_dict:
2080+
resultfile = self._resultpath / resultfilename
2081+
2082+
sol_dict[resultfilename] = {}
2083+
2084+
if not self._sim_dict[resultfilename][self.DICT_RESULT_AVAILABLE]:
2085+
sol_dict[resultfilename]['msg'] = 'No result file available!'
2086+
sol_dict[resultfilename]['data'] = {}
2087+
continue
2088+
2089+
if var_list is None:
2090+
var_list_row = list(self._mod.getSolutions(resultfile=resultfile.as_posix()))
2091+
else:
2092+
var_list_row = var_list
2093+
2094+
try:
2095+
sol = self._mod.getSolutions(varList=var_list_row, resultfile=resultfile.as_posix())
2096+
sol_data = {var: sol[idx] for idx, var in enumerate(var_list_row)}
2097+
sol_dict[resultfilename]['msg'] = 'Simulation available'
2098+
sol_dict[resultfilename]['data'] = sol_data
2099+
except ModelicaSystemError as ex:
2100+
msg = f"Error reading solution for {resultfilename}: {ex}"
2101+
logger.warning(msg)
2102+
sol_dict[resultfilename]['msg'] = msg
2103+
sol_dict[resultfilename]['data'] = {}
2104+
2105+
return sol_dict

0 commit comments

Comments
 (0)