Repository: opentripplanner/OpenTripPlanner (2025-07-30T16:32:20Z)

Issue:
Set bicycle speed for ways similar to car speed
**Goal / high level use-case**
We would like to return realistic bike speeds on different ways. For example, we don't want suggesting cycling 40 km/h on a living street even if the user can cycle at 40 km/h along a flat trunk road.

**Describe the solution you'd like**
Use car speed to limit bike speed on the ways

**Describe alternatives you've considered**
Have a separate field for bike speed in addition to car speed





Discussion:

Patch:
diff --git a/application/src/main/java/org/opentripplanner/osm/tagmapping/FinlandMapper.java b/application/src/main/java/org/opentripplanner/osm/tagmapping/FinlandMapper.java
index 98a546e3bc3..97328534cc9 100644
--- a/application/src/main/java/org/opentripplanner/osm/tagmapping/FinlandMapper.java
+++ b/application/src/main/java/org/opentripplanner/osm/tagmapping/FinlandMapper.java
@@ -217,6 +217,7 @@ else if (speedLimit <= 16.65f) {
     props.setCarSpeed("highway=living_street", 5.55f);
     // ~= 16 kph
     props.setCarSpeed("highway=track", 4.5f);
+    props.setCarSpeed("highway=pedestrian", 4.5f);
 
     super.populateProperties(props);
   }
diff --git a/application/src/main/java/org/opentripplanner/osm/tagmapping/NorwayMapper.java b/application/src/main/java/org/opentripplanner/osm/tagmapping/NorwayMapper.java
index f705439f24e..3bd33ebdbf4 100644
--- a/application/src/main/java/org/opentripplanner/osm/tagmapping/NorwayMapper.java
+++ b/application/src/main/java/org/opentripplanner/osm/tagmapping/NorwayMapper.java
@@ -618,6 +618,7 @@ else if (speedLimit >= 11.1f) {
 
     props.setCarSpeed("highway=living_street", 1.94f); // 7 km/h
     props.setCarSpeed("highway=pedestrian", 1.94f); // 7 km/h
+    props.setCarSpeed("highway=footway", 1.94f); // 7 km/h
 
     props.defaultCarSpeed = 22.22f; // 80 km/h
     props.maxPossibleCarSpeed = 30.56f; // 110 km/h
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 4da067032f4..8b462c68359 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
@@ -212,9 +212,9 @@ public double calculateSpeed(
         case WALK -> walkingBike
           ? preferences.bike().walking().speed()
           : preferences.walk().speed();
-        case BICYCLE -> preferences.bike().speed();
+        case BICYCLE -> Math.min(preferences.bike().speed(), getCyclingSpeedLimit());
         case CAR -> getCarSpeed();
-        case SCOOTER -> preferences.scooter().speed();
+        case SCOOTER -> Math.min(preferences.scooter().speed(), getCyclingSpeedLimit());
         case FLEX -> throw new IllegalArgumentException("getSpeed(): Invalid mode " + traverseMode);
       };
 
@@ -548,6 +548,17 @@ public float getCarSpeed() {
     return carSpeed;
   }
 
+  /**
+   * Gets cycling speed limit which is based on the car speed limit. The effective speed limit can
+   * differ from the actual speed limit if the effective cycling distance has been adjusted due to
+   * elevation changes.
+   */
+  private double getCyclingSpeedLimit() {
+    return hasElevationExtension()
+      ? getCarSpeed() * (elevationExtension.getEffectiveBikeDistance() / getDistanceMeters())
+      : getCarSpeed();
+  }
+
   public boolean isSlopeOverride() {
     return BitSetUtils.get(flags, SLOPEOVERRIDE_FLAG_INDEX);
   }


Test patch:
diff --git a/application/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java b/application/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java
index 8ed56ea56ab..279d306d983 100644
--- a/application/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java
+++ b/application/src/test/java/org/opentripplanner/routing/algorithm/GraphRoutingTest.java
@@ -151,6 +151,16 @@ public StreetEdge street(
       return streetBuilder(from, to, length, permissions).buildAndConnect();
     }
 
+    public StreetEdge street(
+      StreetVertex from,
+      StreetVertex to,
+      int length,
+      StreetTraversalPermission permissions,
+      float carSpeed
+    ) {
+      return streetBuilder(from, to, length, permissions).withCarSpeed(carSpeed).buildAndConnect();
+    }
+
     public List<StreetEdge> street(
       StreetVertex from,
       StreetVertex to,
diff --git a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/ElevationSnapshotTest.snap b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/ElevationSnapshotTest.snap
index 4876d74d476..227e60e8356 100644
--- a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/ElevationSnapshotTest.snap
+++ b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/ElevationSnapshotTest.snap
@@ -2,7 +2,7 @@ org.opentripplanner.routing.algorithm.mapping.ElevationSnapshotTest.directBike=[
   [
     {
       "arrivedAtDestinationWithRentedBicycle" : false,
-      "duration" : 721,
+      "duration" : 723,
       "elevationGained" : 18.9,
       "elevationLost" : 30.64,
       "endTime" : "2009-10-21T23:10:00.000+00:00",
@@ -10,7 +10,7 @@ org.opentripplanner.routing.algorithm.mapping.ElevationSnapshotTest.directBike=[
         "details" : { },
         "fare" : { }
       },
-      "generalizedCost" : 1618,
+      "generalizedCost" : 1636,
       "legs" : [
         {
           "agencyTimeZoneOffset" : -25200000,
@@ -19,13 +19,13 @@ org.opentripplanner.routing.algorithm.mapping.ElevationSnapshotTest.directBike=[
           "distance" : 2636.74,
           "endTime" : "2009-10-21T23:10:00.000+00:00",
           "from" : {
-            "departure" : "2009-10-21T22:57:59.000+00:00",
+            "departure" : "2009-10-21T22:57:57.000+00:00",
             "lat" : 45.52832,
             "lon" : -122.70059,
             "name" : "SW Johnson St. & NW 24th Ave. (P1)",
             "vertexType" : "NORMAL"
           },
-          "generalizedCost" : 1618,
+          "generalizedCost" : 1636,
           "interlineWithPreviousLeg" : false,
           "legElevation" : "0,63.7,1,63.7,11,63.4,21,63.2,31,63.3,41,63.5,51,63.3,61,62.9,71,62.5,81,62.2,91,62.0,101,61.7,111,61.5,121,61.2,131,60.9,141,60.6,151,60.3,160,60.2,240,60.2,1193,60.2,1272,55.5,1589,55.5,1668,40.4,1851,40.4,1920,37.3,1932,37.3,2079,55.5,2504,55.5,2504,NaN,2637,NaN",
           "legGeometry" : {
@@ -37,7 +37,7 @@ org.opentripplanner.routing.algorithm.mapping.ElevationSnapshotTest.directBike=[
           "realTime" : false,
           "rentedBike" : false,
           "route" : "",
-          "startTime" : "2009-10-21T22:57:59.000+00:00",
+          "startTime" : "2009-10-21T22:57:57.000+00:00",
           "steps" : [
             {
               "absoluteDirection" : "NORTH",
@@ -116,14 +116,14 @@ org.opentripplanner.routing.algorithm.mapping.ElevationSnapshotTest.directBike=[
           "walkingBike" : false
         }
       ],
-      "startTime" : "2009-10-21T22:57:59.000+00:00",
+      "startTime" : "2009-10-21T22:57:57.000+00:00",
       "tooSloped" : false,
       "transfers" : 0,
       "transitTime" : 0,
       "waitingTime" : 0,
       "walkDistance" : 2636.74,
       "walkLimitExceeded" : false,
-      "walkTime" : 721
+      "walkTime" : 723
     }
   ]
 ]
diff --git a/application/src/test/java/org/opentripplanner/street/integration/BikeWalkingTest.java b/application/src/test/java/org/opentripplanner/street/integration/BikeWalkingTest.java
index 2a381f1d563..87290140135 100644
--- a/application/src/test/java/org/opentripplanner/street/integration/BikeWalkingTest.java
+++ b/application/src/test/java/org/opentripplanner/street/integration/BikeWalkingTest.java
@@ -319,11 +319,11 @@ public void build() {
           elevator(StreetTraversalPermission.PEDESTRIAN_AND_BICYCLE, D, Q);
 
           biLink(A, S1);
-          AB = street(A, B, 100, StreetTraversalPermission.PEDESTRIAN);
-          BC = street(B, C, 100, StreetTraversalPermission.PEDESTRIAN);
-          CD = street(C, D, 100, StreetTraversalPermission.ALL);
-          DE = street(D, E, 100, StreetTraversalPermission.PEDESTRIAN_AND_BICYCLE);
-          EF = street(E, F, 100, StreetTraversalPermission.PEDESTRIAN_AND_BICYCLE);
+          AB = street(A, B, 100, StreetTraversalPermission.PEDESTRIAN, 40);
+          BC = street(B, C, 100, StreetTraversalPermission.PEDESTRIAN, 40);
+          CD = street(C, D, 100, StreetTraversalPermission.ALL, 40);
+          DE = street(D, E, 100, StreetTraversalPermission.PEDESTRIAN_AND_BICYCLE, 40);
+          EF = street(E, F, 100, StreetTraversalPermission.PEDESTRIAN_AND_BICYCLE, 40);
           biLink(F, E1);
           pathway(E1, S2, 60, 100);
         }
diff --git a/application/src/test/java/org/opentripplanner/street/model/edge/StreetEdgeTest.java b/application/src/test/java/org/opentripplanner/street/model/edge/StreetEdgeTest.java
index 8b89f2ececb..d85b277779c 100644
--- a/application/src/test/java/org/opentripplanner/street/model/edge/StreetEdgeTest.java
+++ b/application/src/test/java/org/opentripplanner/street/model/edge/StreetEdgeTest.java
@@ -12,6 +12,8 @@
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.GeometryFactory;
 import org.locationtech.jts.geom.LineString;
@@ -19,6 +21,7 @@
 import org.opentripplanner.framework.geometry.GeometryUtils;
 import org.opentripplanner.framework.i18n.I18NString;
 import org.opentripplanner.routing.api.request.StreetMode;
+import org.opentripplanner.routing.api.request.preference.RoutingPreferences;
 import org.opentripplanner.routing.core.VehicleRoutingOptimizeType;
 import org.opentripplanner.routing.util.ElevationUtils;
 import org.opentripplanner.routing.util.SlopeCosts;
@@ -394,6 +397,46 @@ void testBikeOptimizeTriangle() {
     assertEquals(expectedWeight, result.getWeight(), DELTA);
   }
 
+  @ParameterizedTest
+  @EnumSource(value = TraverseMode.class, names = { "BICYCLE", "SCOOTER" })
+  void testBikeSpeed(TraverseMode mode) {
+    StreetEdge e1 = streetEdgeBuilder(v1, v2, 100.0, ALL).withCarSpeed(8.0f).buildAndConnect();
+    assertEquals(5.0, e1.calculateSpeed(getPreferencesForBikeAndScooterSpeed(5.0f), mode, false));
+    assertEquals(8.0, e1.calculateSpeed(getPreferencesForBikeAndScooterSpeed(10.0f), mode, false));
+  }
+
+  private static RoutingPreferences getPreferencesForBikeAndScooterSpeed(float speed) {
+    return RoutingPreferences.DEFAULT.copyOf()
+      .withBike(bike -> bike.withSpeed(speed))
+      .withScooter(scooter -> scooter.withSpeed(speed))
+      .build();
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = TraverseMode.class, names = { "BICYCLE", "SCOOTER" })
+  void testBikeSpeedWithElevation(TraverseMode mode) {
+    StreetEdge e1 = streetEdgeBuilder(v1, v2, 100.0, ALL)
+      .withCarSpeed(8.0f)
+      .withElevationExtension(
+        new StreetElevationExtension(
+          100,
+          false,
+          new PackedCoordinateSequence.Float(0, 2, 0),
+          1.0f,
+          1.2,
+          1.5,
+          1.1,
+          1.3,
+          1.0,
+          0.1f,
+          false
+        )
+      )
+      .buildAndConnect();
+    assertEquals(9.0, e1.calculateSpeed(getPreferencesForBikeAndScooterSpeed(9.0f), mode, false));
+    assertEquals(9.6, e1.calculateSpeed(getPreferencesForBikeAndScooterSpeed(10.0f), mode, false));
+  }
+
   @Test
   void setName() {
     var path = I18NString.of("path");