Production-Grade VRP Route Optimization Algorithms for Waste Logistics
Constraint-hardened Vehicle Routing for waste fleets — capacity envelopes, deterministic solvers, and audit-grade dispatch.
Waste collection operations demand deterministic routing architectures. Municipal fleets run against fixed regulatory windows, strict payload envelopes, and non-negotiable compliance mandates — and when a route plan silently violates one of them, the failure surfaces as an axle-weight citation at a weigh station, a noise-ordinance complaint filed against the city, or a rejected public-records audit weeks later. Academic stochastic models frequently collapse in this environment because they optimize expected mileage while treating constraints as soft penalties, producing plans that are cheap on paper and illegal on the street. This guide is written for the waste operations managers, logistics engineers, and municipal Python developers who own the dispatch pipeline and have to defend every route it emits. It walks the full arc: how to encode hard constraints so the solver cannot return a violating plan, how to bind those constraints to Google OR-Tools, how to feed live telemetry back into solver parameters, and how to produce an immutable compliance record for every run.
Constraint Architecture and Feasibility Enforcement
The optimization core relies on constraint programming and guided local search, explicitly rejecting heuristic approximations that trade constraint satisfaction for marginal mileage gains. The single most important design decision on a waste-routing pipeline is which constraints are hard — encoded as boundaries the solver treats as infeasible to cross — versus soft — encoded as penalty weights the solver is free to violate when the cost math favors it. Getting this boundary wrong is the root cause of most production incidents, because a soft constraint is, by definition, a constraint the optimizer is allowed to break.
Four constraint classes must be modeled as hard in municipal waste routing:
- Regulatory service windows. Collection must align with municipal noise ordinances, transfer-station operating hours, and residential set-out rules. We model these as Time Window Constraints — hard
[earliest, latest]bounds on the arrival dimension of every node. A violation triggers immediate solver rejection rather than soft penalty weighting, so a dispatch plan can never schedule a 5:40 a.m. pickup on a street with a 7:00 a.m. noise cutoff. - Gross vehicle weight and axle limits. Municipal trucks operate under a gross vehicle weight rating (GVWR) and per-axle federal limits. The routing engine evaluates the Capacity & Weight Limits dimension during node insertion; cumulative load is bounded so the solver computes split trips deterministically instead of producing an overloaded run that fails at the scale.
- Hours-of-service (HOS) ceilings. Driver on-duty and driving time are capped by FMCSA rules. These become a hard time dimension with a per-vehicle upper bound and mandatory rest nodes, mapped in detail under DOT/FMCSA Rule Mapping.
- Disposal-node forcing. A truck that hits its capacity ceiling must be routed to a transfer station before it can service another stop — encoded as a forced intermediate node rather than a suggestion.
The cumulative-load constraint that the capacity dimension enforces at every node along a route is simply:
where is the demand (in integer kilograms) at stop and is the rated capacity of vehicle . Modeling this as a hard dimension means the solver prunes any insertion that would breach before it ever scores the route — the branch is never explored, so no penalty tuning can accidentally re-admit it.
Why hard and not soft? A soft penalty makes the constraint negotiable: give the solver a large enough mileage saving and it will happily accept a 200 kg overload or a 12-minute noise-window breach because the objective function nets out positive. Regulators do not weigh violations against fuel savings. Encoding these as hard boundaries is what makes a plan audit-safe by construction rather than by hope.
Core Algorithmic Pattern
The waste-routing problem is a Capacitated Vehicle Routing Problem with Time Windows (CVRPTW), extended with multiple depots and heterogeneous vehicles. In practice the production pattern is: build the distance/time matrix, register one OR-Tools dimension per hard constraint, seed with the PATH_CHEAPEST_ARC first-solution strategy, then refine with guided local search under a bounded time limit. Constraint programming (CP) with guided local search gives reproducible output — the same input matrix and the same seed yield the same route — which is the property auditors actually require. Full callback registration and dimension syntax live in the OR-Tools Implementation guide; the pattern below shows the constraint-validation, structured-logging, retry, and deterministic-invocation skeleton that wraps it.
Note that Python’s standard logging module does not include a JSONFormatter class — a custom formatter is required for structured JSON output, and that pattern is used consistently across this site.
import hashlib
import json
import logging
import gc
from typing import List, Dict, Optional
from dataclasses import dataclass
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
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):
log_obj = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
}
log_obj.update({k: v for k, v in record.__dict__.items()
if k not in logging.LogRecord.__dict__ and not k.startswith("_")})
return json.dumps(log_obj)
logger = logging.getLogger("vrp_solver")
logger.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(JSONFormatter())
logger.addHandler(_handler)
@dataclass(frozen=True)
class RouteNode:
node_id: int
lat: float
lon: float
demand_kg: float
service_time_sec: int
time_window_start: int # seconds from shift start
time_window_end: int # seconds from shift start
def validate_node(node: RouteNode) -> bool:
if not (-90 <= node.lat <= 90) or not (-180 <= node.lon <= 180):
logger.warning("Invalid coordinates quarantined", extra={"node_id": node.node_id})
return False
if node.demand_kg < 0:
logger.warning("Negative demand quarantined", extra={"node_id": node.node_id})
return False
return True
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(RuntimeError)
)
def solve_vrp_deterministic(
nodes: List[RouteNode],
vehicle_capacity_kg: int,
solver_timeout_sec: int = 30
) -> Optional[Dict]:
valid_nodes = [n for n in nodes if validate_node(n)]
if len(valid_nodes) != len(nodes):
logger.info("Quarantined invalid nodes before solver invocation",
extra={"total": len(nodes), "valid": len(valid_nodes)})
input_hash = hashlib.sha256(
str(sorted([(n.node_id, n.lat, n.lon, n.demand_kg) for n in valid_nodes])).encode()
).hexdigest()
logger.info("Solver invocation", extra={"input_hash": input_hash, "node_count": len(valid_nodes)})
manager = pywrapcp.RoutingIndexManager(len(valid_nodes), 1, 0)
routing = pywrapcp.RoutingModel(manager)
# Full constraint binding patterns are documented in the OR-Tools Implementation guide
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
search_parameters.time_limit.FromSeconds(solver_timeout_sec)
search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
solution = routing.SolveWithParameters(search_parameters)
if solution is None:
logger.error("Solver failed to find feasible route", extra={"input_hash": input_hash})
raise RuntimeError("Infeasible constraint set")
gc.collect()
logger.info("Solver completed successfully", extra={"input_hash": input_hash})
return {
"solution_hash": hashlib.sha256(str(solution.ObjectiveValue()).encode()).hexdigest(),
"objective_value": solution.ObjectiveValue()
}
Two properties of this skeleton matter in production. First, invalid coordinates and negative demands are quarantined before the matrix is built, so a single corrupt sensor row cannot poison the solve. Second, the SHA-256 input_hash is computed over the sorted, validated node set — sorting makes the hash order-independent, so two dispatch requests describing the same work produce the same hash and the same route, which is the reproducibility guarantee auditors lean on.
Fleet and Depot Distribution
Fleet distribution across municipal yards requires explicit depot-assignment logic. Single-depot assumptions collapse the moment you service cross-jurisdictional collection zones or manage specialized equipment pools (rear-loaders, front-loaders, roll-offs, organics trucks). OR-Tools models multiple depots by passing per-vehicle start and end indices to the RoutingIndexManager, so each truck binds to a primary return facility at initialization with secondary depot fallbacks modeled explicitly for maintenance routing, emergency offloading, or seasonal equipment swaps.
Vehicle heterogeneity is expressed as a per-vehicle capacity vector rather than a single scalar, so the capacity dimension enforces a different for a 12-tonne rear-loader than for a 25-tonne roll-off. Yard utilization accounting then falls out of the depot binding: because every route begins and ends at a known facility, deadhead mileage per yard, trucks-out-per-yard, and end-of-shift return load are all derivable from the solution object without a separate reconciliation pass. This prevents cross-contamination of routing matrices between yards and keeps yard-level resource accounting accurate.
def build_depot_index(num_vehicles: int, vehicle_home_yard: Dict[int, int]) -> Dict[str, list]:
"""Map each vehicle to its start/end depot node index for RoutingIndexManager.
vehicle_home_yard: {vehicle_id -> depot_node_index}. Missing entries fall back to depot 0."""
starts = [vehicle_home_yard.get(v, 0) for v in range(num_vehicles)]
ends = list(starts) # trucks return to their originating yard by default
logger.info("Depot binding", extra={"starts": starts, "ends": ends})
return {"starts": starts, "ends": ends}
Dynamic Calibration and Telemetry Integration
Static solver parameters degrade as collection density shifts seasonally, holidays compress schedules, or storms disrupt service. The architecture applies Dynamic Threshold Tuning to adjust search depth, neighborhood exploration limits, and solve timeouts based on historical solve times, constraint-violation rates, and fleet utilization — decoupling solver configuration from static YAML and binding it to operational telemetry. Demand itself comes from the field: bin fill levels, GPS positions, and on-board scale readings are normalized upstream by the Telematics & Sensor Data Ingestion pipeline before they ever reach the distance-matrix build, so the solver consumes clean, deduplicated, timestamp-monotonic records.
The load-bearing question is what happens when telemetry is absent. Sensors go dark from hardware degradation, cellular dead zones, or duty-cycle throttling, and a solver that treats a missing fill reading as zero demand will skip a full bin. The pipeline therefore falls back to historical fill-rate baselines and volumetric heuristics per node, keeping the solve feasible while preserving safety margins. That degradation ladder — live reading, then rolling per-node baseline, then conservative worst-case estimate — is formalized in the Fallback Routing Logic module so that every substituted value is itself logged and auditable.
def resolve_demand(node_id: int, live: Optional[float],
baseline: Dict[int, float], default_kg: float = 240.0) -> Dict:
"""Deterministic demand resolution with a logged fallback ladder.
Never silently substitutes zero for a missing reading."""
if live is not None and live >= 0:
return {"demand_kg": round(live, 1), "source": "live_sensor"}
if node_id in baseline:
return {"demand_kg": round(baseline[node_id], 1), "source": "historical_baseline"}
logger.warning("No live or baseline demand; using conservative default",
extra={"node_id": node_id, "default_kg": default_kg})
return {"demand_kg": default_kg, "source": "conservative_default"}
Immutable Compliance Logging
Immutable audit trails are mandatory for regulatory defensibility. Every route-generation event produces a cryptographic hash of the input matrix and the active constraint configuration, so an auditor can later prove that the plan actually run was the plan the constraints implied. Dispatch logs record constraint-satisfaction proofs alongside final itineraries, letting municipal auditors reconstruct routing decisions without touching the live operational database — which satisfies public-records requests and environmental-compliance audits while preserving data sovereignty. The canonical structure for these records, and how they slot into the wider Core Architecture & Compliance Mapping design, defines exactly which fields are mandatory.
At minimum, a production audit record carries the DOT/FMCSA and EPA fields an inspector will ask for:
input_hashandconstraint_config_hash— SHA-256 over the validated node set and the serialized hard-constraint bounds.driver_id,on_duty_seconds,driving_seconds— the HOS accounting mapped from 49 CFR Part 395 that proves the shift stayed under the driving ceiling.epa_manifest_idand per-stopwaste_stream_code— tying regulated-waste transfers to the EPA e-Manifest (RCRA hazardous waste manifest, EPA Form 8700-22) so declared tonnage reconciles with weighbridge tickets.solution_hashandobjective_value— the reproducibility fingerprint of the emitted plan.
def build_audit_record(input_hash: str, constraint_bounds: Dict,
solution: Dict, driver_id: str,
on_duty_sec: int, driving_sec: int) -> Dict:
constraint_config_hash = hashlib.sha256(
json.dumps(constraint_bounds, sort_keys=True).encode()
).hexdigest()
record = {
"input_hash": input_hash,
"constraint_config_hash": constraint_config_hash,
"solution_hash": solution["solution_hash"],
"objective_value": solution["objective_value"],
"driver_id": driver_id,
"on_duty_seconds": on_duty_sec, # 49 CFR Part 395 HOS accounting
"driving_seconds": driving_sec,
}
logger.info("Immutable audit record emitted", extra=record)
return record
Because the record is derived entirely from hashes and integer counters, it can be written to append-only storage (an object-lock bucket or a WORM table) and replayed byte-for-byte during an audit.
Failure Modes and Production Gotchas
Every waste-routing pipeline meets the same four failure classes in its first month of production. Each has a concrete, deterministic mitigation.
Infeasible constraint sets. When hard bounds cannot all be satisfied — too much demand for the fleet, or overlapping tight windows — OR-Tools returns None rather than a bad plan. Detect it explicitly and relax the softest legal dimension (usually adding an overflow vehicle or a second disposal trip), never a regulatory one:
def solve_with_feasibility_guard(solve_fn, relax_fn, max_relaxations: int = 2):
attempt = 0
while attempt <= max_relaxations:
result = solve_fn()
if result is not None:
return result
logger.warning("Infeasible set; applying bounded relaxation",
extra={"attempt": attempt})
relax_fn(attempt) # e.g. add an overflow vehicle — NOT a wider noise window
attempt += 1
raise RuntimeError("Infeasible after bounded relaxation")
Solver timeouts. Guided local search will run until its time limit; without one it hangs a worker. Always set search_parameters.time_limit.FromSeconds(...) and treat the first-solution result as a valid fallback if refinement does not converge.
GPS drift. Coordinate error near urban canyons corrupts the distance matrix. Snap raw fixes to the municipal right-of-way network and reject displacements that exceed a plausible per-interval speed before matrix generation:
from math import radians, sin, cos, asin, sqrt
def haversine_km(lat1, lon1, lat2, lon2):
r = 6371.0
dlat, dlon = radians(lat2 - lat1), radians(lon2 - lon1)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return 2 * r * asin(sqrt(a))
def reject_drift(prev, curr, elapsed_sec, max_kmh=90.0) -> bool:
km = haversine_km(*prev, *curr)
if elapsed_sec > 0 and (km / (elapsed_sec / 3600.0)) > max_kmh:
logger.warning("GPS drift rejected", extra={"jump_km": round(km, 3)})
return True
return False
The great-circle distance used above is the Haversine formula:
Memory bottlenecks during large matrix construction. An dense distance matrix is ; at several thousand stops the dense float array dominates memory. Build the matrix in chunks, store it as a sparse or int32 representation, and force a collection between solver invocations. The mitigation for this class is covered next.
Performance and Scalability
A dense distance matrix for stops holds entries; at 5,000 stops that is 25 million cells, and stored as 64-bit floats it exceeds 190 MB before the solver has allocated anything. Three techniques keep large fleets tractable:
- Chunked matrix computation. Compute rows in bounded batches rather than materializing the full array at once, releasing intermediate buffers between chunks so peak memory tracks the chunk size, not the whole matrix.
- Sparse and narrow representations. Store travel times as
int32seconds (OR-Tools wants integer costs anyway) and drop pairs beyond a routing radius to a sentinel, cutting footprint by more than half versus dense floats. - Explicit GC between solves. Long-running dispatch workers that solve back-to-back accumulate solver arenas; a
gc.collect()after each solve, as in the core pattern above, prevents slow memory creep across a shift.
def build_matrix_chunked(nodes: List[RouteNode], chunk: int = 500) -> "list[list[int]]":
"""Row-chunked int32 travel-time matrix; peak memory tracks `chunk`, not n^2."""
n = len(nodes)
matrix = [[0] * n for _ in range(n)]
for start in range(0, n, chunk):
for i in range(start, min(start + chunk, n)):
for j in range(n):
if i == j:
continue
km = haversine_km(nodes[i].lat, nodes[i].lon, nodes[j].lat, nodes[j].lon)
matrix[i][j] = int(km / 30.0 * 3600) # ~30 km/h municipal avg → seconds
gc.collect()
return matrix
As a benchmark target, a single worker with these mitigations solves a 1,500-stop, 40-vehicle multi-depot instance to a stable guided-local-search plateau inside a 30-second time limit; beyond roughly 3,000 stops, shard the problem by collection zone and solve zones in parallel before merging depot returns. Batch recalculation on demand-spike days — storm surges, post-holiday overflow — reuses the same chunked build against a re-resolved demand vector rather than rebuilding geometry from scratch.
By decoupling routing logic from ephemeral state and enforcing cryptographic verification at the pipeline boundary, waste-management systems achieve deterministic, compliant, and reproducible dispatch plans. Structured logging, explicit error handling, telemetry-driven calibration, and hard-constraint feasibility together let these architectures scale reliably across seasonal demand swings, infrastructure gaps, and evolving municipal mandates.
Related
- Time Window Constraints — hard service-window boundaries and VRPTW modeling
- Capacity & Weight Limits — GVWR/axle envelopes and split-trip logic
- OR-Tools Implementation — dimension registration and solver configuration
- Dynamic Threshold Tuning — telemetry-driven solver calibration
- Telematics & Sensor Data Ingestion — the sensor pipeline feeding demand and geometry