Raw topological distance rarely reflects operational reality for heavy commercial vehicles. Dimensional restrictions, toll pricing, grade penalties, and surface degradation transform a straightforward road network into a tightly constrained optimization surface — and none of those factors are captured by the edge lengths in a raw OSM extract. Configuring edge weights for freight logistics is the step inside the broader OSM Graph Architecture & Network Modeling pipeline that converts topological structure into operationally meaningful cost: the layer where a motorway and a weight-restricted service road stop looking equivalent to a solver.
The methodology below treats weight configuration as a multi-stage pipeline. Each stage adds a cost dimension — time, dimensional compliance, grade, economics — and the stages compose cleanly so you can swap individual components without rebuilding the entire graph.
Prerequisites
Python environment
# Python 3.11+ recommended; 3.9 minimum
pip install osmnx>=1.8 networkx>=3.2 pandas>=2.0 numpy>=1.24 geopandas>=0.14 pyarrow>=14
Data inputs
- A directed, topologically cleaned OSM network. If starting from a raw PBF extract, complete building directed graphs from OSM PBF files first — directional consistency, intersection snapping, and removal of non-navigable ways must be done before weight assignment, not after.
- Vehicle class specifications: GVWR (tonnes), axle configuration, body height (metres), and emission class.
- Regional toll schedules in CSV or JSON format, keyed by toll node or edge ID.
- A Digital Elevation Model (DEM) raster for elevation-based grade computation — SRTM 30 m or equivalent.
Reference documentation
The OSM HGV Tagging Guidelines define the canonical tag vocabulary for access restrictions. Deviating from community-maintained tagging conventions introduces silent mismatches where legal restrictions are invisible to your filter logic.
Conceptual architecture
The diagram below shows how the five weight layers stack on top of the base graph topology. Each layer is additive — removing one does not break the others — and the final freight_cost attribute is what the routing solver consumes.
The key architectural decision is layer ordering: hard filters (Layer 2) must execute before grade and economic penalties, because assigning a penalty to an edge that will later be masked to inf is wasted computation on large graphs.
Step-by-step implementation
1. Base graph initialization and primary weight assignment
Time-based weights align better with driver hours-of-service regulations, fleet scheduling, and fuel consumption models than raw distance. The first step resolves maxspeed from OSM tags and applies a realistic freight speed reduction factor.
# requires: osmnx>=1.8, pandas>=2.0, numpy>=1.24
import osmnx as ox
import pandas as pd
import numpy as np
G = ox.graph_from_place("Chicago, Illinois, USA", network_type="drive")
edges = ox.graph_to_gdfs(G, nodes=False, edges=True)
# Road-class default speeds (km/h) — freight-appropriate conservatism
CLASS_SPEED_KMH: dict[str, float] = {
"motorway": 100, "motorway_link": 80,
"trunk": 90, "trunk_link": 70,
"primary": 70, "primary_link": 55,
"secondary": 60, "tertiary": 50,
"residential": 40, "living_street": 20,
"service": 30, "unclassified": 45,
}
def resolve_speed(row: pd.Series) -> float:
"""Deterministic speed fallback: explicit tag → class default → 40 km/h."""
raw = row.get("maxspeed")
if pd.notna(raw):
try:
# Strip suffix variants: "50 km/h", "30 mph", "50"
val = float(str(raw).split()[0])
return val * 1.60934 if "mph" in str(raw) else val
except (ValueError, AttributeError):
pass
return CLASS_SPEED_KMH.get(row.get("highway"), 40.0)
# Vectorized speed resolution — avoid row-level loops on 500k+ edge graphs
edges["speed_kmh"] = edges.apply(resolve_speed, axis=1)
# 20% freight reduction for loaded vehicles: acceleration lag, payload inertia
FREIGHT_FACTOR = 0.80
edges["speed_ms"] = edges["speed_kmh"] * (1000 / 3600) * FREIGHT_FACTOR
edges["base_time_sec"] = edges["length"] / edges["speed_ms"]
The FREIGHT_FACTOR of 0.80 is a conservative starting point; calibrate against GPS telemetry before deploying. A fleet running refrigerated trailers on urban arterials may need 0.70 or lower.
2. Freight constraint mapping and penalty application
Freight routing requires both hard filters (edge removal) and soft penalties (cost inflation). Apply them in sequence — hard filters first to reduce graph size before computing soft penalties.
Hard filter tags to check:
| OSM tag | Interpretation | Action |
|---|---|---|
maxweight |
Gross vehicle weight limit (tonnes) | Remove edge if GVWR exceeds limit |
maxheight |
Vertical clearance (metres) | Remove edge if vehicle height exceeds limit |
maxaxleload |
Per-axle load limit (tonnes) | Remove edge if axle load exceeds limit |
hgv=no |
Heavy goods vehicle prohibition | Remove edge unconditionally |
access=no |
General access prohibition | Remove edge unless freight exemption applies |
# requires: pandas>=2.0, numpy>=1.24
# Vehicle class parameters — parameterize per fleet type
VEHICLE_HEIGHT_M = 4.0 # metres
VEHICLE_GVWR_T = 26.0 # tonnes
VEHICLE_AXLE_T = 11.5 # tonnes per axle
def parse_numeric(series: pd.Series, default: float) -> pd.Series:
"""Coerce tag strings to float, substituting default for missing/malformed values."""
return pd.to_numeric(
series.str.extract(r"([\d.]+)", expand=False),
errors="coerce"
).fillna(default)
# Hard filters — assign inf rather than drop to preserve graph connectivity
PASSABLE_MASK = (
(parse_numeric(edges.get("maxheight", pd.Series(dtype=str)), 99.0) >= VEHICLE_HEIGHT_M) &
(parse_numeric(edges.get("maxweight", pd.Series(dtype=str)), 99.0) >= VEHICLE_GVWR_T) &
(parse_numeric(edges.get("maxaxleload",pd.Series(dtype=str)), 99.0) >= VEHICLE_AXLE_T) &
(~edges.get("hgv", pd.Series(dtype=str)).isin(["no", "private"]))
)
edges["base_time_sec"] = edges["base_time_sec"].where(PASSABLE_MASK, other=float("inf"))
# Soft penalties — multiplicative on passable edges only
penalty = pd.Series(1.0, index=edges.index)
surface = edges.get("surface", pd.Series(dtype=str))
penalty = penalty.where(~surface.isin(["unpaved", "gravel", "compacted"]), other=penalty * 1.30)
penalty = penalty.where(~surface.isin(["dirt", "mud", "sand"]), other=penalty * 1.60)
lanes = pd.to_numeric(edges.get("lanes", pd.Series(dtype=float)), errors="coerce").fillna(2)
penalty = penalty.where(lanes > 1, other=penalty * 1.20)
highway = edges.get("highway", pd.Series(dtype=str))
penalty = penalty.where(highway != "service", other=penalty * 1.15)
edges["freight_time_sec"] = edges["base_time_sec"] * penalty
Using float("inf") rather than dropping edges prevents fragmentation: a prohibited bridge may still be the only connection between two strongly-connected components. Inspect connectivity after filtering (see Validation section) before converting inf-weighted edges to outright removals.
3. Elevation and grade integration
Heavy vehicles experience non-linear speed loss and fuel penalties on grades exceeding 4%. Routing engines that ignore elevation consistently underestimate transit times through hilly terrain — sometimes by 30–40% on mountain corridor routes.
# requires: osmnx>=1.8, numpy>=1.24
# Assumes 'elevation' node attribute populated via raster sampling (e.g. ox.elevation.add_node_elevations_raster)
nodes = ox.graph_to_gdfs(G, nodes=True, edges=False)
u_idx = edges.index.get_level_values("u")
v_idx = edges.index.get_level_values("v")
elev_u = nodes.loc[u_idx, "elevation"].values
elev_v = nodes.loc[v_idx, "elevation"].values
# Grade percentage: positive = uphill, negative = downhill
edges["grade_pct"] = ((elev_v - elev_u) / edges["length"].values) * 100
# Vectorized non-linear grade penalty for Class 8 / articulated trucks
abs_grade = edges["grade_pct"].abs().values
grade_factor = np.where(
abs_grade <= 4, 1.0,
np.where(
abs_grade <= 8, 1.0 + (abs_grade - 4) * 0.05,
np.where(
abs_grade <= 12, 1.0 + (abs_grade - 4) * 0.12,
float("inf") # >12% — exceeds typical loaded-truck legal limit
)
)
)
edges["grade_factor"] = grade_factor
edges["freight_time_sec"] *= grade_factor
The 12% hard cutoff reflects typical regulatory limits for loaded Class 8 trucks in the US and EU. For lighter delivery vans, raise this threshold to 15–18% and reduce the penalty slope in the 8–12% band. For electric vehicles, also account for regenerative braking benefit on downhill segments — see calibrating speed profiles for electric delivery fleets for a worked implementation.
4. Toll, regulatory, and economic cost modeling
Time is rarely the sole optimization target in freight logistics. Toll avoidance, low-emission zone (LEZ) compliance, and congestion pricing directly impact operational margins. The simplest production-ready approach normalizes monetary costs into time-equivalent seconds, allowing standard single-objective solvers to optimize total operational cost without custom multi-objective algorithms.
# requires: pandas>=2.0
# toll_df: DataFrame with columns [u, v, key, toll_usd]
# hourly_operating_rate: total fleet cost per hour in USD (labour + fuel + depreciation)
HOURLY_RATE_USD = 90.0 # example: $90/hr for a Class 8 route
toll_df = pd.read_csv("toll_schedule.csv") # preprocess to match edge index
toll_map = toll_df.set_index(["u", "v", "key"])["toll_usd"]
# Map toll costs onto edges; NaN → 0 for toll-free edges
edges["toll_usd"] = edges.index.map(toll_map).fillna(0.0)
edges["toll_time_sec"] = (edges["toll_usd"] / HOURLY_RATE_USD) * 3600
# Additive combination: routing solver sees a single scalar cost per edge
edges["freight_cost_sec"] = edges["freight_time_sec"] + edges["toll_time_sec"]
At $90/hr, a $4.50 bridge toll is equivalent to 3 minutes of travel time. This normalization lets dispatchers reason about the trade-off in familiar operational terms. For LEZ compliance, apply a daily compliance fee divided by the expected number of zone entries per day, or assign float("inf") to non-compliant vehicles in restricted zones.
5. Dynamic weight profiles and vehicle-class switching
Production freight systems rarely route a single vehicle class. A utility van, a rigid 12-tonne truck, and a 44-tonne articulated trailer require fundamentally different constraint sets. Encapsulate each profile as a parameter object and generate edge weights on demand:
# requires: dataclasses (stdlib), pandas>=2.0
from dataclasses import dataclass
@dataclass
class FreightProfile:
name: str
gvwr_t: float # gross vehicle weight rating, tonnes
height_m: float # body height, metres
axle_load_t: float # per-axle load, tonnes
speed_factor: float # fraction of posted speed limit
hourly_rate_usd: float # fleet operating cost per hour
max_grade_pct: float # maximum traversable grade
PROFILES: dict[str, FreightProfile] = {
"van": FreightProfile(
"Sprinter Van", gvwr_t=3.5, height_m=2.8, axle_load_t=2.0,
speed_factor=0.90, hourly_rate_usd=45.0, max_grade_pct=18.0,
),
"rigid_hgv": FreightProfile(
"Rigid HGV", gvwr_t=18.0, height_m=3.8, axle_load_t=9.0,
speed_factor=0.82, hourly_rate_usd=72.0, max_grade_pct=12.0,
),
"artic_44t": FreightProfile(
"Articulated 44t", gvwr_t=44.0, height_m=4.1, axle_load_t=11.5,
speed_factor=0.78, hourly_rate_usd=95.0, max_grade_pct=10.0,
),
}
def apply_profile(edges_df: "pd.DataFrame", profile: FreightProfile) -> "pd.Series":
"""Return a freight_cost_sec Series for the given vehicle profile."""
# (implementation follows the steps above, parametrized on profile fields)
...
Store each profile’s computed weights as named columns (freight_cost_van, freight_cost_rigid_hgv, etc.) on the edge GeoDataFrame. Routing queries then select the appropriate column rather than recomputing weights at request time, keeping per-query latency constant regardless of fleet complexity.
Configuration reference
| Parameter | Recommended range | Notes |
|---|---|---|
FREIGHT_FACTOR |
0.70–0.90 | Lower for urban last-mile; higher for motorway trunking |
| Surface penalty — unpaved | 1.25–1.40 | Calibrate against GPS telemetry per road category |
| Surface penalty — dirt/mud | 1.50–2.00 | Seasonal variation; apply time-of-year adjustment |
| Lane-width penalty | 1.10–1.25 | Higher for articulated trailers |
| Grade penalty slope (4–8%) | 0.04–0.07 per pct | Increase for heavier axle loads |
| Grade hard cutoff | 10–15% | Lower for loaded 44t artic; higher for vans |
HOURLY_RATE_USD |
40–120 | Update quarterly; include all variable operating costs |
MAPE validation target |
< 12% | At segment level; accept < 18% for rural sparse data |
Key OSM tag mappings
| OSM tag | Values | Freight action |
|---|---|---|
hgv |
no, private, delivery, yes |
Hard filter or conditional access |
maxweight |
numeric string in tonnes | Hard filter by GVWR |
maxheight |
numeric string in metres | Hard filter by vehicle height |
maxaxleload |
numeric string in tonnes | Hard filter by axle load |
surface |
unpaved, gravel, compacted, asphalt |
Soft penalty multiplier |
toll |
yes, no |
Trigger toll lookup in pricing table |
access |
no, private, delivery |
Conditional removal or penalty |
Production optimization and scaling
Vectorization discipline. Every penalty computation in this pipeline operates on pd.Series or np.ndarray columns. Avoid edges.apply(fn, axis=1) for anything beyond the initial speed resolution — on a 1M-edge national graph, row-level apply is 50–100× slower than equivalent NumPy operations.
Graph serialization. When exporting to routing engines, strip unused OSM metadata. Retain only u, v, key, length, and your computed weight columns. Use Apache Parquet (edges.to_parquet()) for intermediate persistence — it preserves dtypes and is 3–5× faster to load than CSV or GeoJSON at scale.
Delta-update pipeline. Toll schedules and construction closures change daily. Build a delta-update step that recalculates affected edge weights without rebuilding the entire topology:
# requires: pandas>=2.0
def update_toll_weights(edges_df: "pd.DataFrame", new_tolls: "pd.DataFrame",
hourly_rate: float) -> "pd.DataFrame":
"""Recalculate toll_time_sec only for edges in new_tolls."""
idx = pd.MultiIndex.from_frame(new_tolls[["u", "v", "key"]])
edges_df.loc[idx, "toll_usd"] = new_tolls.set_index(["u", "v", "key"])["toll_usd"]
edges_df.loc[idx, "toll_time_sec"] = (
edges_df.loc[idx, "toll_usd"] / hourly_rate * 3600
)
edges_df["freight_cost_sec"] = (
edges_df["freight_time_sec"] + edges_df["toll_time_sec"]
)
return edges_df
Routing engine backends. For large-scale regional routing (>5M edges), consider offloading to OSRM with Docker or Valhalla costing configuration, both of which support multi-profile weight tables natively and use contraction hierarchies to reduce query latency to single-digit milliseconds.
Validation and testing
Run these checks after computing edge weights and before wiring the graph into a routing solver:
1. Connectivity check. Hard filters must not create stranded components in your service area.
# requires: networkx>=3.2
import networkx as nx
# Build a subgraph that excludes inf-weight edges
finite_edges = [(u, v, d) for u, v, d in G.edges(data=True)
if d.get("freight_cost_sec", float("inf")) < float("inf")]
G_passable = nx.DiGraph()
G_passable.add_edges_from(finite_edges)
scc = list(nx.strongly_connected_components(G_passable))
print(f"Strongly connected components: {len(scc)}")
# Expect 1 for a well-connected urban network; investigate any with > 3 components
2. Weight distribution sanity check. Edge costs should follow a roughly log-normal distribution; spikes indicate tagging anomalies.
# requires: pandas>=2.0, numpy>=1.24
finite_costs = edges["freight_cost_sec"][edges["freight_cost_sec"] < float("inf")]
p95 = finite_costs.quantile(0.95)
p99 = finite_costs.quantile(0.99)
print(f"P95 edge cost: {p95:.1f}s P99: {p99:.1f}s")
# P99/P95 ratio > 5 usually indicates DEM or speed tag anomalies
3. GPS trace validation. Align historical GPS telemetry to your graph using map-matching and compute MAPE at segment level. Target < 12% on motorways and primary roads; accept up to 18% on rural tracks where GPS signal is sparse.
4. Node attribute synchronization. Verify that final-mile costs reflect unloading constraints. Mapping node attributes for urban delivery zones explains how loading bay availability and curb access rules must be synchronized with edge costs — a route that terminates at a node with no legal stopping zone is operationally useless regardless of its edge-weight optimality.
5. Regression test suite. Maintain a set of known origin–destination pairs with expected routes (verified by dispatchers). After any weight update, rerun routes on these pairs and alert if the solver selects a different path — this catches unintended side-effects of toll schedule updates or OSM data refreshes.
Troubleshooting
Routes use weight-restricted roads despite maxweight filters
The maxweight filter is likely applied after routing rather than during graph preparation. OSM maxweight values are stored as strings ("12", "12 t", "12000 kg") and pd.to_numeric silently converts unparseable strings to NaN, which then passes through a >= threshold check. Audit with edges["maxweight"].value_counts() to find non-standard formats, then extend parse_numeric() to handle unit suffixes before filtering.
Missing maxspeed tags bias routes toward slow roads
When maxspeed is absent, a conservative fallback (e.g. 40 km/h) makes high-speed roads with missing tags look cheaper than lower-class roads with correct tags. Implement the four-level fallback hierarchy described in Step 1, and cross-reference the OSM source:maxspeed tag — values like DE:rural map to statutory country defaults and should be resolved rather than ignored.
Grade penalties make necessary mountain passes impassable
A float("inf") cutoff at 12% is correct for loaded 44t articulated trucks but too aggressive for lighter vehicles. Parametrize max_grade_pct per profile and switch the grade function to return a high-but-finite multiplier (e.g. 4.0×) rather than inf for grades just above the cutoff. This allows the solver to use a steep edge as a last resort while still strongly discouraging it.
Hard filters disconnect the graph in sparse rural areas
Run nx.strongly_connected_components() after applying dimensional filters. For bridges or tunnels that are the sole connection between two areas, demote the hard filter to a steep soft penalty (× 10.0) so the solver can still find a path in constrained networks. Flag these edges in a separate column (is_constrained_crossing=True) so dispatchers receive a warning when the route uses them.
Toll normalization produces counterintuitive toll avoidance
If the solver avoids tolls by taking a 45-minute detour to avoid a $2 toll, the HOURLY_RATE_USD is set too low relative to actual fleet costs. Recalculate using total variable cost: driver wage + fuel at current price + vehicle depreciation + tyre wear. At correct rates, the time-equivalent of most urban tolls falls below 5 minutes, making avoidance economically irrational on typical routes.
Related
- Building directed graphs from OSM PBF files — topology construction that must precede weight assignment
- Mapping node attributes for urban delivery zones — synchronizing terminal-node costs with edge weight models
- Speed profile calibration for heavy vehicles — per-road-class speed calibration that feeds the freight speed factor
- Handling turn restrictions in routing graphs — restriction modeling that compounds with edge weight configuration
- Custom cost functions for routing solvers — integrating multi-dimensional freight weights into OSRM, Valhalla, and NetworkX solvers
- OSM Graph Architecture & Network Modeling — parent topic covering the full routing graph pipeline