M8-T01: add meter source identity schema

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent 43c2ddce1a
commit 009856a50d
9 changed files with 644 additions and 3 deletions
+4
View File
@@ -5,12 +5,16 @@ from app.models.config import AppConfigEntry
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
__all__ = [
"AppConfigEntry",
"AuthSession",
"AuthUser",
"Location",
"MeterSource",
"MeterSourceBinding",
"MeterSourceChannel",
"PooRecord",
"PublicIPHistory",
"PublicIPState",
+15 -1
View File
@@ -13,12 +13,12 @@ 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.types import JSON
from app.db import Base
from app.models.meter_source import MeterSourceBinding
def _uuid4_str() -> str:
@@ -85,6 +85,10 @@ class Meter(Base):
back_populates="meter", cascade="save-update, merge"
)
source_bindings: Mapped[list["MeterSourceBinding"]] = relationship(
back_populates="meter", cascade="save-update, merge"
)
class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob.
@@ -292,6 +296,12 @@ class EnergyCostPeriod(Base):
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
)
# Nullable while M8 adopts historical DSMR rows. Future normal periods
# will point at the binding that supplied both cumulative endpoints.
source_binding_id: Mapped[int | None] = mapped_column(
ForeignKey("meter_source_binding.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)
@@ -307,6 +317,10 @@ class EnergyCostPeriod(Base):
# Relationship back to the meter epoch.
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")
source_binding: Mapped["MeterSourceBinding | 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;
+127
View File
@@ -0,0 +1,127 @@
"""Protocol-agnostic source, channel, and meter-binding identity models."""
from __future__ import annotations
import uuid as _uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db import Base
if TYPE_CHECKING:
from app.models.energy import EnergyCostPeriod, Meter
def _uuid4_str() -> str:
return str(_uuid.uuid4())
def half_open_intervals_overlap(
started_at: datetime,
ended_at: datetime | None,
other_started_at: datetime,
other_ended_at: datetime | None,
) -> bool:
"""Return whether two ``[started_at, ended_at)`` intervals overlap.
``None`` denotes an open-ended interval. Equal boundaries do not overlap,
which lets a source binding hand off at one exact timestamp.
"""
return (other_ended_at is None or started_at < other_ended_at) and (
ended_at is None or other_started_at < ended_at
)
class MeterSource(Base):
"""A configured protocol connection that discovers one or more channels."""
__tablename__ = "meter_source"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uuid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=_uuid4_str)
name: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(64), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(String(1024), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
channels: Mapped[list["MeterSourceChannel"]] = relationship(
back_populates="source", cascade="save-update, merge"
)
class MeterSourceChannel(Base):
"""A stable cumulative measurement identity discovered from a source."""
__tablename__ = "meter_source_channel"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uuid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=_uuid4_str)
source_id: Mapped[int] = mapped_column(
ForeignKey("meter_source.id", ondelete="RESTRICT"), nullable=False, index=True
)
channel_key: Mapped[str] = mapped_column(String(128), nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
suggested_commodity: Mapped[str | None] = mapped_column(String(32), nullable=True)
unit: Mapped[str] = mapped_column(String(32), nullable=False)
device_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
latest_value: Mapped[float | None] = mapped_column(Numeric(20, 6), nullable=True)
latest_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
latest_quality: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
source: Mapped["MeterSource"] = relationship(back_populates="channels")
bindings: Mapped[list["MeterSourceBinding"]] = relationship(
back_populates="channel", cascade="save-update, merge"
)
__table_args__ = (
UniqueConstraint("source_id", "channel_key", name="uq_meter_source_channel_source_key"),
)
class MeterSourceBinding(Base):
"""Connect one source channel to one physical meter for a half-open window."""
__tablename__ = "meter_source_binding"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uuid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=_uuid4_str)
meter_id: Mapped[int] = mapped_column(
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=False, index=True
)
channel_id: Mapped[int] = mapped_column(
ForeignKey("meter_source_channel.id", ondelete="RESTRICT"), nullable=False, index=True
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
meter: Mapped["Meter"] = relationship(back_populates="source_bindings")
channel: Mapped["MeterSourceChannel"] = relationship(back_populates="bindings")
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="source_binding", cascade="save-update, merge", passive_deletes="all"
)
Index("ix_meter_source_kind_enabled", MeterSource.kind, MeterSource.enabled)