M7-T03: make billing engine meter-aware (no cross-meter delta, delta sanity guard, period.meter_id)

This commit is contained in:
2026-06-25 17:01:54 +02:00
parent 227b146c2c
commit 17057ed41e
2 changed files with 752 additions and 45 deletions
+547 -18
View File
@@ -41,9 +41,11 @@ from app.models.energy import (
EnergyContract,
EnergyContractVersion,
EnergyCostPeriod,
Meter,
TibberPrice,
)
from app.services.energy_cost import (
_MAX_DELTA_KWH,
compute_closed_periods,
compute_period,
floor_to_quarter,
@@ -179,6 +181,45 @@ def _make_reading(
return r
def _make_meter(
session: Session,
*,
started_at: datetime,
ended_at: datetime | None = None,
label: str = "Test Meter",
commodity: str = "electricity",
reason: str = "initial",
note: str | None = None,
) -> Meter:
"""Insert and flush a Meter row; return the ORM object."""
now = datetime.now(_UTC)
m = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=ended_at,
reason=reason,
note=note,
created_at=now,
)
session.add(m)
session.flush()
return m
def _make_active_meter(session: Session, *, started_at: datetime | None = None) -> Meter:
"""Insert and flush an active electricity meter covering the full test day.
By default the meter starts at 2026-06-23 00:00 UTC (the beginning of the
test day), covering all boundaries used by the standard test helpers
(_T0 = 10:00, _T1 = 10:15, etc.).
"""
if started_at is None:
# Start well before any test boundary to cover the whole test day.
started_at = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
return _make_meter(session, started_at=started_at, ended_at=None)
def _make_tibber_price(
session: Session,
*,
@@ -252,30 +293,37 @@ class TestFloorToQuarter:
class TestRegisterAt:
"""register_at now requires a Meter argument; all tests use an active meter."""
def test_returns_none_when_no_readings(self, energy_db: Session) -> None:
result = register_at(energy_db, _ts(10, 0))
meter = _make_active_meter(energy_db)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_returns_most_recent_at_or_before_boundary(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
# Insert two readings: one before boundary, one after.
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0", r1="10.0", r2="20.0", source_id=1)
_make_reading(energy_db, recorded_at=_ts(10, 5), d1="999.0", d2="999.0", r1="999.0", r2="999.0", source_id=2)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("100.0")
assert result["d2"] == Decimal("200.0")
def test_exact_boundary_included(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0", r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("500.0")
def test_missing_register_key_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading(
recorded_at=_ts(10, 0),
source_id=99,
@@ -284,10 +332,11 @@ class TestRegisterAt:
energy_db.add(r)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_null_register_value_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading(
recorded_at=_ts(10, 0),
source_id=88,
@@ -301,20 +350,61 @@ class TestRegisterAt:
energy_db.add(r)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_values_are_decimal(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="20915.154", d2="18372.099",
r1="1234.567", r2="890.123", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert isinstance(result["d1"], Decimal)
assert result["d1"] == Decimal("20915.154")
assert result["r2"] == Decimal("890.123")
def test_reading_outside_meter_window_excluded(self, energy_db: Session) -> None:
"""A reading before meter.started_at must not be returned (cross-meter isolation)."""
# Meter starts at 10:00 — a reading at 09:55 is from the old epoch.
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0",
r1="10.0", r2="20.0", source_id=1)
energy_db.commit()
# Boundary is 10:00; the reading at 09:55 is before meter.started_at.
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None, (
"register_at must not return a reading from before meter.started_at"
)
def test_reading_at_meter_started_at_included(self, energy_db: Session) -> None:
"""A reading exactly at meter.started_at must be included (half-open lower bound)."""
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0",
r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("500.0")
def test_reading_at_meter_ended_at_excluded(self, energy_db: Session) -> None:
"""A reading exactly at meter.ended_at must be excluded (half-open upper bound)."""
# Meter covers [10:00, 10:15) — a reading at 10:15 belongs to the next epoch.
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=_ts(10, 15))
_make_reading(energy_db, recorded_at=_ts(10, 15), d1="500.0", d2="600.0",
r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
# Boundary is 10:15, reading is at 10:15 = ended_at → excluded.
result = register_at(energy_db, _ts(10, 15), meter)
assert result is None, (
"register_at must exclude a reading exactly at meter.ended_at "
"(half-open upper bound)"
)
# ---------------------------------------------------------------------------
# 1-2. compute_period — manual dual-tariff
@@ -351,12 +441,14 @@ _END_R2 = "3000.100"
def _setup_manual_scenario(session: Session) -> EnergyContractVersion:
"""Create active manual contract + two boundary readings; return the version."""
"""Create active manual contract + active meter + two boundary readings; return the version."""
contract = _make_contract(session, kind="manual", active=True)
version = _make_version(
session, contract, _MANUAL_VALUES,
effective_from=_ts(0, 0), # covers t0=10:00
)
# Active meter covering the full test day (started before T0).
_make_active_meter(session)
# Start reading (at t0)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
@@ -535,6 +627,7 @@ class TestComputePeriodTibber:
def _setup(self, session: Session, total: float = 0.25) -> tuple[EnergyContractVersion, TibberPrice]:
contract = _make_contract(session, kind="tibber", active=True)
version = _make_version(session, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(session)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -600,6 +693,7 @@ class TestComputePeriodMissingTibberPrice:
"""When Tibber price is absent for the period, no EnergyCostPeriod is written."""
contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -619,6 +713,7 @@ class TestComputePeriodMissingTibberPrice:
"""A TibberPrice with starts_at > t0 must NOT be used; period is skipped."""
contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -643,9 +738,10 @@ class TestComputePeriodMissingTibberPrice:
class TestComputePeriodMissingReadings:
def test_degraded_when_start_reading_missing(self, energy_db: Session) -> None:
"""No DsmrReading at or before t0 → degraded row written."""
"""No DsmrReading at or before t0 within the meter window → degraded row written."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only an end reading; no start reading.
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
@@ -663,7 +759,7 @@ class TestComputePeriodMissingReadings:
assert row.net_cost == 0.0
def test_degraded_when_end_reading_missing(self, energy_db: Session) -> None:
"""No DsmrReading at or before t1 → degraded row written.
"""No DsmrReading at or before t1 within the meter window → degraded row written.
We place a reading BEFORE t0 (so t0 boundary has data) but the first
reading AT OR AFTER t1 is only after t1+5min, leaving the t1 boundary
@@ -677,6 +773,7 @@ class TestComputePeriodMissingReadings:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only a reading AFTER t1 — no reading at or before t1.
_make_reading(energy_db, recorded_at=_ts(10, 20), d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
@@ -694,6 +791,7 @@ class TestComputePeriodMissingReadings:
"""Degraded rows written due to missing readings have contract_version_id=None."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all.
energy_db.commit()
@@ -711,6 +809,7 @@ class TestComputePeriodMissingReadings:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# First compute: no readings at all → degraded.
energy_db.commit()
@@ -786,6 +885,8 @@ class TestCrossVersionSelection:
effective_from=_ts(8, 0),
effective_to=None,
)
# Active meter covering the full test day.
_make_active_meter(session)
session.commit()
return v1, v2
@@ -857,9 +958,10 @@ class TestCrossVersionSelection:
class TestSummarize:
def _setup_two_periods(self, session: Session) -> None:
"""Insert contract + readings for two consecutive 15-min periods and compute them."""
"""Insert contract + meter + readings for two consecutive 15-min periods and compute them."""
contract = _make_contract(session, kind="manual", active=True)
_make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(session)
# Period 1: [10:00, 10:15)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
@@ -954,6 +1056,7 @@ class TestSummarize:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all → degraded period.
energy_db.commit()
compute_period(energy_db, _T0)
@@ -977,6 +1080,7 @@ class TestSummarize:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
energy_db.commit()
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
@@ -1294,12 +1398,28 @@ class TestSummarizePrincipleC:
class TestComputeClosedPeriods:
def test_skips_future_periods(self, energy_db: Session) -> None:
"""Periods whose t1 > now must not be computed."""
"""Periods whose t1 > now must not be computed.
The meter covers from before the lookback window so that all past periods
have a meter; the contract effective_from=now ensures that every past
period's contract-version lookup returns None, causing them all to be
skipped (no row written). Only the current open period [now, now+15min)
is a "future" period — and the normal tick never writes it.
Written = 0 confirms that no row was written (neither the future period
nor any past period without a contract).
"""
now = datetime.now(_UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=datetime.now(_UTC))
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=now)
# Meter covering from well before the 7-day lookback window so every past
# period has a meter. Without a matching contract version those past
# periods are all skipped (not written as degraded).
_make_meter(energy_db, started_at=now - timedelta(days=10), ended_at=None)
energy_db.commit()
# The period [now, now+15min) is still open — should not be computed.
# All closed past periods: meter OK, but no contract version → skip, no write.
written = compute_closed_periods(energy_db)
assert written == 0 # nothing written (no closed periods with data)
@@ -1314,6 +1434,8 @@ class TestComputeClosedPeriods:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
# Meter covering from before past_t0.
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
_make_reading(energy_db, recorded_at=past_t0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=past_t1, d1=_END_D1, d2=_END_D2,
@@ -1349,6 +1471,8 @@ class TestComputeClosedPeriods:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
# Meter covering from before past_t0.
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
# First compute: no readings → degraded.
energy_db.commit()
compute_period(energy_db, past_t0)
@@ -1386,6 +1510,7 @@ class TestRecomputeRange:
"""recompute_range must overwrite non-degraded rows (explicit opt-in)."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -1415,6 +1540,7 @@ class TestRecomputeRange:
"""recompute_range returns the number of periods actually written."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Add readings for two consecutive periods: [10:00,10:15), [10:15,10:30).
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
@@ -1436,6 +1562,7 @@ class TestRecomputeRange:
"""Calling recompute_range twice must not create duplicate rows."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -1469,6 +1596,7 @@ class TestRecomputeRange:
# Step 1: successful compute.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
start_reading = _make_reading(
energy_db, recorded_at=_T0,
d1=_START_D1, d2=_START_D2, r1=_START_R1, r2=_START_R2, source_id=1,
@@ -1528,16 +1656,19 @@ class TestRegisterAtFreshness:
``_READING_MAX_STALENESS`` (15 minutes) old at a given boundary is treated
as absent so the period is marked degraded rather than producing a
zero-delta fake-success row.
All tests now pass a meter that covers the reading and boundary times.
"""
def test_stale_reading_returns_none(self, energy_db: Session) -> None:
"""A reading older than 15 min before the boundary must be rejected."""
reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 30) # 10:30 — 30 min later (> 15 min staleness)
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
assert result is None, (
"register_at must return None when the closest reading is more than "
"15 minutes before the boundary"
@@ -1547,12 +1678,13 @@ class TestRegisterAtFreshness:
"""A reading within 15 min of the boundary must be returned normally."""
reading_time = _ts(10, 5) # 10:05
boundary = _ts(10, 15) # 10:15 — only 10 min gap (within window)
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time,
d1="30000.0", d2="15000.0", r1="1000.0", r2="500.0",
source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
assert result is not None, (
"register_at must return the reading when it is within the 15-min staleness window"
)
@@ -1562,12 +1694,13 @@ class TestRegisterAtFreshness:
"""A reading exactly 15 min before the boundary sits at the edge — accepted."""
reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 15) # 10:15 — exactly 15 min gap
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time,
d1="40000.0", d2="20000.0", r1="2000.0", r2="1000.0",
source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
# boundary - reading == 15 min == staleness limit → NOT stale (strict <)
assert result is not None
@@ -1581,12 +1714,13 @@ class TestRegisterAtFreshness:
"""
# Insert a reading at a realistic "now" time.
reading_time = _ts(10, 0) # 10:00 on 2026-06-23
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit()
# Query with a far-future boundary (e.g. end of month, same day +8 hours)
far_future_boundary = _ts(18, 0) # 18:00 — 8 hours later
result = register_at(energy_db, far_future_boundary)
result = register_at(energy_db, far_future_boundary, meter)
assert result is None, (
"register_at must return None for a far-future boundary rather than "
"the latest historical reading"
@@ -1615,6 +1749,7 @@ class TestFuturePeriodDegraded:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only a reading at 10:00 — far from the 14:00/14:15 boundaries.
_make_reading(energy_db, recorded_at=_ts(10, 0), source_id=1)
energy_db.commit()
@@ -1643,12 +1778,14 @@ class TestRecomputeRangeNoFuturePeriods:
future end datetime; the only periods that may be written are those
where t1 <= now. No row with period_start > now should appear in the DB.
"""
now = datetime.now(UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
# Meter covering from before now.
_make_meter(energy_db, started_at=now - timedelta(hours=1), ended_at=None)
# No readings needed — we only care that future rows are NOT written.
energy_db.commit()
now = datetime.now(UTC)
far_future_end = now + timedelta(days=7)
# Use a past start so there are some candidate periods.
past_start = floor_to_quarter(now - timedelta(minutes=30))
@@ -1678,15 +1815,21 @@ class TestRecomputeRangeNoFuturePeriods:
We only verify that no future rows exist after step 1 (Fix B), because
that is the core slot-squatting prevention.
"""
now = datetime.now(UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
# Contract effective from far past so all periods in scope have a version.
_make_version(
energy_db, contract, _MANUAL_VALUES,
effective_from=datetime(2020, 1, 1, tzinfo=UTC),
)
# Meter covering from far past.
_make_meter(
energy_db,
started_at=datetime(2020, 1, 1, tzinfo=UTC),
ended_at=None,
)
energy_db.commit()
now = datetime.now(UTC)
far_future = now + timedelta(days=30)
past_start = floor_to_quarter(now - timedelta(hours=1))
@@ -1714,6 +1857,7 @@ class TestNormalPeriodsUnaffected:
"""A normal period with readings seconds before each boundary → non-degraded."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Readings placed very close to boundaries (as DSMR normally delivers them).
# t0=10:00, reading at 09:59:50 (10 s before); t1=10:15, reading at 10:14:55 (5 s before)
@@ -1734,3 +1878,388 @@ class TestNormalPeriodsUnaffected:
)
# import_cost matches hand-calc: same deltas as standard scenario
assert abs(row.import_cost - 0.4101) < 1e-6
# ---------------------------------------------------------------------------
# M7-T03 New tests: meter-aware billing engine
# ---------------------------------------------------------------------------
class TestMeterAwareComputePeriod:
"""M7-T03 Acceptance criteria: meter-aware compute_period behaviour.
Covers:
① Same-meter period: delta computed correctly, meter_id attributed.
② Cross-meter boundary period: degraded.
③ No active meter coverage: degraded with meter_id=None.
④ Negative delta: degraded (D6 guard — no negative costs).
⑤ Super-large delta exceeding _MAX_DELTA_KWH: degraded (D6 guard).
⑥ Recompute re-judges meter attribution.
⑦ No-contract skip preserved inside single-meter path.
"""
# ① Same-meter period: delta correct + meter_id attributed
def test_same_meter_period_computes_correctly_with_meter_id(
self, energy_db: Session
) -> None:
"""A normal period within a single meter epoch: costs correct, meter_id set."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
# Cost correctness (same as manual scenario hand-calc).
assert row.degraded is False
assert abs(row.import_cost - 0.4101) < 1e-9
assert abs(row.export_revenue - 0.005) < 1e-9
assert abs(row.net_cost - 0.4051) < 1e-9
# meter_id must be set to the active meter's id.
assert row.meter_id == meter.id, (
f"Expected meter_id={meter.id}, got {row.meter_id}"
)
# ② Cross-meter boundary: degraded
def test_cross_meter_boundary_period_is_degraded(self, energy_db: Session) -> None:
"""A period spanning two meter epochs must be written as degraded.
Scenario:
- Old meter: [09:00, 10:15) — covers t0=10:00 but NOT t1=10:15.
- New meter: [10:15, ∞) — covers t1=10:15.
- Period [10:00, 10:15): m0 != m1 → degraded with meter_id=m0.id.
"""
old_meter = _make_meter(energy_db, started_at=_ts(9, 0), ended_at=_T1)
new_meter = _make_meter(energy_db, started_at=_T1, ended_at=None)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
# Readings exist, but the period still spans two meters.
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True # a row was written
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
"A period spanning two meter epochs must be written as degraded"
)
# meter_id should be m0 (t0's meter), not m1.
assert row.meter_id == old_meter.id, (
f"Cross-meter degraded row should attribute to old_meter (id={old_meter.id}), "
f"got meter_id={row.meter_id}"
)
# No real cost should be produced.
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# Suppress unused variable warning.
_ = new_meter
# ③ No active meter coverage: degraded with meter_id=None
def test_no_meter_coverage_is_degraded_with_null_meter_id(
self, energy_db: Session
) -> None:
"""When no meter epoch covers t0, the period is written as degraded(meter_id=None)."""
# No meter inserted at all.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, "No-meter period must be written as degraded"
assert row.meter_id is None, (
"No-meter degraded row must have meter_id=None, "
f"got meter_id={row.meter_id}"
)
assert row.import_cost == 0.0
# ④ Negative delta: degraded (D6 guard)
def test_negative_delta_is_degraded(self, energy_db: Session) -> None:
"""A period with any negative register delta → degraded (D6 guard).
Negative deltas occur after a meter reset or DSMR rollover and must
never produce a negative cost row.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# End reading has LOWER d1 than start → negative delta for d1.
_make_reading(energy_db, recorded_at=_T0, d1="20000.500", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="20000.000", d2="10001.200",
r1="5000.000", r2="3000.100", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
"A period with a negative delta must be written as degraded (no negative costs)"
)
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# ⑤ Super-large delta: degraded (D6 guard)
def test_super_large_delta_is_degraded(self, energy_db: Session) -> None:
"""A period with a delta > _MAX_DELTA_KWH → degraded (D6 guard).
Implausibly large deltas indicate an anomaly (wrong scale, DSMR
reporting bug) and must never produce a grossly inflated cost row.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = 200 kWh >> _MAX_DELTA_KWH (100 kWh).
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="10200.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
f"A delta > {_MAX_DELTA_KWH} kWh must be written as degraded (D6 guard)"
)
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# ⑤b Delta exactly at limit is NOT degraded (guard fires at strictly >)
def test_delta_exactly_at_max_limit_not_degraded(self, energy_db: Session) -> None:
"""A delta exactly equal to _MAX_DELTA_KWH is NOT degraded.
The guard condition is strictly > _MAX_DELTA_KWH, so a delta of exactly
100 kWh passes through and is computed normally. This is intentional:
the threshold is set far above any plausible residential consumption
(400 kW average over 15 min) to avoid false positives.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = _MAX_DELTA_KWH exactly (100 kWh) — must NOT degrade.
max_delta = float(_MAX_DELTA_KWH)
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta),
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, (
f"A delta exactly at _MAX_DELTA_KWH ({max_delta} kWh) must NOT trigger "
"the D6 guard (guard condition is strictly >)"
)
assert row.import_cost > 0
# ⑤c Delta strictly above limit IS degraded
def test_delta_strictly_above_max_limit_is_degraded(self, energy_db: Session) -> None:
"""A delta strictly greater than _MAX_DELTA_KWH → degraded (D6 guard fires)."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = _MAX_DELTA_KWH + 0.001 (strictly over threshold) → degraded.
max_delta_plus = float(_MAX_DELTA_KWH) + 0.001
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta_plus),
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
f"A delta of {max_delta_plus} kWh (> _MAX_DELTA_KWH) must trigger D6 guard"
)
# ⑤c Delta just below limit is NOT degraded
def test_delta_just_below_max_limit_not_degraded(self, energy_db: Session) -> None:
"""A delta just below _MAX_DELTA_KWH must NOT trigger the D6 guard."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = 99.999 kWh — just under 100, should compute normally.
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="10099.999", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, (
"A delta of 99.999 kWh must NOT trigger the D6 guard (guard fires at > 100 kWh)"
)
assert row.import_cost > 0
# ⑥ recompute re-judges meter attribution
def test_recompute_re_judges_meter_attribution(self, energy_db: Session) -> None:
"""recompute_range must re-attribute meter_id to the current meter_at judgment.
Scenario:
1. Compute period [10:00, 10:15) with meter M1 active → row has meter_id=M1.id.
2. Retroactively close M1 at 10:00 and open M2 from 09:00 (so M2 covers both
boundaries, by updating the meter row directly — simulating update_meter).
3. Call recompute_range → row's meter_id must now be M2.id.
"""
# Step 1: initial compute with M1.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
m1 = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
compute_period(energy_db, _T0)
energy_db.commit()
row_before = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row_before.meter_id == m1.id, "Pre-condition: meter_id should be m1.id"
# Step 2: retroactively replace M1 with M2.
# Close M1 (make it a zero-width closed epoch before any test reading).
# Open M2 covering the whole day.
m1.ended_at = datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC) # before test day
m2 = _make_meter(
energy_db,
started_at=datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC),
ended_at=None,
label="Replacement Meter",
)
energy_db.commit()
# Step 3: recompute the range — should re-attribute to m2.
count = recompute_range(energy_db, _T0, _T1)
assert count == 1
energy_db.expire_all()
row_after = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row_after.meter_id == m2.id, (
f"After recompute, meter_id should be m2.id={m2.id}, "
f"got {row_after.meter_id}"
)
assert row_after.degraded is False
# ⑦ No-contract skip preserved inside single-meter path
def test_no_contract_is_skip_not_degraded(self, energy_db: Session) -> None:
"""Single-meter path with no active contract → skip (no row written).
This ensures that 'meter OK, contract missing' still produces a skip
(not a degraded row), preserving the existing skip semantics.
"""
# Active meter but NO contract.
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is False, (
"Single-meter, no-contract period must be skipped (return False, no row)"
)
rows = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalars().all()
assert len(rows) == 0, (
"No EnergyCostPeriod row must be written when skipping due to missing contract"
)
# Degraded within-meter rows get the correct meter_id
def test_degraded_within_meter_has_meter_id(self, energy_db: Session) -> None:
"""Degraded rows due to missing readings within a known meter get meter_id set."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
meter = _make_active_meter(energy_db)
# No readings at all → will degrade due to missing readings.
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True
assert row.meter_id == meter.id, (
f"Within-meter degraded row must carry meter_id={meter.id}, "
f"got {row.meter_id}"
)
# Delta sanity guard with all-zero deltas (zero is fine, not negative)
def test_zero_delta_not_degraded(self, energy_db: Session) -> None:
"""A period with all-zero deltas (no energy used) must NOT be degraded.
Zero is valid — perfectly matching start/end readings means no energy
was consumed or produced in that window.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Identical start and end readings → all deltas = 0.
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
assert row.import_cost == 0.0
assert row.net_cost == 0.0