Prorating Shared-Corridor Revenue Across Operators
The task on this page is to split one collected fare across the operators who jointly serve a shared corridor, dividing it by an agreed weighting — route distance, vehicle-hours, or a flat contractual split — so the operator shares sum back to the exact fare with no cent invented or lost. When a single ticket carries a rider along a trunk corridor served by a city bus for two stops and a regional rail line for eight, both operators have a claim on that fare, and the split has to be reproducible to the cent across millions of journeys. This is a deep-dive within Inter-Agency Settlement Clearing, inside Revenue Reconciliation & Settlement; the shares it produces are the obligations the clearing engine later nets.
The corridor case is distinct from a two-tap transfer split. A cross-operator transfer, whose eligibility is decided in calculating cross-operator transfer windows with Python, divides a fare between exactly two legs at a boundary crossing. A corridor split can span three or more operators along one continuous priced segment, weighted by a physical measure rather than a flat agreement, so the remainder handling has to stay exact across an arbitrary number of legs. This page owns that multi-leg corridor split; it takes the transfer decision as already-made upstream context and does not re-derive it.
Corridor Proration Flow
The split runs as a short pipeline: pick the weighting basis from the corridor agreement, compute each leg’s exact real-valued share, floor every share to whole cents, then hand the leftover cents to the legs with the largest fractional remainders until the parts re-sum to the collected fare. The final equality check is not decorative — it is the guarantee the settlement ledger depends on.
Given a collected fare in whole cents and an agreed non-negative weight for each corridor leg, every leg’s raw share is the proportional slice
Because almost never divides the total weight into whole cents, the raw shares are real numbers that must be resolved to integer cents without breaking . The largest-remainder method does exactly that: floor every share, then allocate the handful of leftover cents to whichever legs were rounded down the most. Kept in Decimal, the reconstructed shares provably re-sum to .
Step 1 — Model the Corridor and Its Weight Bases
A corridor split needs more than a bare weight per operator; the weight is derived from a physical measure that the settlement agreement names. Model each leg with its raw measures and select the basis at split time, so the same corridor definition can be re-prorated under distance today and vehicle-hours after a contract renegotiation without editing leg data.
from __future__ import annotations
import logging
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
logger = logging.getLogger("transit.settlement.corridor")
class WeightBasis(Enum):
DISTANCE_KM = "distance_km"
VEHICLE_HOURS = "vehicle_hours"
FLAT = "flat"
class ProrationError(Exception):
"""Base exception for corridor proration failures."""
class ConservationError(ProrationError):
"""Reconstructed shares failed to sum to the collected fare."""
@dataclass(frozen=True)
class CorridorLeg:
operator_id: str
distance_km: Decimal # revenue kilometres this operator carried
vehicle_hours: Decimal # in-service vehicle-hours on the segment
flat_weight: Decimal # contractually fixed split weight
def weight_for(self, basis: WeightBasis) -> Decimal:
table = {
WeightBasis.DISTANCE_KM: self.distance_km,
WeightBasis.VEHICLE_HOURS: self.vehicle_hours,
WeightBasis.FLAT: self.flat_weight,
}
weight = table[basis]
if weight < Decimal("0"):
raise ProrationError(f"Negative {basis.value} weight for {self.operator_id}.")
return weight
Distances and hours are Decimal too, not float — a corridor measured to three decimal kilometres still has to produce a deterministic weight, and mixing a binary float measure into a monetary split reintroduces exactly the drift the split is meant to avoid.
Step 2 — Split with the Largest-Remainder Method
The split takes the collected fare and the chosen basis, computes each leg’s exact Decimal share, floors it, and then walks the legs in descending remainder order handing out the leftover cents one at a time. Ties are broken deterministically by operator id so the same corridor always prorates identically — a settlement that shifts a cent between operators on re-run is a dispute waiting to happen.
from decimal import ROUND_DOWN
from typing import Sequence
def prorate_corridor(
fare_cents: Decimal,
legs: Sequence[CorridorLeg],
basis: WeightBasis,
) -> dict[str, Decimal]:
"""Split a collected corridor fare across legs by the chosen basis so the
integer-cent shares sum exactly to fare_cents."""
if fare_cents < Decimal("0"):
raise ProrationError("Collected fare cannot be negative.")
if not legs:
raise ProrationError("A corridor split needs at least one leg.")
fare = fare_cents.quantize(Decimal("1"))
weights = {leg.operator_id: leg.weight_for(basis) for leg in legs}
total_weight = sum(weights.values(), Decimal("0"))
if total_weight <= Decimal("0"):
raise ProrationError(f"Total {basis.value} weight must be positive.")
floors: dict[str, Decimal] = {}
remainders: dict[str, Decimal] = {}
for op, w in weights.items():
exact = fare * (w / total_weight)
whole = exact.quantize(Decimal("1"), rounding=ROUND_DOWN)
floors[op] = whole
remainders[op] = exact - whole
leftover = int(fare - sum(floors.values()))
# Rank by remainder descending, breaking ties by operator id for determinism.
ranked = sorted(remainders, key=lambda op: (-remainders[op], op))
for op in ranked[:leftover]:
floors[op] += Decimal("1")
allocated = sum(floors.values(), Decimal("0"))
if allocated != fare:
raise ConservationError(
f"Corridor shares summed to {allocated}, expected {fare}."
)
logger.info(
"Prorated %s cents across %d legs on %s basis.",
fare, len(legs), basis.value,
)
return floors
The leftover is guaranteed to be a small non-negative integer strictly less than the number of legs, because flooring each share can only ever remove a fractional part below one cent. Distributing exactly that many cents to the highest-remainder legs is what closes the gap, and the closing ConservationError guard turns any arithmetic slip into an immediate, traceable failure rather than a silent cent leak into settlement.
Validation & Test Cases
Exercise the split against a clean even division and against a fare that cannot divide evenly. The even split proves the happy path; the odd-cent case proves the remainder is placed deterministically and the parts still re-sum to the fare.
from decimal import Decimal
legs = [
CorridorLeg("CITY_BUS", Decimal("2.0"), Decimal("0.20"), Decimal("1")),
CorridorLeg("REGIONAL_RAIL", Decimal("8.0"), Decimal("0.40"), Decimal("1")),
]
# Even case: a 500c fare on a 2km / 8km corridor splits 20% / 80% cleanly.
shares = prorate_corridor(Decimal("500"), legs, WeightBasis.DISTANCE_KM)
assert shares == {"CITY_BUS": Decimal("100"), "REGIONAL_RAIL": Decimal("400")}
assert sum(shares.values()) == Decimal("500")
# Odd-cent case: 101c split three ways (1/3 each) cannot divide evenly.
tri = [
CorridorLeg("OP_A", Decimal("1"), Decimal("1"), Decimal("1")),
CorridorLeg("OP_B", Decimal("1"), Decimal("1"), Decimal("1")),
CorridorLeg("OP_C", Decimal("1"), Decimal("1"), Decimal("1")),
]
odd = prorate_corridor(Decimal("101"), tri, WeightBasis.FLAT)
# 33.67 each floors to 33 (sum 99); the two leftover cents go to the first legs by tie-break.
assert odd == {"OP_A": Decimal("34"), "OP_B": Decimal("34"), "OP_C": Decimal("33")}
assert sum(odd.values()) == Decimal("101")
# Error case: an all-zero weight basis cannot define a split.
zero_weight = [CorridorLeg("OP_X", Decimal("0"), Decimal("0"), Decimal("0"))]
try:
prorate_corridor(Decimal("100"), zero_weight, WeightBasis.DISTANCE_KM)
raise AssertionError("expected ProrationError")
except ProrationError:
pass
The three-way case is the one that protects settlement. Each exact share is 101 × 1/3 = 33.67…, which floors to 33 for a total of 99; the two leftover cents are handed to OP_A and OP_B because all remainders tie and the operator-id tie-break is stable. The parts sum to 101 exactly, and — crucially — they sum to 101 the same way every time the corridor is re-prorated, so no operator gains or loses a cent on a re-run.
Edge Cases & Gotchas
- Zero-weight legs. An operator with a zero measure on the chosen basis correctly receives a zero share, but a corridor where every weight is zero has no defined split and must raise rather than divide by zero.
- Basis swaps mid-period. If a contract switches from distance to vehicle-hours partway through a settlement period, prorate each sub-period under its own basis; re-prorating the whole period under one basis silently rewrites already-settled shares.
- More legs than leftover cents. The leftover is always smaller than the leg count, so some legs never receive a remainder cent. That is correct — the largest-remainder method awards cents only to the legs most under-allocated by flooring.
- Rounding direction. Always floor, then top up. Rounding each share to nearest independently can overshoot the fare, producing a negative leftover that the top-up loop cannot resolve.
Integration Note
This split is the source of the corridor obligations that Inter-Agency Settlement Clearing aggregates: each leg’s share becomes a directed claim from the fare-collecting operator to the operator that carried that leg. Because the shares provably sum to the collected fare, they enter the clearing engine’s obligation matrix without introducing a conservation break — the exact failure that engine’s zero-sum assertion is built to catch. Its closest sibling is Building a Clearinghouse Netting Batch in Python, which takes the matrix these obligations feed and reduces it to the minimal set of cash transfers; a proration bug here would surface there as a batch that fails to net to zero.
FAQ
Why floor and top up instead of rounding each share to the nearest cent?
F.
Which weighting basis should a corridor agreement use?
CorridorLeg so the same corridor can be re-prorated under a renegotiated basis without re-deriving the data.
How are ties in the remainder broken?
Can the same method split a corridor across more than three operators?
Related
- Inter-Agency Settlement Clearing — the parent topic whose obligation matrix these corridor shares feed.
- Building a Clearinghouse Netting Batch in Python — the sibling that nets the matrix these shares populate down to minimal transfers.
- Calculating Cross-Operator Transfer Windows with Python — the upstream decision that determines whether a boundary crossing is a shared leg at all.
- Ledger & Audit Trail Design — where each prorated share is persisted with its corridor and basis for dispute drill-down.
- Daily Variance Reconciliation — the check that confirms corridor shares reconcile to collected fares before settlement.
Part of Inter-Agency Settlement Clearing, within Revenue Reconciliation & Settlement.