Batch Recalculation on Demand-Spike Days with OR-Tools
Re-dispatch on storm and seasonal surges by re-solving a fresh demand vector while reusing the cached matrix.
This page solves one operational emergency: aggregate demand jumps mid-shift — post-holiday overflow, storm debris, a seasonal surge — and dispatch must re-plan the fleet in seconds by re-solving against the new demand, not by rebuilding the whole model from cold geometry.
When the day’s plan was computed at 5 a.m. and reality diverges by lunchtime, the naive fix is to rerun the entire pipeline: rebuild the distance matrix, re-register every dimension, solve from scratch. That is wasteful and slow — the geometry has not changed, only the load at known stops. The disciplined recalculation reuses the cached matrix, re-resolves demand through the same live-then-baseline ladder the VRP Route Optimization Algorithms pipeline already relies on, warm-starts the solver from the plan currently on the road, adds capacity only if the spike demands it, and re-solves under a hard time bound. This is the on-demand recalculation path referenced by Dynamic Threshold Tuning: the same calibrated solver, re-invoked against a moved demand vector rather than a rebuilt problem.
Environment & Data Prerequisites
The recalculation needs only the pinned solver and the standard library:
python -m pip install \
ortools==9.11.4210 \
pytest==8.2.0
Two inputs drive a spike-day re-solve: the baseline demand vector the morning plan was built on, and the live delta arriving from the field. Demand stays in whole kilograms as int, and the node set is held fixed so the cached matrix stays valid — a spike raises the load at existing bins rather than moving them.
| Field | Type | Unit / range | Role in recalculation |
|---|---|---|---|
stop_id |
str |
e.g. BIN_04 |
cache key + audit join |
lat / lon |
float |
WGS84 bounds | matrix key; unchanged on a spike |
baseline_kg |
int |
0 … capacity | morning demand the live plan was solved on |
live_kg |
int or None |
0 … capacity | current sensor reading; None falls to baseline |
spike_threshold |
float |
e.g. 1.20 |
aggregate ratio that triggers a re-solve |
Step-by-Step Implementation
Step 1 — A matrix cache that refuses to rebuild geometry
The distance matrix is keyed by the coordinate set, so as long as the stops are unchanged a re-solve is a cache hit, not a rebuild. A build counter makes the reuse auditable — and testable.
from __future__ import annotations
from dataclasses import dataclass
import math
import hashlib
import json
@dataclass(frozen=True)
class Stop:
stop_id: str
lat: float
lon: float
class DistanceMatrixCache:
"""Coordinate-keyed matrix cache. builds increments only on a genuine miss."""
def __init__(self) -> None:
self._cache: dict[str, list[list[int]]] = {}
self.builds = 0
@staticmethod
def _key(stops: list[Stop]) -> str:
raw = json.dumps([(s.stop_id, s.lat, s.lon) for s in stops], sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()
def get(self, stops: list[Stop]) -> tuple[list[list[int]], str]:
key = self._key(stops)
if key not in self._cache:
self.builds += 1 # a cold build — the thing we avoid on a spike
self._cache[key] = self._build(stops)
return self._cache[key], key
@staticmethod
def _build(stops: list[Stop]) -> list[list[int]]:
n = len(stops)
d = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i == j:
continue
mean_lat = math.radians((stops[i].lat + stops[j].lat) / 2)
dlat = stops[i].lat - stops[j].lat
dlon = (stops[i].lon - stops[j].lon) * math.cos(mean_lat)
d[i][j] = int(111_320 * math.hypot(dlat, dlon))
return d
BASE_STOPS: list[Stop] = [
Stop("DEPOT", 40.7128, -74.0060),
Stop("BIN_01", 40.7180, -74.0020),
Stop("BIN_02", 40.7205, -74.0110),
Stop("BIN_03", 40.7090, -74.0150),
Stop("BIN_04", 40.7060, -74.0030),
Stop("BIN_05", 40.7220, -73.9950),
Stop("BIN_06", 40.7040, -74.0120),
]
BASELINE_DEMAND = {"BIN_01": 120, "BIN_02": 140, "BIN_03": 130,
"BIN_04": 110, "BIN_05": 150, "BIN_06": 125}
CAPACITY_KG = 600
Step 2 — Re-resolve demand through the live-then-baseline ladder
A spike does not mean every sensor reported. Demand is resolved per stop: use the live reading when present and non-negative, otherwise fall back to the morning baseline — never silently substitute zero, which would skip an overflowing bin. This mirrors the fallback ladder formalized in Fallback Routing Logic.
def resolve_demand(stops: list[Stop],
baseline: dict[str, int],
live: dict[str, int | None]) -> list[int]:
"""Index-aligned demand vector: live reading, else baseline. Depot is 0."""
vec = []
for s in stops:
if s.stop_id == "DEPOT":
vec.append(0)
continue
reading = live.get(s.stop_id)
if reading is not None and reading >= 0:
vec.append(int(reading))
else:
vec.append(int(baseline[s.stop_id]))
return vec
Step 3 — Detect the spike trigger
The trigger is an aggregate ratio: total re-resolved demand over the baseline the live plan was solved on. Below the threshold, the morning plan stands and no re-solve fires; at or above it, recalculation is warranted.
def detect_spike(baseline_vec: list[int], live_vec: list[int],
threshold: float = 1.20) -> dict:
agg_base = sum(baseline_vec)
agg_live = sum(live_vec)
ratio = agg_live / agg_base if agg_base else float("inf")
return {"triggered": ratio >= threshold,
"ratio": round(ratio, 3),
"aggregate_baseline_kg": agg_base,
"aggregate_live_kg": agg_live}
The number of trucks the spike needs is the ceiling of total demand over per-vehicle capacity — this is what turns a load surge into an added vehicle rather than an overloaded run:
where is the re-resolved demand at stop and is the per-vehicle capacity in kilograms.
Step 4 — Reuse the matrix and warm-start the re-solve
The re-solve pulls the matrix from cache (no rebuild), sizes the fleet from the formula above, and warm-starts from the plan currently on the road. ReadAssignmentFromRoutes seeds the search with the live routes; added trucks get empty routes. A hard time_limit bounds the recalculation so a spike-day re-solve cannot itself become an outage.
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
import math as _math
def _params(time_limit_s: int) -> pywrapcp.DefaultRoutingSearchParameters:
p = pywrapcp.DefaultRoutingSearchParameters()
p.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
p.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
p.time_limit.FromSeconds(time_limit_s)
return p
def _model(stops, dist, demand_vec, num_vehicles, capacity):
manager = pywrapcp.RoutingIndexManager(len(stops), num_vehicles, 0)
routing = pywrapcp.RoutingModel(manager)
tc = routing.RegisterTransitCallback(
lambda a, b: dist[manager.IndexToNode(a)][manager.IndexToNode(b)])
routing.SetArcCostEvaluatorOfAllVehicles(tc)
dcb = routing.RegisterUnaryTransitCallback(
lambda a: demand_vec[manager.IndexToNode(a)])
routing.AddDimensionWithVehicleCapacity(
dcb, 0, [capacity] * num_vehicles, True, "Payload")
return routing, manager
def _routes(routing, manager, sol, stops):
out = []
for v in range(manager.GetNumberOfVehicles()):
idx, r = routing.Start(v), []
while not routing.IsEnd(idx):
n = manager.IndexToNode(idx)
if n != 0:
r.append(stops[n].stop_id)
idx = sol.Value(routing.NextVar(idx))
out.append(r)
return out
def warm_resolve(stops, dist, demand_vec, num_vehicles, capacity,
prior_routes: list[list[str]], time_limit_s: int = 3):
routing, manager = _model(stops, dist, demand_vec, num_vehicles, capacity)
id_to_node = {s.stop_id: i for i, s in enumerate(stops)}
seed = [[id_to_node[sid] for sid in r] for r in prior_routes]
while len(seed) < num_vehicles: # added trucks start empty
seed.append([])
routing.CloseModelWithParameters(_params(time_limit_s))
initial = routing.ReadAssignmentFromRoutes(seed, True)
sol = routing.SolveFromAssignmentWithParameters(initial, _params(time_limit_s))
if sol is None:
return None
return {"objective": sol.ObjectiveValue(),
"routes": _routes(routing, manager, sol, stops),
"vehicles": num_vehicles}
Step 5 — Emit the delta-dispatch audit record
Every recalculation writes one structured record naming the trigger, the capacity change, and the objective delta, so an auditor can reconstruct why the road plan changed at 12:14. The JSONFormatter is the site-wide structured-logging pattern, with fields distinct to a re-dispatch event.
import logging
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"event": record.getMessage(),
"trigger_reason": getattr(record, "trigger_reason", None),
"spike_ratio": getattr(record, "spike_ratio", None),
"vehicles_before": getattr(record, "vehicles_before", None),
"vehicles_after": getattr(record, "vehicles_after", None),
"delta_objective": getattr(record, "delta_objective", None),
"matrix_cache_reused": getattr(record, "matrix_cache_reused", None),
}
return json.dumps(payload, separators=(",", ":"))
logger = logging.getLogger("waste_ops.recalc")
if not logger.handlers:
_h = logging.StreamHandler()
_h.setFormatter(JSONFormatter())
logger.addHandler(_h)
logger.setLevel(logging.INFO)
def recalculate(cache: DistanceMatrixCache, stops, baseline, live,
base_plan: dict, capacity: int, threshold: float = 1.20) -> dict:
dist, key = cache.get(stops) # reuse — no rebuild on a spike
baseline_vec = resolve_demand(stops, baseline, {}) # all-baseline reference
live_vec = resolve_demand(stops, baseline, live)
spike = detect_spike(baseline_vec, live_vec, threshold)
if not spike["triggered"]:
return {"recalculated": False, **spike}
needed = _math.ceil(sum(live_vec) / capacity)
vehicles_after = max(base_plan["vehicles"], needed)
plan = warm_resolve(stops, dist, live_vec, vehicles_after, capacity,
base_plan["routes"])
record = {
"recalculated": True,
"trigger_reason": f"aggregate demand ratio {spike['ratio']} >= {threshold}",
"spike_ratio": spike["ratio"],
"vehicles_before": base_plan["vehicles"],
"vehicles_after": vehicles_after,
"delta_objective": plan["objective"] - base_plan["objective"],
"matrix_cache_reused": True,
"routes": plan["routes"],
}
logger.info("demand_spike_recalculated", extra=record)
return record
Compliance Output
A triggered recalculation prints one delta-dispatch record. On a 1.6× storm-debris spike the six-bin baseline (two trucks) needs a third, and the matrix is served from cache:
{
"recalculated": true,
"trigger_reason": "aggregate demand ratio 1.6 >= 1.2",
"spike_ratio": 1.6,
"vehicles_before": 2,
"vehicles_after": 3,
"delta_objective": 977,
"matrix_cache_reused": true,
"routes": [["BIN_02"], ["BIN_04", "BIN_06", "BIN_03"], ["BIN_01", "BIN_05"]]
}
Each field carries an operational or audit purpose:
trigger_reason— the human-readable justification for a mid-shift re-dispatch, so the change is defensible rather than mysterious; it pairs with the threshold logic in Dynamic Threshold Tuning.spike_ratio— the aggregate demand multiple that crossed the threshold; the primary signal distinguishing a genuine surge from sensor noise.vehicles_before/vehicles_after— the capacity change, proving the spike was absorbed by adding a truck rather than overloading the existing fleet past its registered GVWR.delta_objective— the added routing cost the surge imposed, reconciled later against fuel and hours-of-service accounting.matrix_cache_reused— the record that geometry was not rebuilt, which is both the performance guarantee and the evidence that the re-solve used the same audited distances as the morning plan.
The re-resolved live readings themselves arrive pre-validated from the Telematics & Sensor Data Ingestion pipeline, so a spike is never triggered by a corrupt fix.
Verification
The tests prove the two properties that define a correct spike-day recalculation: a genuine surge adds capacity, and the distance matrix is reused rather than rebuilt.
def _base_plan(cache):
dist, _ = cache.get(BASE_STOPS)
vec = resolve_demand(BASE_STOPS, BASELINE_DEMAND, {})
# cold morning solve at two vehicles
routing, manager = _model(BASE_STOPS, dist, vec, 2, CAPACITY_KG)
sol = routing.SolveWithParameters(_params(5))
return {"objective": sol.ObjectiveValue(),
"routes": _routes(routing, manager, sol, BASE_STOPS),
"vehicles": 2}
def test_spike_adds_capacity_and_reuses_matrix():
cache = DistanceMatrixCache()
base = _base_plan(cache)
assert cache.builds == 1 # exactly one cold build so far
live = {sid: int(kg * 1.6) for sid, kg in BASELINE_DEMAND.items()}
result = recalculate(cache, BASE_STOPS, BASELINE_DEMAND, live, base, CAPACITY_KG)
assert result["recalculated"] is True
assert result["vehicles_after"] > result["vehicles_before"] # capacity added
assert result["matrix_cache_reused"] is True
assert cache.builds == 1 # NOT rebuilt during recalculation
def test_small_fluctuation_does_not_trigger():
cache = DistanceMatrixCache()
base = _base_plan(cache)
live = {sid: int(kg * 1.05) for sid, kg in BASELINE_DEMAND.items()}
result = recalculate(cache, BASE_STOPS, BASELINE_DEMAND, live, base, CAPACITY_KG)
assert result["recalculated"] is False # 1.05 < 1.20 threshold
assert cache.builds == 1
Common Errors
Every recalculation rebuilds the distance matrix and the re-solve takes as long as a cold start. Root cause: constructing the matrix inside the re-solve instead of reading it from a keyed cache, so the geometry build runs again even though no stop moved. Fix: route every solve through DistanceMatrixCache.get, key it on the coordinate set, and assert cache.builds stays flat across a spike, exactly as the verification does.
A spike-day re-solve runs for minutes and blows the dispatch deadline. Root cause: the warm re-solve was called without a time_limit, so guided local search keeps refining indefinitely — a recalculation meant to take three seconds becomes its own incident. Fix: always bound the re-solve with p.time_limit.FromSeconds(...) as _params does, and treat the warm-started first solution as a valid fallback if refinement has not converged in time.
ReadAssignmentFromRoutes returns None or the solve raises on a mismatched vector. Root cause: the demand vector length no longer matches the node count — usually because debris stops were appended to the stop list but the demand vector (or the warm-start routes) was not extended in lockstep. Fix: build the demand vector from the same stops list every time via resolve_demand, and if you genuinely add stops, extend the matrix with only the new rows and columns and rebuild the warm-start routes rather than reusing the stale plan.
FAQ
Why reuse the matrix instead of just rebuilding — it is only a few milliseconds here?
At six stops the rebuild is trivial, but a metropolitan network is thousands of stops and an matrix is tens of millions of cells; rebuilding it mid-shift under a spike is exactly when you have the least time to spare. Reuse also preserves auditability: the re-dispatched plan is provably scored against the same distances as the morning plan, so the delta_objective is a true comparison rather than an artifact of a regenerated matrix.
Does warm-starting bias the solver toward the old plan?
It seeds the search, it does not pin it. ReadAssignmentFromRoutes gives guided local search a feasible starting point built from the routes currently on the road, which is far better than a cold first solution when only demand has shifted. The solver is free to move stops between trucks — including onto the newly added vehicle — and the bounded re-solve converges faster because it starts near a good plan.
Related
- Dynamic Threshold Tuning — the calibration layer that owns the spike threshold and solve budgets
- Auto-tuning Route Cost Thresholds — deriving the trigger ratio from historical demand
- Distance Matrix Construction — how the cached matrix is built and keyed
- Fallback Routing Logic — where the demand-resolution ladder escalates
- Telematics & Sensor Data Ingestion — the validated live readings that drive the spike