M8-T08: add WarmteLink reading schema
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""add normalized WarmteLink scalar reading history
|
||||
|
||||
Revision ID: 20260822_17_warmtelink_readings
|
||||
Revises: 20260822_16_dsmr_source_adoption
|
||||
Create Date: 2026-08-22 00:00:00.000000
|
||||
|
||||
The upgrade is additive: existing business rows are neither changed nor
|
||||
removed. The downgrade is schema-only and is exercised only on isolated test
|
||||
databases.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260822_17_warmtelink_readings"
|
||||
down_revision: Union[str, None] = "20260822_16_dsmr_source_adoption"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"warmtelink_reading",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("channel_id", sa.Integer(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("value", sa.Numeric(precision=15, scale=3), nullable=False),
|
||||
sa.Column("unit", sa.String(length=32), nullable=False),
|
||||
sa.Column("quality", sa.String(length=32), nullable=False),
|
||||
sa.Column("equipment_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"quality IN ('valid', 'invalid', 'unverifiable')",
|
||||
name="ck_warmtelink_reading_quality",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["channel_id"], ["meter_source_channel.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"channel_id", "recorded_at", name="uq_warmtelink_reading_channel_recorded_at"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_warmtelink_reading_recorded_at", "warmtelink_reading", ["recorded_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_warmtelink_reading_recorded_at", table_name="warmtelink_reading")
|
||||
op.drop_table("warmtelink_reading")
|
||||
@@ -4,10 +4,12 @@ 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,
|
||||
@@ -93,12 +95,48 @@ class MeterSourceChannel(Base):
|
||||
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."""
|
||||
|
||||
|
||||
@@ -577,7 +577,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
||||
|
||||
### M8-T08 — 建立 WarmteLink 标量读数表
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Status**: `done`
|
||||
- **Depends**: M8-T07
|
||||
- **Context**: WarmteLink 与 DSMR payload 结构不同,建立可精确计算的 Decimal 标量历史表。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
APP_BASELINE_REVISION = "20260822_16_dsmr_source_adoption"
|
||||
APP_BASELINE_REVISION = "20260822_17_warmtelink_readings"
|
||||
|
||||
|
||||
class AppDatabaseAdoptionError(RuntimeError):
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Schema and migration tests for the normalized WarmteLink reading table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sqlalchemy.exc
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, event, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.meter_source import MeterSourceChannel, WarmteLinkReading
|
||||
from scripts.app_db_adopt import APP_BASELINE_REVISION
|
||||
|
||||
REVISION_16 = "20260822_16_dsmr_source_adoption"
|
||||
REVISION_17 = "20260822_17_warmtelink_readings"
|
||||
|
||||
|
||||
def _config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
def _engine(database_url: str):
|
||||
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _foreign_keys(connection, _record) -> None:
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _add_channel(connection, timestamp: datetime, channel_key: str = "heating-total") -> int:
|
||||
source_id = connection.execute(
|
||||
text("SELECT id FROM meter_source WHERE kind = 'dsmr_mqtt'")
|
||||
).scalar_one()
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO meter_source_channel "
|
||||
"(uuid, source_id, channel_key, label, suggested_commodity, unit, device_type, "
|
||||
"fingerprint, latest_value, latest_at, latest_quality, created_at, updated_at) "
|
||||
"VALUES (:uuid, :source_id, :channel_key, 'WarmteLink channel', 'heating', 'GJ', "
|
||||
"'warmtelink', NULL, NULL, NULL, NULL, :at, :at)"
|
||||
),
|
||||
{
|
||||
"uuid": f"{channel_key[:8]:0<8}-0000-4000-8000-000000000000",
|
||||
"source_id": source_id,
|
||||
"channel_key": channel_key,
|
||||
"at": timestamp,
|
||||
},
|
||||
)
|
||||
return int(connection.execute(text("SELECT last_insert_rowid()")).scalar_one())
|
||||
|
||||
|
||||
def _insert_reading(connection, channel_id: int, timestamp: datetime, value: Decimal) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO warmtelink_reading "
|
||||
"(channel_id, recorded_at, received_at, value, unit, quality, equipment_fingerprint) "
|
||||
"VALUES (:channel_id, :recorded_at, :received_at, :value, 'GJ', 'unverifiable', :fingerprint)"
|
||||
),
|
||||
{
|
||||
"channel_id": channel_id,
|
||||
"recorded_at": timestamp,
|
||||
"received_at": timestamp,
|
||||
"value": str(value),
|
||||
"fingerprint": "a" * 64,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_path: Path):
|
||||
empty_url = f"sqlite:///{tmp_path / 'warmtelink_empty.db'}"
|
||||
empty_config = _config(empty_url)
|
||||
command.upgrade(empty_config, "head")
|
||||
command.upgrade(empty_config, "head")
|
||||
empty_engine = _engine(empty_url)
|
||||
try:
|
||||
with empty_engine.connect() as connection:
|
||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_17
|
||||
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||
finally:
|
||||
empty_engine.dispose()
|
||||
|
||||
database_url = f"sqlite:///{tmp_path / 'warmtelink_upgrade.db'}"
|
||||
config = _config(database_url)
|
||||
timestamp = datetime(2026, 8, 22, tzinfo=timezone.utc)
|
||||
|
||||
command.upgrade(config, REVISION_16)
|
||||
engine = _engine(database_url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
channel_id = _add_channel(connection, timestamp)
|
||||
before_counts = {
|
||||
table: int(connection.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
|
||||
for table in ("dsmr_reading", "meter_source", "meter_source_channel", "meter_source_binding")
|
||||
}
|
||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_16
|
||||
assert channel_id > 0
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(config, "head")
|
||||
command.upgrade(config, "head")
|
||||
engine = _engine(database_url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
after_counts = {
|
||||
table: int(connection.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
|
||||
for table in before_counts
|
||||
}
|
||||
assert after_counts == before_counts
|
||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_17
|
||||
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_warmtelink_reading_constraints_indexes_and_decimal_round_trip(tmp_path: Path):
|
||||
database_url = f"sqlite:///{tmp_path / 'warmtelink_constraints.db'}"
|
||||
config = _config(database_url)
|
||||
command.upgrade(config, "head")
|
||||
timestamp = datetime(2026, 8, 22, 10, 30, tzinfo=timezone.utc)
|
||||
engine = _engine(database_url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
first_channel = _add_channel(connection, timestamp)
|
||||
second_channel = _add_channel(connection, timestamp, "hot-water-total")
|
||||
_insert_reading(connection, first_channel, timestamp, Decimal("12345.678"))
|
||||
_insert_reading(connection, second_channel, timestamp, Decimal("4.125"))
|
||||
|
||||
with Session(engine) as session:
|
||||
reading = session.query(WarmteLinkReading).filter_by(channel_id=first_channel).one()
|
||||
assert reading.value == Decimal("12345.678")
|
||||
assert reading.recorded_at == timestamp
|
||||
assert reading.received_at == timestamp
|
||||
session.add(
|
||||
WarmteLinkReading(
|
||||
channel_id=first_channel,
|
||||
recorded_at=timestamp + timedelta(minutes=1),
|
||||
received_at=timestamp + timedelta(minutes=1),
|
||||
value=Decimal("234.567"),
|
||||
unit="GJ",
|
||||
quality="valid",
|
||||
equipment_fingerprint="c" * 64,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
assert session.query(WarmteLinkReading).filter_by(value=Decimal("234.567")).one().value == Decimal(
|
||||
"234.567"
|
||||
)
|
||||
|
||||
# Numeric(15, 3) promises twelve integer digits. Exercise its
|
||||
# declared upper boundary through the SQLite ORM path, rather than
|
||||
# a raw SQL value that could bypass its Decimal result processor.
|
||||
boundary = Decimal("999999999999.999")
|
||||
session.add(
|
||||
WarmteLinkReading(
|
||||
channel_id=second_channel,
|
||||
recorded_at=timestamp + timedelta(minutes=1),
|
||||
received_at=timestamp + timedelta(minutes=1),
|
||||
value=boundary,
|
||||
unit="GJ",
|
||||
quality="valid",
|
||||
equipment_fingerprint="d" * 64,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
assert (
|
||||
session.query(WarmteLinkReading)
|
||||
.filter_by(channel_id=second_channel, recorded_at=timestamp + timedelta(minutes=1))
|
||||
.one()
|
||||
.value
|
||||
== boundary
|
||||
)
|
||||
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
_insert_reading(connection, first_channel, timestamp, Decimal("5.000"))
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO warmtelink_reading "
|
||||
"(channel_id, recorded_at, received_at, value, unit, quality, equipment_fingerprint) "
|
||||
"VALUES (:channel_id, :at, :at, 1.000, 'GJ', 'estimated-valid', :fingerprint)"
|
||||
),
|
||||
{"channel_id": first_channel, "at": timestamp, "fingerprint": "b" * 64},
|
||||
)
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("DELETE FROM meter_source_channel WHERE id = :id"), {"id": first_channel})
|
||||
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text("DELETE FROM meter_source WHERE id = (SELECT source_id FROM meter_source_channel WHERE id = :id)"),
|
||||
{"id": first_channel},
|
||||
)
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = {column["name"]: column for column in inspector.get_columns("warmtelink_reading")}
|
||||
assert columns["value"]["type"].precision == 15
|
||||
assert columns["value"]["type"].scale == 3
|
||||
assert "raw_telegram" not in columns
|
||||
assert "equipment_identifier" not in columns
|
||||
assert "raw_identifier" not in columns
|
||||
assert {tuple(item["column_names"]) for item in inspector.get_unique_constraints("warmtelink_reading")} >= {
|
||||
("channel_id", "recorded_at"),
|
||||
}
|
||||
foreign_key = inspector.get_foreign_keys("warmtelink_reading")[0]
|
||||
assert foreign_key["constrained_columns"] == ["channel_id"]
|
||||
assert foreign_key["referred_table"] == "meter_source_channel"
|
||||
assert foreign_key["options"]["ondelete"] == "RESTRICT"
|
||||
assert "ix_warmtelink_reading_recorded_at" in {
|
||||
index["name"] for index in inspector.get_indexes("warmtelink_reading")
|
||||
}
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_warmtelink_reading_model_uses_restrictive_relationship_and_aware_columns():
|
||||
relationship = MeterSourceChannel.warmtelink_readings.property
|
||||
assert "delete" not in relationship.cascade
|
||||
assert "delete-orphan" not in relationship.cascade
|
||||
assert WarmteLinkReading.__table__.c.recorded_at.type.timezone is True
|
||||
assert WarmteLinkReading.__table__.c.received_at.type.timezone is True
|
||||
assert APP_BASELINE_REVISION == REVISION_17
|
||||
|
||||
|
||||
def test_warmtelink_reading_downgrade_is_schema_only_on_temporary_database(tmp_path: Path):
|
||||
database_url = f"sqlite:///{tmp_path / 'warmtelink_downgrade.db'}"
|
||||
config = _config(database_url)
|
||||
command.upgrade(config, "head")
|
||||
command.downgrade(config, REVISION_16)
|
||||
|
||||
engine = _engine(database_url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
assert "warmtelink_reading" not in set(inspect(engine).get_table_names())
|
||||
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_16
|
||||
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user