Snap transit stops per access/egress mode (DMP-16462) - #3
Draft
mark-idleman wants to merge 1 commit into
Draft
Conversation
Stops were attached to the street network at a single point found with a filter that every configured routing profile had to accept at once. Nothing near an airport platform satisfies that -- footways ban cars, terminal roadways ban pedestrians -- so the attachment landed on whatever distant service road allowed everything, or was dropped entirely with no error. At SLC that put the TRAX platform's walk access at 2,547 m instead of 8 m, past the 24-minute access cap, so the airport recorded zero boardings for 2025_Q4. Each stop now gets one attachment per mode instead of one attachment for all modes. A query looks up the attachment belonging to its own access/egress profile, so a walking leg uses the platform footway while a car leg can use the kerb. - gtfs.stop_snap_profiles (comma-separated, first entry primary) selects which profiles are snapped. Defaults to "foot" alone. - The primary profile alone decides stop node identity, so the transit graph does not depend on which modes happen to be configured. This preserves the existing merge of co-located stops onto a shared stop node, now keyed on walking geometry. - A request naming an unsnapped profile falls back to the primary attachments with a warning rather than failing. - An unknown profile name is rejected before the store is created; previously it would NPE inside createWeighting and be reported as an invalid GTFS feed. Changes the graph store format: per-profile pt_to_street_<profile> / street_to_pt_<profile> plus a stop_snap_profiles manifest. Loading a store written before this change fails with an explicit message, so a router image must not be rolled ahead of a graph rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BNewborn
reviewed
Aug 19, 2026
| LOGGER.warn("Feed {}: {} stops could not be attached to the street network for any of the profiles" | ||
| + " {}, so they are reachable only by stop id.", id, unattachedStops, snapFiltersByProfile.keySet()); | ||
| } | ||
| if (sharedAttachments > 0) { |
There was a problem hiding this comment.
is this true? Does a stop/profile sharing a node mean one of the stop/profile points becomes unroutable? Claude suggests this is only for car / access, but we dont use that ATM. Maybe its worth just removing this warning if I'm understanding this all correctly?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
DMP-16462. Stops were attached to the street network at a single point, found with a filter that every configured routing profile had to accept at once:
Nothing near an airport platform satisfies that — footways ban cars, terminal roadways ban pedestrians — so the attachment landed on whatever distant service road allowed everything, or was dropped entirely with no error. At SLC that made the TRAX platform's access walk 2,547 m instead of 8 m, past our 24-minute cap, so the airport recorded zero boardings for all of 2025_Q4.
This is upstream code (
547215973, Michael Zilske, 2023), unchanged in upstream 8.0 and 9.0 — upgrading does not fix it. It's near-free upstream, whose test config has 2 profiles; our config has 10, includingtruck,small_truckandcar_freeway, which no transit query would ever request as an access mode.The intersection existed for a reason: there is exactly one
ptToStreetentry per stop, one shared attachment for every access/egress mode. Given that, the filter genuinely did have to satisfy every profile. This PR removes the constraint rather than narrowing it.Description
One attachment per mode instead of one attachment for all modes. A query looks up the attachment belonging to its own access/egress profile, so a walking leg uses the platform footway while a car leg can use the kerb — same stop node, different doors into it.
gtfs.stop_snap_profiles(comma-separated, first entry primary) selects which profiles get snapped. Defaults tofootalone.getProfile()returned null and NPE'd insidecreateWeighting, surfacing as "Is your GTFS file valid?".GraphExplorerresolves its two maps once in the constructor instead of reaching throughgtfsStorageper edge.Reviewer notes
Two things worth a close look:
streetToPtis one-to-one. If two stops share their nearest street node for a mode, only the first is discoverable from the street side for that mode (counted in the log line). Today's merging hides this. Fixing it properly means anIntObjectHashMap<int[]>and a multi-successorgetAdjNode(), which touches search semantics — deliberately left out of scope. Nothing requests car access today.Store format
Breaking: per-profile
pt_to_street_<profile>/street_to_pt_<profile>plus astop_snap_profilesmanifest. Loading an older store fails with an explicit message instead ofFileNotFoundException. A router image must not be rolled ahead of a graph rebuild — relevant becausegraphhopper_runner_image_tagandgraphhopper_builder_image_tagare bumped independently in the model repo.Testing
PerModeStopSnappingIT(5 tests, all passing) covers: thefootdefault, independent per-profile attachments, round-trip through flush/reload, fallback for an unsnapped profile, and rejection of an unknown profile name.Regression-checked against a clean
patternsworktree overGraphHopperGtfsIT,GraphHopperMultimodalIT,AnotherAgencyIT,RealtimeIT,FreeWalkIT,ExtendedRouteTypeIT:patternsbaselineZero new failures, zero fixed. The 21 are pre-existing on
patternsand unrelated. One regression was found and fixed during this work:testCustomProfileAccessrequestscar_customas its access profile and initially hard-failed, which is what motivated the fallback.Full
mvn test-compilepasses across all modules.Not yet done
40.78427, -111.98336dropping from 2,547 m to ~8 m (w1141358400) — needs asouthwest/2025_Q4pilot build and a live router.gtfs.stop_snap_profiles: footindefault_gh_config.yaml, av102base image, and image tag bumps in bothusa_base.yaml.tmpl:111-112andregional_base.yaml.tmpl:80-81(the latter is whatsouthwest/2025_Q4actually inherits).