M8-T01: add meter source identity schema
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user