Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions docs/vdi4655.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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.
10 changes: 4 additions & 6 deletions examples/vdi_profile_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
127 changes: 112 additions & 15 deletions src/demandlib/vdi/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__(
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +500 to +502
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would always require these inputs. If you do not want to have TWW, you can simply give 0.

* ``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"]]
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not agree with this behaviour. This will also happen if there is a typo in the parameter. I think the best way to deal with this ist to introduce a Building() class where we can define what will happen with any of the parameters.

# (6.4) Do calculations according to VDI 4655 for each 'typtag'
for typtag in typtage_combinations:
Expand Down Expand Up @@ -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),
Comment on lines +703 to +704
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't these temperatures in the norm? If so, I think it's okay to set them as defaults.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, these are the values used per default in the norm.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the default value is part of the norm. I agree.

)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See acomment above.

df_typ = (
self.type_days[tl]
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above.

load_curve_house[column] = (
Expand Down
32 changes: 25 additions & 7 deletions tests/test_vdi4655.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."""
Expand Down