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
+202 -24
View File
@@ -1,7 +1,7 @@
"""Billing engine for DSMR 15-minute energy metering periods. """Billing engine for DSMR 15-minute energy metering periods.
This module implements the two-layer billing model described in §3.4 of the This module implements the two-layer billing model described in §3.4 of the
M6 design document: M6 design document, extended in M7-T03 to be meter-aware:
**Layer 1 — per-period metering cost (immutable, price-snapshot)** **Layer 1 — per-period metering cost (immutable, price-snapshot)**
``compute_period(session, t0)`` computes the import cost, export revenue, and ``compute_period(session, t0)`` computes the import cost, export revenue, and
@@ -31,17 +31,49 @@ Design notes
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"`` - **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
for cumulative kWh registers. ``register_at`` converts them to Decimal. for cumulative kWh registers. ``register_at`` converts them to Decimal.
- **Degraded vs skip semantics**: - **Degraded vs skip semantics**:
- *No meter coverage* (``meter_at`` returns None for t0): write a
``degraded=True`` row with ``meter_id=None``.
- *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True``
row with ``meter_id=m0.id``; losing this one period at the swap boundary is
acceptable (D5 decision).
- *Missing readings* (``register_at`` returns None for start or end - *Missing readings* (``register_at`` returns None for start or end
boundary): write a ``degraded=True`` row with costs at 0 so the period is boundary within the meter window): write a ``degraded=True`` row with
tracked and can be retried by ``compute_closed_periods``. ``meter_id=m0.id`` so the period is tracked and can be retried by
``compute_closed_periods``.
- *Negative or excessively large delta* (delta sanity guard D6): write a
``degraded=True`` row with ``meter_id=m0.id``; prevents negative costs and
grossly inflated costs from meter resets, DSMR rollover, or data spikes.
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do - *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
not write a row); the period will be retried once prices arrive. not write a row); the period will be retried once prices arrive.
- *Missing active contract version*: skip (no contract to compute against). - *Missing active contract version*: skip (no contract to compute against).
- **Meter-aware register lookup**: ``register_at`` now accepts a ``meter``
parameter and restricts the DSMR reading query to readings within
``[meter.started_at, meter.ended_at)`` (half-open), preventing old-meter
readings from leaking into a new-meter epoch.
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all - **Lookback window in ``compute_closed_periods``**: to avoid scanning all
historical DSMR data on every tick, the function looks back at most 7 days historical DSMR data on every tick, the function looks back at most 7 days
from the current time. This covers typical short outages (no data / no from the current time. This covers typical short outages (no data / no
contract) while staying bounded. Periods older than 7 days must be contract) while staying bounded. Periods older than 7 days must be
recovered via an explicit ``recompute_range`` call. recovered via an explicit ``recompute_range`` call.
Meter-aware compute_period ordering rationale (M7-T03)
-------------------------------------------------------
The order of checks inside ``compute_period`` is:
1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False.
2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)):
- No meter (m0 is None) → write degraded, meter_id=None.
- Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id.
3. **Active contract version check** → skip (no write) if absent.
4. **Boundary register readings** within m0's window → write degraded if missing.
5. **Delta sanity guard** → write degraded if any delta < 0 or > _MAX_DELTA_KWH.
6. **Price strategy** → skip (no write) if Tibber price missing.
7. **Upsert billing record** with meter_id=m0.id.
Why meter before contract? The meter is a *structural* prerequisite: without a
known meter epoch we cannot trust the delta at all, so we commit a degraded row
immediately. The contract skip, by contrast, is transient (the period can be
re-computed once a contract is configured), so it produces no row.
""" """
from __future__ import annotations from __future__ import annotations
@@ -59,8 +91,9 @@ from app.integrations.pricing.strategies import (
TibberPriceNotFoundError, TibberPriceNotFoundError,
get_strategy, get_strategy,
) )
from app.models.energy import DsmrReading, EnergyCostPeriod from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
from app.services.contracts import active_contract_version_at, active_contract_versions from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.meters import meter_at
from app.services.timezone import local_date, local_now from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,6 +114,15 @@ _LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
# producing a spurious zero-delta "successful" row. # producing a spurious zero-delta "successful" row.
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES) _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# Maximum plausible kWh delta for a single 15-minute period (D6 sanity guard).
# A typical Dutch household uses well under 5 kWh per quarter hour even under
# heavy load. 100 kWh per 15 minutes corresponds to ~400 kW — far beyond any
# residential consumption — but is lenient enough to never fire on legitimate
# data. Any delta at or above this threshold indicates a meter reset, DSMR
# rollover, sign error, or other data anomaly, and the period is marked
# degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100")
# DSMR payload register keys (cumulative kWh, JSON string values). # DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) _KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) _KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
@@ -123,15 +165,29 @@ def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# register_at — boundary reading lookup # register_at — boundary reading lookup (meter-aware)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | None: def register_at(
"""Return the four cumulative kWh register values at *boundary*. session: Session,
boundary: datetime,
meter: Meter,
) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
Queries the most recent ``DsmrReading`` with ``recorded_at ≤ boundary`` Queries the most recent ``DsmrReading`` with:
and extracts the four energy registers from ``payload``: ``recorded_at ≤ boundary``
AND ``recorded_at ≥ meter.started_at``
AND (``meter.ended_at IS NULL`` OR ``recorded_at < meter.ended_at``)
The meter window constraint (half-open ``[started_at, ended_at)``) ensures
that readings from a previous meter epoch are never used to anchor a new
meter's computation. Without this guard, the final reading of the old meter
would be visible at the start of the new meter's epoch and produce a
cross-meter delta, defeating the isolation guarantee.
Extracts the four energy registers from ``payload``:
d1 — electricity_delivered_1 (delivered low-tariff / dal) d1 — electricity_delivered_1 (delivered low-tariff / dal)
d2 — electricity_delivered_2 (delivered high-tariff / normal) d2 — electricity_delivered_2 (delivered high-tariff / normal)
@@ -142,18 +198,40 @@ def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | No
------- -------
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None`` dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
when: when:
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary``. - No ``DsmrReading`` row exists with ``recorded_at ≤ boundary`` within
*meter*'s epoch window.
- The most recent such reading is older than ``_READING_MAX_STALENESS``
relative to *boundary* (freshness guard).
- Any of the four register keys is absent from the payload. - Any of the four register keys is absent from the payload.
- Any of the four register values is ``None`` (null in JSON). - Any of the four register values is ``None`` (null in JSON).
SQLite naive datetime note
--------------------------
``recorded_at`` is stored as a naive UTC datetime in SQLite. Comparisons
against *boundary* (always tz-aware UTC) use ``_as_utc()`` for the
freshness check. The SQL ``WHERE`` clause comparisons work correctly
because SQLAlchemy's SQLite dialect strips tzinfo when binding parameters
(leaving the wall-clock UTC value unchanged), consistent with the storage
format.
""" """
row: DsmrReading | None = ( # Build the meter-window constraints: [started_at, ended_at).
session.execute( meter_lower = meter.started_at # DsmrReading.recorded_at >= meter.started_at
meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set)
stmt = (
select(DsmrReading) select(DsmrReading)
.where(DsmrReading.recorded_at <= boundary) .where(
DsmrReading.recorded_at <= boundary,
DsmrReading.recorded_at >= meter_lower,
)
.order_by(DsmrReading.recorded_at.desc()) .order_by(DsmrReading.recorded_at.desc())
.limit(1) .limit(1)
).scalar_one_or_none()
) )
# Apply the upper bound only when the meter is closed (ended_at is not None).
if meter_upper is not None:
stmt = stmt.where(DsmrReading.recorded_at < meter_upper)
row: DsmrReading | None = session.execute(stmt).scalar_one_or_none()
if row is None: if row is None:
return None return None
@@ -214,8 +292,14 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
Side-effects Side-effects
------------ ------------
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``. - Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
- If readings are missing at either boundary: inserts/updates a degraded - If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
row (costs=0, degraded=True). a degraded row with ``meter_id=None``.
- If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``):
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
- If readings are missing at either boundary within the meter window:
inserts/updates a degraded row with ``meter_id=m0.id``.
- If any delta is negative or exceeds ``_MAX_DELTA_KWH`` (D6 sanity guard):
inserts/updates a degraded row with ``meter_id=m0.id``.
- If the active contract version is missing: **skips** (returns False, no write). - If the active contract version is missing: **skips** (returns False, no write).
- If the Tibber price is missing (TibberPriceNotFoundError): **skips** - If the Tibber price is missing (TibberPriceNotFoundError): **skips**
(returns False, no write). (returns False, no write).
@@ -229,7 +313,50 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
if existing is not None and not existing.degraded and not overwrite: if existing is not None and not existing.degraded and not overwrite:
return False return False
# --- Active contract version at t0 (checked before readings) --- # --- Meter determination (structural prerequisite, checked before contract) ---
#
# A missing or cross-boundary meter is a structural problem: we cannot trust
# the delta at all, so we write a degraded row immediately. This is different
# from the contract skip (transient, no write): the degraded row ensures the
# period appears in the history and can be revisited once the meter timeline
# is corrected and a recompute_range is triggered.
#
# Ordering rationale:
# 1. No meter (m0 is None) → degraded(meter_id=None): no epoch for t0.
# 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5.
# 3. (Single meter, proceed) → contract check → readings → delta guard → price.
#
# We place meter before contract so that "cross-table period" is always
# marked degraded regardless of contract state. If we checked contract
# first, a missing-contract skip would silently discard the cross-table
# evidence; once a contract is added and recompute runs, the engine would
# incorrectly use cross-table reads.
m0 = meter_at(session, t0)
m1 = meter_at(session, t1)
if m0 is None:
# No meter epoch covers t0 — degraded with no meter attribution.
logger.debug(
"compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).",
t0.isoformat(),
)
_upsert_degraded(session, t0, now, existing, meter_id=None)
return True
if m1 is None or m0.id != m1.id:
# Period spans a meter boundary or t1 has no meter. Degrade with m0's id
# (t0's meter attribution): the period's start belongs to m0's epoch.
logger.debug(
"compute_period(%s): period crosses meter boundary "
"(m0.id=%s, m1.id=%s) — writing degraded.",
t0.isoformat(),
m0.id,
m1.id if m1 is not None else None,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Active contract version at t0 ---
# If there is no active contract covering t0, skip the period entirely. # If there is no active contract covering t0, skip the period entirely.
# We do not write a degraded row — there is no meaningful state to recover # We do not write a degraded row — there is no meaningful state to recover
# without a contract (we would not know which strategy to apply once data # without a contract (we would not know which strategy to apply once data
@@ -240,14 +367,13 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat()) logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
return False return False
# --- Boundary readings --- # --- Boundary readings within m0's meter window ---
start_regs = register_at(session, t0) start_regs = register_at(session, t0, m0)
end_regs = register_at(session, t1) end_regs = register_at(session, t1, m0)
if start_regs is None or end_regs is None: if start_regs is None or end_regs is None:
# Missing readings → write/update a degraded placeholder so the period # Missing readings within the meter window → degraded with m0 attribution.
# is visible and can be retried by compute_closed_periods once data arrives. _upsert_degraded(session, t0, now, existing, meter_id=m0.id)
_upsert_degraded(session, t0, now, existing)
return True # a record was written (degraded) return True # a record was written (degraded)
# --- Compute deltas (end start) --- # --- Compute deltas (end start) ---
@@ -258,6 +384,26 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
r2=end_regs["r2"] - start_regs["r2"], r2=end_regs["r2"] - start_regs["r2"],
) )
# --- Delta sanity guard (D6) ---
# Any negative delta indicates a meter reset, DSMR rollover, or data error.
# Any delta exceeding _MAX_DELTA_KWH (100 kWh per 15 min = 400 kW average)
# is implausible for residential use and indicates an anomaly.
# Both cases produce a degraded row so no negative or grossly inflated cost
# is ever written to the billing record.
all_deltas = (deltas.d1, deltas.d2, deltas.r1, deltas.r2)
if any(d < Decimal("0") for d in all_deltas) or any(d > _MAX_DELTA_KWH for d in all_deltas):
logger.debug(
"compute_period(%s): delta sanity guard triggered "
"(d1=%s, d2=%s, r1=%s, r2=%s) — writing degraded.",
t0.isoformat(),
deltas.d1,
deltas.d2,
deltas.r1,
deltas.r2,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Price strategy --- # --- Price strategy ---
strategy = get_strategy(version.contract.kind) strategy = get_strategy(version.contract.kind)
try: try:
@@ -288,6 +434,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
existing.currency = version.contract.currency existing.currency = version.contract.currency
existing.pricing = pricing existing.pricing = pricing
existing.contract_version_id = version.id existing.contract_version_id = version.id
existing.meter_id = m0.id
existing.degraded = False existing.degraded = False
existing.computed_at = now existing.computed_at = now
else: else:
@@ -303,6 +450,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
currency=version.contract.currency, currency=version.contract.currency,
pricing=pricing, pricing=pricing,
contract_version_id=version.id, contract_version_id=version.id,
meter_id=m0.id,
degraded=False, degraded=False,
computed_at=now, computed_at=now,
) )
@@ -316,9 +464,25 @@ def _upsert_degraded(
t0: datetime, t0: datetime,
now: datetime, now: datetime,
existing: EnergyCostPeriod | None, existing: EnergyCostPeriod | None,
*,
meter_id: int | None,
) -> None: ) -> None:
"""Insert or update a degraded placeholder for period *t0*. """Insert or update a degraded placeholder for period *t0*.
Parameters
----------
session:
Active SQLAlchemy session.
t0:
UTC start of the 15-minute period.
now:
Current UTC timestamp for the ``computed_at`` field.
existing:
The existing ``EnergyCostPeriod`` row for this period, or ``None``.
meter_id:
The meter ID to attribute this degraded period to, or ``None`` when
no meter epoch covers the period (no-meter degraded case).
When *existing* is not None (row was previously written — either degraded When *existing* is not None (row was previously written — either degraded
or successful), the row is explicitly reset to the standard degraded state. or successful), the row is explicitly reset to the standard degraded state.
This is required for the ``recompute_range`` (overwrite=True) path: if the This is required for the ``recompute_range`` (overwrite=True) path: if the
@@ -326,6 +490,11 @@ def _upsert_degraded(
since disappeared, the stale non-zero costs must be cleared so the row since disappeared, the stale non-zero costs must be cleared so the row
accurately reflects the current "missing readings" state rather than accurately reflects the current "missing readings" state rather than
masquerading as a valid result. masquerading as a valid result.
The ``meter_id`` is always updated to reflect the current meter attribution
judgment (the result of ``meter_at`` at the time of recompute). This
ensures that a retroactive ``started_at`` change + ``recompute_range`` will
re-attribute historical degraded periods to the correct meter epoch.
""" """
if existing is not None: if existing is not None:
# Explicitly reset to degraded state — identical field values to the # Explicitly reset to degraded state — identical field values to the
@@ -340,6 +509,7 @@ def _upsert_degraded(
existing.net_cost = 0.0 existing.net_cost = 0.0
existing.pricing = {} existing.pricing = {}
existing.contract_version_id = None existing.contract_version_id = None
existing.meter_id = meter_id
existing.degraded = True existing.degraded = True
existing.computed_at = now existing.computed_at = now
else: else:
@@ -355,6 +525,7 @@ def _upsert_degraded(
currency="EUR", # placeholder; real currency known after contract lookup currency="EUR", # placeholder; real currency known after contract lookup
pricing={}, pricing={},
contract_version_id=None, contract_version_id=None,
meter_id=meter_id,
degraded=True, degraded=True,
computed_at=now, computed_at=now,
) )
@@ -381,6 +552,7 @@ def compute_closed_periods(session: Session) -> int:
3. For each boundary, calls ``compute_period(overwrite=False)``, which: 3. For each boundary, calls ``compute_period(overwrite=False)``, which:
- Skips periods that already have a *successful* (non-degraded) record. - Skips periods that already have a *successful* (non-degraded) record.
- Retries periods that have a *degraded* record. - Retries periods that have a *degraded* record.
- Writes degraded rows for periods with no meter or cross-meter boundaries.
- Skips periods for which no active contract version exists or the - Skips periods for which no active contract version exists or the
Tibber price is unavailable (without writing a degraded row). Tibber price is unavailable (without writing a degraded row).
@@ -429,11 +601,17 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int:
This is the *explicit opt-in* path for recovering from: This is the *explicit opt-in* path for recovering from:
- Periods where readings or prices arrived late. - Periods where readings or prices arrived late.
- Price corrections (new contract version retroactively applied). - Price corrections (new contract version retroactively applied).
- Retroactive meter changes (``update_meter`` with new ``started_at``) —
re-running this function will re-judge meter attribution and re-compute
costs using the corrected epoch boundaries.
- Any other reason to override the immutability guard. - Any other reason to override the immutability guard.
The function iterates over every UTC quarter-hour boundary in The function iterates over every UTC quarter-hour boundary in
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``. ``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
Existing rows (including successful ones) are overwritten. Existing rows (including successful ones) are overwritten; their
``meter_id`` fields will reflect the *current* ``meter_at`` judgment for
each period's start timestamp, naturally re-attributing periods when meter
``started_at`` values have been retroactively corrected.
Parameters Parameters
---------- ----------
+547 -18
View File
@@ -41,9 +41,11 @@ from app.models.energy import (
EnergyContract, EnergyContract,
EnergyContractVersion, EnergyContractVersion,
EnergyCostPeriod, EnergyCostPeriod,
Meter,
TibberPrice, TibberPrice,
) )
from app.services.energy_cost import ( from app.services.energy_cost import (
_MAX_DELTA_KWH,
compute_closed_periods, compute_closed_periods,
compute_period, compute_period,
floor_to_quarter, floor_to_quarter,
@@ -179,6 +181,45 @@ def _make_reading(
return r 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( def _make_tibber_price(
session: Session, session: Session,
*, *,
@@ -252,30 +293,37 @@ class TestFloorToQuarter:
class TestRegisterAt: 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: 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 assert result is None
def test_returns_most_recent_at_or_before_boundary(self, energy_db: Session) -> 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. # 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(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) _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() 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 is not None
assert result["d1"] == Decimal("100.0") assert result["d1"] == Decimal("100.0")
assert result["d2"] == Decimal("200.0") assert result["d2"] == Decimal("200.0")
def test_exact_boundary_included(self, energy_db: Session) -> None: 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) _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() 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 is not None
assert result["d1"] == Decimal("500.0") assert result["d1"] == Decimal("500.0")
def test_missing_register_key_returns_none(self, energy_db: Session) -> None: def test_missing_register_key_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading( r = DsmrReading(
recorded_at=_ts(10, 0), recorded_at=_ts(10, 0),
source_id=99, source_id=99,
@@ -284,10 +332,11 @@ class TestRegisterAt:
energy_db.add(r) energy_db.add(r)
energy_db.commit() energy_db.commit()
result = register_at(energy_db, _ts(10, 0)) result = register_at(energy_db, _ts(10, 0), meter)
assert result is None assert result is None
def test_null_register_value_returns_none(self, energy_db: Session) -> None: def test_null_register_value_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading( r = DsmrReading(
recorded_at=_ts(10, 0), recorded_at=_ts(10, 0),
source_id=88, source_id=88,
@@ -301,20 +350,61 @@ class TestRegisterAt:
energy_db.add(r) energy_db.add(r)
energy_db.commit() energy_db.commit()
result = register_at(energy_db, _ts(10, 0)) result = register_at(energy_db, _ts(10, 0), meter)
assert result is None assert result is None
def test_values_are_decimal(self, energy_db: Session) -> 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", _make_reading(energy_db, recorded_at=_ts(10, 0), d1="20915.154", d2="18372.099",
r1="1234.567", r2="890.123", source_id=1) r1="1234.567", r2="890.123", source_id=1)
energy_db.commit() 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 is not None
assert isinstance(result["d1"], Decimal) assert isinstance(result["d1"], Decimal)
assert result["d1"] == Decimal("20915.154") assert result["d1"] == Decimal("20915.154")
assert result["r2"] == Decimal("890.123") 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 # 1-2. compute_period — manual dual-tariff
@@ -351,12 +441,14 @@ _END_R2 = "3000.100"
def _setup_manual_scenario(session: Session) -> EnergyContractVersion: 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) contract = _make_contract(session, kind="manual", active=True)
version = _make_version( version = _make_version(
session, contract, _MANUAL_VALUES, session, contract, _MANUAL_VALUES,
effective_from=_ts(0, 0), # covers t0=10:00 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) # Start reading (at t0)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2, _make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) 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]: def _setup(self, session: Session, total: float = 0.25) -> tuple[EnergyContractVersion, TibberPrice]:
contract = _make_contract(session, kind="tibber", active=True) contract = _make_contract(session, kind="tibber", active=True)
version = _make_version(session, contract, _TIBBER_VALUES, effective_from=_ts(0, 0)) 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, _make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _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.""" """When Tibber price is absent for the period, no EnergyCostPeriod is written."""
contract = _make_contract(energy_db, kind="tibber", active=True) contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0)) _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, _make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _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.""" """A TibberPrice with starts_at > t0 must NOT be used; period is skipped."""
contract = _make_contract(energy_db, kind="tibber", active=True) contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0)) _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, _make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -643,9 +738,10 @@ class TestComputePeriodMissingTibberPrice:
class TestComputePeriodMissingReadings: class TestComputePeriodMissingReadings:
def test_degraded_when_start_reading_missing(self, energy_db: Session) -> None: 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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only an end reading; no start reading. # Only an end reading; no start reading.
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2) r1=_END_R1, r2=_END_R2, source_id=2)
@@ -663,7 +759,7 @@ class TestComputePeriodMissingReadings:
assert row.net_cost == 0.0 assert row.net_cost == 0.0
def test_degraded_when_end_reading_missing(self, energy_db: Session) -> None: 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 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 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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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. # 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, _make_reading(energy_db, recorded_at=_ts(10, 20), d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2) 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.""" """Degraded rows written due to missing readings have contract_version_id=None."""
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all. # No readings at all.
energy_db.commit() energy_db.commit()
@@ -711,6 +809,7 @@ class TestComputePeriodMissingReadings:
""" """
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# First compute: no readings at all → degraded. # First compute: no readings at all → degraded.
energy_db.commit() energy_db.commit()
@@ -786,6 +885,8 @@ class TestCrossVersionSelection:
effective_from=_ts(8, 0), effective_from=_ts(8, 0),
effective_to=None, effective_to=None,
) )
# Active meter covering the full test day.
_make_active_meter(session)
session.commit() session.commit()
return v1, v2 return v1, v2
@@ -857,9 +958,10 @@ class TestCrossVersionSelection:
class TestSummarize: class TestSummarize:
def _setup_two_periods(self, session: Session) -> None: 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) contract = _make_contract(session, kind="manual", active=True)
_make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(session)
# Period 1: [10:00, 10:15) # Period 1: [10:00, 10:15)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2, _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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all → degraded period. # No readings at all → degraded period.
energy_db.commit() energy_db.commit()
compute_period(energy_db, _T0) compute_period(energy_db, _T0)
@@ -977,6 +1080,7 @@ class TestSummarize:
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
energy_db.commit() energy_db.commit()
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits). # Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
@@ -1294,12 +1398,28 @@ class TestSummarizePrincipleC:
class TestComputeClosedPeriods: class TestComputeClosedPeriods:
def test_skips_future_periods(self, energy_db: Session) -> None: 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) 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() energy_db.commit()
# The period [now, now+15min) is still open — should not be computed. # 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) written = compute_closed_periods(energy_db)
assert written == 0 # nothing written (no closed periods with data) 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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1)) _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, _make_reading(energy_db, recorded_at=past_t0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=past_t1, d1=_END_D1, d2=_END_D2, _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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1)) _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. # First compute: no readings → degraded.
energy_db.commit() energy_db.commit()
compute_period(energy_db, past_t0) compute_period(energy_db, past_t0)
@@ -1386,6 +1510,7 @@ class TestRecomputeRange:
"""recompute_range must overwrite non-degraded rows (explicit opt-in).""" """recompute_range must overwrite non-degraded rows (explicit opt-in)."""
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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, _make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _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.""" """recompute_range returns the number of periods actually written."""
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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). # 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, _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.""" """Calling recompute_range twice must not create duplicate rows."""
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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, _make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1) r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2, _make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -1469,6 +1596,7 @@ class TestRecomputeRange:
# Step 1: successful compute. # Step 1: successful compute.
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
start_reading = _make_reading( start_reading = _make_reading(
energy_db, recorded_at=_T0, energy_db, recorded_at=_T0,
d1=_START_D1, d2=_START_D2, r1=_START_R1, r2=_START_R2, source_id=1, 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 ``_READING_MAX_STALENESS`` (15 minutes) old at a given boundary is treated
as absent so the period is marked degraded rather than producing a as absent so the period is marked degraded rather than producing a
zero-delta fake-success row. 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: def test_stale_reading_returns_none(self, energy_db: Session) -> None:
"""A reading older than 15 min before the boundary must be rejected.""" """A reading older than 15 min before the boundary must be rejected."""
reading_time = _ts(10, 0) # 10:00 reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 30) # 10:30 — 30 min later (> 15 min staleness) 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) _make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit() energy_db.commit()
result = register_at(energy_db, boundary) result = register_at(energy_db, boundary, meter)
assert result is None, ( assert result is None, (
"register_at must return None when the closest reading is more than " "register_at must return None when the closest reading is more than "
"15 minutes before the boundary" "15 minutes before the boundary"
@@ -1547,12 +1678,13 @@ class TestRegisterAtFreshness:
"""A reading within 15 min of the boundary must be returned normally.""" """A reading within 15 min of the boundary must be returned normally."""
reading_time = _ts(10, 5) # 10:05 reading_time = _ts(10, 5) # 10:05
boundary = _ts(10, 15) # 10:15 — only 10 min gap (within window) 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, _make_reading(energy_db, recorded_at=reading_time,
d1="30000.0", d2="15000.0", r1="1000.0", r2="500.0", d1="30000.0", d2="15000.0", r1="1000.0", r2="500.0",
source_id=1) source_id=1)
energy_db.commit() energy_db.commit()
result = register_at(energy_db, boundary) result = register_at(energy_db, boundary, meter)
assert result is not None, ( assert result is not None, (
"register_at must return the reading when it is within the 15-min staleness window" "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.""" """A reading exactly 15 min before the boundary sits at the edge — accepted."""
reading_time = _ts(10, 0) # 10:00 reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 15) # 10:15 — exactly 15 min gap boundary = _ts(10, 15) # 10:15 — exactly 15 min gap
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time, _make_reading(energy_db, recorded_at=reading_time,
d1="40000.0", d2="20000.0", r1="2000.0", r2="1000.0", d1="40000.0", d2="20000.0", r1="2000.0", r2="1000.0",
source_id=1) source_id=1)
energy_db.commit() 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 <) # boundary - reading == 15 min == staleness limit → NOT stale (strict <)
assert result is not None assert result is not None
@@ -1581,12 +1714,13 @@ class TestRegisterAtFreshness:
""" """
# Insert a reading at a realistic "now" time. # Insert a reading at a realistic "now" time.
reading_time = _ts(10, 0) # 10:00 on 2026-06-23 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) _make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit() energy_db.commit()
# Query with a far-future boundary (e.g. end of month, same day +8 hours) # 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 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, ( assert result is None, (
"register_at must return None for a far-future boundary rather than " "register_at must return None for a far-future boundary rather than "
"the latest historical reading" "the latest historical reading"
@@ -1615,6 +1749,7 @@ class TestFuturePeriodDegraded:
""" """
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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. # 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) _make_reading(energy_db, recorded_at=_ts(10, 0), source_id=1)
energy_db.commit() energy_db.commit()
@@ -1643,12 +1778,14 @@ class TestRecomputeRangeNoFuturePeriods:
future end datetime; the only periods that may be written are those 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. 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) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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. # No readings needed — we only care that future rows are NOT written.
energy_db.commit() energy_db.commit()
now = datetime.now(UTC)
far_future_end = now + timedelta(days=7) far_future_end = now + timedelta(days=7)
# Use a past start so there are some candidate periods. # Use a past start so there are some candidate periods.
past_start = floor_to_quarter(now - timedelta(minutes=30)) 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 We only verify that no future rows exist after step 1 (Fix B), because
that is the core slot-squatting prevention. that is the core slot-squatting prevention.
""" """
now = datetime.now(UTC)
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
# Contract effective from far past so all periods in scope have a version. # Contract effective from far past so all periods in scope have a version.
_make_version( _make_version(
energy_db, contract, _MANUAL_VALUES, energy_db, contract, _MANUAL_VALUES,
effective_from=datetime(2020, 1, 1, tzinfo=UTC), 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() energy_db.commit()
now = datetime.now(UTC)
far_future = now + timedelta(days=30) far_future = now + timedelta(days=30)
past_start = floor_to_quarter(now - timedelta(hours=1)) 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.""" """A normal period with readings seconds before each boundary → non-degraded."""
contract = _make_contract(energy_db, kind="manual", active=True) contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _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). # 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) # 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 # import_cost matches hand-calc: same deltas as standard scenario
assert abs(row.import_cost - 0.4101) < 1e-6 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