M6-T01: add 5 energy tables, models, and migration

- app/models/energy.py: DsmrReading, EnergyContract, EnergyContractVersion,
  TibberPrice, EnergyCostPeriod (per design §3.5; JSON blobs, RESTRICT FKs).
- alembic_app migration 20260623_11_energy_tables (head), env.py imports.
- app_db_adopt APP_BASELINE_REVISION bumped to new head.
- tests/test_energy_models.py + expose_catalog test fixups for new head.
This commit is contained in:
2026-06-23 20:33:35 +02:00
parent 355c8eb8b8
commit 18fe18281b
7 changed files with 1093 additions and 11 deletions
+234
View File
@@ -0,0 +1,234 @@
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
Five tables:
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
- energy_contract: contract head (manual or tibber, one active at a time).
- energy_contract_version: versioned pricing values; append-only for auditability.
- tibber_price: cached Tibber 15-minute spot prices (immutable).
- energy_cost_period: computed 15-minute billing periods (immutable snapshot).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db import Base
class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob.
``recorded_at`` is a real indexed column (not inside the payload) so that
time-range queries are efficient. ``source_id`` holds the telegram's own
integer ``id`` and is used for idempotent upsert (skip if already present).
The entire telegram frame is stored verbatim in ``payload``; no field
allow-list is applied so future commodities (gas, heating, three-phase)
are accommodated without a schema change.
"""
__tablename__ = "dsmr_reading"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC timestamp of the sample — real column so it can be indexed efficiently.
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
# Telegram's own auto-increment id (DSMR Reader assigns it). Used for
# idempotent ingestion: if the same telegram arrives twice we skip it.
# Nullable because some DSMR sources may not emit an id.
source_id: Mapped[int | None] = mapped_column(Integer, unique=True, nullable=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)
class EnergyContract(Base):
"""Contract head: a named energy contract with a chosen pricing strategy.
``kind`` determines which price strategy is used (``manual`` for fixed
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
Only one contract may be ``active`` at a time; the service layer enforces
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
so that price changes can be tracked without modifying historical records.
"""
__tablename__ = "energy_contract"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Human-readable label; freely editable by the user.
name: Mapped[str] = mapped_column(String(255), nullable=False)
# Strategy selector: "manual" or "tibber". Application-layer validation
# enforces the allowed set; no DB CHECK constraint is added to keep the
# migration simple and the strategy registry extensible.
kind: Mapped[str] = mapped_column(String(32), nullable=False)
# Whether this is the currently active contract (at most one should be True).
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# ISO 4217 currency code for all monetary values in this contract.
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="EUR")
# Audit timestamps.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship to versions (back-reference; not loaded eagerly).
versions: Mapped[list["EnergyContractVersion"]] = relationship(
back_populates="contract", cascade="save-update, merge"
)
class EnergyContractVersion(Base):
"""One time-bounded version of an energy contract's pricing values.
Pricing changes are modelled as new versions (append-only); existing versions
are never modified so that historical ``EnergyCostPeriod`` records remain
fully auditable. ``effective_to`` is ``NULL`` for the currently open version.
"""
__tablename__ = "energy_contract_version"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# FK to the parent contract. RESTRICT prevents deletion of a contract that
# still has versioned pricing rows attached to it.
contract_id: Mapped[int] = mapped_column(
ForeignKey("energy_contract.id", ondelete="RESTRICT"), nullable=False
)
# Start of this version's validity window (inclusive, UTC).
effective_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
# End of this version's validity window (exclusive, UTC). NULL means open-ended
# (i.e. this is the most recent / current version).
effective_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Pricing values as a JSON object conforming to the profile structure for
# ``contract.kind`` (validated by the application layer against the YAML profile).
values: Mapped[dict] = mapped_column(JSON, nullable=False)
# Creation timestamp.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship back to the parent contract.
contract: Mapped["EnergyContract"] = relationship(back_populates="versions")
# Relationship to cost periods that reference this version.
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="contract_version", cascade="save-update, merge"
)
class TibberPrice(Base):
"""Cached Tibber 15-minute spot price point (immutable once fetched).
``starts_at`` is unique so that upserts are idempotent. Past prices are
never overwritten; the fetch job only adds rows for future time slots.
"""
__tablename__ = "tibber_price"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC start of the 15-minute slot; unique so upsert is idempotent.
starts_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# Resolution label as returned by the Tibber API (e.g. "QUARTER_HOURLY").
resolution: Mapped[str] = mapped_column(String(32), nullable=False)
# Price components in the contract currency (all include VAT, user-facing).
energy: Mapped[float] = mapped_column(Float, nullable=False)
tax: Mapped[float] = mapped_column(Float, nullable=False)
total: Mapped[float] = mapped_column(Float, nullable=False)
# Tibber price level (e.g. "NORMAL", "CHEAP", "EXPENSIVE"); may be absent.
level: Mapped[str | None] = mapped_column(String(32), nullable=True)
# ISO 4217 currency code as returned by the API.
currency: Mapped[str] = mapped_column(String(8), nullable=False)
# UTC timestamp of when this row was fetched/inserted.
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class EnergyCostPeriod(Base):
"""Computed billing record for one 15-minute metering period (immutable snapshot).
Each row captures the per-register kWh deltas, the resulting import cost and
export revenue, and a full snapshot of the pricing values used so that the
calculation is fully auditable and reproducible without re-querying the
contract version. Rows are written once and never modified; explicit
recomputation via the API is the only way to overwrite a period.
"""
__tablename__ = "energy_cost_period"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC start of the 15-minute period; unique so upsert is idempotent.
period_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# Per-register kWh deltas for the period (end minus start of cumulative registers).
# _1 = dal/low-tariff, _2 = normal/high-tariff (NL convention).
d1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered low
d2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered high
r1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned low
r2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned high
# Computed monetary amounts for the period (in ``currency``).
import_cost: Mapped[float] = mapped_column(Float, nullable=False)
export_revenue: Mapped[float] = mapped_column(Float, nullable=False)
net_cost: Mapped[float] = mapped_column(Float, nullable=False)
# ISO 4217 currency code matching the contract.
currency: Mapped[str] = mapped_column(String(8), nullable=False)
# Full snapshot of the pricing inputs used during computation. This makes
# each row self-contained and auditable even if the contract is later changed.
pricing: Mapped[dict] = mapped_column(JSON, nullable=False)
# FK to the exact contract version whose values were used. RESTRICT prevents
# deletion of a version that has cost records attached. Nullable to support
# periods computed in ``degraded`` mode (missing price data).
contract_version_id: Mapped[int | None] = mapped_column(
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
)
# True when the period was computed with incomplete data (missing readings or
# missing price); serves as a flag for later recomputation.
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# UTC timestamp of when this row was computed/inserted.
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship back to the contract version.
contract_version: Mapped["EnergyContractVersion | None"] = relationship(
back_populates="cost_periods"
)
# Index on recorded_at for efficient time-range queries on DSMR readings.
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
# no composite index is needed for single-meter deployments.)
# Index on period_start is covered by the unique constraint (SQLite creates an
# implicit index for UNIQUE columns), so no additional index is required.
# Index on starts_at for TibberPrice is covered by the unique constraint similarly.