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
zoneinfoin the standard library,Decimal | Noneunion syntax, andStrEnumused to tag the structure type. - Pydantic v2 (
pydantic>=2.6) — schedule contracts use v2field_validatorandmodel_validatorsemantics; v1 will not validate the monotonic-threshold rule these models enforce. decimal.Decimalfor all money and usage math — neverfloat. 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(notpytz) — 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.
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:
A step-rate charge selects the single band whose range contains q and applies that band’s rate to the 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, ...)andstep_charge(0, ...)both return0.00for 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
zoneinfoand 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
structurevalues — 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_chargeraises, 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() |
Related Topics
- Municipal Utility Billing Architecture & Rate Taxonomy — the parent guide covering the full rate and account model this structure design plugs into.
- Customer Class & Service Tier Mapping — resolves the class and tier that select which schedule applies.
- Fallback Routing for Missing Rate Data — reuses the marginal-slice model to price provisional estimates when a read is missing.
- Seasonal Rate Mapping & Calendar Logic — layers seasonal multipliers and time-of-use windows over the resolved schedule.
- Surcharge & Fee Application Logic — decorates the energy charge with taxes, franchise fees, and jurisdictional surcharges.
- Implementing Tiered Block Rates with Pandas — vectorizes the block engine across municipal-scale datasets.