Add Speed and Travel Cost to a Network in PyQGIS
Distance routing needs nothing from you: QGIS measures the geometry. Time routing needs a speed for every edge, and everything that goes wrong with it goes wrong quietly — a unit multiplier out by a factor of 3.6 produces travel times that are wrong by that factor while every route still looks perfectly sensible on the map.
This recipe belongs to Network Analysis & Routing in PyQGIS. It covers the speed strategy's three arguments, filling in speeds a dataset does not carry, running several cost criteria off one graph, and subclassing the strategy when speed alone is not the model you need.
Prerequisites
- QGIS 3.34 LTR or newer, network in a projected CRS measured in metres.
- A line layer with a speed attribute, or a road-class attribute you can map to speeds.
- The graph basics from building a network graph.
The speed strategy
from qgis.analysis import QgsVectorLayerDirector, QgsNetworkSpeedStrategy
speed_index = roads.fields().indexOf("speed_kph")
director = QgsVectorLayerDirector(
roads, -1, "", "", "", QgsVectorLayerDirector.DirectionBoth
)
director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 / 3600.0))
Breakdown: The three arguments are the attribute index, the default speed used wherever the attribute is null or unusable, and the multiplier that converts the attribute's units into layer-units-per-second. The default speed is expressed in the attribute's units, not in layer units — so 48 here means 48 km/h, and the multiplier is applied to it too. Getting the index from fields().indexOf() rather than hard-coding it is not fussiness: a field added or removed upstream silently shifts every index after it, and a wrong index either raises or, worse, reads a completely different numeric column as a speed.
Verifying the arithmetic once takes ten seconds and is worth doing on every new dataset:
edge = graph.edge(0)
length = graph.vertex(edge.fromVertex()).point().distance(
graph.vertex(edge.toVertex()).point()
)
print(f"{length:.0f} m in {edge.cost(0):.1f} s "
f"= {3.6 * length / edge.cost(0):.0f} km/h")
Breakdown: If that prints something close to a plausible road speed, the multiplier is right. If it prints 12 km/h or 340 km/h, it is out by 3.6 in one direction or the other. Doing this check on edge zero of every new network is cheap insurance against a whole analysis being wrong by a constant — and a constant error is the hardest kind to notice, because every route is still ranked correctly relative to every other route.
Filling in missing speeds
Most road datasets carry a class rather than a speed, and the mapping from one to the other is a modelling decision you should write down.
from qgis.core import QgsField, QgsExpression, QgsExpressionContext
from qgis.core import QgsExpressionContextUtils
from qgis.PyQt.QtCore import QVariant
SPEEDS = {
"motorway": 112, "trunk": 96, "primary": 80,
"secondary": 64, "tertiary": 48, "residential": 32, "track": 16,
}
roads.startEditing()
if roads.fields().indexOf("speed_kph") == -1:
roads.addAttribute(QgsField("speed_kph", QVariant.Int))
roads.updateFields()
index = roads.fields().indexOf("speed_kph")
for feature in roads.getFeatures():
roads.changeAttributeValue(
feature.id(), index, SPEEDS.get(feature["highway"], 40)
)
roads.commitChanges()
Breakdown: Writing the speeds into a real field rather than computing them inside a strategy makes the assumption inspectable — anyone can open the attribute table and see what the model believes. The .get() fallback of 40 catches classes not in the table, which on an OpenStreetMap extract will be several; printing the set of unmatched classes once before committing is worth doing, because a common class landing on the fallback distorts a whole region. Wrapping this in one edit session, rather than one per feature, is what keeps it fast — the mechanics of that are covered in editing features with transactions.
Several criteria from one graph
Strategies are additive, and the analyzer picks between them by index.
from qgis.analysis import QgsNetworkDistanceStrategy
director.addStrategy(QgsNetworkDistanceStrategy()) # 0
director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 / 3600.0)) # 1
director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 24.0, 1000.0 / 7200.0)) # 2
tree_short, cost_short = QgsGraphAnalyzer.dijkstra(graph, origin, 0)
tree_fast, cost_fast = QgsGraphAnalyzer.dijkstra(graph, origin, 1)
tree_peak, cost_peak = QgsGraphAnalyzer.dijkstra(graph, origin, 2)
Breakdown: The criterion index is the position in the order strategies were added, starting at zero, and nothing checks that you passed the index you meant — an out-of-range index is undefined rather than an error on some builds. Criterion 2 here models congestion crudely by halving the multiplier, which is equivalent to halving every speed; a more careful model would use a separate peak-speed field. The point is that all three share one graph build, so comparing a free-flow and a peak catchment costs one build and three cheap runs.
Writing your own strategy
Where cost is not length over speed — a gradient penalty for cycling, a surcharge for unlit paths at night, a toll — subclass QgsNetworkStrategy.
from qgis.analysis import QgsNetworkStrategy
class GradientStrategy(QgsNetworkStrategy):
def __init__(self, speed_index, gradient_index):
super().__init__()
self.speed_index = speed_index
self.gradient_index = gradient_index
def cost(self, distance, feature):
speed = feature[self.speed_index] or 15
gradient = feature[self.gradient_index] or 0
penalty = 1.0 + max(0.0, gradient) * 0.12
return distance / (speed * 1000.0 / 3600.0) * penalty
def requiredAttributes(self):
return [self.speed_index, self.gradient_index]
director.addStrategy(GradientStrategy(speed_index, gradient_index))
Breakdown: cost() receives the edge's length in layer units and the feature it came from, and returns whatever number should be minimised. requiredAttributes() is the method people forget: it tells the director which fields to fetch, and omitting it means the feature arrives without those attributes and every lookup returns null. Because the penalty here applies only to positive gradients, the strategy is asymmetric — uphill costs more than downhill — which is correct for cycling and is the reason the direction handling matters. Keep the method cheap; it is called once per edge, twice for two-way segments, and a slow implementation dominates the build time on a large network.
What a speed model leaves out
It is worth being explicit about the gap between a QGIS travel time and a satellite navigation travel time, because stakeholders will compare them.
Junction delay is absent. A route with forty sets of traffic lights and a route with none, of the same length and the same class, cost the same here. On urban work this is the largest single source of underestimation, and the usual mitigation is to deflate residential and secondary speeds well below the legal limit — which is why the table above sets residential at 32 km/h rather than 48.
Turn restrictions and turn penalties are absent, so a route may include a right turn across a dual carriageway that no driver would make. Traffic is absent, so a peak-hour journey costs the same as a Sunday morning one unless you model it with a second strategy. Acceleration is absent, so a hundred short edges at 112 km/h cost the same as one long one.
None of that makes the output useless — a network travel time is dramatically better than a buffer, and the ranking between alternatives is usually right even when the absolute numbers are not. What it means is that the numbers should be presented as modelled travel times with the assumptions attached, and that anyone comparing them against a routing service's figures should expect the QGIS numbers to be optimistic in towns and close to correct on open road.
QGIS version compatibility
QgsNetworkSpeedStrategy and QgsNetworkDistanceStrategy have been unchanged since QGIS 3.0. Subclassing QgsNetworkStrategy from Python has worked throughout, though requiredAttributes() returning attribute indices rather than names is a detail that has caught out code ported from older examples. In the Processing algorithms the equivalent controls are SPEED_FIELD and DEFAULT_SPEED, with the units fixed at km/h and the layer assumed to be in metres.
Troubleshooting
- Every journey time is 3.6× too long or too short. The multiplier is 1 on a km/h field, or 1000/3600 on a m/s field.
- Times are plausible but routes never use the motorway. The speed field is null there and the default speed applies, so the motorway is modelled as a residential street.
- A custom strategy sees null attributes.
requiredAttributes()was not implemented, or returns names instead of indices. - Fastest and shortest give identical answers. Only one strategy was added, so both criterion indices read the same costs.
- Times are wrong only on some roads. A class in the data is missing from the speed lookup and landing on the fallback.
- Everything slowed down after adding a strategy. A Python
cost()runs per edge; it is the build's inner loop.
Conclusion
Set the multiplier from the field's units and verify it against one edge before believing anything. Write assumed speeds into a field so the model is visible. Add several strategies to one graph rather than rebuilding per scenario, and subclass when the cost model is genuinely not length over speed. Time-based routing is not harder than distance-based routing; it just has one number in it that nothing will check for you.
Frequently Asked Questions
Can I model time-of-day variation? Only by adding a strategy per time band and switching criterion index, or by rebuilding with a different speed field. There is no temporal dimension in the graph.
Does the speed strategy handle a zero or negative speed? A zero speed divides by zero. Clean the field first, or use a custom strategy with a floor — the default speed applies only to nulls, not to zeros.
How do I add a fixed penalty per junction?
Not directly, because cost is attached to edges rather than to vertices. The usual approximation is a small additive term inside a custom strategy's cost(), which charges per edge rather than per turn and is close enough on a dense network.
Is the default speed in layer units or field units? Field units. It is multiplied by the same multiplier as the attribute, which is the sensible behaviour and the opposite of what most people assume on first reading.