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