Billing fix: don't bill periods from stale/out-of-range DSMR readings
Root cause (found in the live DB): register_at() returned the latest reading for any boundary beyond the DSMR data range (a future instant or a long gap), so future periods got start==end -> delta 0 and were written as degraded=False 'ok' rows with cost 0. recompute_range() over a future end then filled the whole month with such fake rows, which then occupied the slots so the per-minute tick (immutability guard skips non-degraded rows) never recomputed the real data. - register_at: ignore a reading older than one period (15min) before the boundary -> returns None -> period is marked degraded instead of a fake 0. - recompute_range: only process closed periods (t1 <= now); never future ones. - Tests cover staleness window, out-of-range -> degraded, recompute skips future, and a regression that normal closed periods are unaffected.
This commit is contained in:
@@ -71,6 +71,15 @@ logger = logging.getLogger(__name__)
|
||||
_PERIOD_MINUTES = 15
|
||||
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
|
||||
|
||||
# Maximum age a DSMR reading may have relative to the boundary being queried.
|
||||
# Under normal operation DSMR readings arrive every ~10 seconds, so a reading
|
||||
# more than one period (15 minutes) old at the boundary indicates either a data
|
||||
# gap or — critically — a *future* boundary being resolved against the last
|
||||
# historical reading. In both cases the reading is considered stale and
|
||||
# ``register_at`` returns None, letting the period be marked degraded instead of
|
||||
# producing a spurious zero-delta "successful" row.
|
||||
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
||||
|
||||
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||
@@ -148,6 +157,13 @@ def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | No
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Freshness guard: reject readings that are too old relative to *boundary*.
|
||||
# ``recorded_at`` is stored as a naive UTC datetime in SQLite; attach UTC
|
||||
# tzinfo before comparing with *boundary* (which is always tz-aware UTC) to
|
||||
# avoid an "offset-naive vs offset-aware" TypeError.
|
||||
if _as_utc(row.recorded_at) < _as_utc(boundary) - _READING_MAX_STALENESS:
|
||||
return None
|
||||
|
||||
payload = row.payload or {}
|
||||
try:
|
||||
d1_raw = payload[_KEY_D1]
|
||||
@@ -437,18 +453,26 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int:
|
||||
"""
|
||||
t0 = floor_to_quarter(_as_utc(start))
|
||||
end_utc = _as_utc(end)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
written = 0
|
||||
while t0 < end_utc:
|
||||
try:
|
||||
did_write = compute_period(session, t0, overwrite=True)
|
||||
if did_write:
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"recompute_range: unexpected error for t0=%s — continuing.",
|
||||
t0.isoformat(),
|
||||
)
|
||||
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
|
||||
# Only recompute periods that have already closed (t1 ≤ now). Even
|
||||
# when the caller passes a future *end*, we must not write speculative
|
||||
# "future" rows: register_at would resolve to the latest historical
|
||||
# reading for both boundaries, producing a zero-delta fake-success row
|
||||
# that blocks the real computation once the period actually closes.
|
||||
if t1 <= now:
|
||||
try:
|
||||
did_write = compute_period(session, t0, overwrite=True)
|
||||
if did_write:
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"recompute_range: unexpected error for t0=%s — continuing.",
|
||||
t0.isoformat(),
|
||||
)
|
||||
t0 += timedelta(minutes=_PERIOD_MINUTES)
|
||||
|
||||
session.commit()
|
||||
|
||||
Reference in New Issue
Block a user