Tolls are the clearest case of a routing cost that arrives in the wrong units. Travel time is in seconds, distance in metres, and a toll in euros — and a shortest-path algorithm can only minimise one quantity. This page covers the conversion that makes them comparable, as part of configuring edge weights for freight logistics within OSM Graph Architecture & Network Modeling.

The conversion itself is a division. The care is in what you divide by, where you attach the result, and how you stop the toll leaking onto edges that are not tolled.

When to use this approach

Model tolls explicitly whenever the fleet operates in a tolled network and the cost is material relative to the time saved — which is most European long-haul and much North American freight. Skip it where the fleet has a flat-rate transponder arrangement that makes marginal toll cost zero, because in that case the honest model really is no toll cost at all.

The decision that has to precede the modelling is what the business actually optimises. A carrier paid per delivery optimises cost; one paid per hour optimises time; one with a service-level agreement optimises reliability. The hourly rate is the number that encodes which.

The hourly rate decides which route wins A tolled motorway saves 26 minutes and costs 18 euros against a free trunk road. At a 30 euro hourly rate the toll is not worth it; at 60 it is marginal; at 110 the motorway wins comfortably. The routing decision is entirely a function of the rate. Motorway saves 26 min and costs €18 — is it worth it? rate €30/h toll ≡ 36 min — worse than the 26 saved take the free road rate €60/h toll ≡ 18 min marginal — 8 minutes of benefit rate €110/h ≡ 10 min take the motorway — 16 minutes of benefit 26 min — the time the motorway saves None of these answers is wrong. Which one the router gives depends entirely on a business number that is often left at a default nobody chose.

Implementation

The conversion is trivial; the tariff lookup is where the work is.

# requires: none beyond the standard library
from dataclasses import dataclass


@dataclass(frozen=True)
class VehicleProfile:
    axles: int
    euro_class: str
    hourly_rate: float          # fully loaded operating cost per hour


def toll_seconds(charge: float, profile: VehicleProfile) -> int:
    """Monetary charge -> equivalent seconds of operating time."""
    if profile.hourly_rate <= 0:
        raise ValueError("hourly_rate must be positive — a zero rate makes tolls free")
    return int(round(charge / profile.hourly_rate * 3600))

Guarding against a zero rate is not paranoia. A default-constructed profile with hourly_rate = 0 produces a division error at best and, if someone “fixes” it with a fallback of zero seconds, a fleet that takes every toll road in the network without anyone noticing why.

Real tariffs depend on the vehicle, so the lookup usually needs axles and emission class:

# requires: pandas (pip install pandas)
import pandas as pd


def resolve_charge(tariffs: pd.DataFrame, section_id: str,
                   profile: VehicleProfile) -> float:
    """Charge for one tolled section, by axle count and emission class."""
    rows = tariffs[
        (tariffs["section_id"] == section_id)
        & (tariffs["min_axles"] <= profile.axles)
        & (tariffs["max_axles"] >= profile.axles)
    ]
    if rows.empty:
        # An unknown section must not be free — that biases every route through it
        raise KeyError(f"no tariff for section {section_id}, {profile.axles} axles")

    exact = rows[rows["euro_class"] == profile.euro_class]
    # Fall back to the most expensive band rather than the cheapest
    chosen = exact if not exact.empty else rows
    return float(chosen["charge"].max())

Falling back to the most expensive band is deliberate. An unmatched emission class means the data is incomplete, and the safe direction to be wrong is toward over-charging: an over-priced toll road produces a slightly conservative route, whereas an under-priced one produces an invoice nobody budgeted for.

Attaching the result correctly is the last piece:

# requires: networkx (pip install networkx)
import networkx as nx


def apply_barrier_tolls(G: nx.DiGraph, sections: dict[str, list[tuple]],
                        tariffs, profile: VehicleProfile) -> int:
    """Attach a barrier toll once, on the first edge of each tolled section."""
    applied = 0
    for section_id, edges in sections.items():
        seconds = toll_seconds(resolve_charge(tariffs, section_id, profile), profile)
        u, v = edges[0]                     # entry edge only, not every edge
        data = G[u][v]
        data["toll_equiv_s"] = seconds
        data["freight_cost_s"] = data.get("freight_cost_s", 0) + seconds
        applied += 1
    return applied

Applying the charge to edges[0] rather than to every edge in the section is the difference between a correct model and one that multiplies a single barrier charge by forty. Distance-based tolls — common on some networks — are the genuine exception, and those should be applied per kilometre across the section rather than once.

Key parameters and tuning

Parameter Recommended value Notes
hourly_rate 40–120 by fleet and region Fully loaded marginal cost, not driver wage alone
Tariff granularity axles × emission class Both drive real tariffs in most European schemes
Unmatched tariff raise A free unknown section biases every route through it
Fallback band most expensive Conservative routing beats an unbudgeted invoice
Barrier toll placement entry edge only Per-edge application multiplies a single charge
Distance toll placement per kilometre The one case where spreading is correct
Rate review cadence quarterly Fuel and wage movement makes a stale rate quietly wrong

Integration points

Into the composite weight. The time equivalent joins the freight cost built in configuring edge weights for freight logistics, summed alongside travel time and surface penalties because they are now in the same units. Keeping toll_equiv_s as its own column as well as folding it into the total lets a report say what a route’s tolls actually cost.

With low-emission-zone charges. The same normalisation applies, and the two should share the hourly rate. Two different rates for two different charge types produce a model where a fleet is more toll-averse than zone-averse for no articulable reason — see encoding low-emission-zone penalties.

With engine profiles. Valhalla’s use_tolls and OSRM’s Lua toll handling both express a preference rather than a price. Where the exact monetary trade-off matters, compute the equivalent yourself and inject it as a weight, as covered in integrating custom traffic weights into OSRM.

Where the charge is attached changes it by an order of magnitude A 40 kilometre tolled section made of 38 edges carries an 18 euro barrier charge. Applied once at the entry edge the route pays 18 euros. Applied to every edge it pays 684, and the router avoids the motorway entirely. One €18 barrier charge, 38 edges in the section entry edge only charged once — €18, ≡ 18 min at €60/h every edge … 38 edges … €684 — the motorway is never chosen again The symptom is a fleet that mysteriously never uses a motorway it should. The cause is an attachment decision made once and never revisited. Distance-based schemes invert this: there, per-edge is correct and a single entry charge understates by the same factor. Tariff by axle count, converted to time The same 40 kilometre tolled section costs between 9.20 and 23.40 euros depending on axle count, which converts to between nine and twenty-three minutes of operating time at a sixty euro hourly rate. Toll tariff by axle count on one motorway section 2 axles €9.20 ≡ 9 min at €60/h 3 axles €14.10 ≡ 14 min 4 axles €18.00 ≡ 18 min 5+ axles €23.40 ≡ 23 min A single tariff applied to every vehicle understates the artic by fourteen minutes and overstates the two-axle van by nine. Resolve the tariff per profile at weight-computation time, not per request — the axle count is a property of the vehicle, not the query.

Validation checklist

  • An untolled control group is unmoved. Route a fixed set of origin-destination pairs with no tolled edge on any path, before and after enabling tolls. Any change means the charge leaked into the base cost.
  • A tolled route’s charge matches the tariff. Sum toll_equiv_s along the returned path, convert back to currency, and compare against the published tariff for that vehicle.
  • Doubling the hourly rate halves the equivalent. A trivial check that nonetheless catches a rate read in the wrong unit — per minute rather than per hour is the usual slip.
  • Unknown sections raise. Feed a section id absent from the tariff table and confirm the pipeline stops rather than treating it as free.
  • Barrier charges appear once per section. Count edges carrying a non-zero toll_equiv_s within a section and confirm it is one, except on distance-based schemes.