Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphExplorer.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package com.graphhopper.gtfs;

import com.carrotsearch.hppc.IntIntHashMap;
import com.conveyal.gtfs.GTFSFeed;
import com.google.common.collect.Iterators;
import com.google.transit.realtime.GtfsRealtime;
Expand Down Expand Up @@ -50,11 +51,21 @@ public final class GraphExplorer {
private final int blockedRouteTypes;
private final PtGraph ptGraph;
private final Graph graph;

public GraphExplorer(Graph graph, PtGraph ptGraph, Weighting accessEgressWeighting, GtfsStorage gtfsStorage, RealtimeFeed realtimeFeed, boolean reverse, boolean streetOnly, boolean ptOnly, double walkSpeedKmh, boolean ignoreValidities, int blockedRouteTypes) {
/**
* Street attachments for the mode this explorer routes, resolved once from {@code stopSnapProfile}.
* A search crossing between the street and transit graphs must use the attachments belonging to its
* own access/egress mode: walking onto the platform footway is not the same place as being dropped
* at the kerb.
*/
private final IntIntHashMap ptToStreet;
private final IntIntHashMap streetToPt;

public GraphExplorer(Graph graph, PtGraph ptGraph, Weighting accessEgressWeighting, GtfsStorage gtfsStorage, RealtimeFeed realtimeFeed, boolean reverse, boolean streetOnly, boolean ptOnly, double walkSpeedKmh, boolean ignoreValidities, int blockedRouteTypes, String stopSnapProfile) {
this.graph = graph;
this.ptGraph = ptGraph;
this.accessEgressWeighting = accessEgressWeighting;
this.ptToStreet = gtfsStorage.getPtToStreet(stopSnapProfile);
this.streetToPt = gtfsStorage.getStreetToPt(stopSnapProfile);
this.ignoreValidities = ignoreValidities;
this.blockedRouteTypes = blockedRouteTypes;
this.edgeExplorer = graph.createEdgeExplorer();
Expand Down Expand Up @@ -294,9 +305,9 @@ public int getId() {

public Label.NodeId getAdjNode() {
if (ptEdge != null) {
return new Label.NodeId(gtfsStorage.getPtToStreet().getOrDefault(ptEdge.getAdjNode(), -1), ptEdge.getAdjNode());
return new Label.NodeId(ptToStreet.getOrDefault(ptEdge.getAdjNode(), -1), ptEdge.getAdjNode());
} else {
return new Label.NodeId(adjNode, gtfsStorage.getStreetToPt().getOrDefault(adjNode, -1));
return new Label.NodeId(adjNode, streetToPt.getOrDefault(adjNode, -1));
}
}

Expand Down
63 changes: 51 additions & 12 deletions reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphHopperGtfs.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
import com.google.common.collect.Multimaps;
import com.graphhopper.GraphHopper;
import com.graphhopper.GraphHopperConfig;
import com.graphhopper.config.Profile;
import com.graphhopper.gtfs.analysis.Trips;
import com.graphhopper.routing.ev.Subnetwork;
import com.graphhopper.routing.querygraph.QueryGraph;
import com.graphhopper.routing.util.DefaultSnapFilter;
import com.graphhopper.routing.util.EdgeFilter;
import com.graphhopper.routing.weighting.Weighting;
import com.graphhopper.storage.index.InMemConstructionIndex;
import com.graphhopper.storage.index.IndexStructureInfo;
Expand Down Expand Up @@ -90,8 +92,12 @@ protected void importPublicTransit() {
}
}
} else {
// Resolved before any store is created: a bad profile name is a config error, and reporting it
// from inside the block below would blame the GTFS feed for it.
List<String> stopSnapProfiles = readStopSnapProfiles();
ensureWriteAccess();
getGtfsStorage().create();
getGtfsStorage().setStopSnapProfiles(stopSnapProfiles);
ptGraph.create(100);
InMemConstructionIndex indexBuilder = new InMemConstructionIndex(IndexStructureInfo.create(
new BBox(-180.0, 180.0, -90.0, 90.0), 300));
Expand All @@ -102,22 +108,22 @@ protected void importPublicTransit() {
getGtfsStorage().loadGtfsFromZipFileOrDirectory("gtfs_" + idx++, new File(gtfsFile));
}
getGtfsStorage().postInit();
LOGGER.info("Snapping stops to the street network once per profile: {} (primary: {})",
stopSnapProfiles, stopSnapProfiles.get(0));
Map<String, Transfers> allTransfers = new HashMap<>();
HashMap<String, GtfsReader> allReaders = new HashMap<>();
getGtfsStorage().getGtfsFeeds().forEach((id, gtfsFeed) -> {
Transfers transfers = new Transfers(gtfsFeed);
allTransfers.put(id, transfers);
GtfsReader gtfsReader = new GtfsReader(id, ptGraph, ptGraph, getGtfsStorage(), getLocationIndex(), transfers, indexBuilder);
// Stops must be connected to the networks of all the modes
List<DefaultSnapFilter> snapFilters = getProfiles().stream().map(p ->
new DefaultSnapFilter(createWeighting(p, new PMap()), getEncodingManager().getBooleanEncodedValue(Subnetwork.key(p.getName())))).collect(Collectors.toList());
gtfsReader.connectStopsToStreetNetwork(e -> {
for (DefaultSnapFilter snapFilter : snapFilters) {
if (!snapFilter.accept(e))
return false;
}
return true;
});
// One attachment per mode, rather than one attachment that every mode must accept.
Map<String, EdgeFilter> snapFilters = new LinkedHashMap<>();
for (String profileName : stopSnapProfiles) {
Profile profile = getProfile(profileName);
snapFilters.put(profileName, new DefaultSnapFilter(createWeighting(profile, new PMap()),
getEncodingManager().getBooleanEncodedValue(Subnetwork.key(profileName))));
}
gtfsReader.connectStopsToStreetNetwork(snapFilters, stopSnapProfiles.get(0));
LOGGER.info("Building transit graph for feed {}", gtfsFeed.feedId);
gtfsReader.buildPtNetwork();
allReaders.put(id, gtfsReader);
Expand Down Expand Up @@ -145,13 +151,46 @@ protected void importPublicTransit() {
gtfsStorage.setStopIndex(stopIndex);
}

/**
* Profiles to snap stops for, from {@code gtfs.stop_snap_profiles} (comma-separated, first entry is
* primary). Defaults to {@link GtfsStorage#DEFAULT_STOP_SNAP_PROFILE} alone: riders reach transit on
* foot, and requiring an attachment that cars and trucks can also use strands stops whose only
* nearby street is a footway (DMP-16462). List additional profiles to support non-walk access legs.
*/
private List<String> readStopSnapProfiles() {
String configured = ghConfig.getString("gtfs.stop_snap_profiles", GtfsStorage.DEFAULT_STOP_SNAP_PROFILE);
List<String> profiles = new ArrayList<>();
for (String name : configured.split(",")) {
String profileName = name.trim();
if (profileName.isEmpty()) {
continue;
}
// getProfile returns null for an unknown name, which would otherwise NPE inside createWeighting.
if (getProfile(profileName) == null) {
throw new IllegalArgumentException("gtfs.stop_snap_profiles names profile '" + profileName
+ "', which is not configured. Available profiles: "
+ getProfiles().stream().map(Profile::getName).collect(Collectors.toList()));
}
if (!profiles.contains(profileName)) {
profiles.add(profileName);
}
}
if (profiles.isEmpty()) {
throw new IllegalArgumentException("gtfs.stop_snap_profiles is set but names no usable profile: '"
+ configured + "'");
}
return profiles;
}

private void interpolateTransfers(HashMap<String, GtfsReader> readers, Map<String, Transfers> allTransfers) {
LOGGER.info("Looking for transfers");
final int maxTransferWalkTimeSeconds = ghConfig.getInt("gtfs.max_transfer_interpolation_walk_time_seconds", 120);
QueryGraph queryGraph = QueryGraph.create(getBaseGraph(), Collections.emptyList());
Weighting transferWeighting = createWeighting(getProfile("foot"), new PMap());
final GraphExplorer graphExplorer = new GraphExplorer(queryGraph, ptGraph, transferWeighting, getGtfsStorage(), RealtimeFeed.empty(), true, true, false, 5.0, false, 0);
getGtfsStorage().getStationNodes().values().stream().distinct().map(n -> new Label.NodeId(gtfsStorage.getPtToStreet().getOrDefault(n, -1), n)).forEach(stationNode -> {
// Transfer walking always uses the primary profile's attachments, matching the foot weighting above.
String transferSnapProfile = getGtfsStorage().getPrimaryStopSnapProfile();
final GraphExplorer graphExplorer = new GraphExplorer(queryGraph, ptGraph, transferWeighting, getGtfsStorage(), RealtimeFeed.empty(), true, true, false, 5.0, false, 0, transferSnapProfile);
getGtfsStorage().getStationNodes().values().stream().distinct().map(n -> new Label.NodeId(gtfsStorage.getPtToStreet(transferSnapProfile).getOrDefault(n, -1), n)).forEach(stationNode -> {
MultiCriteriaLabelSetting router = new MultiCriteriaLabelSetting(graphExplorer, true, false, false, 0, new ArrayList<>());
router.setLimitStreetTime(Duration.ofSeconds(maxTransferWalkTimeSeconds).toMillis());
for (Label label : router.calcLabels(stationNode, Instant.ofEpochMilli(0))) {
Expand Down
76 changes: 65 additions & 11 deletions reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package com.graphhopper.gtfs;

import com.carrotsearch.hppc.IntIntHashMap;
import com.conveyal.gtfs.GTFSFeed;
import com.conveyal.gtfs.model.*;
import com.google.common.collect.HashMultimap;
Expand Down Expand Up @@ -93,26 +94,79 @@ static class TripWithStopTimes {
this.indexBuilder = indexBuilder;
}

void connectStopsToStreetNetwork(EdgeFilter filter) {
/**
* Attaches every stop to the street network once per mode.
*
* Each stop gets a single stop node in the transit graph, but one street attachment per profile in
* {@code snapFiltersByProfile}, so a rail platform can be reached over the adjacent footway on foot
* and over the nearest kerb by car. Previously a stop had one attachment, found with a filter every
* profile had to accept at once; nothing near an airport platform satisfies that, which pushed the
* attachment hundreds of metres away or dropped it entirely (DMP-16462).
*
* {@code primaryProfile} decides stop node identity, including which co-located stops collapse onto
* a shared stop node. Identity must come from exactly one profile, otherwise the set of stop nodes
* -- and hence the transit graph -- would depend on which modes happen to be configured.
*
* @param snapFiltersByProfile snap filter per profile
* @param primaryProfile key in {@code snapFiltersByProfile} that governs stop node identity
*/
void connectStopsToStreetNetwork(Map<String, EdgeFilter> snapFiltersByProfile, String primaryProfile) {
if (!snapFiltersByProfile.containsKey(primaryProfile)) {
throw new IllegalArgumentException("Primary stop snap profile '" + primaryProfile
+ "' is not among the snapped profiles " + snapFiltersByProfile.keySet());
}
int unattachedStops = 0;
int sharedAttachments = 0;
for (Stop stop : feed.stops.values()) {
if (stop.location_type == 0) { // Only stops. Not interested in parent stations for now.
Snap locationSnap = streetNetworkIndex.findClosest(stop.stop_lat, stop.stop_lon, filter);
int stopNode;
if (locationSnap.isValid()) {
stopNode = gtfsStorage.getStreetToPt().getOrDefault(locationSnap.getClosestNode(), -1);
if (stopNode == -1) {
stopNode = out.createNode();
indexBuilder.addToAllTilesOnLine(stopNode, stop.stop_lat, stop.stop_lon, stop.stop_lat, stop.stop_lon);
gtfsStorage.getPtToStreet().put(stopNode, locationSnap.getClosestNode());
gtfsStorage.getStreetToPt().put(locationSnap.getClosestNode(), stopNode);
Map<String, Integer> streetNodeByProfile = new LinkedHashMap<>();
for (Map.Entry<String, EdgeFilter> e : snapFiltersByProfile.entrySet()) {
Snap snap = streetNetworkIndex.findClosest(stop.stop_lat, stop.stop_lon, e.getValue());
if (snap.isValid()) {
streetNodeByProfile.put(e.getKey(), snap.getClosestNode());
}
} else {
}

Integer primaryStreetNode = streetNodeByProfile.get(primaryProfile);
int stopNode = -1;
if (primaryStreetNode != null) {
// Reuse the stop node of an earlier stop sharing this street node, so co-located stops
// (including across feeds) stay a single boarding point as they did before.
stopNode = gtfsStorage.getStreetToPt(primaryProfile).getOrDefault(primaryStreetNode, -1);
}
if (stopNode == -1) {
stopNode = out.createNode();
indexBuilder.addToAllTilesOnLine(stopNode, stop.stop_lat, stop.stop_lon, stop.stop_lat, stop.stop_lon);
}
if (streetNodeByProfile.isEmpty()) {
unattachedStops++;
}

for (Map.Entry<String, Integer> e : streetNodeByProfile.entrySet()) {
IntIntHashMap ptToStreet = gtfsStorage.getPtToStreet(e.getKey());
IntIntHashMap streetToPt = gtfsStorage.getStreetToPt(e.getKey());
if (!ptToStreet.containsKey(stopNode)) {
ptToStreet.put(stopNode, e.getValue());
}
// This direction is one-to-one: if another stop already claimed this street node for
// this profile, it stays reachable from the street side and this one does not.
if (streetToPt.containsKey(e.getValue())) {
sharedAttachments++;
} else {
streetToPt.put(e.getValue(), stopNode);
}
}
gtfsStorage.getStationNodes().put(new GtfsStorage.FeedIdWithStopId(id, stop.stop_id), stopNode);
}
}
if (unattachedStops > 0) {
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) {

@BNewborn BNewborn Aug 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

LOGGER.info("Feed {}: {} stop/profile attachments landed on a street node already claimed by another"
+ " stop; those are not discoverable from the street side for that profile.", id, sharedAttachments);
}
}

void buildPtNetwork() {
Expand Down
Loading