Skip to main content
Routal

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:

FamilyWhat it constrainsStop fieldsVehicle fields
CapacityHow much one vehicle can carry.weight (kg), volume (m³)default_max_weight, default_max_volume, default_max_services
SkillsWhat kinds of goods a vehicle can carry.requires: ["frozen", "adr", "oversized"]default_provides: ["frozen", ...]
GeometryWhen and from where the vehicle operates.time_windows, locationdefault_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
frozenVehicle has freezer compartment (≤ -18°C).
refrigeratedVehicle has chilled compartment (0 to 4°C).
ambientVehicle is room temperature (default for most).
adr-class-3Driver and vehicle licensed for flammable liquids (ADR class 3).
oversizedVehicle is large enough for oversized items (mattresses, large appliances).
tail-liftVehicle has a tail lift for unloading heavy items without a forklift.
crew-2Two-person crew (some installations or heavy unloadings require it).
forklift-pickupStop 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:

  1. 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 duration bounded by setting default_max_time on the vehicle, indirectly capping how long any single stop can stretch.
  2. 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_window to start 20 minutes after their actual depot arrival.
  3. Chamber-by-chamber capacity. A frozen truck typically has a frozen chamber AND a fridge chamber AND ambient space. The single default_max_volume covers all of them. If you need per-chamber capacity, split into multiple "virtual" vehicles in Routal — one per chamber — using chain_id is 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 and provides: ["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: 1 on 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 frozen capacity 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 requires skill is provided by only one vehicle, and that vehicle's time window doesn't overlap with all the stops that need it.

Recovery hierarchy:

  1. Page the supervisor with the diagnostic (which stops, which constraints). Don't auto-relax.
  2. Add a vehicle for the day from a backup pool, or borrow capacity from a sister depot.
  3. Reschedule overflow stops to the next day's plan (POST /v2/stop/move).
  4. Renegotiate with the customer — windows that systematically can't be met aren't customer service, they're operational debt.

Observability — the metrics that matter

MetricWhat 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_totalDrivers 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_idCauseWhat to do
highway.optimization.error.no_result_foundDemand 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_lngA 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_invalidA 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 validationNegative 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_requestsOptimizer 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