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 zoneinfo in 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.Decimal for every register and delta value — the delta becomes money, so float is 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 incoming cumulative_register, a flow_direction (forward or reverse for net-metered services), and the register’s physical modulus — 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.

Negative-delta classification ladder and routing A raw delta is computed as the incoming cumulative register minus the previous validated register using Decimal. It then passes down an ordered ladder of tests. First, if the raw delta is at least zero it is ordinary forward consumption, classified NORMAL and released to the rate engine. Otherwise, if the modular delta is small and the previous read sat near the wrap point it is a recovered register rollover, classified ROLLOVER_CORRECTED and released to the rate engine. Otherwise, if the meter is flagged for reverse flow and the negative magnitude is above the plausible floor it is valid net-metered backfeed, classified REVERSE_FLOW and routed to the net-metering credit path. Everything that falls through is classified NEGATIVE_ANOMALY and routed to quarantine plus fallback routing, never dropped. raw_delta = curr − prev raw_delta ≥ 0 ? ordinary forward consumption register rollover ? modular delta ≤ ceiling · prev near wrap valid reverse flow ? flagged reverse · raw ≥ floor NEGATIVE_ANOMALY → quarantine + fallback routing NORMAL → rate engine ROLLOVER_CORRECTED → rate engine REVERSE_FLOW → net-metering credit no no no yes yes yes
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 → 10 is recoverable; a register reset to zero after a meter swap looks nearly identical but is not consumption at all. The prev_register >= modulus - rollover_ceiling guard keeps a genuine wrap from being confused with a swap where the old register was mid-range — a reset lands in NEGATIVE_ANOMALY and is held until the field-service work order is reconciled.
  • Small negative on a forward meter. A delta of -0.4 kWh is 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 above reverse_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.99 stored as a float and subtracted from the next read can produce a tiny spurious negative from binary rounding alone. Keeping the register as Decimal end 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.

Up: Reading Anomaly Detection Algorithms · Meter Data Ingestion & Validation Pipelines