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.
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.
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_salong 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_swithin a section and confirm it is one, except on distance-based schemes.
Related
- Configuring edge weights for freight logistics — the composite cost this equivalent is summed into
- Encoding low-emission-zone penalties — the same normalisation applied to a different charge, sharing the hourly rate
- Custom cost functions for routing solvers — how coefficient balance decides whether a toll term actually bites
- Integrating custom traffic weights into OSRM — injecting a computed weight where the engine only offers a preference flag