diff --git a/application/src/ext-test/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRoutingTest.java b/application/src/ext-test/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRoutingTest.java new file mode 100644 index 00000000000..e749352e5cd --- /dev/null +++ b/application/src/ext-test/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRoutingTest.java @@ -0,0 +1,97 @@ +package org.opentripplanner.ext.mobilityprofile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.opentripplanner.osm.model.OsmWay; +import org.opentripplanner.street.model.StreetTraversalPermission; +import org.opentripplanner.street.model.edge.StreetEdge; +import org.opentripplanner.street.model.edge.StreetEdgeBuilder; +import org.opentripplanner.street.model.edge.TemporaryPartialStreetEdge; +import org.opentripplanner.street.model.edge.TemporaryPartialStreetEdgeBuilder; +import org.opentripplanner.street.model.vertex.OsmVertex; +import org.opentripplanner.street.model.vertex.StreetVertex; + +class MobilityProfileRoutingTest { + + @Test + void canComputeTravelTime() { + assertEquals( + 0.250 / 1.609 / 2.5, + MobilityProfileRouting.computeTravelHours(250, MobilityProfile.NONE), + 1e-6 + ); + } + + @Test + void canDetectHighwayFootwayTag() { + assertTrue(createFootway().isFootway()); + assertFalse(createServiceWay().isFootway()); + } + + private static OsmWay createServiceWay() { + OsmWay serviceWay = new OsmWay(); + serviceWay.addTag("highway", "service"); + return serviceWay; + } + + private static OsmWay createFootway() { + OsmWay footway = new OsmWay(); + footway.addTag("highway", "footway"); + return footway; + } + + @Test + void canRemoveWalkPermissionOnNonFootway() { + OsmWay serviceWay = createServiceWay(); + StreetTraversalPermission permissions = StreetTraversalPermission.ALL; + assertEquals( + StreetTraversalPermission.BICYCLE_AND_CAR, + MobilityProfileRouting.adjustPedestrianPermissions(serviceWay, permissions) + ); + } + + @Test + void canPreserveWalkPermissionOnFootway() { + OsmWay footway = createFootway(); + StreetTraversalPermission permissions = StreetTraversalPermission.ALL; + assertEquals( + StreetTraversalPermission.ALL, + MobilityProfileRouting.adjustPedestrianPermissions(footway, permissions) + ); + } + + @Test + void canProRateProfileCosts() { + Map profileCost = new EnumMap<>(MobilityProfile.class); + profileCost.put(MobilityProfile.NONE, 10.0f); + profileCost.put(MobilityProfile.DEVICE, 100.0f); + + StreetVertex from = new OsmVertex(33.4, -84.5, 101); + StreetVertex to = new OsmVertex(33.5, -84.6, 102); + + StreetEdge edge = new StreetEdgeBuilder<>() + .withProfileCosts(profileCost) + .withFromVertex(from) + .withToVertex(to) + .withPermission(StreetTraversalPermission.ALL) + .withMeterLength(100) + .buildAndConnect(); + TemporaryPartialStreetEdge tmpEdge = new TemporaryPartialStreetEdgeBuilder() + .withParentEdge(edge) + .withFromVertex(from) + .withToVertex(to) + .withMeterLength(40) + .buildAndConnect(); + + Map proRatedProfileCost = MobilityProfileRouting.getProRatedProfileCosts( + tmpEdge + ); + assertEquals(4.0f, proRatedProfileCost.get(MobilityProfile.NONE), 1e-6); + assertEquals(40.0f, proRatedProfileCost.get(MobilityProfile.DEVICE), 1e-6); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfile.java b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfile.java new file mode 100644 index 00000000000..7c6c1e3e10c --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfile.java @@ -0,0 +1,54 @@ +package org.opentripplanner.ext.mobilityprofile; + +import static org.apache.commons.lang3.StringUtils.isBlank; + +/** + * Enumeration for the mobility profiles, and their associated column names for CSV parsing. + */ +public enum MobilityProfile { + NONE("None"), + SOME("Some"), + DEVICE("Device"), + WCHAIRM("WChairM"), + WCHAIRE("WChairE"), + MSCOOTER("MScooter"), + LOW_VISION("LowVision"), + BLIND("Blind"), + SOME_LOW_VISION("Some-LowVision"), + DEVICE_LOW_VISION("Device-LowVision"), + WCHAIRM_LOW_VISION("WChairM-LowVision"), + WCHAIRE_LOW_VISION("WChairE-LowVision"), + MSCOOTER_LOW_VISION("MScooter-LowVision"), + SOME_BLIND("Some-Blind"), + DEVICE_BLIND("Device-Blind"), + WCHAIRM_BLIND("WChairM-Blind"), + WCHAIRE_BLIND("WChairE-Blind"), + MSCOOTER_BLIND("MScooter-Blind"); + + private final String text; + + MobilityProfile(String text) { + this.text = text; + } + + public String getText() { + return text; + } + + @Override + public String toString() { + return text; + } + + public static MobilityProfile fromString(String value) { + if (isBlank(value)) return null; + + for (MobilityProfile p : MobilityProfile.values()) { + if (p.text.equals(value)) { + return p; + } + } + + throw new RuntimeException(String.format("Invalid mobility profile '%s'", value)); + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileData.java b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileData.java new file mode 100644 index 00000000000..37cdd02d4f1 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileData.java @@ -0,0 +1,14 @@ +package org.opentripplanner.ext.mobilityprofile; + +import java.util.Map; +import javax.annotation.Nonnull; + +public record MobilityProfileData( + float lengthInMeters, + + long fromNode, + + long toNode, + + @Nonnull Map costs +) {} diff --git a/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileParser.java b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileParser.java new file mode 100644 index 00000000000..19d74687304 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileParser.java @@ -0,0 +1,90 @@ +package org.opentripplanner.ext.mobilityprofile; + +import com.csvreader.CsvReader; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helper class that processes CSV files containing profile-based OSM costs. + */ +public class MobilityProfileParser { + + private static final Logger LOG = LoggerFactory.getLogger(MobilityProfileParser.class); + + private static final int ONE_MILE_IN_METERS = 1609; + + private MobilityProfileParser() {} + + /** + * Process rows from the given CSV stream and build a table indexed by both the + * upstream/downstream nodes, where each value is a map of costs by mobility profile. + */ + public static Map parseData(InputStream is) { + try { + var reader = new CsvReader(is, StandardCharsets.UTF_8); + reader.setDelimiter(','); + reader.readHeaders(); + + Map map = new HashMap<>(); + int lineNumber = 1; + while (reader.readRecord()) { + parseRow(lineNumber, reader, map); + lineNumber++; + } + + LOG.info("Imported {} rows from mobility-profile.csv", map.size()); + return map; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** Helper to build a key of the form "id:from=>to" for an OSM way. */ + public static String getKey(long id, long from, long to) { + return String.format("%d:%d=>%d", id, from, to); + } + + private static void parseRow( + int lineNumber, + CsvReader reader, + Map map + ) throws IOException { + String currentColumnHeader = ""; + try { + long fromNode = Long.parseLong(reader.get("Upstream Node"), 10); + long toNode = Long.parseLong(reader.get("Downstream Node"), 10); + String id = reader.get("Way Id"); + long osmWayId = Long.parseLong(id, 10); + String key = getKey(osmWayId, fromNode, toNode); + float lengthMeters = ONE_MILE_IN_METERS * Float.parseFloat(reader.get("Link Length")); + + var weightMap = new EnumMap(MobilityProfile.class); + for (var profile : MobilityProfile.values()) { + currentColumnHeader = profile.getText(); + try { + weightMap.put(profile, Float.parseFloat(reader.get(currentColumnHeader))); + } catch (NumberFormatException | NullPointerException e) { + LOG.warn( + "Ignoring missing/invalid data at line {}, column {}.", + lineNumber, + currentColumnHeader + ); + } + } + + map.put(key, new MobilityProfileData(lengthMeters, fromNode, toNode, weightMap)); + } catch (NumberFormatException | NullPointerException e) { + LOG.warn( + "Skipping mobility profile data at line {}: missing/invalid data in column {}.", + lineNumber, + currentColumnHeader + ); + } + } +} diff --git a/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRouting.java b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRouting.java new file mode 100644 index 00000000000..2b2e4da01b0 --- /dev/null +++ b/application/src/ext/java/org/opentripplanner/ext/mobilityprofile/MobilityProfileRouting.java @@ -0,0 +1,87 @@ +package org.opentripplanner.ext.mobilityprofile; + +import static java.util.Map.entry; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import org.opentripplanner.osm.model.OsmWay; +import org.opentripplanner.street.model.StreetTraversalPermission; +import org.opentripplanner.street.model.edge.StreetEdge; +import org.opentripplanner.street.model.edge.TemporaryPartialStreetEdge; + +public class MobilityProfileRouting { + + private static final Map TRAVEL_SPEED_MPH_BY_PROFILE = new EnumMap<>( + Map.ofEntries( + entry(MobilityProfile.NONE, 2.5f), + entry(MobilityProfile.SOME, 2.0f), + entry(MobilityProfile.DEVICE, 1.5f), + entry(MobilityProfile.WCHAIRM, 3.0f), + entry(MobilityProfile.WCHAIRE, 4.0f), + entry(MobilityProfile.MSCOOTER, 5.0f), + entry(MobilityProfile.LOW_VISION, 2.0f), + entry(MobilityProfile.BLIND, 1.5f), + entry(MobilityProfile.SOME_LOW_VISION, 2.0f), + entry(MobilityProfile.DEVICE_LOW_VISION, 1.5f), + entry(MobilityProfile.WCHAIRM_LOW_VISION, 2.0f), + entry(MobilityProfile.WCHAIRE_LOW_VISION, 2.0f), + entry(MobilityProfile.MSCOOTER_LOW_VISION, 2.0f), + entry(MobilityProfile.SOME_BLIND, 2.0f), + entry(MobilityProfile.DEVICE_BLIND, 1.5f), + entry(MobilityProfile.WCHAIRM_BLIND, 2.0f), + entry(MobilityProfile.WCHAIRE_BLIND, 1.5f), + entry(MobilityProfile.MSCOOTER_BLIND, 2.0f) + ) + ); + + public static final double ONE_MILE_IN_KILOMETERS = 1.609; + + private MobilityProfileRouting() { + // Np public constructor. + } + + /** Computes the travel time, in hours, for the given distance and mobility profile. */ + public static float computeTravelHours(double meters, MobilityProfile mobilityProfile) { + return (float) ( + meters / + 1000 / + ONE_MILE_IN_KILOMETERS / + TRAVEL_SPEED_MPH_BY_PROFILE.getOrDefault( + mobilityProfile, + TRAVEL_SPEED_MPH_BY_PROFILE.get(MobilityProfile.NONE) + ) + ); + } + + public static StreetTraversalPermission adjustPedestrianPermissions( + OsmWay way, + StreetTraversalPermission permissions + ) { + return way.isFootway() || way.isTransitPlatform() + ? permissions + : permissions.remove(StreetTraversalPermission.PEDESTRIAN); + } + + /** Multiplies profile costs by the distance ratio between the given edge and its parent. */ + public static Map getProRatedProfileCosts( + TemporaryPartialStreetEdge tmpEdge + ) { + StreetEdge parentEdge = tmpEdge.getParentEdge(); + if (parentEdge.profileCost != null) { + float ratio = (float) (tmpEdge.getDistanceMeters() / parentEdge.getDistanceMeters()); + return getProRatedProfileCosts(parentEdge.profileCost, ratio); + } + return new HashMap<>(); + } + + public static Map getProRatedProfileCosts( + Map cost, + float ratio + ) { + // Has to be a HashMap for graph serialization + Map result = new EnumMap<>(MobilityProfile.class); + cost.forEach((k, v) -> result.put(k, v * ratio)); + return result; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/routerequest/LegacyRouteRequestMapper.java b/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/routerequest/LegacyRouteRequestMapper.java index 19bee5e430d..bbfb4f7c5d0 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/routerequest/LegacyRouteRequestMapper.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/routerequest/LegacyRouteRequestMapper.java @@ -254,6 +254,7 @@ public static RouteRequest toRouteRequest( "locale", (String v) -> request.setLocale(GraphQLUtils.getLocale(environment, v)) ); + callWith.argument("mobilityProfile", request::setMobilityProfileFromString); return request; } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java b/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java index 080d69c571e..a4f0cc9487c 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java @@ -86,6 +86,7 @@ static OsmModule provideOsmModule( .withBoardingAreaRefTags(config.boardingLocationTags) .withIssueStore(issueStore) .withStreetLimitationParameters(streetLimitationParameters) + .withPreventWalkingOnRoads(config.preventWalkingOnRoads) .build(); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java index 08d23087a45..893d0d0c745 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java @@ -2,15 +2,20 @@ import com.google.common.collect.Iterables; import gnu.trove.iterator.TLongIterator; +import gnu.trove.list.TLongList; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LineString; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileParser; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileRouting; import org.opentripplanner.framework.geometry.GeometryUtils; import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; import org.opentripplanner.framework.i18n.I18NString; @@ -56,6 +61,12 @@ public class OsmModule implements GraphBuilderModule { private final SafetyValueNormalizer normalizer; private final VertexGenerator vertexGenerator; private final OsmDatabase osmdb; + private List osmStreets; + private List osmFootways; + private OsmWay lastQueriedCrossing; + private OsmWay lastIntersectingStreetFound; + private OsmWay lastQueriedCrossingExtension; + private OsmWay lastAdjacentCrossingFound; private final StreetLimitationParameters streetLimitationParameters; OsmModule( @@ -512,6 +523,25 @@ private StreetEdgePair getEdgesForStreet( return new StreetEdgePair(street, backStreet); } + private String getCrossingName(OsmWay way, String defaultName) { + // Scan the nodes of this way to find the intersecting street. + var otherWayOpt = getIntersectingStreet(way); + if (otherWayOpt.isPresent()) { + OsmWay otherWay = otherWayOpt.get(); + if (otherWay.hasTag("name")) { + return String.format("crossing over %s", otherWay.getTag("name")); + } else if (otherWay.isServiceRoad()) { + return "crossing over service road"; + } else if (otherWay.isOneWayForwardDriving()) { + return "crossing over turn lane"; + } else { + // Default on using the OSM way ID, which should not happen. + return String.format("crossing %s", way.getId()); + } + } + return defaultName; + } + private StreetEdge getEdgeForStreet( IntersectionVertex startEndpoint, IntersectionVertex endEndpoint, @@ -522,18 +552,34 @@ private StreetEdge getEdgeForStreet( LineString geometry, boolean back ) { - String label = "way " + way.getId() + " from " + index; + long wayId = way.getId(); + String label = "way " + wayId + " from " + index; label = label.intern(); I18NString name = params.edgeNamer().getNameForWay(way, label); float carSpeed = way.getOsmProvider().getOsmTagMapper().getCarSpeedForWay(way, back); + String startId = startEndpoint.getLabel().toString(); + String endId = endEndpoint.getLabel().toString(); + String profileKey = ""; + try { + long startShortId = Long.parseLong(startId.replace("osm:node:", ""), 10); + long endShortId = Long.parseLong(endId.replace("osm:node:", ""), 10); + profileKey = getProfileKey(way, startShortId, endShortId); + } catch (NumberFormatException nfe) { + LOG.warn("Unable to extract OSM nodes for way {} {}, {}=>{}", name, wayId, startId, endId); + } + + StreetTraversalPermission perms = params.preventWalkingOnRoads() + ? MobilityProfileRouting.adjustPedestrianPermissions(way, permissions) + : permissions; + StreetEdgeBuilder seb = new StreetEdgeBuilder<>() .withFromVertex(startEndpoint) .withToVertex(endEndpoint) .withGeometry(geometry) .withName(name) .withMeterLength(length) - .withPermission(permissions) + .withPermission(perms) .withBack(back) .withCarSpeed(carSpeed) .withLink(way.isLink()) @@ -541,11 +587,135 @@ private StreetEdge getEdgeForStreet( .withSlopeOverride(way.getOsmProvider().getWayPropertySet().getSlopeOverride(way)) .withStairs(way.isSteps()) .withWheelchairAccessible(way.isWheelchairAccessible()) + .withProfileKey(profileKey) .withBogusName(way.hasNoName()); + // If this is a street crossing (denoted with the tag "footway:crossing"), + // add a crossing indication in the edge name. + // TODO: i18n. + String editedName = name.toString(); + if (way.isMarkedCrossing()) { + editedName = getCrossingName(way, editedName); + seb.withName(editedName); + seb.withBogusName(false); + } else { + OsmWay continuedCrossing = getContinuedMarkedCrossing(way); + if (continuedCrossing != null) { + // Change the name of this segment to the name of the crossing. + editedName = getCrossingName(continuedCrossing, editedName); + seb.withName(editedName); + seb.withBogusName(false); + } else if ("sidewalk".equals(editedName) || "path".equals(editedName)) { + editedName = String.format("%s %s", editedName, wayId); + // seb.withName(editedName); + } + } + return seb.buildAndConnect(); } + /** * + * Obtains the correct key for this OSM way. + */ + private static String getProfileKey(OsmWay way, long startShortId, long endShortId) { + long wayId = way.getId(); + TLongList nodeRefs = way.getNodeRefs(); + + int startIndex = nodeRefs.indexOf(startShortId); + int endIndex = nodeRefs.indexOf(endShortId); + boolean isReverse = endIndex < startIndex; + + // Use the start and end nodes of the OSM way per the OSM data to lookup the mobility costs. + long wayFromId = nodeRefs.get(0); + long wayToId = nodeRefs.get(nodeRefs.size() - 1); + return isReverse + ? MobilityProfileParser.getKey(wayId, wayToId, wayFromId) + : MobilityProfileParser.getKey(wayId, wayFromId, wayToId); + } + + /** Gets the streets from a collection of OSM ways. */ + public static List getStreets(Collection ways) { + return ways + .stream() + .filter(w -> !w.isFootway()) + // Keep named streets, service roads, and slip/turn lanes. + .filter(w -> w.hasTag("name") || w.isServiceRoad() || w.isOneWayForwardDriving()) + .toList(); + } + + /** Gets the intersecting street, if any, for the given way using ways in osmdb. */ + private Optional getIntersectingStreet(OsmWay way) { + // Perf: If the same way is queried again, return the previously found intersecting street. + if (way == lastQueriedCrossing) { + return Optional.ofNullable(lastIntersectingStreetFound); + } + + if (osmStreets == null) { + osmStreets = getStreets(osmdb.getWays()); + } + + lastQueriedCrossing = way; + Optional intersectingStreetOptional = getIntersectingStreet(way, osmStreets); + lastIntersectingStreetFound = intersectingStreetOptional.orElse(null); + return intersectingStreetOptional; + } + + /** Gets the intersecting street, if any, for the given way and candidate streets. */ + public static Optional getIntersectingStreet(OsmWay way, List streets) { + TLongList nodeRefs = way.getNodeRefs(); + if (nodeRefs.size() >= 3) { + // There needs to be at least three nodes: 2 extremities that are on the sidewalk, + // and one somewhere in the middle that joins the crossing with the street. + // We exclude the first and last node which are on the sidewalk. + long[] nodeRefsArray = nodeRefs.toArray(1, nodeRefs.size() - 2); + return streets + .stream() + .filter(w -> Arrays.stream(nodeRefsArray).anyMatch(nid -> w.getNodeRefs().contains(nid))) + .findFirst(); + } + return Optional.empty(); + } + + /** Gets the footways from a collection of OSM ways. */ + public static List getFootways(Collection ways) { + return ways.stream().filter(OsmWay::isFootway).toList(); + } + + /** + * Determines whether a way is a continuation (connects through end nodes) of a marked crossing, + * using the footways from osmdb. + */ + private OsmWay getContinuedMarkedCrossing(OsmWay way) { + // Perf: If the same way is queried again, return the previously found intersecting street. + if (way == lastQueriedCrossingExtension) { + return lastAdjacentCrossingFound; + } + + if (osmFootways == null) { + osmFootways = getFootways(osmdb.getWays()); + } + + lastQueriedCrossingExtension = way; + lastAdjacentCrossingFound = getContinuedMarkedCrossing(way, osmFootways); + return lastAdjacentCrossingFound; + } + + /** Determines whether a way is a continuation (i.e. connects through end nodes) of marked crossing. */ + public static OsmWay getContinuedMarkedCrossing(OsmWay way, Collection ways) { + int adjacentWayCount = 0; + OsmWay markedCrossing = null; + + for (OsmWay w : ways) { + if (way.isAdjacentTo(w)) { + adjacentWayCount++; + if (markedCrossing == null && w.isMarkedCrossing()) { + markedCrossing = w; + } + } + } + return adjacentWayCount == 1 ? markedCrossing : null; + } + private float getMaxCarSpeed() { float maxSpeed = 0f; for (OsmProvider provider : providers) { diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java index 6e90fb20b1c..066be11ae8a 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java @@ -26,6 +26,7 @@ public class OsmModuleBuilder { private boolean staticBikeParkAndRide = false; private int maxAreaNodes; private StreetLimitationParameters streetLimitationParameters = new StreetLimitationParameters(); + private boolean preventWalkingOnRoads; OsmModuleBuilder(Collection providers, Graph graph) { this.providers = providers; @@ -77,6 +78,11 @@ public OsmModuleBuilder withStreetLimitationParameters(StreetLimitationParameter return this; } + public OsmModuleBuilder withPreventWalkingOnRoads(boolean value) { + this.preventWalkingOnRoads = value; + return this; + } + public OsmModule build() { return new OsmModule( providers, @@ -90,7 +96,8 @@ public OsmModule build() { areaVisibility, platformEntriesLinking, staticParkAndRide, - staticBikeParkAndRide + staticBikeParkAndRide, + preventWalkingOnRoads ) ); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java index 52bf8d65314..38229fc79ff 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java @@ -21,7 +21,8 @@ public record OsmProcessingParameters( boolean areaVisibility, boolean platformEntriesLinking, boolean staticParkAndRide, - boolean staticBikeParkAndRide + boolean staticBikeParkAndRide, + boolean preventWalkingOnRoads ) { public OsmProcessingParameters { boardingAreaRefTags = Set.copyOf(Objects.requireNonNull(boardingAreaRefTags)); diff --git a/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java b/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java index 8d4df6634fd..b0d24492264 100644 --- a/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java +++ b/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java @@ -120,10 +120,10 @@ public WalkStepBuilder addEdge(Edge edge) { @Nullable public String directionTextNoParens() { - var str = directionText.toString(); - if (str == null) { - return null; //Avoid null reference exceptions with pathways which don't have names + if (directionText == null) { + return null; // Avoid null reference exceptions with pathways which don't have names } + var str = directionText.toString(); int idx = str.indexOf('('); if (idx > 0) { return str.substring(0, idx - 1); diff --git a/application/src/main/java/org/opentripplanner/osm/model/OsmWay.java b/application/src/main/java/org/opentripplanner/osm/model/OsmWay.java index 7b5fbe56748..8853a53b360 100644 --- a/application/src/main/java/org/opentripplanner/osm/model/OsmWay.java +++ b/application/src/main/java/org/opentripplanner/osm/model/OsmWay.java @@ -157,6 +157,46 @@ public boolean isRoutableArea() { ); } + public boolean isFootway() { + return "footway".equals(getTag("highway")); + } + + public boolean isMarkedCrossing() { + String crossingMarkingsTag = getTag("crossing:markings"); + return ( + "crossing".equals(getTag("footway")) && + ( + crossingMarkingsTag != null && + !"no".equals(crossingMarkingsTag) || + "marked".equals(getTag("crossing")) + ) + ); + } + + public boolean isServiceRoad() { + return "service".equals(getTag("highway")); + } + + /** Whether this way is connected to the given way through their extremities. */ + public boolean isAdjacentTo(OsmWay way) { + long wayFirstNode = way.nodes.get(0); + long wayLastNode = way.nodes.get(way.nodes.size() - 1); + + long firstNode = nodes.get(0); + long lastNode = nodes.get(nodes.size() - 1); + + return ( + firstNode == wayFirstNode && + lastNode != wayLastNode || + firstNode == wayLastNode && + lastNode != wayFirstNode || + lastNode == wayFirstNode && + firstNode != wayLastNode || + lastNode == wayLastNode && + firstNode != wayFirstNode + ); + } + /** * Given a set of {@code permissions} check if it can really be applied to both directions * of the way and return the permissions for both cases. @@ -200,4 +240,8 @@ public StreetTraversalPermissionPair splitPermissions(StreetTraversalPermission public String url() { return String.format("https://www.openstreetmap.org/way/%d", getId()); } + + public boolean isTransitPlatform() { + return "platform".equals(getTag("railway")) || "platform".equals(getTag("public_transport")); + } } diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java index 32f5ccf533b..283f07ef5eb 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java @@ -1,5 +1,6 @@ package org.opentripplanner.routing.algorithm.mapping; +import static org.opentripplanner.model.plan.RelativeDirection.CONTINUE; import static org.opentripplanner.model.plan.RelativeDirection.ENTER_STATION; import static org.opentripplanner.model.plan.RelativeDirection.EXIT_STATION; import static org.opentripplanner.model.plan.RelativeDirection.FOLLOW_SIGNS; @@ -188,69 +189,90 @@ private void processState(State backState, State forwardState) { if (current == null) { createFirstStep(backState, forwardState); createdNewStep = true; - } else if ( - modeTransition || - !continueOnSameStreet(edge, streetNameNoParens) || - // went on to or off of a roundabout - edge.isRoundabout() != - (roundaboutExit > 0) || - isLink(edge) && - !isLink(backState.getBackEdge()) - ) { - // Street name has changed, or we've gone on to or off of a roundabout. - - // if we were just on a roundabout, make note of which exit was taken in the existing step - if (roundaboutExit > 0) { - // ordinal numbers from - current.withExit(Integer.toString(roundaboutExit)); - if (streetNameNoParens.equals(roundaboutPreviousStreet)) { - current.withStayOn(true); - } - roundaboutExit = 0; - } - - // start a new step - current = createWalkStep(forwardState, backState); - createdNewStep = true; - steps.add(current); - - // indicate that we are now on a roundabout and use one-based exit numbering - if (edge.isRoundabout()) { - roundaboutExit = 1; - roundaboutPreviousStreet = getNormalizedName(backState.getBackEdge().getName().toString()); - } - - double thisAngle = DirectionUtils.getFirstAngle(geom); - current.withDirections(lastAngle, thisAngle, edge.isRoundabout()); - // new step, set distance to length of first edge - distance = edge.getDistanceMeters(); } else { - // street name has not changed double thisAngle = DirectionUtils.getFirstAngle(geom); RelativeDirection direction = RelativeDirection.calculate( lastAngle, thisAngle, edge.isRoundabout() ); - if (edge.isRoundabout()) { - // we are on a roundabout, and have already traversed at least one edge of it. - if (multipleTurnOptionsInPreviousState(backState)) { - // increment exit count if we passed one. - roundaboutExit += 1; + + // G-MAP-specific: Overwrite the name on short street edges so they are merged with longer street + // sections to clean up turn-by-turn instructions. + if (edge instanceof StreetEdge streetEdge && streetEdge.hasProfileCost()) { + if (shouldOverwriteCurrentDirectionText(edge, direction)) { + // HACK: If the instruction is "continue", the current street name is bogus and its length is very short (< 10 meters) + // but not the next edge one, use the next street name and don't start a new step. + current.withDirectionText(I18NString.of(streetNameNoParens)); + current.withBogusName(false); + } + if (shouldOverwriteEdgeDirectionText(edge, direction)) { + // HACK: Similar hack if the next edge name is bogus and its length is very short (< 10 meters) + // but not the current step. In this case, continue using the current street name and don't start a new step. + streetNameNoParens = current.directionTextNoParens(); + streetEdge.setName(current.directionText()); + streetEdge.setBogusName(false); + } + } + + if ( + modeTransition || + !continueOnSameStreet(edge, streetNameNoParens) || + // went on to or off of a roundabout + edge.isRoundabout() != + (roundaboutExit > 0) || + isLink(edge) && + !isLink(backState.getBackEdge()) + ) { + // Street name has changed, or we've gone on to or off of a roundabout. + + // if we were just on a roundabout, make note of which exit was taken in the existing step + if (roundaboutExit > 0) { + // ordinal numbers from + current.withExit(Integer.toString(roundaboutExit)); + if (streetNameNoParens.equals(roundaboutPreviousStreet)) { + current.withStayOn(true); + } + roundaboutExit = 0; + } + + // start a new step + current = createWalkStep(forwardState, backState); + createdNewStep = true; + steps.add(current); + + // indicate that we are now on a roundabout and use one-based exit numbering + if (edge.isRoundabout()) { + roundaboutExit = 1; + roundaboutPreviousStreet = + getNormalizedName(backState.getBackEdge().getName().toString()); } - } else if (direction != RelativeDirection.CONTINUE) { - // we are not on a roundabout, and not continuing straight through. - // figure out if there were other plausible turn options at the last intersection - // to see if we should generate a "left to continue" instruction. - if (isPossibleToTurnToOtherStreet(backState, edge, streetName, thisAngle)) { - // turn to stay on same-named street - current = createWalkStep(forwardState, backState); - createdNewStep = true; - current.withDirections(lastAngle, thisAngle, false); - current.withStayOn(true); - steps.add(current); - // new step, set distance to length of first edge - distance = edge.getDistanceMeters(); + + current.withDirections(lastAngle, thisAngle, edge.isRoundabout()); + // new step, set distance to length of first edge + distance = edge.getDistanceMeters(); + } else { + // street name has not changed + if (edge.isRoundabout()) { + // we are on a roundabout, and have already traversed at least one edge of it. + if (multipleTurnOptionsInPreviousState(backState)) { + // increment exit count if we passed one. + roundaboutExit += 1; + } + } else if (direction != RelativeDirection.CONTINUE) { + // we are not on a roundabout, and not continuing straight through. + // figure out if there were other plausible turn options at the last intersection + // to see if we should generate a "left to continue" instruction. + if (isPossibleToTurnToOtherStreet(backState, edge, streetName, thisAngle)) { + // turn to stay on same-named street + current = createWalkStep(forwardState, backState); + createdNewStep = true; + current.withDirections(lastAngle, thisAngle, false); + current.withStayOn(true); + steps.add(current); + // new step, set distance to length of first edge + distance = edge.getDistanceMeters(); + } } } } @@ -264,9 +286,7 @@ private void processState(State backState, State forwardState) { WalkStepBuilder threeBack = steps.get(lastIndex - 2); WalkStepBuilder twoBack = steps.get(lastIndex - 1); WalkStepBuilder lastStep = steps.get(lastIndex); - boolean isOnSameStreet = lastStep - .directionTextNoParens() - .equals(threeBack.directionTextNoParens()); + boolean isOnSameStreet = isOnSameStreet(lastStep, twoBack, threeBack); if (twoBack.distance() < MAX_ZAG_DISTANCE && isOnSameStreet) { if (isUTurn(twoBack, lastStep)) { steps.remove(lastIndex - 1); @@ -295,6 +315,25 @@ private void processState(State backState, State forwardState) { current.addEdge(edge); } + public static boolean isOnSameStreet( + WalkStepBuilder lastStep, + WalkStepBuilder twoBack, + WalkStepBuilder threeBack + ) { + String lastStepName = lastStep.directionTextNoParens(); + String twoBackStepName = twoBack.directionTextNoParens(); + String threeBackStepName = threeBack.directionTextNoParens(); + if (lastStepName == null || twoBackStepName == null || threeBackStepName == null) return false; + + // Keep an explicit instruction when crossing to the other side of the same street. + // (An instruction can be given to cross at a particular location because others may not be accessible, practical, etc.) + return ( + !lastStepName.startsWith("crossing") && + !twoBackStepName.startsWith("crossing") && + lastStepName.equals(threeBackStepName) + ); + } + private static RelativeDirection relativeDirectionForTransitLink(StreetTransitEntranceLink link) { if (link.isExit()) { return EXIT_STATION; @@ -426,6 +465,19 @@ private boolean isTurnToOtherStreet(String streetName, double angleDiff, Edge al return angleDiff > Math.PI / 4 || altAngleDiff - angleDiff < Math.PI / 16; } + private boolean shouldOverwriteCurrentDirectionText(Edge edge, RelativeDirection direction) { + return direction == CONTINUE && distance < 10 && current.bogusName() && !edge.hasBogusName(); + } + + private boolean shouldOverwriteEdgeDirectionText(Edge edge, RelativeDirection direction) { + return ( + direction == CONTINUE && + edge.getDistanceMeters() < 10 && + !current.bogusName() && + edge.hasBogusName() + ); + } + private boolean continueOnSameStreet(Edge edge, String streetNameNoParens) { return !( current.directionText().toString() != null && diff --git a/application/src/main/java/org/opentripplanner/routing/api/request/RouteRequest.java b/application/src/main/java/org/opentripplanner/routing/api/request/RouteRequest.java index 8e649b3be49..049715a35e1 100644 --- a/application/src/main/java/org/opentripplanner/routing/api/request/RouteRequest.java +++ b/application/src/main/java/org/opentripplanner/routing/api/request/RouteRequest.java @@ -14,6 +14,7 @@ import java.util.Objects; import java.util.function.Consumer; import javax.annotation.Nullable; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; import org.opentripplanner.model.GenericLocation; import org.opentripplanner.model.plan.SortOrder; import org.opentripplanner.model.plan.paging.cursor.PageCursor; @@ -77,6 +78,8 @@ public class RouteRequest implements Cloneable, Serializable { private boolean wheelchair = false; + private MobilityProfile mobilityProfile = null; + private Instant bookingTime; /* CONSTRUCTORS */ @@ -140,6 +143,21 @@ public void setWheelchair(boolean wheelchair) { this.wheelchair = wheelchair; } + /** + * Applicable mobility profile for street routing + */ + public MobilityProfile mobilityProfile() { + return mobilityProfile; + } + + public void setMobilityProfile(MobilityProfile profile) { + this.mobilityProfile = profile; + } + + public void setMobilityProfileFromString(String profile) { + this.mobilityProfile = MobilityProfile.fromString(profile); + } + /** * The epoch date/time in seconds that the trip should depart (or arrive, for requests where * arriveBy is true) diff --git a/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java index 16c6f1e722c..4c3341a2a41 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java @@ -182,6 +182,8 @@ public class BuildConfig implements OtpDataStoreConfig { private final List transitRouteToStationCentroid; public final URI stopConsolidation; + public final boolean preventWalkingOnRoads; + /** * Set all parameters from the given Jackson JSON tree, applying defaults. Supplying * MissingNode.getInstance() will cause all the defaults to be applied. This could be done @@ -631,6 +633,14 @@ that we support remote input files (cloud storage or arbitrary URLs) not all dat ) .asUri(null); + preventWalkingOnRoads = + root + .of("preventWalkingOnRoads") + .since(V2_5) + .summary("Determines whether to prevent pedestrian routing on roads or not.") + .description("True to prevent pedestrian routing on roads.") + .asBoolean(false); + osmDefaults = OsmConfig.mapOsmDefaults(root, "osmDefaults"); osm = OsmConfig.mapOsmConfig(root, "osm", osmDefaults); demDefaults = DemConfig.mapDemDefaultsConfig(root, "demDefaults"); diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java index ac69b43e275..95477a57a46 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/UpdatersConfig.java @@ -4,6 +4,7 @@ import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_2; import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.BIKE_RENTAL; import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.MQTT_GTFS_RT_UPDATER; +import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.OSM_IMPEDANCE_UPDATER; import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.REAL_TIME_ALERTS; import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.SIRI_AZURE_ET_UPDATER; import static org.opentripplanner.standalone.config.routerconfig.UpdatersConfig.Type.SIRI_AZURE_SX_UPDATER; @@ -28,6 +29,7 @@ import org.opentripplanner.standalone.config.framework.json.NodeAdapter; import org.opentripplanner.standalone.config.routerconfig.updaters.GtfsRealtimeAlertsUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.MqttGtfsRealtimeUpdaterConfig; +import org.opentripplanner.standalone.config.routerconfig.updaters.OsmImpedanceUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.PollingTripUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETGooglePubsubUpdaterConfig; import org.opentripplanner.standalone.config.routerconfig.updaters.SiriETUpdaterConfig; @@ -41,6 +43,7 @@ import org.opentripplanner.updater.TimetableSnapshotSourceParameters; import org.opentripplanner.updater.UpdatersParameters; import org.opentripplanner.updater.alert.GtfsRealtimeAlertsUpdaterParameters; +import org.opentripplanner.updater.impedance.OsmImpedanceUpdaterParameters; import org.opentripplanner.updater.siri.updater.SiriETUpdaterParameters; import org.opentripplanner.updater.siri.updater.SiriSXUpdaterParameters; import org.opentripplanner.updater.siri.updater.google.SiriETGooglePubsubUpdaterParameters; @@ -202,6 +205,11 @@ public List getSiriAzureSXUpdaterParameters() { return getParameters(SIRI_AZURE_SX_UPDATER); } + @Override + public List getOsmImpedanceUpdaterParameters() { + return getParameters(OSM_IMPEDANCE_UPDATER); + } + private List getParameters(Type key) { return (List) configList.get(key); } @@ -217,6 +225,7 @@ public enum Type { MQTT_GTFS_RT_UPDATER(MqttGtfsRealtimeUpdaterConfig::create), REAL_TIME_ALERTS(GtfsRealtimeAlertsUpdaterConfig::create), VEHICLE_POSITIONS(VehiclePositionsUpdaterConfig::create), + OSM_IMPEDANCE_UPDATER(OsmImpedanceUpdaterConfig::create), SIRI_ET_UPDATER(SiriETUpdaterConfig::create), SIRI_ET_GOOGLE_PUBSUB_UPDATER(SiriETGooglePubsubUpdaterConfig::create), SIRI_SX_UPDATER(SiriSXUpdaterConfig::create), diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/OsmImpedanceUpdaterConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/OsmImpedanceUpdaterConfig.java new file mode 100644 index 00000000000..5bbed3aa149 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/updaters/OsmImpedanceUpdaterConfig.java @@ -0,0 +1,23 @@ +package org.opentripplanner.standalone.config.routerconfig.updaters; + +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_5; + +import java.time.Duration; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; +import org.opentripplanner.updater.impedance.OsmImpedanceUpdaterParameters; + +public class OsmImpedanceUpdaterConfig { + + public static OsmImpedanceUpdaterParameters create(String configRef, NodeAdapter c) { + return new OsmImpedanceUpdaterParameters( + configRef, + c.of("url").since(V2_5).summary("URL to fetch the GTFS-RT feed from.").asString(), + c + .of("frequency") + .since(V2_5) + .summary("How often the URL should be fetched.") + .asDuration(Duration.ofSeconds(30)), + HttpHeadersConfig.headers(c, V2_5) + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdge.java b/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdge.java index b606bcd9962..1541f7d296a 100644 --- a/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdge.java +++ b/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdge.java @@ -4,13 +4,17 @@ import java.io.ObjectOutputStream; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.impl.PackedCoordinateSequence; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileRouting; import org.opentripplanner.framework.geometry.CompactLineStringUtils; import org.opentripplanner.framework.geometry.DirectionUtils; import org.opentripplanner.framework.geometry.GeometryUtils; @@ -63,6 +67,7 @@ public class StreetEdge static final int BICYCLE_NOTHRUTRAFFIC = 7; static final int WALK_NOTHRUTRAFFIC = 8; static final int CLASS_LINK = 9; + public static final long DEFAULT_LARGE_COST = 10000; private StreetEdgeCostExtension costExtension; @@ -84,6 +89,12 @@ public class StreetEdge */ private float bicycleSafetyFactor; + /** The key to the mobility profile data for this street edge. */ + public String profileKey; + + /** A map of cost based on a mobility profile. Implemented as HashMap for serialization. */ + public transient Map profileCost = new HashMap<>(); + /** * walkSafetyFactor = length * walkSafetyFactor. For example, a 100m street with a safety * factor of 2.0 will be considered in term of safety cost as the same as a 200m street with a @@ -145,6 +156,8 @@ protected StreetEdge(StreetEdgeBuilder builder) { inAngle = lineStringInOutAngles.inAngle(); outAngle = lineStringInOutAngles.outAngle(); elevationExtension = builder.streetElevationExtension(); + profileCost = builder.profileCosts(); + profileKey = builder.profileKey(); } public StreetEdgeBuilder toBuilder() { @@ -447,10 +460,15 @@ public void setName(I18NString name) { this.name = name; } + @Override public boolean hasBogusName() { return BitSetUtils.get(flags, HASBOGUSNAME_FLAG_INDEX); } + public void setBogusName(boolean bogusName) { + flags = BitSetUtils.set(flags, HASBOGUSNAME_FLAG_INDEX, bogusName); + } + public LineString getGeometry() { return CompactLineStringUtils.uncompactLineString( fromv.getLon(), @@ -625,19 +643,23 @@ public void addRentalRestriction(RentalRestrictionExtension ext) { public SplitStreetEdge splitDestructively(SplitterVertex v) { SplitLineString geoms = GeometryUtils.splitGeometryAtPoint(getGeometry(), v.getCoordinate()); + // Instance where props are copied StreetEdgeBuilder seb1 = new StreetEdgeBuilder<>() .withFromVertex((StreetVertex) fromv) .withToVertex(v) .withGeometry(geoms.beginning()) .withName(name) + .withProfileKey(profileKey) .withPermission(permission) .withBack(isBack()); + // Instance where props are copied StreetEdgeBuilder seb2 = new StreetEdgeBuilder<>() .withFromVertex(v) .withToVertex((StreetVertex) tov) .withGeometry(geoms.ending()) .withName(name) + .withProfileKey(profileKey) .withPermission(permission) .withBack(isBack()); @@ -684,6 +706,16 @@ public SplitStreetEdge splitDestructively(SplitterVertex v) { seb1.withMilliMeterLength(l1); seb2.withMilliMeterLength(l2); + if (hasProfileCost()) { + float ratio1 = (float) l1 / length_mm; + float ratio2 = (float) l2 / length_mm; + seb1.withProfileCosts(MobilityProfileRouting.getProRatedProfileCosts(profileCost, ratio1)); + seb2.withProfileCosts(MobilityProfileRouting.getProRatedProfileCosts(profileCost, ratio2)); + + seb1.withName(String.format("%s split r%4.3f l%4.3f", name, ratio1, l1 / 1000.0)); + seb2.withName(String.format("%s split r%4.3f l%4.3f", name, ratio2, l2 / 1000.0)); + } + copyPropertiesToSplitEdge(seb1, 0, l1 / 1000.0); copyPropertiesToSplitEdge(seb2, l1 / 1000.0, getDistanceMeters()); @@ -698,6 +730,10 @@ public SplitStreetEdge splitDestructively(SplitterVertex v) { return splitEdges; } + public boolean hasProfileCost() { + return profileCost != null && !profileCost.isEmpty(); + } + /** Split this street edge and return the resulting street edges. The original edge is kept. */ public SplitStreetEdge splitNonDestructively( SplitterVertex v, @@ -709,27 +745,58 @@ public SplitStreetEdge splitNonDestructively( StreetEdge e1 = null; StreetEdge e2 = null; + // TODO: refactor + // we have this code implemented in both directions, because splits are fudged half a millimeter + // when the length of this is odd. We want to make sure the lengths of the split streets end up + // exactly the same as their backStreets so that if they are split again the error does not accumulate + // and so that the order in which they are split does not matter. + int l1 = defaultMillimeterLength(geoms.beginning()); + int l2 = defaultMillimeterLength(geoms.ending()); + if (!isBack()) { + // cast before the divide so that the sum is promoted + double frac = (double) l1 / (l1 + l2); + l1 = (int) (length_mm * frac); + l2 = length_mm - l1; + } else { + // cast before the divide so that the sum is promoted + double frac = (double) l2 / (l1 + l2); + l2 = (int) (length_mm * frac); + l1 = length_mm - l2; + } + if (direction == LinkingDirection.OUTGOING || direction == LinkingDirection.BOTH_WAYS) { + // Instance where props are copied var seb1 = new TemporaryPartialStreetEdgeBuilder() .withParentEdge(this) .withFromVertex((StreetVertex) fromv) .withToVertex(v) .withGeometry(geoms.beginning()) .withName(name) + .withProfileKey(profileKey) .withBack(isBack()); + if (hasProfileCost()) { + float ratio = (float) l1 / length_mm; + seb1.withProfileCosts(MobilityProfileRouting.getProRatedProfileCosts(profileCost, ratio)); + } copyPropertiesToSplitEdge(seb1, 0, defaultMillimeterLength(geoms.beginning()) / 1000.0); e1 = seb1.buildAndConnect(); copyRentalRestrictionsToSplitEdge(e1); tempEdges.addEdge(e1); } if (direction == LinkingDirection.INCOMING || direction == LinkingDirection.BOTH_WAYS) { + // Instance where props are copied var seb2 = new TemporaryPartialStreetEdgeBuilder() .withParentEdge(this) .withFromVertex(v) .withToVertex((StreetVertex) tov) .withGeometry(geoms.ending()) .withName(name) + .withProfileKey(profileKey) .withBack(isBack()); + if (hasProfileCost()) { + float ratio = (float) l2 / length_mm; + seb2.withProfileCosts(MobilityProfileRouting.getProRatedProfileCosts(profileCost, ratio)); + } copyPropertiesToSplitEdge( seb2, getDistanceMeters() - defaultMillimeterLength(geoms.ending()) / 1000.0, @@ -770,12 +837,14 @@ public Optional createPartialEdge(StreetVertex from, StreetVertex to) { double lengthRatio = partial.getLength() / parent.getLength(); double length = getDistanceMeters() * lengthRatio; + // Instance where props are copied var tpseb = new TemporaryPartialStreetEdgeBuilder() .withParentEdge(this) .withFromVertex(from) .withToVertex(to) .withGeometry(partial) .withName(getName()) + .withProfileKey(profileKey) .withMeterLength(length); copyPropertiesToSplitEdge(tpseb, start, start + length); TemporaryPartialStreetEdge se = tpseb.buildAndConnect(); @@ -1098,7 +1167,8 @@ private StateEditor doTraverse(State s0, TraverseMode traverseMode, boolean walk traverseMode, speed, walkingBike, - s0.getRequest().wheelchair() + s0.getRequest().wheelchair(), + s0.getRequest().mobilityProfile() ); default -> otherTraversalCosts(preferences, traverseMode, walkingBike, speed); }; @@ -1254,7 +1324,8 @@ private TraversalCosts walkingTraversalCosts( TraverseMode traverseMode, double speed, boolean walkingBike, - boolean wheelchair + boolean wheelchair, + MobilityProfile mobilityProfile ) { double time, weight; if (wheelchair) { @@ -1295,6 +1366,25 @@ private TraversalCosts walkingTraversalCosts( ); } + // G-MAP-specific: Tabulated weights for known paths are provided through profileCost + // (assuming a pre-determined travel speed for each profile) + // and the travel speed for that profile is used to overwrite the time calculated above. + if (mobilityProfile != null) { + if (hasProfileCost()) { + var defaultTravelHours = MobilityProfileRouting.computeTravelHours( + getEffectiveWalkDistance(), + mobilityProfile + ); + time = defaultTravelHours * 3600; + // Impedance of the path is in seconds, so it already matches other OTP weights + // for compatibility with street/transit transitions. + weight = profileCost.getOrDefault(mobilityProfile, (float) weight); + } else { + // For non-tabulated ways, use the calculated travel time above but assign a high weight. + weight = DEFAULT_LARGE_COST * 10.0; + } + } + return new TraversalCosts(time, weight); } diff --git a/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdgeBuilder.java b/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdgeBuilder.java index 99a02205eb6..244ae59861e 100644 --- a/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdgeBuilder.java +++ b/application/src/main/java/org/opentripplanner/street/model/edge/StreetEdgeBuilder.java @@ -11,7 +11,10 @@ import static org.opentripplanner.street.model.edge.StreetEdge.WALK_NOTHRUTRAFFIC; import static org.opentripplanner.street.model.edge.StreetEdge.WHEELCHAIR_ACCESSIBLE_FLAG_INDEX; +import java.util.EnumMap; +import java.util.Map; import org.locationtech.jts.geom.LineString; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.street.model.StreetTraversalPermission; @@ -38,6 +41,8 @@ public class StreetEdgeBuilder> { private float bicycleSafetyFactor; private short flags; private StreetElevationExtension streetElevationExtension; + private Map profileCosts = new EnumMap<>(MobilityProfile.class); + private String profileKey; public StreetEdgeBuilder() { this.defaultLength = true; @@ -59,6 +64,8 @@ public StreetEdgeBuilder(StreetEdge original) { this.walkSafetyFactor = original.getWalkSafetyFactor(); this.bicycleSafetyFactor = original.getBicycleSafetyFactor(); this.flags = original.getFlags(); + this.profileCosts = original.profileCost; + this.profileKey = original.profileKey; } public StreetEdge buildAndConnect() { @@ -248,6 +255,24 @@ public StreetElevationExtension streetElevationExtension() { return streetElevationExtension; } + public B withProfileCosts(Map costs) { + this.profileCosts = costs; + return instance(); + } + + public Map profileCosts() { + return profileCosts; + } + + public B withProfileKey(String key) { + this.profileKey = key; + return instance(); + } + + public String profileKey() { + return profileKey; + } + @SuppressWarnings("unchecked") final B instance() { return (B) this; diff --git a/application/src/main/java/org/opentripplanner/street/model/edge/TemporaryPartialStreetEdge.java b/application/src/main/java/org/opentripplanner/street/model/edge/TemporaryPartialStreetEdge.java index 31792614cde..d8f70b4eaef 100644 --- a/application/src/main/java/org/opentripplanner/street/model/edge/TemporaryPartialStreetEdge.java +++ b/application/src/main/java/org/opentripplanner/street/model/edge/TemporaryPartialStreetEdge.java @@ -2,6 +2,8 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.LineString; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileRouting; +import org.opentripplanner.framework.i18n.LocalizedString; public final class TemporaryPartialStreetEdge extends StreetEdge implements TemporaryEdge { @@ -29,6 +31,21 @@ public final class TemporaryPartialStreetEdge extends StreetEdge implements Temp .addRentalRestriction(builder.parentEdge().getToVertex().rentalRestrictions()); this.parentEdge = builder.parentEdge(); this.geometry = super.getGeometry(); + this.profileCost = MobilityProfileRouting.getProRatedProfileCosts(this); + this.profileKey = builder.profileKey(); + if (this.hasProfileCost()) { + float ratio = (float) (getDistanceMeters() / getParentEdge().getDistanceMeters()); + this.setName( + new LocalizedString( + String.format( + "%s tmp r%4.3f l%4.3f", + builder.parentEdge().getName(), + ratio, + getDistanceMeters() + ) + ) + ); + } } /** diff --git a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java index c93ea598256..05ad8e77937 100644 --- a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java +++ b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java @@ -6,6 +6,7 @@ import org.locationtech.jts.geom.Envelope; import org.opentripplanner.astar.spi.AStarRequest; import org.opentripplanner.ext.dataoverlay.routing.DataOverlayContext; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; import org.opentripplanner.framework.geometry.SphericalDistanceLibrary; import org.opentripplanner.model.GenericLocation; import org.opentripplanner.routing.api.request.RouteRequest; @@ -48,6 +49,8 @@ public class StreetSearchRequest implements AStarRequest { private DataOverlayContext dataOverlayContext; + private MobilityProfile mobilityProfile; + /** * Constructor only used for creating a default instance. */ @@ -73,6 +76,7 @@ private StreetSearchRequest() { this.fromEnvelope = createEnvelope(from); this.to = builder.to; this.toEnvelope = createEnvelope(to); + this.mobilityProfile = builder.mobilityProfile; } public static StreetSearchRequestBuilder of() { @@ -159,6 +163,10 @@ public boolean isCloseToStartOrEnd(Vertex vertex) { ); } + public MobilityProfile mobilityProfile() { + return mobilityProfile; + } + @Nullable private static Envelope createEnvelope(GenericLocation location) { if (location == null) { diff --git a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestBuilder.java b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestBuilder.java index fd2eff30e7e..06927fdb384 100644 --- a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestBuilder.java +++ b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestBuilder.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.function.Consumer; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; import org.opentripplanner.model.GenericLocation; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.routing.api.request.preference.RoutingPreferences; @@ -16,6 +17,8 @@ public class StreetSearchRequestBuilder { GenericLocation from; GenericLocation to; + MobilityProfile mobilityProfile; + StreetSearchRequestBuilder(StreetSearchRequest original) { this.startTime = original.startTime(); this.mode = original.mode(); @@ -24,6 +27,7 @@ public class StreetSearchRequestBuilder { this.wheelchair = original.wheelchair(); this.from = original.from(); this.to = original.to(); + this.mobilityProfile = original.mobilityProfile(); } public StreetSearchRequestBuilder withStartTime(Instant startTime) { @@ -55,6 +59,11 @@ public StreetSearchRequestBuilder withWheelchair(boolean wheelchair) { return this; } + public StreetSearchRequestBuilder withMobilityProfile(MobilityProfile profile) { + this.mobilityProfile = profile; + return this; + } + public StreetSearchRequestBuilder withFrom(GenericLocation from) { this.from = from; return this; diff --git a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestMapper.java b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestMapper.java index b4193e709e3..c682f9c94a0 100644 --- a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestMapper.java +++ b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequestMapper.java @@ -12,7 +12,8 @@ public static StreetSearchRequestBuilder map(RouteRequest opt) { .withPreferences(opt.preferences()) .withWheelchair(opt.wheelchair()) .withFrom(opt.from()) - .withTo(opt.to()); + .withTo(opt.to()) + .withMobilityProfile(opt.mobilityProfile()); } public static StreetSearchRequestBuilder mapToTransferRequest(RouteRequest opt) { diff --git a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java index 312fa3e2fbc..a57ead67d01 100644 --- a/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java +++ b/application/src/main/java/org/opentripplanner/updater/UpdatersParameters.java @@ -5,6 +5,7 @@ import org.opentripplanner.ext.siri.updater.azure.SiriAzureSXUpdaterParameters; import org.opentripplanner.ext.vehiclerentalservicedirectory.api.VehicleRentalServiceDirectoryFetcherParameters; import org.opentripplanner.updater.alert.GtfsRealtimeAlertsUpdaterParameters; +import org.opentripplanner.updater.impedance.OsmImpedanceUpdaterParameters; import org.opentripplanner.updater.siri.updater.SiriETUpdaterParameters; import org.opentripplanner.updater.siri.updater.SiriSXUpdaterParameters; import org.opentripplanner.updater.siri.updater.google.SiriETGooglePubsubUpdaterParameters; @@ -40,4 +41,6 @@ public interface UpdatersParameters { List getSiriAzureETUpdaterParameters(); List getSiriAzureSXUpdaterParameters(); + + List getOsmImpedanceUpdaterParameters(); } diff --git a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java index feea541741c..4f4996dda1d 100644 --- a/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java +++ b/application/src/main/java/org/opentripplanner/updater/configure/UpdaterConfigurator.java @@ -18,6 +18,7 @@ import org.opentripplanner.updater.GraphUpdaterManager; import org.opentripplanner.updater.UpdatersParameters; import org.opentripplanner.updater.alert.GtfsRealtimeAlertsUpdater; +import org.opentripplanner.updater.impedance.OsmImpedanceUpdater; import org.opentripplanner.updater.siri.SiriTimetableSnapshotSource; import org.opentripplanner.updater.siri.updater.SiriETUpdater; import org.opentripplanner.updater.siri.updater.SiriSXUpdater; @@ -222,6 +223,10 @@ private List createUpdatersFromConfig() { updaters.add(new SiriAzureSXUpdater(configItem, timetableRepository)); } + for (var configItem : updatersParameters.getOsmImpedanceUpdaterParameters()) { + updaters.add(new OsmImpedanceUpdater(configItem)); + } + return updaters; } diff --git a/application/src/main/java/org/opentripplanner/updater/impedance/ImpedanceUpdateHandler.java b/application/src/main/java/org/opentripplanner/updater/impedance/ImpedanceUpdateHandler.java new file mode 100644 index 00000000000..5167368c1f2 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/impedance/ImpedanceUpdateHandler.java @@ -0,0 +1,64 @@ +package org.opentripplanner.updater.impedance; + +import java.util.Map; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileData; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileRouting; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.street.model.edge.StreetEdge; +import org.opentripplanner.street.model.vertex.OsmVertex; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Processes updated impedances to the graph. + */ +public class ImpedanceUpdateHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ImpedanceUpdateHandler.class); + + public void update(Graph graph, Map impedances) { + long start = System.currentTimeMillis(); + long count = 0; + + // Some basic G-MAP stats: + // - total edges: ~902k + // - walkable edges: ~92k + // - First-time impedance load: ~29k entries + + for (StreetEdge se : graph.getStreetEdges()) { + var impedance = impedances.get(se.profileKey); + if (impedance != null) { + String symbol = "★"; + Map proRatedCosts = impedance.costs(); + + // Create pro-rated impedances for split edges with intermediate OsmVertex or SplitterVertex. + long fromNodeId = 0; + if (se.getFromVertex() instanceof OsmVertex osmFrom) fromNodeId = osmFrom.nodeId; + long toNodeId = 0; + if (se.getToVertex() instanceof OsmVertex osmTo) toNodeId = osmTo.nodeId; + + if (fromNodeId != impedance.fromNode() || toNodeId != impedance.toNode()) { + double ratio = se.getDistanceMeters() / impedance.lengthInMeters(); + proRatedCosts = + MobilityProfileRouting.getProRatedProfileCosts(impedance.costs(), (float) ratio); + symbol = "☆"; + } + + // Update profile costs for this StreetEdge object if an impedance entry was found. + se.profileCost = proRatedCosts; + count++; + + // Amend the name with an indication that impedances were applied + se.setName(I18NString.of(String.format("%s%s", se.getName(), symbol))); + } + } + + LOG.info( + "{} new impedance entries imported into graph in {} seconds.", + count, + (System.currentTimeMillis() - start) / 1000 + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdater.java b/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdater.java new file mode 100644 index 00000000000..ed4a7e07f2a --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdater.java @@ -0,0 +1,133 @@ +package org.opentripplanner.updater.impedance; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileData; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileParser; +import org.opentripplanner.framework.io.OtpHttpClient; +import org.opentripplanner.framework.io.OtpHttpClientFactory; +import org.opentripplanner.updater.spi.HttpHeaders; +import org.opentripplanner.updater.spi.PollingGraphUpdater; +import org.opentripplanner.updater.spi.WriteToGraphCallback; +import org.opentripplanner.utils.tostring.ToStringBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * G-MAP OSM impedance updater + */ +public class OsmImpedanceUpdater extends PollingGraphUpdater { + + private static final Logger LOG = LoggerFactory.getLogger(OsmImpedanceUpdater.class); + + private final String url; + private final ImpedanceUpdateHandler updateHandler; + private final HttpHeaders headers; + private WriteToGraphCallback saveResultOnGraph; + private Map previousImpedances = Map.of(); + + public OsmImpedanceUpdater(OsmImpedanceUpdaterParameters config) { + super(config); + this.url = config.url(); + this.headers = HttpHeaders.of().add(config.headers()).build(); + + this.updateHandler = new ImpedanceUpdateHandler(); + LOG.info("Creating impedance updater running every {}: {}", pollingPeriod(), url); + } + + @Override + public String toString() { + return ToStringBuilder.of(this.getClass()).addStr("url", url).toString(); + } + + @Override + public void setup(WriteToGraphCallback writeToGraphCallback) { + this.saveResultOnGraph = writeToGraphCallback; + } + + @Override + protected void runPolling() { + LOG.info("Fetching mobility impedances..."); + try (OtpHttpClientFactory clientFactory = new OtpHttpClientFactory()) { + OtpHttpClient otpHttpClient = clientFactory.create(LOG); + final Map impedances = otpHttpClient.getAndMap( + URI.create(url), + this.headers.asMap(), + MobilityProfileParser::parseData + ); + LOG.info("Fetched mobility impedances."); + + // Filter out which rows have been updated since previous poll. + Map changedImpedances = getChangedImpedances( + impedances, + previousImpedances + ); + previousImpedances = impedances; + + // Handle update in graph writer runnable + if (!changedImpedances.isEmpty()) { + saveResultOnGraph.execute(updateContext -> + updateHandler.update(updateContext.graph(), changedImpedances) + ); + } else { + LOG.error("Impedance data unchanged (not updating graph)."); + } + } catch (Exception e) { + // Download errors, including timeouts, will be caught here. + LOG.error("Error parsing impedance data from {}", url, e); + } + } + + /** + * Indicates whether two profile data have the same impedances. + */ + public static boolean areSameImpedances( + @Nonnull MobilityProfileData profileData1, + @Nonnull MobilityProfileData profileData2 + ) { + return profileData1.equals(profileData2); + } + + /** + * Performs a diff with existing entries, to avoid updating unchanged portions of the graph. + */ + public static Map getChangedImpedances( + @Nonnull Map newImpedances, + @Nonnull Map existingImpedances + ) { + Map result = new HashMap<>(); + + for (var entry : newImpedances.entrySet()) { + String key = entry.getKey(); + + // Include entries that exist in both sets and that were modified in newImpedances. + // Include entries introduced in newImpedances not in existingImpedances. + var existingImpedance = existingImpedances.get(key); + if (existingImpedance == null || !areSameImpedances(entry.getValue(), existingImpedance)) { + result.put(key, entry.getValue()); + } + } + + for (var entry : existingImpedances.entrySet()) { + String key = entry.getKey(); + + // Include entries that were removed in newImpedances, but mark them as empty map. + if (!newImpedances.containsKey(key)) { + MobilityProfileData removedData = entry.getValue(); + result.put( + key, + new MobilityProfileData( + removedData.lengthInMeters(), + removedData.fromNode(), + removedData.toNode(), + Map.of() + ) + ); + } + } + + return result; + } +} diff --git a/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterParameters.java b/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterParameters.java new file mode 100644 index 00000000000..e5efb4b9910 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterParameters.java @@ -0,0 +1,13 @@ +package org.opentripplanner.updater.impedance; + +import java.time.Duration; +import org.opentripplanner.updater.spi.HttpHeaders; +import org.opentripplanner.updater.spi.PollingGraphUpdaterParameters; + +public record OsmImpedanceUpdaterParameters( + String configRef, + String url, + Duration frequency, + HttpHeaders headers +) + implements PollingGraphUpdaterParameters {} diff --git a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls index 83a08ca00f4..e3595a20ca8 100644 --- a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls +++ b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls @@ -1383,6 +1383,8 @@ type QueryType { between transit stops. Default value: 120 """ minTransferTime: Int, + "Mobility profile for obtaining itineraries according to one's mobility limitations." + mobilityProfile: String, "The weight multipliers for transit modes. WALK, BICYCLE, CAR, TRANSIT and LEG_SWITCH are not included." modeWeight: InputModeWeight, "Penalty (in seconds) for using a non-preferred transfer. Default value: 180" diff --git a/application/src/test/java/org/opentripplanner/graph_builder/module/osm/OsmModuleTest.java b/application/src/test/java/org/opentripplanner/graph_builder/module/osm/OsmModuleTest.java index 0b3d762ed6e..12c2f59884d 100644 --- a/application/src/test/java/org/opentripplanner/graph_builder/module/osm/OsmModuleTest.java +++ b/application/src/test/java/org/opentripplanner/graph_builder/module/osm/OsmModuleTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.opentripplanner.osm.wayproperty.WayPropertiesBuilder.withModes; @@ -379,5 +380,78 @@ private void testBuildingAreas(boolean skipVisibility) { } } + @Test + void testGetIntersectingStreet() { + OsmWay way = new OsmWay(); + way.getNodeRefs().add(new long[] { 10001, 10002, 10003, 10004 }); + OsmWay street = new OsmWay(); + street.setId(50001); + street.getNodeRefs().add(new long[] { 20001, 20002, 20003, 10002, 20004, 20005 }); + OsmWay otherStreet = new OsmWay(); + otherStreet.setId(50002); + otherStreet.getNodeRefs().add(new long[] { 30001, 30002, 30003, 30004, 30005 }); + + var intersectingStreet = OsmModule.getIntersectingStreet(way, List.of(street, otherStreet)); + assertTrue(intersectingStreet.isPresent()); + assertEquals(50001, intersectingStreet.get().getId()); + + var intersectingStreet2 = OsmModule.getIntersectingStreet(way, List.of(otherStreet)); + assertFalse(intersectingStreet2.isPresent()); + } + + @Test + void testGetStreets() { + OsmWay footway = new OsmWay(); + footway.addTag("highway", "footway"); + OsmWay street = new OsmWay(); + street.addTag("highway", "primary"); + street.addTag("name", "3rd Street"); + OsmWay serviceRoad = new OsmWay(); + serviceRoad.addTag("highway", "service"); + OsmWay otherStreet = new OsmWay(); + otherStreet.addTag("highway", "trunk"); + otherStreet.addTag("oneway", "true"); + OsmWay blankPath = new OsmWay(); + + List streets = OsmModule.getStreets( + List.of(street, footway, serviceRoad, otherStreet, blankPath) + ); + assertEquals(3, streets.size()); + assertTrue(streets.containsAll(List.of(serviceRoad, street, otherStreet))); + } + + @Test + void testIsContinuationOfMarkedCrossing() { + OsmWay footway = new OsmWay(); + footway.addTag("highway", "footway"); + footway.getNodeRefs().add(new long[] { 10001, 10000, 10002 }); + + OsmWay crossing = new OsmWay(); + crossing.getNodeRefs().add(new long[] { 10002, 10003, 10004 }); + crossing.addTag("highway", "footway"); + crossing.addTag("footway", "crossing"); + crossing.addTag("crossing", "marked"); + + OsmWay otherCrossing = new OsmWay(); + otherCrossing.getNodeRefs().add(new long[] { 10003, 10001, 10004 }); + otherCrossing.addTag("highway", "footway"); + otherCrossing.addTag("footway", "crossing"); + otherCrossing.addTag("crossing", "unmarked"); + + // If more than one footway are adjacent to the crossing, there is no continuation. + OsmWay otherFootway = new OsmWay(); + otherFootway.addTag("highway", "footway"); + otherFootway.getNodeRefs().add(new long[] { 10002, 10006 }); + + assertEquals( + crossing, + OsmModule.getContinuedMarkedCrossing(footway, List.of(footway, crossing, otherCrossing)) + ); + assertNull(OsmModule.getContinuedMarkedCrossing(footway, List.of(footway, otherCrossing))); + assertNull( + OsmModule.getContinuedMarkedCrossing(footway, List.of(footway, crossing, otherFootway)) + ); + } + private record VertexPair(Vertex v0, Vertex v1) {} } diff --git a/application/src/test/java/org/opentripplanner/osm/model/OsmWayTest.java b/application/src/test/java/org/opentripplanner/osm/model/OsmWayTest.java index 9ac9457a9ec..b1fd1dcf4ea 100644 --- a/application/src/test/java/org/opentripplanner/osm/model/OsmWayTest.java +++ b/application/src/test/java/org/opentripplanner/osm/model/OsmWayTest.java @@ -1,12 +1,17 @@ package org.opentripplanner.osm.model; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.opentripplanner.osm.wayproperty.specifier.WayTestData; -public class OsmWayTest { +class OsmWayTest { @Test void testIsBicycleDismountForced() { @@ -176,6 +181,84 @@ void escalator() { assertFalse(escalator.isEscalator()); } + private static OsmWay createGenericHighway() { + var osm = new OsmWay(); + osm.addTag("highway", "primary"); + return osm; + } + + private static OsmWay createGenericFootway() { + var osm = new OsmWay(); + osm.addTag("highway", "footway"); + return osm; + } + + private static OsmWay createFootway( + String footwayValue, + String crossingTag, + String crossingValue + ) { + var osm = createGenericFootway(); + osm.addTag("footway", footwayValue); + osm.addTag(crossingTag, crossingValue); + return osm; + } + + @Test + void footway() { + assertFalse(createGenericHighway().isFootway()); + assertTrue(createGenericFootway().isFootway()); + } + + @Test + void serviceRoad() { + assertFalse(createGenericHighway().isServiceRoad()); + + var osm2 = new OsmWay(); + osm2.addTag("highway", "service"); + assertTrue(osm2.isServiceRoad()); + } + + @ParameterizedTest + @MethodSource("createCrossingCases") + void markedCrossing(OsmWay way, boolean result) { + assertEquals(result, way.isMarkedCrossing()); + } + + static Stream createCrossingCases() { + return Stream.of( + Arguments.of(createGenericFootway(), false), + Arguments.of(createFootway("whatever", "unused", "unused"), false), + Arguments.of(createFootway("crossing", "crossing", "marked"), true), + Arguments.of(createFootway("crossing", "crossing", "other"), false), + Arguments.of(createFootway("crossing", "crossing:markings", "yes"), true), + Arguments.of(createFootway("crossing", "crossing:markings", "marking-details"), true), + Arguments.of(createFootway("crossing", "crossing:markings", null), false), + Arguments.of(createFootway("crossing", "crossing:markings", "no"), false) + ); + } + + private static OsmWay createPlatform(String kind) { + var osm = new OsmWay(); + osm.addTag(kind, "platform"); + return osm; + } + + @ParameterizedTest + @MethodSource("createTransitPlatformCases") + void transitPlatform(OsmWay way, boolean result) { + assertEquals(result, way.isTransitPlatform()); + } + + static Stream createTransitPlatformCases() { + return Stream.of( + Arguments.of(createGenericHighway(), false), + Arguments.of(createGenericFootway(), false), + Arguments.of(createPlatform("railway"), true), + Arguments.of(createPlatform("public_transport"), true) + ); + } + private OsmWay getClosedPolygon() { var way = new OsmWay(); way.addNodeRef(1); diff --git a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java index 6e41f6ddf2b..3f95a6f0bf1 100644 --- a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java +++ b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java @@ -5,12 +5,20 @@ import static org.opentripplanner.model.plan.RelativeDirection.ENTER_STATION; import static org.opentripplanner.model.plan.RelativeDirection.EXIT_STATION; import static org.opentripplanner.model.plan.RelativeDirection.FOLLOW_SIGNS; +import static org.opentripplanner.routing.algorithm.mapping.StatesToWalkStepsMapper.isOnSameStreet; +import com.beust.jcommander.internal.Lists; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.opentripplanner.astar.model.GraphPath; +import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.model.plan.RelativeDirection; import org.opentripplanner.model.plan.WalkStep; +import org.opentripplanner.model.plan.WalkStepBuilder; import org.opentripplanner.routing.services.notes.StreetNotesService; import org.opentripplanner.street.search.state.TestStateBuilder; @@ -74,4 +82,36 @@ private static List buildWalkSteps(TestStateBuilder builder) { var mapper = new StatesToWalkStepsMapper(path.states, null, new StreetNotesService(), 0); return mapper.generateWalkSteps(); } + + @ParameterizedTest + @MethodSource("createIsOnSameStreetCases") + void testIsOnSameStreet(List streets, boolean expected, String message) { + List steps = streets + .stream() + .map(s -> + s != null ? WalkStep.builder().withDirectionText(I18NString.of(s)) : WalkStep.builder() + ) + .toList(); + + int lastIndex = steps.size() - 1; + WalkStepBuilder threeBack = steps.get(lastIndex - 2); + WalkStepBuilder twoBack = steps.get(lastIndex - 1); + WalkStepBuilder lastStep = steps.get(lastIndex); + + assertEquals(expected, isOnSameStreet(lastStep, twoBack, threeBack), message); + } + + static Stream createIsOnSameStreetCases() { + return Stream.of( + Arguments.of(List.of("Street1", "Street2", "Street3"), false, "Is not a zig-zag"), + Arguments.of(List.of("Street1", "Street2", "Street1"), true, "Is a zig-zag"), + Arguments.of(List.of("Street1", "crossing over Street2", "Street1"), false, "Is a crossing"), + Arguments.of( + List.of("crossing over turn lane", "Street1", "crossing over turn lane"), + false, + "Is many crossings" + ), + Arguments.of(Lists.newArrayList(null, null, null), false, "Is not a zig-zag") + ); + } } diff --git a/application/src/test/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterTest.java b/application/src/test/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterTest.java new file mode 100644 index 00000000000..b8ec6b192ac --- /dev/null +++ b/application/src/test/java/org/opentripplanner/updater/impedance/OsmImpedanceUpdaterTest.java @@ -0,0 +1,95 @@ +package org.opentripplanner.updater.impedance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.opentripplanner.ext.mobilityprofile.MobilityProfile; +import org.opentripplanner.ext.mobilityprofile.MobilityProfileData; + +class OsmImpedanceUpdaterTest { + + private MobilityProfileData profileData( + long from, + long to, + float impNone, + float impWChairE, + float impBlind + ) { + return new MobilityProfileData( + 10, + from, + to, + Map.of( + MobilityProfile.NONE, + impNone, + MobilityProfile.WCHAIRE, + impWChairE, + MobilityProfile.BLIND, + impBlind + ) + ); + } + + @Test + void areSameImpedances() { + MobilityProfileData profileData1 = profileData(1001, 1002, 1.2F, 5.0F, 3.4F); + MobilityProfileData profileData2 = profileData(1002, 1003, 1.6F, 5.0F, 3.4F); + MobilityProfileData profileData3 = profileData(1001, 1002, 1.2F, 5.0F, 3.4F); + MobilityProfileData profileData4 = profileData(1003, 1004, 1.2F, 5.0F, 3.4F); + + assertTrue(OsmImpedanceUpdater.areSameImpedances(profileData1, profileData1)); + assertFalse(OsmImpedanceUpdater.areSameImpedances(profileData1, profileData2)); + assertTrue(OsmImpedanceUpdater.areSameImpedances(profileData1, profileData3)); + assertFalse(OsmImpedanceUpdater.areSameImpedances(profileData1, profileData4)); + } + + /** + * Contains a simple case for filtering impedances. + * Impedance data are simplified to three profiles: "None", "WChairE", and "Blind". + * OSM attributes such as way ids and corresponding geometry are assumed identical. + */ + @Test + void getChangedImpedances() { + Map newImpedances = Map.of( + "Street1", + profileData(1001, 1002, 1.2F, 5.0F, 3.4F), + "Street2", + profileData(1002, 1003, 2.1F, 7.0F, 4.4F), + "Street3", + profileData(1003, 1004, 0.6F, 1.1F, 1.5F), + "Street4", + profileData(1004, 1005, 1.4F, 1.6F, 1.8F), + "Street6", + profileData(1006, 1007, 1.2F, 5.0F, 3.4F) + ); + Map oldImpedances = Map.of( + "Street1", + profileData(1001, 1002, 1.2F, 5.0F, 3.4F), + "Street2", + profileData(1002, 1003, 2.1F, 3.0F, 4.4F), + "Street3", + profileData(1003, 1004, 0.6F, 1.1F, 1.5F), + "Street4", + profileData(1004, 1005, 1.4F, 1.6F, 1.8F), + "Street5", + profileData(1005, 1006, 2.3F, 2.5F, 3.0F) + ); + + Map changedImpedances = Map.of( + "Street6", + profileData(1006, 1007, 1.2F, 5.0F, 3.4F), + "Street5", + new MobilityProfileData(10, 1005, 1006, Map.of()), + "Street2", + profileData(1002, 1003, 2.1F, 7.0F, 4.4F) + ); + + assertEquals( + changedImpedances, + OsmImpedanceUpdater.getChangedImpedances(newImpedances, oldImpedances) + ); + } +} diff --git a/doc/user/BuildConfiguration.md b/doc/user/BuildConfiguration.md index 99e98066e73..639f961c90c 100644 --- a/doc/user/BuildConfiguration.md +++ b/doc/user/BuildConfiguration.md @@ -37,6 +37,7 @@ Sections follow that describe particular settings in more depth. | [osmCacheDataInMem](#osmCacheDataInMem) | `boolean` | If OSM data should be cached in memory during processing. | *Optional* | `false` | 2.0 | | [osmNaming](#osmNaming) | `enum` | A custom OSM namer to use. | *Optional* | `"default"` | 1.5 | | platformEntriesLinking | `boolean` | Link unconnected entries to public transport platforms. | *Optional* | `false` | 2.0 | +| [preventWalkingOnRoads](#preventWalkingOnRoads) | `boolean` | Determines whether to prevent pedestrian routing on roads or not. | *Optional* | `false` | 2.5 | | [readCachedElevations](#readCachedElevations) | `boolean` | Whether to read cached elevation data. | *Optional* | `true` | 2.0 | | staticBikeParkAndRide | `boolean` | Whether we should create bike P+R stations from OSM data. | *Optional* | `false` | 1.5 | | staticParkAndRide | `boolean` | Whether we should create car P+R stations from OSM data. | *Optional* | `true` | 1.5 | @@ -531,6 +532,15 @@ data, and to `false` to read the stream from the source each time. A custom OSM namer to use. +

preventWalkingOnRoads

+ +**Since version:** `2.5` ∙ **Type:** `boolean` ∙ **Cardinality:** `Optional` ∙ **Default value:** `false` +**Path:** / + +Determines whether to prevent pedestrian routing on roads or not. + +True to prevent pedestrian routing on roads. +

readCachedElevations

**Since version:** `2.0` ∙ **Type:** `boolean` ∙ **Cardinality:** `Optional` ∙ **Default value:** `true`