· DMI· Klimagrid· graddage· HDD· varmeforbrug· temperaturkorrektion· API· Python· normalisering
DMI Klimagrid graddage API — temperaturkorrektion af varmeforbrug
DMI Klimagrid API til graddage (HDD): API-kald, graddage pr. postnummer, normalår-normalisering, Python-integration og Celery-baseret daglig datahentning.
Af M-Bus Gateway
DMI Klimagrid leverer gratis graddage-data pr. postnummer til temperaturkorrektion af varmeforbrug. Her er integrationen i M-Bus Gateway platformen.
Hvad er graddage?
Graddage (Heating Degree Days, HDD):
→ Mål for kulden i en given periode
→ HDD = max(0, T_basis - T_udetemperatur)
→ T_basis = 17°C (dansk standard)
→ Høje HDD = koldt vejr = højt varmeforbrug
Eksempel:
Udetemperatur = 5°C: HDD = 17 - 5 = 12 graddage
Udetemperatur = 20°C: HDD = max(0, 17-20) = 0 graddage
Normalår (2001-2020 gennemsnit):
København: ~2.900 HDD/år
Aarhus: ~2.750 HDD/år
Odense: ~2.650 HDD/år
Aalborg: ~3.000 HDD/år
Brug:
→ Korrigér varmeforbrug år-til-år (koldt vs. varmt år)
→ Sammenlign energimærke med faktisk forbrug
→ Prognosticér á conto-behov
DMI Klimagrid API
# server/src/dmi/client.py
import httpx
import asyncio
from datetime import date, timedelta
DMI_API_BASE = "https://dmigw.govcloud.dk/v2/climategrid"
DMI_API_KEY = settings.DMI_API_KEY # Gratis API-nøgle fra dmi.dk
async def fetch_degree_days(
zip_code: str,
country: str,
from_date: date,
to_date: date,
) -> list[dict]:
"""
Hent graddage pr. dag for et postnummer.
Returnerer: [{"date": "2025-01-01", "hdd": 12.3}, ...]
"""
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(
f"{DMI_API_BASE}/collections/day/items",
params={
"api-key": DMI_API_KEY,
"datetime": f"{from_date}/{to_date}",
"bbox": _zip_to_bbox(zip_code),
"limit": 1000,
},
)
resp.raise_for_status()
data = resp.json()
return [
{
"date": feature["properties"]["from"],
"hdd": feature["properties"].get("meanDailyHDD17", 0),
"zip_code": zip_code,
"country": country,
}
for feature in data.get("features", [])
]
def _zip_to_bbox(zip_code: str) -> str:
"""
Konvertér dansk postnummer til bounding box.
DMI Klimagrid kræver koordinater (WGS84).
Brug en statisk oversigt eller DAWA API for koordinater.
"""
# Forenkling: Brug centerpunkt + 0.1 graders bbox:
coords = ZIP_CODE_CENTERS.get(zip_code, (55.676098, 12.568337)) # København default
lat, lon = coords
return f"{lon-0.1},{lat-0.1},{lon+0.1},{lat+0.1}"
# Statisk tabel (postnr → koordinat-center):
ZIP_CODE_CENTERS = {
"1000": (55.676, 12.568), # København K
"2200": (55.700, 12.551), # København N
"8000": (56.153, 10.204), # Aarhus C
"5000": (55.396, 10.388), # Odense C
"9000": (57.048, 9.917), # Aalborg C
# ... komplet tabel i config
}
Database: degree_days tabel
# server/src/db/models.py
from sqlmodel import SQLModel, Field
from datetime import date
from typing import Optional
class DegreeDay(SQLModel, table=True):
__tablename__ = "degree_days"
id: Optional[int] = Field(default=None, primary_key=True)
date: date = Field(index=True)
zip_code: str = Field(max_length=10, index=True)
country: str = Field(default="DK", max_length=2)
heating_degree_days: float
class Config:
# Ingen tenant_id — fælles data for alle tenants
pass
Celery task: Daglig DMI-sync
# server/src/workers/tasks/dmi.py
from celery import shared_task
import structlog
logger = structlog.get_logger()
@shared_task(
name="dmi.sync_degree_days",
max_retries=3,
default_retry_delay=300,
)
def sync_degree_days():
"""
Hent graddage fra DMI for alle aktive postnumre.
Kører dagligt kl. 05:00 UTC via Celery beat.
"""
from server.src.db.sync_session import get_sync_session
from server.src.dmi.client import fetch_degree_days
import asyncio
yesterday = date.today() - timedelta(days=1)
with get_sync_session() as session:
# Find alle aktive postnumre:
zip_codes = session.execute(
select(Property.zip_code, Property.country)
.where(Property.deleted_at.is_(None))
.distinct()
).all()
for zip_code, country in zip_codes:
try:
rows = asyncio.run(fetch_degree_days(
zip_code=zip_code,
country=country,
from_date=yesterday,
to_date=yesterday,
))
with get_sync_session() as session:
for row in rows:
# Upsert (idempotent):
existing = session.execute(
select(DegreeDay)
.where(DegreeDay.date == row["date"])
.where(DegreeDay.zip_code == zip_code)
).scalar_one_or_none()
if not existing:
session.add(DegreeDay(
date=row["date"],
zip_code=zip_code,
country=country,
heating_degree_days=row["hdd"],
))
session.commit()
logger.info("dmi_sync_ok", zip_code=zip_code, date=str(yesterday))
except Exception as exc:
logger.error("dmi_sync_failed", zip_code=zip_code, error=str(exc))
Normalisering: Sammenlign år med forskel i kulde
# server/src/analytics/normalization.py
def normalize_consumption(
actual_kwh: float,
actual_hdd: float,
normal_hdd: float,
) -> float:
"""
Normaliser faktisk forbrug til normalår.
Bruges til sammenligning på tværs af år.
actual_kwh: Faktisk forbrug (kWh eller MWh)
actual_hdd: Graddage i afregningsperioden
normal_hdd: Normalår-graddage (2001-2020 gennemsnit)
"""
if actual_hdd == 0:
return actual_kwh # Undgå division med 0
normalized = actual_kwh * (normal_hdd / actual_hdd)
return round(normalized, 2)
# Eksempel:
# 2024 var et koldt år: Forbrug = 18.500 kWh, HDD = 3.200
# Normalår for postnummer 2200: HDD = 2.900
# Normaliseret: 18.500 × (2.900 / 3.200) = 16.773 kWh
# → Ejendommen er "egentlig" mere effektiv end rådata viser
Konklusion
DMI Klimagrid API er gratis og leverer graddage pr. postnummer med daglig opdatering. Celery beat henter data automatisk og gemmer det i degree_days-tabellen. Normalisering til normalår (2001-2020) gør år-til-år sammenligning meningsfuld — en kold vinter skal ikke se ud som et ineffektivt varmesystem.