October Was 13 % Milder. Weather-Normalise Your Heating Meter in 55 Lines of Python
October 2026 was 13 % milder in Zürich. Compute Heizgradtage from MeteoSwiss open data in 55 lines of Python and weather-normalise a heating meter.
MeteoSwiss’ provisional October bulletin, carried by Keystone-SDA on 30 October 2026, puts Switzerland at a mean of 8 °C, which is 1.4 °C above the 1991–2020 reference period. Run 55 lines of standard-library Python against the Zürich / Fluntern station and the same month comes out at 218 Heizgradtage (Kd), against a normal October of 250 Kd. That makes it 13 % milder. Every heating meter in the city looks a little better this month, and the building earned only part of that.
A meter reading is half building and half weather. The good news is that the weather half is public. MeteoSwiss publishes SwissMetNet, “158 automatic monitoring stations” according to its STAC catalogue, under a CC-BY licence. Fluntern has daily values back to 1 January 1864, refreshed every morning up to yesterday. Behind that file are the station technicians and data engineers who keep a thermometer at 604 m a.s.l. honest through more than 160 winters. Every weather-corrected energy figure in Switzerland stands on their work.
What a Heizgradtag actually counts
MeteoSwiss defines a heating day as one whose mean temperature is “12 °C oder weniger”, a day that, “gemäss dieser SIA-Definition”, must be heated. HGT 20/12 sums, over all heating days, the gap between a 20 °C room and the daily mean: a 5 °C day adds 15 Kd, a 13 °C day adds nothing. ATD 12/12 is simpler: every day adds max(0, 12 − Tmean). MeteoSwiss notes that both heating days and Heizgradtage have declined nationwide in recent decades and attributes the decline to climate warming.
←TODAY: In 2026, Zürich’s October counted 218 Kd against a 250 Kd norm, and most office spreadsheets compared the raw kWh anyway.
→3012: Every meter reading carries its weather twin, and nobody argues about a mild month again.
Fulcrum: The correction only works because 160 years of the same thermometer sit in one plain-text file.
The Tool
The Tool: MeteoSwiss Open Government Data for SwissMetNet (collection ch.meteoschweiz.ogd-smn on data.geo.admin.ch), read with the Python 3 standard library alone: no pip install, no API key. Each station has two daily CSVs: historical, from the start of measurement to 31 December last year, and recent, “from January 1st of this year until yesterday”. Three columns, tre200d0 (daily mean air temperature at 2 m), xno000d0 (“Heizgradzahl 12/20 (SIA: Heizgradtage)”) and xno012d0 (“Heizgradtage ATD12”), turn your office’s energy reports from guesswork into arithmetic.
Setup
Setup:
mkdir hgt-lab
cd hgt-lab
python --version # 3.10 or newer (tested on 3.10.11)
# optional: read what every column means
curl -O https://data.geo.admin.ch/ch.meteoschweiz.ogd-smn/ogd-smn_meta_parameters.csv
# save the script below as hgt.py, then:
python -I hgt.py
# first run downloads ~6.5 MB historical + ~48 kB recent CSV and caches bothFirst steps
First steps:
- Save the program below as
hgt.pyand run it. The first line should read59480 days read, own HGT differs from MeteoSwiss on 0 days: your arithmetic and the national weather service agree. - Open
ogd-smn_sma_d_recent.csvin Notepad: semicolons, dates like01.10.2026 00:00in UTC, plain numbers. That is the whole format. - Replace
METERED_KWHwith your own building’s October heating reading and run again. - Change
STATIONtoBER,GVE,LUGorDAV, whichever is nearest your site.
The code, explained
The code, explained:
# Heizgradtage HGT 20/12 from MeteoSwiss open data -> weather-normalise a meter reading
import csv, os, urllib.request
from datetime import date
STATION = "SMA" # Zürich / Fluntern (see ogd-smn_meta_stations.csv)
START, END = (10, 1), (10, 31) # period to check: 1 Oct - 31 Oct
YEAR = 2026
METERED_KWH = 1850.0 # your heating meter for that period (example value)
HOT_WATER_SHARE = 0.20 # part of the energy that does not depend on weather
BASE = "https://data.geo.admin.ch/ch.meteoschweiz.ogd-smn/{s}/ogd-smn_{s}_d_{p}.csv"
def load(part):
s = STATION.lower()
cache = f"ogd-smn_{s}_d_{part}.csv"
if not os.path.exists(cache):
urllib.request.urlretrieve(BASE.format(s=s, p=part), cache)
with open(cache, encoding="cp1252") as fh: # MeteoSwiss CSVs are Windows-1252
return list(csv.DictReader(fh, delimiter=";"))
days = {} # date -> (Tmean, published HGT, published ATD)
for row in load("historical") + load("recent"):
if not row["tre200d0"]:
continue
d, m, y = row["reference_timestamp"][:10].split(".")
days[date(int(y), int(m), int(d))] = (float(row["tre200d0"]),
row["xno000d0"], row["xno012d0"])
def hgt(t): return 20.0 - t if t <= 12.0 else 0.0 # SIA heating day: Tmean 12 °C or less
def atd(t): return max(0.0, 12.0 - t) # accumulated temperature difference
bad = sum(1 for t, h, a in days.values()
if h and abs(round(hgt(t), 1) - float(h)) > 0.05)
print(f"{len(days)} days read, own HGT differs from MeteoSwiss on {bad} days")
def period(year):
a, b = date(year, *START), date(year, *END)
vals = [v[0] for k, v in days.items() if a <= k <= b]
return sum(map(hgt, vals)), sum(map(atd, vals)), len(vals)
h_now, a_now, n = period(YEAR)
norm = [period(y) for y in range(1991, 2021)] # climate normal 1991-2020
h_norm = sum(p[0] for p in norm) / len(norm)
a_norm = sum(p[1] for p in norm) / len(norm)
print(f"{YEAR}: {n} days, HGT20/12 = {h_now:.0f} Kd, ATD12/12 = {a_now:.0f} Kd")
print(f"norm 1991-2020: HGT20/12 = {h_norm:.0f} Kd, ATD12/12 = {a_norm:.0f} Kd")
print(f"this period was {100 * (1 - h_now / h_norm):.0f} % milder than normal (HGT)")
for name, now, ref in (("HGT", h_now, h_norm), ("ATD", a_now, a_norm)):
weather = METERED_KWH * (1 - HOT_WATER_SHARE)
fixed = METERED_KWH * HOT_WATER_SHARE
normal = fixed + weather * ref / now
print(f"{name}: {METERED_KWH:.0f} kWh metered -> {normal:.0f} kWh in a normal year")
warmest = sorted((period(y)[0], y) for y in range(1981, YEAR + 1))[:3]
print("mildest periods since 1981 (HGT):", ", ".join(f"{y} {v:.0f}" for v, y in warmest))Run on 7 November 2026, it printed:
59480 days read, own HGT differs from MeteoSwiss on 0 days
2026: 31 days, HGT20/12 = 218 Kd, ATD12/12 = 58 Kd
norm 1991-2020: HGT20/12 = 250 Kd, ATD12/12 = 80 Kd
this period was 13 % milder than normal (HGT)
HGT: 1850 kWh metered -> 2066 kWh in a normal year
ATD: 1850 kWh metered -> 2393 kWh in a normal year
mildest periods since 1981 (HGT): 2022 36, 2001 47, 1995 86Lines 1–10, the knobs: Every value you might change sits at the top as a constant, a capitalised name the rest of the program only reads. START, END are (month, day) pairs, so the script handles a week, a month or a whole heating season. The 1,850 kWh and the 20 % hot-water share are illustrative teaching inputs, not a real building and not a norm value: replace both with your own figures.
Lines 12–18, download once, then read from disk: load is a function, a named block you can call again. It downloads the station file only if no cached copy exists, then parses it with csv.DictReader, which turns each line into a dictionary keyed by column name. The detail to get right: MeteoSwiss ships its CSVs as Windows-1252 (cp1252) with semicolons. The daily station file is plain ASCII, but the metadata files carry umlauts and accents, and read as UTF-8 they stop Python with a UnicodeDecodeError. MeteoSwiss’ own download docs add a warning: fetch the granularity you need, because self-summed 10-minute data “will not always lead to the same results!”
Lines 20–26, one dictionary, one key per day: Historical and recent rows are joined with +, empty temperature cells are skipped, and the dd.mm.yyyy timestamp becomes a real date object. Each date maps to a tuple (a fixed little bundle): the measured mean plus MeteoSwiss’ published HGT and ATD.
Lines 28–33, compute it yourself, then check it: hgt and atd are the two definitions as one-line functions. The bad counter compares your value with the published one on every day that has one, allowing for rounding to 0.1 Kd. Zero mismatches earns your trust in the rest of the output.
Lines 35–55, the norm and the normalisation: period(year) sums both indicators for your window; averaging 1991 to 2020 gives the climate normal. The normalisation keeps the weather-independent share fixed and scales the rest by norm ÷ actual. October 2026 was mild, so the normal-year figure is higher than the meter: 2,066 kWh with HGT, 2,393 kWh with ATD. The last line ranks the mildest Octobers since 1981: 2022, 2001 and 1995. The trade-off, plainly: this ratio method is a teaching method, not the SIA 380:2015 procedure, and it assumes weather-dependent energy scales linearly with degree-days. A building with large solar gains or a lazy controller will not behave that way.
Two wordings, one dataset
The Kanton Freiburg energy-monitoring page states that “in der Schweiz wird die Methode der SIA … angewendet”, using Heizgradtage 20/12, and phrases heating days as a mean “unter 12°C”. MeteoSwiss writes “12 °C oder weniger”. The wordings differ only at exactly 12.0 °C, and the data reconciles them. Across the 15,819 days since 1 January 1981 that carry a published HGT, the <= rule matches MeteoSwiss on every day; strict < differs on 63 days sitting exactly on 12.0 °C, where MeteoSwiss books 8.0 Kd. The concrete wall never noticed the debate.
HGT or ATD on the same meter
One meter, two answers: 2,066 kWh or 2,393 kWh. The gap is the base temperature. I. Mojic and M. Haller of the SPF Institut für Solartechnik at OST, in a 2019 literature review for the Swiss Federal Office of Energy (BFE), describe Swiss heating use as “üblicherweise mittels der Heizgradtag-Methode witterungsbereinigt” before SIA 380:2015, and note that the new norm recommends ATD. The hard part, they write, is choosing the base temperature: a standard 12 °C “zu grossen Abweichungen führt”, and without a building-specific value HGT does better than ATD for housing. Their review gives your office a sound reason for whichever indicator it picks.
A quieter point from the late 2070s: the energy data that survived looked like this file, semicolon-separated plain text that a 25-year-old can still open in Notepad. Keep your meter logs the same way.
Atelier
Atelier: A small Zürich studio or Bauleitung team handing clients a first-winter energy report can easily end up comparing raw kWh across years with different weather, which flatters mild winters and blames cold ones. An office letting AI assistants draft those reports needs a verified number even more, because a fluent paragraph built on uncorrected kWh is still wrong. Monday move: add a “normal-year kWh” column next to the metered column in your monitoring sheet for one building, filled by this script.
Hack
Hack: Test the Freiburg wording against the published series and predict the count before you run it. Swap the comparison in line 28 to strict-less-than and add one counter. Expect the first output line to read differs from MeteoSwiss on 63 days, and the counter to print 63 too, because every one of those days sits exactly on 12.0 °C. Then switch the comparison back.
def hgt(t): return 20.0 - t if t < 12.0 else 0.0 # "unter 12°C" wording
edge = sum(1 for t, h, a in days.values() if h and t == 12.0)
print(edge, "published days sit exactly on 12.0 °C")Learn-it
Learn-it:
- MeteoSwiss Open Data docs, download rules and file layout: opendatadocs.meteoswiss.ch/general/download
- SwissMetNet STAC collection (licence, stations, update time): data.geo.admin.ch, ch.meteoschweiz.ogd-smn
- MeteoSwiss on the Heiztag and Heizgradtage definition: MeteoSwiss, Heiztag
- Mojic and Haller, SPF / OST, on weather-normalisation methods: KlimaGapLite literature review
- PAZ note: once your meter data lives in Python, the next step is wiring it into the model. PAZ Academy’s Archicad Python Automatisierung course covers that bridge.
Download the Fluntern file tonight, run the 55 lines, and put a normal-year column next to your next meter reading.
PAZ Kaffi · multidisciplinary editorial, led by PAZ Academy