M8-T03: adopt DSMR history into meter sources

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent a78401c2ef
commit 28486a83c7
7 changed files with 628 additions and 29 deletions
+34 -10
View File
@@ -13,8 +13,8 @@ from __future__ import annotations
import uuid as _uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, event, text
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.types import JSON
from app.db import Base
@@ -97,8 +97,9 @@ class DsmrReading(Base):
(that field overflows and must be manually reset to zero — a known DSMR
quirk — so relying on it for uniqueness risks silently dropping new data).
The table's own autoincrement ``id`` PK is the stable internal identity, and
``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a
single P1 meter emits exactly one telegram per timestamp.
``(meter_source_id, recorded_at)`` is the UNIQUE de-duplication key: each
configured P1 source emits at most one telegram per timestamp, while
different sources may legitimately emit at the same instant.
``recorded_at`` is a real column (not inside the payload) so time-range
queries are efficient. The entire telegram frame is stored verbatim in
@@ -110,21 +111,44 @@ class DsmrReading(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent
# idempotency key). The unique index also serves time-range queries.
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# UTC timestamp of the sample. Idempotency is per configured source, so
# distinct P1 sources may legitimately emit at the same instant.
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Telegram's own id (DSMR Reader assigns it). Stored only as a reference /
# debugging aid — NOT used for uniqueness or idempotency (it overflows and
# gets reset to zero). Nullable because some DSMR sources may not emit one.
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
telegram_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Compatibility for the pre-M8 ingest implementation. This is an ORM
# alias only; the physical database column is ``telegram_id``.
source_id = synonym("telegram_id")
# The configured source is the durable identity of the cumulative reading
# stream. It is non-null after the revision-16 historical adoption.
meter_source_id: Mapped[int] = mapped_column(
ForeignKey("meter_source.id", ondelete="RESTRICT"), nullable=False, index=True
)
# Full telegram frame as a JSON object; values are typically JSON strings
# (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
__table_args__ = (
UniqueConstraint(
"meter_source_id", "recorded_at", name="uq_dsmr_reading_source_recorded_at"
),
)
@event.listens_for(DsmrReading, "before_insert")
def _supply_legacy_dsmr_source(_mapper, connection, target: DsmrReading) -> None:
"""Keep the pre-T04 single-source writer working during the schema handoff."""
if target.meter_source_id is None:
target.meter_source_id = connection.execute(
text("SELECT id FROM meter_source WHERE kind = 'dsmr_mqtt' ORDER BY id LIMIT 1")
).scalar_one()
class EnergyContract(Base):
"""Contract head: a named energy contract with a chosen pricing strategy.