Route optimisation over a truck-aware matrix

# Route optimisation over a truck-aware matrix

`POST /optimise` (alias `/optimize`) is route optimisation for multi-vehicle, multi-stop fleets: which vehicle visits which stops, in what order, inside time windows and capacities. The part that matters sits underneath it. The travel-time matrix the solver plans against is computed by our own routing engine with your `costing`, `costing_options` and `adr` profile applied, so truck dimensions and ADR tunnel categories shape the plan itself rather than being checked once it comes back. A plan that hands a driver a stop they cannot legally reach is worse than no plan. One request bills a flat 10 calls, fair use caps a problem at 200 unique locations, and the endpoint is enabled per deployment: where an operator has not enabled it, you get a `503`.

## Why does the matrix underneath decide whether a plan is legal?

Because a solver only ever sees numbers, and the law lives in those numbers. A vehicle-routing solver does not know what a bridge is. It sees an N×N matrix of durations and rearranges stops to minimise a cost. If that matrix was built for a car, then every leg it reasons about is a car leg, and the plan comes back neatly sequenced and legally worthless for a 4 m artic carrying a tunnel-code load.

This breaks in two ways we have both seen:

- **Optimise on car times, route on truck times.** The planner sizes a matrix with no truck parameters, picks a sequence, then routes it properly. The sequence is optimal for a vehicle you do not own, and the real drive times do not match the plan the depot is working to.
- **Optimise, then validate.** The plan is checked afterwards and failing stops get dropped or bounced to a human. You are holding a partial plan and a phone call, which is what the optimiser existed to prevent.

The gateway never lets the solver do its own routing. It computes the full travel-time and distance matrix through its own engine, with your costing applied, and hands the solver explicit matrices. `POST /optimise` therefore respects exactly the same constraints as `POST /route` and `POST /matrix`. The dimensional argument is the same one we make for single routes in [truck routing with dimensions enforced in costing](/news/truck-routing-dimensions), and the matrix that feeds this endpoint is the one described in [the matrix API](/news/matrix-api).

## What does a request look like?

A fleet, some stops, a costing and optionally an ADR profile. Locations are `{ "lat": …, "lon": … }` objects with named keys, so there is no coordinate-order ambiguity. Durations are seconds, distances are metres. A London depot, deliveries to Birmingham and Manchester, back to the depot, 44-tonne artic carrying hazmat with ADR tunnel code C:

```sh
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl -fsS -X POST "$BASE/optimise" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"vehicles": [
{ "id": 1,
"start": { "lat": 51.5074, "lon": -0.1278 },
"end": { "lat": 51.5074, "lon": -0.1278 },
"capacity": [10], "time_window": [28800, 64800] }
],
"jobs": [
{ "id": 10, "location": { "lat": 52.4862, "lon": -1.8904 },
"service_s": 300, "delivery": [2] },
{ "id": 11, "location": { "lat": 53.4808, "lon": -2.2426 },
"service_s": 300, "delivery": [3] }
],
"costing": "truck",
"adr": {
"dimensions": { "height_m": 4.0, "width_m": 2.55,
"length_m": 16.5, "gross_weight_t": 40.0 },
"tunnel_code": "C",
"hazmat": true
}
}'
```

The top-level body:

| Field | Required | Description |
| --- | --- | --- |
| `vehicles` | yes | The fleet. At least one. |
| `jobs` | see note | Single-stop jobs. At least one job or shipment is required. |
| `shipments` | see note | Pickup and delivery pairs that ride the same vehicle, pickup first. |
| `costing` | no | `"auto"` (default) or `"truck"`. Chooses the matrix costing. |
| `costing_options` | no | Valhalla-style options passed to the matrix verbatim, e.g. `{"truck": {"height": 4.0}}`. |
| `adr` | no | ADR profile (`dimensions`, `tunnel_code`, `hazmat`). Requires `"costing": "truck"`, else `400`. |
| `eu_drivers_hours` | no | Auto-generate a compliant mid-shift break. See below. |

`time_window`, `time_windows` and the response's `arrival` values are plain seconds on any consistent epoch you choose. The example uses seconds since midnight, so `28800` is 08:00 and `64800` is 18:00.

The response, truncated but shape-complete:

```json
{
"code": 0,
"profile": "truck",
"summary": { "cost": 26000, "routes": 1, "unassigned": 0,
"duration": 26000, "service": 600, "waiting_time": 0,
"distance": 655000 },
"unassigned": [],
"routes": [
{ "vehicle": 1, "cost": 26000, "duration": 26000, "distance": 655000,
"service": 600, "waiting_time": 0,
"steps": [
{ "type": "start", "arrival": 28800, "duration": 0,
"service": 0, "waiting_time": 0, "load": [5],
"location": { "lat": 51.5074, "lon": -0.1278 } },
{ "type": "job", "id": 10, "arrival": 35400, "duration": 6600,
"service": 300, "waiting_time": 0, "load": [3],
"location": { "lat": 52.4862, "lon": -1.8904 } },
{ "type": "job", "id": 11, "arrival": 41000, "duration": 11900,
"service": 300, "waiting_time": 0, "load": [0],
"location": { "lat": 53.4808, "lon": -2.2426 } },
{ "type": "end", "arrival": 55400, "duration": 26000,
"service": 0, "waiting_time": 0, "load": [0],
"location": { "lat": 51.5074, "lon": -0.1278 } }
] }
]
}
```

Two things when you parse it. `profile` echoes the matrix costing actually used, `"auto"` or `"truck"`, so you can assert you got the vehicle you asked for. And unassignable tasks are never silently dropped, each appearing in `unassigned` with its id, type and location, so a stop no vehicle can legally serve is visible rather than missing.

## How does it handle driver breaks and work limits?

With explicit breaks, auto-generated EU drivers' hours, per-vehicle caps and per-vehicle costs. Fleets do not optimise for time alone: they optimise inside the law and inside a budget.

A `breaks` array on a vehicle is a mandatory rest with no location: the solver places it wherever the `time_windows` allow, and it comes back as a step of `type: "break"`. Each break takes `id`, `time_windows`, an optional `service_s` duration, an optional `max_load` (the break is only allowed while the load is at or below it) and an optional `description`.

Set `eu_drivers_hours` at the top level and the gateway generates one for every vehicle that has a `time_window` but no explicit `breaks`:

```json
{ "eu_drivers_hours": true }
```

`true` gives the Regulation (EC) No 561/2006 defaults: 45 minutes' rest after at most 4.5 hours' driving. Pass an object to override them, for example `{ "driving_before_break_s": 16200, "break_duration_s": 2700 }`. A break is generated only when the shift is long enough to need one, and an explicit `breaks` array always wins.

The rest of the compliance and cost surface lives on the vehicle:

| Field | What it does |
| --- | --- |
| `time_window` | The working window, in seconds on your epoch. |
| `max_travel_time` | Caps driving time in seconds, excluding service, setup and waiting. A daily driving limit maps here. |
| `max_tasks` | Caps how many tasks this vehicle may serve. |
| `capacity` | Multidimensional capacity, matched against job `delivery` and `pickup` and shipment `amount`. |
| `skills` | A task requiring a skill the vehicle lacks is never assigned to it. ADR-trained drivers and tail-lifts both model here. |
| `costs` | `{fixed, per_hour, per_task_hour, per_km}`, for plans that are cost-optimal rather than merely time-optimal. |

## What does an optimisation cost?

A flat 10 calls of its class per request, regardless of problem size, and the internal N×N matrix it triggers is not billed on top. The class is decided from the request: Standard for `costing: "auto"`, Premium for truck or ADR.

| Request | Class | Entry-band price per optimisation |
| --- | --- | --- |
| `"costing": "auto"` | Standard, 0.05p per call | 0.5p |
| `"costing": "truck"` or an `adr` profile | Premium, 1p per call | 10p |

Both classes are tiered and marginal, so each band prices only the calls inside it: Standard runs 0.05p, then 0.03p beyond 1M calls a month, then 0.02p beyond 10M. Premium runs 1p, 0.7p and 0.4p across the same bands. Above 25M a month we quote.

On the free tier, 50,000 included calls a month land after you verify an email, and commercial use is allowed. Included volume is denominated in standard calls and a premium call draws 20, so a truck optimisation draws 200 included calls and an `auto` one draws 10.

## What it does not do

Route optimisation is off unless the operator turns it on. The solver runs as an optional sidecar, so a deployment with no `SN_VROOM_URL` configured returns `503` with type `urn:sn-gateway:problem:optimisation-not-enabled`, and there is nothing you can do about it from the client side. On self-host, the distro's `docker-compose.yml` ships a ready-made service to uncomment.

The other limits worth knowing before you build against this:

- **200 unique locations per request.** Beyond that you get `422` with type `urn:sn-gateway:problem:optimisation-too-large`, and a body carrying `max_locations` and `locations` so you can size the split. Talk to us for larger sustained problems.
- **No geometry comes back.** The solver returns sequences, not lines. `options.g: true` is a `400`, not a slow path. Fetch each leg from `POST /route` when you need something to draw.
- **`eu_drivers_hours` is a single-shift approximation.** It covers a day fleet's mid-shift rest. It does not model split breaks, daily and weekly rest or duty-time-exact enforcement. For those, post-validate the returned plan. We would rather tell you the shape of the gap than let you find it at a tachograph download.
- **Unreachable pairs are penalised, not rejected.** If a location pair cannot be reached under your costing, the matrix cell gets a very large sentinel cost instead of an error, so the solver avoids it where any alternative exists. A returned duration in the millions of seconds means a stop is probably unreachable: check its coordinates and your truck constraints.
- **`400` is yours, `502` is ours.** Invalid input is `400` and never worth retrying. A `502` (`urn:sn-gateway:problem:upstream-error`) means the sidecar failed, and a retry is reasonable.

## Try it

The full field reference, every error slug and the shipment shapes are in the [route optimisation docs](/docs/optimisation). Agents get the same capability as the `optimise_routes` [MCP tool](/docs/mcp): check compliance with `check_adr_tunnel`, optimise the visits, then fetch geometry per leg. Issue a key with one card-free call and paste the curl above.

Routing derives from OpenStreetMap, so credit "© OpenStreetMap contributors" when you render or republish it.