Detecting Negative Consumption Anomalies in Python
A negative consumption delta is one of the few meter readings that is always worth stopping the line for: left unchecked it turns into a phantom credit memo, a reconciliation break, and eventually a ratepayer dispute. This page is the focused counterpart to the broader Reading Anomaly Detection Algorithms workflow inside the Meter Data Ingestion & Validation Pipelines subsystem — where the parent page decides which reads are believable, this one solves the single hardest sub-problem: when curr_register - prev_register comes back below zero, is it a fixed-width register that rolled over, a legitimately reverse-flowing net-metered service, or genuine firmware drift that must be quarantined before it reaches the rate engine? Getting that three-way classification right is the difference between silently recovering a valid delta and silently issuing a credit no ordinance authorizes.
Prerequisites
Only a handful of assumptions and standard-library imports are needed; the classifier is deliberately dependency-light so it can run inline at the ingestion edge.
- Python 3.11+ — for
zoneinfoin the standard library and modern union syntax. - Pydantic v2 (
pydantic>=2.6) — the read contract that upstream Schema Validation & Data Quality Checks already enforce; this page assumes each read is structurally valid and focuses on the behavioral negative-delta case. decimal.Decimalfor every register and delta value — the delta becomes money, sofloatis never acceptable here; a binary rounding error on a cumulative register can flip a near-zero delta negative all by itself.- Per meter you have the previous validated
cumulative_register, the incomingcumulative_register, aflow_direction(forwardorreversefor net-metered services), and the register’s physicalmodulus— the wrap point of a fixed-width dial set (e.g.Decimal("100000")for a five-digit register).
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
Annotated implementation
The core insight is that a rollover and a true negative look identical to a naive subtraction but are trivially separable with modular arithmetic. When a five-digit register wraps from 99990 to 10, the raw delta is -99980, but the modular delta (10 - 99990) mod 100000 is 20 — a perfectly plausible interval. Genuine reverse flow, by contrast, is a small negative on a meter explicitly flagged for backfeed. Everything else is drift and gets quarantined.
The classifier is an ordered decision ladder: each test that fails falls through to the next, and the last branch is the fail-safe. The order matters — rollover is checked before reverse flow so a wrapped register on a net-metered service is recovered as consumption rather than mis-booked as a credit.
class DeltaClass(str, Enum):
NORMAL = "normal" # forward consumption, release to rating
ROLLOVER_CORRECTED = "rollover" # register wrapped; true delta recovered
REVERSE_FLOW = "reverse_flow" # net-metered backfeed, credit is valid
NEGATIVE_ANOMALY = "negative_anomaly" # firmware drift / bad read, quarantine
@dataclass(frozen=True)
class DeltaResult:
delta: Decimal # the delta to bill (kWh); never negative for NORMAL/ROLLOVER
classification: DeltaClass
raw_delta: Decimal # preserved for the audit trail
def classify_consumption_delta(
prev_register: Decimal,
curr_register: Decimal,
modulus: Decimal,
flow_direction: str = "forward",
# A rollover strands at most one interval's worth of the modulus, so the
# corrected delta must stay under a sane per-interval ceiling to count.
rollover_ceiling: Decimal = Decimal("1000"),
# Reverse flow is real but bounded; a huge negative is drift, not backfeed.
reverse_floor: Decimal = Decimal("-1000"),
) -> DeltaResult:
"""Classify a negative meter delta as rollover, reverse flow, or anomaly.
All arithmetic is Decimal: the delta feeds a charge, so a float rounding
error on a large cumulative register could itself manufacture a spurious
negative. Cumulative registers must already be validated as >= 0 upstream.
"""
raw_delta = curr_register - prev_register
# Fast path: ordinary forward consumption.
if raw_delta >= 0:
return DeltaResult(raw_delta, DeltaClass.NORMAL, raw_delta)
# Modular correction: what the delta would be if the register wrapped once.
# Decimal's % keeps the dividend's sign, so normalize into [0, modulus).
corrected = raw_delta % modulus
if corrected < 0:
corrected += modulus
if Decimal("0") < corrected <= rollover_ceiling:
# Only treat as rollover when the previous read sat near the wrap point;
# a small register jumping to a small register is NOT a rollover.
if prev_register >= modulus - rollover_ceiling:
return DeltaResult(corrected, DeltaClass.ROLLOVER_CORRECTED, raw_delta)
# Legitimate reverse flow: meter is flagged for backfeed and the negative
# magnitude is physically plausible for a net-metered interval.
if flow_direction == "reverse" and raw_delta >= reverse_floor:
return DeltaResult(raw_delta, DeltaClass.REVERSE_FLOW, raw_delta)
# Everything else is drift, a misread, or an out-of-order register reset.
return DeltaResult(raw_delta, DeltaClass.NEGATIVE_ANOMALY, raw_delta)
The ordering of the branches is the design: rollover is tested before reverse flow so that a wrapped register on a net-metered service is still recovered as consumption rather than mis-booked as a credit, and the anomaly branch is the default so any unexplained negative fails safe into quarantine instead of into rating. Because the previous validated register is required, the caller keeps a lightweight per-meter cache (Redis or a last_register column) so each classification is an O(1) lookup rather than a table scan.
Routing follows directly from the classification. NORMAL and ROLLOVER_CORRECTED release to the rate engine; REVERSE_FLOW posts a credit through the net-metering path; NEGATIVE_ANOMALY is diverted — never dropped — and, so the billing cycle still closes on time, handed to Fallback Routing for Missing Rate Data for a provisional, clearly flagged estimate pending review.
def route(result: DeltaResult, meter_id: str) -> str:
if result.classification in (DeltaClass.NORMAL, DeltaClass.ROLLOVER_CORRECTED):
return "rate_engine"
if result.classification is DeltaClass.REVERSE_FLOW:
return "net_metering_credit"
# Quarantine with the raw delta preserved for forensic review + audit.
return "quarantine"
Edge cases and billing gotchas
Negative deltas are where municipal meter data is most adversarial. These are the scenarios a naive if delta < 0 check gets wrong:
- Register rollover vs. reset. A wrap from
99990 → 10is recoverable; a register reset to zero after a meter swap looks nearly identical but is not consumption at all. Theprev_register >= modulus - rollover_ceilingguard keeps a genuine wrap from being confused with a swap where the old register was mid-range — a reset lands inNEGATIVE_ANOMALYand is held until the field-service work order is reconciled. - Small negative on a forward meter. A delta of
-0.4 kWhis too small to be a rollover and the meter is not flagged for backfeed, so it is firmware drift or a transmission glitch. Defaulting it to zero would silently under-bill; routing it to quarantine preserves the evidence and forces a decision. - Legitimate reverse flow (net metering). A rooftop-solar service should report negative deltas during peak generation. Those are only trusted when
flow_direction == "reverse"and the magnitude sits abovereverse_floor; a meter that suddenly reports reverse flow without the backfeed flag is an anomaly, not a credit. - Float-manufactured negatives. On a large cumulative register,
999999.99stored as a float and subtracted from the next read can produce a tiny spurious negative from binary rounding alone. Keeping the register asDecimalend to end removes this class of false anomaly entirely — the single most common cause of “phantom” negatives in legacy pipelines.
Verification snippet
Correctness here is best pinned with a fixture of known-outcome reads — one confirmed rollover, one reset, one net-metering credit, one drift artifact — so a change to a threshold fails loudly instead of quietly re-classifying live billing data.
M = Decimal("100000") # five-digit register modulus
def test_delta_classification() -> None:
# Ordinary forward consumption.
assert classify_consumption_delta(
Decimal("500"), Decimal("512"), M
).classification is DeltaClass.NORMAL
# Rollover near the wrap point: raw -99980, corrected to 20 kWh.
r = classify_consumption_delta(Decimal("99990"), Decimal("10"), M)
assert r.classification is DeltaClass.ROLLOVER_CORRECTED
assert r.delta == Decimal("20")
# Meter swap / reset from mid-range: NOT a rollover -> quarantine.
assert classify_consumption_delta(
Decimal("40000"), Decimal("5"), M
).classification is DeltaClass.NEGATIVE_ANOMALY
# Valid net-metered backfeed.
assert classify_consumption_delta(
Decimal("500"), Decimal("488"), M, flow_direction="reverse"
).classification is DeltaClass.REVERSE_FLOW
# Small negative on a forward meter: firmware drift.
assert classify_consumption_delta(
Decimal("500"), Decimal("499.6"), M
).classification is DeltaClass.NEGATIVE_ANOMALY
Run this suite against a replay of a historical billing window and assert the classifier reproduces the dispositions the billing team recorded manually. Every quarantined read should also emit a tamper-evident audit entry using the same hash-chained pattern described in the parent Reading Anomaly Detection Algorithms workflow, so a state auditor can later reconstruct exactly why a delta was rejected.
Frequently Asked Questions
How do I tell a register rollover apart from a true negative reading?
Apply the register modulus, not a subtraction. A wrap produces a raw delta close to the negative of the modulus, so the modular delta (curr - prev) % modulus recovers a small, plausible interval, and the previous read sits near the wrap point. A true negative — firmware drift or a reverse-flow glitch — produces a small raw negative whose modular correction would be implausibly large, so it fails the rollover test and is quarantined.
Should a negative delta ever be billed as zero?
No. Defaulting a negative to zero silently under-bills and destroys the evidence needed to diagnose the meter. A negative that is not a recoverable rollover or a valid net-metering credit is routed to quarantine and, so the cycle still closes, to fallback routing for a provisional estimate that is trued up once the real read or a field work order resolves.
Why must the register and delta be decimal.Decimal instead of float?
Because the delta becomes a charge, and binary floating point cannot represent most decimal fractions exactly. On a large cumulative register, float subtraction can manufacture a tiny negative delta from rounding alone, creating a phantom anomaly that never existed in the meter. decimal.Decimal end to end makes every delta exact and reproducible, which is also what the audit trail requires.
How does this handle net-metered solar customers who legitimately push power back?
Reverse flow is only trusted when the meter is explicitly flagged flow_direction == "reverse" and the negative magnitude is within a physically plausible floor. That combination routes to the net-metering credit path. A meter reporting reverse flow without the backfeed flag is treated as an anomaly, not a credit, so an unconfigured or mis-wired meter can never generate an unauthorized credit.
Where does this classifier sit relative to statistical anomaly detection?
It runs first, as a deterministic guardrail. Physically impossible or sign-inverted reads must never be left to a probabilistic model; they are resolved here with certainty and excluded from the statistical baseline so a single bad delta cannot distort the rolling mean and standard deviation that the isolation-forest stage judges every other read against.
Related Topics
- Reading Anomaly Detection Algorithms — the parent workflow; where this negative-delta guardrail fits in the full detection pipeline.
- Meter Data Ingestion & Validation Pipelines — the subsystem this page belongs to.
- Schema Validation & Data Quality Checks — the upstream contract that guarantees the well-formed, non-negative cumulative registers this classifier assumes.
- AMI/AMR Feed Synchronization Protocols — clock-drift and out-of-order handling that keeps synchronization artifacts from reading as negatives.
- Fallback Routing for Missing Rate Data — provisional handling for quarantined reads so the billing cycle still closes on schedule.
Up: Reading Anomaly Detection Algorithms · Meter Data Ingestion & Validation Pipelines