Capacitated distribution
When weight, volume, cold chain, or specialist equipment are non-negotiable. The optimizer respects every constraint you declare; the cost of not declaring them is a truck that gets to the third stop, runs out of room, and has to come back to the depot. This recipe walks the constraint matrix and the operational patterns for cold chain, oversized loads, and mixed fleets.
Who this is for
Your operation moves physical goods with hard physical constraints:
- A wholesaler distributing palletized goods to retailers — every truck has a finite load capacity in weight and pallet positions.
- A frozen / refrigerated foods distributor maintaining the cold chain end-to-end — only specific vehicles carry refrigeration.
- A beverage / bottled water / gas distributor where volume is the binding constraint rather than weight — a van full of empty 18L containers weighs little but the truck is "full".
- A specialty chemicals or hazardous goods distributor — only licensed vehicles can carry certain SKUs.
- A pharmacy or vaccine distribution chain — temperature-controlled and chain-of-custody are both binding.
What makes you different from a generic last-mile operation: the wrong truck showing up at the wrong stop is not "inefficient", it's "broken". A van without refrigeration cannot serve a frozen-foods stop; a 3.5-ton van cannot serve a stop that needs an 8-ton truck; a driver without ADR can't carry hazardous goods.
This recipe walks through the constraint matrix and the operational patterns that make the optimizer respect every declared constraint — including what to do when "no feasible solution" fires at midnight and the nightly batch can't ship.
The constraint matrix
Three families of constraint, each with a different shape:
| Family | What it constrains | Stop fields | Vehicle fields |
|---|---|---|---|
| Capacity | How much one vehicle can carry. | weight (kg), volume (m³) | default_max_weight, default_max_volume, default_max_services |
| Skills | What kinds of goods a vehicle can carry. | requires: ["frozen", "adr", "oversized"] | default_provides: ["frozen", ...] |
| Geometry | When and from where the vehicle operates. | time_windows, location | default_time_window, default_start_location, default_end_location, default_max_distance, default_max_time |
The optimizer treats all three as hard constraints. A vehicle that doesn't satisfy all three for a candidate stop will not be assigned that stop, even if relaxing one constraint would produce a better overall plan.
Capacity — the most common constraint
Set on every stop:
{
"external_id": "ORDER-9001",
"label": "Acme Restaurant",
"weight": 32, // kilograms — sum of all items in this order
"volume": 0.4 // cubic meters — sum of all items in this order
}Set on every vehicle (becomes the default on every route it executes):
{
"external_id": "TRUCK-007",
"label": "Truck 007 — 8-ton",
"default_max_weight": 8000, // 8 tonnes
"default_max_volume": 35, // 35 m³
"default_max_services": 25 // optional cap on stops/day for driver fatigue
}The optimizer sums weight/volume of all stops on a route and won't exceed
the vehicle's max. If your operation is dominated by volume (bottled
beverages, packaging, empty containers) — set default_max_volume. If it's
dominated by weight (industrial supplies, beverages cases, hardware) — set
default_max_weight. Most operations set both, even if one is rarely the
binding one.
default_max_services caps stops/day per driver. Useful for residential
delivery operations where 25 stops is a long day regardless of weight; less
useful for B2B where it's typically 8–12.
Skills — requires / provides
When the constraint isn't "how much" but "what kind", use skill matching:
// stop
{
"external_id": "ORDER-9001",
"requires": ["frozen"]
}
// vehicle
{
"external_id": "TRUCK-007",
"default_provides": ["frozen", "refrigerated", "ambient"]
}A vehicle that provides a superset of what the stop requires is
eligible. Empty requires means "any vehicle is fine" — the default.
Skill examples that show up in real operations:
| Skill string (suggested) | What it means |
|---|---|
frozen | Vehicle has freezer compartment (≤ -18°C). |
refrigerated | Vehicle has chilled compartment (0 to 4°C). |
ambient | Vehicle is room temperature (default for most). |
adr-class-3 | Driver and vehicle licensed for flammable liquids (ADR class 3). |
oversized | Vehicle is large enough for oversized items (mattresses, large appliances). |
tail-lift | Vehicle has a tail lift for unloading heavy items without a forklift. |
crew-2 | Two-person crew (some installations or heavy unloadings require it). |
forklift-pickup | Stop has a forklift on site — vehicle can be a regular truck. |
Be explicit. requires: ["frozen"] matches provides: ["frozen", "ambient"]
because freezer trucks usually carry ambient too. But a frozen stop will
not match a vehicle that only provides: ["refrigerated"] — chilled and
frozen are different chambers.
Quickstart
The Quickstart below shows the full capacitated-distribution stop and vehicle shapes — weight, volume, skill requirements, narrow service durations. The remainder of the daily-plan job (ensure plan → bulk-add → optimize → dispatch) is the same as nightly batch B2B.
# (1) Onboard the fleet — bulk-create vehicles with default_max_* + provides.
curl -X POST "https://api.routal.com/v3/vehicles?private_key=YOUR_KEY&project_id=PROJECT_ID" \
-H 'Content-Type: application/json' \
-d '[
{
"external_id": "TRUCK-FROZEN-001",
"label": "Truck 001 — Frozen 8t",
"default_provides": ["frozen", "refrigerated", "ambient"],
"default_max_weight": 8000,
"default_max_volume": 35,
"default_max_services": 30,
"default_time_window": [21600, 64800]
},
{
"external_id": "TRUCK-AMBIENT-002",
"label": "Truck 002 — Ambient 3.5t",
"default_provides": ["ambient"],
"default_max_weight": 3500,
"default_max_volume": 18,
"default_max_services": 35,
"default_time_window": [21600, 64800]
}
]'
# (2) Bulk-add stops with weight, volume, requires. Stops requiring "frozen"
# can only land on the frozen truck.
curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=PROJECT_ID" \
-H 'Content-Type: application/json' \
-d '[
{
"external_id": "ORDER-9001",
"label": "FrozenFoods Restaurant — A",
"location": { "lat": 41.20, "lng": 2.05 },
"duration": 600,
"time_windows": [[28800, 43200]],
"weight": 120,
"volume": 0.8,
"requires": ["frozen"]
},
{
"external_id": "ORDER-9002",
"label": "Beta Hardware Store",
"location": { "lat": 41.18, "lng": 2.07 },
"duration": 480,
"time_windows": [[28800, 50400]],
"weight": 240,
"volume": 1.4,
"requires": []
}
]'
# (3) Optimize. Returns no_result_found if there's no feasible assignment.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"import createClient from 'openapi-fetch';
import type { paths } from './routal';
const routal = createClient<paths>({ baseUrl: 'https://api.routal.com' });
const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!;
const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!;
type CapacitatedOrder = {
id: string;
customer: string;
lat: number;
lng: number;
serviceMinutes: number;
windowFromSec: number;
windowToSec: number;
weightKg: number; // explicit — never omit on a capacitated plan
volumeM3: number; // explicit — never omit on a capacitated plan
requires: string[]; // ['frozen'], ['adr-class-3'], etc.
};
type CapacitatedVehicle = {
id: string;
label: string;
provides: string[]; // superset of what stops can require
maxWeightKg: number;
maxVolumeM3: number;
maxServices?: number;
startSec: number; // working window start, seconds from midnight
endSec: number;
};
/** Onboard the fleet once — typically run during initial integration setup. */
export async function syncCapacitatedFleet(vehicles: CapacitatedVehicle[]): Promise<void> {
const { error } = await routal.POST('/v3/vehicles', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } },
body: vehicles.map((v) => ({
external_id: v.id,
label: v.label,
default_provides: v.provides,
default_max_weight: v.maxWeightKg,
default_max_volume: v.maxVolumeM3,
default_max_services: v.maxServices,
default_time_window: [v.startSec, v.endSec],
})) as never,
});
if (error) throw error;
}
/** Bulk-add capacitated stops to a plan. */
export async function bulkAddCapacitatedStops(
planId: string,
orders: CapacitatedOrder[],
): Promise<void> {
if (orders.length === 0) return;
const { error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: orders.map((o) => ({
external_id: o.id,
label: o.customer,
location: { lat: o.lat, lng: o.lng },
duration: o.serviceMinutes * 60,
time_windows: [[o.windowFromSec, o.windowToSec]],
weight: o.weightKg, // always present, even if 0
volume: o.volumeM3, // always present, even if 0
requires: o.requires,
})) as never,
});
if (error) throw error;
}
/** Diagnostic — call before optimize to detect obviously infeasible plans. */
export async function preflightCapacityCheck(planId: string): Promise<{
totalWeight: number;
totalVolume: number;
fleetWeight: number;
fleetVolume: number;
feasible: boolean;
unmatchedSkills: string[];
}> {
const { data: stops } = await routal.GET('/v2/plan/{id}/stops', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
const { data: vehicles } = await routal.GET('/v2/vehicles', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID, limit: 500 } },
});
const stopList = (stops as Array<{ weight?: number; volume?: number; requires?: string[] }>) ?? [];
const vehList = (vehicles?.docs ?? []) as Array<{
enabled?: boolean;
default_max_weight?: number;
default_max_volume?: number;
default_provides?: string[];
}>;
const totalWeight = stopList.reduce((s, x) => s + (x.weight ?? 0), 0);
const totalVolume = stopList.reduce((s, x) => s + (x.volume ?? 0), 0);
const activeVeh = vehList.filter((v) => v.enabled !== false);
const fleetWeight = activeVeh.reduce((s, v) => s + (v.default_max_weight ?? 0), 0);
const fleetVolume = activeVeh.reduce((s, v) => s + (v.default_max_volume ?? 0), 0);
const provided = new Set<string>(activeVeh.flatMap((v) => v.default_provides ?? []));
const unmatchedSkills = Array.from(
new Set(
stopList.flatMap((s) => s.requires ?? []).filter((skill) => !provided.has(skill)),
),
);
return {
totalWeight,
totalVolume,
fleetWeight,
fleetVolume,
unmatchedSkills,
feasible:
totalWeight <= fleetWeight &&
totalVolume <= fleetVolume &&
unmatchedSkills.length === 0,
};
}import os
import requests
ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"
def sync_capacitated_fleet(vehicles: list[dict]) -> None:
payload = [
{
"external_id": v["id"],
"label": v["label"],
"default_provides": v["provides"],
"default_max_weight": v["max_weight_kg"],
"default_max_volume": v["max_volume_m3"],
"default_max_services": v.get("max_services"),
"default_time_window": [v["start_sec"], v["end_sec"]],
}
for v in vehicles
]
resp = requests.post(
f"{BASE}/v3/vehicles",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID},
json=payload,
timeout=60,
)
resp.raise_for_status()
def bulk_add_capacitated_stops(plan_id: str, orders: list[dict]) -> None:
if not orders:
return
payload = [
{
"external_id": o["id"],
"label": o["customer"],
"location": {"lat": o["lat"], "lng": o["lng"]},
"duration": o["service_minutes"] * 60,
"time_windows": [[o["window_from_sec"], o["window_to_sec"]]],
"weight": o["weight_kg"],
"volume": o["volume_m3"],
"requires": o["requires"],
}
for o in orders
]
resp = requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id,
"project_id": PROJECT_ID},
json=payload,
timeout=60,
)
resp.raise_for_status()
def preflight_capacity_check(plan_id: str) -> dict:
"""
Diagnostic — call before optimize to detect obviously infeasible plans.
Returns aggregate stop demand vs aggregate fleet supply, plus any required
skills that no vehicle provides.
"""
stops = requests.get(
f"{BASE}/v2/plan/{plan_id}/stops",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
).json()
vehicles = requests.get(
f"{BASE}/v2/vehicles",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID, "limit": 500},
timeout=30,
).json().get("docs", [])
total_weight = sum(s.get("weight") or 0 for s in stops)
total_volume = sum(s.get("volume") or 0 for s in stops)
active = [v for v in vehicles if v.get("enabled") is not False]
fleet_weight = sum(v.get("default_max_weight") or 0 for v in active)
fleet_volume = sum(v.get("default_max_volume") or 0 for v in active)
provided = {p for v in active for p in (v.get("default_provides") or [])}
required = {r for s in stops for r in (s.get("requires") or [])}
unmatched = sorted(required - provided)
return {
"total_weight": total_weight,
"total_volume": total_volume,
"fleet_weight": fleet_weight,
"fleet_volume": fleet_volume,
"unmatched_skills": unmatched,
"feasible": (
total_weight <= fleet_weight
and total_volume <= fleet_volume
and not unmatched
),
}Production hardening
Always declare weight and volume, even when "small"
The most common production bug in capacitated operations is treating weight
and volume as optional. A stop without weight defaults to 0 — and 0 fits
anywhere, so the optimizer is free to put 50 of them on the smallest van. On
execution day the driver runs out of room and turns around.
Make the import job fail closed: if the source order doesn't carry weight and volume, raise an error rather than push the stop with zeros.
if (order.weightKg == null || order.volumeM3 == null) {
throw new Error(
`order ${order.id} missing weight/volume — refuse to push to a capacitated plan`,
);
}Same logic for requires: a frozen-foods order pushed without
requires: ["frozen"] will land on an ambient truck and arrive thawed.
Pre-flight before optimize
POST /v2/plan/{id}/optimize returns
highway.optimization.error.no_result_found when constraints can't be
satisfied — but it doesn't tell you which constraint failed. Run an
aggregate-supply-vs-aggregate-demand check on your side before calling
optimize:
const check = await preflightCapacityCheck(planId);
if (!check.feasible) {
await alertSupervisor({
plan_id: planId,
overage_weight_kg: Math.max(0, check.totalWeight - check.fleetWeight),
overage_volume_m3: Math.max(0, check.totalVolume - check.fleetVolume),
unmatched_skills: check.unmatchedSkills,
});
return; // don't call optimize — we already know the answer
}This catches 90% of no_result_found errors before they happen and gives the
supervisor actionable information (overage in kg / m³ / skill names) instead
of a generic optimizer rejection.
Note: pre-flight is necessary but not sufficient. The aggregate supply-vs-demand check passes even when per-time-window capacity is broken (e.g. all stops want service between 10:00 and 12:00 but only two trucks are available in that window). The optimizer is the only check that catches geometry + capacity interactions.
Cold chain — model the chain, not just the truck
A frozen-foods route has additional patterns the basic requires: ["frozen"]
doesn't enforce:
- Time-temperature ceilings. A frozen item out of the cold chamber for
more than N minutes is no longer frozen. Routal doesn't enforce this — but
you can keep route
durationbounded by settingdefault_max_timeon the vehicle, indirectly capping how long any single stop can stretch. - Pre-cooling and post-cooling time. The truck takes 20 minutes at the
start of the day to chill down. Model it as the first stop of the route or
by setting the driver's
default_time_windowto start 20 minutes after their actual depot arrival. - Chamber-by-chamber capacity. A frozen truck typically has a frozen
chamber AND a fridge chamber AND ambient space. The single
default_max_volumecovers all of them. If you need per-chamber capacity, split into multiple "virtual" vehicles in Routal — one per chamber — usingchain_idis not the right tool for this.
If your cold chain requirements need stronger guarantees than requires
gives you, talk to support — there are options that aren't part of the
public REST surface today.
Oversized loads — when volume isn't enough
default_max_volume is total m³. It doesn't model shape: a 2m³ mattress
won't fit in a van with 2m³ of remaining capacity if the remaining capacity
is fragmented or short. Two patterns to compensate:
- Use
requires: ["oversized"]on the stop andprovides: ["oversized"]only on vehicles that can geometrically take the item. The match becomes yes/no, not "do you have enough m³ left". - Constrain to one oversized stop per route. Set
default_max_services: 1on a vehicle dedicated to a single oversized delivery. Crude but effective when the geometry truly demands it.
Mixed fleets — explicit provides saves you
A fleet of 20 ambient trucks + 3 frozen trucks + 2 ADR-class-3 trucks looks
ambiguous to the optimizer until you set default_provides correctly:
// ambient trucks
{ "default_provides": ["ambient"] }
// frozen trucks (also carry ambient)
{ "default_provides": ["ambient", "refrigerated", "frozen"] }
// ADR-class-3 trucks (specialized — only ADR cargo)
{ "default_provides": ["ambient", "adr-class-3"] }A stop with requires: ["frozen"] only matches the 3 frozen trucks. A stop
with requires: ["adr-class-3"] only matches the 2 ADR trucks. A stop with no
requires matches every truck.
Don't over-engineer it: skills you never require on a stop are harmless on
the vehicle side. Keep the catalog focused on what actually differentiates
your operation.
What to do when no_result_found fires anyway
Despite the pre-flight, the optimizer may still reject. Common causes:
- A stop's time window is shorter than the minimum service + travel time from any vehicle's depot. Pre-flight misses this because it doesn't compute travel.
- All
frozencapacity is full by 12:00 but stops at 16:00 also require frozen — single-chamber-day capacity exhausted, even though aggregate supply matches aggregate demand. - A specific
requiresskill is provided by only one vehicle, and that vehicle's time window doesn't overlap with all the stops that need it.
Recovery hierarchy:
- Page the supervisor with the diagnostic (which stops, which constraints). Don't auto-relax.
- Add a vehicle for the day from a backup pool, or borrow capacity from a sister depot.
- Reschedule overflow stops to the next day's plan
(
POST /v2/stop/move). - Renegotiate with the customer — windows that systematically can't be met aren't customer service, they're operational debt.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
capacitated.fleet_utilization{dimension} | weight / volume / services — average % of capacity used per route. >85% is great. >100% means you're forecasting wrong. <50% is missed efficiency. |
capacitated.demand_supply_ratio{dimension} | Aggregate stop demand / aggregate fleet supply for the day. ≥1.0 means you can't ship without overflow. |
capacitated.unmatched_skill_total{skill} | Times a requires skill had no provides match. Spike = vehicle was sick or you onboarded a new product type and forgot to update the fleet. |
capacitated.no_result_found_rate | % of optimize calls that returned no_result_found. Sustained > 5% is a routing problem, not a one-off. |
capacitated.overweight_alerts_total | Drivers reporting their truck was over capacity at dispatch time. Should be zero — if not, your weight data upstream is wrong. |
Tag every metric by depot (if multi-depot) and by skill so the dispatcher can break down "frozen capacity utilization" separately from "ambient utilization".
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.no_result_found | Demand exceeds supply on at least one dimension (weight, volume, skill, or time window). | Run pre-flight first. When it still fires, page the supervisor with the diagnostic; don't auto-relax. |
highway.geocoding.error.wrong_lat_lng | A stop's location.lat / lng is out of the valid range. | Validate coords upstream. Refuse to push a stop with lat: 0, lng: 0 or values outside [-90, 90] / [-180, 180]. |
highway.stop.error.custom_fields_invalid | A stop sent custom fields that aren't defined in the project. | Update the project's custom-field schema, or fix the payload. |
400 Bad Request (no message_id) — weight / volume validation | Negative number, NaN, or value out of the schema's allowed range. | Validate upstream. Treat null as "missing" → either default to 0 (and explicitly accept the risk) or refuse. |
highway.optimization.error.too_much_requests | Optimizer rate-limited the request. Heavy capacitated optimizations are more compute-intensive. | Back off 60–90 s and retry. If persistent on large fleets, talk to support about dedicated optimizer capacity. |
Next steps
- Nightly batch — B2B distribution — the daily-plan scaffolding this recipe inherits.
- Field service with appointments — when capacity (parts in the van) and skills (technician certifications) both matter, combine this recipe's vehicle setup with that one's stop shape.
