diff --git a/docs/vdi4655.rst b/docs/vdi4655.rst index a871457..2168088 100644 --- a/docs/vdi4655.rst +++ b/docs/vdi4655.rst @@ -39,10 +39,13 @@ Here's a basic example of how to use the VDI 4655 module:: } ] + # Create climate object with weather data + climate = vdi.Climate().from_try_data(try_region=4) + # Create region region = vdi.Region( 2017, - try_region=4, + climate=climate, houses=houses, resample_rule="1h" ) @@ -53,41 +56,70 @@ Here's a basic example of how to use the VDI 4655 module:: House Parameters ---------------- +The houses need to be defined as a list of dictionaries. Required parameters for each house: * ``name``: Unique identifier for the house * ``house_type``: Either "EFH" (single-family) or "MFH" (multi-family) * ``N_Pers``: Number of persons, up to 12 (relevant for EFH) * ``N_WE``: Number of apartments, up to 40 (relevant for MFH) + +Optional parameters for each house: + * ``Q_Heiz_a``: Annual heating demand in kWh * ``Q_TWW_a``: Annual hot water demand in kWh * ``W_a``: Annual electricity demand in kWh - -Optional parameters: - * ``summer_temperature_limit``: Temperature threshold for summer season (default: 15°C) * ``winter_temperature_limit``: Temperature threshold for winter season (default: 5°C) +(If any of the annual energy values are not provided, the respective time series +will be returned with all NaNs.) + Weather Data ------------ The module uses German test reference year (TRY) weather data by 'Deutscher Wetterdienst' (DWD) -for determining the daily temperature and cloud coverage. You can: +for determining the daily temperature and cloud coverage. Weather data is handled through the Climate class, +which offers several ways to initialize: + +* Use built-in TRY weather data from 2010: + + :: + + climate = vdi.Climate().from_try_data(try_region=4) + +* Load data from a custom weather file: + + :: + + climate = vdi.Climate().from_dwd_weather_file(fn_weather='path/to/weather.dat', try_region=4) + + The weather file must adhere to the standard of the TRY weather data published + in 2016 by DWD (available at https://kunden.dwd.de/obt/) + + Please refer to the function documentation for the caveats of using custom weather data. + In short, the function should be used with caution, since this is not the usage intended + by the norm and the profiles are supposed to be generated with the 2010 TRY weather data. -* Use the weather data from one of the 15 TRY regions by DWD from 2010 +* Initialize with your own data: - * Specify a TRY region number (``try_region`` parameter), or + :: - * Use geographical coordinates to determine the TRY region (requires geopandas) + climate = vdi.Climate( + temperature=your_temp_data, + cloud_coverage=your_cloud_data, + energy_factors=your_energy_factors + ) -* Provide your own weather file (``file_weather`` parameter), adhering to the standard - of the TRY weather data published in 2016 by DWD (available at https://kunden.dwd.de/obt/) + To use other sources of weather data, a custom ``Climate()`` object can be created + by providing daily average temperature and cloud coverage time series, as well as + matching energy factors, which scale the typical days relative to each other. Further Reading --------------- For more details about the VDI 4655 standard, refer to: -* VDI 4655: Reference load profiles of single-family and multi-family houses for the use of CHP systems -* May 2008 (ICS 91.140.01) -* Verein Deutscher Ingenieure e.V. +| VDI 4655: Reference load profiles of single-family and multi-family houses for the use of CHP systems +| May 2008 (ICS 91.140.01) +| Verein Deutscher Ingenieure e.V. diff --git a/examples/vdi_profile_example.py b/examples/vdi_profile_example.py index e65a10d..dcc0036 100644 --- a/examples/vdi_profile_example.py +++ b/examples/vdi_profile_example.py @@ -46,12 +46,11 @@ for n in range(2): my_houses.append( { - "N_Pers": 3, "name": "EFH_{0}".format(n), + "house_type": "EFH", + "N_Pers": 3, "N_WE": 1, "Q_Heiz_a": 6000, - "copies": 24, - "house_type": "EFH", "Q_TWW_a": 1500, "W_a": 5250, "summer_temperature_limit": 15, @@ -60,12 +59,11 @@ ) my_houses.append( { - "N_Pers": 45, "name": "MFH_{0}".format(n), + "house_type": "MFH", + "N_Pers": 45, "N_WE": 15, "Q_Heiz_a": 60000, - "copies": 24, - "house_type": "MFH", "Q_TWW_a": 15000, "W_a": 45000, "summer_temperature_limit": 15, diff --git a/src/demandlib/vdi/regions.py b/src/demandlib/vdi/regions.py index 66bb549..f06b09c 100644 --- a/src/demandlib/vdi/regions.py +++ b/src/demandlib/vdi/regions.py @@ -29,6 +29,7 @@ For a given year, the typical days can be matched to the actual calendar days, based on the following conditions: + - Season: summer, winter or transition - Day: weekday or sunday (Or holiday, which counts as sunday) - Cloud coverage: cloudy or not cloudy @@ -65,6 +66,9 @@ class Climate: cloud_coverage : iterable of numbers The cloud coverage in the area as daily mean values. The number of values must equal 365 or 366 for a leap year. + energy_factors : pandas DataFrame + Factors for each house type, season type and energy type + for the appropriate TRY region, as provided by the VDI 4655. """ def __init__( @@ -78,15 +82,73 @@ def __init__( self.energy_factors = energy_factors def from_try_data(self, try_region, hoy=8760): - if try_region not in list(range(1, 16)): - raise ValueError( - f">{try_region}< is not a valid number of a DWD TRY region." - ) + """ + Create a climate object from test-reference-year data. + + Parameters + ---------- + try_region : int + Number of the test-reference-year region where the building + is located, as defined by the german weather service DWD. + The module dwd_try provides the function find_try_region() to find + the correct region for given coordinates. + hoy : int, optional + Number of hours of the year. The default is 8760. + """ + self.check_try_region(try_region) + fn_weather = os.path.join( os.path.dirname(__file__), "resources_weather", "TRY2010_{:02d}_Jahr.dat".format(try_region), ) + self.from_dwd_weather_file(fn_weather, try_region, hoy) + + return self + + def from_dwd_weather_file(self, fn_weather, try_region, hoy=8760): + """ + Create a climate object from a DWD weather file. + + The weather file must adhere to the standard of the TRY weather + data published in 2016 by the German weather service DWD, + available at https://kunden.dwd.de/obt/. + + .. note:: + + The function ``from_try_data()`` is the implementation for using + weather data as intended by the VDI 4655, because it loads + the original weather data. Using different weather data is + not supported by the norm. + + However, ``from_dwd_weather_file()`` enables users to load the + most recent DWD test reference year weather files **at their + own risk**. They still need to provide a TRY region number, + which is required for loading the energy factors. + These are used for scaling the typical days relative to each + other, depending on the TRY region. But users need to be aware + that they do not have the originally intended effect when + used with different weather data. + + Other file types are currently not supported. Instead, users + need to create a ``Climate()`` object and provide temperature + and cloud coverage time series, as well as matching energy + factors. + + Parameters + ---------- + fn_weather : str + Name of the weather data file to load. + try_region : int + Number of the test-reference-year region where the building + is located, as defined by the German weather service DWD. + The module ``dwd_try`` provides the function ``find_try_region()`` + to find the correct region for given coordinates. + hoy : int, optional + Number of hours of the year. The default is 8760. + """ + self.check_try_region(try_region) + weather = dwd_try.read_dwd_weather_file(fn_weather) weather = ( weather.set_index( @@ -127,6 +189,12 @@ def check_attributes(self): "\n* temperature\n* cloud_coverage\n* energy_factors" ) + def check_try_region(self, try_region): + if try_region not in list(range(1, 16)): + raise ValueError( + f">{try_region}< is not a valid number of a DWD TRY region." + ) + class Region: """Define region-dependent boundary conditions for the load profiles. @@ -426,18 +494,49 @@ def add_houses(self, houses): "MFH" (multi-family) * ``N_Pers``: Number of persons, up to 12 (relevant for EFH) * ``N_WE``: Number of apartments, up to 40 (relevant for MFH) - * ``Q_Heiz_a``: Annual heating demand in kWh - * ``Q_TWW_a``: Annual hot water demand in kWh - * ``W_a``: Annual electricity demand in kWh Optional: + * ``Q_Heiz_a``: Annual heating demand in kWh + * ``Q_TWW_a``: Annual hot water demand in kWh + * ``W_a``: Annual electricity demand in kWh * ``summer_temperature_limit``: Temperature threshold for summer season (default: 15°C) * ``winter_temperature_limit``: Temperature threshold for winter season (default: 5°C) + (If any of the annual energy values are not provided, the + respective time series will be returned with all NaNs.) + """ + param_req = ["name", "house_type", "N_Pers", "N_WE"] + param_opt = [ + "Q_Heiz_a", + "Q_TWW_a", + "W_a", + "summer_temperature_limit", + "winter_temperature_limit", + ] + for i, h in enumerate(houses): + param_missing = [p for p in param_req if p not in h.keys()] + if len(param_missing) > 0: + msg = ( + f"House {i} is missing the following required " + f"parameters: {param_missing}" + ) + raise AttributeError(msg) + + for h in houses: + param_wrong = [ + k for k in h.keys() if k not in param_req + param_opt + ] + if len(param_wrong) > 0: + msg = ( + f"The following parameters for house {h['name']} " + f"are not supported: {param_wrong}" + ) + raise AttributeError(msg) + houses_wrong = r"\n".join( [str(h) for h in houses if h["house_type"] not in ["EFH", "MFH"]] ) @@ -470,8 +569,6 @@ def get_daily_energy_demand_houses(self, tl): """ if tl not in self.type_days: self.type_days[tl] = self._get_typical_days(self._holidays, tl) - # typtage_combinations = settings["typtage_combinations"] - # houses_list = settings["houses_list_VDI"] if self.zero_summer_heat_demand: # Reduze the value of 'F_Heiz_TT' to zero. @@ -503,9 +600,9 @@ def get_daily_energy_demand_houses(self, tl): n_we = house["N_WE"] # Get yearly energy demands - q_heiz_a = house["Q_Heiz_a"] - w_a = house["W_a"] - q_tww_a = house["Q_TWW_a"] + q_heiz_a = house.get("Q_Heiz_a", float("NaN")) + w_a = house.get("W_a", float("NaN")) + q_tww_a = house.get("Q_TWW_a", float("NaN")) # (6.4) Do calculations according to VDI 4655 for each 'typtag' for typtag in typtage_combinations: @@ -603,8 +700,8 @@ def get_load_curve_houses(self): for house in self.houses: t_limit = namedtuple("temperature_limit", "summer winter") tl = t_limit( - summer=house["summer_temperature_limit"], - winter=house["winter_temperature_limit"], + summer=house.get("summer_temperature_limit", 15), + winter=house.get("winter_temperature_limit", 5), ) df_typ = ( self.type_days[tl] @@ -632,7 +729,7 @@ def get_load_curve_houses(self): # The typical day calculation inherently does not add up to the # desired total energy demand of the full year. Here we fix that: for column in load_curve_house.columns: - q_a = house[column.replace("TT", "a")] + q_a = house.get(column.replace("TT", "a"), float("NaN")) sum_ = load_curve_house[column].sum() if sum_ > 0: # Would produce NaN otherwise load_curve_house[column] = ( diff --git a/tests/test_vdi4655.py b/tests/test_vdi4655.py index e16c13e..2a54216 100644 --- a/tests/test_vdi4655.py +++ b/tests/test_vdi4655.py @@ -205,11 +205,22 @@ def test_dwd_weather_file_missing_header(self): finally: os.remove(temp_filepath) + def test_house_parameters_missing(self): + houses = [{"name": "House with missing parameters"}] + with pytest.raises(AttributeError, match="required parameters"): + Region(2017, climate=Climate().from_try_data(4), houses=houses) + + def test_house_parameters_unsupported(self, example_houses): + houses = example_houses.copy() + houses[0]["unsupported"] = "unsupported paramter" + with pytest.raises(AttributeError, match="not supported"): + Region(2017, climate=Climate().from_try_data(4), houses=houses) + def test_wrong_house_type(self, example_houses): houses = example_houses + [ { "N_Pers": 3, - "name": "Wrong_heouse_type", + "name": "Wrong_house_type", "N_WE": 1, "Q_Heiz_a": 6000, "house_type": "wrong", @@ -224,16 +235,23 @@ def test_wrong_house_type(self, example_houses): def test_house_missing_energy_values(self, example_houses): """Test handling of houses with missing energy values.""" - houses = example_houses.copy() - # Remove some energy values - del houses[0]["Q_Heiz_a"] - del houses[0]["W_a"] + houses = [ + { + "name": "EFH", + "house_type": "EFH", + "N_Pers": 3, + "N_WE": 1, + } + ] region = Region( 2017, climate=Climate().from_try_data(4), houses=houses ) - with pytest.raises(KeyError, match="Q_Heiz_a"): - region.get_load_curve_houses() + + load_curves = region.get_load_curve_houses() + + # Check if load curves for house with NaN values are all zero + assert load_curves.isna().all(axis=None) def test_invalid_try_region_warning(self, example_houses): """Test warning and skipping behavior for invalid TRY region."""