DOT/FMCSA Rule Mapping for Waste Route Optimization

Translate federal mandates into graph edge weights and node feasibility constraints.

A municipal packer truck is a commercial motor vehicle the moment it crosses a scale or leaves the yard, which means every route it runs is governed by the same federal driving-time, weight, and hazardous-material rules as an interstate freight carrier. The sub-system this reference defines is the translation layer that converts those statutes — Hours of Service, gross and per-axle weight limits, hazardous-material routing restrictions — into hard solver constraints that the routing engine cannot violate by construction. Skip this layer and the failure is not abstract: a solver optimizing purely for mileage will happily plan a shift that runs a driver past their 11-hour driving ceiling, loads an axle over its posted rating, or sends a truck carrying regulated waste down a prohibited road. Each of those is a citation, a failed audit, and a CSA-score hit that raises the fleet’s insurance basis. This reference is part of the Core Architecture & Compliance Mapping framework, and it exists so that every regulatory limit becomes a named dimension in the model with a Code of Federal Regulations citation attached, rather than a comment in a dispatcher’s spreadsheet.

The governing principle is the same one that runs through the whole architecture: regulatory limits are hard constraints, never soft penalties. A penalty-weighted solver will, under enough cost pressure, always find an objective value where paying the fine “wins” — and that decision lands on your compliance record. Everything below binds each rule as a dimension the solver may never exceed, so infeasibility is caught at model-build time instead of on the curb at 6 a.m.

Binding federal rules as hard solver constraints Left to right. A validated, frozen FederalRuleSet object feeds four parallel binding lanes: Hours of Service bound as a Time dimension with 11-hour drive and 14-hour span ceilings and a 30-minute break interval (49 CFR 395); gross vehicle weight bound as a cumulative dimension capped at GVWR (23 CFR 658.17(a) and 49 CFR 393); rear-axle share bound as a second cumulative dimension capped at the tandem limit (23 CFR 658.17(c)); and regulated-waste stops bound as an allowed-vehicle restriction (49 CFR 397). All four lanes register into one OR-Tools RoutingModel before the first solve. The model emits a feasible dispatch manifest on success, or branches on a dashed infeasible path to the fallback routing tier — regulatory dimensions are never relaxed. FederalRuleSet validated · frozen ruleset_version HOS → Time dimension 11h drive · 14h span · 30-min break interval 49 CFR 395.3 Gross weight → cumulative dim. accumulates along route · capped at GVWR 23 CFR 658.17(a) · 49 CFR 393 Per-axle → cumulative dim. rear-tandem share · capped at 34,000 lb 23 CFR 658.17(c) Hazmat → allowed-vehicle rule regulated stops → endorsed vehicles only 49 CFR 397 OR-Tools RoutingModel solve with hard dims Feasible manifest dispatch · audit-stamped Fallback tier split · relief · defer feasible infeasible
Each regulatory family binds as its own hard dimension or restriction — with its CFR citation attached — before the first solve. Infeasibility branches to the fallback tier; no dimension is ever relaxed to force a route.

Prerequisites

This page targets Python 3.10 or newer — the code uses X | None union syntax and structural typing that older interpreters reject. Pin the following packages; OR-Tools in particular changes its routing API across minor versions, so an unpinned install will eventually break the callback signatures below.

python -m pip install \
    ortools==9.10.4067 \
    pydantic==2.7.1 \
    numpy==1.26.4

Before any rule can be bound, the regulatory limits themselves must be a validated, versioned object — not loose keyword arguments. The canonical data contract for routes and stops lives in Route Schema Design; the rule set below is its regulatory companion. Every field carries the federal limit it encodes, and a validator rejects any value looser than the statutory maximum, so a misconfigured deployment fails at load time rather than silently planning illegal routes.

from pydantic import BaseModel, ConfigDict, field_validator

# Federal statutory ceilings — a config value may be STRICTER but never looser.
FED_MAX_DRIVE_S = 11 * 3600      # 49 CFR 395.3(a)(3)(i)
FED_MAX_ONDUTY_S = 14 * 3600     # 49 CFR 395.3(a)(2)
FED_BREAK_AFTER_S = 8 * 3600     # 49 CFR 395.3(a)(3)(ii)
FED_MIN_BREAK_S = 30 * 60        # 49 CFR 395.3(a)(3)(ii)
FED_MAX_GROSS_KG = 36_287        # 80,000 lb — 23 CFR 658.17(a)
FED_MAX_TANDEM_KG = 15_422       # 34,000 lb tandem — 23 CFR 658.17(c)


class FederalRuleSet(BaseModel):
    """Versioned, validated regulatory limits bound into the routing model."""
    model_config = ConfigDict(strict=True, frozen=True)

    ruleset_version: str                 # e.g. "2024.1" — travels into the audit hash
    max_drive_s: int = FED_MAX_DRIVE_S
    max_onduty_s: int = FED_MAX_ONDUTY_S
    break_after_s: int = FED_BREAK_AFTER_S
    break_duration_s: int = FED_MIN_BREAK_S
    gvwr_kg: int                         # per-vehicle gross rating (49 CFR 393)
    axle_limit_kg: int = FED_MAX_TANDEM_KG
    axle_ratio: float = 0.62             # share of hopper mass on the rear tandem

    @field_validator("max_drive_s")
    @classmethod
    def drive_not_looser(cls, v: int) -> int:
        if v > FED_MAX_DRIVE_S:
            raise ValueError(f"max_drive_s {v} exceeds federal 11h ceiling")
        return v

    @field_validator("max_onduty_s")
    @classmethod
    def onduty_not_looser(cls, v: int) -> int:
        if v > FED_MAX_ONDUTY_S:
            raise ValueError(f"max_onduty_s {v} exceeds federal 14h window")
        return v

    @field_validator("gvwr_kg", "axle_limit_kg")
    @classmethod
    def weight_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("weight limits must be positive")
        return v

You also need the outputs of the distance/time matrix build — a symmetric time_matrix (seconds) and dist_matrix (meters) over your stop set. The chunked, memory-bounded construction of those matrices is covered in the OR-Tools Implementation reference; this page assumes they already exist and are validated.

Core Implementation

Rule mapping is a fixed sequence: build the model, then bind each regulatory family as its own dimension or restriction before the first solve. The order matters only in that every binding must be registered before SolveWithParameters runs — after that the model is frozen. The logger uses the site-wide JSONFormatter pattern (Python’s standard logging has no built-in JSON formatter, so a custom subclass is required) so that each binding emits an audit-grade structured record.

Step 1 — Model scaffold and structured logging

import json
import logging
from dataclasses import dataclass
from ortools.constraint_solver import routing_enums_pb2, pywrapcp


class JSONFormatter(logging.Formatter):
    """Custom JSON formatter — the standard logging module has no built-in JSONFormatter."""
    def format(self, record: logging.LogRecord) -> str:
        obj = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
        }
        obj.update({k: v for k, v in record.__dict__.items()
                    if k not in logging.LogRecord("", 0, "", 0, "", (), None).__dict__})
        return json.dumps(obj)


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


@dataclass(frozen=True)
class Fleet:
    num_vehicles: int
    depot: int
    demands_kg: list[int]                    # collected mass per stop
    service_s: list[int]                     # dwell seconds per stop
    service_windows: list[tuple[int, int]]   # (open, close) seconds per stop
    hazmat_nodes: list[int]                  # stops carrying regulated waste
    hazmat_endorsed_vehicles: list[int]      # vehicles cleared for hazmat routing

Step 2 — Bind Hours of Service as a time dimension

HOS maps to two solver facts: a per-route duration ceiling (the 11-hour driving limit) and a per-route span ceiling (the 14-hour on-duty window). Both are expressed on a single Time dimension. The 30-minute break after eight cumulative hours is bound with OR-Tools’ native break-interval API, which forces a fixed-duration gap into the schedule rather than approximating it as extra travel time.

def bind_hours_of_service(routing, mgr, fleet: Fleet, rules: FederalRuleSet,
                          time_matrix: list[list[int]]) -> None:
    """49 CFR Part 395 -> Time dimension ceiling + native break interval."""
    def time_cb(i: int, j: int) -> int:
        node = mgr.IndexToNode(i)
        return time_matrix[node][mgr.IndexToNode(j)] + fleet.service_s[node]

    time_idx = routing.RegisterTransitCallback(time_cb)
    # slack = max break duration; capacity = 14h on-duty window (hard span ceiling)
    routing.AddDimension(time_idx, rules.break_duration_s, rules.max_onduty_s,
                         False, "Time")
    time_dim = routing.GetDimensionOrDie("Time")

    for node, (open_s, close_s) in enumerate(fleet.service_windows):
        time_dim.CumulVar(mgr.NodeToIndex(node)).SetRange(open_s, close_s)

    solver = routing.solver()
    node_visit = [fleet.service_s[mgr.IndexToNode(i)] if not routing.IsEnd(i) else 0
                  for i in range(routing.Size())]

    for v in range(fleet.num_vehicles):
        end = routing.End(v)
        # 11h DRIVING ceiling: elapsed on-duty time at route end minus breaks
        time_dim.SetSpanUpperBoundForVehicle(rules.max_drive_s + rules.break_duration_s, v)
        # mandatory 30-min break, startable only after 8h on duty (49 CFR 395.3(a)(3)(ii))
        brk = solver.FixedDurationIntervalVar(
            rules.break_after_s, rules.max_onduty_s - rules.break_duration_s,
            rules.break_duration_s, False, f"break_v{v}")
        time_dim.SetBreakIntervalsOfVehicle([brk], v, node_visit)

    logger.info("bound HOS", extra={"cfr": "49 CFR 395",
                "max_drive_s": rules.max_drive_s, "max_onduty_s": rules.max_onduty_s})

The full velocity-filtered state machine that reconciles ELD telemetry drift against these limits — the piece that decides what actually counts as driving time before it reaches this dimension — is detailed in Mapping DOT Hours of Service to Waste Routes.

Step 3 — Bind gross and per-axle weight as cumulative dimensions

Weight is not a single depot check; fill accumulates monotonically along the route, so both the gross rating and the rear-axle share are cumulative dimensions. The detailed capacity-dimension pattern lives in Capacity & Weight Limits; here it is bound twice — once for total mass, once for the axle share — because a truck can sit under its gross rating while overloading a single axle.

def bind_weight_limits(routing, mgr, fleet: Fleet, rules: FederalRuleSet) -> None:
    """23 CFR 658.17 / 49 CFR 393 -> two cumulative weight dimensions."""
    def demand_cb(i: int) -> int:
        return fleet.demands_kg[mgr.IndexToNode(i)]

    demand_idx = routing.RegisterUnaryTransitCallback(demand_cb)
    routing.AddDimensionWithVehicleCapacity(
        demand_idx, 0, [rules.gvwr_kg] * fleet.num_vehicles, True, "Gross")

    def axle_cb(i: int) -> int:
        return int(fleet.demands_kg[mgr.IndexToNode(i)] * rules.axle_ratio)

    axle_idx = routing.RegisterUnaryTransitCallback(axle_cb)
    routing.AddDimensionWithVehicleCapacity(
        axle_idx, 0, [rules.axle_limit_kg] * fleet.num_vehicles, True, "Axle")

    logger.info("bound weight", extra={"cfr": "23 CFR 658.17",
                "gvwr_kg": rules.gvwr_kg, "axle_limit_kg": rules.axle_limit_kg})

The True fix-start flag forces each cumulative value to begin at zero and never exceed the per-vehicle capacity — that is exactly what makes GVWR and axle limits hard. The federal ceiling those capacities encode is the gross vehicle weight cap, with total gross bounded by the Federal Bridge Formula:

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

where WW is the maximum weight in pounds on any group of axles, LL is the axle-group spacing in feet, and NN is the number of axles in the group (23 CFR 658.17). For a standard tandem this reduces to the 34,000 lb tandem and 80,000 lb gross ceilings encoded in FederalRuleSet.

Step 4 — Bind hazardous-material routing as vehicle restrictions

Regulated waste streams (medical, certain industrial residues) trigger hazardous-material driving and routing rules under 49 CFR Part 397. The mapping is a node-level restriction: a stop carrying regulated waste may be served only by a vehicle whose operator holds the endorsement and whose routing avoids prohibited segments. OR-Tools expresses this with SetAllowedVehiclesForIndex.

def bind_hazmat_routing(routing, mgr, fleet: Fleet) -> None:
    """49 CFR Part 397 -> restrict regulated-waste stops to endorsed vehicles."""
    for node in fleet.hazmat_nodes:
        index = mgr.NodeToIndex(node)
        routing.SetAllowedVehiclesForIndex(fleet.hazmat_endorsed_vehicles, index)
    logger.info("bound hazmat", extra={"cfr": "49 CFR 397",
                "restricted_stops": len(fleet.hazmat_nodes)})

Step 5 — Assemble, solve, and log the rule set version

def solve_compliant(fleet: Fleet, rules: FederalRuleSet,
                    time_matrix, dist_matrix):
    mgr = pywrapcp.RoutingIndexManager(len(dist_matrix), fleet.num_vehicles, fleet.depot)
    routing = pywrapcp.RoutingModel(mgr)

    def dist_cb(i: int, j: int) -> int:
        return dist_matrix[mgr.IndexToNode(i)][mgr.IndexToNode(j)]
    routing.SetArcCostEvaluatorOfAllVehicles(routing.RegisterTransitCallback(dist_cb))

    bind_hours_of_service(routing, mgr, fleet, rules, time_matrix)
    bind_weight_limits(routing, mgr, fleet, rules)
    bind_hazmat_routing(routing, mgr, fleet)

    params = pywrapcp.DefaultRoutingSearchParameters()
    params.local_search_metaheuristic = (
        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    params.time_limit.FromSeconds(30)

    solution = routing.SolveWithParameters(params)
    logger.info("solve complete", extra={"ruleset_version": rules.ruleset_version,
                "feasible": solution is not None})
    return mgr, routing, solution

Because the ruleset_version string travels into every log line and, downstream, into the audit hash, a route is always defensible against the exact regulatory revision it was solved under — critical when FMCSA amends a threshold mid-year and you must prove which rules a two-year-old dispatch obeyed.

Regulatory Mapping

Every solver parameter above traces to a specific citation. This table is the auditable crosswalk an inspector or contract compliance officer will ask for — each constraint on the left, the federal rule it encodes on the right.

Solver binding Parameter Federal rule
Time span upper bound max_drive_s (11 h) 49 CFR 395.3(a)(3)(i) — driving limit
Time dimension capacity max_onduty_s (14 h) 49 CFR 395.3(a)(2) — on-duty window
SetBreakIntervalsOfVehicle break_after_s / break_duration_s 49 CFR 395.3(a)(3)(ii) — 30-min break
Gross cumulative dimension gvwr_kg (80,000 lb) 23 CFR 658.17(a); vehicle parts 49 CFR 393
Axle cumulative dimension axle_limit_kg (34,000 lb) 23 CFR 658.17© — tandem-axle limit
SetAllowedVehiclesForIndex hazmat_nodes 49 CFR Part 397 — hazmat driving/routing
Manifest field in audit record manifest_id 40 CFR 262.20 — EPA Form 8700-22

The EPA manifest linkage matters at the schema boundary: regulated-waste stops must carry an EPA Uniform Hazardous Waste Manifest number (Form 8700-22, required under 40 CFR 262.20) that the compliance record captures alongside the DOT constraints. The exact field-level structure for state e-manifest submission is defined in the JSON Schema for Municipal Disposal Tracking. Retention of the resulting records — who may write and read them, how signing keys rotate — is governed by Security & Access Boundaries.

Validation & Verification

A hard constraint you never test is a hope, not a boundary. The verification discipline is to construct an instance that should violate a rule and assert the solver refuses it — then assert the passing case emits the expected structured fields. The unit test below builds a two-stop route whose combined mass exceeds the axle limit and confirms the Axle dimension makes it infeasible.

def test_axle_limit_prunes_overload():
    rules = FederalRuleSet(ruleset_version="test", gvwr_kg=36_287, axle_limit_kg=15_422)
    # two stops each putting 13,000 kg on the rear axle after the 0.62 ratio:
    # 2 * int(21_000 * 0.62) = 26,040 kg > 15,422 kg axle limit -> infeasible
    fleet = Fleet(
        num_vehicles=1, depot=0,
        demands_kg=[0, 21_000, 21_000],
        service_s=[0, 300, 300],
        service_windows=[(0, 50_400), (0, 50_400), (0, 50_400)],
        hazmat_nodes=[], hazmat_endorsed_vehicles=[0],
    )
    tm = [[0, 600, 600], [600, 0, 600], [600, 600, 0]]
    _, _, solution = solve_compliant(fleet, rules, tm, tm)
    assert solution is None, "solver must refuse an over-axle route, not price it"


def test_feasible_route_logs_ruleset_version(caplog):
    rules = FederalRuleSet(ruleset_version="2024.1", gvwr_kg=36_287)
    fleet = Fleet(
        num_vehicles=1, depot=0,
        demands_kg=[0, 4_000], service_s=[0, 300],
        service_windows=[(0, 50_400), (0, 50_400)],
        hazmat_nodes=[], hazmat_endorsed_vehicles=[0],
    )
    tm = [[0, 600], [600, 0]]
    _, _, solution = solve_compliant(fleet, rules, tm, tm)
    assert solution is not None

Beyond unit assertions, three structured log fields must appear on every production solve and are the ones an audit query filters on: cfr (the citation each binding stamps), ruleset_version (which revision governed the plan), and feasible (whether a legal route existed). If any binding log line is missing, a rule silently did not register — treat a missing cfr field as a failed deploy, not a cosmetic gap.

Failure Modes & Edge Cases

The defining rule of this sub-system is that regulatory dimensions are never relaxed to force a solution. When solve_compliant returns None, some hard rule cannot be satisfied, and the legal responses are structural, not permissive.

HOS-infeasible shift (route needs more than 11 driving hours). You cannot widen the driving ceiling. The mitigations are to split the territory across a second vehicle, hand the tail of the route to a relief driver with a fresh HOS clock, or defer low-priority stops to the next service day. This branch is the entry point to the Fallback Routing Logic state machine, which selects among those strategies and records the choice.

def solve_or_split(fleet: Fleet, rules: FederalRuleSet, time_matrix, dist_matrix):
    mgr, routing, solution = solve_compliant(fleet, rules, time_matrix, dist_matrix)
    if solution is not None:
        return solution
    if fleet.num_vehicles == 1:
        # add a relief vehicle with its OWN fresh HOS clock — never extend the ceiling
        widened = Fleet(**{**fleet.__dict__, "num_vehicles": 2,
                           "hazmat_endorsed_vehicles": fleet.hazmat_endorsed_vehicles + [1]})
        logger.warning("HOS infeasible: adding relief vehicle",
                       extra={"cfr": "49 CFR 395", "action": "split_shift"})
        _, _, solution = solve_compliant(widened, rules, time_matrix, dist_matrix)
    return solution

No hazmat-endorsed vehicle available. If a regulated-waste stop has no allowed vehicle (hazmat_endorsed_vehicles is empty or all are saturated), SetAllowedVehiclesForIndex makes the whole model infeasible. The correct handling is to detect this before solving and quarantine the stop for a dedicated hazmat run rather than let the solver fail opaquely.

def guard_hazmat_coverage(fleet: Fleet) -> list[int]:
    """Return hazmat stops with no eligible vehicle — quarantine before solving."""
    if fleet.hazmat_endorsed_vehicles:
        return []
    orphaned = list(fleet.hazmat_nodes)
    if orphaned:
        logger.error("no hazmat-endorsed vehicle", extra={"cfr": "49 CFR 397",
                     "orphaned_stops": orphaned, "action": "quarantine"})
    return orphaned

Weight-infeasible single stop. If one stop’s demand alone exceeds a truck’s GVWR, no assignment can serve it. This is a data error, not a routing problem — catch it at ingestion via the Route Schema Design validator so it never reaches the solver. Live demand values arrive through the Telematics & Sensor Data Ingestion pipeline, and a sensor over-read can manufacture an impossible demand; the ingestion gate, not the solver, is where that is rejected.

Integration Checklist

FAQ

Do municipal waste trucks really fall under FMCSA Hours of Service rules?

Yes, in most cases. A vehicle over 10,001 lb operated in commerce is a commercial motor vehicle under 49 CFR 390.5, and refuse collection generally qualifies. Some intrastate short-haul operations use the 150 air-mile exception (49 CFR 395.1(e)), which relaxes the logging requirement but not the underlying driving limits. Because the exception is jurisdiction-specific, model the full 11/14-hour limits by default and tighten only where your legal team confirms an exemption applies.

Why bind gross weight and axle weight as two separate dimensions?

Because compliance with one does not imply compliance with the other. A packer hopper sits behind the cab, so collected mass loads the rear axle disproportionately — the rear tandem can hit its 34,000 lb limit well before the truck reaches its 80,000 lb gross rating. A model that checks only gross weight will plan routes that are legal by GVWR and illegal by axle load. The two cumulative dimensions with different capacities are what catch that.

How is the mandatory 30-minute break modeled without hand-coding a state machine?

OR-Tools’ SetBreakIntervalsOfVehicle on the Time dimension forces a fixed-duration interval into the vehicle’s schedule, with a start window constrained to begin after eight cumulative on-duty hours. That is a native, hard modeling of 49 CFR 395.3(a)(3)(ii). The velocity-filtered state machine in the companion how-to handles the separate problem of deciding what telemetry counts as driving time before it feeds this dimension.

What should the solver do when no compliant route exists?

Transition to a structural fallback — split the shift across vehicles, assign a relief driver with a fresh HOS clock, or defer stops — and log the choice. It must never relax a regulatory dimension to manufacture a “solution.” An infeasible result is information: it tells you the plan as scoped cannot be run legally, which is exactly what you want to know before dispatch rather than after a citation.

Up: Core Architecture & Compliance Mapping