Computing Demand Ratchets with Rolling Windows

A demand ratchet is easy to state and easy to get wrong: billed demand for the month is the greater of this month’s peak and a fixed percentage of the highest peak over a trailing window of prior months. The trap is the window. “Trailing 11 months” has to mean exactly the eleven billing months before the current one — no more when history is long, no fewer when the account is new, and it must shed the month that set the maximum the instant that month ages past the window’s edge. Get the window wrong and a customer either keeps paying against an expired peak or escapes a ratchet that should still bind. This page sits under demand-charge and ratchet calculation, part of the broader Automated Rate Calculation & Rule Engines reference, and it isolates one thing: a rolling-window function that computes billed demand deterministically in Decimal.

Minimal Prerequisites

  • Python 3.11+ — for X | None unions and standard-library niceties; no third-party packages are needed for the window itself.
  • decimal.Decimal for every kW and percentage — the ratchet floor is a multiplication of a peak by a fraction, and that product must be exact and reproducible on re-rate.
  • Billing months as YYYY-MM strings — lexical order equals chronological order, so month comparison and sorting need no date parsing.

Data assumptions: each closed month contributes one already-settled billed peak in kW (the value actually billed, which may itself have been ratchet-lifted). The current month’s peak has already been reduced from interval data upstream; this function does not touch interval reads. The ratchet percentage and window length come from the account’s rate schedule.

Annotated Implementation

The whole ratchet lives in one small, pure function backed by an ordered history. Keeping it pure — inputs in, BilledDemand out, no I/O — is what makes it trivially replayable against historical accounts.

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP

FOUR_PLACES = Decimal("0.0001")


@dataclass(frozen=True, slots=True)
class BilledDemand:
    billed_kw: Decimal          # the kW that will be priced
    current_peak_kw: Decimal    # this month's metered peak
    ratchet_floor_kw: Decimal   # ratchet_pct * trailing maximum
    trailing_max_kw: Decimal    # highest billed peak in the window
    window_months: tuple[str, ...]   # exactly which months formed the window
    floor_binding: bool         # True when the ratchet set the bill, not this month


def billed_demand_with_ratchet(
    current_month: str,                 # "YYYY-MM" of the month being billed
    current_peak_kw: Decimal,           # this month's reduced peak
    history: dict[str, Decimal],        # prior months -> billed peak kW
    ratchet_pct: Decimal,               # e.g. Decimal("0.80") for an 80% ratchet
    window: int = 11,                   # trailing months considered
) -> BilledDemand:
    """billed = max(current peak, ratchet_pct * max(prior `window` billed peaks))."""
    if window < 1:
        raise ValueError("window must be >= 1")
    if not (Decimal("0") <= ratchet_pct <= Decimal("1")):
        raise ValueError("ratchet_pct must be a fraction in [0, 1]")

    # Take only months strictly before the current one, newest last, then the trailing slice.
    prior_months = sorted(m for m in history if m < current_month)
    window_months = tuple(prior_months[-window:])
    trailing_max = max((history[m] for m in window_months), default=Decimal("0"))

    # The floor is quantized once so the comparison and the ledger agree exactly.
    floor = (ratchet_pct * trailing_max).quantize(FOUR_PLACES, rounding=ROUND_HALF_UP)
    billed = max(current_peak_kw, floor)

    return BilledDemand(
        billed_kw=billed,
        current_peak_kw=current_peak_kw,
        ratchet_floor_kw=floor,
        trailing_max_kw=trailing_max,
        window_months=window_months,
        floor_binding=floor > current_peak_kw,
    )

Two details do the real work. Filtering m < current_month before slicing guarantees the current month can never be part of its own floor, and slicing [-window:] after an ascending sort keeps exactly the trailing months regardless of how deep the history goes — an account with three months of history yields a three-month window, one with five years yields exactly eleven. Returning window_months and trailing_max_kw alongside the result is what lets an auditor or a disputing customer see precisely which past month set the floor.

Rolling trailing-window ratchet floor versus the current-month peak Eleven prior billing months form a trailing window; the highest billed peak among them is the trailing maximum. The trailing maximum is multiplied by the ratchet percentage to produce the ratchet floor. Separately, the current month has its own metered peak. Billed demand is the greater of the current-month peak and the ratchet floor. trailing 11 months (prior billed peaks) max current peak (M0) trailing_max highest of prior 11 peaks ratchet floor ratchet_pct × trailing_max billed demand max(current peak, ratchet floor) floor current

Edge Cases and Billing Gotchas

The rolling window fails in a handful of specific, testable ways, and every one of them changes a commercial customer’s bill.

  • Fewer than N months of history. A new account cannot have eleven trailing months. The slice [-window:] simply returns everything available, so a three-month account ratchets against its own three-month maximum; the ratchet strengthens naturally as history accrues, and max(..., default=Decimal("0")) keeps a first-ever month floor-free rather than raising.
  • Tie peaks in the window. When two prior months share the highest peak, max returns that value once and the floor is unaffected — a tie never inflates the floor. If policy needs to attribute the floor to a specific month, report the earliest tie from window_months rather than assuming the last; the numeric result is identical either way.
  • Ratchet-percent boundary values. A ratchet_pct of Decimal("1") makes the floor equal the full trailing maximum (the customer never bills below its historical peak), and Decimal("0") disables the ratchet entirely. The guard rejects values outside [0, 1] so a percentage accidentally passed as 80 instead of 0.80 fails loudly instead of billing eighty times the peak.
  • Zero-demand current month. A vacant month yields a current peak of Decimal("0") while the floor stays positive, so floor_binding is true and the customer correctly pays the ratchet floor. This is the ratchet working as designed, not a defect — the demand-charge workflow’s audit record is what keeps it from being mistaken for one.

Verification Snippet

Exercise the window against a fixture that covers a binding ratchet, a short history, and the zero-demand case, asserting both the billed kW and which months formed the window.

def test_rolling_window_ratchet() -> None:
    history = {
        "2025-08": Decimal("420.0"),   # the summer peak that sets the floor
        "2025-09": Decimal("310.0"),
        "2025-10": Decimal("180.0"),
        "2025-11": Decimal("150.0"),
    }
    # Current month is low; 80% of the 420 kW summer peak should bind.
    result = billed_demand_with_ratchet(
        current_month="2025-12", current_peak_kw=Decimal("160.0"),
        history=history, ratchet_pct=Decimal("0.80"), window=11,
    )
    assert result.trailing_max_kw == Decimal("420.0")
    assert result.ratchet_floor_kw == Decimal("336.0000")     # 0.80 * 420
    assert result.billed_kw == Decimal("336.0000")            # floor beats 160
    assert result.floor_binding is True
    assert result.window_months == ("2025-08", "2025-09", "2025-10", "2025-11")

    # A new account with one prior month ratchets only against that month.
    fresh = billed_demand_with_ratchet(
        current_month="2026-02", current_peak_kw=Decimal("90.0"),
        history={"2026-01": Decimal("100.0")}, ratchet_pct=Decimal("0.80"),
    )
    assert fresh.ratchet_floor_kw == Decimal("80.0000")       # 0.80 * 100
    assert fresh.billed_kw == Decimal("90.0000")              # current peak wins
    assert fresh.floor_binding is False

    # Zero-demand month still pays the floor.
    idle = billed_demand_with_ratchet(
        current_month="2026-03", current_peak_kw=Decimal("0"),
        history={"2025-07": Decimal("500.0")}, ratchet_pct=Decimal("0.90"),
    )
    assert idle.billed_kw == Decimal("450.0000") and idle.floor_binding is True

Running this against real historical accounts — not synthetic peaks — is what surfaces the tariff-specific quirks, such as whether the window counts calendar months or billing cycles, before a ratchet reaches a ratepayer.

FAQ

Why filter months strictly before the current one?
The ratchet floor must be built only from prior history, never from the month being billed. Filtering m < current_month before taking the trailing slice guarantees the current peak cannot contribute to its own floor, which would otherwise make every month at least ratchet_pct of itself and quietly understate high-usage months. Because months are YYYY-MM strings, the lexical comparison is also the chronological one.
What happens when the account has fewer than eleven months of history?
The trailing slice returns whatever exists, so a young account ratchets against its own shorter history and a brand-new account with no prior months has a zero floor and simply bills its current peak. The window strengthens automatically as months accrue, with no special-casing, because max carries a Decimal zero default when the window is empty.
How is the ratchet floor kept decimal-exact?
The floor is a single multiplication of the trailing maximum by the ratchet percentage, both Decimal, quantized once to four places with ROUND_HALF_UP. Quantizing at that one boundary means the comparison against the current peak and the value written to the ledger are identical, so re-rating the same month reproduces the same billed kW to the digit.
Does a tie for the highest prior peak change the floor?
No. When two months share the highest billed peak, max returns that value once and the floor is unchanged. If a policy needs to attribute the floor to a particular month for the audit trail, pick the earliest of the tied months from window_months; the billed kW is the same regardless of which tied month is named.
Should the history store metered peaks or billed peaks?
Billed peaks. Once a ratchet lifts a month's billed demand above its metered demand, many tariffs carry the lifted value forward, so storing the metered peak would let a floor silently erode over time. Recording the value that was actually billed keeps the trailing maximum faithful to what the contract charged.

Up: Demand-Charge & Ratchet Calculation · Automated Rate Calculation & Rule Engines