Degree Days & Climate Correction#
This example shows how to
compute Swiss heating degree days HGT 20/12 and accumulated temperature differences ATD from an outdoor temperature series,
climate-correct a measured heating consumption with a reference climatology,
fit a fixed-base degree-day regression (energy vs. outside temperature) and
apply the GT&S method of the BFE (degree days and solar radiation).
All plots are interactive — hover to see exact values.
# Configure Plotly to render as HTML (required for Jupyter Book)
import plotly.io as pio
pio.renderers.default = "notebook_connected"
Heating degree days from hourly data#
The bundled sample contains hourly outdoor temperatures of Zürich-Kloten
for 2023. heating_degree_days resamples to daily means, counts a day as
heating day when the daily mean is at most 12 °C and sums 20 °C − T_mean
over those days (SIA 381/3 convention).
import pandas as pd
from importlib import resources
from pyedautils.degree_days import heating_degree_days
data_path = resources.files("pyedautils") / "data" / "kloten_weather_sample.csv"
weather = pd.read_csv(data_path, parse_dates=["timestamp"])
temp = weather[["timestamp", "temperature"]]
hgt_monthly = heating_degree_days(temp, freq="MS")
hgt_monthly.round(1)
| hgt | ht | n_days | n_valid | completeness | t_mean | |
|---|---|---|---|---|---|---|
| period | ||||||
| 2023-01-01 00:00:00+00:00 | 548.5 | 31.0 | 31 | 31 | 1.0 | 2.3 |
| 2023-02-01 00:00:00+00:00 | 495.0 | 28.0 | 28 | 28 | 1.0 | 2.3 |
| 2023-03-01 00:00:00+00:00 | 427.1 | 31.0 | 31 | 31 | 1.0 | 6.2 |
| 2023-04-01 00:00:00+00:00 | 339.0 | 26.0 | 30 | 30 | 1.0 | 7.8 |
| 2023-05-01 00:00:00+00:00 | 118.2 | 13.0 | 31 | 31 | 1.0 | 13.5 |
| 2023-06-01 00:00:00+00:00 | 0.0 | 0.0 | 30 | 30 | 1.0 | 19.9 |
| 2023-07-01 00:00:00+00:00 | 0.0 | 0.0 | 31 | 31 | 1.0 | 19.8 |
| 2023-08-01 00:00:00+00:00 | 0.0 | 0.0 | 31 | 31 | 1.0 | 19.6 |
| 2023-09-01 00:00:00+00:00 | 16.4 | 2.0 | 30 | 30 | 1.0 | 17.7 |
| 2023-10-01 00:00:00+00:00 | 150.7 | 15.0 | 31 | 31 | 1.0 | 12.4 |
| 2023-11-01 00:00:00+00:00 | 428.3 | 29.0 | 30 | 30 | 1.0 | 5.5 |
| 2023-12-01 00:00:00+00:00 | 528.0 | 31.0 | 31 | 31 | 1.0 | 3.0 |
n_days are the calendar days of each month, n_valid the days with data,
completeness their ratio. Use freq="YS" for calendar years or
freq="season" for heating seasons (July–June).
heating_degree_days(temp, freq="YS").round(1)
| hgt | ht | n_days | n_valid | completeness | t_mean | |
|---|---|---|---|---|---|---|
| period | ||||||
| 2023-01-01 00:00:00+00:00 | 3051.2 | 206.0 | 365 | 365 | 1.0 | 10.9 |
Plot against a reference#
A reference climatology is usually built from a long series (see
reference_degree_days and the open-data downloaders). Here we use
illustrative monthly reference values for the Swiss Plateau.
from pyedautils.plots import plot_degree_days
# Illustrative HGT 20/12 climatology [Kd] per calendar month (index 1..12)
ref_monthly = pd.Series([560, 470, 390, 240, 90, 15, 2, 3, 70, 230, 420, 530],
index=range(1, 13))
fig = plot_degree_days(hgt_monthly, reference=ref_monthly,
title="Heating Degree Days 20/12 — Kloten 2023")
fig.show()
Accumulated temperature difference (SIA 380)#
SIA 380:2015 recommends the ATD with a single base temperature, the heating
limit of the building. The default is 12 °C; a building-specific value can
come from the energy signature (compute_pes(...).tb).
from pyedautils.degree_days import accumulated_temperature_difference
atd_monthly = accumulated_temperature_difference(temp, base=12.0)
atd_monthly[["atd", "ht"]].round(1).T
| period | 2023-01-01 00:00:00+00:00 | 2023-02-01 00:00:00+00:00 | 2023-03-01 00:00:00+00:00 | 2023-04-01 00:00:00+00:00 | 2023-05-01 00:00:00+00:00 | 2023-06-01 00:00:00+00:00 | 2023-07-01 00:00:00+00:00 | 2023-08-01 00:00:00+00:00 | 2023-09-01 00:00:00+00:00 | 2023-10-01 00:00:00+00:00 | 2023-11-01 00:00:00+00:00 | 2023-12-01 00:00:00+00:00 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| atd | 300.5 | 271.0 | 179.1 | 131.0 | 14.2 | 0.0 | 0.0 | 0.0 | 0.4 | 30.7 | 196.3 | 280.0 |
| ht | 31.0 | 28.0 | 31.0 | 26.0 | 13.0 | 0.0 | 0.0 | 0.0 | 2.0 | 15.0 | 29.0 | 31.0 |
Climate correction of a measured consumption#
The classic correction scales only the weather-dependent share of the
consumption with DD_ref / DD_actual; domestic hot water stays untouched.
from pyedautils.degree_days import climate_correction_factor, normalize_consumption
hgt_2023 = hgt_monthly["hgt"].sum()
hgt_ref = ref_monthly.sum()
factor = climate_correction_factor(hgt_2023, hgt_ref)
print(f"HGT 2023: {hgt_2023:.0f} Kd, reference: {hgt_ref:.0f} Kd, factor: {factor:.3f}")
# Measured 2023 consumption of 120 MWh with 75 % space heating share
e_norm = normalize_consumption(120_000, hgt_2023, hgt_ref, weather_dependent_share=0.75)
print(f"Climate-corrected consumption: {e_norm:,.0f} kWh")
HGT 2023: 3051 Kd, reference: 3020 Kd, factor: 0.990
Climate-corrected consumption: 119,081 kWh
Monthly factors follow from a monthly series and the climatology (matched by calendar month):
climate_correction_factor(hgt_monthly["hgt"], ref_monthly).round(2)
3 period(s) with zero degree days; factor set to NaN
period
2023-01-01 00:00:00+00:00 1.02
2023-02-01 00:00:00+00:00 0.95
2023-03-01 00:00:00+00:00 0.91
2023-04-01 00:00:00+00:00 0.71
2023-05-01 00:00:00+00:00 0.76
2023-06-01 00:00:00+00:00 NaN
2023-07-01 00:00:00+00:00 NaN
2023-08-01 00:00:00+00:00 NaN
2023-09-01 00:00:00+00:00 4.26
2023-10-01 00:00:00+00:00 1.53
2023-11-01 00:00:00+00:00 0.98
2023-12-01 00:00:00+00:00 1.00
Name: factor, dtype: float64
Degree-day regression with a fixed base temperature#
fit_degree_day_line fits E = a + b · HDD(base) and reports the ASHRAE
Guideline 14 metrics CV(RMSE) and NMBE. The base temperature is not
searched; use the balance temperature of the energy signature or a known
heating limit.
from pyedautils.degree_days import fit_degree_day_line
from pyedautils.plots import plot_degree_day_regression
sig_path = resources.files("pyedautils") / "data" / "bldg_engy_sig_simple.csv"
sig = pd.read_csv(sig_path, sep=";", parse_dates=["timestamp"])
energy = sig[["timestamp", "value"]]
temperature = sig[["timestamp", "temperature"]]
line = fit_degree_day_line(energy, temperature, base=15.0)
print(f"intercept = {line.intercept:.1f} kWh/d, slope = {line.slope:.2f} kWh/Kd, "
f"R² = {line.r2:.3f}, CV(RMSE) = {line.cvrmse:.1f} %, NMBE = {line.nmbe:.2f} %")
fig = plot_degree_day_regression(energy, temperature, base=15.0)
fig.show()
intercept = 6.4 kWh/d, slope = 10.43 kWh/Kd, R² = 0.785, CV(RMSE) = 38.5 %, NMBE = 0.00 %
GT&S method (BFE / Prognos)#
The degree-day and radiation method weights monthly deviations of degree days (base 20 °C, no heating limit) and global radiation with functional elasticities and the normal-year load curve. The documented parameters for multi-family houses in 2010 reproduce the published annual factor of 1.063 for the Swiss weather of 2010.
from pyedautils.degree_days import gts_correction_factor, GTS_MFH_2010_EXAMPLE, GTS_CH_2010_WEATHER
ex = GTS_MFH_2010_EXAMPLE
res = gts_correction_factor(
GTS_CH_2010_WEATHER["gt"], ex["gt_norm"],
GTS_CH_2010_WEATHER["rad"], ex["rad_norm"],
ex["load_share_norm"], ex["elasticity_gt"], ex["elasticity_rad"],
)
print(f"ratio = {res.ratio:.3f} (divide the consumption), factor = {res.factor:.3f} (multiply)")
res.monthly.round(3)
ratio = 1.065 (divide the consumption), factor = 0.939 (multiply)
| share | d_gt | d_rad | e_gt | e_rad | effect | contribution | |
|---|---|---|---|---|---|---|---|
| month | |||||||
| 1 | 0.196 | 0.104 | -0.018 | 1.221 | -0.110 | 0.129 | 0.221 |
| 2 | 0.153 | 0.039 | 0.051 | 1.302 | -0.190 | 0.041 | 0.159 |
| 3 | 0.118 | 0.053 | 0.084 | 1.437 | -0.307 | 0.051 | 0.124 |
| 4 | 0.080 | -0.144 | 0.262 | 1.593 | -0.427 | -0.341 | 0.053 |
| 5 | 0.023 | 0.215 | -0.149 | 3.295 | -1.538 | 0.936 | 0.045 |
| 6 | 0.002 | -0.198 | 0.014 | 3.360 | -2.744 | -0.705 | 0.001 |
| 7 | 0.001 | -0.400 | 0.130 | 1.806 | -2.275 | -1.017 | -0.000 |
| 8 | 0.001 | 0.304 | -0.059 | 7.237 | -3.038 | 2.382 | 0.003 |
| 9 | 0.022 | 0.113 | 0.172 | 2.977 | -1.179 | 0.133 | 0.025 |
| 10 | 0.074 | 0.115 | 0.062 | 1.555 | -0.339 | 0.157 | 0.086 |
| 11 | 0.146 | -0.058 | 0.120 | 1.247 | -0.120 | -0.086 | 0.133 |
| 12 | 0.185 | 0.142 | 0.105 | 1.204 | -0.080 | 0.163 | 0.215 |