valhalla / valhalla/valhalla

Transit isochrones fail to include reachable destinations due to tile-level exclusion bug

Open
#5,636 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
6.2k
Forks
981
Avg merge
2d 14h
Merged PRs (30d)
14

Description

# Transit isochrones fail to include reachable destinations due to tile-level exclusion bug

## Summary

Multimodal transit isochrones fail to include destinations that are demonstrably reachable via the route API. For example, Brighton is reachable from London Victoria in 93 minutes according to `/route`, but does not appear in a 120-minute `/isochrone` request. This affects any multi-leg transit journey requiring transfers
between stations in the same 0.25° tile.

## Steps to Reproduce

1. Start Valhalla with UK OSM data + UK transit GTFS feeds (TFL + National Rail)
2. Test the route API:
```bash
curl http://localhost:8002/route -d '{
"locations":[
{"lat":51.4952,"lon":-0.1441},
{"lat":50.8225,"lon":-0.1372}
],
"costing":"multimodal",
"date_time":{"type":1,"value":"2025-10-16T09:00"}
}'
```
Result: Route found in 93 minutes (5579 seconds) ✓

3. Test the isochrone API:
```bash
curl http://localhost:8002/isochrone -d '{
"locations":[{"lat":51.4952,"lon":-0.1441}],
"costing":"multimodal",
"contours":[{"time":120}],
"polygons":true,
"date_time":{"type":1,"value":"2025-10-16T09:00"}
}'
```
Result: Brighton (50.8225°, -0.1372°) is NOT included in the 120-minute isochrone ✗

Expected Behaviour

If a destination is reachable in 93 minutes via the route API, it should appear within a 120-minute isochrone from the same origin.

Actual Behaviour

The isochrone polygon stops at approximately 51.09° latitude (just south of Three Bridges station), never reaching Brighton at 50.82° latitude despite having 27 minutes of spare time.


Image

Root Cause Analysis

I've traced this to two bugs in the codebase:

Bug 1: Transit edges skipped in isochrone grid marking

Location: src/thor/isochrone.cc lines 229-241

// Transit lines and ferries can't really be "reached" you really just
// pass through those cells.
if (edge->IsTransitLine() || edge->use() == Use::kFerry) {
return; // EARLY RETURN - transit edges never mark the grid!
}

Transit edges are completely skipped when marking the isochrone grid. This causes transit segments to never appear in the output polygon generation.

Impact: Partial - removing this early return allows transit lines to mark grid cells, but doesn't solve the full problem.

Bug 2: Tile-level exclusion prevents transfers (CRITICAL)

Location: src/thor/dijkstras.cc lines 443-454

if (nodeinfo->type() == NodeType::kMultiUseTransitPlatform ||
nodeinfo->type() == NodeType::kTransitStation) {

if (processed_tiles_.find(tile->id().tileid()) == processed_tiles_.end()) {
tc->AddToExcludeList(tile);
processed_tiles_.emplace(tile->id().tileid());
}

// check if excluded.
if (tc->IsExcluded(tile, nodeinfo)) {
return; // BLOCKS RE-ENTRY TO TILES CONTAINING TRANSIT STATIONS
}
}

The multimodal expansion algorithm marks entire 0.25° tiles as "processed" after visiting the first transit station. This prevents the algorithm from re-entering the tile to visit other stations, completely breaking transfer scenarios.

Example:
- Three Bridges station: 51.11692°, -0.13424° → Tile (204, 0)
- Haywards Heath station: 51.00542°, -0.10758° → Same Tile (204, 0)
- Brighton station: 50.8225°, -0.1372° → Tile (203, 0)

After reaching Three Bridges, the algorithm marks tile (204, 0) as processed and refuses to expand to Haywards Heath. This prevents the onward journey to Brighton.

Why it can't be easily fixed: Removing the tile-level exclusion causes exponential search space growth that crashes the service. The algorithm needs this protection to prevent infinite loops, but it's too coarse-grained (tile-level instead of node-level with visit counting).

Why Route API Works But Isochrone Doesn't

The route API uses a different expansion strategy that doesn't have these tile-level restrictions - it can explore multiple paths through the same tile because it's searching for a single optimal path, not generating a full reachable polygon.

Related Issues

- #892 - Transit isochrones not working (open since 2018)
- This appears to be the same underlying architectural problem

Environment

- Valhalla version: 3.5.1 (latest from ghcr.io/valhalla/valhalla-scripted:latest)
- Dataset: Great Britain OSM + TFL GTFS + UK National Rail GTFS
- Test date: October 16, 2025
- Docker deployment on macOS

Proposed Solution

The tile-level exclusion logic needs to be replaced with proper node-level tracking that:
1. Allows visiting different stations in the same tile
2. Limits visits per node (e.g., max 3 visits) to prevent infinite loops
3. Relies on edge status tracking for loop prevention rather than tile-level blocking

However, my attempts to implement this caused service crashes due to search space explosion, suggesting a deeper architectural redesign may be needed.

Impact

This bug makes Valhalla's transit isochrone functionality unusable for any real-world transit network where transfers are required. It only works for direct single-leg journeys, which severely limits its practical application for public transit analysis.

Please please please tell me that there's actually a way around this. It took me a while to get a working build and I thought I had cracked it.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.