Step-Rate vs Block-Rate Structure Design in Municipal Utility Billing

Few rate-design choices ripple as far into the billing stack as step-rate versus block-rate pricing. The two structures use the same inputs — a consumption volume and a table of thresholds and prices — yet produce materially different bills, and a calculation engine that conflates them will overcharge or undercharge every account whose usage sits near a boundary. This guide sits under Municipal Utility Billing Architecture & Rate Taxonomy and treats the choice as an architectural commitment: it dictates the shape of your rate schema, the arithmetic of your charge engine, and the audit evidence a rate case will demand.

The specific workflow failure this guide addresses is the silent structural mismatch — a schedule authored as a block (marginal) rate but priced as a step (whole-volume) rate, or the reverse. Both structures read from an ordered list of (threshold, price) pairs, so a mislabeled schedule does not raise an error; it simply produces a plausible-looking, wrong charge. For an account that lands one kilowatt-hour over a tier edge, a whole-volume step re-prices the entire bill at the higher rate while a marginal block re-prices only that single unit — a divergence of tens of dollars on a residential bill and thousands on an industrial one. The remedy is to make the structure an explicit, validated property of the schedule, to implement each charge model as a separate decimal-safe function, and to record which model priced every invoice.

Prerequisites

This workflow targets a modern, reproducible Python stack. Pin the following before implementing anything below:

  • Python 3.11+ — required for zoneinfo in the standard library, Decimal | None union syntax, and StrEnum used to tag the structure type.
  • Pydantic v2 (pydantic>=2.6) — schedule contracts use v2 field_validator and model_validator semantics; v1 will not validate the monotonic-threshold rule these models enforce.
  • decimal.Decimal for all money and usage math — never float. A tier boundary crossing multiplied in binary floating point drifts by fractions of a cent, and across a full billing run those fractions become an unreconcilable variance.
  • zoneinfo (not pytz) — schedule effective dates are resolved against the read’s local civil service date so that a schedule taking effect at midnight applies to the correct billing day.

Data assumptions: each account carries a validated customer_class and a jurisdiction_code, both resolved upstream in Customer Class & Service Tier Mapping; a metered consumption_kwh arrives as a non-negative Decimal; and rate schedules are versioned records drawn from the authoritative rate repository whose write access is governed by Security Boundaries & Role-Based Access. Thresholds and prices are governed configuration, never code constants.

Architecture Overview

Rate structure resolution is a short, deterministic pipeline: a consumption record and a service date select a point-in-time schedule version; the schedule declares whether it is block (marginal) or step (whole-volume); a single dispatcher routes the volume to the matching charge function; the result is quantized once at the charge boundary; and an audit record captures the structure, the schedule version, and the resulting charge before the line is handed to the fee and surcharge layer.

Block-rate versus step-rate charge construction Two pricing pipelines side by side. The block-rate (marginal) column prices each tier's own slice of usage at that tier's rate and sums the slices into the charge. The step-rate (whole-volume) column locates the single band the total usage reaches and applies that one band's rate to all of the usage. Both read the same ordered table of thresholds and prices, so a mislabeled schedule silently produces the wrong number. BLOCK-RATE marginal — slice by slice STEP-RATE whole volume re-priced Tier 1 slice × r₁ Tier 2 slice × r₂ Tier 3 slice × r₃ ∑ slices = charge continuous across thresholds Total usage q Find band k that q reaches Apply rₖ to ALL of q charge = rₖ × q jumps at every threshold same ordered (threshold, price) table feeds both

Figure: Block-rate charges only the volume within each tier at that tier's marginal rate; step-rate re-prices the entire volume at the rate of the step the customer reaches — the distinction that drives schema and boundary handling.

Expressed as math, the two models differ only in how the volume q interacts with the ordered thresholds. A block-rate charge sums the volume that falls inside each band i (bounded below by t_{i-1} and above by t_i) at that band’s rate r_i:

Cblock=iri(min(q,ti)ti1)+C_{\text{block}} = \sum_{i} r_i \cdot \bigl(\min(q, t_i) - t_{i-1}\bigr)^{+}

A step-rate charge selects the single band whose range contains q and applies that band’s rate to the whole volume:

Cstep=rkqwheretk1<qtkC_{\text{step}} = r_{k} \cdot q \quad\text{where}\quad t_{k-1} < q \le t_{k}

Charge versus usage for block-rate and step-rate on identical thresholds A charge-versus-usage chart with two overlaid curves sharing the same axes and the same tier thresholds at 500 and 1500 kilowatt-hours. The block-rate curve is a continuous, upward-bending piecewise-linear line whose slope steepens at each threshold. The step-rate curve tracks a lower slope inside each band but jumps vertically at every threshold because crossing it re-prices all prior usage; these vertical segments are the cliffs. Above the first threshold the step-rate charge is always at or above the block-rate charge. Charge ($) Usage (kWh) t₁ = 500 t₂ = 1500 BAND 1 BAND 2 BAND 3 cliff cliff Block-rate (marginal) Step-rate (whole-volume)

Figure: On identical thresholds, block-rate rises smoothly and steepens per tier, while step-rate re-prices the whole bill at each crossing — the vertical cliffs are the one-kWh jumps that drive customer disputes.

The sections below implement each stage in order.

Step-by-Step Implementation

Step 1 — Model the rate schedule with a validated structure flag

The structure type must be an explicit, validated field of the schedule — never inferred from context at calculation time. A Pydantic v2 contract binds the ordered bands, the structure discriminator, and the effective-date window, and rejects the malformed tables that produce silent mispricing: unordered thresholds, a non-final band with no ceiling, or a negative price.

from __future__ import annotations
from datetime import date
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, Field, model_validator

class RateStructure(StrEnum):
    BLOCK = "block"   # marginal: each band priced only on its own slice
    STEP = "step"     # whole-volume: one band's rate applied to all usage

class RateBand(BaseModel):
    # None on the final band means "no ceiling".
    upper_kwh: Decimal | None = Field(default=None, ge=0)
    price_per_kwh: Decimal = Field(..., ge=0)

class RateSchedule(BaseModel):
    schedule_id: str = Field(..., pattern=r"^RS-[A-Z0-9]{4,}$")
    customer_class: str
    structure: RateStructure
    bands: list[RateBand] = Field(..., min_length=1)
    effective_from: date
    effective_to: date | None = None   # None = open-ended / current

    @model_validator(mode="after")
    def check_bands(self) -> "RateSchedule":
        uppers = [b.upper_kwh for b in self.bands]
        # Only the final band may be open-ended.
        if any(u is None for u in uppers[:-1]):
            raise ValueError("only the final band may omit upper_kwh")
        # Thresholds must be strictly increasing.
        finite = [u for u in uppers if u is not None]
        if finite != sorted(set(finite)):
            raise ValueError("band thresholds must be strictly increasing")
        if self.effective_to and self.effective_to <= self.effective_from:
            raise ValueError("effective_to must follow effective_from")
        return self

Making structure a required discriminator is what prevents the whole class of bug this design exists to stop: the same band list can no longer be priced two different ways by accident, because the schedule itself declares which engine is authoritative.

Step 2 — Implement the block-rate (marginal) charge engine

Block pricing allocates the volume across bands and charges each slice at its own rate. The loop walks the bands, prices the portion of usage that falls inside each, and stops once the volume is exhausted. Crucially, it quantizes exactly once, at the end — quantizing per band accumulates rounding error that reconciliation cannot later close.

from decimal import Decimal, ROUND_HALF_UP

CENTS = Decimal("0.01")

def block_charge(usage_kwh: Decimal, bands: list[RateBand]) -> Decimal:
    """Marginal pricing: only the volume within each band is charged
    at that band's rate."""
    total = Decimal("0")
    lower = Decimal("0")
    for band in bands:
        ceiling = band.upper_kwh if band.upper_kwh is not None else usage_kwh
        slice_kwh = max(Decimal("0"), min(usage_kwh, ceiling) - lower)
        total += slice_kwh * band.price_per_kwh
        lower = ceiling
        if band.upper_kwh is not None and usage_kwh <= band.upper_kwh:
            break
    return total.quantize(CENTS, rounding=ROUND_HALF_UP)

This is the same marginal-slice model reused to price provisional estimates in Fallback Routing for Missing Rate Data, and the tier-assignment mechanics scale up in the companion walkthrough Implementing Tiered Block Rates with Pandas, which vectorizes this loop across municipal-scale datasets.

Step 3 — Implement the step-rate (whole-volume) charge engine and expose the cliff

Step pricing does the opposite: it finds the single band whose range contains the total volume and applies that band’s rate to all of it. The engine is shorter, but the design work is in exposing the cliff — the discontinuity where crossing a threshold re-prices the entire bill. Returning the marginal cost of the boundary-crossing unit alongside the charge lets the billing UI and rate analysts surface that jump instead of hiding it.

def _select_step_band(usage_kwh: Decimal, bands: list[RateBand]) -> RateBand:
    for band in bands:
        if band.upper_kwh is None or usage_kwh <= band.upper_kwh:
            return band
    return bands[-1]  # defensive: open-ended final band should already match

def step_charge(usage_kwh: Decimal, bands: list[RateBand]) -> Decimal:
    """Whole-volume pricing: the reached band's rate applies to all usage."""
    band = _select_step_band(usage_kwh, bands)
    return (usage_kwh * band.price_per_kwh).quantize(CENTS, rounding=ROUND_HALF_UP)

def step_cliff_at(threshold: Decimal, bands: list[RateBand]) -> Decimal:
    """Cost of the single unit that crosses `threshold` under step pricing:
    the whole-bill jump a customer sees for one extra kWh."""
    below = step_charge(threshold, bands)
    above = step_charge(threshold + Decimal("1"), bands)
    return (above - below).quantize(CENTS, rounding=ROUND_HALF_UP)

The cliff is not a bug to be hidden — many statutes deliberately use step rates as a conservation signal — but it must be disclosed. A one-kWh crossing that adds a whole-bill jump is precisely the kind of sharp swing that generates customer disputes, so a well-designed step engine reports the cliff magnitude at each threshold for rate-case exhibits and pre-bill warnings.

Step 4 — Resolve the point-in-time schedule and dispatch by structure

A charge is only correct if it is priced under the schedule version that was in force on the read’s service date. When a Public Utilities Commission (PUC) order changes rates mid-cycle, applying today’s schedule retroactively to the whole period is a compliance failure. The resolver selects the version effective on the service date; a single dispatcher then routes to the engine the schedule itself declares.

from datetime import date

def resolve_schedule(
    schedules: list[RateSchedule], service_dt: date
) -> RateSchedule:
    """Return the schedule version in force on the read's service date."""
    candidates = [
        s for s in schedules
        if s.effective_from <= service_dt
        and (s.effective_to is None or service_dt <= s.effective_to)
    ]
    if not candidates:
        raise LookupError(f"no rate schedule effective on {service_dt.isoformat()}")
    # Most recently effective version wins if windows overlap.
    return max(candidates, key=lambda s: s.effective_from)

def calculate_charge(usage_kwh: Decimal, schedule: RateSchedule) -> Decimal:
    if usage_kwh < 0:
        raise ValueError("consumption_kwh cannot be negative")
    match schedule.structure:
        case RateStructure.BLOCK:
            return block_charge(usage_kwh, schedule.bands)
        case RateStructure.STEP:
            return step_charge(usage_kwh, schedule.bands)

The match on schedule.structure is the entire safety mechanism: the structure travels with the data, so there is exactly one place where block and step diverge, and it is driven by a validated field rather than a caller’s assumption. Seasonal multipliers and time-of-use windows layer on top of this resolver through Seasonal Rate Mapping & Calendar Logic, and the resulting energy charge is decorated with taxes and franchise fees by Surcharge & Fee Application Logic.

Step 5 — Emit a tamper-evident audit record

Every priced line must leave verifiable proof of how it was priced: which schedule version, which structure, and what charge. Each record chains the previous record’s digest into its own SHA-256 hash, forming an append-only log where altering any historical charge invalidates every hash after it. The record captures identifiers and amounts only — never personally identifiable information — satisfying the metadata-only discipline mandated by Data Governance & Privacy Compliance.

import hashlib
from datetime import datetime, timezone

def audit_charge(
    account_id: str,
    schedule: RateSchedule,
    usage_kwh: Decimal,
    charge: Decimal,
    prev_hash: str,
) -> dict:
    ts = datetime.now(timezone.utc).isoformat()
    payload = (
        f"{prev_hash}|{account_id}|{schedule.schedule_id}"
        f"|{schedule.structure.value}|{usage_kwh}|{charge}|{ts}"
    )
    digest = hashlib.sha256(payload.encode()).hexdigest()
    return {
        "account_id": account_id,
        "schedule_id": schedule.schedule_id,
        "structure": schedule.structure.value,
        "usage_kwh": str(usage_kwh),
        "charge": str(charge),
        "priced_at": ts,
        "prev_hash": prev_hash,
        "integrity_hash": digest,
    }

Recording the structure in the audit trail is what lets an auditor prove, years later in a rate case, that a given customer was billed marginally rather than on whole-volume — the single fact that most often separates a defensible bill from a refund.

Edge-Case Handling

Rate-structure engines live or die on their boundary conditions. The scenarios below are the ones that most often turn a correct-looking schedule into a wrong bill:

  • Exact-threshold usage. Consumption landing precisely on a band ceiling must resolve to one band, deterministically. Both engines use usage <= upper_kwh (inclusive upper bound), so a customer at exactly the threshold stays in the lower band rather than tipping into the next — the choice must be documented in the rate ordinance and matched in code.
  • Zero consumption. A zero read must return the fixed/minimum charge only, never a spurious first-band amount. block_charge(0, ...) and step_charge(0, ...) both return 0.00 for the energy component; the minimum charge is applied by the surcharge layer, not the tier engine.
  • Usage above the top band. With a marginal (block) schedule the final band must be open-ended (upper_kwh = None) or high-volume usage is under-billed; the schema validator enforces that only the last band omits a ceiling.
  • Mid-cycle PUC rate change. When an order takes effect partway through a cycle, price each read under the schedule version effective on its service date via resolve_schedule. Never apply the current version retroactively to the whole period — that is a reportable compliance error.
  • DST boundary service date. A read timestamped during the autumn “fall-back” hour is ambiguous in local time. Resolve the timestamp through zoneinfo and derive the service date from the local .date() so effective-date selection lands on the correct billing day.
  • Structure migration (step → block or block → step). A jurisdiction changing structure mid-year is two schedule versions with adjacent effective windows and different structure values — not an in-place edit. The dispatcher prices each cycle correctly with no code change because the structure travels with each version.
  • Negative or rollover register. A meter that wraps past its maximum can report a negative delta. That is corruption, not usage; calculate_charge raises, diverting the record to the anomaly and Fallback Routing for Missing Rate Data paths rather than pricing a nonsensical volume.

Verification and Audit Trail

Correctness is confirmed two ways: property-based checks that hold for any valid schedule, and replay against historical bills whose totals are already known.

from decimal import Decimal

def verify_structures(bands: list[RateBand]) -> None:
    """Invariants that must hold for any consumption volume."""
    # 1. At usage inside the first band, block and step agree exactly.
    q_low = Decimal("50")
    assert block_charge(q_low, bands) == step_charge(q_low, bands)

    # 2. Above the first threshold, step >= block (whole-volume re-pricing
    #    is never cheaper than marginal for an increasing rate schedule).
    q_high = Decimal("5000")
    assert step_charge(q_high, bands) >= block_charge(q_high, bands)

    # 3. Block pricing is monotonic in usage — more kWh never costs less.
    assert block_charge(q_high, bands) >= block_charge(q_low, bands)

Run the same suite against a fixture of historical bills whose reads and totals are known: reprice each with the resolved schedule version and confirm the charge reproduces the ledgered figure to the cent. A systematic few-cents drift across the fixture points to float leaking into the math or per-band quantization; a large single-account divergence at a threshold points to a step/block mislabel. To confirm the audit chain, recompute each integrity_hash from its prev_hash and stored fields and compare to the persisted digest — any mismatch localizes the exact record that was altered.

Troubleshooting

Symptom Likely cause Fix
Bill jumps sharply for one extra kWh near a tier edge Schedule is a step (whole-volume) structure — expected behavior Confirm structure matches the ordinance; disclose the cliff via step_cliff_at, or switch to a block schedule
Whole bill re-priced at the top rate when only some usage is high Block schedule priced with the step engine (mislabel) Set structure = BLOCK; the dispatcher then calls block_charge
High-usage accounts under-billed Final band has a finite ceiling, so top-tier volume is dropped Give the last band upper_kwh = None; the schema validator enforces this
Totals reconcile a few cents off float in the math or quantizing per band Use Decimal end to end; quantize once at the charge boundary with ROUND_HALF_UP
Charges differ by pricing run for the same read Overlapping effective windows resolved non-deterministically Let resolve_schedule pick the most recently effective version; keep windows non-overlapping in the repository
Retroactive re-pricing after a commission order Current schedule applied to the whole cycle Resolve by the read’s service date so pre-order usage keeps the prior version
LookupError: no rate schedule effective Effective-date gap between two versions Close the gap in the rate repository; route the record to fallback until corrected
Off-by-one-day pricing near midnight Service date derived in UTC instead of local civil time Resolve read_dt through zoneinfo and use the local .date()

Up: Municipal Utility Billing Architecture & Rate Taxonomy