Implementing Tiered Block Rates with Pandas
Tiered block rates look trivial on a rate card and turn subtle the moment you vectorize them. A block schedule prices consumption marginally — each tier’s volume is charged at that tier’s own rate — which is the model detailed in Step-Rate vs Block-Rate Structure Design. The failure this page resolves is the one that hides inside a fast Pandas pipeline: you clip consumption into tiers with NumPy, multiply by a rate vector, and ship a batch of bills that reconcile a few cents off per account because the arithmetic ran in IEEE-754 float. At municipal scale that fractional-cent drift is real revenue variance an auditor will flag, and a schedule whose rate codes did not all resolve will have silently billed some accounts at zero. The remedy is a vectorized slice that stays fast, a hard boundary between the float-speed clipping step and the decimal.Decimal money step, explicit quarantine for unmatched rate codes, and a reconciliation assertion that refuses to post a batch whose tiered charges do not tie to an independently derived control total.
Prerequisites
This targets a reproducible Python 3.11+ stack; assume the following imports and data shape and nothing else.
import pandas as pd
import numpy as np
from decimal import Decimal, ROUND_HALF_UP
Data assumptions: meter_reads is a DataFrame with one row per account for the cycle, carrying account_id, customer_class, service_tier, a jurisdiction_code derived from the service address, and a net_consumption already normalized to billable units (rollovers and reversals resolved upstream). A separate rate_lookup frame binds each (customer_class, service_tier) to a rate_schedule_id. The block schedule itself — ordered tier bounds and per-tier rates — is authoritative configuration, not a code literal; write access to it is governed by Security Boundaries & Role-Based Access. Any read that arrives with a null or unresolved schedule is out of scope here and belongs to Fallback Routing for Missing Rate Data; this page assumes the mapping either resolves or is quarantined.
Annotated Implementation
Two operations, kept deliberately separate. First a deterministic merge that routes any unmatched (customer_class, service_tier) pair to a quarantine frame instead of letting a left join leave NaN rates to be silently treated as zero. Missing rate codes are the primary way an automated cycle over-bills or under-bills without raising anything.
def map_rate_schedules(
meter_reads: pd.DataFrame, rate_lookup: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Merge reads to rate codes, diverting unmatched pairs to quarantine."""
merged = meter_reads.merge(
rate_lookup,
on=["customer_class", "service_tier"],
how="left",
indicator=True, # exposes which side each row matched on
)
valid = merged[merged["_merge"] == "both"].drop(columns="_merge").copy()
# Unmatched rows are a controlled state, not a defect to bill at zero.
quarantine = merged[merged["_merge"] == "left_only"].drop(columns="_merge").copy()
quarantine["quarantine_reason"] = "MISSING_RATE_CODE"
# A monotonic upper-bound column is the invariant every slice below relies on.
assert valid["tier_upper_bound"].is_monotonic_increasing, "tier bounds must ascend"
return valid, quarantine
The core charge engine. The clipping is vectorized in float for speed, then every currency multiplication crosses into Decimal before a single cent is accumulated — mixing float and Decimal raises TypeError, which is exactly the guardrail you want, because a silent float multiply is the drift you are trying to eliminate. Jurisdictional fees and assistance credits are applied after volumetric pricing, never before: a discount that shrank a tier volume would push usage across the wrong boundary and misprice the whole bill.
Figure: One account priced end to end. The clip step runs in fast float to split 8200 units into tier volumes; each volume then crosses into Decimal for its marginal multiply, the products sum to the tiered charge, and fixed fees, the floored assistance credit, and a single ROUND_HALF_UP quantize are applied last to yield the final bill.
# Block schedule loaded from governed config; shown inline for clarity.
LOWER_BOUNDS = np.array([0, 1000, 3000, 6000]) # unit floors per tier
UPPER_BOUNDS = np.array([1000, 3000, 6000, np.inf]) # inf = uncapped top tier
MARGINAL_RATES = [Decimal("0.0045"), Decimal("0.0062"),
Decimal("0.0081"), Decimal("0.0105")] # $/unit per tier
ZERO, CENTS = Decimal("0"), Decimal("0.01")
def calculate_block_charges(valid: pd.DataFrame) -> pd.DataFrame:
"""Marginal, decimal-safe block-rate charges over a DataFrame."""
consumption = valid["net_consumption"].to_numpy(dtype=float)
# Vectorized slice: clip each account's usage into every tier's window,
# then subtract the floor so each column holds the volume *in* that tier.
tier_volumes = np.clip(consumption[:, None], LOWER_BOUNDS, UPPER_BOUNDS) - LOWER_BOUNDS
tier_volumes = np.maximum(tier_volumes, 0) # never a negative tier volume
# Cross into Decimal for the money math; quantize once, at the end.
tiered = []
for row in tier_volumes:
total = ZERO
for vol, rate in zip(row, MARGINAL_RATES):
total += Decimal(str(vol)) * rate # Decimal(str(...)) avoids float repr drift
tiered.append(total)
valid = valid.copy()
valid["tiered_charge"] = pd.Series(tiered, index=valid.index)
# Overlays apply to the priced bill, not to consumption.
fixed_fees = valid.get("fixed_fees", pd.Series(ZERO, index=valid.index))
credits = valid.get("program_credits", pd.Series(ZERO, index=valid.index))
valid["gross_charge"] = valid["tiered_charge"] + fixed_fees
# Cap a credit at the gross charge — a bill never goes negative here.
valid["net_charge"] = (valid["gross_charge"] - credits).apply(
lambda x: x if x > ZERO else ZERO
)
valid["final_bill_amount"] = valid["net_charge"].apply(
lambda x: x.quantize(CENTS, rounding=ROUND_HALF_UP)
)
return valid
Quantizing exactly once, at the charge boundary, is the decisive move: rounding per tier would seed the very fractional-cent variance reconciliation cannot later close. The order of overlays matters too — franchise fees, stormwater assessments, and taxes resolve from jurisdiction_code and layer onto the priced volumetric charge, mirroring Surcharge & Fee Application Logic, while credits from Assistance Program Eligibility Taxonomy apply last and are floored so a percentage discount can never drive a net charge below zero.
Edge Cases and Billing Gotchas
- Exact threshold hits. An account landing on precisely 3000 units must fill tiers 1 and 2 and leave tier 3 at zero volume — not spill a unit into the next band. Because
np.clip(x, lower, upper) - loweryields zero for any tier whose floor is at or abovex, boundary values allocate exactly with no double counting. Verify this against boundary fixtures rather than trusting it by eye. - Uncapped top tier. The final
UPPER_BOUNDSentry must benp.inf. A finite ceiling silently caps the highest-usage accounts and under-bills them;inflets the clip carry all remaining volume into the top band. - Negative net consumption. A meter reversal can produce a negative
net_consumption.np.maximum(tier_volumes, 0)prevents a negative delta from generating a spurious credit, but a negative read is a defect, not a discount — flag it for manual review upstream rather than pricing it, consistent with the corruption-vs-gap distinction in Fallback Routing for Missing Rate Data. - Prorated and short cycles. When cycle lengths vary, tier bounds authored as monthly volumes over-charge a long cycle and under-charge a short one. Normalize consumption to a daily average, price against per-day tier bounds, then scale by actual cycle days — never apply a monthly tier table to a 40-day read.
Verification Snippet
Correctness is confirmed by tying the sum of tiered charges to an independently derived control total before anything posts. Compute each account’s expected charge the slow, obvious way and assert the vectorized engine agrees to the cent.
def control_total_charge(usage: Decimal) -> Decimal:
"""Reference marginal calculation, one account, no vectorization."""
total, lower = ZERO, Decimal("0")
for lo, hi, rate in zip(LOWER_BOUNDS, UPPER_BOUNDS, MARGINAL_RATES):
ceiling = Decimal(str(hi)) if np.isfinite(hi) else usage
slice_vol = max(Decimal("0"), min(usage, ceiling) - Decimal(str(lo)))
total += slice_vol * rate
return total.quantize(CENTS, rounding=ROUND_HALF_UP)
fixture = pd.DataFrame({
"account_id": ["A1", "A2", "A3"],
"net_consumption": [500, 3000, 8200], # sub-tier, exact edge, top tier
})
priced = calculate_block_charges(fixture)
for _, r in priced.iterrows():
expected = control_total_charge(Decimal(str(r["net_consumption"])))
assert r["tiered_charge"].quantize(CENTS) == expected, r["account_id"]
# Batch-level tie-out: the posted total must equal the reference total.
batch = priced["tiered_charge"].map(lambda x: x.quantize(CENTS)).sum()
reference = sum(control_total_charge(Decimal(str(u))) for u in fixture["net_consumption"])
assert batch == reference, "batch does not reconcile to control total"
Run the same tie-out against a corpus of historical bills whose totals are already known; a variance beyond a single cent per account signals a float leak or a mis-authored tier bound and blocks the batch. Every applied schedule, quarantine diversion, and reconciliation result should also emit a metadata-only audit record — account IDs, amounts, and timestamps, never PII — per Data Governance & Privacy Compliance, so an auditor can reconstruct exactly how each charge was priced.
Frequently Asked Questions
Related Topics
- Step-Rate vs Block-Rate Structure Design — the parent guide defining marginal block versus whole-volume step pricing.
- Municipal Utility Billing Architecture & Rate Taxonomy — the rate and account model this engine prices against.
- Fallback Routing for Missing Rate Data — where quarantined, unresolved rate codes are estimated and trued up.
- Surcharge & Fee Application Logic — the jurisdictional fee layer applied to the priced volumetric charge.
- Data Governance & Privacy Compliance — the metadata-only audit discipline every applied charge must satisfy.
Up one level: Step-Rate vs Block-Rate Structure Design · Parent guide: Municipal Utility Billing Architecture & Rate Taxonomy · Return to the utilitybilling.org home.