M8-T03: adopt DSMR history into meter sources

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent a78401c2ef
commit 28486a83c7
7 changed files with 628 additions and 29 deletions
@@ -0,0 +1,269 @@
"""adopt historical DSMR rows into the source and binding model
Revision ID: 20260822_16_dsmr_source_adoption
Revises: 20260822_15_meter_sources
Create Date: 2026-08-22 00:00:00.000000
The upgrade is deliberately data-preserving: it creates one migration-owned
DSMR source/channel, moves the telegram identifier to ``telegram_id``, and
audits every reading and cost row before committing. Old ``app_config`` rows,
payload JSON, and cost snapshots are never deleted or rewritten.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260822_16_dsmr_source_adoption"
down_revision: Union[str, None] = "20260822_15_meter_sources"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _as_bool(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
def _iso_now() -> str:
return datetime.now(tz=timezone.utc).replace(tzinfo=None).isoformat(sep=" ")
def _config(connection: sa.Connection) -> dict[str, object]:
rows = connection.execute(sa.text("SELECT key, value FROM app_config")).all()
values = {str(key): str(value) for key, value in rows}
# An unconfigured historical DSMR installation needs a disabled identity,
# not guessed connection details. Preserve every legacy value we model
# when any legacy DSMR/MQTT configuration was explicitly present.
legacy_keys = {
"MQTT_BROKER_HOST", "MQTT_BROKER_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
"MQTT_TLS_ENABLED", "DSMR_MQTT_TOPIC", "DSMR_TARIFF_TOPIC", "DSMR_SAMPLE_INTERVAL_S",
}
if not legacy_keys & values.keys():
return {}
return {
"broker_host": values.get("MQTT_BROKER_HOST", ""),
"broker_port": int(values.get("MQTT_BROKER_PORT", "1883")),
"username": values.get("MQTT_USERNAME", ""),
"password": values.get("MQTT_PASSWORD", ""),
"tls_enabled": _as_bool(values.get("MQTT_TLS_ENABLED")),
"topic": values.get("DSMR_MQTT_TOPIC", "dsmr/json"),
"tariff_topic": values.get("DSMR_TARIFF_TOPIC", "dsmr/meter-stats/electricity_tariff"),
"sample_interval_s": int(values.get("DSMR_SAMPLE_INTERVAL_S", "10")),
}
def _count(connection: sa.Connection, table: str) -> int:
return int(connection.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
def upgrade() -> None:
connection = op.get_bind()
readings_before = _count(connection, "dsmr_reading")
costs_before = _count(connection, "energy_cost_period")
sources_before = _count(connection, "meter_source")
channels_before = _count(connection, "meter_source_channel")
bindings_before = _count(connection, "meter_source_binding")
now = _iso_now()
# A source exists even without historical configuration/readings. It stays
# disabled unless the old explicit DSMR switch was enabled, so no broker or
# topic is guessed at runtime.
config = _config(connection)
source_result = connection.execute(
sa.text(
"INSERT INTO meter_source "
"(uuid, name, kind, enabled, config, status, last_seen_at, last_error, created_at, updated_at) "
"VALUES (:uuid, :name, 'dsmr_mqtt', :enabled, :config, 'unknown', NULL, NULL, :now, :now)"
),
{
"uuid": str(uuid.uuid4()),
"name": "Migrated DSMR source",
"enabled": _as_bool(
connection.execute(
sa.text("SELECT value FROM app_config WHERE key = 'DSMR_INGEST_ENABLED'")
).scalar_one_or_none()
),
"config": __import__("json").dumps(config),
"now": now,
},
)
source_id = source_result.lastrowid
if source_id is None:
raise RuntimeError("DSMR source adoption failed to create a source")
channel_result = connection.execute(
sa.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, 'electricity-total', 'DSMR electricity total', 'electricity', "
"'kWh', NULL, NULL, NULL, NULL, NULL, :now, :now)"
),
{"uuid": str(uuid.uuid4()), "source_id": source_id, "now": now},
)
channel_id = channel_result.lastrowid
if channel_id is None:
raise RuntimeError("DSMR source adoption failed to create an electricity channel")
if _count(connection, "meter_source") != sources_before + 1:
raise RuntimeError("DSMR source adoption source row-count audit failed")
if _count(connection, "meter_source_channel") != channels_before + 1:
raise RuntimeError("DSMR source adoption channel row-count audit failed")
# Rename/add while nullable, back-fill all rows, then make the FK non-null
# and replace the legacy timestamp-only uniqueness in a SQLite batch rebuild.
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
batch_op.alter_column("source_id", new_column_name="telegram_id")
batch_op.add_column(sa.Column("meter_source_id", sa.Integer(), nullable=True))
connection.execute(
sa.text("UPDATE dsmr_reading SET meter_source_id = :source_id WHERE meter_source_id IS NULL"),
{"source_id": source_id},
)
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
batch_op.drop_constraint("uq_dsmr_reading_recorded_at", type_="unique")
batch_op.alter_column("meter_source_id", existing_type=sa.Integer(), nullable=False)
batch_op.create_foreign_key(
"fk_dsmr_reading_meter_source_id", "meter_source", ["meter_source_id"], ["id"],
ondelete="RESTRICT",
)
batch_op.create_unique_constraint(
"uq_dsmr_reading_source_recorded_at", ["meter_source_id", "recorded_at"]
)
batch_op.create_index("ix_dsmr_reading_meter_source_id", ["meter_source_id"])
adopted_readings = int(
connection.execute(
sa.text("SELECT COUNT(*) FROM dsmr_reading WHERE meter_source_id = :source_id"),
{"source_id": source_id},
).scalar_one()
)
if adopted_readings != readings_before:
raise RuntimeError("DSMR source adoption reading source audit failed")
# Bind each electricity meter only where it overlaps the actual DSMR data.
data_window = connection.execute(
sa.text("SELECT MIN(recorded_at), MAX(recorded_at) FROM dsmr_reading")
).one()
expected_binding_count = 0
if data_window[0] is not None:
meters = connection.execute(
sa.text(
"SELECT id, started_at, ended_at FROM meter WHERE commodity = 'electricity' "
"ORDER BY started_at, id"
)
).all()
for meter_id, started_at, ended_at in meters:
# Intersect [meter start, meter end) with the inclusive historical
# samples. A closed boundary at the final sample remains valid for
# the preceding interval; an empty intersection gets no fake binding.
if started_at > data_window[1] or (ended_at is not None and ended_at <= data_window[0]):
continue
expected_binding_count += 1
binding_start = max(started_at, data_window[0])
binding_end = ended_at
connection.execute(
sa.text(
"INSERT INTO meter_source_binding "
"(uuid, meter_id, channel_id, started_at, ended_at, created_at, updated_at) "
"VALUES (:uuid, :meter_id, :channel_id, :started_at, :ended_at, :now, :now)"
),
{
"uuid": str(uuid.uuid4()), "meter_id": meter_id, "channel_id": channel_id,
"started_at": binding_start, "ended_at": binding_end, "now": now,
},
)
# A cost period may be linked only if exactly one binding covers both its
# start and end. Historical boundary/unknown rows remain auditable but are
# explicitly degraded instead of being silently attributed to a current meter.
periods = connection.execute(
sa.text("SELECT id, meter_id, period_start, degraded FROM energy_cost_period")
).all()
resolvable_normal_periods: dict[int, int] = {}
unresolved_period_ids: set[int] = set()
for period_id, meter_id, period_start, degraded_before in periods:
candidates = []
if meter_id is not None:
candidates = connection.execute(
sa.text(
"SELECT id FROM meter_source_binding "
"WHERE meter_id = :meter_id AND started_at <= :start "
"AND (ended_at IS NULL OR julianday(ended_at) > julianday(:start, '+15 minutes'))"
),
{"meter_id": meter_id, "start": period_start},
).all()
if len(candidates) == 1:
if not degraded_before:
resolvable_normal_periods[period_id] = candidates[0][0]
connection.execute(
sa.text("UPDATE energy_cost_period SET source_binding_id = :binding_id WHERE id = :id"),
{"binding_id": candidates[0][0], "id": period_id},
)
else:
unresolved_period_ids.add(period_id)
connection.execute(
sa.text(
"UPDATE energy_cost_period SET degraded = 1, source_binding_id = NULL WHERE id = :id"
),
{"id": period_id},
)
readings_after = _count(connection, "dsmr_reading")
costs_after = _count(connection, "energy_cost_period")
if readings_after != readings_before or costs_after != costs_before:
raise RuntimeError("DSMR source adoption row-count audit failed")
if _count(connection, "meter_source_binding") != bindings_before + expected_binding_count:
raise RuntimeError("DSMR source adoption binding row-count audit failed")
if int(
connection.execute(
sa.text("SELECT COUNT(*) FROM meter_source_binding WHERE channel_id = :channel_id"),
{"channel_id": channel_id},
).scalar_one()
) != expected_binding_count:
raise RuntimeError("DSMR source adoption binding channel audit failed")
for period_id, binding_id in resolvable_normal_periods.items():
bound, degraded = connection.execute(
sa.text("SELECT source_binding_id, degraded FROM energy_cost_period WHERE id = :id"),
{"id": period_id},
).one()
if bound != binding_id or degraded:
raise RuntimeError("DSMR source adoption resolvable cost audit failed")
if unresolved_period_ids:
unresolved_count = int(
connection.execute(
sa.text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE id IN :period_ids AND (degraded != 1 OR source_binding_id IS NOT NULL)"
).bindparams(sa.bindparam("period_ids", expanding=True)),
{"period_ids": list(unresolved_period_ids)},
).scalar_one()
)
if unresolved_count:
raise RuntimeError("DSMR source adoption unresolved cost audit failed")
orphan_rows = connection.execute(sa.text("PRAGMA foreign_key_check")).all()
if orphan_rows:
raise RuntimeError("DSMR source adoption foreign-key audit failed")
normal_unbound = int(
connection.execute(
sa.text("SELECT COUNT(*) FROM energy_cost_period WHERE degraded = 0 AND source_binding_id IS NULL")
).scalar_one()
)
if normal_unbound:
raise RuntimeError(f"DSMR source adoption left {normal_unbound} normal cost period(s) unbound")
if _count(connection, "meter_source") < 1:
raise RuntimeError("DSMR source adoption source audit failed")
def downgrade() -> None:
# Schema-only downgrade for isolated test databases. It intentionally does
# not delete migration-created source/channel/binding rows.
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
batch_op.drop_index("ix_dsmr_reading_meter_source_id")
batch_op.drop_constraint("uq_dsmr_reading_source_recorded_at", type_="unique")
batch_op.drop_constraint("fk_dsmr_reading_meter_source_id", type_="foreignkey")
batch_op.drop_column("meter_source_id")
batch_op.alter_column("telegram_id", new_column_name="source_id")
batch_op.create_unique_constraint("uq_dsmr_reading_recorded_at", ["recorded_at"])
+34 -10
View File
@@ -13,8 +13,8 @@ from __future__ import annotations
import uuid as _uuid import uuid as _uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, event, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.types import JSON from sqlalchemy.types import JSON
from app.db import Base from app.db import Base
@@ -97,8 +97,9 @@ class DsmrReading(Base):
(that field overflows and must be manually reset to zero — a known DSMR (that field overflows and must be manually reset to zero — a known DSMR
quirk — so relying on it for uniqueness risks silently dropping new data). quirk — so relying on it for uniqueness risks silently dropping new data).
The table's own autoincrement ``id`` PK is the stable internal identity, and The table's own autoincrement ``id`` PK is the stable internal identity, and
``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a ``(meter_source_id, recorded_at)`` is the UNIQUE de-duplication key: each
single P1 meter emits exactly one telegram per timestamp. configured P1 source emits at most one telegram per timestamp, while
different sources may legitimately emit at the same instant.
``recorded_at`` is a real column (not inside the payload) so time-range ``recorded_at`` is a real column (not inside the payload) so time-range
queries are efficient. The entire telegram frame is stored verbatim in queries are efficient. The entire telegram frame is stored verbatim in
@@ -110,21 +111,44 @@ class DsmrReading(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent # UTC timestamp of the sample. Idempotency is per configured source, so
# idempotency key). The unique index also serves time-range queries. # distinct P1 sources may legitimately emit at the same instant.
recorded_at: Mapped[datetime] = mapped_column( recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
DateTime(timezone=True), nullable=False, unique=True
)
# Telegram's own id (DSMR Reader assigns it). Stored only as a reference / # Telegram's own id (DSMR Reader assigns it). Stored only as a reference /
# debugging aid — NOT used for uniqueness or idempotency (it overflows and # debugging aid — NOT used for uniqueness or idempotency (it overflows and
# gets reset to zero). Nullable because some DSMR sources may not emit one. # gets reset to zero). Nullable because some DSMR sources may not emit one.
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True) telegram_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Compatibility for the pre-M8 ingest implementation. This is an ORM
# alias only; the physical database column is ``telegram_id``.
source_id = synonym("telegram_id")
# The configured source is the durable identity of the cumulative reading
# stream. It is non-null after the revision-16 historical adoption.
meter_source_id: Mapped[int] = mapped_column(
ForeignKey("meter_source.id", ondelete="RESTRICT"), nullable=False, index=True
)
# Full telegram frame as a JSON object; values are typically JSON strings # Full telegram frame as a JSON object; values are typically JSON strings
# (e.g. "20915.154") — callers must cast to Decimal before arithmetic. # (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
payload: Mapped[dict] = mapped_column(JSON, nullable=False) payload: Mapped[dict] = mapped_column(JSON, nullable=False)
__table_args__ = (
UniqueConstraint(
"meter_source_id", "recorded_at", name="uq_dsmr_reading_source_recorded_at"
),
)
@event.listens_for(DsmrReading, "before_insert")
def _supply_legacy_dsmr_source(_mapper, connection, target: DsmrReading) -> None:
"""Keep the pre-T04 single-source writer working during the schema handoff."""
if target.meter_source_id is None:
target.meter_source_id = connection.execute(
text("SELECT id FROM meter_source WHERE kind = 'dsmr_mqtt' ORDER BY id LIMIT 1")
).scalar_one()
class EnergyContract(Base): class EnergyContract(Base):
"""Contract head: a named energy contract with a chosen pricing strategy. """Contract head: a named energy contract with a chosen pricing strategy.
+6 -1
View File
@@ -363,7 +363,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T03 — 将 DSMR 历史迁入 Source / Binding [structural] ### M8-T03 — 将 DSMR 历史迁入 Source / Binding [structural]
- **Status**: `todo` - **Status**: `done`
- **Depends**: M8-T02 - **Depends**: M8-T02
- **Context**: 先把既有电力链路安全迁到统一模型,之后才能让 runtime 和计费真正按 source 工作。 - **Context**: 先把既有电力链路安全迁到统一模型,之后才能让 runtime 和计费真正按 source 工作。
@@ -373,6 +373,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- `modify scripts/app_db_adopt.py` - `modify scripts/app_db_adopt.py`
- `create tests/test_dsmr_source_migration.py` - `create tests/test_dsmr_source_migration.py`
- `modify tests/test_energy_models.py` - `modify tests/test_energy_models.py`
- `modify tests/test_meter_sources.py`
**Steps** **Steps**
1. 把 ORM `DsmrReading.source_id` 重命名为 nullable `telegram_id`,新增 non-null 1. 把 ORM `DsmrReading.source_id` 重命名为 nullable `telegram_id`,新增 non-null
@@ -385,6 +386,8 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。 5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。
6.`APP_BASELINE_REVISION` 同步到 revision 16;所有升级 fixture 仅在 `tmp_path` 中构造,不读取 6.`APP_BASELINE_REVISION` 同步到 revision 16;所有升级 fixture 仅在 `tmp_path` 中构造,不读取
真实 app DB 或 volume。 真实 app DB 或 volume。
7. 把 T01 自身的 revision 14→15 schema 对账测试固定升级到明确 revision 15,不以可继续前进的
`head` 作为 T01 终点;T03 的新 fixture 单独负责 revision 14/15→16 历史回填对账。
**Out of scope / 不要碰** **Out of scope / 不要碰**
- 不删除旧 DSMR `app_config` 行,不改变 MQTT subscription,不改 API 响应。 - 不删除旧 DSMR `app_config` 行,不改变 MQTT subscription,不改 API 响应。
@@ -395,11 +398,13 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] 两个 source 可在同一 timestamp 各存一条 DSMR reading;同 source 重复 timestamp 被拒绝。 - [ ] 两个 source 可在同一 timestamp 各存一条 DSMR reading;同 source 重复 timestamp 被拒绝。
- [ ] `telegram_id` 不参与幂等唯一键,旧 telegram id 完整保留。 - [ ] `telegram_id` 不参与幂等唯一键,旧 telegram id 完整保留。
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 16 head,升级重复运行幂等。 - [ ] `APP_BASELINE_REVISION` 等于唯一 revision 16 head,升级重复运行幂等。
- [ ] T01 schema-only fixture 固定停在 revision 15T03 fixture 到 revision 16,二者职责不随 head 漂移。
- [ ] 历史升级 fixture、空库升级、`pytest``ruff check .` 全绿。 - [ ] 历史升级 fixture、空库升级、`pytest``ruff check .` 全绿。
**Reviewer checklist** **Reviewer checklist**
- 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。 - 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。
- baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。 - baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。
- 前序 migration 测试是否使用明确 revision 边界,而非把历史阶段误写为永久 `head`
- 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。 - 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。
### M8-T04 — DSMR runtime 改为多 Source 配置 [structural] ### M8-T04 — DSMR runtime 改为多 Source 配置 [structural]
+1 -1
View File
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings from app.config import get_settings
APP_BASELINE_REVISION = "20260822_15_meter_sources" APP_BASELINE_REVISION = "20260822_16_dsmr_source_adoption"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
+285
View File
@@ -0,0 +1,285 @@
"""Isolated revision-14/15 fixtures for the DSMR source-adoption migration."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
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
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 _insert_meter(connection, label: str, started: datetime, ended: datetime | None) -> int:
connection.execute(
text(
"INSERT INTO meter (uuid, label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES (:uuid, :label, 'electricity', :started, :ended, 'initial', NULL, :started)"
),
{"uuid": f"{label:0<8}-0000-4000-8000-000000000000", "label": label,
"started": started, "ended": ended},
)
return int(connection.execute(text("SELECT last_insert_rowid()")).scalar_one())
def _insert_contract_version(connection, start: datetime) -> int:
connection.execute(
text(
"INSERT INTO energy_contract (name, kind, active, currency, created_at, updated_at) "
"VALUES ('Historic contract', 'manual', 1, 'EUR', :at, :at)"
),
{"at": start},
)
contract_id = int(connection.execute(text("SELECT last_insert_rowid()")).scalar_one())
connection.execute(
text(
"INSERT INTO energy_contract_version "
"(contract_id, effective_from, effective_to, \"values\", created_at) "
"VALUES (:contract_id, :at, NULL, :values, :at)"
),
{"contract_id": contract_id, "at": start, "values": json.dumps({"historic": True})},
)
return int(connection.execute(text("SELECT last_insert_rowid()")).scalar_one())
def _insert_cost(
connection,
period_start: datetime,
meter_id: int,
contract_version_id: int,
sequence: int,
) -> None:
amount = 2.5 + sequence
connection.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, export_revenue, net_cost, "
"currency, pricing, contract_version_id, meter_id, degraded, computed_at) "
"VALUES (:start, :d1, :d2, :r1, :r2, :import_cost, :export_revenue, :net_cost, "
"'EUR', :pricing, :contract_version_id, :meter, 0, :computed_at)"
),
{
"start": period_start,
"d1": 1.0 + sequence,
"d2": 2.0 + sequence,
"r1": 3.0 + sequence,
"r2": 4.0 + sequence,
"import_cost": amount,
"export_revenue": 0.25 + sequence,
"net_cost": amount - (0.25 + sequence),
"pricing": json.dumps({"historic": True, "sequence": sequence}),
"contract_version_id": contract_version_id,
"meter": meter_id,
"computed_at": period_start + timedelta(seconds=sequence),
},
)
def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'revision_14_history.db'}"
config = _config(database_url)
command.upgrade(config, "20260625_14_meter_uuid")
start = datetime(2026, 8, 1, tzinfo=timezone.utc)
engine = _engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text("INSERT INTO app_config (key, value, updated_at) VALUES (:key, :value, :at)"),
[
{"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start},
{"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start},
{"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start},
{"key": "DSMR_SAMPLE_INTERVAL_S", "value": "15", "at": start},
{"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start},
{"key": "MQTT_BROKER_PORT", "value": "1884", "at": start},
{"key": "MQTT_USERNAME", "value": "historic-user", "at": start},
{"key": "MQTT_PASSWORD", "value": "historic-password", "at": start},
{"key": "MQTT_TLS_ENABLED", "value": "true", "at": start},
{"key": "UNRELATED_CONFIG", "value": "untouched", "at": start},
],
)
config_before = dict(connection.execute(text("SELECT key, value FROM app_config")).all())
for offset, telegram_id in ((0, 77), (20, 78), (40, 77)):
connection.execute(
text("INSERT INTO dsmr_reading (recorded_at, source_id, payload) VALUES (:at, :id, :payload)"),
{"at": start + timedelta(minutes=offset), "id": telegram_id,
"payload": json.dumps({"id": telegram_id, "keep": f"payload-{offset}"})},
)
first = _insert_meter(connection, "meterone", start - timedelta(hours=1), start + timedelta(minutes=20))
second = _insert_meter(connection, "metertwo", start + timedelta(minutes=20), start + timedelta(minutes=40))
third = _insert_meter(connection, "meterthree", start + timedelta(minutes=40), None)
_insert_meter(connection, "nodata", start + timedelta(days=1), None)
contract_version = _insert_contract_version(connection, start - timedelta(days=1))
# One normal period per epoch plus a period ending exactly at each
# replacement boundary. Meter/binding intervals are half-open, so
# the latter must remain unbound/degraded.
_insert_cost(connection, start, first, contract_version, 0)
_insert_cost(connection, start + timedelta(minutes=5), first, contract_version, 1)
_insert_cost(connection, start + timedelta(minutes=21), second, contract_version, 2)
_insert_cost(connection, start + timedelta(minutes=25), second, contract_version, 3)
_insert_cost(connection, start + timedelta(minutes=41), third, contract_version, 4)
cost_before = connection.execute(
text(
"SELECT id, period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, "
"export_revenue, net_cost, currency, pricing, contract_version_id, meter_id, "
"degraded, computed_at FROM energy_cost_period ORDER BY period_start"
)
).mappings().all()
finally:
engine.dispose()
command.upgrade(config, "20260822_16_dsmr_source_adoption")
engine = _engine(database_url)
try:
with engine.connect() as connection:
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
"20260822_16_dsmr_source_adoption"
)
assert connection.execute(text("SELECT COUNT(*) FROM dsmr_reading")).scalar_one() == 3
source = connection.execute(
text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'")
).one()
assert source.enabled == 1
assert json.loads(source.config) == {
"broker_host": "mqtt.example.invalid", "broker_port": 1884,
"username": "historic-user", "password": "historic-password", "tls_enabled": True,
"topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 15,
}
assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr"
assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before
assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77"
assert connection.execute(text("SELECT payload FROM dsmr_reading ORDER BY recorded_at")).scalars().all() == [
json.dumps({"id": 77, "keep": "payload-0"}),
json.dumps({"id": 78, "keep": "payload-20"}),
json.dumps({"id": 77, "keep": "payload-40"}),
]
assert connection.execute(text("SELECT COUNT(*) FROM meter_source_binding")).scalar_one() == 3
assert connection.execute(
text("SELECT COUNT(*) FROM meter_source_binding WHERE meter_id = (SELECT id FROM meter WHERE label = 'nodata')")
).scalar_one() == 0
periods = connection.execute(
text(
"SELECT id, period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, "
"export_revenue, net_cost, currency, pricing, contract_version_id, meter_id, "
"degraded, computed_at, source_binding_id FROM energy_cost_period ORDER BY period_start"
)
).mappings().all()
assert [
{key: value for key, value in period.items() if key not in {"degraded", "source_binding_id"}}
for period in periods
] == [
{key: value for key, value in period.items() if key != "degraded"}
for period in cost_before
]
assert [(period["degraded"], period["source_binding_id"] is not None) for period in periods] == [
(0, True), (1, False), (0, True), (1, False), (0, True)
]
assert [period["period_start"] for period in periods if period["source_binding_id"] is None] == [
(start + timedelta(minutes=5)).isoformat(sep=" "),
(start + timedelta(minutes=25)).isoformat(sep=" "),
]
bound_meter_ids = connection.execute(
text(
"SELECT binding.meter_id FROM energy_cost_period AS period "
"LEFT JOIN meter_source_binding AS binding "
"ON binding.id = period.source_binding_id ORDER BY period.period_start"
)
).scalars().all()
assert bound_meter_ids == [first, None, second, None, third]
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
finally:
engine.dispose()
def test_dsmr_source_timestamp_uniqueness_allows_two_sources(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'two_sources.db'}"
config = _config(database_url)
command.upgrade(config, "20260822_16_dsmr_source_adoption")
engine = _engine(database_url)
timestamp = datetime(2026, 8, 1, tzinfo=timezone.utc)
try:
with engine.begin() as connection:
first_source = connection.execute(text("SELECT id FROM meter_source WHERE kind = 'dsmr_mqtt'")).scalar_one()
assert connection.execute(
text("SELECT enabled FROM meter_source WHERE id = :id"), {"id": first_source}
).scalar_one() == 0
connection.execute(
text(
"INSERT INTO meter_source (uuid, name, kind, enabled, config, status, created_at, updated_at) "
"VALUES ('22222222-2222-4222-8222-222222222222', 'Second DSMR', 'dsmr_mqtt', 0, '{}', "
"'unknown', :at, :at)"
), {"at": timestamp},
)
second_source = connection.execute(text("SELECT last_insert_rowid()")).scalar_one()
for source_id in (first_source, second_source):
connection.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, telegram_id, meter_source_id, payload) "
"VALUES (:at, 9, :source, '{}')"
), {"at": timestamp, "source": source_id},
)
with pytest.raises(sqlalchemy.exc.IntegrityError):
connection.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, telegram_id, meter_source_id, payload) "
"VALUES (:at, 10, :source, '{}')"
), {"at": timestamp, "source": first_source},
)
finally:
engine.dispose()
inspector = inspect(create_engine(database_url))
assert ("meter_source_id", "recorded_at") in {
tuple(item["column_names"]) for item in inspector.get_unique_constraints("dsmr_reading")
}
def test_revision_15_without_legacy_dsmr_config_creates_unconfigured_source(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'revision_15_no_dsmr_config.db'}"
config = _config(database_url)
command.upgrade(config, "20260822_15_meter_sources")
engine = _engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"INSERT INTO app_config (key, value, updated_at) "
"VALUES ('UNRELATED_CONFIG', 'untouched', :at)"
),
{"at": datetime(2026, 8, 1, tzinfo=timezone.utc)},
)
finally:
engine.dispose()
command.upgrade(config, "20260822_16_dsmr_source_adoption")
engine = _engine(database_url)
try:
with engine.connect() as connection:
source = connection.execute(
text("SELECT enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'")
).one()
assert source.enabled == 0
assert json.loads(source.config) == {}
assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == {
"UNRELATED_CONFIG": "untouched"
}
finally:
engine.dispose()
+29 -15
View File
@@ -21,7 +21,7 @@ import pytest
import sqlalchemy.exc import sqlalchemy.exc
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine, event as sa_event, inspect, text from sqlalchemy import UniqueConstraint, create_engine, event as sa_event, inspect, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import Base from app.db import Base
@@ -114,31 +114,42 @@ def test_energy_tables_exist_after_upgrade(energy_db):
def test_dsmr_reading_columns(energy_db): def test_dsmr_reading_columns(energy_db):
"""dsmr_reading must have id, recorded_at (NOT NULL), source_id (nullable), payload (NOT NULL).""" """dsmr_reading stores its telegram id separately from its source identity."""
inspector = inspect(energy_db) inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("dsmr_reading")} columns = {col["name"]: col for col in inspector.get_columns("dsmr_reading")}
assert "id" in columns and not columns["id"]["nullable"] assert "id" in columns and not columns["id"]["nullable"]
assert "recorded_at" in columns and not columns["recorded_at"]["nullable"] assert "recorded_at" in columns and not columns["recorded_at"]["nullable"]
assert "source_id" in columns and columns["source_id"]["nullable"] assert "telegram_id" in columns and columns["telegram_id"]["nullable"]
assert "meter_source_id" in columns and not columns["meter_source_id"]["nullable"]
assert "payload" in columns and not columns["payload"]["nullable"] assert "payload" in columns and not columns["payload"]["nullable"]
def test_dsmr_reading_recorded_at_unique(energy_db): def test_dsmr_reading_source_timestamp_unique(energy_db):
"""dsmr_reading.recorded_at is the UNIQUE de-dup key (telegram-id-independent).""" """DSMR de-duplication is unique per configured source and timestamp."""
inspector = inspect(energy_db) inspector = inspect(energy_db)
unique_constraints = inspector.get_unique_constraints("dsmr_reading") unique_constraints = inspector.get_unique_constraints("dsmr_reading")
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]] assert ("meter_source_id", "recorded_at") in {
assert "recorded_at" in unique_cols, "recorded_at must have a unique constraint" tuple(uc["column_names"]) for uc in unique_constraints
}
foreign_keys = {
tuple(foreign_key["constrained_columns"]): foreign_key
for foreign_key in inspector.get_foreign_keys("dsmr_reading")
}
assert foreign_keys[("meter_source_id",)]["referred_table"] == "meter_source"
assert foreign_keys[("meter_source_id",)]["options"]["ondelete"] == "RESTRICT"
assert "ix_dsmr_reading_meter_source_id" in {
index["name"] for index in inspector.get_indexes("dsmr_reading")
}
def test_dsmr_reading_source_id_not_unique(energy_db): def test_dsmr_reading_telegram_id_not_unique(energy_db):
"""dsmr_reading.source_id (telegram id) must NOT be unique — it overflows/resets, """dsmr_reading.telegram_id must NOT be unique — it overflows/resets,
so it is kept only as a reference value and never relied on for dedup.""" so it is kept only as a reference value and never relied on for dedup."""
inspector = inspect(energy_db) inspector = inspect(energy_db)
unique_constraints = inspector.get_unique_constraints("dsmr_reading") unique_constraints = inspector.get_unique_constraints("dsmr_reading")
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]] unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
assert "source_id" not in unique_cols, "source_id must NOT have a unique constraint" assert "telegram_id" not in unique_cols, "telegram_id must NOT have a unique constraint"
def test_energy_contract_columns(energy_db): def test_energy_contract_columns(energy_db):
@@ -410,12 +421,15 @@ def test_energy_cost_period_meter_id_fk_ondelete_restrict():
) )
def test_dsmr_reading_recorded_at_unique_in_metadata(): def test_dsmr_reading_source_timestamp_unique_in_metadata():
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata, """DsmrReading de-duplicates by source/timestamp, never telegram id."""
and source_id must NOT be unique (decoupled from the telegram id)."""
table = Base.metadata.tables["dsmr_reading"] table = Base.metadata.tables["dsmr_reading"]
assert table.columns["recorded_at"].unique, "recorded_at must be declared unique" assert not table.columns["telegram_id"].unique, "telegram_id must NOT be unique"
assert not table.columns["source_id"].unique, "source_id must NOT be unique" assert any(
tuple(constraint.columns.keys()) == ("meter_source_id", "recorded_at")
for constraint in table.constraints
if isinstance(constraint, UniqueConstraint)
)
def test_tibber_price_starts_at_unique_in_metadata(): def test_tibber_price_starts_at_unique_in_metadata():
+4 -2
View File
@@ -139,7 +139,9 @@ def test_populated_revision_14_upgrades_to_meter_source_head_with_audit(tmp_path
finally: finally:
engine.dispose() engine.dispose()
command.upgrade(config, "head") # This T01 fixture intentionally audits the schema-only revision 15.
# Revision 16 has its own DSMR-history adoption fixture.
command.upgrade(config, "20260822_15_meter_sources")
engine = _engine_with_foreign_keys(database_url) engine = _engine_with_foreign_keys(database_url)
try: try:
@@ -187,7 +189,7 @@ def test_populated_revision_14_upgrades_to_meter_source_head_with_audit(tmp_path
assert cost_fks["contract_version_id"]["referred_table"] == "energy_contract_version" assert cost_fks["contract_version_id"]["referred_table"] == "energy_contract_version"
assert cost_fks["source_binding_id"]["referred_table"] == "meter_source_binding" assert cost_fks["source_binding_id"]["referred_table"] == "meter_source_binding"
command.upgrade(config, "head") command.upgrade(config, "20260822_15_meter_sources")
assert { assert {
table_name: engine.connect().execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() table_name: engine.connect().execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
for table_name in before_counts for table_name in before_counts