diff --git a/CHANGELOG.md b/CHANGELOG.md index 46adb60d..3e4e1736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,3 +140,10 @@ - cap pandas version to <3 - v0.7.1 - fix bug in clean_and_space_equally #171 +- 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 e4df3370..834bf5dc 100644 --- a/ebcpy/__init__.py +++ b/ebcpy/__init__.py @@ -5,7 +5,8 @@ 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 -__version__ = '0.7.1' +__version__ = '0.8.0' diff --git a/ebcpy/data_types.py b/ebcpy/data_types.py index 65a8a33a..f6aa61d7 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.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() + df_to_save.to_parquet( filepath, engine=kwargs.get('engine', 'pyarrow'), compression=parquet_split[-1][1:] if parquet_split[-1] else None, index=True diff --git a/ebcpy/simulationapi/dymola_api.py b/ebcpy/simulationapi/dymola_api.py index 0dae2cb8..8ddbe62e 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,7 +201,15 @@ def __init__( self.fully_initialized = False self.debug = kwargs.pop("debug", False) self.show_window = kwargs.pop("show_window", False) - 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) @@ -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 diff --git a/ebcpy/simulationapi/dymola_utils.py b/ebcpy/simulationapi/dymola_utils.py new file mode 100644 index 00000000..c72ba360 --- /dev/null +++ b/ebcpy/simulationapi/dymola_utils.py @@ -0,0 +1,207 @@ +from pathlib import Path +from typing import Union, List + +from .dymola_api import DymolaAPI +from ..data_types import 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], + 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, + 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 +): + """ + 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. + (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. + E.g. ``["MyModel(nLayer=1)", "MyModel(nLayer=2)"]`` + :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 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) + 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 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 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: + 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 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 diff --git a/ebcpy/utils/__init__.py b/ebcpy/utils/__init__.py index d4cf1db5..c0108782 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 warning: 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/examples/README.md b/examples/README.md index 86a7d48a..09f6844b 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.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 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/e3_0_simple_dymola_example.py b/examples/e3_0_simple_dymola_example.py new file mode 100644 index 00000000..8735b0cf --- /dev/null +++ b/examples/e3_0_simple_dymola_example.py @@ -0,0 +1,217 @@ +# # Example: Basic Dymola Simulation Workflow +# +# Goals of this example: +# 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 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 with AixLib. +# Adjust the ``mos_script_pre`` path to your local BESMod startup script. + +import datetime +import os +from pathlib import Path + +import numpy as np + +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): + """ + 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. Adapt this function to your study. + + :param str mat_result_file: + Path to the .mat file produced by Dymola. + 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. + :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) + + # --- Adapt this section to your study --- + # Example: compute mean storage temperature across all layers + variables = df.columns.to_list() + 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) + + # 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 + + +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 = { + "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 — 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}(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 supported + "hydraulic.distribution.stoBuf.layer[*].T", + "hydraulic.distribution.stoBuf.port_a_consumer.m_flow", + "hydraulic.distribution.stoBuf.port_a_heatGenerator.m_flow", + "hydraulic.distribution.sigBusDistr.*", + "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_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]], + ) + + # ## Study 1: Parameter study + # 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) + result_paths_study_1 = simple_dymola_sim_study( + model_names=model_names_to_simulate, + 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, + ) + + # ## 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. + # 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, + } + result_paths_study_2 = simple_dymola_sim_study( + model_names=model_names_to_simulate, + 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, + ) + + # ## 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) + random_example_values = rng.integers(5, 100, size=len(model_names_to_simulate)) + model_study_div_params = [ + {"parameterStudy.VPerQFlow": int(val)} + for val in random_example_values + ] + result_paths_study_3 = simple_dymola_sim_study( + model_names=model_names_to_simulate, + 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, + ) + + 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.") + + +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 diff --git a/examples/e5_modifier_example.py b/examples/e5_modifier_example.py index ace1b54f..0c0c54cf 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 diff --git a/tests/test_simulationapi.py b/tests/test_simulationapi.py index 56fd5985..95c699f6 100644 --- a/tests/test_simulationapi.py +++ b/tests/test_simulationapi.py @@ -435,5 +435,132 @@ 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.dymola_exe_path = "/usr/local/bin/dymola" + else: + self.dymola_exe_path = 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, + dymola_exe_path=self.dymola_exe_path, + 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"], + 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, + dymola_exe_path=self.dymola_exe_path, + ) + 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"], + 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, + dymola_exe_path=self.dymola_exe_path, + ) + 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 + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index a8fe225e..6ac84846 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.