From 95139f4c2a1ba6f690a0088d96563f9a90948445 Mon Sep 17 00:00:00 2001 From: Mark Idleman Date: Mon, 17 Aug 2026 15:06:43 -0700 Subject: [PATCH] Snap transit stops per access/egress mode (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 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_ / street_to_pt_ 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 --- .../com/graphhopper/gtfs/GraphExplorer.java | 19 +- .../com/graphhopper/gtfs/GraphHopperGtfs.java | 63 +++++-- .../java/com/graphhopper/gtfs/GtfsReader.java | 76 ++++++-- .../com/graphhopper/gtfs/GtfsStorage.java | 168 +++++++++++++++-- .../graphhopper/gtfs/PtLocationSnapper.java | 17 +- .../gtfs/PtRouterFreeWalkImpl.java | 4 +- .../com/graphhopper/gtfs/PtRouterImpl.java | 6 +- .../gtfs/PtRouterTripBasedImpl.java | 4 +- .../com/graphhopper/gtfs/TripFromLabel.java | 2 +- .../graphhopper/PerModeStopSnappingIT.java | 176 ++++++++++++++++++ .../resources/PtIsochroneResource.java | 4 +- 11 files changed, 486 insertions(+), 53 deletions(-) create mode 100644 reader-gtfs/src/test/java/com/graphhopper/PerModeStopSnappingIT.java diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphExplorer.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphExplorer.java index 80a84c7f05e..84c23b14482 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphExplorer.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphExplorer.java @@ -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; @@ -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(); @@ -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)); } } diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphHopperGtfs.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphHopperGtfs.java index feb90fa7f4c..6f0d47d4fca 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphHopperGtfs.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GraphHopperGtfs.java @@ -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; @@ -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 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)); @@ -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 allTransfers = new HashMap<>(); HashMap 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 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 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); @@ -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 readStopSnapProfiles() { + String configured = ghConfig.getString("gtfs.stop_snap_profiles", GtfsStorage.DEFAULT_STOP_SNAP_PROFILE); + List 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 readers, Map 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))) { diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsReader.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsReader.java index d7d181e936d..98349dc365f 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsReader.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsReader.java @@ -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; @@ -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 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 streetNodeByProfile = new LinkedHashMap<>(); + for (Map.Entry 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 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) { + 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() { diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsStorage.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsStorage.java index a0e79d7ec61..bf2dbc143d9 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsStorage.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/GtfsStorage.java @@ -45,11 +45,20 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class GtfsStorage { private static final Logger LOGGER = LoggerFactory.getLogger(GtfsStorage.class); + /** + * Profile used to snap stops when {@code gtfs.stop_snap_profiles} is not configured. Riders reach + * transit on foot, and reader-gtfs already assumes a "foot" profile exists for transfer walking. + */ + public static final String DEFAULT_STOP_SNAP_PROFILE = "foot"; + + private static final String STOP_SNAP_PROFILES_FILE = "stop_snap_profiles"; + static ObjectMapper ionMapper = new ObjectMapper(); private LineIntIndex stopIndex; @@ -168,8 +177,22 @@ public String toString() { private Map stationNodes; private IntObjectHashMap skippedEdgesForTransfer; - private IntIntHashMap ptToStreet; - private IntIntHashMap streetToPt; + /** + * Street attachment of each stop, one mapping per stop-snap profile. + * + * A stop has a single stop node in the transit graph, but may attach to the street network at a + * different place for each mode: a rail platform is reached on foot via an adjacent footway, and + * by car via the nearest kerb. Which mapping a query consults is decided by its access/egress + * profile; see {@link GraphExplorer}. + * + * The first entry of {@link #stopSnapProfiles} is the primary profile. It alone decides stop node + * identity -- and therefore which co-located stops collapse onto one stop node -- so that the + * transit graph does not depend on which modes happen to be configured. + */ + private List stopSnapProfiles = Collections.singletonList(DEFAULT_STOP_SNAP_PROFILE); + private Map ptToStreetByProfile = new LinkedHashMap<>(); + private Map streetToPtByProfile = new LinkedHashMap<>(); + private final Set warnedUnsnappedProfiles = ConcurrentHashMap.newKeySet(); public enum EdgeType { HIGHWAY, ENTER_TIME_EXPANDED_NETWORK, LEAVE_TIME_EXPANDED_NETWORK, ENTER_PT, EXIT_PT, HOP, DWELL, BOARD, ALIGHT, OVERNIGHT, TRANSFER, WAIT, WAIT_ARRIVAL @@ -201,8 +224,14 @@ boolean loadExisting() { GTFSFeed feed = new GTFSFeed(dbFile); this.gtfsFeeds.put(gtfsFeedId, feed); } - ptToStreet = deserializeIntoIntIntHashMap("pt_to_street"); - streetToPt = deserializeIntoIntIntHashMap("street_to_pt"); + stopSnapProfiles = readStopSnapProfiles(); + ptToStreetByProfile = new LinkedHashMap<>(); + streetToPtByProfile = new LinkedHashMap<>(); + for (String profile : stopSnapProfiles) { + ptToStreetByProfile.put(profile, deserializeIntoIntIntHashMap(ptToStreetFile(profile))); + streetToPtByProfile.put(profile, deserializeIntoIntIntHashMap(streetToPtFile(profile))); + } + LOGGER.info("Loaded stop street attachments for profiles {} (primary: {})", stopSnapProfiles, getPrimaryStopSnapProfile()); skippedEdgesForTransfer = deserializeIntoIntObjectHashMap("skipped_edges_for_transfer"); try (InputStream is = Files.newInputStream(Paths.get(dir.getLocation() + "interpolated_transfers"))) { MappingIterator objectMappingIterator = ionMapper.reader(JsonNode.class).readValues(is); @@ -224,6 +253,46 @@ boolean loadExisting() { return true; } + private static String ptToStreetFile(String profile) { + return "pt_to_street_" + profile; + } + + private static String streetToPtFile(String profile) { + return "street_to_pt_" + profile; + } + + /** + * Reads the profile list written by {@link #flush()}. + * + * A store written before per-profile stop snapping has a single "pt_to_street" instead, and its + * attachments were built by intersecting every configured profile -- not equivalent to any profile + * we could name here. Fail with an explicit message rather than a FileNotFoundException, because + * the usual cause is a router image rolled ahead of a graph rebuild. + */ + private List readStopSnapProfiles() { + File file = new File(dir.getLocation() + STOP_SNAP_PROFILES_FILE); + if (!file.exists()) { + throw new IllegalStateException("Graph store at " + dir.getLocation() + " predates per-profile stop" + + " snapping: '" + STOP_SNAP_PROFILES_FILE + "' is missing. This store must be rebuilt by a" + + " matching builder image; a newer router cannot read it."); + } + try { + List profiles = new ArrayList<>(); + for (String line : Files.readAllLines(file.toPath())) { + String profile = line.trim(); + if (!profile.isEmpty()) { + profiles.add(profile); + } + } + if (profiles.isEmpty()) { + throw new IllegalStateException("No stop snap profiles recorded in " + file); + } + return profiles; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private IntIntHashMap deserializeIntoIntIntHashMap(String filename) { try (FileInputStream in = new FileInputStream(dir.getLocation() + filename)) { ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(in)); @@ -273,11 +342,26 @@ void create() { private void init() { this.gtfsFeedIds = data.getHashSet("gtfsFeeds"); this.stationNodes = data.getHashMap("stationNodes"); - this.ptToStreet = new IntIntHashMap(); - this.streetToPt = new IntIntHashMap(); this.skippedEdgesForTransfer = new IntObjectHashMap<>(); } + /** + * Declares which profiles stops will be snapped for, ordered, primary first. Must be called before + * the GTFS readers run. + */ + void setStopSnapProfiles(List profiles) { + if (profiles == null || profiles.isEmpty()) { + throw new IllegalArgumentException("At least one stop snap profile is required"); + } + this.stopSnapProfiles = Collections.unmodifiableList(new ArrayList<>(profiles)); + this.ptToStreetByProfile = new LinkedHashMap<>(); + this.streetToPtByProfile = new LinkedHashMap<>(); + for (String profile : this.stopSnapProfiles) { + ptToStreetByProfile.put(profile, new IntIntHashMap()); + streetToPtByProfile.put(profile, new IntIntHashMap()); + } + } + void loadGtfsFromZipFileOrDirectory(String id, File zipFileOrDirectory) { File dbFile = new File(dir.getLocation() + "/" + id); try { @@ -315,12 +399,65 @@ public Map> getFares() { return faresByFeed; } - public IntIntHashMap getPtToStreet() { - return ptToStreet; + public List getStopSnapProfiles() { + return stopSnapProfiles; + } + + /** + * Profile that decided stop node identity at import time. Used wherever the street attachment is + * needed but no access/egress mode is in play -- notably transfer walking, which is always on foot. + */ + public String getPrimaryStopSnapProfile() { + return stopSnapProfiles.get(0); + } + + /** + * Stop node -> street node for the given profile. Absent means this stop has no attachment usable + * by that mode, which callers represent as a street node of -1. + */ + public IntIntHashMap getPtToStreet(String profile) { + return requireSnapProfile(ptToStreetByProfile, profile); + } + + /** + * Street node -> stop node for the given profile. Note this direction is inherently 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. + */ + public IntIntHashMap getStreetToPt(String profile) { + return requireSnapProfile(streetToPtByProfile, profile); } - public IntIntHashMap getStreetToPt() { - return streetToPt; + /** + * Maps a requested access/egress profile onto one that actually has attachments. + * + * A request may name any configured routing profile, but only the profiles in + * {@code gtfs.stop_snap_profiles} were snapped. Rather than fail such a query, fall back to the + * primary profile's attachments: those sit on the walking network, which a non-walk mode can still + * use wherever the attachment node is shared with a road. List the mode in + * {@code gtfs.stop_snap_profiles} and rebuild to give it attachments of its own. + */ + public String resolveStopSnapProfile(String requestedProfile) { + if (ptToStreetByProfile.containsKey(requestedProfile)) { + return requestedProfile; + } + if (warnedUnsnappedProfiles.add(requestedProfile)) { + LOGGER.warn("Stops were not snapped for profile '{}' (snapped: {}); falling back to '{}'" + + " attachments for access/egress with that profile. Add it to gtfs.stop_snap_profiles" + + " and rebuild the graph to give it its own attachments.", + requestedProfile, stopSnapProfiles, getPrimaryStopSnapProfile()); + } + return getPrimaryStopSnapProfile(); + } + + private IntIntHashMap requireSnapProfile(Map maps, String profile) { + IntIntHashMap map = maps.get(profile); + if (map == null) { + throw new IllegalArgumentException("No stop snapping was built for profile '" + profile + "'." + + " Configured stop snap profiles: " + stopSnapProfiles + + ". Add it to gtfs.stop_snap_profiles and rebuild the graph."); + } + return map; } public Map getGtfsFeeds() { @@ -332,8 +469,15 @@ public Map getStationNodes() { } public void flush() { - serialize("pt_to_street", ptToStreet); - serialize("street_to_pt", streetToPt); + try { + Files.write(Paths.get(dir.getLocation() + STOP_SNAP_PROFILES_FILE), stopSnapProfiles); + } catch (IOException e) { + throw new RuntimeException(e); + } + for (String profile : stopSnapProfiles) { + serialize(ptToStreetFile(profile), ptToStreetByProfile.get(profile)); + serialize(streetToPtFile(profile), streetToPtByProfile.get(profile)); + } serialize("skipped_edges_for_transfer", skippedEdgesForTransfer); try (OutputStream os = Files.newOutputStream(Paths.get(dir.getLocation() + "interpolated_transfers"))) { SequenceWriter sequenceWriter = ionMapper.writer().writeValuesAsArray(os); diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtLocationSnapper.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtLocationSnapper.java index d15a6696f15..374d09f3baf 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtLocationSnapper.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtLocationSnapper.java @@ -1,6 +1,7 @@ package com.graphhopper.gtfs; import com.carrotsearch.hppc.IntHashSet; +import com.carrotsearch.hppc.IntIntHashMap; import com.carrotsearch.hppc.cursors.IntCursor; import com.conveyal.gtfs.GTFSFeed; import com.conveyal.gtfs.model.Stop; @@ -42,12 +43,20 @@ public PtLocationSnapper(BaseGraph baseGraph, LocationIndex locationIndex, GtfsS this.gtfsStorage = gtfsStorage; } - public Result snapAll(List locations, List snapFilters) { + /** + * @param snapFilters snap filter per location, parallel to {@code locations} + * @param snapProfiles stop snap profile per location, parallel to {@code locations}: whose street + * attachments this endpoint uses -- the access profile for the origin, the + * egress profile for the destination + */ + public Result snapAll(List locations, List snapFilters, List snapProfiles) { PointList points = new PointList(2, false); ArrayList pointSnaps = new ArrayList<>(); ArrayList> allSnaps = new ArrayList<>(); for (int i = 0; i < locations.size(); i++) { GHLocation location = locations.get(i); + final IntIntHashMap ptToStreet = gtfsStorage.getPtToStreet(snapProfiles.get(i)); + final IntIntHashMap streetToPt = gtfsStorage.getStreetToPt(snapProfiles.get(i)); if (location instanceof GHPointLocation) { GHPoint point = ((GHPointLocation) location).ghPoint; final Snap closest = locationIndex.findClosest(point.lat, point.lon, snapFilters.get(i)); @@ -64,18 +73,18 @@ public Result snapAll(List locations, List snapFilters) Stop stop = gtfsStorage.getGtfsFeeds().get(e.getKey().feedId).stops.get(e.getKey().stopId); final Snap stopSnap = new Snap(stop.stop_lat, stop.stop_lon); stopSnap.setClosestNode(stopNodeId.value); - allSnaps.add(() -> new Label.NodeId(gtfsStorage.getPtToStreet().getOrDefault(stopSnap.getClosestNode(), -1), stopSnap.getClosestNode())); + allSnaps.add(() -> new Label.NodeId(ptToStreet.getOrDefault(stopSnap.getClosestNode(), -1), stopSnap.getClosestNode())); points.add(stopSnap.getQueryPoint().lat, stopSnap.getQueryPoint().lon); } } } else { pointSnaps.add(closest); - allSnaps.add(() -> new Label.NodeId(closest.getClosestNode(), gtfsStorage.getStreetToPt().getOrDefault(closest.getClosestNode(), -1))); + allSnaps.add(() -> new Label.NodeId(closest.getClosestNode(), streetToPt.getOrDefault(closest.getClosestNode(), -1))); points.add(closest.getSnappedPoint()); } } else if (location instanceof GHStationLocation) { final Snap stopSnap = findByStopId((GHStationLocation) location, i); - allSnaps.add(() -> new Label.NodeId(gtfsStorage.getPtToStreet().getOrDefault(stopSnap.getClosestNode(), -1), stopSnap.getClosestNode())); + allSnaps.add(() -> new Label.NodeId(ptToStreet.getOrDefault(stopSnap.getClosestNode(), -1), stopSnap.getClosestNode())); points.add(stopSnap.getQueryPoint().lat, stopSnap.getQueryPoint().lon); } } diff --git a/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtRouterFreeWalkImpl.java b/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtRouterFreeWalkImpl.java index 96ddcd195ab..21ad2b49954 100644 --- a/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtRouterFreeWalkImpl.java +++ b/reader-gtfs/src/main/java/com/graphhopper/gtfs/PtRouterFreeWalkImpl.java @@ -178,7 +178,7 @@ private class RequestHandler { GHResponse route() { StopWatch stopWatch = new StopWatch().start(); - PtLocationSnapper.Result result = new PtLocationSnapper(baseGraph, locationIndex, gtfsStorage).snapAll(Arrays.asList(enter, exit), Arrays.asList(accessSnapFilter, egressSnapFilter)); + PtLocationSnapper.Result result = new PtLocationSnapper(baseGraph, locationIndex, gtfsStorage).snapAll(Arrays.asList(enter, exit), Arrays.asList(accessSnapFilter, egressSnapFilter), Arrays.asList(gtfsStorage.resolveStopSnapProfile(accessProfile.getName()), gtfsStorage.resolveStopSnapProfile(egressProfile.getName()))); queryGraph = result.queryGraph; response.addDebugInfo("idLookup:" + stopWatch.stop().getSeconds() + "s"); @@ -213,7 +213,7 @@ private void parseSolutionsAndAddToResponse(List> solutio private List> findPaths(Label.NodeId startNode, Label.NodeId destNode) { StopWatch stopWatch = new StopWatch().start(); - GraphExplorer graphExplorer = new GraphExplorer(queryGraph, ptGraph, accessEgressWeighting, gtfsStorage, realtimeFeed, arriveBy, false, false, walkSpeedKmH, false, blockedRouteTypes); + GraphExplorer graphExplorer = new GraphExplorer(queryGraph, ptGraph, accessEgressWeighting, gtfsStorage, realtimeFeed, arriveBy, false, false, walkSpeedKmH, false, blockedRouteTypes, gtfsStorage.getPrimaryStopSnapProfile()); List