diff --git a/doc-templates/Flex.md b/doc-templates/Flex.md index 2015e898cae..0e79ecdbffc 100644 --- a/doc-templates/Flex.md +++ b/doc-templates/Flex.md @@ -10,8 +10,19 @@ To enable this turn on `FlexRouting` as a feature in `otp-config.json`. -The GTFS feeds should conform to the -[GTFS-Flex v2 draft PR](https://github.com/google/transit/pull/388) +The GTFS feeds must conform to the final, approved version of the draft which has been +merged into the [mainline specification](https://gtfs.org/schedule/reference/) in March 2024. + +### Experimental features + +This sandbox feature also has experimental support for the following fields: + +- `safe_duration_factor` +- `safe_duration_offset` + +These features are currently [undergoing specification](https://github.com/MobilityData/gtfs-flex/pull/79) +and their definition might change. OTP's implementation will be also be changed so be careful +when relying on this feature. ## Configuration diff --git a/docs/sandbox/Flex.md b/docs/sandbox/Flex.md index 61d15851a56..7b08fd7d05f 100644 --- a/docs/sandbox/Flex.md +++ b/docs/sandbox/Flex.md @@ -10,8 +10,19 @@ To enable this turn on `FlexRouting` as a feature in `otp-config.json`. -The GTFS feeds should conform to the -[GTFS-Flex v2 draft PR](https://github.com/google/transit/pull/388) +The GTFS feeds must conform to the final, approved version of the draft which has been +merged into the [mainline specification](https://gtfs.org/schedule/reference/) in March 2024. + +### Experimental features + +This sandbox feature also has experimental support for the following fields: + +- `safe_duration_factor` +- `safe_duration_offset` + +These features are currently [undergoing specification](https://github.com/MobilityData/gtfs-flex/pull/79) +and their definition might change. OTP's implementation will be also be changed so be careful +when relying on this feature. ## Configuration diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/FlexStopTimesForTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/FlexStopTimesForTest.java new file mode 100644 index 00000000000..d76382999a2 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/flex/FlexStopTimesForTest.java @@ -0,0 +1,49 @@ +package org.opentripplanner.ext.flex; + +import static org.opentripplanner.model.StopTime.MISSING_VALUE; + +import org.opentripplanner._support.geometry.Polygons; +import org.opentripplanner.framework.time.TimeUtils; +import org.opentripplanner.model.StopTime; +import org.opentripplanner.transit.model._data.TransitModelForTest; +import org.opentripplanner.transit.model.site.RegularStop; +import org.opentripplanner.transit.model.site.StopLocation; + +public class FlexStopTimesForTest { + + private static final TransitModelForTest TEST_MODEL = TransitModelForTest.of(); + private static final StopLocation AREA_STOP = TEST_MODEL.areaStopForTest("area", Polygons.BERLIN); + private static final RegularStop REGULAR_STOP = TEST_MODEL.stop("stop").build(); + + public static StopTime area(String startTime, String endTime) { + return area(AREA_STOP, endTime, startTime); + } + + public static StopTime area(StopLocation areaStop, String endTime, String startTime) { + var stopTime = new StopTime(); + stopTime.setStop(areaStop); + stopTime.setFlexWindowStart(TimeUtils.time(startTime)); + stopTime.setFlexWindowEnd(TimeUtils.time(endTime)); + return stopTime; + } + + public static StopTime regularArrival(String arrivalTime) { + return regularStopTime(TimeUtils.time(arrivalTime), MISSING_VALUE); + } + + public static StopTime regularStopTime(String arrivalTime, String departureTime) { + return regularStopTime(TimeUtils.time(arrivalTime), TimeUtils.time(departureTime)); + } + + public static StopTime regularStopTime(int arrivalTime, int departureTime) { + var stopTime = new StopTime(); + stopTime.setStop(REGULAR_STOP); + stopTime.setArrivalTime(arrivalTime); + stopTime.setDepartureTime(departureTime); + return stopTime; + } + + public static StopTime regularDeparture(String departureTime) { + return regularStopTime(MISSING_VALUE, TimeUtils.time(departureTime)); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPathTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPathTest.java new file mode 100644 index 00000000000..8bd3abee785 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPathTest.java @@ -0,0 +1,38 @@ +package org.opentripplanner.ext.flex.flexpathcalculator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opentripplanner._support.geometry.LineStrings; +import org.opentripplanner.routing.api.request.framework.TimePenalty; + +class FlexPathTest { + + private static final int THIRTY_MINS_IN_SECONDS = (int) Duration.ofMinutes(30).toSeconds(); + private static final FlexPath PATH = new FlexPath( + 10_000, + THIRTY_MINS_IN_SECONDS, + () -> LineStrings.SIMPLE + ); + + static List cases() { + return List.of( + Arguments.of(TimePenalty.ZERO, THIRTY_MINS_IN_SECONDS), + Arguments.of(TimePenalty.of(Duration.ofMinutes(10), 1), 2400), + Arguments.of(TimePenalty.of(Duration.ofMinutes(10), 1.5f), 3300), + Arguments.of(TimePenalty.of(Duration.ZERO, 3), 5400) + ); + } + + @ParameterizedTest + @MethodSource("cases") + void calculate(TimePenalty mod, int expectedSeconds) { + var modified = PATH.withDurationModifier(mod); + assertEquals(expectedSeconds, modified.durationSeconds); + assertEquals(LineStrings.SIMPLE, modified.getGeometry()); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculatorTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculatorTest.java new file mode 100644 index 00000000000..70f39af2420 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculatorTest.java @@ -0,0 +1,38 @@ +package org.opentripplanner.ext.flex.flexpathcalculator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.ext.flex.FlexStopTimesForTest.area; +import static org.opentripplanner.ext.flex.FlexStopTimesForTest.regularStopTime; +import static org.opentripplanner.street.model._data.StreetModelForTest.V1; +import static org.opentripplanner.street.model._data.StreetModelForTest.V2; +import static org.opentripplanner.transit.model._data.TransitModelForTest.id; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.geometry.LineStrings; +import org.opentripplanner.ext.flex.trip.ScheduledDeviatedTrip; + +class ScheduledFlexPathCalculatorTest { + + private static final ScheduledDeviatedTrip TRIP = ScheduledDeviatedTrip + .of(id("123")) + .withStopTimes( + List.of( + regularStopTime("10:00", "10:01"), + area("10:10", "10:20"), + regularStopTime("10:25", "10:26"), + area("10:40", "10:50") + ) + ) + .build(); + + @Test + void calculateTime() { + var c = (FlexPathCalculator) (fromv, tov, fromStopIndex, toStopIndex) -> + new FlexPath(10_000, (int) Duration.ofMinutes(10).toSeconds(), () -> LineStrings.SIMPLE); + var calc = new ScheduledFlexPathCalculator(c, TRIP); + var path = calc.calculateFlexPath(V1, V2, 0, 1); + assertEquals(Duration.ofMinutes(19), Duration.ofSeconds(path.durationSeconds)); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculatorTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculatorTest.java new file mode 100644 index 00000000000..e504f8ac762 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculatorTest.java @@ -0,0 +1,35 @@ +package org.opentripplanner.ext.flex.flexpathcalculator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.geometry.LineStrings; +import org.opentripplanner.routing.api.request.framework.TimePenalty; +import org.opentripplanner.street.model._data.StreetModelForTest; + +class TimePenaltyCalculatorTest { + + private static final int THIRTY_MINS_IN_SECONDS = (int) Duration.ofMinutes(30).toSeconds(); + + @Test + void calculate() { + FlexPathCalculator delegate = (fromv, tov, fromStopIndex, toStopIndex) -> + new FlexPath(10_000, THIRTY_MINS_IN_SECONDS, () -> LineStrings.SIMPLE); + + var mod = TimePenalty.of(Duration.ofMinutes(10), 1.5f); + var calc = new TimePenaltyCalculator(delegate, mod); + var path = calc.calculateFlexPath(StreetModelForTest.V1, StreetModelForTest.V2, 0, 5); + assertEquals(3300, path.durationSeconds); + } + + @Test + void nullValue() { + FlexPathCalculator delegate = (fromv, tov, fromStopIndex, toStopIndex) -> null; + var mod = TimePenalty.of(Duration.ofMinutes(10), 1.5f); + var calc = new TimePenaltyCalculator(delegate, mod); + var path = calc.calculateFlexPath(StreetModelForTest.V1, StreetModelForTest.V2, 0, 5); + assertNull(path); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledDrivingDurationTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledDrivingDurationTest.java new file mode 100644 index 00000000000..a4b245b7de8 --- /dev/null +++ b/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledDrivingDurationTest.java @@ -0,0 +1,45 @@ +package org.opentripplanner.ext.flex.trip; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.street.model._data.StreetModelForTest.V1; +import static org.opentripplanner.street.model._data.StreetModelForTest.V2; +import static org.opentripplanner.transit.model._data.TransitModelForTest.id; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.geometry.LineStrings; +import org.opentripplanner.ext.flex.FlexStopTimesForTest; +import org.opentripplanner.ext.flex.flexpathcalculator.FlexPath; +import org.opentripplanner.ext.flex.flexpathcalculator.FlexPathCalculator; +import org.opentripplanner.model.StopTime; +import org.opentripplanner.routing.api.request.framework.TimePenalty; + +class UnscheduledDrivingDurationTest { + + static final FlexPathCalculator STATIC_CALCULATOR = (fromv, tov, fromStopIndex, toStopIndex) -> + new FlexPath(10_000, (int) Duration.ofMinutes(10).toSeconds(), () -> LineStrings.SIMPLE); + private static final StopTime STOP_TIME = FlexStopTimesForTest.area("10:00", "18:00"); + + @Test + void noPenalty() { + var trip = UnscheduledTrip.of(id("1")).withStopTimes(List.of(STOP_TIME)).build(); + + var calculator = trip.flexPathCalculator(STATIC_CALCULATOR); + var path = calculator.calculateFlexPath(V1, V2, 0, 0); + assertEquals(600, path.durationSeconds); + } + + @Test + void withPenalty() { + var trip = UnscheduledTrip + .of(id("1")) + .withStopTimes(List.of(STOP_TIME)) + .withTimePenalty(TimePenalty.of(Duration.ofMinutes(2), 1.5f)) + .build(); + + var calculator = trip.flexPathCalculator(STATIC_CALCULATOR); + var path = calculator.calculateFlexPath(V1, V2, 0, 0); + assertEquals(1020, path.durationSeconds); + } +} diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledTripTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledTripTest.java index fabe534ff23..80bda31fabf 100644 --- a/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledTripTest.java +++ b/src/ext-test/java/org/opentripplanner/ext/flex/trip/UnscheduledTripTest.java @@ -3,6 +3,9 @@ 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 static org.opentripplanner.ext.flex.FlexStopTimesForTest.area; +import static org.opentripplanner.ext.flex.FlexStopTimesForTest.regularArrival; +import static org.opentripplanner.ext.flex.FlexStopTimesForTest.regularDeparture; import static org.opentripplanner.ext.flex.trip.UnscheduledTrip.isUnscheduledTrip; import static org.opentripplanner.ext.flex.trip.UnscheduledTripTest.TestCase.tc; import static org.opentripplanner.model.PickDrop.NONE; @@ -48,11 +51,10 @@ class UnscheduledTripTest { private static final int T15_00 = TimeUtils.hm2time(15, 0); private static final TransitModelForTest TEST_MODEL = TransitModelForTest.of(); + private static final StopLocation AREA_STOP = TEST_MODEL.areaStopForTest("area", Polygons.BERLIN); private static final RegularStop REGULAR_STOP = TEST_MODEL.stop("stop").build(); - private static final StopLocation AREA_STOP = TEST_MODEL.areaStopForTest("area", Polygons.BERLIN); - @Nested class IsUnscheduledTrip { @@ -661,35 +663,6 @@ private static String timeToString(int time) { return TimeUtils.timeToStrCompact(time, MISSING_VALUE, "MISSING_VALUE"); } - private static StopTime area(String startTime, String endTime) { - return area(AREA_STOP, endTime, startTime); - } - - @Nonnull - private static StopTime area(StopLocation areaStop, String endTime, String startTime) { - var stopTime = new StopTime(); - stopTime.setStop(areaStop); - stopTime.setFlexWindowStart(TimeUtils.time(startTime)); - stopTime.setFlexWindowEnd(TimeUtils.time(endTime)); - return stopTime; - } - - private static StopTime regularDeparture(String departureTime) { - return regularStopTime(MISSING_VALUE, TimeUtils.time(departureTime)); - } - - private static StopTime regularArrival(String arrivalTime) { - return regularStopTime(TimeUtils.time(arrivalTime), MISSING_VALUE); - } - - private static StopTime regularStopTime(int arrivalTime, int departureTime) { - var stopTime = new StopTime(); - stopTime.setStop(REGULAR_STOP); - stopTime.setArrivalTime(arrivalTime); - stopTime.setDepartureTime(departureTime); - return stopTime; - } - @Nonnull private static NearbyStop nearbyStop(AreaStop stop) { return new NearbyStop(stop, 1000, List.of(), null); diff --git a/src/ext/java/org/opentripplanner/ext/flex/FlexTripsMapper.java b/src/ext/java/org/opentripplanner/ext/flex/FlexTripsMapper.java index f5ab34cce49..c4167f2f9e1 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/FlexTripsMapper.java +++ b/src/ext/java/org/opentripplanner/ext/flex/FlexTripsMapper.java @@ -12,6 +12,7 @@ import org.opentripplanner.model.StopTime; import org.opentripplanner.model.TripStopTimes; import org.opentripplanner.model.impl.OtpTransitServiceBuilder; +import org.opentripplanner.routing.api.request.framework.TimePenalty; import org.opentripplanner.transit.model.timetable.Trip; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,12 +33,16 @@ public class FlexTripsMapper { ProgressTracker progress = ProgressTracker.track("Create flex trips", 500, tripSize); for (Trip trip : stopTimesByTrip.keys()) { - /* Fetch the stop times for this trip. Copy the list since it's immutable. */ - List stopTimes = new ArrayList<>(stopTimesByTrip.get(trip)); - + var stopTimes = stopTimesByTrip.get(trip); if (UnscheduledTrip.isUnscheduledTrip(stopTimes)) { + var timePenalty = builder.getFlexTimePenalty().getOrDefault(trip, TimePenalty.NONE); result.add( - UnscheduledTrip.of(trip.getId()).withTrip(trip).withStopTimes(stopTimes).build() + UnscheduledTrip + .of(trip.getId()) + .withTrip(trip) + .withStopTimes(stopTimes) + .withTimePenalty(timePenalty) + .build() ); } else if (ScheduledDeviatedTrip.isScheduledFlexTrip(stopTimes)) { result.add( diff --git a/src/ext/java/org/opentripplanner/ext/flex/FlexibleTransitLeg.java b/src/ext/java/org/opentripplanner/ext/flex/FlexibleTransitLeg.java index fac1118556f..b9e10e29214 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/FlexibleTransitLeg.java +++ b/src/ext/java/org/opentripplanner/ext/flex/FlexibleTransitLeg.java @@ -196,6 +196,7 @@ public int getGeneralizedCost() { return generalizedCost; } + @Override public void addAlert(TransitAlert alert) { transitAlerts.add(alert); } diff --git a/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPath.java b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPath.java index 5c5557890d6..3a692dff40b 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPath.java +++ b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/FlexPath.java @@ -1,11 +1,16 @@ package org.opentripplanner.ext.flex.flexpathcalculator; +import java.time.Duration; import java.util.function.Supplier; +import javax.annotation.concurrent.Immutable; import org.locationtech.jts.geom.LineString; +import org.opentripplanner.framework.lang.IntUtils; +import org.opentripplanner.routing.api.request.framework.TimePenalty; /** * This class contains the results from a FlexPathCalculator. */ +@Immutable public class FlexPath { private final Supplier geometrySupplier; @@ -22,7 +27,7 @@ public class FlexPath { */ public FlexPath(int distanceMeters, int durationSeconds, Supplier geometrySupplier) { this.distanceMeters = distanceMeters; - this.durationSeconds = durationSeconds; + this.durationSeconds = IntUtils.requireNotNegative(durationSeconds); this.geometrySupplier = geometrySupplier; } @@ -32,4 +37,16 @@ public LineString getGeometry() { } return geometry; } + + /** + * Returns an (immutable) copy of this path with the duration modified. + */ + public FlexPath withDurationModifier(TimePenalty mod) { + if (mod.isZero()) { + return this; + } else { + int updatedDuration = (int) mod.calculate(Duration.ofSeconds(durationSeconds)).toSeconds(); + return new FlexPath(distanceMeters, updatedDuration, geometrySupplier); + } + } } diff --git a/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculator.java b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculator.java index 7d953abc4fd..cd5228dada5 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculator.java +++ b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/ScheduledFlexPathCalculator.java @@ -20,7 +20,7 @@ public ScheduledFlexPathCalculator(FlexPathCalculator flexPathCalculator, FlexTr @Override public FlexPath calculateFlexPath(Vertex fromv, Vertex tov, int fromStopIndex, int toStopIndex) { - FlexPath flexPath = flexPathCalculator.calculateFlexPath( + final var flexPath = flexPathCalculator.calculateFlexPath( fromv, tov, fromStopIndex, @@ -29,7 +29,6 @@ public FlexPath calculateFlexPath(Vertex fromv, Vertex tov, int fromStopIndex, i if (flexPath == null) { return null; } - int distance = flexPath.distanceMeters; int departureTime = trip.earliestDepartureTime( Integer.MIN_VALUE, fromStopIndex, @@ -50,6 +49,10 @@ public FlexPath calculateFlexPath(Vertex fromv, Vertex tov, int fromStopIndex, i if (departureTime >= arrivalTime) { return null; } - return new FlexPath(distance, arrivalTime - departureTime, flexPath::getGeometry); + return new FlexPath( + flexPath.distanceMeters, + arrivalTime - departureTime, + flexPath::getGeometry + ); } } diff --git a/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculator.java b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculator.java new file mode 100644 index 00000000000..63b661f0f9a --- /dev/null +++ b/src/ext/java/org/opentripplanner/ext/flex/flexpathcalculator/TimePenaltyCalculator.java @@ -0,0 +1,32 @@ +package org.opentripplanner.ext.flex.flexpathcalculator; + +import javax.annotation.Nullable; +import org.opentripplanner.routing.api.request.framework.TimePenalty; +import org.opentripplanner.street.model.vertex.Vertex; + +/** + * A calculator to delegates the main computation to another instance and applies a duration + * modifier afterward. + */ +public class TimePenaltyCalculator implements FlexPathCalculator { + + private final FlexPathCalculator delegate; + private final TimePenalty factors; + + public TimePenaltyCalculator(FlexPathCalculator delegate, TimePenalty penalty) { + this.delegate = delegate; + this.factors = penalty; + } + + @Nullable + @Override + public FlexPath calculateFlexPath(Vertex fromv, Vertex tov, int fromStopIndex, int toStopIndex) { + var path = delegate.calculateFlexPath(fromv, tov, fromStopIndex, toStopIndex); + + if (path == null) { + return null; + } else { + return path.withDurationModifier(factors); + } + } +} diff --git a/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTrip.java b/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTrip.java index c5968507676..402d39e2aa7 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTrip.java +++ b/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTrip.java @@ -5,6 +5,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -15,12 +16,16 @@ import javax.annotation.Nonnull; import org.opentripplanner.ext.flex.FlexServiceDate; import org.opentripplanner.ext.flex.flexpathcalculator.FlexPathCalculator; +import org.opentripplanner.ext.flex.flexpathcalculator.TimePenaltyCalculator; import org.opentripplanner.ext.flex.template.FlexAccessTemplate; import org.opentripplanner.ext.flex.template.FlexEgressTemplate; +import org.opentripplanner.framework.lang.DoubleUtils; import org.opentripplanner.framework.lang.IntRange; +import org.opentripplanner.framework.time.DurationUtils; import org.opentripplanner.model.BookingInfo; import org.opentripplanner.model.PickDrop; import org.opentripplanner.model.StopTime; +import org.opentripplanner.routing.api.request.framework.TimePenalty; import org.opentripplanner.routing.graphfinder.NearbyStop; import org.opentripplanner.standalone.config.sandbox.FlexConfig; import org.opentripplanner.transit.model.framework.FeedScopedId; @@ -50,6 +55,8 @@ public class UnscheduledTrip extends FlexTrip stopTimes = builder.stopTimes(); @@ -64,9 +71,12 @@ public UnscheduledTrip(UnscheduledTripBuilder builder) { for (int i = 0; i < size; i++) { this.stopTimes[i] = new StopTimeWindow(stopTimes.get(i)); - this.dropOffBookingInfos[i] = stopTimes.get(0).getDropOffBookingInfo(); - this.pickupBookingInfos[i] = stopTimes.get(0).getPickupBookingInfo(); + this.dropOffBookingInfos[i] = stopTimes.get(i).getDropOffBookingInfo(); + this.pickupBookingInfos[i] = stopTimes.get(i).getPickupBookingInfo(); } + this.timePenalty = Objects.requireNonNull(builder.timePenalty()); + DurationUtils.requireNonNegative(timePenalty.constant()); + DoubleUtils.requireInRange(timePenalty.coefficient(), 0.05d, Double.MAX_VALUE); } public static UnscheduledTripBuilder of(FeedScopedId id) { @@ -81,8 +91,6 @@ public static UnscheduledTripBuilder of(FeedScopedId id) { * - One or more stop times with a flexible time window but no fixed stop in between them */ public static boolean isUnscheduledTrip(List stopTimes) { - Predicate hasFlexWindow = st -> - st.getFlexWindowStart() != MISSING_VALUE || st.getFlexWindowEnd() != MISSING_VALUE; Predicate hasContinuousStops = stopTime -> stopTime.getFlexContinuousDropOff() != NONE || stopTime.getFlexContinuousPickup() != NONE; if (stopTimes.isEmpty()) { @@ -90,9 +98,9 @@ public static boolean isUnscheduledTrip(List stopTimes) { } else if (stopTimes.stream().anyMatch(hasContinuousStops)) { return false; } else if (N_STOPS.contains(stopTimes.size())) { - return stopTimes.stream().anyMatch(hasFlexWindow); + return stopTimes.stream().anyMatch(StopTime::hasFlexWindow); } else { - return stopTimes.stream().allMatch(hasFlexWindow); + return stopTimes.stream().allMatch(StopTime::hasFlexWindow); } } @@ -120,6 +128,9 @@ public Stream getFlexAccessTemplates( } else { indices = IntStream.range(fromIndex + 1, lastIndexInTrip + 1); } + + final var updatedCalculator = flexPathCalculator(calculator); + // check for every stop after fromIndex if you can alight, if so return a template return indices // if you cannot alight at an index, the trip is not possible @@ -137,12 +148,24 @@ public Stream getFlexAccessTemplates( alightStop.index, alightStop.stop, date, - calculator, + updatedCalculator, config ) ); } + /** + * Get the correct {@link FlexPathCalculator} depending on the {@code timePenalty}. + * If the modifier doesn't actually modify, we return the regular calculator. + */ + protected FlexPathCalculator flexPathCalculator(FlexPathCalculator calculator) { + if (timePenalty.modifies()) { + return new TimePenaltyCalculator(calculator, timePenalty); + } else { + return calculator; + } + } + @Override public Stream getFlexEgressTemplates( NearbyStop egress, diff --git a/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTripBuilder.java b/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTripBuilder.java index 678b7fcce5e..1f8585f5ad0 100644 --- a/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTripBuilder.java +++ b/src/ext/java/org/opentripplanner/ext/flex/trip/UnscheduledTripBuilder.java @@ -2,12 +2,14 @@ import java.util.List; import org.opentripplanner.model.StopTime; +import org.opentripplanner.routing.api.request.framework.TimePenalty; import org.opentripplanner.transit.model.framework.FeedScopedId; public class UnscheduledTripBuilder extends FlexTripBuilder { private List stopTimes; + private TimePenalty timePenalty = TimePenalty.NONE; UnscheduledTripBuilder(FeedScopedId id) { super(id); @@ -29,6 +31,15 @@ public List stopTimes() { return stopTimes; } + public UnscheduledTripBuilder withTimePenalty(TimePenalty factors) { + this.timePenalty = factors; + return this; + } + + public TimePenalty timePenalty() { + return timePenalty; + } + @Override UnscheduledTripBuilder instance() { return this; diff --git a/src/main/java/org/opentripplanner/gtfs/mapping/GTFSToOtpTransitServiceMapper.java b/src/main/java/org/opentripplanner/gtfs/mapping/GTFSToOtpTransitServiceMapper.java index 4d5fe6bd051..ce65d6b0820 100644 --- a/src/main/java/org/opentripplanner/gtfs/mapping/GTFSToOtpTransitServiceMapper.java +++ b/src/main/java/org/opentripplanner/gtfs/mapping/GTFSToOtpTransitServiceMapper.java @@ -171,6 +171,7 @@ public void mapStopTripAndRouteDataIntoBuilder() { builder.getPathways().addAll(pathwayMapper.map(data.getAllPathways())); builder.getStopTimesSortedByTrip().addAll(stopTimeMapper.map(data.getAllStopTimes())); + builder.getFlexTimePenalty().putAll(tripMapper.flexSafeDurationModifiers()); builder.getTripsById().addAll(tripMapper.map(data.getAllTrips())); fareRulesBuilder.fareAttributes().addAll(fareAttributeMapper.map(data.getAllFareAttributes())); diff --git a/src/main/java/org/opentripplanner/gtfs/mapping/StopTimeMapper.java b/src/main/java/org/opentripplanner/gtfs/mapping/StopTimeMapper.java index fc75236f4e4..67b250c5061 100644 --- a/src/main/java/org/opentripplanner/gtfs/mapping/StopTimeMapper.java +++ b/src/main/java/org/opentripplanner/gtfs/mapping/StopTimeMapper.java @@ -102,6 +102,7 @@ private StopTime doMap(org.onebusaway.gtfs.model.StopTime rhs) { lhs.setFarePeriodId(rhs.getFarePeriodId()); lhs.setFlexWindowStart(rhs.getStartPickupDropOffWindow()); lhs.setFlexWindowEnd(rhs.getEndPickupDropOffWindow()); + lhs.setFlexContinuousPickup( PickDropMapper.mapFlexContinuousPickDrop(rhs.getContinuousPickup()) ); diff --git a/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java b/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java index a80ae035ed1..46160fa5daa 100644 --- a/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java +++ b/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java @@ -1,10 +1,13 @@ package org.opentripplanner.gtfs.mapping; +import java.time.Duration; import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.opentripplanner.framework.collection.MapUtils; import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.routing.api.request.framework.TimePenalty; import org.opentripplanner.transit.model.timetable.Trip; /** Responsible for mapping GTFS TripMapper into the OTP model. */ @@ -12,9 +15,10 @@ class TripMapper { private final RouteMapper routeMapper; private final DirectionMapper directionMapper; - private TranslationHelper translationHelper; + private final TranslationHelper translationHelper; private final Map mappedTrips = new HashMap<>(); + private final Map flexSafeDurationModifiers = new HashMap<>(); TripMapper( RouteMapper routeMapper, @@ -38,6 +42,13 @@ Collection getMappedTrips() { return mappedTrips.values(); } + /** + * The map of flex duration factors per flex trip. + */ + Map flexSafeDurationModifiers() { + return flexSafeDurationModifiers; + } + private Trip doMap(org.onebusaway.gtfs.model.Trip rhs) { var lhs = Trip.of(AgencyAndIdMapper.mapAgencyAndId(rhs.getId())); @@ -62,6 +73,17 @@ private Trip doMap(org.onebusaway.gtfs.model.Trip rhs) { lhs.withBikesAllowed(BikeAccessMapper.mapForTrip(rhs)); lhs.withGtfsFareId(rhs.getFareId()); - return lhs.build(); + var trip = lhs.build(); + mapSafeDurationModifier(rhs).ifPresent(f -> flexSafeDurationModifiers.put(trip, f)); + return trip; + } + + private Optional mapSafeDurationModifier(org.onebusaway.gtfs.model.Trip rhs) { + if (rhs.getSafeDurationFactor() == null && rhs.getSafeDurationOffset() == null) { + return Optional.empty(); + } else { + var offset = Duration.ofSeconds(rhs.getSafeDurationOffset().longValue()); + return Optional.of(TimePenalty.of(offset, rhs.getSafeDurationFactor().doubleValue())); + } } } diff --git a/src/main/java/org/opentripplanner/model/StopTime.java b/src/main/java/org/opentripplanner/model/StopTime.java index 2ae04484426..e753b8d2885 100644 --- a/src/main/java/org/opentripplanner/model/StopTime.java +++ b/src/main/java/org/opentripplanner/model/StopTime.java @@ -62,28 +62,6 @@ public final class StopTime implements Comparable { public StopTime() {} - public StopTime(StopTime st) { - this.trip = st.trip; - this.stop = st.stop; - this.arrivalTime = st.arrivalTime; - this.departureTime = st.departureTime; - this.timepoint = st.timepoint; - this.stopSequence = st.stopSequence; - this.stopHeadsign = st.stopHeadsign; - this.routeShortName = st.routeShortName; - this.pickupType = st.pickupType; - this.dropOffType = st.dropOffType; - this.shapeDistTraveled = st.shapeDistTraveled; - this.farePeriodId = st.farePeriodId; - this.flexWindowStart = st.flexWindowStart; - this.flexWindowEnd = st.flexWindowEnd; - this.flexContinuousPickup = st.flexContinuousPickup; - this.flexContinuousDropOff = st.flexContinuousDropOff; - this.dropOffBookingInfo = st.dropOffBookingInfo; - this.pickupBookingInfo = st.pickupBookingInfo; - this.headsignVias = st.headsignVias; - } - /** * The id is used to navigate/link StopTime to other entities (Map from StopTime.id -> Entity.id). * There is no need to navigate in the opposite direction. The StopTime id is NOT stored in a @@ -319,4 +297,11 @@ private static int getAvailableTime(int... times) { return MISSING_VALUE; } + + /** + * Does this stop time define a flex window? + */ + public boolean hasFlexWindow() { + return flexWindowStart != MISSING_VALUE || flexWindowEnd != MISSING_VALUE; + } } diff --git a/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java b/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java index 382d6042a05..544ca29599d 100644 --- a/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java +++ b/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java @@ -3,6 +3,7 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -22,6 +23,7 @@ import org.opentripplanner.model.calendar.impl.CalendarServiceDataFactoryImpl; import org.opentripplanner.model.transfer.ConstrainedTransfer; import org.opentripplanner.model.transfer.TransferPoint; +import org.opentripplanner.routing.api.request.framework.TimePenalty; import org.opentripplanner.transit.model.basic.Notice; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; import org.opentripplanner.transit.model.framework.DefaultEntityById; @@ -92,6 +94,8 @@ public class OtpTransitServiceBuilder { private final TripStopTimes stopTimesByTrip = new TripStopTimes(); + private final Map flexDurationFactors = new HashMap<>(); + private final EntityById fareZonesById = new DefaultEntityById<>(); private final List transfers = new ArrayList<>(); @@ -209,6 +213,10 @@ public TripStopTimes getStopTimesSortedByTrip() { return stopTimesByTrip; } + public Map getFlexTimePenalty() { + return flexDurationFactors; + } + public EntityById getFareZonesById() { return fareZonesById; } diff --git a/src/main/java/org/opentripplanner/routing/api/request/framework/TimePenalty.java b/src/main/java/org/opentripplanner/routing/api/request/framework/TimePenalty.java index aaa81db5a09..0c6fdd96436 100644 --- a/src/main/java/org/opentripplanner/routing/api/request/framework/TimePenalty.java +++ b/src/main/java/org/opentripplanner/routing/api/request/framework/TimePenalty.java @@ -7,6 +7,7 @@ public final class TimePenalty extends AbstractLinearFunction { public static final TimePenalty ZERO = new TimePenalty(Duration.ZERO, 0.0); + public static final TimePenalty NONE = new TimePenalty(Duration.ZERO, 1.0); private TimePenalty(Duration constant, double coefficient) { super(DurationUtils.requireNonNegative(constant), coefficient); @@ -31,6 +32,13 @@ protected boolean isZero(Duration value) { return value.isZero(); } + /** + * Does this penalty actually modify a duration or would it be returned unchanged? + */ + public boolean modifies() { + return !constant().isZero() && coefficient() != 1.0; + } + @Override protected Duration constantAsDuration() { return constant(); diff --git a/src/test/java/org/opentripplanner/_support/geometry/LineStrings.java b/src/test/java/org/opentripplanner/_support/geometry/LineStrings.java new file mode 100644 index 00000000000..515f161be92 --- /dev/null +++ b/src/test/java/org/opentripplanner/_support/geometry/LineStrings.java @@ -0,0 +1,9 @@ +package org.opentripplanner._support.geometry; + +import org.locationtech.jts.geom.LineString; +import org.opentripplanner.framework.geometry.GeometryUtils; + +public class LineStrings { + + public static final LineString SIMPLE = GeometryUtils.makeLineString(0, 0, 1, 1); +} diff --git a/src/test/java/org/opentripplanner/_support/geometry/Polygons.java b/src/test/java/org/opentripplanner/_support/geometry/Polygons.java index a386d8a27e1..ee110ab4f4f 100644 --- a/src/test/java/org/opentripplanner/_support/geometry/Polygons.java +++ b/src/test/java/org/opentripplanner/_support/geometry/Polygons.java @@ -20,7 +20,7 @@ public class Polygons { } ); - public static Polygon OSLO = FAC.createPolygon( + public static final Polygon OSLO = FAC.createPolygon( new Coordinate[] { Coordinates.of(59.961055202323195, 10.62535658370308), Coordinates.of(59.889009435700416, 10.62535658370308), @@ -29,7 +29,7 @@ public class Polygons { Coordinates.of(59.961055202323195, 10.62535658370308), } ); - public static Polygon OSLO_FROGNER_PARK = FAC.createPolygon( + public static final Polygon OSLO_FROGNER_PARK = FAC.createPolygon( new Coordinate[] { Coordinates.of(59.92939032560119, 10.69770054003061), Coordinates.of(59.929138466684975, 10.695210909925208), diff --git a/src/test/java/org/opentripplanner/gtfs/mapping/TripMapperTest.java b/src/test/java/org/opentripplanner/gtfs/mapping/TripMapperTest.java index 4f0c70f22d2..9424e44364b 100644 --- a/src/test/java/org/opentripplanner/gtfs/mapping/TripMapperTest.java +++ b/src/test/java/org/opentripplanner/gtfs/mapping/TripMapperTest.java @@ -33,11 +33,15 @@ public class TripMapperTest { public static final DataImportIssueStore ISSUE_STORE = DataImportIssueStore.NOOP; - private final TripMapper subject = new TripMapper( - new RouteMapper(new AgencyMapper(FEED_ID), ISSUE_STORE, new TranslationHelper()), - new DirectionMapper(ISSUE_STORE), - new TranslationHelper() - ); + private final TripMapper subject = defaultTripMapper(); + + private static TripMapper defaultTripMapper() { + return new TripMapper( + new RouteMapper(new AgencyMapper(FEED_ID), ISSUE_STORE, new TranslationHelper()), + new DirectionMapper(ISSUE_STORE), + new TranslationHelper() + ); + } static { GtfsTestData data = new GtfsTestData(); @@ -56,14 +60,14 @@ public class TripMapperTest { } @Test - public void testMapCollection() throws Exception { + void testMapCollection() throws Exception { assertNull(subject.map((Collection) null)); assertTrue(subject.map(Collections.emptyList()).isEmpty()); assertEquals(1, subject.map(Collections.singleton(TRIP)).size()); } @Test - public void testMap() throws Exception { + void testMap() throws Exception { org.opentripplanner.transit.model.timetable.Trip result = subject.map(TRIP); assertEquals("A:1", result.getId().toString()); @@ -80,7 +84,7 @@ public void testMap() throws Exception { } @Test - public void testMapWithNulls() throws Exception { + void testMapWithNulls() throws Exception { Trip input = new Trip(); input.setId(AGENCY_AND_ID); input.setRoute(new GtfsTestData().route); @@ -101,12 +105,33 @@ public void testMapWithNulls() throws Exception { assertEquals(BikeAccess.UNKNOWN, result.getBikesAllowed()); } - /** Mapping the same object twice, should return the the same instance. */ + /** Mapping the same object twice, should return the same instance. */ @Test - public void testMapCache() throws Exception { + void testMapCache() throws Exception { org.opentripplanner.transit.model.timetable.Trip result1 = subject.map(TRIP); org.opentripplanner.transit.model.timetable.Trip result2 = subject.map(TRIP); assertSame(result1, result2); } + + @Test + void noFlexDurationModifier() { + var mapper = defaultTripMapper(); + mapper.map(TRIP); + assertTrue(mapper.flexSafeDurationModifiers().isEmpty()); + } + + @Test + void flexDurationModifier() { + var flexTrip = new Trip(); + flexTrip.setId(new AgencyAndId("1", "1")); + flexTrip.setSafeDurationFactor(1.5); + flexTrip.setSafeDurationOffset(600d); + flexTrip.setRoute(new GtfsTestData().route); + var mapper = defaultTripMapper(); + var mapped = mapper.map(flexTrip); + var mod = mapper.flexSafeDurationModifiers().get(mapped); + assertEquals(1.5f, mod.coefficient()); + assertEquals(600, mod.constant().toSeconds()); + } } diff --git a/src/test/java/org/opentripplanner/routing/api/request/framework/TimePenaltyTest.java b/src/test/java/org/opentripplanner/routing/api/request/framework/TimePenaltyTest.java index f5bffe1f415..087ffa1d637 100644 --- a/src/test/java/org/opentripplanner/routing/api/request/framework/TimePenaltyTest.java +++ b/src/test/java/org/opentripplanner/routing/api/request/framework/TimePenaltyTest.java @@ -64,4 +64,11 @@ void calculate() { var subject = TimePenalty.of(D2m, 0.5); assertEquals(120 + 150, subject.calculate(Duration.ofMinutes(5)).toSeconds()); } + + @Test + void modifies() { + var subject = TimePenalty.of(D2m, 0.5); + assertTrue(subject.modifies()); + assertFalse(TimePenalty.ZERO.modifies()); + } }