The question
Could the bus telemetry already collected for arrivals also show where road traffic had slowed down? It was a reasonable shortcut. The system knows where the bus was, which route segment it was crossing, and how long the movement took. A slow bus looks like a useful traffic signal until the reason for the delay matters.
A bus that waits for passengers produces a long stop-to-stop interval. That interval contains dwell, not just road travel. If it becomes road evidence, the system converts a transit event into a claim about every vehicle on the road.
The separation
The experiment ended with two branches from the same route-position record:
The transit branch may use low speed near a stop. That is the condition that can start a stop-visit candidate. The road branch rejects the same situation. It only evaluates an interval when both positions are in the same route segment, both are more than 100 meters from their nearest stops, the interval is no more than 60 seconds, route progress is forward, and the route-derived speed is between 7 and 100 km/h.
The relevant predicate is short enough to show directly:
if (previous.segmentIndex !== current.segmentIndex) return false;if ( previous.nearestStopDistanceM <= STOP_EXCLUSION_METERS || current.nearestStopDistanceM <= STOP_EXCLUSION_METERS) { return false;}const routeSpeedKph = (routeDistanceMeters / elapsedSeconds) * 3.6;if (routeSpeedKph < MIN_MOVING_SPEED_KPH || routeSpeedKph > MAX_MOVING_SPEED_KPH) { return false;}This is from eligibleMovingInterval. It does not prove that a road is clear. It only prevents a stop boundary, a route transition, or a physically implausible interval from entering the road matcher.
A concrete boundary case
The road-processing test supplies four points to a successful Valhalla fixture. Their distances from the nearest stop are [200, 50, 200, 200] meters. The matcher accepts the trace, but the intervals on either side of the 50-meter point touch the 100-meter exclusion zone. The processor stores one road sample and counts two skipped intervals.
That result is the useful part of the experiment. A good trace match and a complete set of road samples are not the same outcome. The match run preserves what Valhalla accepted. The interval filter decides which pieces are safe enough to use for pace. See the excludes stop-adjacent intervals from road-speed evidence test in road-intelligence.test.ts.
Matching is another evidence check
The processor sends a continuous trace of four to twelve points to Valhalla's /trace_attributes endpoint with costing: "bus" and shape_match: "map_snap". A match is accepted only when every returned point has a matched or interpolated type, an edge position, no route discontinuity, and a distance from the input trace no greater than 50 meters. The response also carries road identifiers such as the OSM way and node IDs.
The parser test uses a response for J.P. Laurel Avenue with OSM way and node identifiers and changeset 1786991053. That fixture proves the response mapping. It is not a live traffic observation, and the documentation should not turn it into one.
Some traces should not be retried. A Valhalla response with a route discontinuity is rejected, the cursor advances past the prefix, and no road samples are written. A transient HTTP 503 is different: the cursor stays where it is, no samples are written, and the processor schedules a retry after 60 seconds. The road processor tests preserve both cases.
Corroboration happens after matching
One bus can be slow for reasons that have nothing to do with corridor traffic. The road analytics therefore takes the median pace for each vehicle before comparing vehicles. The current output needs three distinct vehicles in the last 30 minutes and a current p50 pace at least 1.3 times the historical p50. Five recent vehicles and at least 60 historical samples raise the road result to high confidence; a qualifying result below those counts is moderate.
The test data makes the unit of evidence clear. Thirty historical samples at 30 km/h and three current buses at 15 km/h produce a slowdown ratio of 2 and a moderate result. Twenty slow samples from one bus produce no slowdown. The second case would have looked persuasive if the code had counted rows instead of vehicles.
const vehiclePaces = medianPacePerVehicle(recent);if (vehiclePaces.length < LIVE_MIN_VEHICLES) return null;const current = summarizeDistribution(vehiclePaces);if (!current) return null;const baseline = selectRoadBaseline(historical, now);if (!baseline) return null;const slowdownRatio = current.p50 / baseline.p50;if (!Number.isFinite(slowdownRatio) || slowdownRatio < SLOWDOWN_RATIO) return null;The production function also checks for a usable baseline and finite values. The important detail is the first line: repeated observations from one bus do not become independent traffic witnesses. See road-analytics.ts and road-analytics.test.ts.
What was kept and what was rejected
The tempting solution was to reuse stop-to-stop transit durations as road speeds. It was rejected because the duration includes dwell, and because the bus is not a neutral road probe while it is boarding. The retained design still uses the bus as a source of road evidence, but only after it has removed stop-adjacent intervals, kept a continuous route segment, passed a road match, and been corroborated with other vehicles.
The separation also keeps failure scope small. If VALHALLA_URL is not configured, the road processor remains disabled. If Valhalla fails transiently, raw collection and transit intelligence continue while the road cursor waits for retry. If a match is structurally bad, it is recorded as a rejected run in the processing summary rather than being allowed to produce plausible-looking samples.
What this does not establish
The experiment does not establish that bus-derived samples describe all road traffic, or that every reported slowdown is caused by a general traffic queue. It establishes a narrower contract: the road branch may report a route-relevant segment slowdown only when the persisted samples pass the movement, matching, baseline, and distinct-vehicle checks.
The split grew after the first raw-observation collector. Transit-event derivation arrived in 521b6db; road intelligence followed in adefc6c, with bounded processing added in 3bbd574. The current filters and failure behavior live in the source linked above.