From 2dc57cf448b07e56d3ba97b2c7f47b24fd96295d Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Mon, 27 Apr 2026 09:48:28 +0200 Subject: [PATCH 01/31] update save parquet for compressions --- ebcpy/data_types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ebcpy/data_types.py b/ebcpy/data_types.py index 65a8a33..1799092 100644 --- a/ebcpy/data_types.py +++ b/ebcpy/data_types.py @@ -113,7 +113,12 @@ def save(self, filepath: str = None, **kwargs) -> None: self._obj.to_csv(filepath, sep=kwargs.get("sep", ",")) elif ".parquet" in filepath.name: parquet_split = filepath.name.split(".parquet") - self._obj.to_parquet( + # Parquet doesn't support SparseDtype — densify before writing + df_to_save = self._obj + for col in df_to_save.columns: + if isinstance(df_to_save[col].dtype, pd.SparseDtype): + df_to_save[col] = df_to_save[col].sparse.to_dense() + df_to_save.to_parquet( filepath, engine=kwargs.get('engine', 'pyarrow'), compression=parquet_split[-1][1:] if parquet_split[-1] else None, index=True From c9441534c7e96d840d54b1e87e04a102da660114 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Mon, 27 Apr 2026 14:06:25 +0200 Subject: [PATCH 02/31] Add deprecation warnings for the structual parameters keywords --- ebcpy/simulationapi/dymola_api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ebcpy/simulationapi/dymola_api.py b/ebcpy/simulationapi/dymola_api.py index 0dae2cb..0c4fb20 100644 --- a/ebcpy/simulationapi/dymola_api.py +++ b/ebcpy/simulationapi/dymola_api.py @@ -76,6 +76,9 @@ class DymolaAPI(SimulationAPI): :keyword Boolean show_window: True to show the Dymola window. Default is False :keyword Boolean modify_structural_parameters: + .. deprecated:: + Will be removed in the next major release. + Use model name modifiers directly instead. True to automatically set the structural parameters of the simulation model via Modelica modifiers. Default is True. See also the keyword ``structural_parameters`` @@ -198,6 +201,14 @@ def __init__( self.fully_initialized = False self.debug = kwargs.pop("debug", False) self.show_window = kwargs.pop("show_window", False) + if "modify_structural_parameters" in kwargs: + if kwargs.pop("modify_structural_parameters", True) is not True: + warnings.warn( + "'modify_structural_parameters' is deprecated and will be removed " + "in the next major release. Use model name modifiers directly instead.", + FutureWarning, + stacklevel=2, + ) self.modify_structural_parameters = kwargs.pop("modify_structural_parameters", True) self.equidistant_output = kwargs.pop("equidistant_output", True) _variables_to_save = kwargs.pop("variables_to_save", {}) @@ -369,6 +380,10 @@ def simulate(self, :keyword dict kwargs_postprocessing: Keyword arguments used in the function `postprocess_mat_result`. :keyword List[str] structural_parameters: + .. deprecated:: + Will be removed in the next major release. + Write modifiers directly in the model_name instead, e.g. + ``model_name='MyModel(myParam=newValue)'`` A list containing all parameter names which are structural in Modelica. This means a modifier has to be created in order to change the value of this parameter. Internally, the given list @@ -386,6 +401,15 @@ def simulate(self, """ # Handle special case for structural_parameters if "structural_parameters" in kwargs: + warnings.warn( + "The 'structural_parameters' keyword argument is deprecated and will " + "be removed in the next major release. Instead, write modifiers directly " + "in the model_name, e.g.:\n" + " model_name='MyModel(myParam=newValue)'\n" + "or pass modified model names via the 'model_names' keyword argument of simulate().", + FutureWarning, + stacklevel=2, + ) _struc_params = kwargs["structural_parameters"] # Check if input is 2-dimensional for multiprocessing. # If not, make it 2-dimensional to avoid list flattening in From f9be639d10815d38508b1e1e4d7f9e032a6a5aee Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Mon, 27 Apr 2026 14:17:23 +0200 Subject: [PATCH 03/31] add first raw workflow for new dymola example --- examples/e3_0_simple_dymola_example.py | 175 +++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 examples/e3_0_simple_dymola_example.py diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py new file mode 100644 index 0000000..a6a1539 --- /dev/null +++ b/examples/e3_0_simple_dymola_example.py @@ -0,0 +1,175 @@ +""" +Goals of this part of the examples: +1. Learn a basic workflow for Dymola simulation studies +""" +import datetime +import os +from pathlib import Path +from typing import Union, List + +import numpy as np +import pandas as pd + +from ebcpy import DymolaAPI, load_time_series_data + +def convert_to_datetime_and_csv(mat_result_file, first_day_of_year, variable_names): + df = load_time_series_data(mat_result_file, variable_names=variable_names) + df.tsd.to_datetime_index(origin=first_day_of_year) + df_path = Path(mat_result_file).with_suffix(".parquet") + df.tsd.save(df_path) + + os.remove(mat_result_file) + return df_path + + +def empty_postprocessing(mat_result, **_kwargs): + return mat_result + + +def simple_dymola_sim_study( + model_names: list[str], + mos_script_pre: Union[str, Path], + parameters: Union[dict, List[dict]] = None, + working_directory: Union[str, Path] = None, + n_cpu: int = 2, + use_postprocessing: bool = True, + use_parameter_study: bool = True, + model_result_file_names: List[str] = None, + save_path: Union[str, Path] = None, +): + # General settings + if working_directory is None: + working_directory = Path(__file__).parent.joinpath("results", "working_directory") + if save_path is None: + save_path = Path(__file__).parent.joinpath("results", "SimResults") + + # keywords practical for debugging + dymola_debugging_kwargs = { + "show_window": True, # opens + "debug": True, + } + + # keywords to increase dymola stability + dymola_stability_kwargs = { + "time_delay_between_starts": 0, # recommended for large models where each model run needs to be translated + } + + + first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) + + simulation_setup = {"start_time": 0, + "stop_time": 3600 * 24 * 30, + "output_interval": 100} + + if use_postprocessing: + postprocess_mat_result = convert_to_datetime_and_csv + kwargs_postprocessing = dict( + variable_names=["*outputs*"], + first_day_of_year=first_day_of_year + ) + else: + postprocess_mat_result = empty_postprocessing + kwargs_postprocessing = {} + + + if use_parameter_study: + all_result_paths = {} + for model_name, result_file_name in zip(model_names, model_result_file_names): + result_file_names = [f"{result_file_name}_{str(param_dict['parameterStudy.VPerQFlow']).replace(".","_")}" for param_dict in parameters] + dym_api = DymolaAPI( + mos_script_pre=mos_script_pre, + model_name=model_name, + working_directory=working_directory, + n_cpu=n_cpu, + n_restart=-1, + equidistant_output=True, + show_window=False, + debug=False, + ) + + + dym_api.set_sim_setup(sim_setup=simulation_setup) + + result_paths = dym_api.simulate( + parameters=parameters, + return_option="savepath", + savepath=save_path, + result_file_name=result_file_names, + postprocess_mat_result=postprocess_mat_result, + kwargs_postprocessing=kwargs_postprocessing, + ) + all_result_paths[model_name] = result_paths + dym_api.close() + return all_result_paths + else: + dym_api = DymolaAPI( + mos_script_pre=mos_script_pre, + # model_name=model_names[0], + working_directory=working_directory, + n_cpu=n_cpu, + show_window=False, + n_restart=-1, + equidistant_output=True, + debug=True, + ) + dym_api.set_sim_setup(sim_setup=simulation_setup) + result_paths = dym_api.simulate( + model_names=model_names, + parameters=parameters, + return_option="savepath", + savepath=save_path, + result_file_name=model_result_file_names, + postprocess_mat_result=postprocess_mat_result, + kwargs_postprocessing=kwargs_postprocessing, + ) + dym_api.close() + return result_paths + +if __name__ == "__main__": + base_model_name = "BESMod.Examples.DesignOptimization.BES" + + storage_layers = np.arange(1,5,1,dtype=int) + model_names_to_simulate = [ + f"{base_model_name}(hydraulic.distribution.parStoBuf(nLayer={n}))" + for n in storage_layers + ] + model_result_names = [f"BufSto_nLayer{n}" for n in storage_layers] + + print(model_names_to_simulate) + + parameter_study_params = [{"parameterStudy.VPerQFlow": np.round(v, decimals=1)} for v in np.linspace(5, 100, 8)] + print(parameter_study_params) + print("study 1") + simple_dymola_sim_study( + model_names=model_names_to_simulate, + mos_script_pre=r"D:\01_git\BESMod\startup.mos", + parameters=parameter_study_params, + use_parameter_study=True, + model_result_file_names=model_result_names, + save_path=Path(__file__).parent.joinpath("results", "SimResults_1") + ) + + print("study 2") + model_study_params = {"parameterStudy.VPerQFlow": 50, "parameterStudy.TBiv": 273.15-5} + simple_dymola_sim_study( + model_names=model_names_to_simulate, + mos_script_pre=r"D:\01_git\BESMod\startup.mos", + parameters=model_study_params, + use_parameter_study=False, + model_result_file_names=model_result_names, + save_path=Path(__file__).parent.joinpath("results", "SimResults_2") + ) + print("study 3") + rng = np.random.default_rng(42) + random_example_values = rng.integers(5, 100, size=len(model_names_to_simulate)) + model_study_div_params = [{"parameterStudy.VPerQFlow": val} for val in random_example_values] + simple_dymola_sim_study( + model_names=model_names_to_simulate, + mos_script_pre=r"D:\01_git\BESMod\startup.mos", + parameters=model_study_div_params, + use_parameter_study=False, + model_result_file_names=model_result_names, + save_path=Path(__file__).parent.joinpath("results", "SimResults_3") + ) + print("finished") + From 48043276e0a5f4e428c48020b740e2512c263c66 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Mon, 27 Apr 2026 15:09:08 +0200 Subject: [PATCH 04/31] update documentation for example e3_0 --- examples/e3_0_simple_dymola_example.py | 224 +++++++++++++++++++------ 1 file changed, 173 insertions(+), 51 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index a6a1539..ecb7044 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -1,28 +1,66 @@ -""" -Goals of this part of the examples: -1. Learn a basic workflow for Dymola simulation studies -""" +# # Example: Basic Dymola Simulation Workflow +# +# Goals of this example: +# 1. Learn the recommended workflow for Dymola simulation studies using ebcpy +# 2. Understand how to run parameter studies across multiple model variants +# 3. Learn how to use model name modifiers +# 4. Learn how to post-process simulation results into usable formats +# +# This example demonstrates three common simulation patterns: +# - **Study 1:** Parameter study — multiple models × multiple parameter sets +# - **Study 2:** Model comparison — multiple models with the same parameters +# - **Study 3:** Model comparison — multiple models with individual parameters +# +# **Prerequisites:** +# This example requires a Dymola installation and the BESMod library. +# Adjust the `mos_script_pre` path to your local BESMod startup script. + import datetime import os from pathlib import Path from typing import Union, List import numpy as np -import pandas as pd from ebcpy import DymolaAPI, load_time_series_data -def convert_to_datetime_and_csv(mat_result_file, first_day_of_year, variable_names): + +def convert_to_datetime_and_parquet(mat_result_file, first_day_of_year, variable_names): + """ + Post-process a Dymola .mat result file into a datetime-indexed parquet file. + + This function is passed to ``DymolaAPI.simulate()`` via the + ``postprocess_mat_result`` keyword. It is called automatically + for each simulation result. You can write your own custom post-processing functions + + :param str mat_result_file: + Path to the .mat file produced by Dymola. + Mandatory parameter passed over by ``DymolaAPI.simulate()`` + :param datetime.datetime first_day_of_year: + Reference datetime for converting the float index (in seconds) + to a DatetimeIndex. + :param list variable_names: + Variable names or wildcard patterns to extract from the .mat file. + E.g. ``["*outputs*"]`` to extract all output variables. + :return: Path to the saved parquet file. + :rtype: Path + """ df = load_time_series_data(mat_result_file, variable_names=variable_names) df.tsd.to_datetime_index(origin=first_day_of_year) + + # do further post-process + df_path = Path(mat_result_file).with_suffix(".parquet") df.tsd.save(df_path) - os.remove(mat_result_file) return df_path def empty_postprocessing(mat_result, **_kwargs): + """ + No-op post-processing function. Returns the .mat file path unchanged. + Use this when you want to keep the original .mat result files. + """ return mat_result @@ -37,32 +75,79 @@ def simple_dymola_sim_study( model_result_file_names: List[str] = None, save_path: Union[str, Path] = None, ): - # General settings + """ + Run a Dymola simulation study with multiple models and/or parameter variations. + + This function demonstrates two simulation modes: + + **Parameter study** (``use_parameter_study=True``): + Each model is simulated separately with all parameter sets. + Useful when you want the full cross-product of models × parameters. + Each model gets its own DymolaAPI instance, which translates the model + once and then runs all parameter variations. + + **Model comparison** (``use_parameter_study=False``): + All models are simulated in a single ``simulate()`` call using the + ``model_names`` keyword. Each model can receive the same parameters + (pass a single dict) or individual parameters (pass a list of dicts + matching the length of ``model_names``). Each model run will be translated + + Both modes use model name modifiers to change structural parameters. + This is the recommended approach — write the modifier directly in the + model name string. + + :param list[str] model_names: + List of Dymola model names, optionally with modifiers. + E.g. ``["MyModel(nLayer=1)", "MyModel(nLayer=2)"]`` + :param str,Path mos_script_pre: + Path to a .mos script executed before loading packages. + Typically the startup script of your Modelica library. + :param dict,list[dict] parameters: + Parameter values for the simulation. For parameter studies, pass a list + of dicts. For model comparison, pass a single dict (applied to all models) + or a list of dicts (one per model). + :param str,Path working_directory: + Dymola working directory. Default is ``./results/working_directory``. + :param int n_cpu: + Number of parallel Dymola processes. Default is 2. + :param bool use_postprocessing: + If True, .mat files are converted to datetime-indexed parquet files. + If False, raw .mat files are kept. + :param bool use_parameter_study: + If True, runs each model with all parameter sets (cross-product). + If False, runs all models in a single call. + :param list[str] model_result_file_names: + Base names for the result files, one per model. + :param str,Path save_path: + Directory for saving simulation results. Default is ``./results/SimResults``. + :return: Result file paths. For parameter studies, a dict mapping model names + to lists of paths. For model comparison, a list of paths. + :rtype: dict or list + """ + # ## Default paths if working_directory is None: working_directory = Path(__file__).parent.joinpath("results", "working_directory") if save_path is None: save_path = Path(__file__).parent.joinpath("results", "SimResults") - # keywords practical for debugging - dymola_debugging_kwargs = { - "show_window": True, # opens - "debug": True, - } - - # keywords to increase dymola stability - dymola_stability_kwargs = { - "time_delay_between_starts": 0, # recommended for large models where each model run needs to be translated - } - - + # ## Simulation setup + # Define the time range and output resolution for all simulations. + # The output_interval determines the time step of the result data. first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) + simulation_setup = { + "start_time": 0, + "stop_time": 3600 * 24 * 30, # 30 days + "output_interval": 100 # one data point every 100 s + } + additional_packages = [] # use this for custom packages which are note in the mos_script_pre - simulation_setup = {"start_time": 0, - "stop_time": 3600 * 24 * 30, - "output_interval": 100} - + # ## Post-processing setup + # Dymola produces .mat files by default. These are large and use a float + # index (seconds). The post-processing function converts them to + # datetime-indexed parquet files containing only the variables we need. + # If you want to keep the raw .mat files, set use_postprocessing=False. if use_postprocessing: - postprocess_mat_result = convert_to_datetime_and_csv + postprocess_mat_result = convert_to_datetime_and_parquet kwargs_postprocessing = dict( variable_names=["*outputs*"], first_day_of_year=first_day_of_year @@ -71,23 +156,29 @@ def simple_dymola_sim_study( postprocess_mat_result = empty_postprocessing kwargs_postprocessing = {} - + # ## Run simulations if use_parameter_study: + # ### Parameter study mode + # Iterate over each model variant. For each model, a separate DymolaAPI + # instance is created, the model is translated once, and all parameter + # sets are simulated. This is efficient because translation (the slow part) + # happens only once per model. all_result_paths = {} for model_name, result_file_name in zip(model_names, model_result_file_names): - result_file_names = [f"{result_file_name}_{str(param_dict['parameterStudy.VPerQFlow']).replace(".","_")}" for param_dict in parameters] + # Create unique result file names by encoding the varied parameter value + result_file_names = [ + f"{result_file_name}_VPerQFlow{str(param_dict['parameterStudy.VPerQFlow']).replace('.', '_')}" + for param_dict in parameters + ] + dym_api = DymolaAPI( mos_script_pre=mos_script_pre, model_name=model_name, working_directory=working_directory, n_cpu=n_cpu, - n_restart=-1, equidistant_output=True, - show_window=False, - debug=False, + packages=additional_packages ) - - dym_api.set_sim_setup(sim_setup=simulation_setup) result_paths = dym_api.simulate( @@ -100,19 +191,24 @@ def simple_dymola_sim_study( ) all_result_paths[model_name] = result_paths dym_api.close() + return all_result_paths + else: + # ### Model comparison mode + # All models are simulated in a single DymolaAPI call using the + # model_names keyword. Dymola translates each model on the fly. + # This is convenient for comparing different model configurations + # with the same or individual parameter sets. dym_api = DymolaAPI( mos_script_pre=mos_script_pre, - # model_name=model_names[0], working_directory=working_directory, n_cpu=n_cpu, - show_window=False, - n_restart=-1, equidistant_output=True, - debug=True, + packages=additional_packages ) dym_api.set_sim_setup(sim_setup=simulation_setup) + result_paths = dym_api.simulate( model_names=model_names, parameters=parameters, @@ -123,53 +219,79 @@ def simple_dymola_sim_study( kwargs_postprocessing=kwargs_postprocessing, ) dym_api.close() + return result_paths + if __name__ == "__main__": + # ## Define model variants + # We use model name modifiers to vary the number of storage layers. + # This is the recommended approach for structural parameters which need a retranslation — + # write the modifier directly in the model name string. You can also redeclare models here. base_model_name = "BESMod.Examples.DesignOptimization.BES" - - storage_layers = np.arange(1,5,1,dtype=int) + storage_layers = np.arange(1, 5, 1, dtype=int) model_names_to_simulate = [ f"{base_model_name}(hydraulic.distribution.parStoBuf(nLayer={n}))" for n in storage_layers ] model_result_names = [f"BufSto_nLayer{n}" for n in storage_layers] - print(model_names_to_simulate) + print("Model variants:", model_names_to_simulate) + + # ## Define parameter variations + parameter_study_params = [ + {"parameterStudy.VPerQFlow": np.round(v, decimals=1)} + for v in np.linspace(5, 100, 8) + ] + print("Parameter sets:", parameter_study_params) - parameter_study_params = [{"parameterStudy.VPerQFlow": np.round(v, decimals=1)} for v in np.linspace(5, 100, 8)] - print(parameter_study_params) - print("study 1") + # ## Study 1: Parameter study + # Each model variant is simulated with all parameter sets. + # Total simulations: len(model_names) × len(parameter_sets) = 4 × 8 = 32 + print("\n--- Study 1: Parameter study ---") simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=r"D:\01_git\BESMod\startup.mos", parameters=parameter_study_params, use_parameter_study=True, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_1") + save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), ) - - print("study 2") - model_study_params = {"parameterStudy.VPerQFlow": 50, "parameterStudy.TBiv": 273.15-5} + + # ## Study 2: Model comparison with shared parameters + # All model variants are simulated with the same parameter set. + # Total simulations: len(model_names) = 4 + print("\n--- Study 2: Model comparison (shared parameters) ---") + model_study_params = { + "parameterStudy.VPerQFlow": 50, + "parameterStudy.TBiv": 273.15 - 5 + } simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=r"D:\01_git\BESMod\startup.mos", parameters=model_study_params, use_parameter_study=False, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_2") + save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), ) - print("study 3") + + # ## Study 3: Model comparison with individual parameters + # Each model variant gets its own parameter set. + # Total simulations: len(model_names) = 4 + print("\n--- Study 3: Model comparison (individual parameters) ---") rng = np.random.default_rng(42) random_example_values = rng.integers(5, 100, size=len(model_names_to_simulate)) - model_study_div_params = [{"parameterStudy.VPerQFlow": val} for val in random_example_values] + model_study_div_params = [ + {"parameterStudy.VPerQFlow": int(val)} + for val in random_example_values + ] simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=r"D:\01_git\BESMod\startup.mos", parameters=model_study_div_params, use_parameter_study=False, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_3") + save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), ) - print("finished") + print("\n--- All studies finished ---") \ No newline at end of file From ec4a98f18307f7d6dce387b82b82bd4f4467322e Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 08:24:20 +0200 Subject: [PATCH 05/31] make example more reusable --- examples/e3_0_simple_dymola_example.py | 121 +++++++++++++++++++------ 1 file changed, 93 insertions(+), 28 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index ecb7044..7e47c72 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -25,7 +25,31 @@ from ebcpy import DymolaAPI, load_time_series_data -def convert_to_datetime_and_parquet(mat_result_file, first_day_of_year, variable_names): +def filter_strings(strings, required_substrings, forbidden_substrings=None): + """ + Filters and returns strings that: + - Contain all substrings from required_substrings. + - Do not contain any substring from forbidden_substrings. + + Parameters: + strings (list of str): The list of strings to search within. + required_substrings (list of str): The substrings that each string must contain. + forbidden_substrings (list of str, optional): The substrings that must not be present in any string. + Defaults to an empty list if not provided. + + Returns: + list of str: A list of strings that meet both criteria. + """ + if forbidden_substrings is None: + forbidden_substrings = [] + + return [ + s for s in strings + if all(req in s for req in required_substrings) and all(forb not in s for forb in forbidden_substrings) + ] + + +def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): """ Post-process a Dymola .mat result file into a datetime-indexed parquet file. @@ -48,7 +72,10 @@ def convert_to_datetime_and_parquet(mat_result_file, first_day_of_year, variable df = load_time_series_data(mat_result_file, variable_names=variable_names) df.tsd.to_datetime_index(origin=first_day_of_year) - # do further post-process + # adapt this to your study + variables = df.columns.to_list() + var_names_layer_temp = filter_strings(variables, ["layer", "T"]) + df["stoBuf.mean_T"] = df[var_names_layer_temp].mean(axis=1) df_path = Path(mat_result_file).with_suffix(".parquet") df.tsd.save(df_path) @@ -65,15 +92,18 @@ def empty_postprocessing(mat_result, **_kwargs): def simple_dymola_sim_study( - model_names: list[str], + model_names: List[str], mos_script_pre: Union[str, Path], + simulation_setup, parameters: Union[dict, List[dict]] = None, working_directory: Union[str, Path] = None, n_cpu: int = 2, use_postprocessing: bool = True, - use_parameter_study: bool = True, + use_parameter_study: bool = False, model_result_file_names: List[str] = None, save_path: Union[str, Path] = None, + kwargs_postprocessing: dict = None, + **kwargs ): """ Run a Dymola simulation study with multiple models and/or parameter variations. @@ -133,13 +163,8 @@ def simple_dymola_sim_study( # ## Simulation setup # Define the time range and output resolution for all simulations. # The output_interval determines the time step of the result data. - first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) - simulation_setup = { - "start_time": 0, - "stop_time": 3600 * 24 * 30, # 30 days - "output_interval": 100 # one data point every 100 s - } - additional_packages = [] # use this for custom packages which are note in the mos_script_pre + + additional_packages = kwargs.pop("additional_packages", []) # use this for custom packages which are note in the mos_script_pre # ## Post-processing setup # Dymola produces .mat files by default. These are large and use a float @@ -147,11 +172,9 @@ def simple_dymola_sim_study( # datetime-indexed parquet files containing only the variables we need. # If you want to keep the raw .mat files, set use_postprocessing=False. if use_postprocessing: - postprocess_mat_result = convert_to_datetime_and_parquet - kwargs_postprocessing = dict( - variable_names=["*outputs*"], - first_day_of_year=first_day_of_year - ) + postprocess_mat_result = custom_postprocessing + if kwargs_postprocessing is None: + raise Exception else: postprocess_mat_result = empty_postprocessing kwargs_postprocessing = {} @@ -165,11 +188,13 @@ def simple_dymola_sim_study( # happens only once per model. all_result_paths = {} for model_name, result_file_name in zip(model_names, model_result_file_names): - # Create unique result file names by encoding the varied parameter value - result_file_names = [ - f"{result_file_name}_VPerQFlow{str(param_dict['parameterStudy.VPerQFlow']).replace('.', '_')}" - for param_dict in parameters - ] + # Create unique result file names by encoding the varied parameter values + result_file_names = [] + for param_dict in parameters: + name = result_file_name + for key, val in param_dict.items(): + name += f"_{key.split(".")[-1]}{val.replace('.', '_')}" + result_file_names.append(name) dym_api = DymolaAPI( mos_script_pre=mos_script_pre, @@ -224,6 +249,16 @@ def simple_dymola_sim_study( if __name__ == "__main__": + # TODO: Adjust this path to your local BESMod startup script + MOS_SCRIPT = r"D:\01_git\BESMod\startup.mos" + + # adapt this to your study + simulation_setup = { + "start_time": 0, + "stop_time": 3600 * 24 * 30, # 30 days + "output_interval": 900 # one data point every 900 s (15 min) + } + # ## Define model variants # We use model name modifiers to vary the number of storage layers. # This is the recommended approach for structural parameters which need a retranslation — @@ -238,6 +273,31 @@ def simple_dymola_sim_study( print("Model variants:", model_names_to_simulate) + # ## Study 0: Single model, default parameters + # The simplest possible simulation — verify your setup works. + print("\n--- Study 0: Single model ---") + result_paths_study_0 = simple_dymola_sim_study( + model_names=[model_names_to_simulate[0]], + mos_script_pre=MOS_SCRIPT, + simulation_setup=simulation_setup, + use_parameter_study=False, + use_postprocessing=False, + model_result_file_names=[model_result_names[0]], + save_path=Path(__file__).parent.joinpath("results", "SimResults_0"), + ) + # adapt this to your study + first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) + kwargs_postprocessing = dict( + variable_names=["outputs*", + "hydraulic.distribution.stoBuf.layer[*].T", + "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", + "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", + "hydraulic.distribution.sigBusDis.*" + "hydraulic.generation.sigBusGen.*"], + first_day_of_year=first_day_of_year + ) + + # ## Study 1: Parameter study # ## Define parameter variations parameter_study_params = [ {"parameterStudy.VPerQFlow": np.round(v, decimals=1)} @@ -245,17 +305,18 @@ def simple_dymola_sim_study( ] print("Parameter sets:", parameter_study_params) - # ## Study 1: Parameter study # Each model variant is simulated with all parameter sets. # Total simulations: len(model_names) × len(parameter_sets) = 4 × 8 = 32 print("\n--- Study 1: Parameter study ---") - simple_dymola_sim_study( + result_paths_study_1 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=r"D:\01_git\BESMod\startup.mos", + mos_script_pre=MOS_SCRIPT, + simulation_setup=simulation_setup, parameters=parameter_study_params, use_parameter_study=True, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), + kwargs_postprocessing=kwargs_postprocessing, ) # ## Study 2: Model comparison with shared parameters @@ -266,13 +327,15 @@ def simple_dymola_sim_study( "parameterStudy.VPerQFlow": 50, "parameterStudy.TBiv": 273.15 - 5 } - simple_dymola_sim_study( + result_paths_study_2 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=r"D:\01_git\BESMod\startup.mos", + mos_script_pre=MOS_SCRIPT, + simulation_setup=simulation_setup, parameters=model_study_params, use_parameter_study=False, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), + kwargs_postprocessing=kwargs_postprocessing, ) # ## Study 3: Model comparison with individual parameters @@ -285,13 +348,15 @@ def simple_dymola_sim_study( {"parameterStudy.VPerQFlow": int(val)} for val in random_example_values ] - simple_dymola_sim_study( + result_paths_study_3 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=r"D:\01_git\BESMod\startup.mos", + mos_script_pre=MOS_SCRIPT, + simulation_setup=simulation_setup, parameters=model_study_div_params, use_parameter_study=False, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), + kwargs_postprocessing=kwargs_postprocessing, ) print("\n--- All studies finished ---") \ No newline at end of file From 9d12afa03fc266c994c0b9d650b974ce3c02805d Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 09:09:32 +0200 Subject: [PATCH 06/31] clean up new example --- examples/e3_0_simple_dymola_example.py | 203 +++++++++++++++---------- 1 file changed, 123 insertions(+), 80 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index 7e47c72..79e6f19 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -1,19 +1,20 @@ # # Example: Basic Dymola Simulation Workflow # # Goals of this example: -# 1. Learn the recommended workflow for Dymola simulation studies using ebcpy +# 1. Learn a common workflow for Dymola simulation studies using ebcpy # 2. Understand how to run parameter studies across multiple model variants # 3. Learn how to use model name modifiers # 4. Learn how to post-process simulation results into usable formats # -# This example demonstrates three common simulation patterns: +# This example demonstrates common simulation patterns: +# - **Study 0:** Single model — verify your setup works # - **Study 1:** Parameter study — multiple models × multiple parameter sets # - **Study 2:** Model comparison — multiple models with the same parameters # - **Study 3:** Model comparison — multiple models with individual parameters # # **Prerequisites:** -# This example requires a Dymola installation and the BESMod library. -# Adjust the `mos_script_pre` path to your local BESMod startup script. +# This example requires a Dymola installation and the BESMod library with AixLib. +# Adjust the ``MOS_SCRIPT`` path to your local BESMod startup script. import datetime import os @@ -27,25 +28,24 @@ def filter_strings(strings, required_substrings, forbidden_substrings=None): """ - Filters and returns strings that: - - Contain all substrings from required_substrings. - - Do not contain any substring from forbidden_substrings. - - Parameters: - strings (list of str): The list of strings to search within. - required_substrings (list of str): The substrings that each string must contain. - forbidden_substrings (list of str, optional): The substrings that must not be present in any string. - Defaults to an empty list if not provided. - - Returns: - list of str: A list of strings that meet both criteria. + Helper function for filtering strings. + Filter strings that contain all required substrings and none of the forbidden ones. + + :param list[str] strings: + The list of strings to filter. + :param list[str] required_substrings: + Substrings that each string must contain. + :param list[str] forbidden_substrings: + Substrings that must not be present. Default is none. + :return: Filtered list of strings. + :rtype: list[str] """ if forbidden_substrings is None: forbidden_substrings = [] - return [ s for s in strings - if all(req in s for req in required_substrings) and all(forb not in s for forb in forbidden_substrings) + if all(req in s for req in required_substrings) + and all(forb not in s for forb in forbidden_substrings) ] @@ -55,11 +55,11 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): This function is passed to ``DymolaAPI.simulate()`` via the ``postprocess_mat_result`` keyword. It is called automatically - for each simulation result. You can write your own custom post-processing functions + for each simulation result. Adapt this function to your study. :param str mat_result_file: Path to the .mat file produced by Dymola. - Mandatory parameter passed over by ``DymolaAPI.simulate()`` + Mandatory parameter passed by ``DymolaAPI.simulate()``. :param datetime.datetime first_day_of_year: Reference datetime for converting the float index (in seconds) to a DatetimeIndex. @@ -72,13 +72,21 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): df = load_time_series_data(mat_result_file, variable_names=variable_names) df.tsd.to_datetime_index(origin=first_day_of_year) - # adapt this to your study + # --- Adapt this section to your study --- + # Example: compute mean storage temperature across all layers variables = df.columns.to_list() - var_names_layer_temp = filter_strings(variables, ["layer", "T"]) - df["stoBuf.mean_T"] = df[var_names_layer_temp].mean(axis=1) - + layer_temp_vars = filter_strings(variables, ["layer", "T"]) + if layer_temp_vars: + df["stoBuf.mean_T"] = df[layer_temp_vars].mean(axis=1) + + # save the partial result dataframe in a different format + # Here we use parquet because it is fast and results in small file sizes + # If you also save variables with a lot of constant segments + # you can use further compressions like ".parquet.snappy" + # for small and short studies you could also use csv but this is not recommended df_path = Path(mat_result_file).with_suffix(".parquet") df.tsd.save(df_path) + # remove the old mat file os.remove(mat_result_file) return df_path @@ -94,7 +102,7 @@ def empty_postprocessing(mat_result, **_kwargs): def simple_dymola_sim_study( model_names: List[str], mos_script_pre: Union[str, Path], - simulation_setup, + simulation_setup: dict, parameters: Union[dict, List[dict]] = None, working_directory: Union[str, Path] = None, n_cpu: int = 2, @@ -103,12 +111,14 @@ def simple_dymola_sim_study( model_result_file_names: List[str] = None, save_path: Union[str, Path] = None, kwargs_postprocessing: dict = None, + postprocess_mat_result=None, + packages: List[Union[str, Path]] = None, **kwargs ): """ Run a Dymola simulation study with multiple models and/or parameter variations. - This function demonstrates two simulation modes: + This function supports two simulation modes: **Parameter study** (``use_parameter_study=True``): Each model is simulated separately with all parameter sets. @@ -120,7 +130,8 @@ def simple_dymola_sim_study( All models are simulated in a single ``simulate()`` call using the ``model_names`` keyword. Each model can receive the same parameters (pass a single dict) or individual parameters (pass a list of dicts - matching the length of ``model_names``). Each model run will be translated + matching the length of ``model_names``). Each model is translated + individually. Both modes use model name modifiers to change structural parameters. This is the recommended approach — write the modifier directly in the @@ -132,6 +143,9 @@ def simple_dymola_sim_study( :param str,Path mos_script_pre: Path to a .mos script executed before loading packages. Typically the startup script of your Modelica library. + :param dict simulation_setup: + Simulation settings with keys ``start_time``, ``stop_time``, + and ``output_interval``. :param dict,list[dict] parameters: Parameter values for the simulation. For parameter studies, pass a list of dicts. For model comparison, pass a single dict (applied to all models) @@ -141,7 +155,7 @@ def simple_dymola_sim_study( :param int n_cpu: Number of parallel Dymola processes. Default is 2. :param bool use_postprocessing: - If True, .mat files are converted to datetime-indexed parquet files. + If True, .mat files are post-processed using ``postprocess_mat_result``. If False, raw .mat files are kept. :param bool use_parameter_study: If True, runs each model with all parameter sets (cross-product). @@ -150,6 +164,18 @@ def simple_dymola_sim_study( Base names for the result files, one per model. :param str,Path save_path: Directory for saving simulation results. Default is ``./results/SimResults``. + :param dict kwargs_postprocessing: + Keyword arguments passed to the post-processing function. + Required if ``use_postprocessing=True``. + :param callable postprocess_mat_result: + Custom post-processing function. Default is ``custom_postprocessing``. + Signature: ``func(mat_result_file, **kwargs_postprocessing) -> Any`` + :param list packages: + Additional Modelica packages not loaded by ``mos_script_pre``. + :param kwargs: + Additional keyword arguments forwarded to ``DymolaAPI`` constructor + (e.g. ``show_window``, ``debug``, ``n_restart``, ``dymola_version``) + and to ``DymolaAPI.simulate()`` (e.g. ``fail_on_error``). :return: Result file paths. For parameter studies, a dict mapping model names to lists of paths. For model comparison, a list of paths. :rtype: dict or list @@ -159,26 +185,31 @@ def simple_dymola_sim_study( working_directory = Path(__file__).parent.joinpath("results", "working_directory") if save_path is None: save_path = Path(__file__).parent.joinpath("results", "SimResults") - - # ## Simulation setup - # Define the time range and output resolution for all simulations. - # The output_interval determines the time step of the result data. - - additional_packages = kwargs.pop("additional_packages", []) # use this for custom packages which are note in the mos_script_pre + if packages is None: + packages = [] # ## Post-processing setup # Dymola produces .mat files by default. These are large and use a float - # index (seconds). The post-processing function converts them to - # datetime-indexed parquet files containing only the variables we need. - # If you want to keep the raw .mat files, set use_postprocessing=False. + # index (seconds). The post-processing function converts them to a more + # usable format (e.g. datetime-indexed parquet) containing only the + # variables you need. if use_postprocessing: - postprocess_mat_result = custom_postprocessing + if postprocess_mat_result is None: + postprocess_mat_result = custom_postprocessing if kwargs_postprocessing is None: - raise Exception + raise ValueError( + "kwargs_postprocessing is required when use_postprocessing=True. " + "Pass a dict with the keyword arguments for your post-processing function." + ) else: postprocess_mat_result = empty_postprocessing kwargs_postprocessing = {} + # ## Separate kwargs for DymolaAPI constructor and simulate() + # Known simulate() kwargs are forwarded there, everything else goes to DymolaAPI. + simulate_kwarg_keys = {"inputs", "table_name", "file_name", "fail_on_error", "show_eventlog", "squeeze"} + simulate_kwargs = {k: kwargs.pop(k) for k in simulate_kwarg_keys if k in kwargs} + # ## Run simulations if use_parameter_study: # ### Parameter study mode @@ -188,12 +219,14 @@ def simple_dymola_sim_study( # happens only once per model. all_result_paths = {} for model_name, result_file_name in zip(model_names, model_result_file_names): - # Create unique result file names by encoding the varied parameter values + # Create unique result file names by encoding the varied parameter values. + # Adapt this naming scheme to your parameter study. result_file_names = [] for param_dict in parameters: name = result_file_name for key, val in param_dict.items(): - name += f"_{key.split(".")[-1]}{val.replace('.', '_')}" + short_key = key.split(".")[-1] + name += f"_{short_key}{str(val).replace('.', '_')}" result_file_names.append(name) dym_api = DymolaAPI( @@ -202,7 +235,8 @@ def simple_dymola_sim_study( working_directory=working_directory, n_cpu=n_cpu, equidistant_output=True, - packages=additional_packages + packages=packages, + **kwargs ) dym_api.set_sim_setup(sim_setup=simulation_setup) @@ -213,6 +247,7 @@ def simple_dymola_sim_study( result_file_name=result_file_names, postprocess_mat_result=postprocess_mat_result, kwargs_postprocessing=kwargs_postprocessing, + **simulate_kwargs ) all_result_paths[model_name] = result_paths dym_api.close() @@ -230,7 +265,8 @@ def simple_dymola_sim_study( working_directory=working_directory, n_cpu=n_cpu, equidistant_output=True, - packages=additional_packages + packages=packages, + **kwargs ) dym_api.set_sim_setup(sim_setup=simulation_setup) @@ -242,6 +278,7 @@ def simple_dymola_sim_study( result_file_name=model_result_file_names, postprocess_mat_result=postprocess_mat_result, kwargs_postprocessing=kwargs_postprocessing, + **simulate_kwargs ) dym_api.close() @@ -252,94 +289,99 @@ def simple_dymola_sim_study( # TODO: Adjust this path to your local BESMod startup script MOS_SCRIPT = r"D:\01_git\BESMod\startup.mos" - # adapt this to your study - simulation_setup = { + # ## Simulation setup + # Adapt these values to your study. + SIMULATION_SETUP = { "start_time": 0, "stop_time": 3600 * 24 * 30, # 30 days - "output_interval": 900 # one data point every 900 s (15 min) + "output_interval": 900 # one data point every 900 s (15 min) } # ## Define model variants # We use model name modifiers to vary the number of storage layers. - # This is the recommended approach for structural parameters which need a retranslation — - # write the modifier directly in the model name string. You can also redeclare models here. - base_model_name = "BESMod.Examples.DesignOptimization.BES" + # This is the recommended approach for structural parameters which + # need a retranslation — write the modifier directly in the model name + # string. You can also redeclare models here. + BASE_MODEL = "BESMod.Examples.DesignOptimization.BES" storage_layers = np.arange(1, 5, 1, dtype=int) model_names_to_simulate = [ - f"{base_model_name}(hydraulic.distribution.parStoBuf(nLayer={n}))" + f"{BASE_MODEL}(hydraulic.distribution.parStoBuf(nLayer={n}))" for n in storage_layers ] + # Base names for result files — one per model variant model_result_names = [f"BufSto_nLayer{n}" for n in storage_layers] - print("Model variants:", model_names_to_simulate) + # ## Post-processing configuration + # Adapt the variable_names patterns to select which variables + # you want to keep from the .mat files. + FIRST_DAY_OF_YEAR = datetime.datetime(2015, 1, 1, 0, 0) + KWARGS_POSTPROCESSING = dict( + variable_names=[ + "outputs*", # stars as wildcards are supportet + "hydraulic.distribution.stoBuf.layer[*].T", + "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", + "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", + "hydraulic.distribution.sigBusDis.*", + "hydraulic.generation.sigBusGen.*", + ], + first_day_of_year=FIRST_DAY_OF_YEAR, + ) + # ## Study 0: Single model, default parameters # The simplest possible simulation — verify your setup works. print("\n--- Study 0: Single model ---") result_paths_study_0 = simple_dymola_sim_study( model_names=[model_names_to_simulate[0]], mos_script_pre=MOS_SCRIPT, - simulation_setup=simulation_setup, - use_parameter_study=False, + simulation_setup=SIMULATION_SETUP, use_postprocessing=False, model_result_file_names=[model_result_names[0]], save_path=Path(__file__).parent.joinpath("results", "SimResults_0"), ) - # adapt this to your study - first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) - kwargs_postprocessing = dict( - variable_names=["outputs*", - "hydraulic.distribution.stoBuf.layer[*].T", - "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", - "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", - "hydraulic.distribution.sigBusDis.*" - "hydraulic.generation.sigBusGen.*"], - first_day_of_year=first_day_of_year - ) # ## Study 1: Parameter study - # ## Define parameter variations + # Each model variant is simulated with all parameter sets. + # Each parameter set can have multiple and different parameters + # Total simulations: len(model_names) × len(parameter_sets) = 4 × 8 = 32 + print("\n--- Study 1: Parameter study ---") parameter_study_params = [ {"parameterStudy.VPerQFlow": np.round(v, decimals=1)} for v in np.linspace(5, 100, 8) ] print("Parameter sets:", parameter_study_params) - - # Each model variant is simulated with all parameter sets. - # Total simulations: len(model_names) × len(parameter_sets) = 4 × 8 = 32 - print("\n--- Study 1: Parameter study ---") result_paths_study_1 = simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, - simulation_setup=simulation_setup, + simulation_setup=SIMULATION_SETUP, parameters=parameter_study_params, use_parameter_study=True, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), - kwargs_postprocessing=kwargs_postprocessing, + kwargs_postprocessing=KWARGS_POSTPROCESSING, ) # ## Study 2: Model comparison with shared parameters - # All model variants are simulated with the same parameter set. + # All model variants are simulated with the same parameter set. You do not need to specify any parameters. # Total simulations: len(model_names) = 4 print("\n--- Study 2: Model comparison (shared parameters) ---") model_study_params = { "parameterStudy.VPerQFlow": 50, - "parameterStudy.TBiv": 273.15 - 5 + "parameterStudy.TBiv": 273.15 - 5, } result_paths_study_2 = simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, - simulation_setup=simulation_setup, + simulation_setup=SIMULATION_SETUP, parameters=model_study_params, - use_parameter_study=False, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), - kwargs_postprocessing=kwargs_postprocessing, + kwargs_postprocessing=KWARGS_POSTPROCESSING, ) # ## Study 3: Model comparison with individual parameters # Each model variant gets its own parameter set. + # If one model variant does not have a parameter set you have to set an empty dictionary {} # Total simulations: len(model_names) = 4 print("\n--- Study 3: Model comparison (individual parameters) ---") rng = np.random.default_rng(42) @@ -351,12 +393,13 @@ def simple_dymola_sim_study( result_paths_study_3 = simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, - simulation_setup=simulation_setup, + simulation_setup=SIMULATION_SETUP, parameters=model_study_div_params, - use_parameter_study=False, model_result_file_names=model_result_names, save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), - kwargs_postprocessing=kwargs_postprocessing, + kwargs_postprocessing=KWARGS_POSTPROCESSING, ) - print("\n--- All studies finished ---") \ No newline at end of file + print("\n--- All studies finished ---") + print("You could now load the data again in a different scrit with `ebcpy.load_time_series_data()` " + "for plotting and analysis.") \ No newline at end of file From 2d11effca1d9e87e5be6eff04f88e6b89e3634e6 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 09:24:48 +0200 Subject: [PATCH 07/31] fix typo --- examples/e3_0_simple_dymola_example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index 79e6f19..09b9a12 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -105,7 +105,7 @@ def simple_dymola_sim_study( simulation_setup: dict, parameters: Union[dict, List[dict]] = None, working_directory: Union[str, Path] = None, - n_cpu: int = 2, + n_cpu: int = 4, use_postprocessing: bool = True, use_parameter_study: bool = False, model_result_file_names: List[str] = None, @@ -322,7 +322,7 @@ def simple_dymola_sim_study( "hydraulic.distribution.stoBuf.layer[*].T", "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", - "hydraulic.distribution.sigBusDis.*", + "hydraulic.distribution.sigBusDistr.*", "hydraulic.generation.sigBusGen.*", ], first_day_of_year=FIRST_DAY_OF_YEAR, From e174ef758863469395289af24e3836c4c9351682 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 09:25:11 +0200 Subject: [PATCH 08/31] chore: increase version and update changelog --- CHANGELOG.md | 4 ++++ ebcpy/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46adb60..104ae99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,3 +140,7 @@ - cap pandas version to <3 - v0.7.1 - fix bug in clean_and_space_equally #171 +- v0.7.2 + - Add new simple dymola exampl workflow #175 + - Add deprecation warning for the structural_parameters kyword of the DymolaAPI #176 + - Improve saving of .parquet files diff --git a/ebcpy/__init__.py b/ebcpy/__init__.py index e4df337..94803c5 100644 --- a/ebcpy/__init__.py +++ b/ebcpy/__init__.py @@ -8,4 +8,4 @@ from .optimization import Optimizer -__version__ = '0.7.1' +__version__ = '0.7.2' From 6d52caabeb1efe9a45779276a69b3ac61f9e7525 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 09:38:23 +0200 Subject: [PATCH 09/31] fix typo --- examples/e3_0_simple_dymola_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index 09b9a12..b68262f 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -401,5 +401,5 @@ def simple_dymola_sim_study( ) print("\n--- All studies finished ---") - print("You could now load the data again in a different scrit with `ebcpy.load_time_series_data()` " + print("You could now load the data again in a different script with `ebcpy.load_time_series_data()` " "for plotting and analysis.") \ No newline at end of file From 71cf27c8dac5647d58fd54a6bf015e20b65dbf0c Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 11:07:39 +0200 Subject: [PATCH 10/31] fix typo --- examples/e3_0_simple_dymola_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index b68262f..4bba7f8 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -318,7 +318,7 @@ def simple_dymola_sim_study( FIRST_DAY_OF_YEAR = datetime.datetime(2015, 1, 1, 0, 0) KWARGS_POSTPROCESSING = dict( variable_names=[ - "outputs*", # stars as wildcards are supportet + "outputs*", # stars as wildcards are supported "hydraulic.distribution.stoBuf.layer[*].T", "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", From 1e12a7cab5b4beb9b22183693d95248e04b4f23b Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 11:07:56 +0200 Subject: [PATCH 11/31] update documentation --- examples/README.md | 21 +++++++++++++++++---- examples/e5_modifier_example.py | 3 ++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index 86a7d48..6d97a31 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,11 +7,10 @@ This folder contains several example files which help with the understanding of While these examples should run in any IDE, we advise using PyCharm. Before being able to run these examples, be sure to: -1. Create a clean environment of python 3.7 or 3.8. In Anaconda run: `conda create -n py38_ebcpy python=3.8` -2. Activate the environment in your terminal. In Anaconda run: `activate py38_ebcpy` +1. Create a clean environment of python (We support 3.8 to 3.12). In Anaconda run: `conda create -n py313_ebcpy python=3.13` +2. Activate the environment in your terminal. In Anaconda run: `activate py312_ebcpy` 3. Clone the repository by running `git clone https://github.com/RWTH-EBC/ebcpy` -4. Clone the AixLib in order to use the models: `git clone https://github.com/RWTH-EBC/AixLib` - Also check if you're on development using `cd AixLib && git status && cd ..` +4. Clone the BESMod in order to use the models: `git clone https://github.com/RWTH-EBC/BESMod` and install it as described in the BESMod Readme with AixLib 5. Install the library using `pip install ebcpy`. In order to execute everything, install the full version using `pip install ebcpy[full]` @@ -32,11 +31,19 @@ Before being able to run these examples, be sure to: 5. Learn how to change inputs of a simulation 6. Learn how to run simulations in parallel +### `e3_0_simple_dymola_example.py` + +1. Learn a common workflow for Dymola simulation studies using ebcpy +2. Understand how to run parameter studies across multiple model variants +3. Learn how to use model name modifiers +4. Learn how to post-process simulation results into usable formats + ### `e3_dymola_example.py` 1. Learn how to use the `DymolaAPI` 2. Learn the different result options of the simulation 3. Learn how to convert inputs into the Dymola format +4. Learn advanced options for the `DymolaAPI` ### `e4_optimization_example.py` @@ -47,3 +54,9 @@ Before being able to run these examples, be sure to: concave problems, but they are not guaranteed to find the global minimum and can get stock in local optima. Evolutionary algorithms (like the genetic algorithm) are substantially slower, but they can overcome local optima, as shown in the concave examples. + +### `e5_modifier_example.py` + +1. Learn how to use the `DymolaAPI` +2. Learn how to dynamically modify structural parameters in the model +3. Learn how to redeclare models dynamically in the main model diff --git a/examples/e5_modifier_example.py b/examples/e5_modifier_example.py index ace1b54..0c0c54c 100644 --- a/examples/e5_modifier_example.py +++ b/examples/e5_modifier_example.py @@ -1,7 +1,8 @@ """ Goals of this part of the examples: 1. Learn how to use the `DymolaAPI` -2. Learn how to dynamically modify parameters in the model +2. Learn how to dynamically modify structural parameters in the model +3. Learn how to redeclare models dynamically in the main model """ # Start by importing all relevant packages import pathlib From 13cbe2b1c013a48a81349da23d9e6310674a41a4 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 15:39:33 +0200 Subject: [PATCH 12/31] update utils.get_names --- ebcpy/utils/__init__.py | 67 ++++++++++++++++++++++++++++++----------- tests/test_utils.py | 33 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/ebcpy/utils/__init__.py b/ebcpy/utils/__init__.py index d4cf1db..d3fd1f9 100644 --- a/ebcpy/utils/__init__.py +++ b/ebcpy/utils/__init__.py @@ -48,26 +48,44 @@ def setup_logger(name: str, return logger -def get_names(all_names: list, patterns: Union[str, List[str]]) -> List[str]: +def get_names( + all_names: list, + patterns: Union[str, List[str]], + exclude: Union[str, List[str]] = None +) -> List[str]: """ - Filter a list of candidate names by literal values or glob-style patterns. + Filter a list of candidate names by literal values or glob-style patterns, + optionally excluding names that match exclusion patterns. - This function returns all names from `all_names` that match the provided - `patterns`. Patterns may be a single string or a list of strings, and may - contain the wildcard `*` to match any sequence of characters. Literal names - without `*` must match exactly. The matching is performed in two steps: - 1. Each pattern is translated to a regular expression if it contains `*`, - otherwise used as a literal match. - 2. Any pattern that matches no names in `all_names` raises a warning. + This function returns all names from ``all_names`` that match the provided + ``patterns`` and do not match any of the ``exclude`` patterns. + Patterns may be a single string or a list of strings, and may + contain the wildcard ``*`` to match any sequence of characters. Literal names + without ``*`` must match exactly. - The returned list preserves the order of `all_names`. + The returned list preserves the order of ``all_names``. - :param all_names: List of available names to filter. - :param patterns: A pattern or list of patterns (with optional `*` wildcards) - to match against `all_names`. - :return: A list of names from `all_names` that match any of the given patterns, - in original order. - :raises KeyError: If any pattern does not match at least one name. + :param list all_names: + List of available names to filter. + :param str,list[str] patterns: + A pattern or list of patterns (with optional ``*`` wildcards) + to match against ``all_names``. + :param str,list[str] exclude: + A pattern or list of patterns to exclude from the results. + Names matching any exclusion pattern are removed after the + inclusion step. Default is None (no exclusion). + :return: A list of names from ``all_names`` that match any of the given + patterns and none of the exclusion patterns, in original order. + :rtype: list[str] + :raises KeyError: If any inclusion pattern does not match at least one name. + + Example: + + >>> names = ["wall.layer[1].T", "wall.layer[2].T", "wall.layer[1].Q_flow"] + >>> get_names(names, "wall.layer[*].T") + ['wall.layer[1].T', 'wall.layer[2].T'] + >>> get_names(names, "wall.layer[*].*", exclude="*Q_flow") + ['wall.layer[1].T', 'wall.layer[2].T'] """ if isinstance(patterns, str): patterns = [patterns] @@ -93,6 +111,21 @@ def get_names(all_names: list, patterns: Union[str, List[str]]) -> List[str]: "The following variable names/patterns are not in the given .mat file: " + ", ".join(unmatched) ) - # preserve original order + + # Apply exclusion patterns + if exclude is not None: + if isinstance(exclude, str): + exclude = [exclude] + excluded = set() + for pat in exclude: + if '*' in pat: + regex = '^' + re.escape(pat).replace(r'\*', '.*') + '$' + excluded.update(k for k in matched if re.match(regex, k)) + else: + if pat in matched: + excluded.add(pat) + matched -= excluded + + # Preserve original order names = [var for var in all_names if var in matched] return names diff --git a/tests/test_utils.py b/tests/test_utils.py index a8fe225..6ac8484 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -348,6 +348,39 @@ def test_matches(self): result = get_names(all_names, patterns) self.assertEqual(result, expected) + def test_exclude(self): + """ + Test exclusion with literal and wildcard patterns. + """ + test_cases = [ + # exclude single literal + (['alpha', 'beta', 'gamma'], '*', 'beta', ['alpha', 'gamma']), + # exclude wildcard + (['wall[1].T', 'wall[2].T', 'wall[1].Q_flow'], 'wall*', '*Q_flow', + ['wall[1].T', 'wall[2].T']), + # exclude list of patterns + (['a1', 'a2', 'b1', 'b2', 'c1'], '*', ['a*', 'c*'], ['b1', 'b2']), + # exclude with no effect (nothing matches exclusion) + (['x1', 'x2', 'x3'], 'x*', 'y*', ['x1', 'x2', 'x3']), + # exclude all matches + (['a1', 'a2'], 'a*', 'a*', []), + # exclude literal from wildcard match + (['wall1.T', 'wall2.T', 'floor.T'], '*.T', 'floor.T', ['wall1.T', 'wall2.T']), + ] + for all_names, patterns, exclude, expected in test_cases: + with self.subTest(patterns=patterns, exclude=exclude): + result = get_names(all_names, patterns, exclude=exclude) + self.assertEqual(result, expected) + + def test_exclude_none(self): + """ + Test that exclude=None (default) has no effect. + """ + result = get_names(['a', 'b', 'c'], '*') + self.assertEqual(result, ['a', 'b', 'c']) + result_explicit = get_names(['a', 'b', 'c'], '*', exclude=None) + self.assertEqual(result, result_explicit) + def test_errors(self): """ Patterns or literals that match nothing should raise a warning. From 327158174d1f1a961caaee452ebe53c2bcfcb07b Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 15:42:03 +0200 Subject: [PATCH 13/31] add simple_dymola_sim_study as a dymola_utils function --- ebcpy/__init__.py | 1 + ebcpy/simulationapi/dymola_utils.py | 204 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 ebcpy/simulationapi/dymola_utils.py diff --git a/ebcpy/__init__.py b/ebcpy/__init__.py index 94803c5..564cdf1 100644 --- a/ebcpy/__init__.py +++ b/ebcpy/__init__.py @@ -5,6 +5,7 @@ from .data_types import TimeSeriesData, TimeSeries, load_time_series_data from .simulationapi.dymola_api import DymolaAPI from .simulationapi.fmu import FMU_API +from .simulationapi.dymola_utils import simple_dymola_sim_study from .optimization import Optimizer diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py new file mode 100644 index 0000000..fb9c761 --- /dev/null +++ b/ebcpy/simulationapi/dymola_utils.py @@ -0,0 +1,204 @@ +from pathlib import Path +from typing import Union, List + +from ebcpy import DymolaAPI, load_time_series_data + + +def _default_result_file_names(result_file_name, parameters): + """Generate unique result file names from parameter values.""" + result_file_names = [] + for idx, param_dict in enumerate(parameters): + name = result_file_name + for key, val in param_dict.items(): + short_key = key.split(".")[-1] + name += f"_{short_key}{str(val).replace('.', '_')}" + result_file_names.append(name) + return result_file_names + + +def simple_dymola_sim_study( + model_names: List[str], + mos_script_pre: Union[str, Path], + simulation_setup: dict, + working_directory: Union[str, Path], + save_path: Union[str, Path], + parameters: Union[dict, List[dict]] = None, + n_cpu: int = 4, + use_parameter_study: bool = False, + model_result_file_names: List[str] = None, + result_file_name_func=None, + kwargs_postprocessing: dict = None, + postprocess_mat_result=None, + packages: List[Union[str, Path]] = None, + **kwargs +): + """ + Run a Dymola simulation study with multiple models and/or parameter variations. + + This function supports two simulation modes: + + **Parameter study** (``use_parameter_study=True``): + Each model is simulated separately with all parameter sets. + Useful when you want the full cross-product of models × parameters. + Each model gets its own DymolaAPI instance, which translates the model + once and then runs all parameter variations. + + **Model comparison** (``use_parameter_study=False``): + All models are simulated in a single ``simulate()`` call using the + ``model_names`` keyword. Each model can receive the same parameters + (pass a single dict) or individual parameters (pass a list of dicts + matching the length of ``model_names``). Each model is translated + individually. + + Both modes use model name modifiers to change structural parameters. + This is the recommended approach — write the modifier directly in the + model name string. + + :param list[str] model_names: + List of Dymola model names, optionally with modifiers. + E.g. ``["MyModel(nLayer=1)", "MyModel(nLayer=2)"]`` + :param str,Path mos_script_pre: + Path to a .mos script executed before loading packages. + Typically, the startup script of your Modelica library. + :param dict simulation_setup: + Simulation settings with keys ``start_time``, ``stop_time``, + and ``output_interval``. + :param str,Path working_directory: + Dymola working directory. + :param str,Path save_path: + Directory for saving simulation results. + :param dict,list[dict] parameters: + Parameter values for the simulation. For parameter studies, pass a list + of dicts. For model comparison, pass a single dict (applied to all models) + or a list of dicts (one per model). + :param int n_cpu: + Number of parallel Dymola processes. Default is 4. + :param bool use_parameter_study: + If True, runs each model with all parameter sets (cross-product). + If False, runs all models in a single call. + :param list[str] model_result_file_names: + Base names for the result files, one per model. + :param callable result_file_name_func: + Function to generate unique result file names for parameter studies. + Signature: ``func(result_file_name, parameters) -> list[str]`` + Default generates names by appending parameter key-value pairs. + :param dict kwargs_postprocessing: + Keyword arguments passed to the post-processing function. + Required if ``postprocess_mat_result`` is provided. + :param callable postprocess_mat_result: + Custom post-processing function. If None (default), .mat files + are kept unchanged. Signature: ``func(mat_result_file, **kwargs_postprocessing) + :param list packages: + Additional Modelica packages not loaded by ``mos_script_pre``. + :param kwargs: + Additional keyword arguments forwarded to ``DymolaAPI`` constructor + (e.g. ``show_window``, ``debug``, ``n_restart``, ``dymola_version``) + and to ``DymolaAPI.simulate()`` (e.g. ``fail_on_error``). + :return: Result file paths. For parameter studies, a dict mapping model names + to lists of paths. For model comparison, a list of paths. + :rtype: dict or list + """ + # ## Default paths + if working_directory is None: + working_directory = Path(__file__).parent.joinpath("results", "working_directory") + if save_path is None: + save_path = Path(__file__).parent.joinpath("results", "SimResults") + if packages is None: + packages = [] + + if model_result_file_names is not None and len(model_result_file_names) != len(model_names): + raise ValueError( + f"model_result_file_names has length {len(model_result_file_names)} " + f"but model_names has length {len(model_names)}. They must match." + ) + if use_parameter_study and not isinstance(parameters, list): + raise TypeError( + "For parameter studies, parameters must be a list of dicts." + ) + + # ## Post-processing setup + # Dymola produces .mat files by default. These are large and use a float + # index (seconds). The post-processing function converts them to a more + # usable format (e.g. datetime-indexed parquet) containing only the + # variables you need. + if postprocess_mat_result is not None and kwargs_postprocessing is None: + raise ValueError( + "kwargs_postprocessing is required when postprocess_mat_result is provided. " + "Pass a dict with the keyword arguments for your post-processing function." + ) + # Build the simulate kwargs for postprocessing + postprocessing_kwargs = {} + if postprocess_mat_result is not None: + postprocessing_kwargs["postprocess_mat_result"] = postprocess_mat_result + postprocessing_kwargs["kwargs_postprocessing"] = kwargs_postprocessing + + # ## Separate kwargs for DymolaAPI constructor and simulate() + # Known simulate() kwargs are forwarded there, everything else goes to DymolaAPI. + simulate_kwarg_keys = {"inputs", "table_name", "file_name", "fail_on_error", "show_eventlog", "squeeze"} + simulate_kwargs = {k: kwargs.pop(k) for k in simulate_kwarg_keys if k in kwargs} + + # ## Run simulations + if use_parameter_study: + # ### Parameter study mode + # Iterate over each model variant. For each model, a separate DymolaAPI + # instance is created, the model is translated once, and all parameter + # sets are simulated. This is efficient because translation (the slow part) + # happens only once per model. + if result_file_name_func is None: + result_file_name_func = _default_result_file_names + all_result_paths = {} + for model_name, result_file_name in zip(model_names, model_result_file_names): + # Create unique result file names by encoding the varied parameter values. + # Adapt this naming scheme to your parameter study. + result_file_names = result_file_name_func(result_file_name, parameters) + + dym_api = DymolaAPI( + mos_script_pre=mos_script_pre, + model_name=model_name, + working_directory=working_directory, + n_cpu=n_cpu, + packages=packages, + **kwargs + ) + dym_api.set_sim_setup(sim_setup=simulation_setup) + + result_paths = dym_api.simulate( + parameters=parameters, + return_option="savepath", + savepath=save_path, + result_file_name=result_file_names, + **postprocessing_kwargs, + **simulate_kwargs + ) + all_result_paths[model_name] = result_paths + dym_api.close() + + return all_result_paths + + else: + # ### Model comparison mode + # All models are simulated in a single DymolaAPI call using the + # model_names keyword. Dymola translates each model on the fly. + # This is convenient for comparing different model configurations + # with the same or individual parameter sets. + dym_api = DymolaAPI( + mos_script_pre=mos_script_pre, + working_directory=working_directory, + n_cpu=n_cpu, + packages=packages, + **kwargs + ) + dym_api.set_sim_setup(sim_setup=simulation_setup) + + result_paths = dym_api.simulate( + model_names=model_names, + parameters=parameters, + return_option="savepath", + savepath=save_path, + result_file_name=model_result_file_names, + **postprocessing_kwargs, + **simulate_kwargs + ) + dym_api.close() + + return result_paths \ No newline at end of file From 405c4cfee3562ff8ec7993196275820c01445e44 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 15:57:39 +0200 Subject: [PATCH 14/31] update example --- examples/e3_0_simple_dymola_example.py | 239 ++----------------------- 1 file changed, 14 insertions(+), 225 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index 4bba7f8..248c400 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -19,34 +19,11 @@ import datetime import os from pathlib import Path -from typing import Union, List import numpy as np -from ebcpy import DymolaAPI, load_time_series_data - - -def filter_strings(strings, required_substrings, forbidden_substrings=None): - """ - Helper function for filtering strings. - Filter strings that contain all required substrings and none of the forbidden ones. - - :param list[str] strings: - The list of strings to filter. - :param list[str] required_substrings: - Substrings that each string must contain. - :param list[str] forbidden_substrings: - Substrings that must not be present. Default is none. - :return: Filtered list of strings. - :rtype: list[str] - """ - if forbidden_substrings is None: - forbidden_substrings = [] - return [ - s for s in strings - if all(req in s for req in required_substrings) - and all(forb not in s for forb in forbidden_substrings) - ] +from ebcpy import simple_dymola_sim_study, load_time_series_data +from ebcpy.utils import get_names def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): @@ -75,7 +52,7 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): # --- Adapt this section to your study --- # Example: compute mean storage temperature across all layers variables = df.columns.to_list() - layer_temp_vars = filter_strings(variables, ["layer", "T"]) + layer_temp_vars = get_names(variables, "hydraulic.distribution.stoBuf.layer[*].T") if layer_temp_vars: df["stoBuf.mean_T"] = df[layer_temp_vars].mean(axis=1) @@ -91,200 +68,6 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): return df_path -def empty_postprocessing(mat_result, **_kwargs): - """ - No-op post-processing function. Returns the .mat file path unchanged. - Use this when you want to keep the original .mat result files. - """ - return mat_result - - -def simple_dymola_sim_study( - model_names: List[str], - mos_script_pre: Union[str, Path], - simulation_setup: dict, - parameters: Union[dict, List[dict]] = None, - working_directory: Union[str, Path] = None, - n_cpu: int = 4, - use_postprocessing: bool = True, - use_parameter_study: bool = False, - model_result_file_names: List[str] = None, - save_path: Union[str, Path] = None, - kwargs_postprocessing: dict = None, - postprocess_mat_result=None, - packages: List[Union[str, Path]] = None, - **kwargs -): - """ - Run a Dymola simulation study with multiple models and/or parameter variations. - - This function supports two simulation modes: - - **Parameter study** (``use_parameter_study=True``): - Each model is simulated separately with all parameter sets. - Useful when you want the full cross-product of models × parameters. - Each model gets its own DymolaAPI instance, which translates the model - once and then runs all parameter variations. - - **Model comparison** (``use_parameter_study=False``): - All models are simulated in a single ``simulate()`` call using the - ``model_names`` keyword. Each model can receive the same parameters - (pass a single dict) or individual parameters (pass a list of dicts - matching the length of ``model_names``). Each model is translated - individually. - - Both modes use model name modifiers to change structural parameters. - This is the recommended approach — write the modifier directly in the - model name string. - - :param list[str] model_names: - List of Dymola model names, optionally with modifiers. - E.g. ``["MyModel(nLayer=1)", "MyModel(nLayer=2)"]`` - :param str,Path mos_script_pre: - Path to a .mos script executed before loading packages. - Typically the startup script of your Modelica library. - :param dict simulation_setup: - Simulation settings with keys ``start_time``, ``stop_time``, - and ``output_interval``. - :param dict,list[dict] parameters: - Parameter values for the simulation. For parameter studies, pass a list - of dicts. For model comparison, pass a single dict (applied to all models) - or a list of dicts (one per model). - :param str,Path working_directory: - Dymola working directory. Default is ``./results/working_directory``. - :param int n_cpu: - Number of parallel Dymola processes. Default is 2. - :param bool use_postprocessing: - If True, .mat files are post-processed using ``postprocess_mat_result``. - If False, raw .mat files are kept. - :param bool use_parameter_study: - If True, runs each model with all parameter sets (cross-product). - If False, runs all models in a single call. - :param list[str] model_result_file_names: - Base names for the result files, one per model. - :param str,Path save_path: - Directory for saving simulation results. Default is ``./results/SimResults``. - :param dict kwargs_postprocessing: - Keyword arguments passed to the post-processing function. - Required if ``use_postprocessing=True``. - :param callable postprocess_mat_result: - Custom post-processing function. Default is ``custom_postprocessing``. - Signature: ``func(mat_result_file, **kwargs_postprocessing) -> Any`` - :param list packages: - Additional Modelica packages not loaded by ``mos_script_pre``. - :param kwargs: - Additional keyword arguments forwarded to ``DymolaAPI`` constructor - (e.g. ``show_window``, ``debug``, ``n_restart``, ``dymola_version``) - and to ``DymolaAPI.simulate()`` (e.g. ``fail_on_error``). - :return: Result file paths. For parameter studies, a dict mapping model names - to lists of paths. For model comparison, a list of paths. - :rtype: dict or list - """ - # ## Default paths - if working_directory is None: - working_directory = Path(__file__).parent.joinpath("results", "working_directory") - if save_path is None: - save_path = Path(__file__).parent.joinpath("results", "SimResults") - if packages is None: - packages = [] - - # ## Post-processing setup - # Dymola produces .mat files by default. These are large and use a float - # index (seconds). The post-processing function converts them to a more - # usable format (e.g. datetime-indexed parquet) containing only the - # variables you need. - if use_postprocessing: - if postprocess_mat_result is None: - postprocess_mat_result = custom_postprocessing - if kwargs_postprocessing is None: - raise ValueError( - "kwargs_postprocessing is required when use_postprocessing=True. " - "Pass a dict with the keyword arguments for your post-processing function." - ) - else: - postprocess_mat_result = empty_postprocessing - kwargs_postprocessing = {} - - # ## Separate kwargs for DymolaAPI constructor and simulate() - # Known simulate() kwargs are forwarded there, everything else goes to DymolaAPI. - simulate_kwarg_keys = {"inputs", "table_name", "file_name", "fail_on_error", "show_eventlog", "squeeze"} - simulate_kwargs = {k: kwargs.pop(k) for k in simulate_kwarg_keys if k in kwargs} - - # ## Run simulations - if use_parameter_study: - # ### Parameter study mode - # Iterate over each model variant. For each model, a separate DymolaAPI - # instance is created, the model is translated once, and all parameter - # sets are simulated. This is efficient because translation (the slow part) - # happens only once per model. - all_result_paths = {} - for model_name, result_file_name in zip(model_names, model_result_file_names): - # Create unique result file names by encoding the varied parameter values. - # Adapt this naming scheme to your parameter study. - result_file_names = [] - for param_dict in parameters: - name = result_file_name - for key, val in param_dict.items(): - short_key = key.split(".")[-1] - name += f"_{short_key}{str(val).replace('.', '_')}" - result_file_names.append(name) - - dym_api = DymolaAPI( - mos_script_pre=mos_script_pre, - model_name=model_name, - working_directory=working_directory, - n_cpu=n_cpu, - equidistant_output=True, - packages=packages, - **kwargs - ) - dym_api.set_sim_setup(sim_setup=simulation_setup) - - result_paths = dym_api.simulate( - parameters=parameters, - return_option="savepath", - savepath=save_path, - result_file_name=result_file_names, - postprocess_mat_result=postprocess_mat_result, - kwargs_postprocessing=kwargs_postprocessing, - **simulate_kwargs - ) - all_result_paths[model_name] = result_paths - dym_api.close() - - return all_result_paths - - else: - # ### Model comparison mode - # All models are simulated in a single DymolaAPI call using the - # model_names keyword. Dymola translates each model on the fly. - # This is convenient for comparing different model configurations - # with the same or individual parameter sets. - dym_api = DymolaAPI( - mos_script_pre=mos_script_pre, - working_directory=working_directory, - n_cpu=n_cpu, - equidistant_output=True, - packages=packages, - **kwargs - ) - dym_api.set_sim_setup(sim_setup=simulation_setup) - - result_paths = dym_api.simulate( - model_names=model_names, - parameters=parameters, - return_option="savepath", - savepath=save_path, - result_file_name=model_result_file_names, - postprocess_mat_result=postprocess_mat_result, - kwargs_postprocessing=kwargs_postprocessing, - **simulate_kwargs - ) - dym_api.close() - - return result_paths - - if __name__ == "__main__": # TODO: Adjust this path to your local BESMod startup script MOS_SCRIPT = r"D:\01_git\BESMod\startup.mos" @@ -335,9 +118,9 @@ def simple_dymola_sim_study( model_names=[model_names_to_simulate[0]], mos_script_pre=MOS_SCRIPT, simulation_setup=SIMULATION_SETUP, - use_postprocessing=False, - model_result_file_names=[model_result_names[0]], save_path=Path(__file__).parent.joinpath("results", "SimResults_0"), + working_directory=Path(__file__).parent.joinpath("results", "working_directory"), + model_result_file_names=[model_result_names[0]], ) # ## Study 1: Parameter study @@ -354,10 +137,12 @@ def simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, simulation_setup=SIMULATION_SETUP, + working_directory=Path(__file__).parent.joinpath("results", "working_directory"), + save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), parameters=parameter_study_params, use_parameter_study=True, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), + postprocess_mat_result=custom_postprocessing, kwargs_postprocessing=KWARGS_POSTPROCESSING, ) @@ -373,9 +158,11 @@ def simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, simulation_setup=SIMULATION_SETUP, + working_directory=Path(__file__).parent.joinpath("results", "working_directory"), + save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), parameters=model_study_params, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), + postprocess_mat_result=custom_postprocessing, kwargs_postprocessing=KWARGS_POSTPROCESSING, ) @@ -394,9 +181,11 @@ def simple_dymola_sim_study( model_names=model_names_to_simulate, mos_script_pre=MOS_SCRIPT, simulation_setup=SIMULATION_SETUP, + working_directory=Path(__file__).parent.joinpath("results", "working_directory"), + save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), parameters=model_study_div_params, model_result_file_names=model_result_names, - save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), + postprocess_mat_result=custom_postprocessing, kwargs_postprocessing=KWARGS_POSTPROCESSING, ) From 85fb4600dabbaf57a4bb413e9648506a0431977a Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 15:58:10 +0200 Subject: [PATCH 15/31] add tests for new function --- tests/test_simulationapi.py | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 56fd598..f326548 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -435,5 +435,140 @@ class TestFMUAPIMultiCore(TestFMUAPI): n_cpu = 2 +class TestSimpleDymolaSimStudyValidation(unittest.TestCase): + """Test input validation — no Dymola needed.""" + + def test_mismatched_lengths(self): + from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study + with self.assertRaises(ValueError): + simple_dymola_sim_study( + model_names=["Model"], + mos_script_pre="dummy.mos", + simulation_setup={}, + working_directory=".", + save_path=".", + model_result_file_names=["name1", "name2"], + ) + + def test_parameter_study_requires_list(self): + from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study + with self.assertRaises(TypeError): + simple_dymola_sim_study( + model_names=["Model"], + mos_script_pre="dummy.mos", + simulation_setup={}, + working_directory=".", + save_path=".", + parameters={"a": 1}, + use_parameter_study=True, + model_result_file_names=["test"], + ) + + def test_postprocessing_without_kwargs(self): + from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study + with self.assertRaises(ValueError): + simple_dymola_sim_study( + model_names=["Model"], + mos_script_pre="dummy.mos", + simulation_setup={}, + working_directory=".", + save_path=".", + model_result_file_names=["test"], + postprocess_mat_result=lambda x: x, + ) + + +class TestSimpleDymolaSimStudy(unittest.TestCase): + """Test simple_dymola_sim_study with Dymola — kept minimal for speed.""" + + def setUp(self): + self.data_dir = Path(__file__).parent.joinpath("data") + self.example_sim_dir = self.data_dir.joinpath("testzone") + os.makedirs(self.example_sim_dir, exist_ok=True) + self.save_path = self.example_sim_dir.joinpath("sim_study_results") + self.working_dir = self.example_sim_dir.joinpath("sim_study_working") + self.packages = [self.data_dir.joinpath("TestModelVariables.mo")] + self.simulation_setup = { + "start_time": 0.0, + "stop_time": 10.0, + "output_interval": 0.1 + } + if "linux" in sys.platform: + self.mos_script_pre = None + else: + self.mos_script_pre = None + + try: + from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study + self.simple_dymola_sim_study = simple_dymola_sim_study + except ImportError as error: + self.skipTest(f"Could not import simple_dymola_sim_study: {error}") + + # Quick check that Dymola is available + try: + from ebcpy import DymolaAPI + dym = DymolaAPI( + working_directory=self.working_dir, + model_name="TestModelVariables", + packages=self.packages, + n_cpu=1, + ) + dym.close() + except (FileNotFoundError, ImportError, ConnectionError) as error: + self.skipTest(f"Dymola not available: {error}") + + def test_parameter_study(self): + """Test parameter study mode.""" + result_paths = self.simple_dymola_sim_study( + model_names=["TestModelVariables"], + mos_script_pre=self.mos_script_pre, + simulation_setup=self.simulation_setup, + working_directory=self.working_dir, + save_path=self.save_path, + parameters=[{"test_real": 1.0}, {"test_real": 5.0}], + use_parameter_study=True, + model_result_file_names=["param_study"], + packages=self.packages, + n_cpu=1, + ) + self.assertIsInstance(result_paths, dict) + for paths in result_paths.values(): + self.assertEqual(len(paths), 2) + for path in paths: + self.assertTrue(os.path.isfile(path)) + + def test_model_comparison(self): + """Test model comparison mode.""" + result_paths = self.simple_dymola_sim_study( + model_names=["TestModelVariables", "TestModelVariables"], + mos_script_pre=self.mos_script_pre, + simulation_setup=self.simulation_setup, + working_directory=self.working_dir, + save_path=self.save_path, + parameters={"test_real": 5.0}, + model_result_file_names=["model_a", "model_b"], + packages=self.packages, + n_cpu=1, + ) + self.assertIsInstance(result_paths, list) + self.assertEqual(len(result_paths), 2) + + def tearDown(self): + for path in [self.save_path, self.working_dir, self.example_sim_dir]: + try: + shutil.rmtree(path) + except (FileNotFoundError, PermissionError): + pass + + +class TestSimpleDymolaSimStudySingleCore(TestSimpleDymolaSimStudy): + n_cpu = 1 + + +class TestSimpleDymolaSimStudyMultiCore(TestSimpleDymolaSimStudy): + """Run simple_dymola_sim_study tests on multi core.""" + n_cpu = 2 + + if __name__ == "__main__": unittest.main() From 8c86a5b1e18a797856c2925bb7454fe7ece7ec0a Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 15:58:42 +0200 Subject: [PATCH 16/31] chore: increase version and update changelog --- CHANGELOG.md | 11 +++++++---- ebcpy/__init__.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104ae99..3e4e173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,7 +140,10 @@ - cap pandas version to <3 - v0.7.1 - fix bug in clean_and_space_equally #171 -- v0.7.2 - - Add new simple dymola exampl workflow #175 - - Add deprecation warning for the structural_parameters kyword of the DymolaAPI #176 - - Improve saving of .parquet files +- v0.8.0 + - Add `simple_dymola_sim_study` utility for common Dymola simulation workflows + - Add `exclude` parameter to `get_names` for filtering out unwanted variable matches + - Handle SparseDtype columns automatically in `tsd.save()` for parquet format + - Deprecate `structural_parameters` keyword in `DymolaAPI.simulate()` #176 + - Deprecate `modify_structural_parameters` keyword in `DymolaAPI.__init__()` + - Add basic Dymola simulation workflow example #175 diff --git a/ebcpy/__init__.py b/ebcpy/__init__.py index 564cdf1..834bf5d 100644 --- a/ebcpy/__init__.py +++ b/ebcpy/__init__.py @@ -9,4 +9,4 @@ from .optimization import Optimizer -__version__ = '0.7.2' +__version__ = '0.8.0' From 9cbb2de2aeb50d9c3863e54e973637cf1cb84bce Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 28 Apr 2026 16:32:17 +0200 Subject: [PATCH 17/31] update for CI --- ebcpy/simulationapi/dymola_utils.py | 8 ++++---- tests/test_simulationapi.py | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index fb9c761..dbf3ca4 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -18,7 +18,6 @@ def _default_result_file_names(result_file_name, parameters): def simple_dymola_sim_study( model_names: List[str], - mos_script_pre: Union[str, Path], simulation_setup: dict, working_directory: Union[str, Path], save_path: Union[str, Path], @@ -29,6 +28,7 @@ def simple_dymola_sim_study( result_file_name_func=None, kwargs_postprocessing: dict = None, postprocess_mat_result=None, + mos_script_pre: Union[str, Path] = None, packages: List[Union[str, Path]] = None, **kwargs ): @@ -57,9 +57,6 @@ def simple_dymola_sim_study( :param list[str] model_names: List of Dymola model names, optionally with modifiers. E.g. ``["MyModel(nLayer=1)", "MyModel(nLayer=2)"]`` - :param str,Path mos_script_pre: - Path to a .mos script executed before loading packages. - Typically, the startup script of your Modelica library. :param dict simulation_setup: Simulation settings with keys ``start_time``, ``stop_time``, and ``output_interval``. @@ -88,6 +85,9 @@ def simple_dymola_sim_study( :param callable postprocess_mat_result: Custom post-processing function. If None (default), .mat files are kept unchanged. Signature: ``func(mat_result_file, **kwargs_postprocessing) + :param str,Path mos_script_pre: + Path to a .mos script executed before loading packages. + Typically, the startup script of your Modelica library. :param list packages: Additional Modelica packages not loaded by ``mos_script_pre``. :param kwargs: diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index f326548..05b1a56 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -494,9 +494,9 @@ def setUp(self): "output_interval": 0.1 } if "linux" in sys.platform: - self.mos_script_pre = None + self.dymola_exe_path = "/usr/local/bin/dymola" else: - self.mos_script_pre = None + self.dymola_exe_path = None try: from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study @@ -511,6 +511,7 @@ def setUp(self): working_directory=self.working_dir, model_name="TestModelVariables", packages=self.packages, + dymola_exe_path=self.dymola_exe_path, n_cpu=1, ) dym.close() @@ -521,7 +522,6 @@ def test_parameter_study(self): """Test parameter study mode.""" result_paths = self.simple_dymola_sim_study( model_names=["TestModelVariables"], - mos_script_pre=self.mos_script_pre, simulation_setup=self.simulation_setup, working_directory=self.working_dir, save_path=self.save_path, @@ -530,6 +530,7 @@ def test_parameter_study(self): model_result_file_names=["param_study"], packages=self.packages, n_cpu=1, + dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, dict) for paths in result_paths.values(): @@ -541,7 +542,6 @@ def test_model_comparison(self): """Test model comparison mode.""" result_paths = self.simple_dymola_sim_study( model_names=["TestModelVariables", "TestModelVariables"], - mos_script_pre=self.mos_script_pre, simulation_setup=self.simulation_setup, working_directory=self.working_dir, save_path=self.save_path, @@ -549,6 +549,7 @@ def test_model_comparison(self): model_result_file_names=["model_a", "model_b"], packages=self.packages, n_cpu=1, + dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, list) self.assertEqual(len(result_paths), 2) From 169f64c887a072a23a3d2ac3e74f72bd7ece8935 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 09:18:49 +0200 Subject: [PATCH 18/31] add comment to save_path and working_directory --- examples/e3_0_simple_dymola_example.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index 248c400..ca7d12c 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -118,6 +118,8 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): model_names=[model_names_to_simulate[0]], mos_script_pre=MOS_SCRIPT, simulation_setup=SIMULATION_SETUP, + # save path and working directory should not be the same folder + # and if you are in a git repository add the working_directory folder to .gitignore save_path=Path(__file__).parent.joinpath("results", "SimResults_0"), working_directory=Path(__file__).parent.joinpath("results", "working_directory"), model_result_file_names=[model_result_names[0]], From 74c35fcb9b042a39b6c079b6676304ac6aff8fea Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok <101417334+HvanderStok@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:33:16 +0200 Subject: [PATCH 19/31] Update ebcpy/simulationapi/dymola_utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ebcpy/simulationapi/dymola_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index dbf3ca4..af1551f 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -84,7 +84,7 @@ def simple_dymola_sim_study( Required if ``postprocess_mat_result`` is provided. :param callable postprocess_mat_result: Custom post-processing function. If None (default), .mat files - are kept unchanged. Signature: ``func(mat_result_file, **kwargs_postprocessing) + are kept unchanged. Signature: ``func(mat_result_file, **kwargs_postprocessing)`` :param str,Path mos_script_pre: Path to a .mos script executed before loading packages. Typically, the startup script of your Modelica library. From 696b54174def20f7dda9ad49066b75275fb06633 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok <101417334+HvanderStok@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:34:49 +0200 Subject: [PATCH 20/31] Update ebcpy/simulationapi/dymola_api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ebcpy/simulationapi/dymola_api.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ebcpy/simulationapi/dymola_api.py b/ebcpy/simulationapi/dymola_api.py index 0c4fb20..8ddbe62 100644 --- a/ebcpy/simulationapi/dymola_api.py +++ b/ebcpy/simulationapi/dymola_api.py @@ -201,15 +201,15 @@ def __init__( self.fully_initialized = False self.debug = kwargs.pop("debug", False) self.show_window = kwargs.pop("show_window", False) - if "modify_structural_parameters" in kwargs: - if kwargs.pop("modify_structural_parameters", True) is not True: - warnings.warn( - "'modify_structural_parameters' is deprecated and will be removed " - "in the next major release. Use model name modifiers directly instead.", - FutureWarning, - stacklevel=2, - ) - self.modify_structural_parameters = kwargs.pop("modify_structural_parameters", True) + _modify_structural_parameters = kwargs.pop("modify_structural_parameters", True) + if _modify_structural_parameters is not True: + warnings.warn( + "'modify_structural_parameters' is deprecated and will be removed " + "in the next major release. Use model name modifiers directly instead.", + FutureWarning, + stacklevel=2, + ) + self.modify_structural_parameters = _modify_structural_parameters self.equidistant_output = kwargs.pop("equidistant_output", True) _variables_to_save = kwargs.pop("variables_to_save", {}) self.experiment_setup_output = ExperimentSetupOutput(**_variables_to_save) From ae3e02c9038bf583a6a3d50bccdc46d53e3384d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:43:15 +0000 Subject: [PATCH 21/31] Make model_result_file_names always required in simple_dymola_sim_study Agent-Logs-Url: https://github.com/RWTH-EBC/ebcpy/sessions/c1a15a25-9907-4951-8cff-7131a327d25d Co-authored-by: HvanderStok <101417334+HvanderStok@users.noreply.github.com> --- ebcpy/simulationapi/dymola_utils.py | 9 +++++++-- tests/test_simulationapi.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index af1551f..be6fe05 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -74,7 +74,7 @@ def simple_dymola_sim_study( If True, runs each model with all parameter sets (cross-product). If False, runs all models in a single call. :param list[str] model_result_file_names: - Base names for the result files, one per model. + Base names for the result files, one per model. Required. :param callable result_file_name_func: Function to generate unique result file names for parameter studies. Signature: ``func(result_file_name, parameters) -> list[str]`` @@ -106,7 +106,12 @@ def simple_dymola_sim_study( if packages is None: packages = [] - if model_result_file_names is not None and len(model_result_file_names) != len(model_names): + if model_result_file_names is None: + raise ValueError( + "model_result_file_names is required. " + "Provide a list of result file base names, one per model in model_names." + ) + if len(model_result_file_names) != len(model_names): raise ValueError( f"model_result_file_names has length {len(model_result_file_names)} " f"but model_names has length {len(model_names)}. They must match." diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 05b1a56..56c560c 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -438,6 +438,18 @@ class TestFMUAPIMultiCore(TestFMUAPI): class TestSimpleDymolaSimStudyValidation(unittest.TestCase): """Test input validation — no Dymola needed.""" + def test_model_result_file_names_required(self): + from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study + with self.assertRaises(ValueError): + simple_dymola_sim_study( + model_names=["Model"], + mos_script_pre="dummy.mos", + simulation_setup={}, + working_directory=".", + save_path=".", + model_result_file_names=None, + ) + def test_mismatched_lengths(self): from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study with self.assertRaises(ValueError): From 83264966875bef4726e1b434d9d7dc55689fd0b3 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 09:48:40 +0200 Subject: [PATCH 22/31] Revert "Make model_result_file_names always required in simple_dymola_sim_study" This reverts commit ae3e02c9038bf583a6a3d50bccdc46d53e3384d7. --- ebcpy/simulationapi/dymola_utils.py | 9 ++------- tests/test_simulationapi.py | 12 ------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index be6fe05..af1551f 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -74,7 +74,7 @@ def simple_dymola_sim_study( If True, runs each model with all parameter sets (cross-product). If False, runs all models in a single call. :param list[str] model_result_file_names: - Base names for the result files, one per model. Required. + Base names for the result files, one per model. :param callable result_file_name_func: Function to generate unique result file names for parameter studies. Signature: ``func(result_file_name, parameters) -> list[str]`` @@ -106,12 +106,7 @@ def simple_dymola_sim_study( if packages is None: packages = [] - if model_result_file_names is None: - raise ValueError( - "model_result_file_names is required. " - "Provide a list of result file base names, one per model in model_names." - ) - if len(model_result_file_names) != len(model_names): + if model_result_file_names is not None and len(model_result_file_names) != len(model_names): raise ValueError( f"model_result_file_names has length {len(model_result_file_names)} " f"but model_names has length {len(model_names)}. They must match." diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 56c560c..05b1a56 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -438,18 +438,6 @@ class TestFMUAPIMultiCore(TestFMUAPI): class TestSimpleDymolaSimStudyValidation(unittest.TestCase): """Test input validation — no Dymola needed.""" - def test_model_result_file_names_required(self): - from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study - with self.assertRaises(ValueError): - simple_dymola_sim_study( - model_names=["Model"], - mos_script_pre="dummy.mos", - simulation_setup={}, - working_directory=".", - save_path=".", - model_result_file_names=None, - ) - def test_mismatched_lengths(self): from ebcpy.simulationapi.dymola_utils import simple_dymola_sim_study with self.assertRaises(ValueError): From 6e1b787f8c25a29b1d3e627c62939803655e6b81 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 09:51:50 +0200 Subject: [PATCH 23/31] Make model_reult_file_names required in simple_dymola_sim_study --- ebcpy/simulationapi/dymola_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index af1551f..7ed6ad2 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -21,10 +21,10 @@ def simple_dymola_sim_study( simulation_setup: dict, working_directory: Union[str, Path], save_path: Union[str, Path], + model_result_file_names: List[str], parameters: Union[dict, List[dict]] = None, n_cpu: int = 4, use_parameter_study: bool = False, - model_result_file_names: List[str] = None, result_file_name_func=None, kwargs_postprocessing: dict = None, postprocess_mat_result=None, @@ -64,6 +64,8 @@ def simple_dymola_sim_study( Dymola working directory. :param str,Path save_path: Directory for saving simulation results. + :param list[str] model_result_file_names: + Base names for the result files, one per model. :param dict,list[dict] parameters: Parameter values for the simulation. For parameter studies, pass a list of dicts. For model comparison, pass a single dict (applied to all models) @@ -73,8 +75,6 @@ def simple_dymola_sim_study( :param bool use_parameter_study: If True, runs each model with all parameter sets (cross-product). If False, runs all models in a single call. - :param list[str] model_result_file_names: - Base names for the result files, one per model. :param callable result_file_name_func: Function to generate unique result file names for parameter studies. Signature: ``func(result_file_name, parameters) -> list[str]`` @@ -106,7 +106,7 @@ def simple_dymola_sim_study( if packages is None: packages = [] - if model_result_file_names is not None and len(model_result_file_names) != len(model_names): + if len(model_result_file_names) != len(model_names): raise ValueError( f"model_result_file_names has length {len(model_result_file_names)} " f"but model_names has length {len(model_names)}. They must match." From 649decd8eb9f25657bfa0a9c28fec926b51595d9 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 09:54:49 +0200 Subject: [PATCH 24/31] use copy for parquet save --- ebcpy/data_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ebcpy/data_types.py b/ebcpy/data_types.py index 1799092..f6aa61d 100644 --- a/ebcpy/data_types.py +++ b/ebcpy/data_types.py @@ -114,7 +114,7 @@ def save(self, filepath: str = None, **kwargs) -> None: elif ".parquet" in filepath.name: parquet_split = filepath.name.split(".parquet") # Parquet doesn't support SparseDtype — densify before writing - df_to_save = self._obj + df_to_save = self._obj.copy() for col in df_to_save.columns: if isinstance(df_to_save[col].dtype, pd.SparseDtype): df_to_save[col] = df_to_save[col].sparse.to_dense() From 5d475e1c9b645f9de384fa1cf4d46ddfadc63140 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 10:00:07 +0200 Subject: [PATCH 25/31] fix test of mp --- tests/test_simulationapi.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 05b1a56..3dd1c00 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -480,6 +480,7 @@ def test_postprocessing_without_kwargs(self): class TestSimpleDymolaSimStudy(unittest.TestCase): """Test simple_dymola_sim_study with Dymola — kept minimal for speed.""" + n_cpu = None def setUp(self): self.data_dir = Path(__file__).parent.joinpath("data") @@ -512,7 +513,7 @@ def setUp(self): model_name="TestModelVariables", packages=self.packages, dymola_exe_path=self.dymola_exe_path, - n_cpu=1, + n_cpu=self.n_cpu, ) dym.close() except (FileNotFoundError, ImportError, ConnectionError) as error: @@ -529,7 +530,7 @@ def test_parameter_study(self): use_parameter_study=True, model_result_file_names=["param_study"], packages=self.packages, - n_cpu=1, + n_cpu=self.n_cpu, dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, dict) @@ -548,7 +549,7 @@ def test_model_comparison(self): parameters={"test_real": 5.0}, model_result_file_names=["model_a", "model_b"], packages=self.packages, - n_cpu=1, + n_cpu=self.n_cpu, dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, list) From a1bfd86e7b55bf913e099711f8f8c837b1f7f95f Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 10:01:16 +0200 Subject: [PATCH 26/31] fix example readme --- examples/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 6d97a31..09f6844 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,8 +7,8 @@ This folder contains several example files which help with the understanding of While these examples should run in any IDE, we advise using PyCharm. Before being able to run these examples, be sure to: -1. Create a clean environment of python (We support 3.8 to 3.12). In Anaconda run: `conda create -n py313_ebcpy python=3.13` -2. Activate the environment in your terminal. In Anaconda run: `activate py312_ebcpy` +1. Create a clean environment of python (We support 3.8 to 3.13). In Anaconda run: `conda create -n py313_ebcpy python=3.13` +2. Activate the environment in your terminal. In Anaconda run: `activate py313_ebcpy` 3. Clone the repository by running `git clone https://github.com/RWTH-EBC/ebcpy` 4. Clone the BESMod in order to use the models: `git clone https://github.com/RWTH-EBC/BESMod` and install it as described in the BESMod Readme with AixLib 5. Install the library using `pip install ebcpy`. From 6580a2d416a126ee5471c16be7a858d2a68376d1 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok <101417334+HvanderStok@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:02:27 +0200 Subject: [PATCH 27/31] Update ebcpy/simulationapi/dymola_utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ebcpy/simulationapi/dymola_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index 7ed6ad2..eb8b80b 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -1,7 +1,8 @@ from pathlib import Path from typing import Union, List -from ebcpy import DymolaAPI, load_time_series_data +from .dymola_api import DymolaAPI +from ..data_types import load_time_series_data def _default_result_file_names(result_file_name, parameters): From 93a2c2e04529c24f32122f957c4a487ca5551176 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 10:03:45 +0200 Subject: [PATCH 28/31] fix docstring --- ebcpy/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ebcpy/utils/__init__.py b/ebcpy/utils/__init__.py index d3fd1f9..c010878 100644 --- a/ebcpy/utils/__init__.py +++ b/ebcpy/utils/__init__.py @@ -77,7 +77,7 @@ def get_names( :return: A list of names from ``all_names`` that match any of the given patterns and none of the exclusion patterns, in original order. :rtype: list[str] - :raises KeyError: If any inclusion pattern does not match at least one name. + :raises warning: If any inclusion pattern does not match at least one name. Example: From f4860b1d1e6b66d8089e7cb09abf1b8bceadfc25 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Thu, 30 Apr 2026 10:10:18 +0200 Subject: [PATCH 29/31] fix tests --- tests/test_simulationapi.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 3dd1c00..95c699f 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -480,7 +480,6 @@ def test_postprocessing_without_kwargs(self): class TestSimpleDymolaSimStudy(unittest.TestCase): """Test simple_dymola_sim_study with Dymola — kept minimal for speed.""" - n_cpu = None def setUp(self): self.data_dir = Path(__file__).parent.joinpath("data") @@ -513,7 +512,7 @@ def setUp(self): model_name="TestModelVariables", packages=self.packages, dymola_exe_path=self.dymola_exe_path, - n_cpu=self.n_cpu, + n_cpu=1, ) dym.close() except (FileNotFoundError, ImportError, ConnectionError) as error: @@ -530,7 +529,7 @@ def test_parameter_study(self): use_parameter_study=True, model_result_file_names=["param_study"], packages=self.packages, - n_cpu=self.n_cpu, + n_cpu=1, dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, dict) @@ -549,7 +548,7 @@ def test_model_comparison(self): parameters={"test_real": 5.0}, model_result_file_names=["model_a", "model_b"], packages=self.packages, - n_cpu=self.n_cpu, + n_cpu=1, dymola_exe_path=self.dymola_exe_path, ) self.assertIsInstance(result_paths, list) @@ -563,14 +562,5 @@ def tearDown(self): pass -class TestSimpleDymolaSimStudySingleCore(TestSimpleDymolaSimStudy): - n_cpu = 1 - - -class TestSimpleDymolaSimStudyMultiCore(TestSimpleDymolaSimStudy): - """Run simple_dymola_sim_study tests on multi core.""" - n_cpu = 2 - - if __name__ == "__main__": unittest.main() From 0fb08453e74d5f6af2887ac5367f6493395dc9c9 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 26 May 2026 14:53:44 +0200 Subject: [PATCH 30/31] move example code in a main function --- examples/e3_0_simple_dymola_example.py | 89 ++++++++++++++++---------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/examples/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py index ca7d12c..8735b0c 100644 --- a/examples/e3_0_simple_dymola_example.py +++ b/examples/e3_0_simple_dymola_example.py @@ -14,7 +14,7 @@ # # **Prerequisites:** # This example requires a Dymola installation and the BESMod library with AixLib. -# Adjust the ``MOS_SCRIPT`` path to your local BESMod startup script. +# Adjust the ``mos_script_pre`` path to your local BESMod startup script. import datetime import os @@ -56,28 +56,42 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): if layer_temp_vars: df["stoBuf.mean_T"] = df[layer_temp_vars].mean(axis=1) - # save the partial result dataframe in a different format - # Here we use parquet because it is fast and results in small file sizes + # Save the partial result dataframe in a different format. + # Here we use parquet because it is fast and results in small file sizes. # If you also save variables with a lot of constant segments - # you can use further compressions like ".parquet.snappy" - # for small and short studies you could also use csv but this is not recommended + # you can use further compressions like ".parquet.snappy". + # For small and short studies you could also use csv but this is not recommended. df_path = Path(mat_result_file).with_suffix(".parquet") df.tsd.save(df_path) - # remove the old mat file + # Remove the old mat file os.remove(mat_result_file) return df_path -if __name__ == "__main__": - # TODO: Adjust this path to your local BESMod startup script - MOS_SCRIPT = r"D:\01_git\BESMod\startup.mos" +def main(mos_script_pre): + """ + Run four simulation studies demonstrating common Dymola workflows. + + This function executes four studies of increasing complexity: + + - Study 0: Single model with default parameters (setup verification) + - Study 1: Parameter study across multiple model variants (cross-product) + - Study 2: Model comparison with shared parameters + - Study 3: Model comparison with individual parameters per model + + All studies use the BESMod library with varying buffer storage layer + configurations as model variants. Results are saved under + ``./results/SimResults_X/``. + :param str,Path mos_script_pre: + Path to the BESMod startup .mos script. + """ # ## Simulation setup # Adapt these values to your study. - SIMULATION_SETUP = { + simulation_setup = { "start_time": 0, "stop_time": 3600 * 24 * 30, # 30 days - "output_interval": 900 # one data point every 900 s (15 min) + "output_interval": 900 # one data point every 900 s (15 min) } # ## Define model variants @@ -85,10 +99,10 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): # This is the recommended approach for structural parameters which # need a retranslation — write the modifier directly in the model name # string. You can also redeclare models here. - BASE_MODEL = "BESMod.Examples.DesignOptimization.BES" + base_model = "BESMod.Examples.DesignOptimization.BES" storage_layers = np.arange(1, 5, 1, dtype=int) model_names_to_simulate = [ - f"{BASE_MODEL}(hydraulic.distribution.parStoBuf(nLayer={n}))" + f"{base_model}(hydraulic.distribution.parStoBuf(nLayer={n}))" for n in storage_layers ] # Base names for result files — one per model variant @@ -98,8 +112,8 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): # ## Post-processing configuration # Adapt the variable_names patterns to select which variables # you want to keep from the .mat files. - FIRST_DAY_OF_YEAR = datetime.datetime(2015, 1, 1, 0, 0) - KWARGS_POSTPROCESSING = dict( + first_day_of_year = datetime.datetime(2015, 1, 1, 0, 0) + kwargs_postprocessing = dict( variable_names=[ "outputs*", # stars as wildcards are supported "hydraulic.distribution.stoBuf.layer[*].T", @@ -108,7 +122,7 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): "hydraulic.distribution.sigBusDistr.*", "hydraulic.generation.sigBusGen.*", ], - first_day_of_year=FIRST_DAY_OF_YEAR, + first_day_of_year=first_day_of_year, ) # ## Study 0: Single model, default parameters @@ -116,10 +130,10 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): print("\n--- Study 0: Single model ---") result_paths_study_0 = simple_dymola_sim_study( model_names=[model_names_to_simulate[0]], - mos_script_pre=MOS_SCRIPT, - simulation_setup=SIMULATION_SETUP, - # save path and working directory should not be the same folder - # and if you are in a git repository add the working_directory folder to .gitignore + mos_script_pre=mos_script_pre, + simulation_setup=simulation_setup, + # save_path and working_directory should not be the same folder. + # If you are in a git repository, add the working_directory folder to .gitignore. save_path=Path(__file__).parent.joinpath("results", "SimResults_0"), working_directory=Path(__file__).parent.joinpath("results", "working_directory"), model_result_file_names=[model_result_names[0]], @@ -127,7 +141,7 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): # ## Study 1: Parameter study # Each model variant is simulated with all parameter sets. - # Each parameter set can have multiple and different parameters + # Each parameter set can have multiple and different parameters. # Total simulations: len(model_names) × len(parameter_sets) = 4 × 8 = 32 print("\n--- Study 1: Parameter study ---") parameter_study_params = [ @@ -137,19 +151,20 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): print("Parameter sets:", parameter_study_params) result_paths_study_1 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=MOS_SCRIPT, - simulation_setup=SIMULATION_SETUP, + mos_script_pre=mos_script_pre, + simulation_setup=simulation_setup, working_directory=Path(__file__).parent.joinpath("results", "working_directory"), save_path=Path(__file__).parent.joinpath("results", "SimResults_1"), parameters=parameter_study_params, use_parameter_study=True, model_result_file_names=model_result_names, postprocess_mat_result=custom_postprocessing, - kwargs_postprocessing=KWARGS_POSTPROCESSING, + kwargs_postprocessing=kwargs_postprocessing, ) # ## Study 2: Model comparison with shared parameters - # All model variants are simulated with the same parameter set. You do not need to specify any parameters. + # All model variants are simulated with the same parameter set. + # You do not need to specify any parameters. # Total simulations: len(model_names) = 4 print("\n--- Study 2: Model comparison (shared parameters) ---") model_study_params = { @@ -158,19 +173,20 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): } result_paths_study_2 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=MOS_SCRIPT, - simulation_setup=SIMULATION_SETUP, + mos_script_pre=mos_script_pre, + simulation_setup=simulation_setup, working_directory=Path(__file__).parent.joinpath("results", "working_directory"), save_path=Path(__file__).parent.joinpath("results", "SimResults_2"), parameters=model_study_params, model_result_file_names=model_result_names, postprocess_mat_result=custom_postprocessing, - kwargs_postprocessing=KWARGS_POSTPROCESSING, + kwargs_postprocessing=kwargs_postprocessing, ) # ## Study 3: Model comparison with individual parameters # Each model variant gets its own parameter set. - # If one model variant does not have a parameter set you have to set an empty dictionary {} + # If one model variant does not have a parameter set, + # you have to set an empty dictionary {}. # Total simulations: len(model_names) = 4 print("\n--- Study 3: Model comparison (individual parameters) ---") rng = np.random.default_rng(42) @@ -181,16 +197,21 @@ def custom_postprocessing(mat_result_file, first_day_of_year, variable_names): ] result_paths_study_3 = simple_dymola_sim_study( model_names=model_names_to_simulate, - mos_script_pre=MOS_SCRIPT, - simulation_setup=SIMULATION_SETUP, + mos_script_pre=mos_script_pre, + simulation_setup=simulation_setup, working_directory=Path(__file__).parent.joinpath("results", "working_directory"), save_path=Path(__file__).parent.joinpath("results", "SimResults_3"), parameters=model_study_div_params, model_result_file_names=model_result_names, postprocess_mat_result=custom_postprocessing, - kwargs_postprocessing=KWARGS_POSTPROCESSING, + kwargs_postprocessing=kwargs_postprocessing, ) print("\n--- All studies finished ---") - print("You could now load the data again in a different script with `ebcpy.load_time_series_data()` " - "for plotting and analysis.") \ No newline at end of file + print("You could now load the data again in a different script with " + "`ebcpy.load_time_series_data()` for plotting and analysis.") + + +if __name__ == "__main__": + # TODO: Adjust this path to your local BESMod startup script + main(mos_script_pre=r"D:\01_git\BESMod\startup.mos") \ No newline at end of file From 6aa0ccdab578ab7250af74b287d62fb25c35b335 Mon Sep 17 00:00:00 2001 From: Hendrik van der Stok Date: Tue, 26 May 2026 15:36:44 +0200 Subject: [PATCH 31/31] add example in docstring --- ebcpy/simulationapi/dymola_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py index eb8b80b..c72ba36 100644 --- a/ebcpy/simulationapi/dymola_utils.py +++ b/ebcpy/simulationapi/dymola_utils.py @@ -54,6 +54,8 @@ def simple_dymola_sim_study( Both modes use model name modifiers to change structural parameters. This is the recommended approach — write the modifier directly in the model name string. + (e.g. 'BESMod.Examples.GasBoilerBuildingOnly( + redeclare BESMod.Systems.Control.DHWSuperheating control(dTDHW=10))') :param list[str] model_names: List of Dymola model names, optionally with modifiers.