diff --git a/energytool/building.py b/energytool/building.py index a234ecf..04fc631 100644 --- a/energytool/building.py +++ b/energytool/building.py @@ -10,7 +10,7 @@ from pathlib import Path import pandas as pd - +from corrai.base.model import Model from eppy.modeleditor import IDF from eppy.runner.run_functions import run import eppy.json_functions as json_functions @@ -56,56 +56,7 @@ def temporary_directory(): shutil.rmtree(temp_dir) -def expand_parameter_dict(parameter_dict, param_mappings): - """ - Expands a parameter dictionary based on predefined mappings. - - This method takes a dictionary of parameters and expands it by applying - predefined mappings stored in param_mappings. The expansion process works - as follows: - - 1. **If the mapping for a parameter is a dictionary**: - - The method checks if the value exists as a key in the mapping. - - If found, the corresponding mapped values are added to the expanded dictionary. - - 2. **If the mapping for a parameter is a list**: - - The method applies the value to each key in the mapping and adds - these key-value pairs to the expanded dictionary. - - 3. **If a parameter has no predefined mapping**: - - The original parameter and its value are added directly to the expanded - dictionary. - - Parameters - ---------- - parameter_dict : dict - The original parameter dictionary, with parameter names as keys - and their values as values. - param_mappings: dict - A dictionary defining how sampled parameters should be expanded. - - Returns - ------- - dict - An expanded parameter dictionary containing the original parameters along with - additional key-value pairs derived from the predefined mappings. - - """ - expanded_dict = {} - for param_name, value in parameter_dict.items(): - if param_name in param_mappings: - mapping = param_mappings[param_name] - if isinstance(mapping, dict): - if value in mapping: - expanded_dict.update(mapping[value]) - else: - expanded_dict.update({k: value for k in mapping}) - else: - expanded_dict[param_name] = value - return expanded_dict - - -class Building: +class Building(Model): """ The Building class represents a building model. It is based on an EnergyPlus simulation file. @@ -124,19 +75,20 @@ class Building: volume: Calculates and returns the total volume of the building in cubic meters. add_system(system): Adds an HVAC system to the building's systems. del_system(system_name): Deletes an HVAC system from the building's systems by name. - simulate(parameter_dict, simulation_options): Simulates the building model with + simulate(property_dict, simulation_options): Simulates the building model with specified parameters and simulation options, returning the simulation results as a pandas DataFrame. """ - def __init__( - self, - idf_path, - ): + def __init__(self, idf_path): + super().__init__(is_dynamic=True) self.idf = IDF(str(idf_path)) self._idf_path = str(idf_path) self.systems = {category: [] for category in SystemCategories} + def get_property_values(self, property_list: list[str]) -> list[str | int | float]: + return self.get_param_init_value(property_list) + @staticmethod def set_idd(root_eplus): try: @@ -214,8 +166,18 @@ def get_param_init_value( split_key = full_key.split(".") if split_key[0] == ParamCategories.IDF.value: - value = energytool.base.idf_utils.getidfvalue(working_idf, full_key) - values.append(value) + if "*" in split_key: + is_single = False + + object_type = split_key[1] + field_name = split_key[-1] + + objs = working_idf.idfobjects[object_type.upper()] + for obj in objs: + values.append(getattr(obj, field_name)) + else: + value = energytool.base.idf_utils.getidfvalue(working_idf, full_key) + values.append(value) elif split_key[0] == ParamCategories.SYSTEM.value: if split_key[1].upper() in [sys.value for sys in SystemCategories]: @@ -236,15 +198,15 @@ def get_param_init_value( def simulate( self, - parameter_dict: dict[str, str | float | int] = None, - simulation_options: dict[str, str | float | int] = None, - idf_save_path: Path | None = None, - param_mapping: dict = None, + property_dict=None, + simulation_options=None, + idf_save_path=None, + **simulation_kwargs, ) -> pd.DataFrame: """ Simulate the building model with specified parameters and simulation options. - :param parameter_dict: A dictionary containing key-value pairs representing + :param property_dict: A dictionary containing key-value pairs representing parameters to be modified in the building model. These parameters can include changes to the IDF file, energytool HVAC system settings, or weather file. @@ -286,25 +248,34 @@ def simulate( 'STOP': '2023-01-31 23:59:59', 'TIMESTEP': 900 } - results = building.simulate(parameter_dict=parameter_changes, + results = building.simulate(property_dict=parameter_changes, simulation_options=simulation_options) """ + self.idf_save_path = idf_save_path + working_idf = deepcopy(self.idf) working_syst = deepcopy(self.systems) epw_path = None - if parameter_dict is None: - parameter_dict = {} - if param_mapping: - parameter_dict = expand_parameter_dict(parameter_dict, param_mapping) + if property_dict is None: + property_dict = {} - for key in parameter_dict: + for key in property_dict: split_key = key.split(".") # IDF modification if split_key[0] == ParamCategories.IDF.value: - json_functions.updateidf(working_idf, {key: parameter_dict[key]}) + if "*" in split_key: + object_type = split_key[1] + field_name = split_key[-1] + value = property_dict[key] + + objs = working_idf.idfobjects[object_type.upper()] + for obj in objs: + setattr(obj, field_name, value) + else: + json_functions.updateidf(working_idf, {key: property_dict[key]}) # In case it's a SYSTEM parameter, retrieve it in dict by category and name elif split_key[0] == ParamCategories.SYSTEM.value: @@ -317,11 +288,11 @@ def simulate( ) for syst in working_syst[sys_key]: if syst.name == split_key[2]: - setattr(syst, split_key[3], parameter_dict[key]) + setattr(syst, split_key[3], property_dict[key]) # Meteo file elif split_key[0] == ParamCategories.EPW_FILE.value: - epw_path = parameter_dict[key] + epw_path = property_dict[key] else: raise ValueError( f"{split_key[0]} was not recognize as a valid parameter category" @@ -333,11 +304,11 @@ def simulate( epw_path = simulation_options[SimuOpt.EPW_FILE.value] except KeyError: raise ValueError( - "'epw_path' not found in parameter_dict nor in simulation_options" + "'epw_path' not found in property_dict nor in simulation_options" ) elif SimuOpt.EPW_FILE.value in list(simulation_options.keys()): raise ValueError( - "'epw_path' have been used in both parameter_dict and " + "'epw_path' have been used in both property_dict and " "simulation_options" ) ref_year = None @@ -397,8 +368,8 @@ def simulate( ) # Save IDF file after pre-process - if idf_save_path: - self.save(idf_save_path) + if self.idf_save_path: + working_idf.save(idf_save_path) # POST-PROCESS return get_results( diff --git a/energytool/variant.py b/energytool/variant.py new file mode 100644 index 0000000..e94ff3f --- /dev/null +++ b/energytool/variant.py @@ -0,0 +1,143 @@ +import enum +import itertools +from collections.abc import Callable +from copy import deepcopy +from pathlib import Path +from typing import Any + +from multiprocessing import cpu_count + +from joblib import Parallel, delayed +from fastprogress.fastprogress import progress_bar + +from corrai.base.model import Model + + +class VariantKeys(enum.Enum): + MODIFIER = "MODIFIER" + ARGUMENTS = "ARGUMENTS" + DESCRIPTION = "DESCRIPTION" + + +def get_modifier_dict( + variant_dict: dict[str, dict[VariantKeys, Any]], add_existing: bool = False +): + """ + Generate a dictionary that maps modifier values (name) to associated variant names. + + This function takes a dictionary containing variant information and extracts + the MODIFIER values along with their corresponding variants, creating a new + dictionary where each modifier is associated with a list of variant names + that share that modifier. + + :param variant_dict: A dictionary containing variant information where keys are + variant names and values are dictionaries with keys from the + VariantKeys enum (e.g., MODIFIER, ARGUMENTS, DESCRIPTION). + :param add_existing: A boolean flag indicating whether to include existing + variant to each modifier. + If True, existing modifiers will be included; + if False, only non-existing modifiers will be considered. + Set to False by default. + :return: A dictionary that maps modifier values to lists of variant names. + """ + temp_dict = {} + + if add_existing: + temp_dict = { + variant_dict[var][VariantKeys.MODIFIER]: [ + f"EXISTING_{variant_dict[var][VariantKeys.MODIFIER]}" + ] + for var in variant_dict.keys() + } + for var in variant_dict.keys(): + temp_dict[variant_dict[var][VariantKeys.MODIFIER]].append(var) + else: + for var in variant_dict.keys(): + modifier = variant_dict[var][VariantKeys.MODIFIER] + if modifier not in temp_dict: + temp_dict[modifier] = [] + temp_dict[modifier].append(var) + + return temp_dict + + +def get_combined_variants( + variant_dict: dict[str, dict[VariantKeys, Any]], add_existing: bool = False +): + """ + Generate a list of combined variants based on the provided variant dictionary. + + This function takes a dictionary containing variant information and generates a list + of combined variants by taking the Cartesian product of the variant names. + The resulting list contains tuples, where each tuple represents a + combination of variant to create a unique combination. + + :param variant_dict: A dictionary containing variant information where keys are + variant names and values are dictionaries with keys from the + VariantKeys enum (e.g., MODIFIER, ARGUMENTS, DESCRIPTION). + :param add_existing: A boolean flag indicating whether to include existing + variant to each modifier. + If True, existing modifiers will be included; + if False, only non-existing modifiers will be considered. + Set to False by default. + :return: A list of tuples representing combined variants based on the provided + variant dictionary. + """ + modifier_dict = get_modifier_dict(variant_dict, add_existing) + return list(itertools.product(*list(modifier_dict.values()))) + + +def simulate_variants( + model: Model, + variant_dict: dict[str, dict[VariantKeys, Any]], + modifier_map: dict[str, Callable], + simulation_options: dict[str, Any], + n_cpu: int = -1, + add_existing: bool = False, + custom_combinations=None, + save_dir: Path = None, + file_extension: str = ".txt", + simulate_kwargs: dict = None, +): + simulate_kwargs = simulate_kwargs or {} + + if n_cpu <= 0: + n_cpu = max(1, cpu_count() + n_cpu) + + if custom_combinations is not None: + combined_variants = custom_combinations + else: + combined_variants = get_combined_variants(variant_dict, add_existing) + + models = [] + + for idx, simulation in enumerate(combined_variants, start=1): + working_model = deepcopy(model) + + for variant in simulation: + split_var = variant.split("_") + if (add_existing and split_var[0] != "EXISTING") or not add_existing: + modifier = modifier_map[variant_dict[variant][VariantKeys.MODIFIER]] + modifier( + model=working_model, + description=variant_dict[variant][VariantKeys.DESCRIPTION], + **variant_dict[variant][VariantKeys.ARGUMENTS], + ) + + if save_dir: + working_model.save((save_dir / f"Model_{idx}").with_suffix(file_extension)) + + models.append(working_model) + + bar = progress_bar(models) + + results = Parallel(n_jobs=n_cpu)( + delayed( + lambda m: m.simulate( + simulation_options=simulation_options, **simulate_kwargs + ) + )(m) + for m in bar + ) + + return results diff --git a/pyproject.toml b/pyproject.toml index e9bbe99..17792ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ + "corrai>=1.0.0", + "ipython >= 8.13.0", "numpy>=1.22.4, <2.0", "pandas>=2.0.0, <3.0", "eppy>=0.5.63", diff --git a/requirements.txt b/requirements.txt index d931b0b..57d89c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +corrai>=1.0.0 numpy~=1.22.3 pandas~=1.4.2 setuptools~=62.1.0 diff --git a/requirements/install-min.txt b/requirements/install-min.txt index 35a2588..85acb11 100644 --- a/requirements/install-min.txt +++ b/requirements/install-min.txt @@ -1,3 +1,4 @@ -numpy==1.22.4 -pandas==2.0.0 -eppy==0.5.63 \ No newline at end of file +eppy==0.5.63 +corrai>=1.0.0 +pandas>=2.0.0 +numpy>=1.22.4 \ No newline at end of file diff --git a/tests/test_building.py b/tests/test_building.py index 1632ee4..1f873b7 100644 --- a/tests/test_building.py +++ b/tests/test_building.py @@ -15,7 +15,7 @@ def test_building(self): test_build = Building(idf_path=RESOURCES_PATH / "test.idf") test_build.add_system(HeaterSimple(name="Heater", cop=0.1)) - param_dict = { + property_dict = { "idf.material.Urea Formaldehyde Foam_.1327.Conductivity": 0.05, "system.heating.Heater.cop": 0.5, "epw_file": (RESOURCES_PATH / "B4R_weather_Paris_2020.epw").as_posix(), @@ -25,7 +25,7 @@ def test_building(self): } res = test_build.simulate( - parameter_dict=param_dict, simulation_options=simulation_options + property_dict=property_dict, simulation_options=simulation_options ) assert test_build.zone_name_list == [ @@ -67,7 +67,11 @@ def test_building(self): rel=0.05, ) - param_dict = { + def test_boundaries(self): + test_build = Building(idf_path=RESOURCES_PATH / "test.idf") + test_build.add_system(HeaterSimple(name="Heater", cop=0.1)) + + property_dict = { "idf.material.Urea Formaldehyde Foam_.1327.Conductivity": 0.05, "system.heating.Heater.cop": 0.5, } @@ -83,7 +87,7 @@ def test_building(self): } res = test_build.simulate( - parameter_dict=param_dict, simulation_options=simulation_options + property_dict=property_dict, simulation_options=simulation_options ) assert res.sum().to_dict() == approx( @@ -118,40 +122,6 @@ def test_building(self): rel=0.05, ) - param_dict = { - "Conductivity": 0.10, - "system.heating.Heater.cop": 0.5, - } - - param_mapping = { - "Conductivity": [ - "idf.material.Urea Formaldehyde Foam_.1327.Conductivity", - "idf.material.MW Glass Wool (rolls)_.0095.Conductivity", - ] - } - - res = test_build.simulate( - parameter_dict=param_dict, - simulation_options=simulation_options, - param_mapping=param_mapping, - ) - - assert res.sum().to_dict() == approx( - { - "HEATING_Energy_[J]": 31800181020.40322, - "TOTAL_SYSTEM_Energy_[J]": 31800181020.40322, - "BLOCK1:APPTX1W:Zone Other Equipment Total Heating Energy [J](Hourly)": 1627596221.6448004, - "BLOCK1:APPTX1E:Zone Other Equipment Total Heating Energy [J](Hourly)": 1627596221.6448004, - "BLOCK2:APPTX2W:Zone Other Equipment Total Heating Energy [J](Hourly)": 1627596221.6448004, - "BLOCK2:APPTX2E:Zone Other Equipment Total Heating Energy [J](Hourly)": 1627596221.6448004, - "BLOCK1:APPTX1W IDEAL LOADS AIR:Zone Ideal Loads Supply Air Total Heating Energy [J](Hourly)": 4005689459.7009125, - "BLOCK1:APPTX1E IDEAL LOADS AIR:Zone Ideal Loads Supply Air Total Heating Energy [J](Hourly)": 4023417664.7472544, - "BLOCK2:APPTX2W IDEAL LOADS AIR:Zone Ideal Loads Supply Air Total Heating Energy [J](Hourly)": 3907308730.1056757, - "BLOCK2:APPTX2E IDEAL LOADS AIR:Zone Ideal Loads Supply Air Total Heating Energy [J](Hourly) ": 3963674655.647766, - }, - rel=0.05, - ) - def test_save(self): with TemporaryDirectory() as temp_dir: file_path = Path(temp_dir) @@ -166,16 +136,28 @@ def test_save(self): def test_get_initial_value(self): test_build = Building(idf_path=RESOURCES_PATH / "test.idf") - string_search = "idf.DesignSpecification:OutdoorAir.Block1:ApptX1E.Outdoor_Air_Flow_Air_Changes_per_Hour" + string_search = ( + "idf.DesignSpecification:OutdoorAir.Block1:ApptX1E." + "Outdoor_Air_Flow_Air_Changes_per_Hour" + ) init_value = test_build.get_param_init_value(string_search) assert init_value == 3 string_search_two_params = [ "idf.Sizing:Zone.Block1:ApptX1E.Zone_Heating_Sizing_Factor", - "idf.DesignSpecification:OutdoorAir.Block1:ApptX1E.Outdoor_Air_Flow_Air_Changes_per_Hour", + "idf.DesignSpecification:OutdoorAir.Block1:ApptX1E." + "Outdoor_Air_Flow_Air_Changes_per_Hour", ] init_values = test_build.get_param_init_value(string_search_two_params) assert init_values == [1.25, 3] + + string_search_start = ( + "idf.DesignSpecification:OutdoorAir.*." + "Outdoor_Air_Flow_Air_Changes_per_Hour" + ) + + init_values = test_build.get_param_init_value(string_search_start) + assert init_values == [3, 3, 3, 3] diff --git a/tests/test_variant.py b/tests/test_variant.py new file mode 100644 index 0000000..219d27f --- /dev/null +++ b/tests/test_variant.py @@ -0,0 +1,272 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from corrai.base.model import Model + +import pandas as pd + +from energytool.variant import ( + simulate_variants, + VariantKeys, + get_combined_variants, + get_modifier_dict, +) + + +class VariantModel(Model): + def __init__(self): + self.y1 = 1 + self.z1 = 2 + self.multiplier = 1 + + def simulate( + self, + property_dict: dict = None, + simulation_options: dict = None, + simulation_kwargs: dict = None, + ) -> pd.DataFrame: + if property_dict is None: + property_dict = {"x1": 1, "x2": 2} + + result = ( + self.y1 * property_dict["x1"] + self.z1 * property_dict["x2"] + ) * self.multiplier + + # Create a DataFrame with a single row + df = pd.DataFrame( + {"res": [result]}, + index=pd.date_range( + simulation_options["start"], + simulation_options["end"], + freq=simulation_options["timestep"], + ), + ) + + return df + + def save(self, file_path: Path): + """ + Save the current parameters of the model to a file. + + :param file_path: The file path where the parameters will be saved. + """ + with open(f"{file_path}", "w") as file: + file.write(f"y1={self.y1}\n") + file.write(f"z1={self.z1}\n") + file.write(f"multiplier={self.multiplier}\n") + + +def modifier_1(model, description, multiplier=None): + model.y1 = description["y1"] + if multiplier is not None: + model.multiplier = multiplier + + +def modifier_2(model, description): + model.z1 = description["z1"] + + +VARIANT_DICT_true = { + "Variant_1": { + VariantKeys.MODIFIER: "mod1", + VariantKeys.ARGUMENTS: {"multiplier": 2}, + VariantKeys.DESCRIPTION: {"y1": 20}, + }, + "Variant_2": { + VariantKeys.MODIFIER: "mod1_bis", + VariantKeys.ARGUMENTS: {}, + VariantKeys.DESCRIPTION: {"y1": 30}, + }, + "Variant_3": { + VariantKeys.MODIFIER: "mod2", + VariantKeys.ARGUMENTS: {}, + VariantKeys.DESCRIPTION: {"z1": 40}, + }, +} + +VARIANT_DICT_false = { + "EXISTING_mod1": { + VariantKeys.MODIFIER: "mod1", + VariantKeys.ARGUMENTS: {"multiplier": 1}, + VariantKeys.DESCRIPTION: {"y1": 1}, + }, + "Variant_1": { + VariantKeys.MODIFIER: "mod1", + VariantKeys.ARGUMENTS: {"multiplier": 2}, + VariantKeys.DESCRIPTION: {"y1": 20}, + }, + "EXISTING_mod1_bis": { + VariantKeys.MODIFIER: "mod1_bis", + VariantKeys.ARGUMENTS: {"multiplier": 1}, + VariantKeys.DESCRIPTION: {"y1": 1}, + }, + "Variant_2": { + VariantKeys.MODIFIER: "mod1_bis", + VariantKeys.ARGUMENTS: {}, + VariantKeys.DESCRIPTION: {"y1": 30}, + }, + "EXISTING_mod2": { + VariantKeys.MODIFIER: "mod2", + VariantKeys.ARGUMENTS: {}, + VariantKeys.DESCRIPTION: {"z1": 2}, + }, + "Variant_3": { + VariantKeys.MODIFIER: "mod2", + VariantKeys.ARGUMENTS: {}, + VariantKeys.DESCRIPTION: {"z1": 40}, + }, +} + +MODIFIER_MAP = {"mod1": modifier_1, "mod1_bis": modifier_1, "mod2": modifier_2} + +SIMULATION_OPTIONS = { + "start": "2009-01-01 00:00:00", + "end": "2009-01-01 00:00:00", + "timestep": "h", +} + + +class TestVariant: + def test_variant(self): + modifier_dict_true = get_modifier_dict(VARIANT_DICT_true, add_existing=True) + modifier_dict_false = get_modifier_dict(VARIANT_DICT_false, add_existing=False) + expected_dict_modifiers = { + "mod1": ["EXISTING_mod1", "Variant_1"], + "mod1_bis": ["EXISTING_mod1_bis", "Variant_2"], + "mod2": ["EXISTING_mod2", "Variant_3"], + } + assert modifier_dict_false == expected_dict_modifiers + assert modifier_dict_true == expected_dict_modifiers + + variant_list_false = get_combined_variants( + VARIANT_DICT_false, add_existing=False + ) + variant_list_true = get_combined_variants(VARIANT_DICT_true, add_existing=True) + + expected_variant_list = [ + ("EXISTING_mod1", "EXISTING_mod1_bis", "EXISTING_mod2"), # 5 + ("EXISTING_mod1", "EXISTING_mod1_bis", "Variant_3"), # 81 + ("EXISTING_mod1", "Variant_2", "EXISTING_mod2"), # 34 + ("EXISTING_mod1", "Variant_2", "Variant_3"), # 110 + ("Variant_1", "EXISTING_mod1_bis", "EXISTING_mod2"), # 5 # 48 + ("Variant_1", "EXISTING_mod1_bis", "Variant_3"), # 81 # 200 + ("Variant_1", "Variant_2", "EXISTING_mod2"), # 68 + ("Variant_1", "Variant_2", "Variant_3"), # 220 + ] + + assert variant_list_true == expected_variant_list + assert variant_list_false == expected_variant_list + + model = VariantModel() + + expected_list = [5, 81, 34, 110, 5, 81, 68, 220] + + # Sequential + res = simulate_variants( + model=model, + variant_dict=VARIANT_DICT_false, + modifier_map=MODIFIER_MAP, + simulation_options=SIMULATION_OPTIONS, + n_cpu=1, + add_existing=False, + ) + actual_results_f = [] + for df in res: + actual_results_f.extend(df["res"].tolist()) + assert actual_results_f == expected_list + + # Parallel + res = simulate_variants( + model=model, + variant_dict=VARIANT_DICT_false, + modifier_map=MODIFIER_MAP, + simulation_options=SIMULATION_OPTIONS, + n_cpu=-1, + ) + + actual_results = [] + for df in res: + actual_results.extend(df["res"].tolist()) + assert actual_results == expected_list + # for combinations with conflictual values (several y1), + # the last one erases the previous ones + + # Add existing True + res = simulate_variants( + model=model, + variant_dict=VARIANT_DICT_true, + modifier_map=MODIFIER_MAP, + simulation_options=SIMULATION_OPTIONS, + n_cpu=1, + add_existing=True, + ) + + # different from previous one as existing are not + # taken into account when applying modifiers + expected_list = [5, 81, 34, 110, 48, 200, 68, 220] + + actual_results_t = [] + for df in res: + actual_results_t.extend(df["res"].tolist()) + assert actual_results_t == expected_list + + def test_custom_combination(self): + model = VariantModel() + + custom_combination = [ + ("EXISTING_mod1", "Variant_2", "Variant_3"), # 110 + ("Variant_1", "EXISTING_mod1_bis", "EXISTING_mod2"), # 5 + ] + + expected_list = [110, 5] + + # Sequential + res = simulate_variants( + model=model, + variant_dict=VARIANT_DICT_false, + modifier_map=MODIFIER_MAP, + simulation_options=SIMULATION_OPTIONS, + n_cpu=1, + custom_combinations=custom_combination, + add_existing=False, + ) + + actual_results_t = [] + for df in res: + actual_results_t.extend(df["res"].tolist()) + + assert actual_results_t == expected_list + + def test_save_path(self): + model = VariantModel() + variant_dict = { + "Variant_1": { + VariantKeys.MODIFIER: "mod1", + VariantKeys.ARGUMENTS: {"multiplier": 2}, + VariantKeys.DESCRIPTION: {"y1": 20}, + } + } + + modifier_map = {"mod1": modifier_1} + simulation_options = { + "start": "2009-01-01 00:00:00", + "end": "2009-01-01 02:00:00", + "timestep": "h", + } + + with tempfile.TemporaryDirectory() as temp_dir: + save_path = Path(temp_dir) + + simulate_variants( + model=model, + variant_dict=variant_dict, + modifier_map=modifier_map, + simulation_options=simulation_options, + save_dir=save_path, + ) + + assert os.path.exists(save_path) + assert os.path.exists(save_path / "Model_1.txt") + shutil.rmtree(save_path) diff --git a/tox.ini b/tox.ini index e4ccd0b..49dfde5 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,6 @@ commands = pytest --cov=tide --cov-branch --cov-report=term-missing --cov-report=xml [testenv:min] -constrain_package_deps = True -use_frozen_constraints = True deps = -r requirements/tests.txt -r requirements/install-min.txt diff --git a/tutorials/CH1_Building.ipynb b/tutorials/CH1_Building.ipynb index 9dc44b2..2c79dd2 100644 --- a/tutorials/CH1_Building.ipynb +++ b/tutorials/CH1_Building.ipynb @@ -2,15 +2,15 @@ "cells": [ { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "import os\n", "from pathlib import Path\n", "\n", "TUTORIAL_DIR = Path(os.getcwd()).as_posix()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -81,24 +81,21 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], + "cell_type": "code", "source": [ "from energytool.building import Building\n", - "\n", "Building.set_idd(Path(r\"C:\\EnergyPlusV9-4-0\"))" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, + "cell_type": "code", + "source": "building = Building(idf_path=Path(TUTORIAL_DIR) / \"resources/tuto_building.idf\")", "outputs": [], - "source": [ - "building = Building(idf_path=Path(TUTORIAL_DIR) / \"resources/tuto_building.idf\")" - ] + "execution_count": null }, { "cell_type": "markdown", @@ -114,12 +111,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "building" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -185,9 +182,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "from energytool.system import (\n", " HeaterSimple,\n", @@ -197,13 +192,13 @@ " DHWIdealExternal,\n", " ArtificialLighting,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "# Simulate a boiler, multiplying the heat needs by a constant COP\n", "building.add_system(HeaterSimple(name=\"Gaz_boiler\", cop=0.89))\n", @@ -245,7 +240,9 @@ "# Estimate Lighting consumption using a constant power ratio.\n", "# Modify the existing energyplus object\n", "building.add_system(ArtificialLighting(name=\"Random_lights\", power_ratio=4))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -256,12 +253,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "building" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -289,13 +286,13 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "import datetime as dt\n", "from energytool.building import SimuOpt" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -313,21 +310,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "print([key.value for key in SimuOpt])" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], + "cell_type": "code", "source": [ "res = building.simulate(\n", - " parameter_dict=None,\n", + " property_dict=None,\n", " simulation_options={\n", " \"epw_file\": Path(TUTORIAL_DIR) / \"resources/FRA_Bordeaux.075100_IWEC.epw\",\n", " \"start\": \"2025-01-01\",\n", @@ -336,7 +331,9 @@ " \"outputs\": \"SYSTEM\", # See values in energytool.outputs.OutputCategories\n", " },\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -348,12 +345,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "res[[col for col in res if col != \"TOTAL_SYSTEM_Energy_[J]\"]].sum().plot(kind=\"pie\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -375,13 +372,11 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], + "cell_type": "code", "source": [ "res_2 = building.simulate(\n", - " parameter_dict={\n", + " property_dict={\n", " \"idf.material.Urea Formaldehyde Foam_.1327.Conductivity\": 0.05,\n", " \"system.heating.Gaz_boiler.cop\": 0.5,\n", " },\n", @@ -393,16 +388,23 @@ " \"outputs\": \"SYSTEM\", # See values in energytool.outputs.OutputCategories\n", " },\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { + "metadata": {}, "cell_type": "code", - "execution_count": null, + "source": "res_2[[col for col in res_2 if col != \"TOTAL_SYSTEM_Energy_[J]\"]].sum().plot(kind=\"pie\")", + "outputs": [], + "execution_count": null + }, + { "metadata": {}, + "cell_type": "code", + "source": "", "outputs": [], - "source": [ - "res_2[[col for col in res_2 if col != \"TOTAL_SYSTEM_Energy_[J]\"]].sum().plot(kind=\"pie\")" - ] + "execution_count": null } ], "metadata": { diff --git a/tutorials/CH2_Sensitivity_analysis.ipynb b/tutorials/CH2_Sensitivity_analysis.ipynb index d74e871..5ce9566 100644 --- a/tutorials/CH2_Sensitivity_analysis.ipynb +++ b/tutorials/CH2_Sensitivity_analysis.ipynb @@ -2,7 +2,11 @@ "cells": [ { "cell_type": "code", - "metadata": {}, + "metadata": { + "jupyter": { + "is_executing": true + } + }, "source": [ "import os\n", "from pathlib import Path\n", @@ -251,211 +255,131 @@ } }, "source": [ - "A list of dictionaries is used to define uncertain parameters in the model. \n", - "Each dictionary represents one uncertain parameter and follows the structure defined by the Parameter enum.\n", - "The example below defines the Solar Heat Gain Coefficient (SHGC) of the external glazing:\n", - "```\n", - "{\n", - "Parameter.NAME: \"idf.WindowMaterial:SimpleGlazingSystem.Simple DSF_ext_south_glazing - 1002.Solar_Heat_Gain_Coefficient\",\n", - "Parameter.INTERVAL: [0.3, 0.7],\n", - "Parameter.TYPE: \"Real\",\n", - "} \n", - "```\n", - "Parameter.NAME: Full path to the parameter in the IDF model, following the format:\n", - "\"idf...\".\n", - "Use \"*\" for name if the parameter applies to all objects of the given type.\n", - "\n", - "Parameter.INTERVAL: The lower and upper bounds of the uncertainty interval.\n", - "For discrete uncertainties, this should be a list of all possible values.\n", - "\n", - "Parameter.TYPE: Indicates the type of variable:\n", - "\"Real\", \"Integer\", \"Binary\", or \"Choice\".\n", + "Each decision variable of the optimization problem must be described using a `Parameter` object.\n", + "A parameter specifies:\n", + "* `name` — The variable name (string, must be unique within the problem).\n", + "* `ptype` — Variable type, one of:\n", + " * `\"Real\" `— Continuous real number\n", + " * `\"Integer\"` — Discrete integer\n", + " * `\"Binary\"` — Boolean, domain {False, True} (set automatically if no domain is given)\n", + " *` \"Choice\"` — Categorical variable with a fixed set of discrete options\n", + "* `Domain definition` — Choose exactly one of:\n", + " * ` interval=(lo, hi) `— Lower and upper bounds (for \"Real\" and \"Integer\", optional for \"Binary\" if you want (0,1))\n", + " * ` values=(v1, v2, …)` — Explicit list/tuple of allowed values (for \"Choice\", and optionally for \"Integer\", \"Real\", or \"Binary\")\n", + "*` Optional fields`:\n", + " * `init_value` — Initial value (or tuple/list of initial values for batch runs); must be within the defined domain\n", + " * `relabs` — `\"Absolute\"` or `\"Relative\"` (or a boolean flag, depending on usage in your model)\n", + " * `model_property` — String or tuple specifying the corresponding property in the simulation/model\n", + " * `min_max_interval` — Optional extra bounds for use in analysis/validation\n", "\n", - "- Use \"Real\" for continuous parameters like SHGC or U-Factor.\n", - "- Use \"Integer\" for only integer values between the lower and upper bounds\n", - "- Use \"Choice\" for categorical parameters (works in specific cases if more than two values are given. Not Morris)\n", - "- use \"Binary\" for 0 - 1 values\n", - "\n", - "You can define as many uncertain parameters as needed using this structure." + "A list of UncertainParameter is created with a relative uncertainty of +- 30% of the idf value:" ] }, { - "cell_type": "code", "metadata": {}, + "cell_type": "code", "source": [ "uncertain_param_list = [\n", - " {\n", - " Parameter.NAME: \"idf.WindowMaterial:SimpleGlazingSystem.Simple DSF_ext_south_glazing - 1002.Solar_Heat_Gain_Coefficient\",\n", - " Parameter.INTERVAL: [0.5, 0.8],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"idf.WindowMaterial:SimpleGlazingSystem.Simple DSF_ext_south_glazing - 1002.UFactor\",\n", - " Parameter.INTERVAL: [0.5, 0.8],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"idf.Material.Wall_insulation_.1.Thickness\",\n", - " # Parameter.INTERVAL: [0.1, 0.2, 0.4, 0.6],\n", - " Parameter.INTERVAL: [0.1, 0.6],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"idf.AirflowNetwork:MultiZone:Surface:Crack.*.Air_Mass_Flow_Coefficient_at_Reference_Conditions\",\n", - " Parameter.INTERVAL: [0.05, 0.5],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", + " Parameter(\n", + " name=\"SHGC_glazing\", ptype=\"Real\", interval=(0.7, 1.3), relabs = \"Relative\",\n", + " model_property= \"idf.WindowMaterial:SimpleGlazingSystem.Simple DSF_ext_south_glazing - 1002.Solar_Heat_Gain_Coefficient\"\n", + " ),\n", + " Parameter(\n", + " name=\"U_factor\", ptype=\"Real\", interval=(0.7, 1.3), relabs = \"Relative\",\n", + " model_property= \"idf.WindowMaterial:SimpleGlazingSystem.Simple DSF_ext_south_glazing - 1002.UFactor\"\n", + " ),\n", + " Parameter(\n", + " name=\"Wall_thickness\", ptype=\"Real\", interval=(0.7, 1.3), relabs = \"Relative\",\n", + " model_property= \"idf.Material.Wall_insulation_.1.Thickness\"\n", + " ),\n", + " Parameter(\n", + " name=\"AFN_Coefficient\", ptype=\"Real\", interval=(0.7, 1.3), relabs = \"Relative\",\n", + " model_property=\"idf.AirflowNetwork:MultiZone:Surface:Crack.*.Air_Mass_Flow_Coefficient_at_Reference_Conditions\"),\n", "]" ], "outputs": [], "execution_count": null }, { + "metadata": {}, "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, "source": [ - "Import the sensitivity analysis class SAnalysis\n", - "As a minimal configuration, it requires the Building instance, en sensitivity analysis method and the previously defined list of uncertain parameter." + "## Morris method\n", + "To screen out parameters or to have a first estimation of the uncertain parameters rank without running too many simulation, let's try the Morris method. Import the sensitivity analysis class MorrisSanalysis.\n", + "\n", + "\n", + "As a minimal configuration, it requires the Building instance, and the previously defined list of uncertain parameter. Let's also define simulation options." ] }, { - "cell_type": "code", "metadata": {}, + "cell_type": "code", "source": [ - "from corrai.sensitivity import SAnalysis, Method" + "SIM_OPTIONS = {\n", + " SimuOpt.EPW_FILE.value: Path(TUTORIAL_DIR) / r\"resources/FRA_Bordeaux.075100_IWEC.epw\",\n", + " SimuOpt.OUTPUTS.value: \"SYSTEM|SENSOR\",\n", + " SimuOpt.START.value: \"2025-01-01\",\n", + " SimuOpt.STOP.value: \"2025-01-19\",\n", + " SimuOpt.VERBOSE.value: \"v\",\n", + "}" ], "outputs": [], "execution_count": null }, { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "source": [ - "## Morris method\n", - "To screen out parameters or to have a first estimation of the uncertain parameters rank without running too many simulation, it is often a good idea to use the Morris method." - ] - }, - { - "cell_type": "code", "metadata": {}, - "source": [ - "sa_analysis = SAnalysis(\n", - " parameters_list=uncertain_param_list,\n", - " method=Method.MORRIS,\n", - ")" - ], + "cell_type": "code", + "source": "from corrai.sensitivity import MorrisSanalysis", "outputs": [], "execution_count": null }, { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "source": [ - "The draw_sample method of SAnalysis draws parameters values according to the parameters list. The sampling method depends on the sensitivity_method. For Morris a One At a Time method is used (OAT). See [SALib documentation](https://salib.readthedocs.io/en/latest/index.html) for more information\n", - "\n", - "The number of trajectories is set to 15." - ] - }, - { - "cell_type": "code", "metadata": {}, + "cell_type": "code", "source": [ - "sa_analysis.draw_sample(n=5)" + "sa_analysis = MorrisSanalysis(\n", + " parameters=uncertain_param_list,\n", + " model=building,\n", + " simulation_options=SIM_OPTIONS\n", + ")" ], "outputs": [], "execution_count": null }, { + "metadata": {}, "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, "source": [ - "Sampling results are stored in sample. Columns corresponds to parameters.\n", - "Index lines corresponds to a configuration (combination of parameters values)" + "The draw_sample method of SAnalysis draws parameters values according to the parameters list. The sampling method depends on the sensitivity_method. For Morris a One At a Time method is used (OAT). See [SALib documentation](https://salib.readthedocs.io/en/latest/index.html) for more information\n", + "\n", + "The number of trajectories is set to 10." ] }, { - "cell_type": "code", "metadata": {}, - "source": [ - "sa_analysis.sample.head()" - ], - "outputs": [], - "execution_count": null - }, - { "cell_type": "code", - "metadata": {}, - "source": [ - "len(sa_analysis.sample)" - ], + "source": "sa_analysis.add_sample(N=15, n_cpu=1)", "outputs": [], "execution_count": null }, { - "cell_type": "markdown", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, + "cell_type": "markdown", "source": [ - "run_simulations method runs the 105 simulations" + "Information on the performed analysis can be found with `sample`.\n", + "Results of simulation are stored in `values`.\n", + "For more information on the sensitivity analysis tools, please refer to Corrai documention at: https://github.com/BuildingEnergySimulationTools/corrai" ] }, { - "cell_type": "code", "metadata": {}, - "source": [ - "SIM_OPTIONS = {\n", - " SimuOpt.EPW_FILE.value: Path(TUTORIAL_DIR) / r\"resources/FRA_Bordeaux.075100_IWEC.epw\",\n", - " SimuOpt.OUTPUTS.value: \"SYSTEM|SENSOR\",\n", - " SimuOpt.START.value: \"2025-01-01\",\n", - " SimuOpt.STOP.value: \"2025-01-19\",\n", - " SimuOpt.VERBOSE.value: \"v\", \n", - "}" - ], - "outputs": [], - "execution_count": null - }, - { "cell_type": "code", - "metadata": {}, - "source": [ - "building" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "sa_analysis.evaluate(\n", - " model = building, \n", - " simulation_options=SIM_OPTIONS,\n", - ")" - ], + "source": "sa_analysis.sample", "outputs": [], "execution_count": null }, @@ -475,32 +399,8 @@ "cell_type": "code", "metadata": {}, "source": [ - "from corrai.sensitivity import plot_sample\n", - "\n", - "plot_sample(\n", - " sample_results=sa_analysis.sample_results,\n", + "sa_analysis.plot_scatter(\n", " indicator=\"RX1:ZONE1_Zone Mean Air Temperature\",\n", - " show_legends=True,\n", - " y_label=\"Temperature [°C]\",\n", - " x_label=\"Date\",\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "from corrai.sensitivity import plot_sample\n", - "\n", - "plot_sample(\n", - " sample_results=sa_analysis.sample_results,\n", - " # indicator=\"RX1:ZONE1_Zone Mean Air Temperature\",\n", - " indicator=\"HEATING_Energy_[J]\",\n", - " show_legends=False,\n", - " y_label=\"HEATING_Energy_[J]\",\n", - " x_label=\"Date\",\n", ")" ], "outputs": [], @@ -510,60 +410,13 @@ "cell_type": "code", "metadata": {}, "source": [ - "from corrai.metrics import cv_rmse, nmbe\n", - "import numpy as np\n", - "\n", - "sa_analysis.analyze(\n", - " # indicator=\"RX1:ZONE1_Zone Mean Air Temperature\",\n", - " indicator=\"HEATING_Energy_[J]\",\n", - " # agg_method=np.mean,\n", - " agg_method=np.sum,\n", + "sa_analysis.plot_bar(\n", + " indicator=\"RX1:ZONE1_Zone Mean Air Temperature\",\n", ")" ], "outputs": [], "execution_count": null }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "sa_analysis.sensitivity_results" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "sa_analysis.calculate_sensitivity_indicators()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "source": [ - "Sensitivity index results are stored in sensitivity_results.\n", - "Pre-formatted figure for Morris results is available using plot_morris_scatter" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "from corrai.sensitivity import plot_morris_scatter \n", - "plot_morris_scatter(salib_res=sa_analysis.sensitivity_results, title='Elementary effects', unit='J', autosize=True) " - ], - "outputs": [], - "execution_count": null - }, { "cell_type": "markdown", "metadata": { @@ -574,80 +427,25 @@ }, "source": [ "In the figure above:\n", - "- Circle size indicate the total effect of the parameter on the chosen indicator. The bigger, the more influential.\n", + "- Circle size indicates the total effect of the parameter on the chosen indicator. The bigger, the more influential.\n", "- The x axis is the mean elementary effect of the parameters. It represents \"linear\" effect of the parameter.\n", "- The y axis is the standard deviation. It represents interactions between parameters and non linearities.\n", - "- The 3 lines separates the figure in 4 regions. From the closer to the x axis : linear, monotonic, almost monotonic and non-linear and/or non-monotonic effects. See [publication](http://www.ibpsa.org/proceedings/BSO2016/p1101.pdf) for more details\n", - "- The segment represent the uncertainty on the sensitivity index calculation.\n", + "- The 3 lines separate the figure in 4 regions. From the closer to the x axis : linear, monotonic, almost monotonic and non-linear and/or non-monotonic effects. See [publication](http://www.ibpsa.org/proceedings/BSO2016/p1101.pdf) for more details\n", + "- The segment represents the uncertainty on the sensitivity index calculation.\n", "\n", "In this use case. Several conclusions can be drawn:\n", - "- 4 parameters have an influence on the chosen indicator. Two indicators can be neglected.\n", - "- The 4 main parameters have an almost linear influence on the indicator\n", - "- The confidence on the sensitivity index calculation is high" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "source": [ - "## Sobol method" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "source": [ - "Sobol index indicates the contribution of each uncertain parameters to the variance of the output indicator.\n", - "It is a more accurate method to quantify the effect of an uncertain parameter. The second order index also gives more information on the effect of parameters interactions.\n", - "... But it comes at a much higher computational cost.\n", - "In energytool, the index are computed using SALib. The method gives an estimation of the index value. It reduces the simulation sample size.\n", + "- 2 parameters have an influence on the chosen indicator while 2 can be neglected.\n", + "- The main parameters have an almost linear influence on the indicator\n", + "- The confidence on the sensitivity index calculation is pretty high.\n", "\n", - "Below is an example of a SAnalisys configuration to perform Sobol index calculation\n", - "It is very similar to Morris" + "For a more accurate method to quantify the effect of an uncertain parameter, use can also use a Sobol anlaysis.Sobol index indicates the contribution of each uncertain parameters to the variance of the output indicator. The second order index also gives more information on the effect of parameters interactions.\n", + "... But it comes at a much higher computational cost." ] }, { - "cell_type": "code", - "metadata": {}, - "source": [ - "sa_analysis_sob = SAnalysis(\n", - " parameters_list=uncertain_param_list,\n", - " method=Method.SOBOL,\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", "metadata": {}, - "source": [ - "# Salib command an n = 2^x. In this case x shall be >= 6\n", - "sa_analysis_sob.draw_sample(n=2**2)\n", - "len(sa_analysis_sob.sample)" - ], - "outputs": [], - "execution_count": null - }, - { "cell_type": "code", - "metadata": {}, - "source": [ - "sa_analysis_sob.evaluate(\n", - " model = building, \n", - " simulation_options=SIM_OPTIONS,\n", - ")" - ], + "source": "sa_analysis.analyze( indicator=\"RX1:ZONE1_Zone Mean Air Temperature\")[0]", "outputs": [], "execution_count": null }, @@ -660,36 +458,9 @@ } }, "source": [ - "Similarly to Morris, a function is designed to plot preformatted Sobol total index graph" + "## Sobol method" ] }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "from corrai.sensitivity import plot_sobol_st_bar" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "from corrai.metrics import cv_rmse, nmbe\n", - "import numpy as np\n", - "\n", - "sa_analysis_sob.analyze(\n", - " indicator=\"RX1:ZONE1_Zone Mean Air Temperature\",\n", - " agg_method=np.mean,\n", - ")\n", - "\n", - "sa_analysis_sob.calculate_sensitivity_indicators()\n", - "plot_sobol_st_bar(sa_analysis_sob.sensitivity_results)" - ], - "outputs": [], - "execution_count": null - }, { "cell_type": "markdown", "metadata": { @@ -699,29 +470,20 @@ } }, "source": [ - " In this use case, the Sobol method sorted the uncertain parameters in the same order as Morris.\n", - "The Sobol total index represent an uncertain parameter single effect plus the sum of all its interactions on the considered indicator.\n", - "The uncertainty bar shows the confidence interval of the index value.\n", + "Sobol index indicates the contribution of each uncertain parameters to the variance of the output indicator.\n", + "It is a more accurate method to quantify the effect of an uncertain parameter. The second order index also gives more information on the effect of parameters interactions.\n", + "... But it comes at a much higher computational cost.\n", "\n", - "The sum of all the index shall be equal to one.\n", - "Salib computes an estimation of this index. " + "Refer to the following tutorial for more info:\n", + "https://github.com/BuildingEnergySimulationTools/corrai/blob/main/tutorials/Sensitivity%20analysis_python%20model%20of%20an%20opaque%20wall.ipynb" ] }, { - "cell_type": "code", "metadata": {}, - "source": [ - "sa_analysis_sob.sensitivity_results[\"ST\"].sum()" - ], + "cell_type": "code", + "source": "", "outputs": [], "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The confidence intervals of the index overlap and the sum is much higher than 1. A bigger sample is necessary to draw conclusion, and should greatly improve the results." - ] } ], "metadata": { diff --git a/tutorials/CH3_Building_Modifier.ipynb b/tutorials/CH3_Building_Modifier.ipynb index 9d2b8ae..f4d3c5f 100644 --- a/tutorials/CH3_Building_Modifier.ipynb +++ b/tutorials/CH3_Building_Modifier.ipynb @@ -405,9 +405,7 @@ { "cell_type": "code", "metadata": {}, - "source": [ - "from corrai.variant import VariantKeys" - ], + "source": "from energytool.variant import VariantKeys", "outputs": [], "execution_count": null }, @@ -549,7 +547,7 @@ "cell_type": "code", "metadata": {}, "source": [ - "from corrai.variant import (\n", + "from energytool.variant import (\n", " VariantKeys,\n", " simulate_variants,\n", " get_combined_variants,\n", @@ -735,6 +733,13 @@ ], "outputs": [], "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null } ], "metadata": { diff --git a/tutorials/CH4_Calibration.ipynb b/tutorials/CH4_Calibration.ipynb index f85e2a9..fb0ce3d 100644 --- a/tutorials/CH4_Calibration.ipynb +++ b/tutorials/CH4_Calibration.ipynb @@ -2,21 +2,22 @@ "cells": [ { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "import pandas as pd\n", "from pathlib import Path\n", + "import numpy as np\n", "import plotly.graph_objs as go\n", "import os\n", "\n", "TUTORIAL_DIR = Path(os.getcwd()).as_posix()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -83,26 +84,24 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "boundaries = pd.read_csv(\n", " Path(TUTORIAL_DIR) / \"resources/mean_temp_heating.csv\",\n", " index_col=0,\n", " parse_dates=True,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "heating_columns = [\n", " \"2D_Heating_power_[Wh]\",\n", @@ -120,7 +119,9 @@ "# Calculate hourly heating consumption in kWh\n", "hourly_cons_measure_kWh = boundaries[heating_columns].sum(axis=1) / 1000\n", "hourly_cons_measure_kWh.name = \"Total_heating_energy_[kWh]\"" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -131,24 +132,24 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "from energytool.building import Building, SimuOpt\n", "from energytool.system import HeaterSimple, AirHandlingUnit, Sensor, ZoneThermostat\n", "\n", "Building.set_idd(Path(r\"C:\\EnergyPlusV9-4-0\"))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "building = Building(idf_path=Path(TUTORIAL_DIR) / \"resources/model_v0_store_op.idf\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -159,22 +160,20 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "thermal_zones = [\"R4:4G\", \"R4:4D\", \"R2:2D\", \"R7:7D\"]" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "building.add_system(HeaterSimple(name=\"gaz_boiler\", zones=thermal_zones, cop=1))\n", "\n", @@ -200,7 +199,9 @@ " name=\"meteo\", variables=\"Site Outdoor Air Drybulb Temperature\", key_values=\"*\"\n", " )\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -231,9 +232,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "sim_opt = {\n", " SimuOpt.EPW_FILE.value: Path(TUTORIAL_DIR)\n", @@ -243,26 +242,26 @@ " SimuOpt.STOP.value: \"2022-06-30 23:00\",\n", " SimuOpt.VERBOSE.value: \"v\",\n", "}" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "init_sim = building.simulate(parameter_dict=None, simulation_options=sim_opt)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "sim_heating_kWh = (init_sim[\"HEATING_Energy_[J]\"] / 3.6e6).loc[\"2021-10\":\"2022-04\"]\n", "\n", @@ -281,17 +280,17 @@ "fig.update_layout(dict(title=\"Heating Energy [kWh]\", yaxis_title=\"Energy [kWh]\"))\n", "\n", "fig.show()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "to_plot = pd.concat(\n", " [boundaries[\"T_4D\"], init_sim[\"R4:4D_Zone Operative Temperature\"]], axis=1\n", @@ -306,27 +305,27 @@ ")\n", "\n", "fig.show()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "from sklearn.metrics import r2_score\n", - "from corrai.metrics import cv_rmse, nmbe" - ] + "from corrai.base.metrics import cv_rmse, nmbe" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "measured_heating_kWh = hourly_cons_measure_kWh.loc[\"2021-10\":\"2022-04\"]\n", "\n", @@ -345,13 +344,13 @@ "nmbe_monthly = nmbe(sim_monthly, measured_monthly)\n", "cv_rmse_monthly = cv_rmse(sim_monthly, measured_monthly)\n", "r2_monthly = r2_score(sim_monthly, measured_monthly)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "print(\"Hourly comparison:\")\n", "print(f\" NMBE: {nmbe_hourly / 100:.2%}\")\n", @@ -362,7 +361,9 @@ "print(f\" NMBE: {nmbe_monthly / 100:.2%}\")\n", "print(f\" CV(RMSE): {cv_rmse_monthly / 100:.2%}\")\n", "print(f\" R²: {r2_monthly:.3f}\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -408,9 +409,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "building.add_system(\n", " ZoneThermostat(\n", @@ -451,51 +450,52 @@ " overwrite_heating_availability=True,\n", " )\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "sim_opt = {\n", " SimuOpt.EPW_FILE.value: Path(TUTORIAL_DIR)\n", " / r\"resources/pessac_2021_07_2022_06.epw\",\n", " SimuOpt.OUTPUTS.value: \"SYSTEM|SENSOR\",\n", + " SimuOpt.TIMESTEP: \"3600\",\n", " SimuOpt.START.value: \"2021-07-01 00:00\",\n", " SimuOpt.STOP.value: \"2022-06-30 23:00\",\n", " SimuOpt.VERBOSE.value: \"v\",\n", "}" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "adjusted_sim = building.simulate(parameter_dict=None, simulation_options=sim_opt)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "sim_heating_kWh = (adjusted_sim[\"HEATING_Energy_[J]\"] / 3.6e6).loc[\"2021-10\":\"2022-04\"]" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "to_plot = pd.concat(\n", " [daily_cons_measure_kWh, sim_heating_kWh.resample(\"1D\").sum()], axis=1\n", @@ -508,17 +508,17 @@ "fig.update_layout(dict(title=\"Heating Energy [kWh]\", yaxis_title=\"Energy [kWh]\"))\n", "\n", "fig.show()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "to_plot = pd.concat(\n", " [boundaries[\"T_4D\"], adjusted_sim[\"R4:4D_Zone Operative Temperature\"]], axis=1\n", @@ -533,13 +533,13 @@ ")\n", "\n", "fig.show()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "measured_heating_kWh = hourly_cons_measure_kWh.loc[\"2021-10\":\"2022-04\"]\n", "\n", @@ -558,13 +558,13 @@ "nmbe_monthly = nmbe(sim_monthly, measured_monthly)\n", "cv_rmse_monthly = cv_rmse(sim_monthly, measured_monthly)\n", "r2_monthly = r2_score(sim_monthly, measured_monthly)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "print(\"Hourly comparison:\")\n", "print(f\" NMBE: {nmbe_hourly / 100:.2%}\")\n", @@ -575,7 +575,9 @@ "print(f\" NMBE: {nmbe_monthly / 100:.2%}\")\n", "print(f\" CV(RMSE): {cv_rmse_monthly / 100:.2%}\")\n", "print(f\" R²: {r2_monthly:.3f}\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -631,69 +633,20 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, - "outputs": [], "source": [ "from corrai.base.parameter import Parameter" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + ], "outputs": [], - "source": [ - "uncertain_param_List = [\n", - " {\n", - " Parameter.NAME: \"UFactor\",\n", - " Parameter.INTERVAL: [2.5, 5],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"SHGC\",\n", - " Parameter.INTERVAL: [0.2, 0.65],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"ACH\",\n", - " Parameter.INTERVAL: [0.3, 1.5],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"idf.Material.Concrete Block (Heavyweight)_.2.Specific_Heat\",\n", - " Parameter.INTERVAL: [100, 840 + 1.5 * 840],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"idf.Material.Rock wool - at 10C degrees_.16.Conductivity\",\n", - " Parameter.INTERVAL: [0.028, 0.05],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - " {\n", - " Parameter.NAME: \"Infilt_appart\",\n", - " Parameter.INTERVAL: [0.004721 - 0.3 * 0.004721, 0.004721 + 0.3 * 0.004721],\n", - " Parameter.TYPE: \"Real\",\n", - " },\n", - "]" - ] + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], - "source": [ - "uncertain_param_List" - ] - }, - { "cell_type": "markdown", - "metadata": {}, "source": [ "In energy modeling, some parameters in the simulation affect **multiple objects or fields** simultaneously.\n", "\n", @@ -701,125 +654,57 @@ "- A single parameter like `\"Insulation_Therm_Conductivity\"` might need to update the **thermal conductivity** of several different insulation materials in the IDF file.\n", "- A user-defined parameter like `\"Infilt_appart\"` could refer to **air infiltration rates** in multiple apartment zones.\n", "\n", - "To handle this, we use a flexible system called `param_mappings` which **maps a user-defined parameter name** (used in sensitivity analysis or optimization) to one or more actual simulation inputs (IDF paths, system variables, etc.).\n", - "\n", - "It allows us to:\n", - "\n", - "- Abstract complex model modifications into a **single high-level parameter**\n", - "- Apply the same value to **multiple targets** in the model\n", - "- Handle **categorical parameters** that translate into multiple changes (e.g., control strategies or weather files)" + "To handle this, define in model_property all paths included in the parameter change:" ] }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ - "param_mappings = {\n", - " \"Infilt_appart\": [\n", - " \"idf.ZoneInfiltration:DesignFlowRate.R4:4G Infiltration.Design_Flow_Rate\",\n", - " \"idf.ZoneInfiltration:DesignFlowRate.R4:4D Infiltration.Design_Flow_Rate\",\n", - " \"idf.ZoneInfiltration:DesignFlowRate.R2:2D Infiltration.Design_Flow_Rate\",\n", - " \"idf.ZoneInfiltration:DesignFlowRate.R7:7D Infiltration.Design_Flow_Rate\",\n", - " ],\n", - " \"UFactor\": [\n", + "uncertain_param_list = [\n", + " Parameter(name=\"UFactor\", ptype=\"Real\", interval=(2.5, 5), model_property=(\n", " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_loggia - 1001.UFactor\",\n", " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_ext - 1002.UFactor\",\n", - " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_ext - 1003.UFactor\",\n", - " ],\n", - " \"SHGC\": [\n", + " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_ext - 1003.UFactor\"\n", + " )),\n", + " Parameter(name=\"SHGC\", ptype=\"Real\", interval=(0.5, 0.65), model_property=(\n", " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_loggia - 1001.Solar_Heat_Gain_Coefficient\",\n", " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_ext - 1002.Solar_Heat_Gain_Coefficient\",\n", " \"idf.WindowMaterial:SimpleGlazingSystem.Simple Vitrage_ext - 1003.Solar_Heat_Gain_Coefficient\",\n", - " ],\n", - " \"ACH\": [\n", + " )),\n", + " Parameter(name=\"ACH\", ptype=\"Real\", interval=(0.3, 1.5), model_property=(\n", " \"idf.DesignSpecification:OutdoorAir.R4:4G.Outdoor_Air_Flow_Air_Changes_per_Hour\",\n", " \"idf.DesignSpecification:OutdoorAir.R4:4D.Outdoor_Air_Flow_Air_Changes_per_Hour\",\n", " \"idf.DesignSpecification:OutdoorAir.R2:2D.Outdoor_Air_Flow_Air_Changes_per_Hour\",\n", " \"idf.DesignSpecification:OutdoorAir.R7:7D.Outdoor_Air_Flow_Air_Changes_per_Hour\",\n", - " ],\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The following cell show an example of a resulting dictionnary of parameters values using this parameter mapping: " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from corrai.learning.sampling import expand_parameter_dict\n", - "\n", - "param_init_values = {\n", - " param[Parameter.NAME]: param[Parameter.INTERVAL][0]\n", - " for param in uncertain_param_List\n", - "}\n", - "expand_parameter_dict(param_init_values, param_mappings)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ - "from corrai.sensitivity import SAnalysis, Method" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, + " )),\n", + " Parameter(name=\"Specific_Heat\", ptype=\"Real\", interval=(100, 2000),\n", + " model_property=\"idf.Material.Concrete Block (Heavyweight)_.2.Specific_Heat\", ),\n", + " Parameter(name=\"Infilt_appart\", ptype=\"Real\", interval=(0.0033, 0.006), model_property=(\n", + " \"idf.ZoneInfiltration:DesignFlowRate.R4:4G Infiltration.Design_Flow_Rate\",\n", + " \"idf.ZoneInfiltration:DesignFlowRate.R4:4D Infiltration.Design_Flow_Rate\",\n", + " \"idf.ZoneInfiltration:DesignFlowRate.R2:2D Infiltration.Design_Flow_Rate\",\n", + " \"idf.ZoneInfiltration:DesignFlowRate.R7:7D Infiltration.Design_Flow_Rate\",\n", + " )),\n", + "]" + ], "outputs": [], - "source": [ - "sa_analysis = SAnalysis(\n", - " parameters_list=uncertain_param_List,\n", - " method=Method.MORRIS,\n", - ")" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, + "source": "from corrai.sensitivity import MorrisSanalysis", "outputs": [], - "source": [ - "sa_analysis.draw_sample(n=5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "len(sa_analysis.sample)" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "sim_opt_SA = {\n", " SimuOpt.EPW_FILE.value: Path(TUTORIAL_DIR)\n", @@ -829,103 +714,65 @@ " SimuOpt.STOP.value: \"2022-02-05 23:00\",\n", " SimuOpt.VERBOSE.value: \"v\",\n", "}" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + ], "outputs": [], - "source": [ - "sa_analysis.evaluate(\n", - " model=building,\n", - " simulation_options=sim_opt_SA,\n", - " simulate_kwargs={\"param_mapping\": param_mappings},\n", - ")" - ] + "execution_count": null }, { + "metadata": { + "jupyter": { + "outputs_hidden": false + } + }, "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], "source": [ - "from corrai.metrics import cv_rmse, nmbe\n", - "import numpy as np\n", - "\n", - "sa_analysis.analyze(\n", - " indicator=\"HEATING_Energy_[J]\",\n", - " agg_method=np.sum,\n", + "sa_analysis = MorrisSanalysis(\n", + " parameters=uncertain_param_list,\n", + " model=building,\n", + " simulation_options=sim_opt_SA\n", ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + ], "outputs": [], - "source": [ - "from corrai.sensitivity import plot_morris_scatter\n", - "\n", - "sa_analysis.calculate_sensitivity_indicators()\n", - "plot_morris_scatter(\n", - " salib_res=sa_analysis.sensitivity_results,\n", - " title=\"Elementary effects\",\n", - " unit=\"J\",\n", - " autosize=True,\n", - ")" - ] + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from sklearn.metrics import mean_squared_error" - ] - }, - { - "cell_type": "code", - "execution_count": null, "metadata": { "jupyter": { "outputs_hidden": false } }, + "cell_type": "code", + "source": "sa_analysis.add_sample(N=5, n_cpu=1)", "outputs": [], - "source": [ - "for sim in sa_analysis.sample_results:\n", - " sim[2][\"HEATING_Energy_kWh\"] = sim[2][\"HEATING_Energy_[J]\"] / 3600 / 1000" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ - "sa_analysis.analyze(\n", - " indicator=\"HEATING_Energy_kWh\",\n", - " reference_df=hourly_cons_measure_kWh.loc[\"2022-01-01 00:00\":\"2022-02-05 23:00\"],\n", - " agg_method=mean_squared_error,\n", + "import numpy as np\n", + "\n", + "sa_analysis.plot_bar(\n", + " indicator=\"HEATING_Energy_[J]\",\n", + " method=np.sum,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], + "metadata": {}, "source": [ - "plot_morris_scatter(\n", - " sa_analysis.sensitivity_results, title=\"Elementary effects\", unit=\"J\"\n", - ")" - ] + "from corrai.sensitivity import plot_morris_scatter\n", + "\n", + "sa_analysis.plot_scatter(\n", + " indicator=\"HEATING_Energy_[J]\",\n", + " method=np.sum,\n", + ")\n" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -982,13 +829,8 @@ "\n", "We therefore use a **genetic algorithm** — a population-based, stochastic optimization method known for its robustness in complex search spaces.\n", "\n", - "However, running thousands of EnergyPlus simulations can be computationally expensive and time-consuming. To address this, we employ a **surrogate model** (also known as a **metamodel**) that approximates the relationship between input parameters and the RMSE error.\n", - "\n", - "The surrogate model is trained on a sample of EnergyPlus simulations (e.g., from the Morris experiment), and then used to:\n", "\n", - "- Quickly estimate the error for new parameter combinations\n", - "- Guide the optimization algorithm toward promising regions of the parameter space\n", - "- Reduce the number of expensive full simulations required\n", + "Note that running hundreds or thousands of EnergyPlus simulations can be computationally expensive and time-consuming. To address this, it is also possible to use a **surrogate model** (also known as a **metamodel**) that approximates the relationship between input parameters and the RMSE error. This will be included in future comits of energytool.\n", "\n", "---" ] @@ -996,304 +838,91 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "`ModelSampler` from corrai.learning can be used first, to: \n", - "\n", - "- Generate parameter samples using a **space-filling design** (e.g., Latin Hypercube)\n", - "- Interface with the simulation model to **run the simulations automatically**\n", - "- Store both the **sampled parameter sets** and the corresponding **simulation outputs**\n", - "\n", - "This class acts as the bridge between your model and the uncertainty exploration needed for sensitivity analysis or surrogate modeling.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from corrai.learning.sampling import ModelSampler" - ] + "source": "For more examples on calibration, refer to the Corrai tutorial : https://github.com/BuildingEnergySimulationTools/corrai/blob/main/tutorials/Identification_python%20model%20of%20an%20opaque%20wall.ipynb" }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ - "sampler = ModelSampler(\n", - " parameters=uncertain_param_List,\n", + "from corrai.base.metrics import cv_rmse\n", + "from corrai.optimize import PymooModelEvaluator\n", + "\n", + "def agg_func(y_pred, y_true): #conversion to kWh of prediction\n", + " cv_rmse(y_pred/3.6e6, y_true)\n", + "\n", + "pymoo_ev = PymooModelEvaluator(\n", + " parameters=uncertain_param_list,\n", " model=building,\n", - " sampling_method=\"LatinHypercube\",\n", " simulation_options=sim_opt_SA,\n", - " param_mappings=param_mappings,\n", + " indicators_configs=[\n", + " (\"HEATING_Energy_[J]\",agg_func, hourly_cons_measure_kWh.loc[\"2022-01-01 00:00\":\"2022-02-05 23:00\"]),\n", + " ],\n", ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `ModelSampler` class provides two methods for sampling variants: `add_sample` and `draw_sample`. \n", - "\n", - "- `add_sample`: This method adds a sample of of size n. This method is useful when you want to build a specific sample incrementally, running systematically simulation for each sampel, progressively adding variants until the desired sample size is reached.\n", - "\n", - "- `draw_sample`: This method draws a sample of parameters.It will only select combinations without simulating them. They can be simulated later on, using method `simulate_combinations`.\n", - "\n", - "For instance, we could generate 150 simulations using: \n", - " sampler.add_sample(sample_size=150, seed=42) . \n", - "\n", - "Since we simulated 70 combinations for the sensitivity analysis, let's use them instead in this tutorial. Note that 70 simulations for several parameters is not much at all." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + ], "outputs": [], - "source": [ - "sampler.sample = sa_analysis.sample\n", - "sampler.sample_results = [sim[2] for sim in sa_analysis.sample_results]" - ] + "execution_count": null }, { - "cell_type": "markdown", "metadata": {}, - "source": [ - "## Surrogate Modeling with `ModelTrainer` and `MultiModelSO`\n", - "\n", - "To reduce computational cost during calibration, we use a **surrogate model** (or **metamodel**) — a fast approximation of the simulation model that maps parameter inputs to a specific output (e.g., RMSE).\n", - "\n", - "Two tools are available for building and selecting surrogate models:\n", - "\n", - "---\n", - "\n", - "### `ModelTrainer` — Train a Single Surrogate\n", - "\n", - "The `ModelTrainer` wraps any scikit-learn-compatible regression pipeline and handles:\n", - "\n", - "- Splitting training and testing data\n", - "- Fitting the model to simulation results\n", - "- Evaluating the prediction error using standard metrics:\n", - " - `test_nmbe_score`\n", - " - `test_cvrmse_score`\n", - "\n", - "### `MultiModelSO` — Comparing and selecting the best surrogate\n", - "\n", - "The `MultiModelSO` class allows you to easily compare several surrogate models and automatically select the best-performing one.\n", - "\n", - "It provides:\n", - "\n", - "- Training of multiple regression models at once:\n", - "- Linear, polynomial, support vector machines, random forests, neural networks, etc.\n", - "- Cross-validation for fair model comparison\n", - "- Automatic selection of the best model based on a scoring metric (e.g., RMSE)\n", - "- Optional hyperparameter tuning using grid search\n" - ] - }, - { "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ - "from corrai.learning.model_selection import ModelTrainer, MultiModelSO" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Our target **y** (indicator) here is the difference between measured and simulated heating energy comsumption, and our training data set is the simulated sample:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + "source": "res_test = building.simulate(simulation_options=sim_opt_SA)", "outputs": [], - "source": [ - "reference_sim = building.simulate(parameter_dict=None, simulation_options=sim_opt_SA)" - ] + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], - "source": [ - "from sklearn.metrics import mean_squared_error" - ] - }, - { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], "source": [ - "n = len(sampler.sample_results)\n", + "from corrai.optimize import RealContinuousProblem\n", "\n", - "y_preds = [r[\"HEATING_Energy_kWh\"].sum() for r in sampler.sample_results]\n", - "y_true = np.full(n, hourly_cons_measure_kWh.loc[\"2022-01-01\":\"2022-02-05\"].sum())\n", - "\n", - "mse_list = [mean_squared_error([true], [pred]) for true, pred in zip(y_true, y_preds)]\n", - "y = pd.Series(mse_list)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "x_train_df = pd.DataFrame(sampler.sample)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "modelSO = MultiModelSO(fine_tuning=False)\n", - "trainer = ModelTrainer(modelSO)\n", - "trainer.train(X=x_train_df, y=y)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's check the r-score of the best model on prediciton data:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, + "problem = RealContinuousProblem(\n", + " parameters=uncertain_param_list,\n", + " evaluators=[pymoo_ev],\n", + " objective_ids=[\"HEATING_Energy_[J]\"],\n", + ")" + ], "outputs": [], - "source": [ - "trainer.model_pipe.score(trainer.x_test, trainer.y_test)" - ] + "execution_count": null }, { - "cell_type": "markdown", "metadata": {}, - "source": [ - "We can now predict the heating energy on new samples :" - ] - }, - { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sampler.draw_sample(sample_size=1000, seed=42)\n", - "x_pred_df = pd.DataFrame(sampler.not_simulated_samples)\n", - "modelSO.predict(x_pred_df)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, "source": [ - "## Optimization Using the Surrogate Model\n", - "\n", - "After training and evaluating several surrogate models, we now select the **best-performing one** to use as a fast approximation of the simulation model.\n", - "\n", - "This surrogate will be used to **find the combination of parameters** that minimizes the error between the model and the measurements.\n", - "\n", - "We define an **objective function** that takes a vector of input parameters and returns the **predicted RMSE** from the surrogate model.\n", + "from pymoo.algorithms.soo.nonconvex.de import DE\n", + "from pymoo.problems import get_problem\n", + "from pymoo.operators.sampling.lhs import LHS\n", + "from pymoo.optimize import minimize\n", + "from pymoo.termination import get_termination\n", "\n", - "This function is passed to `scipy.optimize.minimize`, which performs a local search for the minimum error.\n", + "algorithm = DE(\n", + " pop_size=15,\n", + " sampling=LHS(),\n", + " CR=0.7,\n", + ")\n", "\n", - "- The optimization is performed in the **parameter space** defined during sampling.\n", - "- The surrogate is used instead of the full simulation to keep the process fast.\n", - "- The result is a calibrated set of parameters that minimize the model-measurement discrepancy, according to the surrogate." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", + "termination = get_termination(\"n_gen\", 5)\n", "\n", + "res = minimize(\n", + " problem,\n", + " algorithm,\n", + " termination,\n", + " seed=1,\n", + " verbose=True)\n", "\n", - "def _objective_function(x):\n", - " x_df = pd.DataFrame([x], columns=[p[Parameter.NAME] for p in uncertain_param_List])\n", - " y_pred = trainer.model_pipe.predict(x_df)\n", - " return float(np.asarray(y_pred).flatten()[0])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, + "print(\"Best solution found: \\nX = %s\\nF = %s\" % (res.X, res.F))" + ], "outputs": [], - "source": [ - "from scipy.optimize import differential_evolution" - ] + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], - "source": [ - "res = differential_evolution(\n", - " _objective_function,\n", - " bounds=[tuple(param[Parameter.INTERVAL]) for param in uncertain_param_List],\n", - ")\n", - "res" - ] - }, - { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], "source": [ - "parameter_names = [param[Parameter.NAME] for param in uncertain_param_List]\n", - "parameter_dict = {param_name: res.x[i] for i, param_name in enumerate(parameter_names)}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's simulate with this set of values for our parameters" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sim_calibrated = building.simulate(\n", - " parameter_dict=parameter_dict,\n", + "sim_calibrated = building.simulate_parameter(\n", + " parameter_value_pairs=[(param, value) for param, value in zip(uncertain_param_list, res.X[i])],\n", " simulation_options=sim_opt,\n", - " param_mapping=param_mappings,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + ")\n", + "\n", "import plotly.graph_objects as go\n", "\n", "sim_daily = sim_calibrated[\"HEATING_Energy_[J]\"].resample(\"D\").sum() / 3600 / 1000\n", @@ -1326,17 +955,13 @@ " yaxis_title=\"Heating [kWh/day]\",\n", " template=\"simple_white\",\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { + "metadata": {}, "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], "source": [ "sim_energy_kWh = sim_calibrated[\"HEATING_Energy_[J]\"] / 3600 / 1000\n", "measured_energy_kWh = hourly_cons_measure_kWh\n", @@ -1362,19 +987,7 @@ "\n", "nmbe_monthly = nmbe(sim_monthly, measured_monthly)\n", "cvrmse_monthly = cv_rmse(sim_monthly, measured_monthly)\n", - "r2_monthly = r2_score(sim_monthly, measured_monthly)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ + "r2_monthly = r2_score(sim_monthly, measured_monthly)\n", "print(\"Hourly comparison:\")\n", "print(f\" NMBE: {nmbe_hourly / 100:.2%}\")\n", "print(f\" CV(RMSE): {cvrmse_hourly / 100:.2%}\")\n", @@ -1389,7 +1002,9 @@ "print(f\" NMBE: {nmbe_monthly / 100:.2%}\")\n", "print(f\" CV(RMSE): {cvrmse_monthly / 100:.2%}\")\n", "print(f\" R²: {r2_monthly:.3f}\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -1402,16 +1017,7 @@ "source": [ "## Conclusion\n", "\n", - "The calibration process has shown some promising signs, but also highlighted clear limitations of the current model.\n", - "\n", - "- The calibrated results are better centered, and on average the model **underestimates the heating demand**.\n", - "- The reduction in CV(RMSE) at the monthly scale suggests that the **overall seasonal dynamics are reasonably captured**.\n", - "- However, at the **hourly level**, the error remains significant — which undermines the usefulness of the model for detailed thermal simulation.\n", - "\n", - "All optimized parameter values ended up at or near the **limits of their acceptable ranges**. This is not a good sign:\n", - "> It likely indicates that the optimization algorithm attempted to escape the defined bounds to further reduce the error — implying that the model is even more inaccurate than initially expected.\n", - "\n", - "Additionally, our surrogate models were trained on a relatively **small dataset** (a few dozen simulations), which is likely insufficient given the **high dimensionality** of the parameter space.\n", + "Calibration is performed on a relatively **small dataset** (a few dozen simulations), which is likely insufficient given the **high dimensionality** of the parameter space.\n", "\n", "**Next steps**\n", "\n", @@ -1421,6 +1027,13 @@ "\n", "In its current state, the model should **not** be used to explain measured data from 2021–2022 or to draw strong conclusions. Further refinement and validation are needed before it becomes a reliable predictive tool." ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null } ], "metadata": {