"""Protocol-agnostic source, channel, and meter-binding identity models.""" from __future__ import annotations import uuid as _uuid from datetime import datetime from decimal import Decimal from typing import TYPE_CHECKING from sqlalchemy import ( Boolean, CheckConstraint, 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" ) warmtelink_readings: Mapped[list["WarmteLinkReading"]] = relationship( back_populates="channel", cascade="save-update, merge" ) __table_args__ = ( UniqueConstraint("source_id", "channel_key", name="uq_meter_source_channel_source_key"), ) class WarmteLinkReading(Base): """One accepted scalar cumulative reading from a WarmteLink channel.""" __tablename__ = "warmtelink_reading" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) channel_id: Mapped[int] = mapped_column( ForeignKey("meter_source_channel.id", ondelete="RESTRICT"), nullable=False ) # These timestamps retain their UTC-aware application semantics. SQLite # stores them without an offset, so callers must always supply aware UTC. recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # SQLite's ORM Numeric path can exactly round-trip this 12-integer-digit # range at scale 3. That is ample for a long-lived cumulative meter while # retaining the protocol's 0.001 resolution without float conversion. value: Mapped[Decimal] = mapped_column(Numeric(15, 3), nullable=False) unit: Mapped[str] = mapped_column(String(32), nullable=False) quality: Mapped[str] = mapped_column(String(32), nullable=False) equipment_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) channel: Mapped["MeterSourceChannel"] = relationship(back_populates="warmtelink_readings") __table_args__ = ( CheckConstraint( "quality IN ('valid', 'invalid', 'unverifiable')", name="ck_warmtelink_reading_quality", ), UniqueConstraint("channel_id", "recorded_at", name="uq_warmtelink_reading_channel_recorded_at"), Index("ix_warmtelink_reading_recorded_at", "recorded_at"), ) 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)