Tuning Routing Profiles for Retail Vehicle Types

This page solves one exact task: producing routing profiles that describe the vehicles a retail business actually cares about — the shopper’s car, the delivery van, the articulated lorry serving a distribution centre — instead of using one generic car profile for all three.

The distinction matters because these vehicles do not share a road network. A van is barred from some residential streets and permitted in loading bays; a lorry cannot pass under a low bridge or turn into a tight service yard; a shopper’s car goes almost everywhere and parks. Modelling all three with one profile gives one of them a correct answer and the other two something plausible and wrong.

Prerequisites

  • A routing engine whose profile is a file you control, per preparing OSM network extracts for routing.
  • An extract retaining the tags each profile reads — access, dimension, weight and surface tags as well as highway classes.
  • Observed trips for the vehicle types you can measure. Delivery telematics for vans and lorries; loyalty-app timings or a commercial travel-time dataset for shopper cars.
  • Python packages: pandas and numpy for the fitting, httpx for probing. Install with pip install pandas numpy httpx.

Configuration and execution parameters

Parameter Shopper car Delivery van Heavy goods
Highway classes all public roads all public, plus service primary and above, plus permitted access
Access tags read access, motor_vehicle plus goods, delivery plus hgv
Dimension limits ignored height, width height, width, weight, length
Speed basis 0.8 × limit, class-adjusted 0.75 × car speed 0.65 × car speed, capped
Turn penalty 8 s 12 s 25 s
U-turn penalty 20 s 40 s effectively barred
Service roads allowed allowed permitted only
Living streets allowed restricted barred

The speed columns are ratios rather than absolutes on purpose. A van does not travel at a fixed speed; it travels somewhat slower than a car on the same road for reasons of acceleration, load and driver behaviour, and expressing that as a ratio means an improvement to the car speed table propagates to the others rather than leaving them stale.

Three vehicles, one origin, three very different networks From the same distribution point, a thirty-minute contour covers 812 square kilometres for a car, 604 for a delivery van and 388 for a heavy goods vehicle. The lorry's contour is not simply smaller — it is shaped by the primary road network, missing whole residential districts the van reaches easily. The lorry's catchment is a different shape, not a smaller one profile 30-min area edges usable what it cannot use shopper car 812 km² 100% private roads only delivery van 604 km² 92% living streets, low bridges heavy goods 388 km² 41% most residential streets Using the car contour to plan lorry service radii promises deliveries down streets no lorry can enter, and using the lorry contour to size a shopper catchment throws away half the customers. The edges-usable column is the honest summary: these are not variations of one network but three networks that happen to overlap. Measured from one regional distribution centre; ratios are typical for a mixed urban-rural region.

Annotated implementation

Fitting a speed table means comparing modelled durations against observed ones and adjusting per road class until the residuals are small and unbiased. The routine below does that fit and — importantly — reports where the observations were too thin to support it.

python
from __future__ import annotations

import numpy as np
import pandas as pd

MIN_OBS_PER_CLASS = 200


def fit_class_factors(observed: pd.DataFrame, modelled: pd.DataFrame) -> pd.DataFrame:
    """Per road class, the factor that best aligns modelled with observed time.

    observed: trip_id, class_share columns (fraction of the trip on each class),
              duration_s
    modelled: trip_id, duration_s from the current profile
    """
    joined = observed.merge(modelled, on="trip_id", suffixes=("_obs", "_mod"))
    classes = [c for c in observed.columns if c.startswith("share_")]

    # Weighted least squares over class shares: how much of the residual does
    # each class explain? A class present in few trips gets a wide interval and
    # should NOT be adjusted on that evidence.
    x = joined[classes].to_numpy()
    ratio = (joined["duration_s_obs"] / joined["duration_s_mod"]).to_numpy()
    factors, *_ = np.linalg.lstsq(x, ratio, rcond=None)

    coverage = (x > 0).sum(axis=0)
    return pd.DataFrame({
        "road_class": [c.removeprefix("share_") for c in classes],
        "factor": np.round(factors, 3),
        "trips_observed": coverage,
        # Below the threshold the factor is noise dressed as a measurement.
        "apply": coverage >= MIN_OBS_PER_CLASS,
    })


def residual_bias(observed: pd.Series, modelled: pd.Series) -> dict:
    """A good fit is unbiased as well as small — check both."""
    resid = observed - modelled
    return {
        "median_s": float(resid.median()),
        "mae_s": float(resid.abs().mean()),
        "pct_within_10": float((resid.abs() / observed < 0.10).mean()),
    }

The apply column is what keeps the fit honest. A road class appearing in twelve observed trips will produce a factor, and that factor will be shaped by whatever those twelve trips happened to encounter. Marking it as unsupported and leaving the default in place is both more accurate and more explainable than a number derived from a dozen journeys.

Failure modes and debugging

Fitting on the wrong population. Delivery telematics measure trips made by professional drivers on a schedule, often outside peak hours. Fitting a shopper-car profile on that data produces a profile that is systematically fast for the population it will be applied to. Fit each profile on trips made by the vehicle it describes, and where that data does not exist, say so rather than substituting.

Over-fitting to one market. A speed table fitted entirely on trips in one dense metro will be wrong in every rural market, and the direction is not obvious — rural roads are faster in some classes and considerably slower in others. Stratifying the fit by density band, even coarsely, avoids exporting one market’s traffic to the whole estate.

Dimension limits that are not in the data. Height and weight restrictions are recorded in the map inconsistently, so a lorry profile that trusts them entirely will route under bridges that are not tagged. This is a known and unavoidable gap; the mitigation is to treat lorry routing as advisory for planning purposes and to keep a manual exception list of known constraints per market.

One profile edited for a specific complaint. Somebody reports that a route looks slow, a class factor is nudged, and every catchment in the estate shifts. Profile changes deserve the same treatment as any other pipeline change: a version, a regression run against the fixed route set, and a note about what evidence motivated them.

What fitting the speed table actually buys With the engine's default speeds, modelled durations are a median of 94 seconds faster than observed and only 38 per cent of trips land within ten per cent. After fitting per road class, the median bias is 3 seconds and 79 per cent of trips are within ten per cent, with the remaining error concentrated in congested peak trips. Default speeds are optimistic in one direction, consistently speed source median bias mean abs error within 10% engine defaults −94 s (too fast) 128 s 38% flat 0.8 × posted limit −41 s 86 s 57% fitted per road class −3 s 44 s 79% The bias column matters more than the error column: a model that is wrong by ninety seconds in the same direction on every trip inflates every catchment, while a symmetric error largely cancels in aggregate. The residual 21 per cent are overwhelmingly peak-hour trips, which is an argument for a second time-of-day profile rather than for further tuning of this one.

Verification

  • Route a known-restricted street per profile. A living street should be barred to lorries and open to cars; a delivery-only service road should be open to the van profile and closed to the car. One probe per rule proves the tags are being read.
  • Compare contour areas across profiles for the same origins, as in the table above. The ratios should be stable across markets; a market where the van and car contours are identical usually means the access tags are not being applied.
  • Check the fit’s residual bias, not only its error. A profile with a small mean error and a consistent sign is still systematically wrong.
  • Re-run the fixed route set after any profile change, and treat the comparison as part of the change rather than as a follow-up.
One probe per rule, per profile A living street must be usable by car, restricted for a van and barred to a lorry; a delivery-only service road barred to a car and open to both commercial profiles; a low bridge open to car and van and barred to a lorry. Each cell has one expected answer and fails loudly when a tag stops being read. The matrix that proves each profile reads its own rules car van lorry living street allowed restricted barred delivery-only service road barred allowed allowed 3.6 m height limit allowed allowed barred 7.5 t weight limit allowed allowed barred Twelve probes, under a second each, and they are the only evidence that three profiles are genuinely three.

Frequently Asked Questions

Is a separate lorry profile worth it for a retail site-selection model?

For the shopper catchment, no — that is a car question. For the site itself, frequently yes: whether a candidate location can be serviced by the standard delivery vehicle is a hard constraint that kills sites late in diligence, and discovering it from a routing profile during screening is far cheaper than discovering it from a driver. Running the lorry profile against the shortlist rather than the whole candidate pool keeps the cost proportionate.

How often do profiles need refitting?

Annually, or when the observation set grows materially. Road speeds change slowly outside of specific interventions, and a profile refitted every month on slightly different samples produces churn that is indistinguishable from noise. What does deserve a prompt refit is a market whose traffic conditions changed structurally — a new bypass, a congestion charge — since those move a whole class rather than a few edges.

Should the shopper profile model parking?

Not inside the routing graph, and yes as a site attribute. Parking search time is real, it can be several minutes in a dense centre, and it is a property of the destination rather than of the route — so encoding it as an edge cost puts it in the wrong place and makes it invisible to anyone reading the profile. Adding a per-site access penalty derived from format and location keeps the routing honest and puts the parking assumption where a reviewer will find it.

What if no observed trips exist at all?

Use published speed data if any is available for the market, fall back on a fraction of posted limits, and record the choice in the build metadata as an assumption rather than a fit. Then plan the measurement: even a few hundred timed trips from a delivery operation give enough signal to check whether the assumption is badly wrong, which is the only question that matters at that stage.

Where should the profiles themselves live?

In version control, beside the pipeline code, and hashed into the build identifier. Profiles are code — they contain speed tables, access logic and penalties that determine every duration the business plans on — and treating them as configuration that lives on a build host is how an edit made during an incident becomes permanent and unattributable. Reviewing a profile change as a pull request, with the route-set regression attached, is the same discipline applied to any other change that moves numbers.

← Back to Preparing OSM Network Extracts for Routing