Interpolating Mileage-Break Rates in Python

Some truckload and volume contracts quote a rate at discrete mileage break points and expect you to interpolate linearly between them; snapping to the nearest break or applying the wrong rule mis-charges every shipment that falls between two breaks.

The failure you are hitting

The contract lists a rate at 100, 250, 500, and 1000 miles and says nothing else — because the pricing analyst assumed you would draw a straight line between the points. Your lookup instead treats those breaks as flat bands or snaps to the closest one, and the result is a systematic error on every mid-band move:

  • A 375-mile shipment is billed the 250-mile rate because the lookup floors to the nearest break at or below the distance. The contract expected the rate halfway between the 250 and 500 points, so the audit passes a charge that is several percent light and quietly under-recovers.
  • A 900-mile move gets the 1000-mile rate by rounding up, over-charging the shipper on a distance the contract prices lower.
  • A 1400-mile haul, past the last quoted break, either errors out or reuses the 1000-mile rate flat, when the contract’s intent — debatable and therefore dangerous to guess — was to hold the last rate, not extrapolate the trend.

This is the interpolation counterpart to the flat-band lookup in Mileage-Band Rate Lookups: the same distance resolution, a different rate rule, and a failure that only shows on the distances between the quoted points.

Root cause analysis

A break-point schedule is not a band table, and treating it like one is the whole bug. Three conditions produce the mis-charge, and each is invisible on a shipment that happens to land exactly on a quoted break:

  1. Step versus linear interpretation. A band table holds one rate flat across a range; a break-point schedule holds a rate at a point and expects a straight line to the next point. Applying step semantics to a linear schedule under-charges on the way up to each break and over-charges just past it — the error is largest at the midpoint between two breaks and zero on the breaks themselves, which is exactly why sample tests on round-number distances never catch it.
  2. Extrapolation past the last break. Beyond the final quoted point there is no next point to interpolate toward. Extending the last segment’s slope extrapolates a rate the contract never quoted; the safe and usually-correct policy is to clamp — hold the last break’s rate — but that must be a declared decision, not an accident of which branch the code fell into.
  3. Float precision at the boundary. Interpolation multiplies and divides distances and rates, and float arithmetic makes a shipment sitting exactly on a break resolve to a rate a hair off the quoted value. That sub-cent drift becomes phantom variance downstream, where it is indistinguishable from a real overcharge.
Interpolating a rate between mileage break points A mileage value enters a bracket search that locates the two adjacent break points surrounding it, a lower break and an upper break. A decision node then chooses between step interpretation, which holds the lower break's rate flat, and linear interpretation, which draws a straight line between the lower and upper break rates and reads the rate at the mileage value. A separate branch handles a mileage value beyond the last break, applying a clamp policy that holds the final rate. All paths converge on a resolved rate output. Mileage value e.g. 375 mi Bracket search lower break: 250 upper break: 500 rate rule? step / linear Step hold lower rate flat Linear line between breaks read rate at 375 Past last break clamp: hold final rate Resolved rate Decimal-exact
The mileage value is bracketed by its two adjacent break points, then a declared rate rule selects step or linear interpolation between them — with a distinct clamp branch for distances past the last quoted break, so nothing extrapolates a rate the contract never priced.

Reproducible diagnostic

Confirm the schedule is linear before you change anything. This snippet reads the break points, computes what step and linear rules each return for a mid-band distance, and shows the gap between them:

from decimal import Decimal

# rate quoted AT each break point (per mile), from the contract
breaks = [(100, Decimal("3.10")), (250, Decimal("2.80")),
          (500, Decimal("2.50")), (1000, Decimal("2.15"))]

miles = 375
# step rule: floor to the break at or below the distance
step_rate = max(r for m, r in breaks if m <= miles)
# linear rule: interpolate between the bracketing breaks
lo = max((b for b in breaks if b[0] <= miles), key=lambda b: b[0])
hi = min((b for b in breaks if b[0] > miles), key=lambda b: b[0])
frac = Decimal(miles - lo[0]) / Decimal(hi[0] - lo[0])
linear_rate = lo[1] + frac * (hi[1] - lo[1])

print(f"step rate:   {step_rate}")
print(f"linear rate: {linear_rate}")
print(f"gap:         {abs(step_rate - linear_rate)}")

Read the result against the schedule’s intent:

Signal Likely cause Where to fix
gap is a meaningful fraction of a cent per mile step applied to a linear schedule interpolate linearly (Step 2)
distance sits exactly on a break, rate is off by 0.00001 float precision drift Decimal-exact math (Step 2)
distance exceeds the largest break no extrapolation policy declared clamp (Step 3)
contract text says “flat within band” genuinely a step schedule use the band lookup instead

If the contract truly quotes flat bands, this page does not apply — build a band table as in Building Mileage-Band Rate Lookups from PCMILER Exports instead. The diagnostic exists to tell the two schedule shapes apart before you pick a rule.

Resolution path

The fix is a bracket search that finds the surrounding breaks, a Decimal-safe linear interpolation between them, and an explicit clamp policy past the last break. Pin the dependencies:

# requirements.txt
pydantic==2.10.6
structlog==24.4.0

Step 1 — Bracket the mileage value

Sort the break points once and use bisect to find the two that surround the distance in O(log n). Handle the three positional cases — before the first break, on or between breaks, and past the last — as distinct returns so the interpolation step never sees an out-of-range index.

# interp/bracket.py
import bisect
from decimal import Decimal
from typing import List, Tuple, Optional


class BreakSchedule:
    def __init__(self, points: List[Tuple[int, Decimal]]) -> None:
        if len(points) < 2:
            raise ValueError("need at least two break points to interpolate")
        ordered = sorted(points, key=lambda p: p[0])
        self._miles = [m for m, _ in ordered]
        self._rates = [r for _, r in ordered]

    def bracket(self, miles: int) -> Tuple[Optional[int], Optional[int]]:
        """Return (lower_idx, upper_idx) surrounding miles.

        A None on either side signals a clamp case for the caller.
        """
        if miles <= self._miles[0]:
            return (0, None)                       # at/below first break -> clamp low
        if miles >= self._miles[-1]:
            return (len(self._miles) - 1, None)    # at/above last break -> clamp high
        hi = bisect.bisect_right(self._miles, miles)
        return (hi - 1, hi)                         # strictly between two breaks

Step 2 — Interpolate linearly with Decimal

Between two breaks, read the rate off the straight line joining them. Keeping the whole computation in Decimal — including the fraction — means a distance sitting exactly on a break returns the quoted rate to the cent, with no float drift.

# interp/linear.py
from decimal import Decimal, ROUND_HALF_UP


def interpolate(schedule: "BreakSchedule", miles: int) -> Decimal:
    lo_idx, hi_idx = schedule.bracket(miles)
    if hi_idx is None:
        # clamp case handled in Step 3; return the anchored break rate
        return schedule._rates[lo_idx]
    m_lo, m_hi = schedule._miles[lo_idx], schedule._miles[hi_idx]
    r_lo, r_hi = schedule._rates[lo_idx], schedule._rates[hi_idx]
    # fraction of the way from the lower break to the upper break
    frac = Decimal(miles - m_lo) / Decimal(m_hi - m_lo)
    rate = r_lo + frac * (r_hi - r_lo)
    # quantize to the contract's rate precision, not the charge precision
    return rate.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)

Step 3 — Apply an explicit clamp policy

Past the last break there is no line to draw. Make the policy a declared choice rather than an emergent behaviour: CLAMP holds the final rate, and any other intent must be spelled out in the contract before the code will extrapolate.

# interp/policy.py
from decimal import Decimal
from enum import Enum


class ExtrapolationPolicy(str, Enum):
    CLAMP = "CLAMP"          # hold the last break's rate (default, safest)
    REJECT = "REJECT"        # refuse distances past the last break


def resolve_rate(schedule: "BreakSchedule", miles: int,
                policy: ExtrapolationPolicy) -> Decimal:
    lo_idx, hi_idx = schedule.bracket(miles)
    beyond_last = hi_idx is None and miles >= schedule._miles[-1]
    if beyond_last and policy is ExtrapolationPolicy.REJECT:
        raise ValueError(
            f"{miles} miles is past the last break {schedule._miles[-1]}; "
            f"no extrapolation policy permits a rate"
        )
    return interpolate(schedule, miles)      # CLAMP falls through to the anchored rate

Verification

Test the midpoints, not the breaks — the error is zero on a quoted point and largest between two. These assertions pin the linear result, the exact-break value, and the clamp behaviour.

import pytest
from decimal import Decimal

SCHEDULE = BreakSchedule([
    (100, Decimal("3.10")), (250, Decimal("2.80")),
    (500, Decimal("2.50")), (1000, Decimal("2.15")),
])


def test_midpoint_interpolates_linearly():
    # halfway between 250mi/2.80 and 500mi/2.50 is 375mi/2.65
    assert interpolate(SCHEDULE, 375) == Decimal("2.6500")


def test_exact_break_returns_quoted_rate():
    # sitting on a break must return the quoted rate with no drift
    assert interpolate(SCHEDULE, 500) == Decimal("2.5000")


def test_clamp_holds_last_rate():
    r = resolve_rate(SCHEDULE, 1400, ExtrapolationPolicy.CLAMP)
    assert r == Decimal("2.15")


def test_reject_policy_raises_past_last_break():
    with pytest.raises(ValueError):
        resolve_rate(SCHEDULE, 1400, ExtrapolationPolicy.REJECT)

In production, log the interpolation fraction and the bracketing break pair on every resolution. A fraction that clusters at 0.0 or 1.0 means shipments are landing on breaks and interpolation is dormant; a fraction stuck at exactly 0.0 for mid-band distances is the signature of a step rule sneaking back in.

Preventive configuration

Encode the rule and the extrapolation policy so a future change is a config edit with a test, not a silent regression:

mileage_break_interp:
  rate_rule: linear             # linear | step — must match the contract
  extrapolation: CLAMP          # CLAMP | REJECT past the last break
  rate_precision: 4             # decimal places for the interpolated rate
  arithmetic: decimal           # never float in the interpolation path
  require_two_breaks: true      # a single point cannot interpolate
  • Rule assertion. Store rate_rule alongside the schedule and assert it matches the contract’s language; a linear schedule loaded as step fails the check.
  • Midpoint regression test. Keep a fixture at the midpoint between each break pair so a rule change fails a test rather than mis-charging live shipments.
  • Decimal-only path. Lint the interpolation module to forbid float, closing the sub-cent drift that produces phantom variance.
  • Shared distance source. The mileage value fed here comes from the same practical-miles resolution used across FTL Base Rate Extraction, so distance and rate rule never disagree on routing type.

FAQ

How do I know whether my contract wants step or linear rates?

The contract language decides it. A schedule that says “rate applies through the next break” is step; one that quotes a rate “per mile at” each break and expects a straight line between them is linear. When the wording is ambiguous, the diagnostic gap between the two rules on a mid-band distance shows how much money rides on the answer — confirm with the carrier rather than guessing.

What rate applies past the last quoted break?

Clamp to the last break’s rate unless the contract explicitly says otherwise. Extending the final segment’s slope extrapolates a number the carrier never quoted and is hard to defend in a dispute, so the safe default holds the last rate flat. Make it a declared CLAMP policy so the behaviour is intentional and testable, not an accident of the code path.

Why do I get a rate a fraction of a cent off on exact break points?

Float arithmetic. Interpolation divides and multiplies distances and rates, and float representation makes a distance sitting exactly on a break resolve a hair off the quoted value. Keep the entire path in Decimal, including the interpolation fraction, and an exact-break distance returns the quoted rate to the cent every time.

Can I interpolate with only one break point?

No — interpolation needs two points to define a line. A single quoted rate is a flat rate; treat it as one and skip the bracket search. The schedule constructor rejects a single point precisely so this case surfaces as a load error instead of a divide-by-zero deep in the interpolation math.

Up one level: Mileage-Band Rate Lookups · Section: Freight Contract Architecture & Rate Mapping