# Route optimisation

`POST /optimise` (alias `/optimize`) solves multi-vehicle, multi-stop
problems: which vehicle visits which stops, in what order, within time
windows and capacities. It is the planning layer on top of routing, and it
carries our differentiator all the way through: when you optimise with
`costing: "truck"` (optionally with an `adr` profile; ADR is the European
agreement on carriage of dangerous goods by road; tunnel codes B–E restrict
which tunnels a hazmat load may use, see [conventions](/docs/conventions)
for the full table), the travel-time matrix underneath is computed by our
routing engine with those dimensional and ADR constraints, so the plan
never assumes a leg a lorry cannot legally drive.

## Availability

**Status, honestly:** the hosted gateway at `https://api.mapmap.ai` is live:
[sign up](/signup) for a key, or run the [self-host distro](/docs/self-host).
Base URL and authentication conventions are on the
[conventions page](/docs/conventions).

The solver itself runs as an optional sidecar on the deployment:

- **Self-host:** set `SN_VROOM_URL` on the gateway to a
  [vroom-express](https://github.com/VROOM-Project/vroom) sidecar; the
  [self-host guide](/docs/self-host) and the distro's `docker-compose.yml`
  include a ready-made service to uncomment.
- When the sidecar is not configured, the endpoint returns `503` with
  problem type `urn:sn-gateway:problem:optimisation-not-enabled`.

The gateway never lets the solver do its own routing: it computes the full
travel-time/distance matrix through its own engine (with your costing,
`costing_options` and `adr` profile applied) and hands the solver explicit
matrices. The optimisation respects exactly the same constraints as
`/route` and `/matrix`.

## Request

You need an API key (`Authorization: Bearer snk_…`; get one in the
[quickstart](/docs/quickstart)). Locations on this endpoint are
`{ "lat": …, "lon": … }` objects with named keys, so there is no
coordinate-order ambiguity. Durations are seconds, distances metres.

### Top-level body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `vehicles` | array | yes (≥ 1) | The fleet. |
| `jobs` | array | see note | Single-stop jobs. At least one job **or** shipment is required. |
| `shipments` | array | see note | Pickup+delivery pairs that must ride the same vehicle, pickup first. |
| `costing` | string | no (default `"auto"`) | Costing model for the travel-time matrix: `"auto"` or `"truck"`. |
| `costing_options` | object | no | Valhalla-style costing options passed to the matrix verbatim, e.g. `{"truck": {"height": 4.0}}`. |
| `adr` | object | no | ADR vehicle profile, same shape as on `POST /route` (`dimensions`, `tunnel_code`, `hazmat`). Requires `"costing": "truck"` (else `400`); merged into `costing_options.truck`. |
| `options` | object | no | Only `g` (geometry) exists, and it is **not supported**: `{"g": true}` is a `400`. Fetch leg geometry via `POST /route` instead. |

### Vehicles

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes | Caller-chosen id, echoed back on the vehicle's route. |
| `start` | `{lat, lon}` | at least one of `start`/`end` | Start location. |
| `end` | `{lat, lon}` | at least one of `start`/`end` | End location; omitted, the route ends at its last stop. |
| `capacity` | integer array | no | Multidimensional capacity; same length as job `delivery`/`pickup` and shipment `amount`. |
| `skills` | integer array | no | Skills this vehicle provides; a task requiring a skill the vehicle lacks is never assigned to it. |
| `time_window` | `[start, end]` | no | Working window in seconds (see "Times" below). |
| `breaks` | array | no | Mandatory rest breaks (see "Driver hours & compliance"). |
| `max_travel_time` | integer | no | Cap on driving time in seconds (excludes service, setup and waiting). A daily driving limit maps here. |
| `max_tasks` | integer | no | Cap on the number of tasks this vehicle may serve. |
| `costs` | object | no | Cost model, `{fixed, per_hour, per_task_hour, per_km}`, for cost-optimal (not just time-optimal) plans. Any omitted field uses the solver default. |

### Jobs

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes | Caller-chosen id, echoed back in steps and `unassigned`. |
| `location` | `{lat, lon}` | yes | Job location. |
| `service_s` | integer | no | On-site service time in seconds. |
| `delivery` | integer array | no | Quantities delivered to the job (matches vehicle `capacity`). |
| `pickup` | integer array | no | Quantities picked up at the job. |
| `skills` | integer array | no | Skills the job requires. |
| `time_windows` | array of `[start, end]` | no | Acceptable arrival windows in seconds. |

### Shipments

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `pickup` | object | yes | The pickup end: `id`, `location`, optional `service_s`, optional `time_windows` (same shapes as on jobs). |
| `delivery` | object | yes | The delivery end, same shape as `pickup`. |
| `amount` | integer array | no | Quantities moved (matches vehicle `capacity`). |
| `skills` | integer array | no | Skills the shipment requires. |

### Times

`time_window`, `time_windows` and the response's `arrival` values are plain
seconds on any consistent epoch **you** choose. The example below uses
seconds since midnight: `28800` = 08:00, `64800` = 18:00. Arrivals in the
response come back on the same scale.

### Driver hours & compliance

Fleets do not just optimise for time; they optimise inside the law. Two
mechanisms, both of which the underlying solver does not model on its own:

**Explicit breaks.** Give a vehicle a `breaks` array. Each break is a
mandatory rest with **no location**: the solver inserts it into the route
wherever the windows allow:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes | Echoed back on the break step. |
| `time_windows` | array of `[start, end]` | yes | When the break may be taken (same epoch as `time_window`). |
| `service_s` | integer | no | Break duration in seconds. |
| `max_load` | integer array | no | The break is only allowed while the load is at or below this. |
| `description` | string | no | Free text, echoed back on the step. |

**EU drivers' hours (auto-generated).** Set `eu_drivers_hours` at the top
level to have the gateway generate a compliant break for every vehicle that
has a `time_window` but no explicit `breaks`:

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

Pass `true` for the Regulation (EC) No 561/2006 defaults (45 minutes' rest
after at most 4.5 hours' driving) or an object to override them:

```json
{ "eu_drivers_hours": { "driving_before_break_s": 16200, "break_duration_s": 2700 } }
```

A break is generated only when the shift is long enough to need one; short
shifts and vehicles without a `time_window` are left alone, and an explicit
`breaks` array always takes precedence. This is a **single-shift
approximation**: it covers a day fleet's mid-shift rest, but does not model
split breaks, daily/weekly rest, or duty-time-exact enforcement; for those,
post-validate the returned plan. Breaks appear in the route as steps of
`type: "break"`.

### Example

A London depot, deliveries in 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
  }
}'
```

## Response

Truncated but shape-complete: every field shown here is always present on
a `200` (except `distance` and `load`, which appear when the matrix carries
distances and the problem carries quantities, and `id`, which only job,
pickup and delivery steps have):

```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 } }
      ] }
  ]
}
```

Reading it:

- `code` is always `0` on HTTP 200; solver failures surface as HTTP
  errors, never as a non-zero code in a 200 body.
- `profile` echoes the matrix costing used: `"auto"` or `"truck"`.
- `cost` is the solver's objective; with the matrices the gateway supplies
  it equals travel time in seconds.
- Step `duration` is cumulative travel time up to that step; `arrival` is
  on your chosen epoch (see "Times" above).
- Unassignable tasks are never silently dropped: each appears in
  `unassigned` as `{ "id": 12, "type": "job", "location": { "lat": …, "lon": … } }`.
- Route geometry is not returned by the solver; request each leg from
  `POST /route` if you need lines on a map.

**Unreachable pairs:** if a location pair is unreachable under the chosen
costing, the matrix cell is penalised with a very large sentinel cost
rather than an error, so the solver avoids it whenever any alternative
exists. If a returned duration looks absurdly large (millions of seconds),
a stop is likely unreachable; check its coordinates and your truck
constraints.

## Errors

Errors are `application/problem+json` (see
[conventions](/docs/conventions) for the envelope and the 402-vs-429
distinction). The one that matters for retry logic: your input's fault is
`400`, the solver's fault is `502`.

| Status | `type` URN | Meaning | Retry? |
| --- | --- | --- | --- |
| 400 | `urn:sn-gateway:problem:bad-request` | Invalid problem: no vehicles; no jobs or shipments; a vehicle with neither `start` nor `end`; an invalid coordinate; `options.g: true`; `adr` without `"costing": "truck"`; or the solver rejected the input. | No; fix the request. |
| 401 | `urn:sn-gateway:problem:unauthorized` | Missing or invalid API key. | No. |
| 422 | `urn:sn-gateway:problem:optimisation-too-large` | More than 200 unique locations (fair-use cap). Body carries `max_locations` and `locations`. | No; split the problem. |
| 429 | `urn:sn-gateway:problem:quota-exceeded` or `…:rate-limited` | Monthly quota or rate limit; `rate-limited` bodies carry `retry_after` (seconds). | After `retry_after`. |
| 502 | `urn:sn-gateway:problem:upstream-error` | The solver sidecar is unreachable or failed. | Yes; transient. |
| 503 | `urn:sn-gateway:problem:optimisation-not-enabled` | No solver sidecar on this deployment (`SN_VROOM_URL` unset). | Not until the operator enables it. |

Example `422`:

```json
{
  "type": "urn:sn-gateway:problem:optimisation-too-large",
  "title": "Optimisation problem too large",
  "status": 422,
  "detail": "optimisation problem too large: 240 unique locations exceeds the fair-use cap of 200 per request; split the problem or contact the operator",
  "max_locations": 200,
  "locations": 240
}
```

## Metering and fair use

One optimisation request bills a flat 10 calls of its class (Standard for
`costing: "auto"`, Premium for truck/ADR) regardless of problem size; it
triggers an internal N×N matrix and a solver run, and that internal matrix
is not billed separately. See [pricing](/pricing) for the per-class rates.
Fair use is capped at 200 unique locations per request (`422` beyond); talk
to sales for larger sustained problems.

## For agents

The same capability is exposed as the `optimise_routes`
[MCP tool](/docs/mcp), so an agent can plan a fleet's day end to end: check
compliance with `check_adr_tunnel`, optimise the visits, then fetch
geometries per leg.

## Next steps

- [Conventions](/docs/conventions): base URL, auth, error envelope, ADR tunnel codes
- [Analysis APIs](/docs/analysis): the matrix endpoint that powers optimisation
- [Self-host](/docs/self-host): enable the solver sidecar with `SN_VROOM_URL`
- [MCP server](/docs/mcp): `optimise_routes` and `check_adr_tunnel` for agents
