Modeling Inclining Block Rates with Decimal
An inclining block rate charges each slice of a customer’s usage at its own, rising, per-unit price: the first block is cheap, every block above it costs more, and a household that consumes into the top block pays the high rate only on the volume that lands there. This is the conservation-oriented structure most water and electric utilities adopt, and it is exactly the arithmetic that goes wrong when a developer reaches for a float. The failure is quiet — a bill that reconciles a fraction of a cent off per account because a rate like 0.087 cannot be represented in binary, multiplied across a block width, and summed over a batch — and it is precisely what decimal.Decimal exists to prevent. This page is the focused, single-account counterpart to the conceptual treatment in step-rate versus block-rate structure design, part of the municipal utility billing architecture and rate taxonomy subsystem. Where that parent guide explains when to choose an inclining block schedule over a whole-volume step schedule, this page shows how to compute one charge from one usage figure, exactly, with the marginal boundary handled correctly.
Minimal Prerequisites
- Python 3.11+ — for
dataclasses,StrEnum-free simplicity, and theX | Noneunion syntax used for the open-ended top block. decimal.Decimalfor every rate, bound, and amount — neverfloat. The whole point of this page is exact base-10 money math; a singlefloatrate contaminates the result.- An ordered block schedule as governed configuration — the block bounds and per-unit rates are authoritative data, not code literals, and for an inclining schedule the rates must be non-decreasing from one block to the next.
Data assumptions: usage is a single non-negative Decimal quantity for one account and one cycle, already normalized to billable units (rollovers and reversals resolved upstream by the ingestion tier). The schedule is a list of blocks in ascending order; each block carries an inclusive upper limit and a marginal rate, and the final block’s limit is None, meaning it is open-ended and absorbs all remaining volume.
Annotated Implementation
The engine walks the blocks once, keeping a running lower bound. For each block it prices only the width of usage that falls inside that block — the volume above the running lower bound and up to this block’s limit — and stops as soon as the usage is exhausted. Every quantity is a Decimal, so no binary-float error can enter, and the result is quantized to cents a single time at the end.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
CENTS = Decimal("0.01")
@dataclass(frozen=True)
class Block:
"""One tier of an inclining schedule, priced on the half-open interval (lower, limit]."""
limit: Decimal | None # inclusive upper bound in billable units; None = open-ended top
rate: Decimal # marginal $/unit charged on volume inside this block
def charge_inclining_blocks(usage: Decimal, blocks: list[Block]) -> Decimal:
"""Charge `usage` under an inclining block schedule, marginal per block.
Each block is priced only on the volume that falls within it. The boundary
unit belongs to the LOWER block: a block with limit L covers units (lower, L].
"""
if usage < 0:
raise ValueError("usage cannot be negative")
total = Decimal("0")
lower = Decimal("0") # top of the volume already priced
for block in blocks:
if block.limit is None:
width = usage - lower # open-ended top takes the rest
else:
width = min(usage, block.limit) - lower # volume inside this block
if width <= 0:
break # usage already exhausted; no higher block applies
total += width * block.rate # exact Decimal arithmetic, no rounding yet
if block.limit is None or usage <= block.limit:
break # fully priced within this block
lower = block.limit # advance to the next block's floor
# Quantize ONCE, at the charge boundary — never per block.
return total.quantize(CENTS, rounding=ROUND_HALF_UP)
The subtle correctness point is the marginal boundary. Each block is priced on the half-open interval (lower, limit], so a customer whose usage lands exactly on a block’s limit fills that block completely and spills nothing into the next one — min(usage, block.limit) - lower yields the block’s full width, and the usage <= block.limit guard then stops the walk. This is what keeps a usage of exactly 1000 units from being charged a single unit at the higher rate above 1000. The open-ended top block is expressed as limit = None rather than a large sentinel number, so a very heavy user is never silently capped by a finite ceiling that would under-bill them. And because the loop accumulates in Decimal and quantizes only once, the returned charge is exact and reproducible — the same guarantee the tiered block-rate engine relies on when it prices a whole DataFrame at once rather than a single account.
Edge Cases and Billing Gotchas
Inclining block math fails in a few predictable, revenue-affecting ways. Encode each as a fixture before the schedule goes live.
- Usage exactly on a boundary. A customer at precisely a block’s limit must fill that block and leave the next one empty — not be charged one unit at the higher marginal rate. The half-open interval
(lower, limit]and theusage <= block.limitguard together guarantee this, so a usage of 1000 against a 500/1000/2000 schedule fills blocks 1 and 2 exactly and never touches block 3. Assert it against explicit boundary fixtures rather than trusting it by eye. - Open-ended top block. The final block must carry
limit = None, not a large finite ceiling. A finite top bound silently caps the highest-usage accounts and under-bills them;Nonelets the last block absorb every remaining unit regardless of how heavy the usage is. - Zero usage. A usage of
Decimal("0")yields a zero volumetric charge, not an error: the first block’s width ismin(0, limit) - 0 = 0, thewidth <= 0guard fires immediately, and the walk stops. Any fixed monthly service fee is a separate line applied on top — see surcharge and fee application logic — and is never folded into this volumetric function. - Rounding per block versus at the end. This engine accumulates every block product in
Decimaland quantizes to cents once, at the very end, which is the correct default and avoids seeding fractional-cent variance across blocks. Some tariffs, however, mandate that each block line print as a rounded amount; if your ordinance requires that, quantize eachwidth * ratebefore summing and document the choice — but never mix the two, because rounding per block and then re-rounding the total double-rounds and drifts from any independently computed control figure.
Verification Snippet
Confirm correctness with assertions that pin the marginal math, the exact-boundary case, the open-ended top, and zero usage. The most valuable test is the partial-top-block case, because that is where an off-by-one in the interval logic shows up.
from decimal import Decimal
SCHEDULE = [
Block(Decimal("500"), Decimal("0.08")),
Block(Decimal("1000"), Decimal("0.11")),
Block(Decimal("2000"), Decimal("0.15")),
Block(None, Decimal("0.19")), # open-ended top block
]
def test_usage_spanning_three_blocks() -> None:
# 500·0.08 + 500·0.11 + 300·0.15 = 40 + 55 + 45 = 140.00
assert charge_inclining_blocks(Decimal("1300"), SCHEDULE) == Decimal("140.00")
def test_usage_exactly_on_a_boundary_does_not_spill() -> None:
# Exactly 1000 fills blocks 1 and 2 only: 40 + 55 = 95.00
assert charge_inclining_blocks(Decimal("1000"), SCHEDULE) == Decimal("95.00")
def test_usage_into_open_ended_top_block() -> None:
# 500·0.08 + 500·0.11 + 1000·0.15 + 500·0.19 = 40 + 55 + 150 + 95 = 340.00
assert charge_inclining_blocks(Decimal("2500"), SCHEDULE) == Decimal("340.00")
def test_zero_usage_is_zero_charge() -> None:
assert charge_inclining_blocks(Decimal("0"), SCHEDULE) == Decimal("0.00")
def test_negative_usage_rejected() -> None:
try:
charge_inclining_blocks(Decimal("-1"), SCHEDULE)
except ValueError:
pass
else:
raise AssertionError("negative usage must raise")
Run this suite against a corpus of historical bills whose totals are already known, and confirm the engine reproduces every charge to the cent. A variance beyond a single cent on any account signals a float leaking into the schedule or a mis-ordered block bound, and it should block the batch before anything posts.
FAQ
Related Topics
- Step-Rate vs Block-Rate Structure Design — the parent guide on when to choose marginal block pricing over whole-volume step pricing.
- Municipal Utility Billing Architecture & Rate Taxonomy — the rate and account model this charge feeds.
- Implementing Tiered Block Rates with Pandas — vectorizing the same marginal math across a whole batch of accounts.
- Surcharge & Fee Application Logic — fixed fees and surcharges layered onto the volumetric charge this function returns.
- Fallback Routing for Missing Rate Data — what to do when the schedule an account needs cannot be resolved.
Up: Step-Rate vs Block-Rate Structure Design · Municipal Utility Billing Architecture & Rate Taxonomy