M8-T14: add generic meter cost periods

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 0fb51d338c
commit 567ddb9779
6 changed files with 759 additions and 8 deletions
+465
View File
@@ -0,0 +1,465 @@
"""Migration and ORM contracts for the commodity-scoped meter cost ledger."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event as sa_event, insert, inspect, select, text
from sqlalchemy.exc import IntegrityError, StatementError
from sqlalchemy.orm import Session
from app.models.energy import MeterCostPeriod
from scripts.app_db_adopt import APP_BASELINE_REVISION
REVISION_18 = "20260822_18_contract_scopes"
REVISION_19 = "20260822_19_meter_cost_periods"
UTC = timezone.utc
def _config(database_url: str) -> Config:
config = Config("alembic_app.ini")
config.set_main_option("sqlalchemy.url", database_url)
return config
def _fk_engine(database_url: str):
engine = create_engine(database_url)
@sa_event.listens_for(engine, "connect")
def _enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys = ON")
return engine
def _period(**overrides) -> MeterCostPeriod:
start = datetime(2026, 8, 22, 12, tzinfo=UTC)
values = {
"commodity": "heating",
"period_start": start,
"period_end": start + timedelta(minutes=15),
"quantity": Decimal("999999999.123456"),
"cost": Decimal("999999.123456789"),
"currency": "EUR",
"cost_breakdown": {"heating": "0.012345678"},
"pricing_snapshot": {"heating": "10.000000", "kind": "district_heating"},
"quality": "valid",
"degraded": True,
"degraded_reason": "test_fixture_without_audit_links",
"created_at": start,
"updated_at": start,
}
values.update(overrides)
return MeterCostPeriod(**values)
def test_meter_cost_period_empty_db_upgrade_shape_and_baseline(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'empty.db'}"
config = _config(database_url)
command.upgrade(config, "head")
command.upgrade(config, "head")
engine = create_engine(database_url)
inspector = inspect(engine)
columns = {column["name"]: column for column in inspector.get_columns("meter_cost_period")}
assert APP_BASELINE_REVISION == REVISION_19
assert {"commodity", "period_start", "period_end", "quantity", "cost"} <= columns.keys()
assert columns["meter_id"]["nullable"]
assert columns["source_binding_id"]["nullable"]
assert columns["contract_version_id"]["nullable"]
assert {index["name"] for index in inspector.get_indexes("meter_cost_period")} >= {
"ix_meter_cost_period_commodity_start",
"ix_meter_cost_period_source_binding_id",
}
assert ("commodity", "period_start") in {
tuple(constraint["column_names"])
for constraint in inspector.get_unique_constraints("meter_cost_period")
}
foreign_keys = {
foreign_key["constrained_columns"][0]: foreign_key
for foreign_key in inspector.get_foreign_keys("meter_cost_period")
}
for column, table in {
"meter_id": "meter",
"source_binding_id": "meter_source_binding",
"contract_version_id": "energy_contract_version",
}.items():
assert foreign_keys[column]["referred_table"] == table
assert foreign_keys[column]["options"]["ondelete"] == "RESTRICT"
engine.dispose()
def test_meter_cost_period_revision_18_upgrade_preserves_electricity_rows(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'revision18.db'}"
config = _config(database_url)
command.upgrade(config, REVISION_18)
engine = create_engine(database_url)
stamp = datetime(2026, 8, 22, 12)
with engine.begin() as connection:
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, source_binding_id, degraded, computed_at) "
"VALUES (:stamp, 1, 2, 3, 4, 5, 6, 7, 'EUR', :pricing, NULL, NULL, NULL, 0, :stamp)"
),
{"stamp": stamp, "pricing": '{"historic":"unchanged"}'},
)
command.upgrade(config, "head")
command.upgrade(config, "head")
with engine.connect() as connection:
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_19
assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1
assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == (
'{"historic":"unchanged"}'
)
assert connection.execute(text("SELECT COUNT(*) FROM meter_cost_period")).scalar_one() == 0
command.downgrade(config, REVISION_18)
assert "meter_cost_period" not in inspect(engine).get_table_names()
engine.dispose()
def test_meter_cost_period_decimal_json_and_degraded_round_trip(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'roundtrip.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
with Session(engine) as session:
normal = _period()
degraded = _period(
commodity="hot_water",
meter_id=None,
source_binding_id=None,
contract_version_id=None,
quality="unverifiable",
degraded=True,
degraded_reason="missing_binding",
quantity=Decimal("0.000000"),
cost=Decimal("0.000000000"),
cost_breakdown={"hot_water": "0.000000000"},
pricing_snapshot={"reason": "missing_binding"},
)
session.add_all((normal, degraded))
session.commit()
session.expire_all()
stored = session.get(MeterCostPeriod, normal.id)
assert stored is not None
assert stored.quantity == Decimal("999999999.123456")
assert stored.cost == Decimal("999999.123456789")
assert stored.cost_breakdown == {"heating": "0.012345678"}
assert all(not isinstance(value, float) for value in stored.pricing_snapshot.values())
assert session.get(MeterCostPeriod, degraded.id).degraded_reason == "missing_binding"
engine.dispose()
def test_meter_cost_period_uses_decimal_text_and_normalises_json_snapshots(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'exact.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
with Session(engine) as session:
row = _period(
cost_breakdown={"nested": [Decimal("2.000000000")]},
pricing_snapshot={"rate": Decimal("2.000000")},
)
session.add(row)
session.commit()
session.expire_all()
stored = session.get(MeterCostPeriod, row.id)
assert stored is not None
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
assert stored.pricing_snapshot == {"rate": "2.000000"}
raw = session.execute(text("SELECT typeof(quantity), typeof(cost) FROM meter_cost_period")).one()
assert raw == ("text", "text")
with pytest.raises(ValueError, match="numeric JSON"):
_period(cost_breakdown={"nested": [1]})
with pytest.raises(ValueError, match="numeric JSON"):
_period(pricing_snapshot={"nested": [1.25]})
engine.dispose()
def test_meter_cost_period_core_json_bind_normalises_decimal_and_rejects_numeric(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'core-json.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
values = {
"commodity": "heating",
"period_start": datetime(2026, 8, 22, 12, tzinfo=UTC),
"period_end": datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
"quantity": Decimal("0.000000"),
"cost": Decimal("0.000000000"),
"currency": "EUR",
"cost_breakdown": {"nested": [Decimal("2.000000000")]},
"pricing_snapshot": {"rate": Decimal("2.000000")},
"quality": "valid",
"degraded": True,
"degraded_reason": "core fixture",
"created_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
"updated_at": datetime(2026, 8, 22, 12, tzinfo=UTC),
}
with engine.begin() as connection:
connection.execute(insert(MeterCostPeriod.__table__).values(values))
with Session(engine) as session:
stored = session.scalar(select(MeterCostPeriod))
assert stored is not None
assert stored.cost_breakdown == {"nested": ["2.000000000"]}
assert stored.pricing_snapshot == {"rate": "2.000000"}
for field, numeric_value in (("cost_breakdown", {"nested": [1]}), ("pricing_snapshot", {"rate": 1.25})):
with engine.begin() as connection, pytest.raises(StatementError, match="numeric JSON"):
connection.execute(
insert(MeterCostPeriod.__table__).values(
{**values, "commodity": f"invalid-{field}", field: numeric_value}
)
)
engine.dispose()
@pytest.mark.parametrize(
("field", "value", "message"),
[
("quantity", Decimal("1000000000.000000"), "precision"),
("quantity", Decimal("1.1234567"), "scale"),
("cost", Decimal("1000000.000000000"), "precision"),
("cost", Decimal("1.1234567899"), "scale"),
],
)
def test_meter_cost_period_rejects_decimal_precision_and_scale_overflow(field, value, message) -> None:
with pytest.raises(ValueError, match=message):
_period(**{field: value})
def test_meter_cost_period_accepts_signed_decimal_boundaries_without_float_bind(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'boundaries.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
with Session(engine) as session:
rows = (
_period(quantity=Decimal("999999999.999999"), cost=Decimal("999999.999999999")),
_period(
commodity="hot_water",
quantity=Decimal("-999999999.999999"),
cost=Decimal("-999999.999999999"),
),
)
session.add_all(rows)
session.commit()
session.expire_all()
assert session.get(MeterCostPeriod, rows[0].id).quantity == Decimal("999999999.999999")
assert session.get(MeterCostPeriod, rows[1].id).cost == Decimal("-999999.999999999")
engine.dispose()
def test_meter_cost_period_rejects_duplicate_period_invalid_interval_and_json_float(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'constraints.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
with Session(engine) as session:
first = _period()
session.add(first)
session.commit()
session.add(_period(cost=Decimal("1.000000000")))
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
session.add(_period(commodity="hot_water", period_end=datetime(2026, 8, 22, 12, tzinfo=UTC)))
with pytest.raises(ValueError, match="after period_start"):
session.flush()
session.rollback()
with pytest.raises(ValueError, match="timezone-aware"):
_period(
commodity="hot_water",
period_start=datetime(2026, 8, 22, 12),
period_end=datetime(2026, 8, 22, 12, 15, tzinfo=UTC),
)
with pytest.raises(ValueError, match="decimal strings"):
_period(pricing_snapshot={"heating": 1.25})
with pytest.raises(ValueError, match="normal meter cost periods"):
session.add(_period(commodity="hot_water", degraded=False, degraded_reason=None))
session.flush()
session.rollback()
engine.dispose()
def test_meter_cost_period_uses_utc_instant_idempotency_and_db_constraints(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'utc.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
offset_start = datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2)))
with Session(engine) as session:
first = _period(period_start=utc_start, period_end=utc_start + timedelta(minutes=15))
assert first.period_start == utc_start
session.add(first)
session.commit()
session.add(
_period(
period_start=offset_start,
period_end=offset_start + timedelta(minutes=15),
cost=Decimal("1.000000000"),
)
)
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
with engine.begin() as connection:
values = {
"start": "2026-08-23 00:00:00.000000",
"quantity": "0.000000",
"cost": "0.000000000",
"now": "2026-08-23 00:00:00.000000",
}
for commodity, end in (("equal", values["start"]), ("reversed", "2026-08-22 23:59:59.000000")):
with pytest.raises(IntegrityError):
connection.execute(
text(
"INSERT INTO meter_cost_period "
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
"VALUES (:commodity, :start, :end, :quantity, :cost, 'EUR', '{}', '{}', 'valid', "
"1, 'core test', :now, :now)"
),
{**values, "commodity": commodity, "end": end},
)
with pytest.raises(IntegrityError):
connection.execute(
text(
"INSERT INTO meter_cost_period "
"(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, "
"pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) "
"VALUES ('missing-reason', :start, '2026-08-23 00:15:00.000000', :quantity, :cost, "
"'EUR', '{}', '{}', 'valid', 1, NULL, :now, :now)"
),
values,
)
engine.dispose()
def test_meter_cost_period_core_uses_utc_instant_idempotency_and_reloads_aware(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'core-utc.db'}"
command.upgrade(_config(database_url), "head")
engine = create_engine(database_url)
utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC)
values = {
"commodity": "heating",
"period_start": utc_start,
"period_end": utc_start + timedelta(minutes=15),
"quantity": Decimal("0.000000"),
"cost": Decimal("0.000000000"),
"currency": "EUR",
"cost_breakdown": {},
"pricing_snapshot": {},
"quality": "valid",
"degraded": True,
"degraded_reason": "core fixture",
"created_at": utc_start,
"updated_at": utc_start,
}
with engine.begin() as connection:
row_id = connection.execute(insert(MeterCostPeriod.__table__).values(values)).inserted_primary_key[0]
with engine.begin() as connection, pytest.raises(IntegrityError):
connection.execute(
insert(MeterCostPeriod.__table__).values(
{
**values,
"period_start": datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2))),
"period_end": datetime(2026, 8, 22, 14, 15, tzinfo=timezone(timedelta(hours=2))),
}
)
)
with engine.connect() as connection, pytest.raises(StatementError, match="timezone-aware"):
connection.execute(
insert(MeterCostPeriod.__table__).values(
{**values, "commodity": "naive", "period_start": datetime(2026, 8, 23, 12)}
)
)
with Session(engine) as session:
stored = session.get(MeterCostPeriod, row_id)
assert stored is not None
assert stored.period_start == utc_start
assert stored.period_start.tzinfo is not None
assert stored.period_start.utcoffset() == timedelta(0)
stored.quality = "unverifiable"
session.commit()
engine.dispose()
def test_meter_cost_period_foreign_keys_restrict_deletion(tmp_path: Path) -> None:
database_url = f"sqlite:///{tmp_path / 'foreign_keys.db'}"
command.upgrade(_config(database_url), "head")
engine = _fk_engine(database_url)
timestamp = datetime(2026, 8, 22, 12)
with engine.begin() as connection:
meter_id = connection.execute(
text(
"INSERT INTO meter (uuid, label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES ('meter-cost-test', 'Test', 'heating', :timestamp, NULL, 'initial', NULL, :timestamp)"
),
{"timestamp": timestamp},
).lastrowid
source_id = connection.execute(
text(
"INSERT INTO meter_source (uuid, name, kind, enabled, config, status, created_at, updated_at) "
"VALUES ('source-cost-test', 'Test', 'warmtelink_serial', 1, '{}', 'online', :timestamp, :timestamp)"
),
{"timestamp": timestamp},
).lastrowid
channel_id = connection.execute(
text(
"INSERT INTO meter_source_channel (uuid, source_id, channel_key, label, unit, created_at, updated_at) "
"VALUES ('channel-cost-test', :source_id, 'heating', 'Heating', 'GJ', :timestamp, :timestamp)"
),
{"source_id": source_id, "timestamp": timestamp},
).lastrowid
binding_id = connection.execute(
text(
"INSERT INTO meter_source_binding "
"(uuid, meter_id, channel_id, started_at, ended_at, created_at, updated_at) "
"VALUES ('binding-cost-test', :meter_id, :channel_id, :timestamp, NULL, :timestamp, :timestamp)"
),
{"meter_id": meter_id, "channel_id": channel_id, "timestamp": timestamp},
).lastrowid
contract_id = connection.execute(
text(
"INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) "
"VALUES ('Test', 'district_heating', 'thermal', 1, 'EUR', :timestamp, :timestamp)"
),
{"timestamp": timestamp},
).lastrowid
version_id = connection.execute(
text(
"INSERT INTO energy_contract_version "
"(contract_id, effective_from, effective_to, \"values\", created_at) "
"VALUES (:contract_id, :timestamp, NULL, '{}', :timestamp)"
),
{"contract_id": contract_id, "timestamp": timestamp},
).lastrowid
connection.execute(
text(
"INSERT INTO meter_cost_period "
"(commodity, period_start, period_end, meter_id, source_binding_id, contract_version_id, "
"quantity, cost, currency, cost_breakdown, pricing_snapshot, quality, degraded, degraded_reason, "
"created_at, updated_at) VALUES "
"('heating', :timestamp, :period_end, :meter_id, :binding_id, :version_id, "
"'0.001000', '0.010000000', 'EUR', '{}', '{}', 'valid', 0, NULL, :timestamp, :timestamp)"
),
{
"timestamp": timestamp,
"period_end": timestamp + timedelta(minutes=15),
"meter_id": meter_id,
"binding_id": binding_id,
"version_id": version_id,
},
)
for statement, values in (
("DELETE FROM meter WHERE id = :id", {"id": meter_id}),
("DELETE FROM meter_source_binding WHERE id = :id", {"id": binding_id}),
("DELETE FROM energy_contract_version WHERE id = :id", {"id": version_id}),
):
with pytest.raises(IntegrityError):
connection.execute(text(statement), values)
engine.dispose()