M7-T01: add meter table + model + energy_cost_period.meter_id + backfill migration
This commit is contained in:
+69
-1
@@ -1,6 +1,7 @@
|
||||
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
||||
|
||||
Five tables:
|
||||
Six tables:
|
||||
- meter: physical electricity meter lifecycle epoch.
|
||||
- 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.
|
||||
@@ -19,6 +20,62 @@ from sqlalchemy.types import JSON
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class Meter(Base):
|
||||
"""One physical electricity meter's installation epoch.
|
||||
|
||||
A ``meter`` record represents the period ``[started_at, ended_at)`` during
|
||||
which a particular physical meter was installed and active. Replacing a meter
|
||||
(swap, home move, etc.) is modelled by closing the current record
|
||||
(``ended_at = swap_timestamp``) and opening a new one
|
||||
(``started_at = swap_timestamp``).
|
||||
|
||||
**Invariant**: for each ``commodity`` there is at most one active meter
|
||||
(``ended_at IS NULL``) at any point in time. The service layer enforces
|
||||
this — no DB-level constraint is added to keep the migration simple and to
|
||||
allow the application to return a meaningful error message.
|
||||
|
||||
``commodity`` defaults to ``"electricity"``; the field is a free-form string
|
||||
(no CHECK constraint) so future commodities (``gas``, ``heating``) can be
|
||||
added without a schema change.
|
||||
|
||||
``reason`` captures why this epoch started — one of ``initial``,
|
||||
``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
|
||||
the application layer controls the allowed set.
|
||||
"""
|
||||
|
||||
__tablename__ = "meter"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Energy commodity this meter measures. Defaults to "electricity".
|
||||
commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")
|
||||
|
||||
# UTC timestamp when this meter epoch starts (inclusive). May be in the past
|
||||
# (retroactive declaration); effective billing start = max(started_at, data start).
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# UTC timestamp when this meter epoch ends (exclusive). NULL = currently active.
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Why this epoch was created. Application-layer validation enforces the
|
||||
# allowed set; no CHECK constraint to keep migrations simple.
|
||||
reason: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# Free-form note (e.g. location, physical meter id, reason details).
|
||||
note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
# UTC timestamp of when this row was created.
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
|
||||
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
|
||||
back_populates="meter", cascade="save-update, merge"
|
||||
)
|
||||
|
||||
|
||||
class DsmrReading(Base):
|
||||
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
||||
|
||||
@@ -217,6 +274,14 @@ class EnergyCostPeriod(Base):
|
||||
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
|
||||
# FK to the meter epoch this period belongs to. RESTRICT prevents deletion of
|
||||
# a meter that still has attributed cost periods. Nullable for backwards
|
||||
# compatibility (pre-M7 rows) and degraded periods where the meter was not
|
||||
# determinable.
|
||||
meter_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("meter.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)
|
||||
@@ -229,6 +294,9 @@ class EnergyCostPeriod(Base):
|
||||
back_populates="cost_periods"
|
||||
)
|
||||
|
||||
# Relationship back to the meter epoch.
|
||||
meter: Mapped["Meter | 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;
|
||||
|
||||
Reference in New Issue
Block a user