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 the X | None union syntax used for the open-ended top block.
  • decimal.Decimal for every rate, bound, and amount — never float. The whole point of this page is exact base-10 money math; a single float rate 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.

Stacked inclining blocks with rising per-unit rates A worked inclining block charge for an account consuming 1300 units. The schedule has four blocks stacked from the bottom up, each priced at its own marginal rate that rises with the block. Block 1 covers 0 to 500 units at 0.08 per unit and is fully filled, charging 500 units for 40.00. Block 2 covers 500 to 1000 units at 0.11 per unit and is fully filled, charging 500 units for 55.00. Block 3 covers 1000 to 2000 units at 0.15 per unit but only 300 units of the account's usage reach it, charging 300 units for 45.00. Block 4 is the open-ended top block above 2000 units at 0.19 per unit and is not reached. The three filled block charges sum to a total of 140.00, quantized once to cents at the end. usage = 1300 units · marginal, rising per block Block 4 · above 2000 rate 0.19 / unit · open-ended not reached Block 3 · 1000–2000 rate 0.15 / unit · 300 units fall here 300 × 0.15 = 45.00 Block 2 · 500–1000 rate 0.11 / unit · 500 units (full) 500 × 0.11 = 55.00 Block 1 · 0–500 rate 0.08 / unit · 500 units (full) 500 × 0.08 = 40.00 boundary unit belongs to the lower block · interval (lower, limit] Σ blocks, quantized once 40.00 + 55.00 + 45.00 = 140.00

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 the usage <= block.limit guard 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; None lets 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 is min(0, limit) - 0 = 0, the width <= 0 guard 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 Decimal and 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 each width * rate before 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

What is the difference between an inclining block rate and a step rate?
An inclining block rate is marginal: each slice of usage is charged at its own block's rate, so only the volume above a boundary pays the higher price. A step rate is whole-volume: crossing a threshold reprices the entire consumption at the higher rate. Inclining blocks avoid the harsh cliff a step rate creates at each boundary, which is why conservation-oriented schedules almost always use them. The conceptual comparison lives in the parent guide on step-rate versus block-rate design.
Why use Decimal instead of float for the block math?
Because IEEE-754 float cannot represent most rate values such as 0.11 or 0.087 exactly. Multiplied by a block width and summed across the blocks and then across a batch, those tiny binary errors accumulate until the posted total drifts from an independently computed control figure — a variance an auditor will flag. Decimal stores base-10 values exactly, so every block product is precise and the single final quantize is the only place any rounding happens.
Which block does a usage figure that lands exactly on a boundary belong to?
The lower block. Each block is priced on the half-open interval from the previous boundary up to and including this block's limit, so a usage of exactly 1000 against a 500/1000/2000 schedule fills the first two blocks completely and puts nothing in the third. Getting this wrong charges a single unit at the higher marginal rate and produces a bill that is a fraction of a cent too high at every boundary customer.
How do I represent the top block that has no upper limit?
Set its limit to None rather than a large sentinel number. The engine treats a None limit as open-ended and prices all remaining volume above the previous boundary at that block's rate. A finite ceiling on the top block silently caps the heaviest users and under-bills them, which is a revenue leak that is invisible until an audit compares billed volume to metered volume.
Should each block charge be rounded, or only the final total?
Only the final total, by default. Accumulate each block's product in Decimal and quantize to cents once at the end, which avoids seeding fractional-cent variance across blocks. If an ordinance requires each printed block line to be a rounded amount, quantize each block product before summing and document that choice, but never round per block and then re-round the total — double-rounding drifts from the control figure.

Up: Step-Rate vs Block-Rate Structure Design · Municipal Utility Billing Architecture & Rate Taxonomy