M-Bus Gateway
← Tilbage til blog
· 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.

Se Celery beat guide eller graddage normalisering guide.