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
+1
View File
@@ -20,6 +20,7 @@ from app.models.energy import ( # noqa: F401
TibberPrice, TibberPrice,
EnergyCostPeriod, EnergyCostPeriod,
) )
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel # noqa: F401
config = context.config config = context.config
@@ -0,0 +1,107 @@
"""add protocol-agnostic meter source, channel, and binding tables
Revision ID: 20260822_15_meter_sources
Revises: 20260625_14_meter_uuid
Create Date: 2026-08-22 00:00:00.000000
This revision is additive on upgrade. It deliberately does not backfill
existing DSMR data; that adoption is a later, separately audited migration.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260822_15_meter_sources"
down_revision: Union[str, None] = "20260625_14_meter_uuid"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"meter_source",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=64), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(length=1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid", name="uq_meter_source_uuid"),
)
op.create_index("ix_meter_source_kind_enabled", "meter_source", ["kind", "enabled"])
op.create_table(
"meter_source_channel",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=False),
sa.Column("channel_key", sa.String(length=128), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("suggested_commodity", sa.String(length=32), nullable=True),
sa.Column("unit", sa.String(length=32), nullable=False),
sa.Column("device_type", sa.String(length=64), nullable=True),
sa.Column("fingerprint", sa.String(length=64), nullable=True),
sa.Column("latest_value", sa.Numeric(precision=20, scale=6), nullable=True),
sa.Column("latest_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("latest_quality", sa.String(length=32), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["source_id"], ["meter_source.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid", name="uq_meter_source_channel_uuid"),
sa.UniqueConstraint("source_id", "channel_key", name="uq_meter_source_channel_source_key"),
)
op.create_index("ix_meter_source_channel_source_id", "meter_source_channel", ["source_id"])
op.create_table(
"meter_source_binding",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("meter_id", sa.Integer(), nullable=False),
sa.Column("channel_id", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["meter_id"], ["meter.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(
["channel_id"], ["meter_source_channel.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid", name="uq_meter_source_binding_uuid"),
)
op.create_index("ix_meter_source_binding_meter_id", "meter_source_binding", ["meter_id"])
op.create_index("ix_meter_source_binding_channel_id", "meter_source_binding", ["channel_id"])
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.add_column(sa.Column("source_binding_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
"fk_energy_cost_period_source_binding_id",
"meter_source_binding",
["source_binding_id"],
["id"],
ondelete="RESTRICT",
)
def downgrade() -> None:
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.drop_constraint("fk_energy_cost_period_source_binding_id", type_="foreignkey")
batch_op.drop_column("source_binding_id")
op.drop_index("ix_meter_source_binding_channel_id", table_name="meter_source_binding")
op.drop_index("ix_meter_source_binding_meter_id", table_name="meter_source_binding")
op.drop_table("meter_source_binding")
op.drop_index("ix_meter_source_channel_source_id", table_name="meter_source_channel")
op.drop_table("meter_source_channel")
op.drop_index("ix_meter_source_kind_enabled", table_name="meter_source")
op.drop_table("meter_source")
+4
View File
@@ -5,12 +5,16 @@ from app.models.config import AppConfigEntry
from app.models.location import Location from app.models.location import Location
from app.models.poo import PooRecord from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState from app.models.public_ip import PublicIPHistory, PublicIPState
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
__all__ = [ __all__ = [
"AppConfigEntry", "AppConfigEntry",
"AuthSession", "AuthSession",
"AuthUser", "AuthUser",
"Location", "Location",
"MeterSource",
"MeterSourceBinding",
"MeterSourceChannel",
"PooRecord", "PooRecord",
"PublicIPHistory", "PublicIPHistory",
"PublicIPState", "PublicIPState",
+15 -1
View File
@@ -13,12 +13,12 @@ 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
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON from sqlalchemy.types import JSON
from app.db import Base from app.db import Base
from app.models.meter_source import MeterSourceBinding
def _uuid4_str() -> str: def _uuid4_str() -> str:
@@ -85,6 +85,10 @@ class Meter(Base):
back_populates="meter", cascade="save-update, merge" back_populates="meter", cascade="save-update, merge"
) )
source_bindings: Mapped[list["MeterSourceBinding"]] = relationship(
back_populates="meter", cascade="save-update, merge"
)
class DsmrReading(Base): class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob. """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 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 # True when the period was computed with incomplete data (missing readings or
# missing price); serves as a flag for later recomputation. # missing price); serves as a flag for later recomputation.
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -307,6 +317,10 @@ class EnergyCostPeriod(Base):
# Relationship back to the meter epoch. # Relationship back to the meter epoch.
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods") 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. # 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; # (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)
+1 -1
View File
@@ -283,7 +283,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T01 — 建立 Source / Channel / Binding 基础模型 [structural] ### M8-T01 — 建立 Source / Channel / Binding 基础模型 [structural]
- **Status**: `todo` - **Status**: `done`
- **Depends**: none - **Depends**: none
- **Context**: 先建立协议无关的身份链和时间约束;本卡只改 schema/model,不接运行时或 HTTP。 - **Context**: 先建立协议无关的身份链和时间约束;本卡只改 schema/model,不接运行时或 HTTP。
+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 = "20260625_14_meter_uuid" APP_BASELINE_REVISION = "20260822_15_meter_sources"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
+4
View File
@@ -105,6 +105,9 @@ def test_energy_tables_exist_after_upgrade(energy_db):
"energy_contract_version", "energy_contract_version",
"tibber_price", "tibber_price",
"energy_cost_period", "energy_cost_period",
"meter_source",
"meter_source_channel",
"meter_source_binding",
} }
for table in expected_tables: for table in expected_tables:
assert table in table_names, f"{table!r} missing after upgrade to head" assert table in table_names, f"{table!r} missing after upgrade to head"
@@ -920,6 +923,7 @@ def test_energy_cost_period_meter_id_nullable(energy_db):
fetched = session.get(EnergyCostPeriod, period_id) fetched = session.get(EnergyCostPeriod, period_id)
assert fetched is not None assert fetched is not None
assert fetched.meter_id is None assert fetched.meter_id is None
assert fetched.source_binding_id is None
def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path): def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path):
+384
View File
@@ -0,0 +1,384 @@
"""Schema and model tests for the M8 source/channel/binding identity chain."""
from __future__ import annotations
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 UniqueConstraint, create_engine, event as sa_event, inspect, text
from sqlalchemy.orm import Session
from app.db import Base
from app.models.energy import EnergyCostPeriod, Meter
from app.models.meter_source import (
MeterSource,
MeterSourceBinding,
MeterSourceChannel,
half_open_intervals_overlap,
)
def _alembic_config(database_url: str) -> Config:
config = Config("alembic_app.ini")
config.set_main_option("sqlalchemy.url", database_url)
return config
def _engine_with_foreign_keys(database_url: str):
engine = create_engine(database_url, connect_args={"check_same_thread": False})
@sa_event.listens_for(engine, "connect")
def _enable_foreign_keys(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
@pytest.fixture()
def source_db(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'meter_sources.db'}"
command.upgrade(_alembic_config(database_url), "head")
engine = _engine_with_foreign_keys(database_url)
yield engine
engine.dispose()
def _add_source_chain(session: Session, now: datetime) -> tuple[Meter, MeterSource, MeterSourceChannel]:
meter = Meter(
label="Heating meter",
commodity="heating",
started_at=now,
ended_at=None,
reason="initial",
note=None,
created_at=now,
)
source = MeterSource(
name="WarmteLink",
kind="warmtelink_serial",
created_at=now,
updated_at=now,
)
session.add_all([meter, source])
session.flush()
channel = MeterSourceChannel(
source_id=source.id,
channel_key="heating-total",
label="District heating total",
suggested_commodity="heating",
unit="GJ",
created_at=now,
updated_at=now,
)
session.add(channel)
session.flush()
return meter, source, channel
def test_populated_revision_14_upgrades_to_meter_source_head_with_audit(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'revision_14.db'}"
config = _alembic_config(database_url)
command.upgrade(config, "20260625_14_meter_uuid")
historical_at = datetime(2026, 8, 1, tzinfo=timezone.utc)
engine = _engine_with_foreign_keys(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:recorded_at, :source_id, :payload)"
),
{
"recorded_at": historical_at,
"source_id": 17,
"payload": '{"electricity_delivered_1": "100.000"}',
},
)
connection.execute(
text(
"INSERT INTO meter "
"(uuid, label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES (:uuid, :label, :commodity, :started_at, NULL, :reason, NULL, :created_at)"
),
{
"uuid": "11111111-1111-4111-8111-111111111111",
"label": "Historical meter",
"commodity": "electricity",
"started_at": historical_at,
"reason": "initial",
"created_at": historical_at,
},
)
meter_id = connection.execute(text("SELECT id FROM meter")).scalar_one()
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 (:period_start, 1, 2, 0, 0, 0.5, 0, 0.5, 'EUR', '{}', NULL, "
":meter_id, 0, :computed_at)"
),
{
"period_start": historical_at,
"meter_id": meter_id,
"computed_at": historical_at,
},
)
before_counts = {
table_name: engine.connect().execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
for table_name in ("dsmr_reading", "meter", "energy_cost_period")
}
finally:
engine.dispose()
command.upgrade(config, "head")
engine = _engine_with_foreign_keys(database_url)
try:
inspector = inspect(engine)
assert {"meter_source", "meter_source_channel", "meter_source_binding"} <= set(
inspector.get_table_names()
)
cost_columns = {column["name"] for column in inspector.get_columns("energy_cost_period")}
assert "source_binding_id" in cost_columns
after_counts = {
table_name: engine.connect().execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
for table_name in ("dsmr_reading", "meter", "energy_cost_period")
}
assert after_counts == before_counts
assert engine.connect().execute(text("SELECT version_num FROM alembic_version")).scalar_one() == (
"20260822_15_meter_sources"
)
assert engine.connect().execute(text("PRAGMA foreign_key_check")).all() == []
channel_constraints = inspector.get_unique_constraints("meter_source_channel")
assert {tuple(item["column_names"]) for item in channel_constraints} >= {
("uuid",),
("source_id", "channel_key"),
}
for table_name in ("meter_source", "meter_source_channel", "meter_source_binding"):
assert ("uuid",) in {
tuple(item["column_names"])
for item in inspector.get_unique_constraints(table_name)
}
assert {
"ix_meter_source_kind_enabled",
"ix_meter_source_channel_source_id",
"ix_meter_source_binding_meter_id",
"ix_meter_source_binding_channel_id",
} <= {
index["name"]
for table_name in ("meter_source", "meter_source_channel", "meter_source_binding")
for index in inspector.get_indexes(table_name)
}
cost_fks = {
foreign_key["constrained_columns"][0]: foreign_key
for foreign_key in inspector.get_foreign_keys("energy_cost_period")
}
assert cost_fks["meter_id"]["referred_table"] == "meter"
assert cost_fks["contract_version_id"]["referred_table"] == "energy_contract_version"
assert cost_fks["source_binding_id"]["referred_table"] == "meter_source_binding"
command.upgrade(config, "head")
assert {
table_name: engine.connect().execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
for table_name in before_counts
} == before_counts
finally:
engine.dispose()
def test_empty_database_upgrade_and_repeat_are_idempotent(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'empty_then_repeat.db'}"
config = _alembic_config(database_url)
command.upgrade(config, "head")
command.upgrade(config, "head")
engine = _engine_with_foreign_keys(database_url)
try:
inspector = inspect(engine)
source_columns = {column["name"]: column for column in inspector.get_columns("meter_source")}
channel_unique = {
tuple(constraint["column_names"])
for constraint in inspector.get_unique_constraints("meter_source_channel")
}
binding_fks = {
tuple(foreign_key["constrained_columns"]): foreign_key
for foreign_key in inspector.get_foreign_keys("meter_source_binding")
}
cost_columns = {
column["name"]: column for column in inspector.get_columns("energy_cost_period")
}
cost_fks = {
tuple(foreign_key["constrained_columns"]): foreign_key
for foreign_key in inspector.get_foreign_keys("energy_cost_period")
}
assert source_columns["uuid"]["nullable"] is False
assert source_columns["config"]["nullable"] is False
assert ("source_id", "channel_key") in channel_unique
assert binding_fks[("meter_id",)]["options"]["ondelete"] == "RESTRICT"
assert binding_fks[("channel_id",)]["options"]["ondelete"] == "RESTRICT"
assert cost_columns["source_binding_id"]["nullable"] is True
assert cost_fks[("source_binding_id",)]["options"]["ondelete"] == "RESTRICT"
finally:
engine.dispose()
def test_meter_source_migration_downgrade_is_schema_reversible(tmp_path: Path):
database_url = f"sqlite:///{tmp_path / 'meter_source_downgrade.db'}"
config = _alembic_config(database_url)
command.upgrade(config, "head")
command.downgrade(config, "20260625_14_meter_uuid")
engine = _engine_with_foreign_keys(database_url)
try:
inspector = inspect(engine)
tables = set(inspector.get_table_names())
assert not {"meter_source", "meter_source_channel", "meter_source_binding"} & tables
assert "source_binding_id" not in {
column["name"] for column in inspector.get_columns("energy_cost_period")
}
finally:
engine.dispose()
def test_source_model_defaults_and_channel_unique_constraint(source_db):
now = datetime.now(timezone.utc)
with Session(source_db) as session:
_, source, channel = _add_source_chain(session, now)
session.commit()
assert source.uuid
assert source.enabled is True
assert source.config == {}
assert source.status == "unknown"
assert channel.uuid
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(source_db) as session:
duplicate = MeterSourceChannel(
source_id=source.id,
channel_key="heating-total",
label="Duplicate",
unit="GJ",
created_at=now,
updated_at=now,
)
session.add(duplicate)
session.commit()
def test_foreign_keys_restrict_history_and_no_relationship_delete_cascade(source_db):
now = datetime.now(timezone.utc)
with Session(source_db) as session:
meter, source, channel = _add_source_chain(session, now)
binding = MeterSourceBinding(
meter_id=meter.id,
channel_id=channel.id,
started_at=now,
ended_at=None,
created_at=now,
updated_at=now,
)
session.add(binding)
session.flush()
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.0,
d2_kwh=0.0,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.0,
export_revenue=0.0,
net_cost=0.0,
currency="EUR",
pricing={},
contract_version_id=None,
meter_id=meter.id,
source_binding_id=binding.id,
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
source_id, channel_id, binding_id = source.id, channel.id, binding.id
for table_name, row_id in (
("meter_source", source_id),
("meter_source_channel", channel_id),
("meter_source_binding", binding_id),
):
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(source_db) as session:
session.execute(text(f"DELETE FROM {table_name} WHERE id = :row_id"), {"row_id": row_id})
session.commit()
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(source_db) as session:
binding = session.get(MeterSourceBinding, binding_id)
assert binding is not None
assert len(binding.cost_periods) == 1
session.delete(binding)
session.commit()
with Session(source_db) as session:
binding = session.get(MeterSourceBinding, binding_id)
period = session.execute(
text("SELECT source_binding_id FROM energy_cost_period WHERE id = :period_id"),
{"period_id": 1},
).scalar_one()
assert binding is not None
assert period == binding_id
assert "delete" not in MeterSource.channels.property.cascade
assert "delete" not in MeterSourceChannel.bindings.property.cascade
assert "delete" not in MeterSourceBinding.cost_periods.property.cascade
assert "delete" not in Meter.source_bindings.property.cascade
def test_channel_unique_constraint_metadata_matches_migrated_schema(source_db):
metadata_table = Base.metadata.tables["meter_source_channel"]
metadata_unique_constraints = {
tuple(column.name for column in constraint.columns)
for constraint in metadata_table.constraints
if isinstance(constraint, UniqueConstraint)
}
inspector = inspect(source_db)
schema_unique_constraints = {
tuple(constraint["column_names"])
for constraint in inspector.get_unique_constraints("meter_source_channel")
}
schema_unique_indexes = {
tuple(index["column_names"])
for index in inspector.get_indexes("meter_source_channel")
if index["unique"]
}
assert ("source_id", "channel_key") in metadata_unique_constraints
assert ("source_id", "channel_key") in schema_unique_constraints
assert ("source_id", "channel_key") not in schema_unique_indexes
def test_half_open_binding_interval_helper():
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
boundary = start + timedelta(hours=1)
later = boundary + timedelta(hours=1)
assert half_open_intervals_overlap(start, boundary, boundary, later) is False
assert half_open_intervals_overlap(start, later, boundary, None) is True
assert half_open_intervals_overlap(start, None, boundary, later) is True
def test_model_tables_and_foreign_keys_are_registered():
assert {"meter_source", "meter_source_channel", "meter_source_binding"} <= set(Base.metadata.tables)
binding_fks = Base.metadata.tables["meter_source_binding"].foreign_keys
assert {foreign_key.ondelete for foreign_key in binding_fks} == {"RESTRICT"}