From 55504ff7bc38e4c82375cd4e18dc97034822ae86 Mon Sep 17 00:00:00 2001 From: BaptisteDE Date: Wed, 10 Sep 2025 09:48:15 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=EF=B8=8FModelicaFmuModel=20new=20?= =?UTF-8?q?version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- corrai/fmu.py | 333 ++++++++++++++++++++------------------------- corrai/sampling.py | 8 +- tests/test_fmu.py | 118 ++++++++-------- 3 files changed, 222 insertions(+), 237 deletions(-) diff --git a/corrai/fmu.py b/corrai/fmu.py index 7867b6a..8728a21 100644 --- a/corrai/fmu.py +++ b/corrai/fmu.py @@ -1,8 +1,10 @@ import datetime as dt import shutil import tempfile -from pathlib import Path import warnings +from pathlib import Path +from contextlib import contextmanager + import fmpy from fmpy import simulate_fmu import pandas as pd @@ -10,6 +12,49 @@ from corrai.base.model import Model +DEFAULT_SIMULATION_OPTIONS = { + "startTime": 0, + "stopTime": 24 * 3600, + "stepSize": 60, + "solver": "CVode", + "tolerance": 1e-6, + "fmi_type": "ModelExchange", +} + + +@contextmanager +def simulation_workspace(fmu_path: Path, boundary_path: Path | None): + """ + Create an isolated temporary workspace with a copy of the FMU and optional + boundary file. Cleans up everything automatically at exit. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + local_fmu = tmpdir / fmu_path.name + shutil.copy(fmu_path, local_fmu) + + local_boundary = None + if boundary_path is not None: + local_boundary = tmpdir / boundary_path.name + shutil.copy(boundary_path, local_boundary) + + yield local_fmu, local_boundary + + +def parse_simulation_times(start, stop, step, output_int): + if all(isinstance(elmt, int) for elmt in (start, stop, step, output_int)): + return start, stop, step, output_int + + elif ( + isinstance(start, (pd.Timestamp, dt.datetime)) + and isinstance(stop, (pd.Timestamp, dt.datetime)) + and isinstance(step, (pd.Timedelta, dt.timedelta)) + and isinstance(output_int, (pd.Timedelta, dt.timedelta)) + ): + return map(datetime_to_second, (start, stop, step, output_int)) + raise ValueError("Invalid 'startTime', 'stopTime', 'stepSize', or 'outputInterval") + def seconds_index_to_datetime_index( index_second: pd.Index, ref_year: int @@ -44,7 +89,7 @@ def seconds_index_to_datetime_index( return pd.DatetimeIndex(pd.to_datetime(diff_seconds, unit="s")) -def datetime_to_second(datetime_in: dt.datetime | pd.Timestamp): +def datetime_to_second(datetime_in: dt.datetime | pd.Timestamp | pd.Timedelta): """ Convert a datetime or timestamp into the number of seconds since the beginning of its year. @@ -64,9 +109,11 @@ def datetime_to_second(datetime_in: dt.datetime | pd.Timestamp): >>> datetime_to_second(dt.datetime(2020, 1, 1, 1, 0, 0)) 3600.0 """ - year = datetime_in.year - origin = dt.datetime(year, 1, 1) - return (datetime_in - origin).total_seconds() + if isinstance(datetime_in, (dt.datetime | pd.Timestamp)): + year = datetime_in.year + origin = dt.datetime(year, 1, 1) + return int((datetime_in - origin).total_seconds()) + return int(datetime_in.total_seconds()) def datetime_index_to_seconds_index(index_datetime: pd.DatetimeIndex) -> pd.Index: @@ -150,49 +197,6 @@ def df_to_combitimetable(df: pd.DataFrame, filename): file.write(df.to_csv(header=False, sep="\t", lineterminator="\n")) -def get_start_stop_year_tz_from_x(x: pd.DataFrame = None): - """ - Extract simulation time bounds and time zone from boundary condition data. - - Parameters - ---------- - x : pd.DataFrame, optional - DataFrame with DatetimeIndex or numeric index. - - Returns - ------- - tuple - (start, stop, year, tz): - - start : float - Minimum time (in seconds). - - stop : float - Maximum time (in seconds). - - year : int or None - Reference year if datetime index was used. - - tz : datetime.tzinfo or None - Time zone information. - - Examples - -------- - >>> import pandas as pd - >>> idx = pd.date_range("2020-01-01", periods=3, freq="H", tz="UTC") - >>> x = pd.DataFrame({"val": [1, 2, 3]}, index=idx) - >>> get_start_stop_year_tz_from_x(x) - (0.0, 7200.0, 2020, datetime.timezone.utc) - """ - if x is None: - return None, None, None - if isinstance(x.index, pd.DatetimeIndex): - idx = datetime_index_to_seconds_index(x.index) - year = x.index[0].year - tz = x.index.tz - else: - idx = x.index - year = None - tz = None - return idx.min(), idx.max(), year, tz - - class ModelicaFmuModel(Model): """ Wrap an FMU (Functional Mock-up Unit) in the corrai Model formalism. @@ -209,7 +213,7 @@ class ModelicaFmuModel(Model): uses a CombiTimeTable. output_list : list of str, optional List of variables to record during simulation. - boundary_table : str or None, optional + boundary_table_name : str or None, optional Name of the CombiTimeTable object in the FMU that is used to provide boundary conditions. @@ -228,7 +232,7 @@ class ModelicaFmuModel(Model): >>> model = ModelicaFmuModel( ... "boundary_test.fmu", ... output_list=["Boundaries.y[1]"], - ... boundary_table="Boundaries", + ... boundary_table_name="Boundaries", ... ) >>> x = pd.DataFrame({"Boundaries.y[1]": [1, 2, 3]}, index=[0, 1, 2]) >>> res = model.simulate(simulation_options={"boundary": x, "stepSize": 1}) @@ -236,116 +240,36 @@ class ModelicaFmuModel(Model): def __init__( self, - fmu_path: Path, - simulation_options: dict[str, float | str | int] = None, - output_list: list[str] = None, - boundary_table: str | None = None, + fmu_path: Path | str, simulation_dir: Path = None, + output_list: list[str] = None, + boundary_table_name: str | None = None, ): - fmu_path = Path(fmu_path) + fmu_path = Path(fmu_path) if isinstance(fmu_path, str) else fmu_path if not fmu_path.exists() or not fmu_path.is_file(): raise FileNotFoundError(f"FMU file not found at {fmu_path}") - self._x = pd.DataFrame() - self.simulation_options = { - "startTime": 0, - "stopTime": 24 * 3600, - "stepSize": 60, - "solver": "CVode", - "outputInterval": 1, - "tolerance": 1e-6, - "fmi_type": "ModelExchange", - } - self.model_path = fmu_path + self.fmu_path = fmu_path self.simulation_dir = ( Path(tempfile.mkdtemp()) if simulation_dir is None else simulation_dir ) self.output_list = output_list - self.parameters = {} - self._begin_year = None - self._tz = None - self.boundary_table = boundary_table - - if simulation_options is not None: - self.set_simulation_options(simulation_options) - - def set_simulation_options( - self, - simulation_options: dict[ - str, float | str | int | dt.datetime | pd.Timestamp - ] = None, - ): - """ - Set simulation options and boundary data if provided. - - Parameters - ---------- - simulation_options : dict, optional - May include: - - ``startTime``, ``stopTime`` : float or datetime - - ``stepSize``, ``solver``, ``outputInterval``, ``tolerance``, ``fmi_type`` - - ``boundary`` : pd.DataFrame, boundary data for the CombiTimeTable - """ - - if simulation_options is None: - return - - if "boundary" in simulation_options: - if self.boundary_table is None: - warnings.warn( - "Boundary provided but no combitimetable name set -> ignoring." + self.boundary_table_name = boundary_table_name + self.boundary_file_path = None + if self.boundary_table_name is not None: + model_description = fmpy.read_model_description(self.fmu_path.as_posix()) + var_map = {var.name: var.start for var in model_description.modelVariables} + try: + self.boundary_file_path = Path( + rf"{var_map[f"{self.boundary_table_name}.fileName"]}" ) - else: - model_description = fmpy.read_model_description( - self.model_path.as_posix() + except KeyError: + warnings.warn( + f"Boundary combitimetable '{self.boundary_table_name}' " + f"not found in FMU -> ignoring boundary.", + UserWarning, + stacklevel=2, ) - varnames = [v.name for v in model_description.modelVariables] - if f"{self.boundary_table}.fileName" not in varnames: - warnings.warn( - f"Boundary combitimetable '{self.boundary_table}' " - f"not found in FMU -> ignoring boundary.", - UserWarning, - stacklevel=2, - ) - else: - self.set_boundary(simulation_options["boundary"]) - - to_update = { - k: v - for k, v in simulation_options.items() - if k not in ["startTime", "stopTime"] - } - self.simulation_options.update(to_update) - - simo = {} - for key in ["startTime", "stopTime"]: - if key in simulation_options: - if isinstance(simulation_options[key], (dt.datetime, pd.Timestamp)): - simo[key] = datetime_to_second(simulation_options[key]) - if key == "startTime": - self._begin_year = simulation_options["startTime"].year - else: - simo[key] = simulation_options[key] - else: - simo[key] = self.simulation_options[key] - self.simulation_options["startTime"] = simo["startTime"] - self.simulation_options["stopTime"] = simo["stopTime"] - - def set_boundary(self, df: pd.DataFrame): - """Set boundary data and update parameters accordingly.""" - if not self._x.equals(df): - new_bounds_path = self.simulation_dir / "boundaries.txt" - df_to_combitimetable(df, new_bounds_path) - self.parameters[f"{self.boundary_table}.fileName"] = ( - new_bounds_path.as_posix() - ) - self._x = df - - start, stop, year, tz = get_start_stop_year_tz_from_x(df) - self.simulation_options["startTime"] = start - self.simulation_options["stopTime"] = stop - self._begin_year = year - self._tz = tz def get_property_values( self, property_list: str | tuple[str, ...] | list[str] @@ -375,7 +299,7 @@ def get_property_values( if isinstance(property_list, str): property_list = (property_list,) - model_description = fmpy.read_model_description(self.model_path.as_posix()) + model_description = fmpy.read_model_description(self.fmu_path.as_posix()) variable_map = {var.name: var for var in model_description.modelVariables} values = [] for prop in property_list: @@ -432,46 +356,91 @@ def simulate( the one from `property_dict` takes precedence, with a warning. """ + property_dict = dict(property_dict or {}) - if debug_param: + if property_dict and debug_param: print(property_dict) - if property_dict and "boundary" in property_dict: - if simulation_options and "boundary" in simulation_options: + simulation_options = { + **DEFAULT_SIMULATION_OPTIONS, + **(simulation_options or {}), + } + + start, stop, step, output_int = ( + simulation_options.get(it, None) + for it in ["startTime", "stopTime", "stepSize", "outputInterval"] + ) + + if output_int is None: + output_int = step + + start_sec, stop_sec, step_sec, output_int_sec = parse_simulation_times( + start, stop, step, output_int + ) + + boundary_df = None + if property_dict: + boundary_df = property_dict.pop("boundary", boundary_df) + + if simulation_options: + sim_boundary = simulation_options.pop("boundary", boundary_df) + + if boundary_df is None and sim_boundary is not None: + boundary_df = sim_boundary + elif boundary_df is not None and sim_boundary is not None: warnings.warn( - "Boundary specified in both property_dict and simulation_options. " - "The one in property_dict will be used.", + "Boundary specified in both property_dict and " + "simulation_options. The one in property_dict will be used.", UserWarning, stacklevel=2, ) - self.set_boundary(property_dict["boundary"]) - property_dict = {k: v for k, v in property_dict.items() if k != "boundary"} - - self.parameters.update(property_dict or {}) - - self.set_simulation_options(simulation_options) - - result = simulate_fmu( - filename=self.model_path, - start_time=self.simulation_options["startTime"], - stop_time=self.simulation_options["stopTime"], - step_size=self.simulation_options["stepSize"], - relative_tolerance=self.simulation_options["tolerance"], - start_values=self.parameters, - output=self.output_list, - solver=self.simulation_options["solver"], - output_interval=self.simulation_options["outputInterval"], - fmi_type=self.simulation_options["fmi_type"], - debug_logging=debug_logging, - logger=logger, - ) + + if boundary_df is not None: + boundary_df = boundary_df.copy() + if isinstance(boundary_df.index, pd.DatetimeIndex): + boundary_df.index = datetime_index_to_seconds_index(boundary_df.index) + + if not ( + boundary_df.index[0] <= start_sec <= boundary_df.index[-1] + and boundary_df.index[0] <= stop_sec <= boundary_df.index[-1] + ): + raise ValueError( + "'startTime' and 'stopTime' are outside boundary DataFrame" + ) + + self.boundary_file_path = self.simulation_dir / "boundaries.txt" + df_to_combitimetable(boundary_df, self.boundary_file_path) + + with simulation_workspace(self.fmu_path, self.boundary_file_path) as ( + local_fmu, + local_boundary, + ): + if local_boundary is not None and self.boundary_table_name: + property_dict[f"{self.boundary_table_name}.fileName"] = ( + local_boundary.as_posix() + ) + + result = simulate_fmu( + filename=local_fmu, + start_time=start_sec, + stop_time=stop_sec, + step_size=step_sec, + relative_tolerance=simulation_options["tolerance"], + start_values=property_dict, + output=self.output_list, + solver=simulation_options["solver"], + output_interval=output_int_sec, + fmi_type=simulation_options["fmi_type"], + debug_logging=debug_logging, + logger=logger, + ) df = pd.DataFrame(result, columns=["time"] + self.output_list) - if self._begin_year is not None: - df.index = seconds_index_to_datetime_index(df["time"], self._begin_year) + if isinstance(start, (pd.Timestamp, dt.datetime)): + df.index = seconds_index_to_datetime_index(df["time"], start.year) df.index = df.index.round("s") - df = df.tz_localize(self._tz) + df = df.tz_localize(start.tz) df.index.freq = df.index.inferred_freq else: df.index = round(df["time"], 2) @@ -495,7 +464,7 @@ def save(self, file_path: Path): Destination path. """ - shutil.copyfile(self.model_path, file_path) + shutil.copyfile(self.fmu_path, file_path) def __repr__(self): """ @@ -524,11 +493,11 @@ def __repr__(self): Name: res.significantDigits, Default Value: 2, Description: Number of significant digits to be shown """ - model_description = fmpy.read_model_description(self.model_path.as_posix()) + model_description = fmpy.read_model_description(self.fmu_path.as_posix()) model_info = f"Model Name: {model_description.modelName}\n" model_info += ( - f"Description: {fmpy.read_model_description(self.model_path.as_posix())}\n" + f"Description: {fmpy.read_model_description(self.fmu_path.as_posix())}\n" ) model_info += f"Version: {model_description.fmiVersion}\n" model_info += "Parameters:\n" diff --git a/corrai/sampling.py b/corrai/sampling.py index f81733b..7b3395e 100644 --- a/corrai/sampling.py +++ b/corrai/sampling.py @@ -1075,10 +1075,14 @@ def __init__( ): super().__init__(parameters, model, simulation_options) - def add_sample(self, n: int, rng: int = None, simulate=True, **lhs_kwargs): + def add_sample( + self, n: int, rng: int = None, simulate=True, n_cpu: int = 1, **lhs_kwargs + ): lhs = LatinHypercube(d=len(self.parameters), rng=rng, **lhs_kwargs) new_dimless_sample = lhs.random(n=n) - self._post_draw_sample(new_dimless_sample, simulate, sample_is_dimless=True) + self._post_draw_sample( + new_dimless_sample, simulate, n_cpu, sample_is_dimless=True + ) class SobolSampler(RealSampler): diff --git a/tests/test_fmu.py b/tests/test_fmu.py index b8375d1..9a93557 100644 --- a/tests/test_fmu.py +++ b/tests/test_fmu.py @@ -1,15 +1,14 @@ -import datetime as dt import os import platform import tempfile from pathlib import Path -import pytest import numpy as np import pandas as pd from corrai.fmu import ModelicaFmuModel from corrai.base.parameter import Parameter +from corrai.sampling import LHSSampler system = platform.system() @@ -25,6 +24,10 @@ class TestFmu: def test_results(self): simu = ModelicaFmuModel( fmu_path=PACKAGE_DIR / "rosen.fmu", + output_list=["res.showNumber"], + ) + + res = simu.simulate( simulation_options={ "startTime": 0, "stopTime": 2, @@ -33,20 +36,44 @@ def test_results(self): "outputInterval": 1, "tolerance": 1e-6, }, - output_list=["res.showNumber"], ) - - res = simu.simulate() ref = pd.DataFrame({"res.showNumber": [401.0, 401.0, 401.0]}) assert np.allclose(res["res.showNumber"].values, ref["res.showNumber"].values) + res = simu.simulate( + simulation_options={ + "startTime": pd.Timestamp("2009-01-01"), + "stopTime": pd.Timestamp("2009-01-02"), + "stepSize": pd.Timedelta("30min"), + "outputInterval": pd.Timedelta("5h"), + } + ) + + ref = pd.DataFrame( + data=np.array([[401.0], [401.0], [401.0], [401.0], [401.0], [401.0]]), + index=pd.date_range("2009-01-01", freq="5h", periods=6, name="time"), + columns=["res.showNumber"], + ) + + pd.testing.assert_frame_equal(res, ref) + + res = simu.simulate( + simulation_options={ + "startTime": pd.Timestamp("2009-01-01"), + "stopTime": pd.Timestamp("2009-01-02"), + "stepSize": pd.Timedelta("5h"), + } + ) + + pd.testing.assert_frame_equal(res, ref) + if system == "Windows": # Because issues with relatives filepaths exporting FMUs from OM. def test_simulate(self): simu = ModelicaFmuModel( fmu_path=PACKAGE_DIR / "boundary_test.fmu", output_list=["Boundaries.y[1]", "Boundaries.y[2]"], - boundary_table="Boundaries", + boundary_table_name="Boundaries", ) new_bounds = pd.DataFrame( @@ -60,7 +87,8 @@ def test_simulate(self): res = simu.simulate( simulation_options={ "solver": "CVode", - "outputInterval": 1, + "startTime": 3, + "stopTime": 5, "stepSize": 1, "boundary": new_bounds, }, @@ -93,8 +121,9 @@ def test_simulate(self): res = simu.simulate( simulation_options={ - "outputInterval": 3600, - "stepSize": 3600, + "startTime": pd.Timestamp("2009-07-13 00:00:00"), + "stopTime": pd.Timestamp("2009-07-13 04:00:00"), + "stepSize": pd.Timedelta("1h"), "boundary": x_datetime, }, solver_duplicated_keep="last", @@ -102,17 +131,6 @@ def test_simulate(self): pd.testing.assert_frame_equal(res, x_datetime) - res = simu.simulate( - simulation_options={ - "startTime": dt.datetime(2009, 7, 13, 0, 0, 0), - "stopTime": dt.datetime(2009, 7, 13, 3, 0, 0), - "outputInterval": 3600, - }, - solver_duplicated_keep="last", - ) - - pd.testing.assert_frame_equal(res, x_datetime.loc[:"2009-7-13 03:00:00", :]) - def test_save(self): simu = ModelicaFmuModel( fmu_path=PACKAGE_DIR / "rosen.fmu", @@ -144,10 +162,9 @@ def test_get_property_values(self): vals = simu.get_property_values(["x.k", "y.k"]) assert vals == ["2.0", "2.0"] - def test_simulate_parameter(self): + def test_simulate_parallel(self): simu = ModelicaFmuModel( fmu_path=PACKAGE_DIR / "rosen.fmu", - simulation_options={"startTime": 0, "stopTime": 2, "stepSize": 1}, output_list=["res.showNumber"], ) @@ -161,39 +178,34 @@ def test_simulate_parameter(self): ), ] - res1 = simu.simulate({"y.k": 4, "x.k": 3}) - res2 = simu.simulate_parameter( - [ - (param[0], 3), - (param[1], 4), - ] - ) - assert res1["res.showNumber"].equals(res2["res.showNumber"]) - - def test_boundary_warning(self): - simu = ModelicaFmuModel( - fmu_path=PACKAGE_DIR / "rosen.fmu", - output_list=["res.showNumber"], - boundary_table="Boundaries", + sampler = LHSSampler( + param, + simu, + { + "startTime": pd.Timestamp("2009-01-01 00:00:00"), + "stopTime": pd.Timestamp("2009-01-01 03:00:00"), + "stepSize": pd.Timedelta("1h"), + }, ) - fake_boundary = pd.DataFrame({"u": [1, 2, 3]}, index=[0, 1, 2]) + sampler.add_sample(10, rng=42, n_cpu=4) - with pytest.warns( - UserWarning, - match="Boundary combitimetable 'Boundaries' " - "not found in FMU -> ignoring boundary.", - ): - res = simu.simulate( - simulation_options={ - "startTime": 0, - "stopTime": 2, - "stepSize": 1, - "boundary": fake_boundary, + pd.testing.assert_frame_equal( + sampler.get_sample_aggregated_time_series("res.showNumber"), + pd.DataFrame( + { + "aggregated_res.showNumber": { + 0: 9028.841228250902, + 1: 3867.529535367751, + 2: 17616.93240203703, + 3: 15.654149284825673, + 4: 39797.376949843376, + 5: 20.053828778963116, + 6: 8760.467701320911, + 7: 286.2421069382586, + 8: 2492.382439642574, + 9: 12.543082479944328, + } } - ) - - ref = pd.DataFrame({"res.showNumber": [401.0, 401.0, 401.0]}) - np.testing.assert_allclose( - res["res.showNumber"].values, ref["res.showNumber"].values + ), ) From 5310294995a9a1918c561a061bdca40a1bd468e9 Mon Sep 17 00:00:00 2001 From: BaptisteDE Date: Wed, 10 Sep 2025 10:35:07 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=EF=B8=8F=20doc=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- corrai/fmu.py | 151 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 113 insertions(+), 38 deletions(-) diff --git a/corrai/fmu.py b/corrai/fmu.py index 8728a21..63c368a 100644 --- a/corrai/fmu.py +++ b/corrai/fmu.py @@ -199,43 +199,64 @@ def df_to_combitimetable(df: pd.DataFrame, filename): class ModelicaFmuModel(Model): """ - Wrap an FMU (Functional Mock-up Unit) in the corrai Model formalism. + Wrapper for a Modelica FMU (Functional Mock-up Unit) in the corrai ``Model`` + formalism. + + Provides functionality to: + - Load an FMU and its metadata. + - Query property initial values. + - Run simulations with configurable options. + - Handle boundary conditions using a CombiTimeTable if defined. Parameters ---------- - fmu_path : Path + fmu_path : Path or str Path to the FMU file. - simulation_options : dict, optional - Dictionary of simulation options including: - ``startTime``, ``stopTime``, ``stepSize``, ``solver``, - ``outputInterval``, ``tolerance``, ``fmi_type``. - Can also include ``boundary`` (pd.DataFrame) if the FMU - uses a CombiTimeTable. + simulation_dir : Path, optional + Directory for simulation files. A temporary directory is created if not + provided. output_list : list of str, optional - List of variables to record during simulation. + Names of FMU variables to record during simulation. boundary_table_name : str or None, optional - Name of the CombiTimeTable object in the FMU that is used to - provide boundary conditions. + Name of the CombiTimeTable object in the FMU used for boundary conditions. + If provided, boundary data can be passed through + ``simulation_options["boundary"]`` or ``property_dict["boundary"]``. If + ``None`` (default), boundaries are ignored. Boundaries specified in + ``property_dict`` will always override ``simulation_options`` boundaries - - If a string is provided, boundary data can be passed through - ``simulation_options["boundary"]``. - - If None (default), no CombiTimeTable will be set and any - provided ``boundary`` will be ignored. - simulation_dir : Path, optional - Directory for simulation files. A temporary directory is created - if not provided. Examples -------- >>> import pandas as pd >>> from corrai.fmu import ModelicaFmuModel - >>> model = ModelicaFmuModel( - ... "boundary_test.fmu", - ... output_list=["Boundaries.y[1]"], + + >>> simu = ModelicaFmuModel( + ... fmu_path=fmu_path, + ... output_list=["Boundaries.y[1]", "Boundaries.y[2]"], ... boundary_table_name="Boundaries", ... ) - >>> x = pd.DataFrame({"Boundaries.y[1]": [1, 2, 3]}, index=[0, 1, 2]) - >>> res = model.simulate(simulation_options={"boundary": x, "stepSize": 1}) + + >>> new_bounds = pd.DataFrame( + ... {"Boundaries.y[1]": [1, 2, 3], "Boundaries.y[2]": [3, 4, 5]}, + ... index=range(3, 6), + ... ) + + >>> res = simu.simulate( + ... simulation_options={ + ... "solver": "CVode", + ... "startTime": 3, + ... "stopTime": 5, + ... "stepSize": 1, + ... "boundary": new_bounds, + ... }, + ... solver_duplicated_keep="last", + ... ) + + Boundaries.y[1] Boundaries.y[2] + time + 3.0 1.0 3.0 + 4.0 2.0 4.0 + 5.0 3.0 5.0 """ def __init__( @@ -321,39 +342,93 @@ def simulate( logger=None, ) -> pd.DataFrame: """ - Run FMU simulation for the given parameters and simulation options. + Run an FMU simulation with properties and boundary configuration. Parameters ---------- property_dict : dict, optional Dictionary of FMU parameter values to set before simulation. - Can include "boundary" (pd.DataFrame) if boundary data must override - simulation_options. + May include a key ``"boundary"`` with a DataFrame of boundary conditions. + If both ``property_dict`` and ``simulation_options`` specify boundaries, + the one in ``property_dict`` takes precedence. simulation_options : dict, optional - Simulation options. May include: - - ``startTime``, ``stopTime``, ``stepSize``, ``solver``, - ``outputInterval``, ``tolerance``, ``fmi_type``. - - ``boundary`` : pd.DataFrame of boundary conditions. + Simulation settings. Supported keys include: + + - ``startTime`` : float or pandas.Timestamp + - ``stopTime`` : float or pandas.Timestamp + - ``stepSize`` : float or pandas.TimeDelta + - ``outputInterval`` : float or pandas.TimeDelta. If not provided, it will + be set equal to ``stepSize`` + - ``solver`` : str + - ``tolerance`` : float + - ``fmi_type`` : {"CoSimulation", "ModelExchange"} + - ``boundary`` : pandas.DataFrame of boundary conditions + solver_duplicated_keep : {"first", "last"}, default "last" - Which value to keep if solver outputs duplicated indices. + Which entry to keep if solver outputs duplicated indices. post_process_pipeline : sklearn.Pipeline, optional - Pipeline applied to simulation results. + Transformation pipeline applied to simulation results before returning. debug_param : bool, default False - If True, prints `property_dict`. + If True, prints the property dictionary before simulation. debug_logging : bool, default False Enable verbose logging from fmpy. logger : callable, optional - Logger for fmpy. + Custom logger for fmpy. Returns ------- - pd.DataFrame - Simulation results, indexed by time. + pandas.DataFrame + Simulation results indexed by time. If ``startTime`` is a + :class:`pandas.Timestamp`, the index is a DateTimeIndex; otherwise, + a numeric index is used. + + Raises + ------ + ValueError + If ``startTime`` or ``stopTime`` are outside the boundary DataFrame. Notes ----- - - If boundary is provided both in `property_dict` and `simulation_options`, - the one from `property_dict` takes precedence, with a warning. + - Duplicate time indices are resolved using ``solver_duplicated_keep``. + + Examples + -------- + Run a basic simulation with default options: + + >>> model = ModelicaFmuModel("simple.fmu", output_list=["y"]) + >>> res = model.simulate( + ... simulation_options={"startTime": 0, "stopTime": 10, "stepSize": 1} + ... ) + >>> res.head() + y + 0.0 0.0 + 1.0 1.1 + 2.0 2.3 + ... + + Run a simulation with boundary conditions: + + >>> import pandas as pd + >>> x = pd.DataFrame({"Boundaries.y[1]": [1, 2, 3]}, index=[0, 1, 2]) + >>> model = ModelicaFmuModel( + ... "boundary_test.fmu", + ... output_list=["Boundaries.y[1]"], + ... boundary_table_name="Boundaries", + ... ) + >>> res = model.simulate( + ... simulation_options={ + ... "boundary": x, + ... "startTime": 0, + ... "stopTime": 2, + ... "stepSize": 1, + ... } + ... ) + >>> res.head() + Boundaries.y[1] + time + 0.0 1.0 + 1.0 2.0 + 2.0 3.0 """ property_dict = dict(property_dict or {}) From add383a8366cd1bec7632fa7c529074e915d7066 Mon Sep 17 00:00:00 2001 From: BaptisteDE Date: Wed, 10 Sep 2025 16:07:23 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20update=20notebook=20tu?= =?UTF-8?q?torial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tutorials/FMU models Handling.ipynb | 282 +++++++++------------------- 1 file changed, 93 insertions(+), 189 deletions(-) diff --git a/tutorials/FMU models Handling.ipynb b/tutorials/FMU models Handling.ipynb index 8151617..9acbb08 100644 --- a/tutorials/FMU models Handling.ipynb +++ b/tutorials/FMU models Handling.ipynb @@ -2,15 +2,15 @@ "cells": [ { "cell_type": "code", - "execution_count": null, "id": "b28b6845", "metadata": {}, - "outputs": [], "source": [ "import pandas as pd\n", "from pathlib import Path\n", "import os" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -52,126 +52,47 @@ "id": "94f78fac-8238-4755-a876-3b7b63a8c323", "metadata": {}, "source": [ - "# 2. Set boundary file\n", + "# 2. Load boundary file\n", "First, let us load measurement data on python, which will be used as our boundary conditions:" ] }, { "cell_type": "code", - "execution_count": null, "id": "71e65ff5-8023-4cd5-884d-c0c1c4118235", "metadata": {}, - "outputs": [], "source": [ "TUTORIAL_DIR = Path(os.getcwd()).as_posix()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "fae39639-7b9d-42c7-ae6f-d403b570dd0b", "metadata": {}, - "outputs": [], "source": [ "reference_df = pd.read_csv(\n", " Path(TUTORIAL_DIR) / \"resources/study_df.csv\",\n", " index_col=0,\n", " parse_dates=True\n", ") " - ] - }, - { - "cell_type": "markdown", - "id": "8e5b0a86-64c9-4bdc-a06a-7a360dfd7985", - "metadata": {}, - "source": [ - "# 2. Set simulations options" - ] - }, - { - "cell_type": "markdown", - "id": "1a90bd42-0eb4-4cdb-aabd-66737fca9efd", - "metadata": {}, - "source": [ - "The used class for running the FMU model requires a model path, simulation options, and optionaly, a reference dataframe for boundary options (to override the default one) and a list of outputs.\n", - "\n", - "We already loaded the boundary file. We can set the simulation options:\n", - "- Start time and stop time should be in second. We can use the index of the DataFrame we just created.\n", - "The function datetime_to_seconds\n", - "helps you convert datetime index in seconds.\n", - "- The solver in the simulation options must be one of 'Euler' or 'CVode'.\n", - "- The output interval is in seconds." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ad64c5af-6f61-4211-b2ea-9c3db82fc3b3", - "metadata": {}, + ], "outputs": [], - "source": [ - "import datetime as dt\n", - "\n", - "def datetime_to_seconds(index_datetime):\n", - " time_start = dt.datetime(index_datetime[0].year, 1, 1, tzinfo=dt.timezone.utc)\n", - " new_index = index_datetime.to_frame().diff().squeeze()\n", - " new_index.iloc[0] = dt.timedelta(\n", - " seconds=index_datetime[0].timestamp() - time_start.timestamp()\n", - " )\n", - " sec_dt = [elmt.total_seconds() for elmt in new_index]\n", - " return list(pd.Series(sec_dt).cumsum())" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "id": "6f5cdd66-c51d-4d06-80d6-a4c53a887b2e", + "id": "284a6920-74d3-4252-a91f-6dc7cab4797c", "metadata": {}, - "outputs": [], "source": [ - "second_index = datetime_to_seconds(reference_df.index)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6bebb18-37f0-4ab9-9d2f-75cda5c29ecc", - "metadata": {}, + "reference_df.head()" + ], "outputs": [], - "source": [ - "simulation_options_FMU = {\n", - " \"startTime\":second_index[0],\n", - " \"stopTime\": second_index[-1],\n", - " \"solver\": \"CVode\", \n", - " \"outputInterval\": 300,\n", - "}" - ] + "execution_count": null }, { "cell_type": "markdown", - "id": "0ea8c4b4-2eab-429c-a67d-743aaa47a5bd", - "metadata": {}, - "source": [ - "And define a list of output that will be included in the dataframe output for any simulation, here the calculated temperatures between layers of the wall." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64149508-369a-4a8c-8928-6c71090b4428", - "metadata": {}, - "outputs": [], - "source": [ - "output_list = [\n", - " \"T_coat_ins.T\",\n", - " \"T_ins_ins.T\",\n", - " \"Tw_out.T\"\n", - "]" - ] - }, - { - "cell_type": "markdown", - "id": "1ec7abf2-46cf-485c-bccb-66334fd5d058", + "id": "40057a02-f73b-4627-9fe4-c26d732db1c3", "metadata": {}, "source": [ "# 3. Instantiate ModelicaFmuModel" @@ -179,161 +100,144 @@ }, { "cell_type": "markdown", - "id": "fddcef82-1ea3-4d8e-a4e0-026f43cf0b38", + "id": "b78c1677-aa17-44da-9204-55e5e4e8da60", "metadata": {}, "source": [ "Now, we can also load an FMU ModelicaModel from corrai.fmu: \n", "\n", "Attributes:\n", "- fmu_path: Path to the FMU file.\n", - "- simulation_options: A dictionary containing simulation settings such as startTime, stopTime, and stepSize.\n", - "- x: Input boundary conditions provided as a pandas.DataFrame.\n", "- output_list: List of simulation output variables.\n", - "- simulation_dir: Directory for storing simulation files.\n", - "- parameters: Dictionary of simulation parameters.\n" + "- simulation_dir: Directory for storing simulation files." ] }, { - "cell_type": "code", - "execution_count": null, - "id": "266c40e8-13b0-4696-9606-09f86b10f33a", "metadata": {}, + "cell_type": "code", + "source": "from corrai.fmu import ModelicaFmuModel", + "id": "555d15649a4ac71c", "outputs": [], - "source": [ - "from corrai.fmu import ModelicaFmuModel " - ] + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, - "id": "dd25e45b-7c44-4c01-b477-10cb3afcaf25", "metadata": {}, - "outputs": [], + "cell_type": "code", "source": [ "TUTORIAL_DIR = Path(os.getcwd()).as_posix()\n", "\n", - "simu_FMU = ModelicaFmuModel(\n", + "simu_fmu = ModelicaFmuModel(\n", " fmu_path=Path(TUTORIAL_DIR) / \"resources/etics_v0.fmu\",\n", - " simulation_options=simulation_options_FMU,\n", - " x = reference_df,\n", - " output_list=output_list,\n", + " output_list= [\n", + " \"T_coat_ins.T\",\n", + " \"T_ins_ins.T\",\n", + " \"Tw_out.T\"\n", + "],\n", + " boundary_table_name=\"Boundaries\"\n", ")" - ] + ], + "id": "af357bb5be00d3da", + "outputs": [], + "execution_count": null }, { + "metadata": {}, "cell_type": "markdown", - "id": "bdaad707-c875-4b51-99a1-716e2c02d250", + "source": "The ``__repr__`` method of ``ModelicaFmuModel`` can give you valuable information on the fmu content and on the Modelica classes used in the model.", + "id": "df700995e986e00e" + }, + { "metadata": {}, - "source": [ - "# 4. Run a simulation" - ] + "cell_type": "code", + "source": "simu_fmu", + "id": "fef9b59331d10486", + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", - "id": "c25f8d44-b471-4e37-85ec-5da0d3d4d906", + "id": "bdaad707-c875-4b51-99a1-716e2c02d250", "metadata": {}, - "source": [ - "A simulation is run using the simulate() method, with the following parameters:\n", - "- parameter_dict (optional): A dictionary containing parameter values for the simulation.\n", - "- simulation_options (optional): A dictionary defining simulation-specific settings such as start and stop times or solver types. Here, the simulation options were already provided when instantiating the model.\n", - "- x (optional): Boundary condition data as a pandas.DataFrame to be used during the simulation. Already provided.\n", - "- solver_duplicated_keep (default: \"last\"): Handles duplicated solver indices by selecting the desired version (\"last\" or \"first\").\n", - "- post_process_pipeline (optional): A scikit-learn pipeline to apply post-processing steps on simulation results.\n", - "- debug_param (default: False): Prints the parameter_dict if enabled.\n", - "- debug_logging (default: False): Enables detailed logging for debugging purposes.\n", - "- logger (optional): A custom logger instance for capturing logs during the simulation.\n" - ] + "source": "# 4. Run a simulation" }, { "cell_type": "markdown", - "id": "22efbb3d-ebfc-485b-beab-03e0257cb288", + "id": "c25f8d44-b471-4e37-85ec-5da0d3d4d906", "metadata": {}, "source": [ - "Let's set the initial and parameter values in a dictionary : \n", - "- initial temperatures of internal wall surface, insulation nodes, and coating surface\n", - "- value of conductivity of insulation." + "It is time to run a simulation !\n", + "\n", + "A simulation is run using ``simulate()`` method, with the following arguments:\n", + "- ``property_dict`` (optional): A dictionary containing model property key and values for the simulation.\n", + "- ``simulation_options`` (optional): A dictionary defining simulation-specific settings such as start and stop times or solver types:\n", + " - ``startTime`` : float or pandas.Timestamp\n", + " - ``stopTime`` : float or pandas.Timestamp\n", + " - ``stepSize`` : float or pandas.TimeDelta\n", + " - ``outputInterval`` : float or pandas.TimeDelta. If not provided, it will\n", + " be set equal to ``stepSize``\n", + " - ``solver`` : str\n", + " - ``tolerance`` : float\n", + " - ``fmi_type`` : {\"CoSimulation\", \"ModelExchange\"}\n", + " - ``boundary`` : pandas.DataFrame of boundary conditions\n", + "\n", + "Whether it is in ``property_dict`` or in ``simulation_options``, boundary dataframe must be specified for the simulation. Otherwise, the fmu would try to read a non existing file.\n", + "For this simulation, we ask to simulate from the first time stamp in the boundary file to the last, with a 15min timestep, but a 1h output reporting frequency.\n", + "\n", + "- ``solver_duplicated_keep`` (default: \"last\"): Handles duplicated solver indices by selecting the desired version (\"last\" or \"first\").\n", + "- ``post_process_pipeline`` (optional): A scikit-learn pipeline to apply post-processing steps on simulation results.\n", + "- ``debug_param`` (default: False): Prints the parameter_dict if enabled.\n", + "- ``debug_logging`` (default: False): Enables detailed logging for debugging purposes.\n", + "- ``logger`` (optional): A custom logger instance for capturing logs during the simulation.\n" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "1403c28c-6474-4b78-8f3f-3cede5d1e48e", "metadata": {}, - "outputs": [], + "cell_type": "code", "source": [ - "parameter_dict = {\n", + "property_dict = {\n", " \"Twall_init\": 24.81 + 273.15,\n", " \"Tins1_init\": 19.70 + 273.15,\n", " \"Tins2_init\": 10.56 + 273.15,\n", " \"Tcoat_init\": 6.4 + 273.15,\n", " 'Lambda_ins.k': 0.04,\n", "}" - ] + ], + "id": "6de925f6d5077da9", + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", - "id": "0078bd89-fde8-4281-bb99-69f687dc1160", "metadata": {}, - "source": [ - "And run the simulation:" - ] - }, - { "cell_type": "code", - "execution_count": null, - "id": "622f79f1-e206-4b08-83e6-3b3242aa7d33", - "metadata": {}, - "outputs": [], "source": [ - "init_res_FMU = simu_FMU.simulate(\n", - " parameter_dict = parameter_dict,\n", + "init_res_fmu = simu_fmu.simulate(\n", + " property_dict = property_dict,\n", + " simulation_options={\n", + " \"startTime\": reference_df.index[0],\n", + " \"stopTime\": reference_df.index[-1],\n", + " \"stepSize\": pd.Timedelta(\"15min\"),\n", + " \"outputInterval\": pd.Timedelta(\"1h\"),\n", + " \"boundary\": reference_df\n", + " },\n", " debug_logging=False\n", ")" - ] - }, - { - "cell_type": "markdown", - "id": "f80e88a7-c338-4955-a81c-358e42917cb4", - "metadata": {}, - "source": [ - "Results are displayed in a dataframe:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1702d4fe-42f7-48bc-baa7-d5c0502d1776", - "metadata": {}, + ], + "id": "396d807d4426f4a1", "outputs": [], - "source": [ - "init_res_FMU" - ] + "execution_count": null }, { - "cell_type": "markdown", - "id": "be4f47a3-2fdd-4b16-bcf6-fc1f05a9e020", "metadata": {}, - "source": [ - "We can quickly plot the results:" - ] + "cell_type": "markdown", + "source": "We can quickly plot the results:", + "id": "5d4e5428f7481fed" }, { - "cell_type": "code", - "execution_count": null, - "id": "642b65ab-cc14-4e2f-9b42-4a11f45e30c6", "metadata": {}, - "outputs": [], - "source": [ - "init_res_FMU.plot()" - ] - }, - { "cell_type": "code", - "execution_count": null, - "id": "c0b58fca-65de-462d-b6a5-fd34707db05b", - "metadata": {}, + "source": "init_res_fmu.plot()", + "id": "ca6490e689e0580f", "outputs": [], - "source": [] + "execution_count": null } ], "metadata": { @@ -352,7 +256,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.13" + "version": "3.12.0" } }, "nbformat": 4, From 6943c0f247a13846dca9e3130c0485a5cd1b9dae Mon Sep 17 00:00:00 2001 From: BaptisteDE Date: Wed, 10 Sep 2025 16:33:57 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=92=9A=20compatibility=20python=20