"""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. Identity & idempotency are **independent of the DSMR Reader's telegram id** (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. ``recorded_at`` is a real column (not inside the payload) so time-range queries are efficient. 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, 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 ) # 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) # 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.