Capacity & Weight Limits for Waste Route Optimization

Model GVWR, axle weights, and volumetric thresholds as hard constraints during node insertion.

A waste route that ignores payload physics is not a route — it is a citation waiting at the scale house. Municipal collection trucks operate inside a gross vehicle weight rating (GVWR), per-axle federal ceilings, and jurisdiction-specific bridge and seasonal-ban limits. When the routing engine treats these as advisory, the solver returns a plan that looks efficient on paper and puts an overloaded chassis onto a posted residential street. The failure surfaces downstream as an axle-weight penalty, accelerated suspension fatigue, or a rejected hazardous-waste manifest whose declared tonnage does not reconcile with the weighbridge ticket.

This page is the capacity sub-system of the VRP Route Optimization Algorithms pipeline. It shows how to encode weight thresholds as a hard capacity dimension the solver cannot cross, how those numeric bounds map to the regulations that authorize them, how to verify the constraint actually fires, and what the engine should do when demand makes a route infeasible. Every code block is complete and pasteable into a Python 3.10+ dispatch worker.

Cumulative payload profile against a hard capacity ceiling, with disposal-node reset and split-trip fallback branches A staircase load profile rising stop by stop toward a dashed payload ceiling Qv. When the accumulated load nears the ceiling the route forks: branch one routes through a transfer station where the payload CumulVar resets to zero and collection resumes; branch two moves the remaining stops onto an overflow vehicle. The step line never crosses the ceiling, illustrating the zero-slack hard constraint set by AddDimensionWithVehicleCapacity. 0 cumulative payload · CumulVar (kg) route sequence → Qᵥ = payload_capacity_kg (GVWR − tare) hard ceiling · AddDimensionWithVehicleCapacity(slack_max = 0) ① disposal node inserted CumulVar → 0 · hopper empties ② split-trip → overflow vehicle 3.0 t 6.5 t 9.3 t 3.2 t 6.2 t Depot S1 S2 S3 Transfer ⟲ S4 S5 Depot

Prerequisites

Pin the solver and validation stack so a dispatch run is byte-for-byte reproducible during an audit. These versions are what the code below is tested against:

python --version   # 3.10 or newer
pip install \
  ortools==9.11.4210 \
  pydantic==2.9.2

Before any dimension is registered, every service stop must pass a typed schema. Under-specified demand — a None tonnage, a negative fill, a value above the physical hopper — is the most common cause of silent solver corruption, so reject it at ingestion rather than mid-solve. The ServiceStop model mirrors the ServiceNode schema used across the OR-Tools Implementation guide and validates units up front.

from __future__ import annotations
from pydantic import BaseModel, Field, field_validator

# Solver arithmetic is integer-only. We carry weight in kilograms as ints to
# avoid floating-point drift accumulating across thousands of node insertions.
KG_PER_TONNE = 1000


class ServiceStop(BaseModel):
    node_id: int = Field(ge=0)
    lat: float = Field(ge=-90.0, le=90.0)
    lon: float = Field(ge=-180.0, le=180.0)
    demand_kg: int = Field(ge=0, le=15_000)  # single-stop physical ceiling

    @field_validator("demand_kg")
    @classmethod
    def reject_non_integer_drift(cls, v: int) -> int:
        if isinstance(v, bool):  # bool is an int subclass; catch the footgun
            raise ValueError("demand_kg must be an integer count of kilograms")
        return v


class VehicleClass(BaseModel):
    name: str
    count: int = Field(ge=0)
    # Payload capacity = GVWR minus curb (tare) weight, in kilograms.
    payload_capacity_kg: int = Field(gt=0)

Deriving payload_capacity_kg from GVWR minus curb weight — rather than hardcoding a hopper volume — is what ties the constraint to a legally defensible number. The GVWR-to-payload relationship is:

Qv=GVWRvtarevQ_v = \text{GVWR}_v - \text{tare}_v

and the constraint the solver must hold at every node ii along vehicle vv’s route is the running sum of demands bounded by that payload:

kiqkQv\sum_{k \preceq i} q_k \le Q_v

Core Implementation

The capacity constraint is a dimension: OR-Tools accumulates a per-node quantity along each route and enforces an upper bound. Three steps bind it — register a demand callback, attach the dimension with a per-vehicle-class capacity vector, then read cumulative values back after the solve. The pattern below is complete and runnable.

from ortools.constraint_solver import pywrapcp, routing_enums_pb2


def build_capacity_model(
    stops: list[ServiceStop],
    fleet: list[VehicleClass],
    depot_index: int = 0,
) -> tuple[pywrapcp.RoutingModel, pywrapcp.RoutingIndexManager, list[int]]:
    """Register a hard capacity dimension keyed to per-vehicle payload ratings."""
    # Flatten the heterogeneous fleet into a per-vehicle capacity vector.
    capacities: list[int] = []
    for cls in fleet:
        capacities.extend([cls.payload_capacity_kg] * cls.count)
    num_vehicles = len(capacities)
    if num_vehicles == 0:
        raise ValueError("Fleet is empty; cannot build a routing model.")

    manager = pywrapcp.RoutingIndexManager(len(stops), num_vehicles, depot_index)
    routing = pywrapcp.RoutingModel(manager)

    # Demand callback: solver index -> kilograms picked up at that node.
    def demand_callback(from_index: int) -> int:
        node = manager.IndexToNode(from_index)
        return stops[node].demand_kg

    demand_idx = routing.RegisterUnaryTransitCallback(demand_callback)

    routing.AddDimensionWithVehicleCapacity(
        demand_idx,
        0,                 # zero slack: no phantom capacity headroom
        capacities,        # hard upper bound per vehicle
        True,              # start cumul at zero (empty hopper leaving depot)
        "Payload",
    )
    return routing, manager, capacities

AddDimensionWithVehicleCapacity with a slack_max of 0 is what makes the limit hard: the solver may not borrow phantom headroom, so a plan that would exceed a truck’s payload is pruned from the search space rather than penalized after the fact. Because different vehicle classes carry different ratings, passing a per-vehicle capacities vector (not a scalar) is essential for a mixed fleet — a rear-loader and a roll-off cannot share one ceiling.

Solve with a deterministic strategy and read cumulative load back so downstream logging can record exactly how full each truck was at each node:

def solve_and_extract(routing, manager, stops):
    params = pywrapcp.DefaultRoutingSearchParameters()
    params.first_solution_strategy = (
        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
    )
    params.local_search_metaheuristic = (
        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
    )
    params.time_limit.FromSeconds(15)

    solution = routing.SolveWithParameters(params)
    if solution is None:
        raise RuntimeError("Solver returned no feasible plan under capacity limits.")

    payload_dim = routing.GetDimensionOrDie("Payload")
    routes: list[dict] = []
    for v in range(routing.vehicles()):
        idx = routing.Start(v)
        legs: list[tuple[int, int]] = []
        while not routing.IsEnd(idx):
            node = manager.IndexToNode(idx)
            load = solution.Value(payload_dim.CumulVar(idx))
            legs.append((stops[node].node_id, load))
            idx = solution.Value(routing.NextVar(idx))
        routes.append({"vehicle": v, "stops": legs})
    return routes

When capacity interacts with service hours, the same CumulVar read pattern is how the Time Window Constraints dimension is inspected, and the payload ceiling itself can be shifted at runtime by the Dynamic Threshold Tuning layer when live scale readings show seasonal density swings. For load-cell smoothing and axle-by-axle apportionment beyond the single-dimension model here, see the deep dive on handling truck capacity constraints in Python.

Regulatory Mapping

Each numeric bound in the model above must trace to a rule an inspector can cite. Do not assert limits in prose — pin them to the code of federal regulations so the compliance registry can version them.

Constraint parameter Regulatory source Numeric bound
Single steer axle 23 U.S.C. § 127(a); 23 CFR 658.17(b) 20,000 lb (9,072 kg)
Tandem axle group 23 U.S.C. § 127(a); 23 CFR 658.17© 34,000 lb (15,422 kg)
Gross vehicle weight on Interstates 23 CFR 658.17(a) 80,000 lb (36,287 kg)
Axle spacing / bridge protection 23 CFR 658.17(d) — Federal Bridge Formula derived, see below
Hazardous-waste manifest tonnage 40 CFR 262.20–262.23 (EPA e-Manifest) declared per shipment

The Federal Bridge Formula determines the maximum weight WW (lb) any group of consecutive axles may carry, given the number of axles NN in the group and the distance LL (ft) between the outermost axles:

W=500(LNN1+12N+36)W = 500\left(\frac{L\,N}{N-1} + 12N + 36\right)

Municipal codes routinely impose stricter local ceilings — posted bridge ratings, spring thaw / frost-law bans, and residential-street weight postings — that override the federal baseline for specific segments. Store these as jurisdictional overrides in a compliance registry keyed by road segment, and resolve the effective limit as the minimum of the federal envelope and any active local restriction. The tonnage carried on each hazardous-waste stop must additionally reconcile with the DOT/FMCSA rule mapping hours-of-service records and the EPA e-Manifest declaration under 40 CFR 262, so the same demand_kg the solver consumes also feeds the manifest’s declared quantity field.

Validation & Verification

A capacity constraint that silently never binds is worse than none — it gives false assurance. Prove it fires with a unit test whose total demand deliberately exceeds a single vehicle, forcing the solver to split the work across trucks or return infeasible.

def test_capacity_forces_split() -> None:
    # Two 9,000 kg stops cannot ride one 10,000 kg truck together.
    stops = [
        ServiceStop(node_id=0, lat=40.0, lon=-83.0, demand_kg=0),      # depot
        ServiceStop(node_id=1, lat=40.1, lon=-83.1, demand_kg=9_000),
        ServiceStop(node_id=2, lat=40.2, lon=-83.2, demand_kg=9_000),
    ]
    fleet = [VehicleClass(name="rear_loader", count=2, payload_capacity_kg=10_000)]

    routing, manager, capacities = build_capacity_model(stops, fleet)
    routes = solve_and_extract(routing, manager, stops)

    # Assertion 1: no route ever exceeds its truck's payload rating.
    for r in routes:
        peak = max((load for _, load in r["stops"]), default=0)
        assert peak <= capacities[r["vehicle"]], "capacity ceiling breached"

    # Assertion 2: the two heavy stops landed on different vehicles.
    served = {sid: v for v, r in enumerate(routes) for sid, _ in r["stops"] if sid}
    assert served[1] != served[2], "solver failed to split over-capacity demand"

In production, emit the peak load per route as a structured field so the assertion above has a logged counterpart. Follow the site-wide JSONFormatter pattern so compliance events land in the audit database as machine-parseable records rather than free text:

import json
import logging


class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "level": record.levelname,
            "event": record.getMessage(),
            "vehicle": getattr(record, "vehicle", None),
            "peak_payload_kg": getattr(record, "peak_payload_kg", None),
            "capacity_kg": getattr(record, "capacity_kg", None),
        }
        return json.dumps(payload, separators=(",", ":"))


logger = logging.getLogger("capacity")
_handler = logging.StreamHandler()
_handler.setFormatter(JSONFormatter())
logger.addHandler(_handler)
logger.setLevel(logging.INFO)

for r in routes:
    peak = max((load for _, load in r["stops"]), default=0)
    logger.info(
        "route_capacity_check",
        extra={"vehicle": r["vehicle"], "peak_payload_kg": peak,
               "capacity_kg": capacities[r["vehicle"]]},
    )

The two log fields an auditor checks are peak_payload_kg and capacity_kg: the former must never exceed the latter on any emitted route, and every dispatched plan should carry one such record per vehicle.

Failure Modes & Edge Cases

When aggregate demand outstrips the whole fleet, SolveWithParameters returns None — the solver is telling you the constraint set is unsatisfiable, not that it failed. Never widen a payload ceiling to make an infeasible plan “work”; that trades a citation for a crash. Instead, degrade along a defined ladder.

1. Forced disposal-node insertion. A truck that fills before its stops are exhausted must route through a transfer station, which resets the payload dimension to zero. Model the station as an optional node the solver may visit mid-route rather than an end-of-day return.

2. Capacity relaxation into overflow vehicles, not over-limit trucks. If demand exceeds fleet payload, add reserve vehicles — never raise payload_capacity_kg past the GVWR-derived bound.

def solve_with_overflow_fallback(stops, fleet, max_rounds: int = 3):
    """Escalate to reserve vehicles when the plan is infeasible — never past GVWR."""
    for round_no in range(1, max_rounds + 1):
        routing, manager, capacities = build_capacity_model(stops, fleet)
        try:
            return solve_and_extract(routing, manager, stops)
        except RuntimeError:
            total = sum(s.demand_kg for s in stops)
            fleet_cap = sum(c.payload_capacity_kg * c.count for c in fleet)
            logger.warning(
                "capacity_infeasible",
                extra={"vehicle": None, "peak_payload_kg": total,
                       "capacity_kg": fleet_cap},
            )
            # Add one reserve truck of the largest class and retry.
            biggest = max(fleet, key=lambda c: c.payload_capacity_kg)
            biggest.count += 1
    raise RuntimeError(f"Still infeasible after {max_rounds} overflow rounds.")

3. Telemetry gaps. When a live scale feed disconnects, demand values arrive stale or missing. Do not solve on a None; fall back to a conservative historical density multiplier and flag the route for manual review. Reconciling those gaps before the matrix is built is the job of the telematics sensor data ingestion pipeline, which substitutes a last-known-good fill estimate rather than letting the solver ingest a corrupt payload.

4. Secondary depot assignment. In multi-yard operations, an over-capacity zone can shed load to an adjacent depot’s vehicles before overflow trucks are provisioned — cheaper than dispatching reserves from a distant yard.

Integration Checklist

Complete every item before a capacity model reaches production dispatch:

FAQ

Should capacity ever be modeled as a soft constraint?

No. Payload limits map to statutory axle and GVWR ceilings, so a soft penalty would let the solver knowingly emit an illegal plan when the cost math favors it. Keep slack_max at zero and handle over-demand by adding vehicles or disposal nodes, not by relaxing the bound.

Why carry weight in integer kilograms instead of tonnes as floats?

OR-Tools dimension arithmetic is integer-only, and floating-point tonnage accumulates rounding drift across thousands of node insertions — enough to flip a borderline route between feasible and infeasible non-deterministically. Multiplying to integer kilograms keeps every solve reproducible for audit.

How do I model a truck that empties at a transfer station mid-route?

Add the transfer station as an optional intermediate node whose demand is negative (or use a dimension slack reset), so visiting it returns the payload CumulVar toward zero and the truck can continue servicing stops. Model it as a node the solver may insert, not a fixed end-of-shift return.

What happens when total demand exceeds the entire fleet's payload?

SolveWithParameters returns None. That is the correct signal that the constraint set is unsatisfiable. Escalate through the overflow ladder — reserve vehicles, then secondary-depot borrowing — and log a capacity_infeasible event. Never widen a truck’s ceiling past its GVWR-derived bound to force a solution.

How does per-axle weight relate to the single total-payload dimension?

The single Payload dimension enforces GVWR; per-axle distribution is a second concern governed by the Federal Bridge Formula and load placement. For axle-by-axle apportionment and load-cell stabilization, use the patterns in the dedicated Python capacity-constraints guide linked in the Related block below.

Up: VRP Route Optimization Algorithms