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.

The multiplier is the whole of the unit arithmeticThe speed strategy divides an edge's length in layer units by a speed, so the speed must be expressed in layer units per second. The multiplier converts from whatever the attribute holds. For a layer in metres, kilometres per hour needs a multiplier of one thousand over three thousand six hundred, miles per hour needs about zero point four four seven, and metres per second needs one.cost = edge length ÷ (speed × multiplier)speed_kph = 96what the attribute holds× 1000 / 3600km/h into metres per second26.7 m/swhat the strategy needsmultipliers for a layer measured in metreskm/h → 1000 / 3600 ≈ 0.2778mph → 1609.34 / 3600 ≈ 0.4470m/s → 1.0a multiplier of 1 on a km/h field makes every journey 3.6× too fastcheck one edge by hand, oncea layer in feet or in degrees changes all of these — work in metres

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.

Class to speed is an assumption, so make it visibleEach road class in the dataset is assigned an assumed speed: motorways at one hundred and twelve, primary roads at eighty, secondary at sixty four, residential at thirty two and tracks at sixteen kilometres per hour. Writing these into a field rather than hiding them in code means the assumption travels with the data.Someone will ask what speed you assumedclassassumed km/hwhymotorway112free flow, no junctionsprimary80limit minus junction delaysecondary64mixed frontageresidential32parked cars, give waytrack16surface, not signagethese are assumed speeds, not limits — a 30 mph street is not driven at 30 mph end to endpublish the table alongside any catchment built from it

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.