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 | Noneunions and standard-library niceties; no third-party packages are needed for the window itself. decimal.Decimalfor 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-MMstrings — 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.
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, andmax(..., 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,
maxreturns 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 fromwindow_monthsrather than assuming the last; the numeric result is identical either way. - Ratchet-percent boundary values. A
ratchet_pctofDecimal("1")makes the floor equal the full trailing maximum (the customer never bills below its historical peak), andDecimal("0")disables the ratchet entirely. The guard rejects values outside[0, 1]so a percentage accidentally passed as80instead of0.80fails 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, sofloor_bindingis 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
Related Topics
- Demand-Charge & Ratchet Calculation — the full workflow this window plugs into, from interval kW to priced demand.
- Automated Rate Calculation & Rule Engines — the parent reference for deterministic, auditable rate computation.
- Customer Class & Service-Tier Mapping — where the ratchet percentage and window length are resolved for an account.
- Surcharge & Fee Application Logic — per-kW riders applied after billed demand is settled.
Up: Demand-Charge & Ratchet Calculation · Automated Rate Calculation & Rule Engines