Configuring Valhalla trace_attributes for Fleet Routing

Once Valhalla Meili map matching is returning a clean matched path, the value for fleet analytics lives in the per-edge attributes that /trace_attributes hands back: the posted speed limit on each segment a vehicle drove, the road class (was the truck on a residential street it should not have been?), the surface (paved versus unpaved, which drives wear models), and the turn geometry between edges. This page is the focused, copy-paste implementation for extracting those attributes reliably — a single self-contained class that builds a tuned request, posts it, and flattens the response into a per-edge table. It extends the parent workflow rather than repeating it; if you have not yet built tiles or made a first successful match, start there.

Extracting per-edge attributes from Valhalla trace_attributes A cleaned ping trace and a tuned costing block feed a trace_attributes request; the response edges array is flattened into a per-edge table of speed limit, road class, surface, and turn degree. Cleaned trace shape + accuracy costing + turn_penalty_factor POST /trace_attributes shape_match = map_snap Per-edge table speed_limit · road_class surface · turn_degree → speed compliance · wear models

Compatibility and Configuration Requirements

Requirement Value / version Notes
Valhalla 3.1+ filters.attributes include/exclude stable since 3.0; edge.speed_limit requires tiles built with speed data
Python 3.10 Type hints and dict merge syntax used below
requests 2.28+ HTTP client for the POST call
Endpoint /trace_attributes Returns per-edge attributes; use /trace_route only for maneuver-level turn text
shape_match map_snap Forces a full HMM match; edge_walk assumes the shape is already on the network
Costing model auto, truck, bus, bicycle, pedestrian Truck restrictions live under costing_options.truck; must match vehicle class
Coordinate format [longitude, latitude] lon/lat keys in the shape; any encoded polyline must be precision 6

Self-Contained trace_attributes Client

The class below builds a tuned request, posts it, and flattens the edges array into a list of per-edge dictionaries. Every parameter choice is commented inline.

import math
import requests
import pandas as pd
from typing import Optional


class TraceAttributeExtractor:
    """
    Post a tuned Valhalla /trace_attributes request and flatten the matched
    edges into a per-edge analytics table.

    Parameters
    ----------
    base_url : str
        Root URL of the Valhalla service, e.g. 'http://localhost:8002'.
    costing : str
        Costing model: 'auto', 'truck', 'bus', 'bicycle', 'pedestrian'.
        Must match the vehicle class or restrictions are meaningless.
    gps_accuracy : float
        Request-level receiver sigma in metres. Scales the HMM emission term.
        5-8 for clean automotive GPS; 12-20 in dense urban canyons.
        A per-point 'accuracy' on a shape entry overrides this value.
    search_radius : float
        Candidate-edge search cutoff in metres around each point. Must be
        wide enough to contain the true edge given real error: keep it at
        roughly 4-5x gps_accuracy. Too small -> no candidates -> empty match.
    interpolation_distance : float
        Metres below which nearby points are collapsed / interpolated.
        Raise it (to ~30) for sparse traces; lower it for dense 1 Hz traces.
    turn_penalty_factor : float
        Multiplier on turn cost inside the costing model. Higher values make
        the matcher prefer straighter, higher-class roads and suppress
        spurious hops onto side streets. 0 = default costing; 50-500 tightens.
    """

    def __init__(
        self,
        base_url: str,
        costing: str = "auto",
        gps_accuracy: float = 8.0,
        search_radius: float = 40.0,
        interpolation_distance: float = 10.0,
        turn_penalty_factor: float = 0.0,
        truck_options: Optional[dict] = None,
        timeout: float = 30.0,
    ):
        self.base_url = base_url.rstrip("/")
        self.costing = costing
        self.gps_accuracy = gps_accuracy
        self.search_radius = search_radius
        self.interpolation_distance = interpolation_distance
        self.turn_penalty_factor = turn_penalty_factor
        self.truck_options = truck_options or {}
        self.timeout = timeout

    def _costing_options(self) -> dict:
        """Assemble the costing_options block for the selected model."""
        opts = {"turn_penalty_factor": self.turn_penalty_factor}
        if self.costing == "truck":
            # Sensible articulated-truck defaults; override via truck_options
            opts.update({
                "height": 4.11,
                "width": 2.6,
                "length": 21.5,
                "weight": 40.0,
                "axle_load": 9.0,
                "hazmat": False,
            })
            opts.update(self.truck_options)
        return {self.costing: opts}

    def _build_shape(self, df: pd.DataFrame) -> list[dict]:
        """Convert a cleaned, time-sorted ping DataFrame to a shape array."""
        shape = []
        for row in df.itertuples(index=False):
            point = {"lon": float(row.lon), "lat": float(row.lat)}
            if hasattr(row, "epoch_s"):
                point["time"] = int(row.epoch_s)          # sharpens transitions
            acc = getattr(row, "horiz_acc_m", None)
            if acc is not None and acc == acc:             # not NaN
                point["accuracy"] = max(float(acc), 4.0)   # floor at 4 m
            shape.append(point)
        return shape

    def match(self, df: pd.DataFrame) -> dict:
        """POST the trace and return the raw Valhalla JSON response."""
        payload = {
            "shape": self._build_shape(df),
            "costing": self.costing,
            "shape_match": "map_snap",
            "costing_options": self._costing_options(),
            "trace_options": {
                "gps_accuracy": self.gps_accuracy,
                "search_radius": self.search_radius,
                "interpolation_distance": self.interpolation_distance,
            },
            "filters": {
                "attributes": [
                    "edge.way_id", "edge.names", "edge.road_class",
                    "edge.speed_limit", "edge.speed", "edge.surface",
                    "edge.length", "edge.begin_heading", "edge.end_heading",
                    "matched.point", "matched.type", "matched.edge_index",
                    "confidence_score",
                ],
                "action": "include",     # return ONLY the listed attributes
            },
        }
        resp = requests.post(
            f"{self.base_url}/trace_attributes", json=payload, timeout=self.timeout
        )
        resp.raise_for_status()
        return resp.json()

    @staticmethod
    def _turn_degree(prev_end: Optional[float], cur_begin: Optional[float]) -> Optional[float]:
        """Signed turn angle in degrees between two edges at their shared node."""
        if prev_end is None or cur_begin is None:
            return None
        delta = (cur_begin - prev_end + 180.0) % 360.0 - 180.0   # normalise to [-180, 180)
        return round(delta, 1)

    def to_edge_table(self, result: dict) -> pd.DataFrame:
        """Flatten the matched edges into one row per edge with turn geometry."""
        edges = result.get("edges", [])
        rows = []
        prev_end_heading = None
        for i, edge in enumerate(edges):
            begin_h = edge.get("begin_heading")
            rows.append({
                "edge_index": i,
                "way_id": edge.get("way_id"),
                "name": (edge.get("names") or [None])[0],
                "road_class": edge.get("road_class"),
                # Posted limit where OSM tags it; fall back to estimated speed
                "speed_limit_kmh": edge.get("speed_limit") or edge.get("speed"),
                "speed_source": "posted" if edge.get("speed_limit") else "estimated",
                "surface": edge.get("surface"),
                "length_km": edge.get("length"),
                "turn_degree": self._turn_degree(prev_end_heading, begin_h),
            })
            prev_end_heading = edge.get("end_heading")
        df = pd.DataFrame(rows)
        df.attrs["confidence_score"] = result.get("confidence_score")
        return df


# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
# extractor = TraceAttributeExtractor(
#     base_url="http://localhost:8002",
#     costing="truck",
#     gps_accuracy=10.0,
#     search_radius=50.0,
#     turn_penalty_factor=100.0,
#     truck_options={"height": 4.0, "weight": 26.0},
# )
# result = extractor.match(cleaned_pings_df)   # df cols: lon, lat, epoch_s, horiz_acc_m
# edge_table = extractor.to_edge_table(result)
# print(edge_table.head(), edge_table.attrs["confidence_score"])

Execution and Tuning Guidelines

Run match() on a single trip’s worth of cleaned, time-sorted pings, then to_edge_table() to get the analytics table. The four tuning knobs interact, so change one at a time and inspect the confidence_score and the edge table together:

  • gps_accuracy — the emission sigma. Raise it (toward 15–20 m) when good matches are being penalised with low confidence in urban canyons; lower it (toward 5 m) with clean open-sky data to make the matcher discriminate more sharply between nearby parallel roads. Setting it far below the true error makes every emission improbable and tanks confidence_score even on a correct path.
  • search_radius — the candidate cutoff. Raise it when the response is empty or points snap to the wrong side of a divided highway; lower it if matching is slow because too many candidates are considered per point. Keep it at roughly 4–5 × gps_accuracy so the true edge cannot fall outside the window.
  • interpolation_distanceraise it (to ~30 m) for sparse traces so intermediate shape points are synthesised along candidate routes, filling gaps; lower it for dense 1 Hz data where over-interpolation just adds noise.
  • turn_penalty_factorraise it (100–500) to suppress spurious hops onto side streets and keep the match on straighter, higher-class roads; lower it toward 0 if the matcher is now cutting across genuine sharp turns at junctions. This flows into the HMM transition term, so it changes which edges win, not just the reported cost.

The speed_source column matters for speed profiling from raw GPS coordinates: a posted limit is authoritative for compliance checks, whereas an estimated value is Valhalla’s modelled free-flow speed and should not be reported as a legal limit. The turn_degree column feeds harsh-cornering and route-shape analytics without a second geometry pass.

Common Pitfalls

speed_limit is null on most edges

Valhalla returns a posted speed_limit only where OpenStreetMap tags maxspeed on the way, which in many regions covers a minority of roads. A null value means the tag is absent, not that matching failed. The class above falls back to edge.speed (the engine’s estimated travel speed) and records which source was used in speed_source. Never present an estimated speed as a legal limit in compliance reporting.

Turn angles look wrong at the first edge or across gaps

turn_degree is undefined for the first matched edge (there is no preceding edge) and is meaningless across a discontinuity where the match was interpolated over a long gap. The _turn_degree helper returns None when either heading is missing, but it cannot know that two consecutive edges are actually separated by a dropped section. Cross-check matched.type from matched_points: where a run of points is interpolated, treat the turn geometry around it as unreliable.

Response missing attributes you requested

With filters.action set to include, Valhalla returns only the listed attributes — a typo in an attribute name (for example edge.roadclass instead of edge.road_class) silently yields a missing column rather than an error. If an expected field is absent from the edge table, first check the exact attribute key against the Valhalla documentation, then confirm the tiles were built with the data that backs it (speed limits in particular require speed data at tile-build time).


Up: Valhalla Meili Map Matching for Telematics | Routing Engine Integration for Fleet Telematics