diff --git a/application/src/main/java/org/opentripplanner/updater/trip/AddedStopTime.java b/application/src/main/java/org/opentripplanner/updater/trip/AddedStopTime.java index 654c420da62..58a87c434e2 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/AddedStopTime.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/AddedStopTime.java @@ -2,7 +2,9 @@ import com.google.transit.realtime.GtfsRealtime; import de.mfdz.MfdzRealtimeExtensions; -import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; import javax.annotation.Nullable; import org.opentripplanner.gtfs.mapping.PickDropMapper; import org.opentripplanner.model.PickDrop; @@ -13,49 +15,125 @@ */ final class AddedStopTime { - @Nullable - private final PickDrop pickup; + private final GtfsRealtime.TripUpdate.StopTimeUpdate stopTimeUpdate; - @Nullable - private final PickDrop dropOff; - - public static final PickDrop DEFAULT_PICK_DROP = PickDrop.SCHEDULED; - - AddedStopTime(@Nullable PickDrop pickup, @Nullable PickDrop dropOff) { - this.pickup = pickup; - this.dropOff = dropOff; + AddedStopTime(GtfsRealtime.TripUpdate.StopTimeUpdate stopTimeUpdate) { + this.stopTimeUpdate = stopTimeUpdate; } PickDrop pickup() { - return Objects.requireNonNullElse(pickup, DEFAULT_PICK_DROP); + return getPickDrop( + getStopTimeProperties() + .map(properties -> properties.hasPickupType() ? properties.getPickupType() : null) + .orElse(null), + getStopTimePropertiesExtension() + .map(properties -> properties.hasPickupType() ? properties.getPickupType() : null) + .orElse(null) + ); } PickDrop dropOff() { - return Objects.requireNonNullElse(dropOff, DEFAULT_PICK_DROP); - } - - static AddedStopTime ofStopTime(GtfsRealtime.TripUpdate.StopTimeUpdate props) { - if (props.getStopTimeProperties().hasExtension(MfdzRealtimeExtensions.stopTimeProperties)) { - var ext = props - .getStopTimeProperties() - .getExtension(MfdzRealtimeExtensions.stopTimeProperties); - var pickup = ext.getPickupType(); - var dropOff = ext.getDropoffType(); - var dropOffType = PickDropMapper.map(dropOff.getNumber()); - var pickupType = PickDropMapper.map(pickup.getNumber()); - return new AddedStopTime(pickupType, dropOffType); - } else { - var pickDrop = toPickDrop(props.getScheduleRelationship()); - return new AddedStopTime(pickDrop, pickDrop); - } + return getPickDrop( + getStopTimeProperties() + .map(properties -> properties.hasDropOffType() ? properties.getDropOffType() : null) + .orElse(null), + getStopTimePropertiesExtension() + .map(properties -> properties.hasDropoffType() ? properties.getDropoffType() : null) + .orElse(null) + ); } - private static PickDrop toPickDrop( - GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship scheduleRelationship + private PickDrop getPickDrop( + @Nullable GtfsRealtime.TripUpdate.StopTimeUpdate.StopTimeProperties.DropOffPickupType dropOffPickupType, + @Nullable MfdzRealtimeExtensions.StopTimePropertiesExtension.DropOffPickupType extensionDropOffPickup ) { - return switch (scheduleRelationship) { - case SCHEDULED, NO_DATA, UNSCHEDULED -> PickDrop.SCHEDULED; - case SKIPPED -> PickDrop.CANCELLED; - }; + if (isSkipped()) { + return PickDrop.CANCELLED; + } + + if (dropOffPickupType != null) { + return PickDropMapper.map(dropOffPickupType.getNumber()); + } + + if (extensionDropOffPickup != null) { + return PickDropMapper.map(extensionDropOffPickup.getNumber()); + } + + return PickDrop.SCHEDULED; + } + + private Optional getStopTimeProperties() { + return stopTimeUpdate.hasStopTimeProperties() + ? Optional.of(stopTimeUpdate.getStopTimeProperties()) + : Optional.empty(); + } + + private Optional getStopTimePropertiesExtension() { + return getStopTimeProperties() + .map(stopTimeProperties -> + stopTimeProperties.hasExtension(MfdzRealtimeExtensions.stopTimeProperties) + ? stopTimeProperties.getExtension(MfdzRealtimeExtensions.stopTimeProperties) + : null + ); + } + + OptionalLong arrivalTime() { + return stopTimeUpdate.hasArrival() + ? getTime(stopTimeUpdate.getArrival()) + : OptionalLong.empty(); + } + + OptionalLong departureTime() { + return stopTimeUpdate.hasDeparture() + ? getTime(stopTimeUpdate.getDeparture()) + : OptionalLong.empty(); + } + + private OptionalLong getTime(GtfsRealtime.TripUpdate.StopTimeEvent stopTimeEvent) { + return stopTimeEvent.hasTime() + ? OptionalLong.of(stopTimeEvent.getTime()) + : OptionalLong.empty(); + } + + int arrivalDelay() { + return stopTimeUpdate.hasArrival() ? getDelay(stopTimeUpdate.getArrival()) : 0; + } + + int departureDelay() { + return stopTimeUpdate.hasDeparture() ? getDelay(stopTimeUpdate.getDeparture()) : 0; + } + + private int getDelay(GtfsRealtime.TripUpdate.StopTimeEvent stopTimeEvent) { + return stopTimeEvent.hasDelay() + ? stopTimeEvent.getDelay() + : stopTimeEvent.hasScheduledTime() + ? (int) (stopTimeEvent.getTime() - stopTimeEvent.getScheduledTime()) + : 0; + } + + boolean isSkipped() { + return ( + stopTimeUpdate.getScheduleRelationship() == + GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship.SKIPPED + ); + } + + OptionalInt stopSequence() { + return stopTimeUpdate.hasStopSequence() + ? OptionalInt.of(stopTimeUpdate.getStopSequence()) + : OptionalInt.empty(); + } + + Optional stopId() { + return stopTimeUpdate.hasStopId() ? Optional.of(stopTimeUpdate.getStopId()) : Optional.empty(); + } + + Optional stopHeadsign() { + return ( + stopTimeUpdate.hasStopTimeProperties() && + stopTimeUpdate.getStopTimeProperties().hasStopHeadsign() + ) + ? Optional.of(stopTimeUpdate.getStopTimeProperties().getStopHeadsign()) + : Optional.empty(); } } diff --git a/application/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java b/application/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java index e3ec690237e..2bf523628d7 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java @@ -249,7 +249,7 @@ public UpdateResult applyTripUpdates( serviceDate, backwardsDelayPropagationType ); - case ADDED -> validateAndHandleAddedTrip( + case NEW, ADDED -> validateAndHandleAddedTrip( tripUpdate, tripDescriptor, tripId, @@ -303,7 +303,7 @@ public UpdateResult applyTripUpdates( /** * Remove previous realtime updates for this trip. This is necessary to avoid previous stop * pattern modifications from persisting. If a trip was previously added with the - * ScheduleRelationship ADDED and is now cancelled or deleted, we still want to keep the realtime + * ScheduleRelationship NEW and is now cancelled or deleted, we still want to keep the realtime * added trip pattern. */ private void purgePatternModifications( @@ -324,7 +324,7 @@ private void purgePatternModifications( ) { // Remove previous realtime updates for this trip. This is necessary to avoid previous // stop pattern modifications from persisting. If a trip was previously added with the ScheduleRelationship - // ADDED and is now cancelled or deleted, we still want to keep the realtime added trip pattern. + // NEW and is now cancelled or deleted, we still want to keep the realtime added trip pattern. this.snapshotManager.revertTripToScheduledTripPattern(tripId, serviceDate); } } @@ -466,7 +466,7 @@ private Result handleScheduledTrip( } /** - * Validate and handle GTFS-RT TripUpdate message containing an ADDED trip. + * Validate and handle GTFS-RT TripUpdate message containing an NEW trip. * * @param tripUpdate GTFS-RT TripUpdate message * @param tripDescriptor GTFS-RT TripDescriptor @@ -490,20 +490,14 @@ private Result validateAndHandleAddedTrip( final Trip trip = transitEditorService.getScheduledTrip(tripId); if (trip != null) { - // TODO: should we support this and add a new instantiation of this trip (making it - // frequency based)? - debug(tripId, serviceDate, "Graph already contains trip id of ADDED trip, skipping."); + debug(tripId, serviceDate, "Graph already contains trip id of NEW trip, skipping."); return UpdateError.result(tripId, TRIP_ALREADY_EXISTS); } // Check whether a start date exists if (!tripDescriptor.hasStartDate()) { // TODO: should we support this and apply update to all days? - debug( - tripId, - serviceDate, - "ADDED trip doesn't have a start date in TripDescriptor, skipping." - ); + debug(tripId, serviceDate, "NEW trip doesn't have a start date in TripDescriptor, skipping."); return UpdateError.result(tripId, NO_START_DATE); } @@ -521,7 +515,7 @@ private Result validateAndHandleAddedTrip( // check if after filtering the stops we still have at least 2 if (stopTimeUpdates.size() < 2) { - debug(tripId, serviceDate, "ADDED trip has fewer than two known stops, skipping."); + debug(tripId, serviceDate, "NEW trip has fewer than two known stops, skipping."); return UpdateError.result(tripId, TOO_FEW_STOPS); } @@ -557,7 +551,7 @@ private List removeUnknownStops( debug( tripId, serviceDate, - "Stop '{}' not found in graph. Removing from ADDED trip.", + "Stop '{}' not found in graph. Removing from NEW trip.", st.getStopId() ); } @@ -567,7 +561,7 @@ private List removeUnknownStops( } /** - * Check stop time updates of trip update that results in a new trip (ADDED or MODIFIED) and find + * Check stop time updates of trip update that results in a new trip (NEW or REPLACEMENT) and find * all stops of that trip. * * @return stops when stop time updates are correct; null if there are errors @@ -582,11 +576,12 @@ private List checkNewStopTimeUpdatesAndFindStops( final List stops = new ArrayList<>(stopTimeUpdates.size()); for (int index = 0; index < stopTimeUpdates.size(); ++index) { - final StopTimeUpdate stopTimeUpdate = stopTimeUpdates.get(index); + final var addedStopTime = new AddedStopTime(stopTimeUpdates.get(index)); // Check stop sequence - if (stopTimeUpdate.hasStopSequence()) { - final Integer stopSequence = stopTimeUpdate.getStopSequence(); + final var optionalStopSequence = addedStopTime.stopSequence(); + if (optionalStopSequence.isPresent()) { + final var stopSequence = optionalStopSequence.getAsInt(); // Check non-negative if (stopSequence < 0) { @@ -601,14 +596,16 @@ private List checkNewStopTimeUpdatesAndFindStops( } previousStopSequence = stopSequence; } else { - // Allow missing stop sequences for ADDED and MODIFIED trips + // Allow missing stop sequences for NEW and REPLACEMENT trips } // Find stops - if (stopTimeUpdate.hasStopId()) { + final var optionalStopId = addedStopTime.stopId(); + if (optionalStopId.isPresent()) { + final var stopId = optionalStopId.get(); // Find stop final var stop = transitEditorService.getRegularStop( - new FeedScopedId(tripId.getFeedId(), stopTimeUpdate.getStopId()) + new FeedScopedId(tripId.getFeedId(), stopId) ); if (stop != null) { // Remember stop @@ -618,7 +615,7 @@ private List checkNewStopTimeUpdatesAndFindStops( tripId, serviceDate, "Graph doesn't contain stop id '{}' of trip update, skipping.", - stopTimeUpdate.getStopId() + stopId ); return null; } @@ -633,9 +630,10 @@ private List checkNewStopTimeUpdatesAndFindStops( } // Check arrival time - if (stopTimeUpdate.hasArrival() && stopTimeUpdate.getArrival().hasTime()) { + final var arrival = addedStopTime.arrivalTime(); + if (arrival.isPresent()) { + final var time = arrival.getAsLong(); // Check for increasing time - final Long time = stopTimeUpdate.getArrival().getTime(); if (previousTime != null && previousTime > time) { debug(tripId, serviceDate, "Trip update contains decreasing times, skipping."); return null; @@ -647,9 +645,10 @@ private List checkNewStopTimeUpdatesAndFindStops( } // Check departure time - if (stopTimeUpdate.hasDeparture() && stopTimeUpdate.getDeparture().hasTime()) { + final var departure = addedStopTime.departureTime(); + if (departure.isPresent()) { + final var time = departure.getAsLong(); // Check for increasing time - final Long time = stopTimeUpdate.getDeparture().getTime(); if (previousTime != null && previousTime > time) { debug(tripId, serviceDate, "Trip update contains decreasing times, skipping."); return null; @@ -664,7 +663,7 @@ private List checkNewStopTimeUpdatesAndFindStops( } /** - * Handle GTFS-RT TripUpdate message containing an ADDED trip. + * Handle GTFS-RT TripUpdate message containing an NEW trip. * * @param stopTimeUpdates GTFS-RT stop time updates * @param tripDescriptor GTFS-RT TripDescriptor @@ -713,7 +712,7 @@ private Result handleAddedTrip( debug( tripId, serviceDate, - "ADDED trip has service date {} for which no service id is available, skipping.", + "NEW trip has service date {} for which no service id is available, skipping.", serviceDate.toString() ); return UpdateError.result(tripId, NO_SERVICE_ON_DATE); @@ -721,6 +720,12 @@ private Result handleAddedTrip( // Just use first service id of set tripBuilder.withServiceId(serviceIds.iterator().next()); } + + var tripHeadsign = getTripHeadsign(tripUpdate); + if (tripHeadsign != null) { + tripBuilder.withHeadsign(new NonLocalizedString(tripHeadsign)); + } + return addTripToGraphAndBuffer( tripBuilder.build(), tripUpdate.getVehicle(), @@ -728,10 +733,22 @@ private Result handleAddedTrip( stops, serviceDate, RealTimeState.ADDED, - !routeExists + !routeExists, + tripHeadsign ); } + @Nullable + private static String getTripHeadsign(TripUpdate tripUpdate) { + if (tripUpdate.hasTripProperties()) { + var tripProperties = tripUpdate.getTripProperties(); + if (tripProperties.hasTripHeadsign()) { + return tripProperties.getTripHeadsign(); + } + } + return null; + } + private Route createRoute(TripDescriptor tripDescriptor, FeedScopedId tripId) { // the route in this update doesn't already exist, but the update contains the information so it will be created if ( @@ -813,7 +830,8 @@ private Result addTripToGraphAndBuffer( final List stops, final LocalDate serviceDate, final RealTimeState realTimeState, - final boolean isAddedRoute + final boolean isAddedRoute, + @Nullable final String tripHeadsign ) { // Preconditions Objects.requireNonNull(stops); @@ -830,7 +848,7 @@ private Result addTripToGraphAndBuffer( // Create StopTimes final List stopTimes = new ArrayList<>(stopTimeUpdates.size()); for (int index = 0; index < stopTimeUpdates.size(); ++index) { - final StopTimeUpdate stopTimeUpdate = stopTimeUpdates.get(index); + final var added = new AddedStopTime(stopTimeUpdates.get(index)); final var stop = stops.get(index); // Create stop time @@ -838,13 +856,15 @@ private Result addTripToGraphAndBuffer( stopTime.setTrip(trip); stopTime.setStop(stop); // Set arrival time - if (stopTimeUpdate.hasArrival() && stopTimeUpdate.getArrival().hasTime()) { - final long arrivalTime = stopTimeUpdate.getArrival().getTime() - midnightSecondsSinceEpoch; + final var arrival = added.arrivalTime(); + if (arrival.isPresent()) { + final var delay = added.arrivalDelay(); + final var arrivalTime = arrival.getAsLong() - midnightSecondsSinceEpoch - delay; if (arrivalTime < 0 || arrivalTime > MAX_ARRIVAL_DEPARTURE_TIME) { debug( trip.getId(), serviceDate, - "ADDED trip has invalid arrival time (compared to start date in " + + "NEW trip has invalid arrival time (compared to start date in " + "TripDescriptor), skipping." ); return UpdateError.result(trip.getId(), INVALID_ARRIVAL_TIME); @@ -852,14 +872,15 @@ private Result addTripToGraphAndBuffer( stopTime.setArrivalTime((int) arrivalTime); } // Set departure time - if (stopTimeUpdate.hasDeparture() && stopTimeUpdate.getDeparture().hasTime()) { - final long departureTime = - stopTimeUpdate.getDeparture().getTime() - midnightSecondsSinceEpoch; + final var departure = added.departureTime(); + if (departure.isPresent()) { + final var delay = added.departureDelay(); + final long departureTime = departure.getAsLong() - midnightSecondsSinceEpoch - delay; if (departureTime < 0 || departureTime > MAX_ARRIVAL_DEPARTURE_TIME) { debug( trip.getId(), serviceDate, - "ADDED trip has invalid departure time (compared to start date in " + + "NEW trip has invalid departure time (compared to start date in " + "TripDescriptor), skipping." ); return UpdateError.result(trip.getId(), INVALID_DEPARTURE_TIME); @@ -867,12 +888,19 @@ private Result addTripToGraphAndBuffer( stopTime.setDepartureTime((int) departureTime); } stopTime.setTimepoint(1); // Exact time - if (stopTimeUpdate.hasStopSequence()) { - stopTime.setStopSequence(stopTimeUpdate.getStopSequence()); - } - var added = AddedStopTime.ofStopTime(stopTimeUpdate); + added.stopSequence().ifPresent(stopTime::setStopSequence); stopTime.setPickupType(added.pickup()); stopTime.setDropOffType(added.dropOff()); + added + .stopHeadsign() + .ifPresentOrElse( + headsign -> stopTime.setStopHeadsign(new NonLocalizedString(headsign)), + () -> { + if (tripHeadsign != null) { + stopTime.setStopHeadsign(new NonLocalizedString(tripHeadsign)); + } + } + ); // Add stop time to list stopTimes.add(stopTime); } @@ -898,12 +926,23 @@ private Result addTripToGraphAndBuffer( ); // Update all times to mark trip times as realtime - // TODO: should we incorporate the delay field if present? + // TODO: This is based on the proposal at https://github.com/google/transit/issues/490 for (int stopIndex = 0; stopIndex < newTripTimes.getNumStops(); stopIndex++) { - newTripTimes.updateArrivalTime(stopIndex, newTripTimes.getScheduledArrivalTime(stopIndex)); + final var addedStopTime = new AddedStopTime(stopTimeUpdates.get(stopIndex)); + + if (addedStopTime.isSkipped()) { + newTripTimes.setCancelled(stopIndex); + } + + final int arrivalDelay = addedStopTime.arrivalDelay(); + final int departureDelay = addedStopTime.departureDelay(); + newTripTimes.updateArrivalTime( + stopIndex, + newTripTimes.getScheduledArrivalTime(stopIndex) + arrivalDelay + ); newTripTimes.updateDepartureTime( stopIndex, - newTripTimes.getScheduledDepartureTime(stopIndex) + newTripTimes.getScheduledDepartureTime(stopIndex) + departureDelay ); } @@ -1026,7 +1065,7 @@ private boolean cancelPreviouslyAddedTrip( } /** - * Validate and handle GTFS-RT TripUpdate message containing a MODIFIED trip. + * Validate and handle GTFS-RT TripUpdate message containing a REPLACEMENT trip. * * @param tripUpdate GTFS-RT TripUpdate message * @param tripDescriptor GTFS-RT TripDescriptor @@ -1050,8 +1089,7 @@ private Result validateAndHandleModifiedTrip( Trip trip = transitEditorService.getTrip(tripId); if (trip == null) { - // TODO: should we support this and consider it an ADDED trip? - debug(tripId, serviceDate, "Feed does not contain trip id of MODIFIED trip, skipping."); + debug(tripId, serviceDate, "Feed does not contain trip id of REPLACEMENT trip, skipping."); return UpdateError.result(tripId, TRIP_NOT_FOUND); } @@ -1137,7 +1175,8 @@ private Result handleModifiedTrip( stops, serviceDate, RealTimeState.MODIFIED, - false + false, + getTripHeadsign(tripUpdate) ); } diff --git a/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestConstants.java b/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestConstants.java index 87a9da83911..f34931a2a98 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestConstants.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestConstants.java @@ -18,6 +18,7 @@ public interface RealtimeTestConstants { String STOP_A1_ID = "A1"; String STOP_B1_ID = "B1"; String STOP_C1_ID = "C1"; + String STOP_D1_ID = "D1"; String TRIP_1_ID = "TestTrip1"; String TRIP_2_ID = "TestTrip2"; String OPERATOR_1_ID = "TestOperator1"; diff --git a/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestEnvironmentBuilder.java b/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestEnvironmentBuilder.java index eb31a555e1d..4f60d8de40d 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestEnvironmentBuilder.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/RealtimeTestEnvironmentBuilder.java @@ -58,11 +58,11 @@ public RealtimeTestEnvironment build() { return new RealtimeTestEnvironment(sourceType, timetableRepository); } - private Trip createTrip(TripInput tripInput) { + void createTrip(TripInput tripInput) { var trip = Trip .of(id(tripInput.id())) .withRoute(tripInput.route()) - .withHeadsign(I18NString.of("Headsign of %s".formatted(tripInput.id()))) + .withHeadsign(tripInput.headsign() == null ? null : I18NString.of(tripInput.headsign())) .withServiceId(SERVICE_ID) .build(); @@ -99,8 +99,6 @@ private Trip createTrip(TripInput tripInput) { .build(); timetableRepository.addTripPattern(pattern.getId(), pattern); - - return trip; } private static StopTime createStopTime( diff --git a/application/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java b/application/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java index d0e6f19a156..fe6a9abb7fa 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java @@ -1,21 +1,12 @@ package org.opentripplanner.updater.trip; import static com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship.CANCELED; -import static com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship.SKIPPED; -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.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.opentripplanner.updater.trip.BackwardsDelayPropagationType.REQUIRED_NO_DATA; import static org.opentripplanner.updater.trip.UpdateIncrementality.DIFFERENTIAL; -import com.google.transit.realtime.GtfsRealtime.TripDescriptor; -import com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship; import com.google.transit.realtime.GtfsRealtime.TripUpdate; -import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent; -import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate; import java.time.Duration; import java.time.LocalDate; import java.util.List; @@ -24,19 +15,12 @@ import org.opentripplanner.ConstantsForTests; import org.opentripplanner.TestOtpModel; import org.opentripplanner._support.time.ZoneIds; -import org.opentripplanner.model.Timetable; import org.opentripplanner.model.TimetableSnapshot; -import org.opentripplanner.transit.model.framework.FeedScopedId; -import org.opentripplanner.transit.model.network.TripPattern; -import org.opentripplanner.transit.model.timetable.RealTimeState; -import org.opentripplanner.transit.model.timetable.Trip; -import org.opentripplanner.transit.model.timetable.TripTimes; import org.opentripplanner.transit.service.DefaultTransitService; import org.opentripplanner.transit.service.TimetableRepository; import org.opentripplanner.transit.service.TransitService; import org.opentripplanner.updater.GtfsRealtimeFuzzyTripMatcher; import org.opentripplanner.updater.TimetableSnapshotSourceParameters; -import org.opentripplanner.utils.time.ServiceDateUtils; public class TimetableSnapshotSourceTest { @@ -81,206 +65,6 @@ public void testGetSnapshot() { assertSame(snapshot, updater.getTimetableSnapshot()); } - @Test - public void testHandleModifiedTrip() { - // GIVEN - - String modifiedTripId = "10.1"; - - TripUpdate tripUpdate; - { - final TripDescriptor.Builder tripDescriptorBuilder = TripDescriptor.newBuilder(); - - tripDescriptorBuilder.setTripId(modifiedTripId); - tripDescriptorBuilder.setScheduleRelationship(ScheduleRelationship.REPLACEMENT); - tripDescriptorBuilder.setStartDate(ServiceDateUtils.asCompactString(SERVICE_DATE)); - - final long midnightSecondsSinceEpoch = ServiceDateUtils - .asStartOfService(SERVICE_DATE, transitService.getTimeZone()) - .toEpochSecond(); - - final TripUpdate.Builder tripUpdateBuilder = TripUpdate.newBuilder(); - - tripUpdateBuilder.setTrip(tripDescriptorBuilder); - - { // Stop O - final StopTimeUpdate.Builder stopTimeUpdateBuilder = tripUpdateBuilder.addStopTimeUpdateBuilder(); - stopTimeUpdateBuilder.setScheduleRelationship( - StopTimeUpdate.ScheduleRelationship.SCHEDULED - ); - stopTimeUpdateBuilder.setStopId("O"); - stopTimeUpdateBuilder.setStopSequence(10); - - { // Arrival - final StopTimeEvent.Builder arrivalBuilder = stopTimeUpdateBuilder.getArrivalBuilder(); - arrivalBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (30 * 60)); - arrivalBuilder.setDelay(0); - } - - { // Departure - final StopTimeEvent.Builder departureBuilder = stopTimeUpdateBuilder.getDepartureBuilder(); - departureBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (30 * 60)); - departureBuilder.setDelay(0); - } - } - - { // Stop C - final StopTimeUpdate.Builder stopTimeUpdateBuilder = tripUpdateBuilder.addStopTimeUpdateBuilder(); - stopTimeUpdateBuilder.setScheduleRelationship( - StopTimeUpdate.ScheduleRelationship.SCHEDULED - ); - stopTimeUpdateBuilder.setStopId("C"); - stopTimeUpdateBuilder.setStopSequence(30); - - { // Arrival - final StopTimeEvent.Builder arrivalBuilder = stopTimeUpdateBuilder.getArrivalBuilder(); - arrivalBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (40 * 60)); - arrivalBuilder.setDelay(0); - } - - { // Departure - final StopTimeEvent.Builder departureBuilder = stopTimeUpdateBuilder.getDepartureBuilder(); - departureBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (45 * 60)); - departureBuilder.setDelay(0); - } - } - - { // Stop D - final StopTimeUpdate.Builder stopTimeUpdateBuilder = tripUpdateBuilder.addStopTimeUpdateBuilder(); - stopTimeUpdateBuilder.setScheduleRelationship(SKIPPED); - stopTimeUpdateBuilder.setStopId("D"); - stopTimeUpdateBuilder.setStopSequence(40); - - { // Arrival - final StopTimeEvent.Builder arrivalBuilder = stopTimeUpdateBuilder.getArrivalBuilder(); - arrivalBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (50 * 60)); - arrivalBuilder.setDelay(0); - } - - { // Departure - final StopTimeEvent.Builder departureBuilder = stopTimeUpdateBuilder.getDepartureBuilder(); - departureBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (51 * 60)); - departureBuilder.setDelay(0); - } - } - - { // Stop P - final StopTimeUpdate.Builder stopTimeUpdateBuilder = tripUpdateBuilder.addStopTimeUpdateBuilder(); - stopTimeUpdateBuilder.setScheduleRelationship( - StopTimeUpdate.ScheduleRelationship.SCHEDULED - ); - stopTimeUpdateBuilder.setStopId("P"); - stopTimeUpdateBuilder.setStopSequence(50); - - { // Arrival - final StopTimeEvent.Builder arrivalBuilder = stopTimeUpdateBuilder.getArrivalBuilder(); - arrivalBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (55 * 60)); - arrivalBuilder.setDelay(0); - } - - { // Departure - final StopTimeEvent.Builder departureBuilder = stopTimeUpdateBuilder.getDepartureBuilder(); - departureBuilder.setTime(midnightSecondsSinceEpoch + (12 * 3600) + (55 * 60)); - departureBuilder.setDelay(0); - } - } - - tripUpdate = tripUpdateBuilder.build(); - } - - var updater = defaultUpdater(); - - // WHEN - updater.applyTripUpdates( - TRIP_MATCHER_NOOP, - REQUIRED_NO_DATA, - DIFFERENTIAL, - List.of(tripUpdate), - feedId - ); - updater.flushBuffer(); - - // THEN - final TimetableSnapshot snapshot = updater.getTimetableSnapshot(); - - // Original trip pattern - { - final FeedScopedId tripId = new FeedScopedId(feedId, modifiedTripId); - final Trip trip = transitService.getTrip(tripId); - final TripPattern originalTripPattern = transitService.findPattern(trip); - - final Timetable originalTimetableForToday = snapshot.resolve( - originalTripPattern, - SERVICE_DATE - ); - final Timetable originalTimetableScheduled = snapshot.resolve(originalTripPattern, null); - - assertNotSame(originalTimetableForToday, originalTimetableScheduled); - - final int originalTripIndexScheduled = originalTimetableScheduled.getTripIndex( - modifiedTripId - ); - assertTrue( - originalTripIndexScheduled > -1, - "Original trip should be found in scheduled time table" - ); - final TripTimes originalTripTimesScheduled = originalTimetableScheduled.getTripTimes( - originalTripIndexScheduled - ); - assertFalse( - originalTripTimesScheduled.isCanceledOrDeleted(), - "Original trip times should not be canceled in scheduled time table" - ); - assertEquals(RealTimeState.SCHEDULED, originalTripTimesScheduled.getRealTimeState()); - - final int originalTripIndexForToday = originalTimetableForToday.getTripIndex(modifiedTripId); - assertTrue( - originalTripIndexForToday > -1, - "Original trip should be found in time table for service date" - ); - final TripTimes originalTripTimesForToday = originalTimetableForToday.getTripTimes( - originalTripIndexForToday - ); - assertTrue( - originalTripTimesForToday.isDeleted(), - "Original trip times should be deleted in time table for service date" - ); - assertEquals(RealTimeState.DELETED, originalTripTimesForToday.getRealTimeState()); - } - - // New trip pattern - { - final TripPattern newTripPattern = snapshot.getNewTripPatternForModifiedTrip( - new FeedScopedId(feedId, modifiedTripId), - SERVICE_DATE - ); - assertNotNull(newTripPattern, "New trip pattern should be found"); - - final Timetable newTimetableForToday = snapshot.resolve(newTripPattern, SERVICE_DATE); - final Timetable newTimetableScheduled = snapshot.resolve(newTripPattern, null); - - assertNotSame(newTimetableForToday, newTimetableScheduled); - - final int newTimetableForTodayModifiedTripIndex = newTimetableForToday.getTripIndex( - modifiedTripId - ); - assertTrue( - newTimetableForTodayModifiedTripIndex > -1, - "New trip should be found in time table for service date" - ); - assertEquals( - RealTimeState.MODIFIED, - newTimetableForToday.getTripTimes(newTimetableForTodayModifiedTripIndex).getRealTimeState() - ); - - assertEquals( - -1, - newTimetableScheduled.getTripIndex(modifiedTripId), - "New trip should not be found in scheduled time table" - ); - } - } - private TimetableSnapshotSource defaultUpdater() { return new TimetableSnapshotSource( new TimetableSnapshotSourceParameters(Duration.ZERO, true), diff --git a/application/src/test/java/org/opentripplanner/updater/trip/TripInput.java b/application/src/test/java/org/opentripplanner/updater/trip/TripInput.java index eb4f3685659..18b45bc6c1d 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/TripInput.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/TripInput.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; import org.opentripplanner.transit.model.network.Route; import org.opentripplanner.transit.model.site.RegularStop; import org.opentripplanner.utils.time.TimeUtils; @@ -10,7 +11,7 @@ * A simple data structure that is used by the {@link RealtimeTestEnvironment} to create * trips, trips on date and patterns. */ -public record TripInput(String id, Route route, List stops) { +public record TripInput(String id, Route route, List stops, @Nullable String headsign) { public static TripInputBuilder of(String id) { return new TripInputBuilder(id); } @@ -22,6 +23,9 @@ public static class TripInputBuilder implements RealtimeTestConstants { // can be made configurable if needed private Route route = ROUTE_1; + @Nullable + private String headsign; + TripInputBuilder(String id) { this.id = id; } @@ -34,13 +38,18 @@ public TripInputBuilder addStop(RegularStop stopId, String arrivalTime, String d } public TripInput build() { - return new TripInput(id, route, stops); + return new TripInput(id, route, stops, headsign); } public TripInputBuilder withRoute(Route route) { this.route = route; return this; } + + public TripInputBuilder withHeadsign(String headsign) { + this.headsign = headsign; + return this; + } } record StopCall(RegularStop stop, int arrivalTime, int departureTime) {} diff --git a/application/src/test/java/org/opentripplanner/updater/trip/TripUpdateBuilder.java b/application/src/test/java/org/opentripplanner/updater/trip/TripUpdateBuilder.java index e8218edfc1f..3606d9f22ef 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/TripUpdateBuilder.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/TripUpdateBuilder.java @@ -7,6 +7,7 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; +import javax.annotation.Nullable; import org.opentripplanner.utils.time.ServiceDateUtils; public class TripUpdateBuilder { @@ -37,32 +38,134 @@ public TripUpdateBuilder( this.midnight = ServiceDateUtils.asStartOfService(serviceDate, zoneId); } - public TripUpdateBuilder addStopTime(String stopId, int minutes) { + public TripUpdateBuilder( + String tripId, + LocalDate serviceDate, + GtfsRealtime.TripDescriptor.ScheduleRelationship scheduleRelationship, + ZoneId zoneId, + String tripHeadsign + ) { + this(tripId, serviceDate, scheduleRelationship, zoneId); + tripUpdateBuilder.setTripProperties( + GtfsRealtime.TripUpdate.TripProperties.newBuilder().setTripHeadsign(tripHeadsign).build() + ); + } + + public TripUpdateBuilder addStopTime(String stopId, int secondsFromMidnight) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + NO_DELAY, + NO_DELAY, + DEFAULT_SCHEDULE_RELATIONSHIP, + null, + null, + null, + NO_VALUE + ); + } + + public TripUpdateBuilder addStopTime(String stopId, int secondsFromMidnight, String headsign) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + NO_DELAY, + NO_DELAY, + DEFAULT_SCHEDULE_RELATIONSHIP, + null, + null, + headsign, + NO_VALUE + ); + } + + public TripUpdateBuilder addStopTimeWithDelay(String stopId, int secondsFromMidnight, int delay) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + delay, + delay, + DEFAULT_SCHEDULE_RELATIONSHIP, + null, + null, + null, + NO_VALUE + ); + } + + public TripUpdateBuilder addStopTimeWithScheduled( + String stopId, + int secondsFromMidnight, + int scheduledSeconds + ) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + NO_DELAY, + NO_DELAY, + DEFAULT_SCHEDULE_RELATIONSHIP, + null, + null, + null, + scheduledSeconds + ); + } + + public TripUpdateBuilder addStopTime( + String stopId, + int secondsFromMidnight, + DropOffPickupType pickDrop + ) { return addStopTime( stopId, - minutes, + secondsFromMidnight, NO_VALUE, NO_DELAY, NO_DELAY, DEFAULT_SCHEDULE_RELATIONSHIP, - null + pickDrop, + null, + null, + NO_VALUE ); } - public TripUpdateBuilder addStopTime(String stopId, int minutes, DropOffPickupType pickDrop) { + public TripUpdateBuilder addStopTime( + String stopId, + int secondsFromMidnight, + StopTimeUpdate.StopTimeProperties.DropOffPickupType pickDrop + ) { return addStopTime( stopId, - minutes, + secondsFromMidnight, NO_VALUE, NO_DELAY, NO_DELAY, DEFAULT_SCHEDULE_RELATIONSHIP, - pickDrop + null, + pickDrop, + null, + NO_VALUE ); } public TripUpdateBuilder addDelayedStopTime(int stopSequence, int delay) { - return addStopTime(null, -1, stopSequence, delay, delay, DEFAULT_SCHEDULE_RELATIONSHIP, null); + return addStopTime( + null, + -1, + stopSequence, + delay, + delay, + DEFAULT_SCHEDULE_RELATIONSHIP, + null, + null, + null, + NO_VALUE + ); } public TripUpdateBuilder addDelayedStopTime( @@ -77,7 +180,10 @@ public TripUpdateBuilder addDelayedStopTime( arrivalDelay, departureDelay, DEFAULT_SCHEDULE_RELATIONSHIP, - null + null, + null, + null, + NO_VALUE ); } @@ -92,7 +198,10 @@ public TripUpdateBuilder addNoDataStop(int stopSequence) { NO_DELAY, NO_DELAY, StopTimeUpdate.ScheduleRelationship.NO_DATA, - null + null, + null, + null, + NO_VALUE ); } @@ -107,7 +216,44 @@ public TripUpdateBuilder addSkippedStop(int stopSequence) { NO_DELAY, NO_DELAY, StopTimeUpdate.ScheduleRelationship.SKIPPED, - null + null, + null, + null, + NO_VALUE + ); + } + + public TripUpdateBuilder addSkippedStop(String stopId, int secondsFromMidnight) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + NO_DELAY, + NO_DELAY, + StopTimeUpdate.ScheduleRelationship.SKIPPED, + null, + null, + null, + NO_VALUE + ); + } + + public TripUpdateBuilder addSkippedStop( + String stopId, + int secondsFromMidnight, + DropOffPickupType pickDrop + ) { + return addStopTime( + stopId, + secondsFromMidnight, + NO_VALUE, + NO_DELAY, + NO_DELAY, + StopTimeUpdate.ScheduleRelationship.SKIPPED, + pickDrop, + null, + null, + NO_VALUE ); } @@ -121,13 +267,16 @@ public TripUpdateBuilder addRawStopTime(StopTimeUpdate stopTime) { } private TripUpdateBuilder addStopTime( - String stopId, - int minutes, + @Nullable String stopId, + int secondsFromMidnight, int stopSequence, int arrivalDelay, int departureDelay, StopTimeUpdate.ScheduleRelationship scheduleRelationShip, - DropOffPickupType pickDrop + @Nullable DropOffPickupType pickDrop, + @Nullable StopTimeUpdate.StopTimeProperties.DropOffPickupType gtfsPickDrop, + @Nullable String headsign, + int scheduledSeconds ) { final StopTimeUpdate.Builder stopTimeUpdateBuilder = tripUpdateBuilder.addStopTimeUpdateBuilder(); stopTimeUpdateBuilder.setScheduleRelationship(scheduleRelationShip); @@ -140,25 +289,41 @@ private TripUpdateBuilder addStopTime( stopTimeUpdateBuilder.setStopSequence(stopSequence); } - if (pickDrop != null) { + if (pickDrop != null || gtfsPickDrop != null || headsign != null) { var stopTimePropsBuilder = stopTimeUpdateBuilder.getStopTimePropertiesBuilder(); - var b = MfdzRealtimeExtensions.StopTimePropertiesExtension.newBuilder(); - b.setDropoffType(pickDrop); - b.setPickupType(pickDrop); - var ext = b.build(); - stopTimePropsBuilder.setExtension(MfdzRealtimeExtensions.stopTimeProperties, ext); + if (headsign != null) { + stopTimePropsBuilder.setStopHeadsign(headsign); + } + + if (gtfsPickDrop != null) { + stopTimePropsBuilder.setDropOffType(gtfsPickDrop); + stopTimePropsBuilder.setPickupType(gtfsPickDrop); + } + if (pickDrop != null) { + var b = MfdzRealtimeExtensions.StopTimePropertiesExtension.newBuilder(); + b.setDropoffType(pickDrop); + b.setPickupType(pickDrop); + var ext = b.build(); + stopTimePropsBuilder.setExtension(MfdzRealtimeExtensions.stopTimeProperties, ext); + } } final GtfsRealtime.TripUpdate.StopTimeEvent.Builder arrivalBuilder = stopTimeUpdateBuilder.getArrivalBuilder(); final GtfsRealtime.TripUpdate.StopTimeEvent.Builder departureBuilder = stopTimeUpdateBuilder.getDepartureBuilder(); - if (minutes > NO_VALUE) { - var epochSeconds = midnight.plusHours(8).plusMinutes(minutes).toEpochSecond(); + if (secondsFromMidnight > NO_VALUE) { + var epochSeconds = midnight.plusSeconds(secondsFromMidnight).toEpochSecond(); arrivalBuilder.setTime(epochSeconds); departureBuilder.setTime(epochSeconds); } + if (scheduledSeconds > NO_VALUE) { + var epochSeconds = midnight.plusSeconds(scheduledSeconds).toEpochSecond(); + arrivalBuilder.setScheduledTime(epochSeconds); + departureBuilder.setScheduledTime(epochSeconds); + } + if (arrivalDelay != NO_DELAY) { arrivalBuilder.setDelay(arrivalDelay); } diff --git a/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/AddedTest.java b/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/AddedTest.java index e926361f4ea..2f3c11befc1 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/AddedTest.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/AddedTest.java @@ -2,15 +2,19 @@ import static com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship.ADDED; 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.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.opentripplanner.updater.spi.UpdateResultAssertions.assertSuccess; +import com.google.transit.realtime.GtfsRealtime; import de.mfdz.MfdzRealtimeExtensions.StopTimePropertiesExtension.DropOffPickupType; import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.model.PickDrop; import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; import org.opentripplanner.transit.model.basic.TransitMode; @@ -47,7 +51,11 @@ void addedTripWithNewRoute() { var tripUpdate = new TripUpdateBuilder(ADDED_TRIP_ID, SERVICE_DATE, ADDED, TIME_ZONE) .addTripExtension() .addStopTime(STOP_A1_ID, 30, DropOffPickupType.PHONE_AGENCY) - .addStopTime(STOP_B1_ID, 40, DropOffPickupType.COORDINATE_WITH_DRIVER) + .addStopTime( + STOP_B1_ID, + 40, + GtfsRealtime.TripUpdate.StopTimeUpdate.StopTimeProperties.DropOffPickupType.COORDINATE_WITH_DRIVER + ) .addStopTime(STOP_B1_ID, 55, DropOffPickupType.NONE) .build(); @@ -124,11 +132,82 @@ void repeatedlyAddedTripWithNewRoute() { assertNotNull(env.getTransitService().getRoute(firstRoute.getId())); } - private TripPattern assertAddedTrip(String tripId, RealtimeTestEnvironment env) { + @Test + public void addedTripWithSkippedStop() { + var env = RealtimeTestEnvironment.gtfs().build(); + var builder = new TripUpdateBuilder(ADDED_TRIP_ID, SERVICE_DATE, ADDED, TIME_ZONE, "A loop"); + builder + .addStopTime(STOP_A1_ID, 30, DropOffPickupType.PHONE_AGENCY) + .addSkippedStop(STOP_B1_ID, 40, DropOffPickupType.COORDINATE_WITH_DRIVER) + .addSkippedStop(STOP_C1_ID, 48) + .addStopTime(STOP_D1_ID, 55, "A (non-stop)") + .addStopTime(STOP_A1_ID, 60); + var tripUpdate = builder.build(); + + env.applyTripUpdate(tripUpdate); + + // THEN + final TripPattern tripPattern = assertAddedTrip(ADDED_TRIP_ID, env); + assertEquals(PickDrop.CALL_AGENCY, tripPattern.getBoardType(0)); + assertEquals(PickDrop.CANCELLED, tripPattern.getAlightType(1)); + assertEquals(PickDrop.CANCELLED, tripPattern.getBoardType(1)); + assertEquals(PickDrop.CANCELLED, tripPattern.getAlightType(2)); + assertEquals(PickDrop.CANCELLED, tripPattern.getBoardType(2)); + assertEquals(PickDrop.SCHEDULED, tripPattern.getAlightType(3)); + var snapshot = env.getTimetableSnapshot(); + var forToday = snapshot.resolve(tripPattern, SERVICE_DATE); + var forTodayAddedTripIndex = forToday.getTripIndex(ADDED_TRIP_ID); + var tripTimes = forToday.getTripTimes(forTodayAddedTripIndex); + var trip = env.getTransitService().getTrip(TimetableRepositoryForTest.id(ADDED_TRIP_ID)); + assertEquals(I18NString.of("A loop"), Objects.requireNonNull(trip).getHeadsign()); + assertEquals(I18NString.of("A loop"), tripTimes.getHeadsign(0)); + assertFalse(tripTimes.isCancelledStop(0)); + assertTrue(tripTimes.isCancelledStop(1)); + assertTrue(tripTimes.isCancelledStop(2)); + assertFalse(tripTimes.isCancelledStop(3)); + assertEquals(I18NString.of("A (non-stop)"), tripTimes.getHeadsign(3)); + } + + @Test + public void addedTripWithDelay() { + var env = RealtimeTestEnvironment.gtfs().build(); + var builder = new TripUpdateBuilder(ADDED_TRIP_ID, SERVICE_DATE, ADDED, TIME_ZONE); + + builder + .addStopTime(STOP_A1_ID, 10000) + .addStopTimeWithDelay(STOP_B1_ID, 11300, 300) + .addStopTimeWithScheduled(STOP_C1_ID, 12500, 12000); + + var tripUpdate = builder.build(); + env.applyTripUpdate(tripUpdate); + + // THEN + var tripPattern = assertAddedTrip(ADDED_TRIP_ID, env); + var snapshot = env.getTimetableSnapshot(); + var forToday = snapshot.resolve(tripPattern, SERVICE_DATE); + var forTodayAddedTripIndex = forToday.getTripIndex(ADDED_TRIP_ID); + var tripTimes = forToday.getTripTimes(forTodayAddedTripIndex); + assertEquals(0, tripTimes.getDepartureDelay(0)); + assertEquals(10000, tripTimes.getDepartureTime(0)); + assertEquals(300, tripTimes.getArrivalDelay(1)); + assertEquals(11300, tripTimes.getArrivalTime(1)); + assertEquals(500, tripTimes.getArrivalDelay(2)); + assertEquals(12500, tripTimes.getArrivalTime(2)); + } + + private static TripPattern assertAddedTrip(String tripId, RealtimeTestEnvironment env) { + return assertAddedTrip(tripId, env, RealTimeState.ADDED); + } + + static TripPattern assertAddedTrip( + String tripId, + RealtimeTestEnvironment env, + RealTimeState realTimeState + ) { var snapshot = env.getTimetableSnapshot(); TransitService transitService = env.getTransitService(); - Trip trip = transitService.getTrip(TimetableRepositoryForTest.id(ADDED_TRIP_ID)); + Trip trip = transitService.getTrip(TimetableRepositoryForTest.id(tripId)); assertNotNull(trip); assertNotNull(transitService.findPattern(trip)); @@ -150,10 +229,7 @@ private TripPattern assertAddedTrip(String tripId, RealtimeTestEnvironment env) forTodayAddedTripIndex > -1, "Added trip should be found in time table for service date" ); - assertEquals( - RealTimeState.ADDED, - forToday.getTripTimes(forTodayAddedTripIndex).getRealTimeState() - ); + assertEquals(realTimeState, forToday.getTripTimes(forTodayAddedTripIndex).getRealTimeState()); final int scheduleTripIndex = schedule.getTripIndex(tripId); assertEquals(-1, scheduleTripIndex, "Added trip should not be found in scheduled time table"); diff --git a/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/ModifiedTest.java b/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/ModifiedTest.java new file mode 100644 index 00000000000..3ca93dbcae9 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/updater/trip/moduletests/addition/ModifiedTest.java @@ -0,0 +1,132 @@ +package org.opentripplanner.updater.trip.moduletests.addition; + +import static com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship.REPLACEMENT; +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.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.updater.trip.moduletests.addition.AddedTest.assertAddedTrip; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; +import org.opentripplanner.transit.model.timetable.RealTimeState; +import org.opentripplanner.updater.trip.RealtimeTestConstants; +import org.opentripplanner.updater.trip.RealtimeTestEnvironment; +import org.opentripplanner.updater.trip.TripInput; +import org.opentripplanner.updater.trip.TripUpdateBuilder; + +public class ModifiedTest implements RealtimeTestConstants { + + @Test + void modifiedTrip() { + var TRIP_INPUT = TripInput + .of(TRIP_1_ID) + .addStop(STOP_A1, "8:30:00", "8:30:00") + .addStop(STOP_B1, "8:40:00", "8:40:00") + .withHeadsign("Original Headsign") + .build(); + var env = RealtimeTestEnvironment.gtfs().addTrip(TRIP_INPUT).build(); + var builder = new TripUpdateBuilder( + TRIP_1_ID, + SERVICE_DATE, + REPLACEMENT, + TIME_ZONE, + "New Headsign" + ); + builder + .addStopTime(STOP_A1_ID, 30) + .addStopTime(STOP_B1_ID, 45, "Changed Headsign") + .addStopTime(STOP_C1_ID, 60); + + var tripUpdate = builder.build(); + + env.applyTripUpdate(tripUpdate); + + // THEN + var snapshot = env.getTimetableSnapshot(); + var tripId = TimetableRepositoryForTest.id(TRIP_1_ID); + + var transitService = env.getTransitService(); + // We do not support trip headsign by service date + // TODO: I currently have no idea how TripOnServiceDate will behave, and will need to revisit this after #5393 is merged + assertEquals( + I18NString.of("Original Headsign"), + transitService.getTrip(TimetableRepositoryForTest.id(TRIP_1_ID)).getHeadsign() + ); + + // Original trip pattern + { + var trip = transitService.getTrip(tripId); + var originalTripPattern = transitService.findPattern(trip); + + var originalTimetableForToday = snapshot.resolve(originalTripPattern, SERVICE_DATE); + var originalTimetableScheduled = snapshot.resolve(originalTripPattern, null); + + assertNotSame(originalTimetableForToday, originalTimetableScheduled); + + var originalTripIndexScheduled = originalTimetableScheduled.getTripIndex(TRIP_1_ID); + assertTrue( + originalTripIndexScheduled > -1, + "Original trip should be found in scheduled time table" + ); + var originalTripTimesScheduled = originalTimetableScheduled.getTripTimes( + originalTripIndexScheduled + ); + assertFalse( + originalTripTimesScheduled.isCanceledOrDeleted(), + "Original trip times should not be canceled in scheduled time table" + ); + assertEquals(RealTimeState.SCHEDULED, originalTripTimesScheduled.getRealTimeState()); + + var originalTripIndexForToday = originalTimetableForToday.getTripIndex(TRIP_1_ID); + assertTrue( + originalTripIndexForToday > -1, + "Original trip should be found in time table for service date" + ); + var originalTripTimesForToday = originalTimetableForToday.getTripTimes( + originalTripIndexForToday + ); + assertTrue( + originalTripTimesForToday.isDeleted(), + "Original trip times should be deleted in time table for service date" + ); + assertEquals(RealTimeState.DELETED, originalTripTimesForToday.getRealTimeState()); + assertEquals(I18NString.of("Original Headsign"), originalTripTimesScheduled.getHeadsign(0)); + assertEquals(I18NString.of("Original Headsign"), originalTripTimesScheduled.getHeadsign(1)); + assertEquals(I18NString.of("Original Headsign"), originalTripTimesForToday.getHeadsign(0)); + assertEquals(I18NString.of("Original Headsign"), originalTripTimesForToday.getHeadsign(1)); + } + + // New trip pattern + { + assertAddedTrip(TRIP_1_ID, env, RealTimeState.MODIFIED); + var newTripPattern = snapshot.getNewTripPatternForModifiedTrip(tripId, SERVICE_DATE); + assertNotNull(newTripPattern, "New trip pattern should be found"); + + var newTimetableForToday = snapshot.resolve(newTripPattern, SERVICE_DATE); + var newTimetableScheduled = snapshot.resolve(newTripPattern, null); + + assertNotSame(newTimetableForToday, newTimetableScheduled); + + var newTimetableForTodayModifiedTripIndex = newTimetableForToday.getTripIndex(TRIP_1_ID); + assertTrue( + newTimetableForTodayModifiedTripIndex > -1, + "New trip should be found in time table for service date" + ); + var tripTimes = newTimetableForToday.getTripTimes(newTimetableForTodayModifiedTripIndex); + assertEquals(RealTimeState.MODIFIED, tripTimes.getRealTimeState()); + + assertEquals( + -1, + newTimetableScheduled.getTripIndex(TRIP_1_ID), + "New trip should not be found in scheduled time table" + ); + + assertEquals(I18NString.of("New Headsign"), tripTimes.getHeadsign(0)); + assertEquals(I18NString.of("Changed Headsign"), tripTimes.getHeadsign(1)); + assertEquals(I18NString.of("New Headsign"), tripTimes.getHeadsign(2)); + } + } +} diff --git a/gtfs-realtime-protobuf/src/main/proto/gtfs-realtime.proto b/gtfs-realtime-protobuf/src/main/proto/gtfs-realtime.proto index 6c3e11ee53f..bd610ae4243 100644 --- a/gtfs-realtime-protobuf/src/main/proto/gtfs-realtime.proto +++ b/gtfs-realtime-protobuf/src/main/proto/gtfs-realtime.proto @@ -106,6 +106,8 @@ message FeedEntity { // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional Shape shape = 6; + optional Stop stop = 7; + optional TripModifications trip_modifications = 8; // The extensions namespace allows 3rd-party developers to extend the // GTFS Realtime Specification in order to add and evaluate new features and @@ -188,6 +190,11 @@ message TripUpdate { // To specify a completely certain prediction, set its uncertainty to 0. optional int32 uncertainty = 3; + // Scheduled time for a new or replacement trip. + // In Unix time (i.e., number of seconds since January 1st 1970 00:00:00 + // UTC). + optional int64 scheduled_time = 4; + // The extensions namespace allows 3rd-party developers to extend the // GTFS Realtime Specification in order to add and evaluate new features // and modifications to the spec. @@ -216,7 +223,7 @@ message TripUpdate { // Expected occupancy after departure from the given stop. // Should be provided only for future stops. // In order to provide departure_occupancy_status without either arrival or - // departure StopTimeEvents, ScheduleRelationship should be set to NO_DATA. + // departure StopTimeEvents, ScheduleRelationship should be set to NO_DATA. optional VehiclePosition.OccupancyStatus departure_occupancy_status = 7; // The relation between the StopTimeEvents and the static schedule. @@ -270,6 +277,32 @@ message TripUpdate { // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional string assigned_stop_id = 1; + // The updated headsign of the vehicle at the stop. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional string stop_headsign = 2; + + enum DropOffPickupType { + // Regularly scheduled pickup/dropoff. + REGULAR = 0; + + // No pickup/dropoff available + NONE = 1; + + // Must phone agency to arrange pickup/dropoff. + PHONE_AGENCY = 2; + + // Must coordinate with driver to arrange pickup/dropoff. + COORDINATE_WITH_DRIVER = 3; + } + + // The updated pickup of the vehicle at the stop. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional DropOffPickupType pickup_type = 3; + + // The updated drop off of the vehicle at the stop. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional DropOffPickupType drop_off_type = 4; + // The extensions namespace allows 3rd-party developers to extend the // GTFS Realtime Specification in order to add and evaluate new features // and modifications to the spec. @@ -340,7 +373,7 @@ message TripUpdate { optional int32 delay = 5; // Defines updated properties of the trip, such as a new shape_id when there is a detour. Or defines the - // trip_id, start_date, and start_time of a DUPLICATED trip. + // trip_id, start_date, and start_time of a DUPLICATED trip. // NOTE: This message is still experimental, and subject to change. It may be formally adopted in the future. message TripProperties { // Defines the identifier of a new trip that is a duplicate of an existing trip defined in (CSV) GTFS trips.txt @@ -374,9 +407,17 @@ message TripUpdate { // or a Shape in the (protobuf) real-time feed. The order of stops (stop sequences) for this trip must remain the same as // (CSV) GTFS. Stops that are a part of the original trip but will no longer be made, such as when a detour occurs, should // be marked as schedule_relationship=SKIPPED. - // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional string shape_id = 4; + // Specifies the headsign for this trip when it differs from the original. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional string trip_headsign = 5; + + // Specifies the name for this trip when it differs from the original. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional string trip_short_name = 6; + // The extensions namespace allows 3rd-party developers to extend the // GTFS Realtime Specification in order to add and evaluate new features // and modifications to the spec. @@ -450,7 +491,7 @@ message VehiclePosition { // The state of passenger occupancy for the vehicle or carriage. // Individual producers may not publish all OccupancyStatus values. Therefore, consumers // must not assume that the OccupancyStatus values follow a linear scale. - // Consumers should represent OccupancyStatus values as the state indicated + // Consumers should represent OccupancyStatus values as the state indicated // and intended by the producer. Likewise, producers must use OccupancyStatus values that // correspond to actual vehicle occupancy states. // For describing passenger occupancy levels on a linear scale, see `occupancy_percentage`. @@ -504,7 +545,7 @@ message VehiclePosition { // including both seated and standing capacity, and current operating regulations allow. // The value may exceed 100 if there are more passengers than the maximum designed capacity. // The precision of occupancy_percentage should be low enough that individual passengers cannot be tracked boarding or alighting the vehicle. - // If multi_carriage_status is populated with per-carriage occupancy_percentage, + // If multi_carriage_status is populated with per-carriage occupancy_percentage, // then this field should describe the entire vehicle with all carriages accepting passengers considered. // This field is still experimental, and subject to change. It may be formally adopted in the future. optional uint32 occupancy_percentage = 10; @@ -539,7 +580,7 @@ message VehiclePosition { // For example, the first carriage in the direction of travel has a value of 1. // If the second carriage in the direction of travel has a value of 3, // consumers will discard data for all carriages (i.e., the multi_carriage_details field). - // Carriages without data must be represented with a valid carriage_sequence number and the fields + // Carriages without data must be represented with a valid carriage_sequence number and the fields // without data should be omitted (alternately, those fields could also be included and set to the "no data" values). // This message/field is still experimental, and subject to change. It may be formally adopted in the future. optional uint32 carriage_sequence = 5; @@ -554,12 +595,12 @@ message VehiclePosition { } // Details of the multiple carriages of this given vehicle. - // The first occurrence represents the first carriage of the vehicle, - // given the current direction of travel. - // The number of occurrences of the multi_carriage_details + // The first occurrence represents the first carriage of the vehicle, + // given the current direction of travel. + // The number of occurrences of the multi_carriage_details // field represents the number of carriages of the vehicle. - // It also includes non boardable carriages, - // like engines, maintenance carriages, etc… as they provide valuable + // It also includes non boardable carriages, + // like engines, maintenance carriages, etc… as they provide valuable // information to passengers about where to stand on a platform. // This message/field is still experimental, and subject to change. It may be formally adopted in the future. repeated CarriageDetails multi_carriage_details = 11; @@ -648,7 +689,7 @@ message Alert { optional SeverityLevel severity_level = 14 [default = UNKNOWN_SEVERITY]; // TranslatedImage to be displayed along the alert text. Used to explain visually the alert effect of a detour, station closure, etc. The image must enhance the understanding of the alert. Any essential information communicated within the image must also be contained in the alert text. - // The following types of images are discouraged : image containing mainly text, marketing or branded images that add no additional information. + // The following types of images are discouraged : image containing mainly text, marketing or branded images that add no additional information. // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional TranslatedImage image = 15; @@ -791,14 +832,10 @@ message TripDescriptor { // enough to the scheduled trip to be associated with it. SCHEDULED = 0; - // An extra trip that was added in addition to a running schedule, for - // example, to replace a broken vehicle or to respond to sudden passenger - // load. - // NOTE: Currently, behavior is unspecified for feeds that use this mode. There are discussions on the GTFS GitHub - // [(1)](https://github.com/google/transit/issues/106) [(2)](https://github.com/google/transit/pull/221) - // [(3)](https://github.com/google/transit/pull/219) around fully specifying or deprecating ADDED trips and the - // documentation will be updated when those discussions are finalized. - ADDED = 1; + // This value has been deprecated as the behavior was unspecified. + // Use DUPLICATED for an extra trip that is the same as a scheduled trip except the start date or time, + // or NEW for an extra trip that is unrelated to an existing trip. + ADDED = 1 [deprecated = true]; // A trip that is running with no schedule associated to it (GTFS frequencies.txt exact_times=0). // Trips with ScheduleRelationship=UNSCHEDULED must also set all StopTimeUpdates.ScheduleRelationship=UNSCHEDULED. @@ -807,8 +844,8 @@ message TripDescriptor { // A trip that existed in the schedule but was removed. CANCELED = 3; - // Should not be used - for backwards-compatibility only. - REPLACEMENT = 5 [deprecated = true]; + // A trip that replaces an existing trip in the schedule. + REPLACEMENT = 5; // An extra trip that was added in addition to a running schedule, for example, to replace a broken vehicle or to // respond to sudden passenger load. Used with TripUpdate.TripProperties.trip_id, TripUpdate.TripProperties.start_date, @@ -816,8 +853,9 @@ message TripDescriptor { // date and/or time. Duplicating a trip is allowed if the service related to the original trip in (CSV) GTFS // (in calendar.txt or calendar_dates.txt) is operating within the next 30 days. The trip to be duplicated is // identified via TripUpdate.TripDescriptor.trip_id. This enumeration does not modify the existing trip referenced by - // TripUpdate.TripDescriptor.trip_id - if a producer wants to cancel the original trip, it must publish a separate - // TripUpdate with the value of CANCELED or DELETED. Trips defined in GTFS frequencies.txt with exact_times that is + // TripUpdate.TripDescriptor.trip_id - if a producer wants to replace the original trip, a value of `REPLACEMENT` should be used instead. + // + // Trips defined in GTFS frequencies.txt with exact_times that is // empty or equal to 0 cannot be duplicated. The VehiclePosition.TripDescriptor.trip_id for the new trip must contain // the matching value from TripUpdate.TripProperties.trip_id and VehiclePosition.TripDescriptor.ScheduleRelationship // must also be set to DUPLICATED. @@ -837,9 +875,39 @@ message TripDescriptor { // real-time predictions. // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. DELETED = 7; + + // An extra trip unrelated to any existing trips, for example, to respond to sudden passenger load. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + NEW = 8; } optional ScheduleRelationship schedule_relationship = 4; + message ModifiedTripSelector { + // The 'id' from the FeedEntity in which the contained TripModifications object affects this trip. + optional string modifications_id = 1; + + // The trip_id from the GTFS feed that is modified by the modifications_id + optional string affected_trip_id = 2; + + // The initially scheduled start time of this trip instance, applied to the frequency based modified trip. Same definition as start_time in TripDescriptor. + optional string start_time = 3; + + // The start date of this trip instance in YYYYMMDD format, applied to the modified trip. Same definition as start_date in TripDescriptor. + optional string start_date = 4; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; + } + + // Linkage to any modifications done to this trip (shape changes, removal or addition of stops). + // If this field is provided, the `trip_id`, `route_id`, `direction_id`, `start_time`, `start_date` fields of the `TripDescriptor` MUST be left empty, to avoid confusion by consumers that aren't looking for the `ModifiedTripSelector` value. + optional ModifiedTripSelector modified_trip = 7; + // The extensions namespace allows 3rd-party developers to extend the // GTFS Realtime Specification in order to add and evaluate new features and // modifications to the spec. @@ -970,12 +1038,12 @@ message TranslatedString { message TranslatedImage { message LocalizedImage { // String containing an URL linking to an image - // The image linked must be less than 2MB. + // The image linked must be less than 2MB. // If an image changes in a significant enough way that an update is required on the consumer side, the producer must update the URL to a new one. - // The URL should be a fully qualified URL that includes http:// or https://, and any special characters in the URL must be correctly escaped. See the following http://www.w3.org/Addressing/URL/4_URI_Recommentations.html for a description of how to create fully qualified URL values. + // The URL should be a fully qualified URL that includes http:// or https://, and any special characters in the URL must be correctly escaped. See the following http://www.w3.org/Addressing/URL/4_URI_Recommentations.html for a description of how to create fully qualified URL values. required string url = 1; - // IANA media type as to specify the type of image to be displayed. + // IANA media type as to specify the type of image to be displayed. // The type must start with "image/" required string media_type = 2; @@ -1033,3 +1101,148 @@ message Shape { // The following extension IDs are reserved for private use by any organization. extensions 9000 to 9999; } + +// Describes a stop which is served by trips. All fields are as described in the GTFS-Static specification. +// NOTE: This message is still experimental, and subject to change. It may be formally adopted in the future. +message Stop { + enum WheelchairBoarding { + UNKNOWN = 0; + AVAILABLE = 1; + NOT_AVAILABLE = 2; + } + + optional string stop_id = 1; + optional TranslatedString stop_code = 2; + optional TranslatedString stop_name = 3; + optional TranslatedString tts_stop_name = 4; + optional TranslatedString stop_desc = 5; + optional float stop_lat = 6; + optional float stop_lon = 7; + optional string zone_id = 8; + optional TranslatedString stop_url = 9; + optional string parent_station = 11; + optional string stop_timezone = 12; + optional WheelchairBoarding wheelchair_boarding = 13 [default = UNKNOWN]; + optional string level_id = 14; + optional TranslatedString platform_code = 15; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; +} + +// NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. +message TripModifications { + // A `Modification` message replaces a span of n stop times from each affected trip starting at `start_stop_selector`. + message Modification { + // The stop selector of the first stop_time of the original trip that is to be affected by this modification. + // Used in conjuction with `end_stop_selector`. + // `start_stop_selector` is required and is used to define the reference stop used with `travel_time_to_stop`. + optional StopSelector start_stop_selector = 1; + + // The stop selector of the last stop of the original trip that is to be affected by this modification. + // The selection is inclusive, so if only one stop_time is replaced by that modification, `start_stop_selector` and `end_stop_selector` must be equivalent. + // If no stop_time is replaced, `end_stop_selector` must not be provided. It's otherwise required. + optional StopSelector end_stop_selector = 2; + + // The number of seconds of delay to add to all departure and arrival times following the end of this modification. + // If multiple modifications apply to the same trip, the delays accumulate as the trip advances. + optional int32 propagated_modification_delay = 3 [default = 0]; + + // A list of replacement stops, replacing those of the original trip. + // The length of the new stop times may be less, the same, or greater than the number of replaced stop times. + repeated ReplacementStop replacement_stops = 4; + + // An `id` value from the `FeedEntity` message that contains the `Alert` describing this Modification for user-facing communication. + optional string service_alert_id = 5; + + // This timestamp identifies the moment when the modification has last been changed. + // In POSIX time (i.e., number of seconds since January 1st 1970 00:00:00 UTC). + optional uint64 last_modified_time = 6; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; + } + + message SelectedTrips { + // A list of trips affected with this replacement that all have the same new `shape_id`. A `TripUpdate` with `schedule_relationship=REPLACEMENT` must not already exist for the trip. + repeated string trip_ids = 1; + // The ID of the new shape for the modified trips in this SelectedTrips. + // May refer to a new shape added using a GTFS-RT Shape message, or to an existing shape defined in the GTFS-Static feed’s shapes.txt. + optional string shape_id = 2; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; + } + + // A list of selected trips affected by this TripModifications. + repeated SelectedTrips selected_trips = 1; + + // A list of start times in the real-time trip descriptor for the trip_id defined in trip_ids. + // Useful to target multiple departures of a trip_id in a frequency-based trip. + repeated string start_times = 2; + + // Dates on which the modifications occurs, in the YYYYMMDD format. Producers SHOULD only transmit detours occurring within the next week. + // The dates provided should not be used as user-facing information, if a user-facing start and end date needs to be provided, they can be provided in the linked service alert with `service_alert_id` + repeated string service_dates = 3; + + // A list of modifications to apply to the affected trips. + repeated Modification modifications = 4; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; +} + +// NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. +// Select a stop by stop sequence or by stop_id. At least one of the two values must be provided. +message StopSelector { + // Must be the same as in stop_times.txt in the corresponding GTFS feed. + optional uint32 stop_sequence = 1; + // Must be the same as in stops.txt in the corresponding GTFS feed. + optional string stop_id = 2; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; +} + +// NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. +message ReplacementStop { + // The difference in seconds between the arrival time at this stop and the arrival time at the reference stop. The reference stop is the stop prior to start_stop_selector. If the modification begins at the first stop of the trip, then the first stop of the trip is the reference stop. + // This value MUST be monotonically increasing and may only be a negative number if the first stop of the original trip is the reference stop. + optional int32 travel_time_to_stop = 1; + + // The replacement stop ID which will now be visited by the trip. May refer to a new stop added using a GTFS-RT Stop message, or to an existing stop defined in the GTFS-Static feed’s stops.txt. The stop MUST have location_type=0 (routable stops). + optional string stop_id = 2; + + // The extensions namespace allows 3rd-party developers to extend the + // GTFS Realtime Specification in order to add and evaluate new features and + // modifications to the spec. + extensions 1000 to 1999; + + // The following extension IDs are reserved for private use by any organization. + extensions 9000 to 9999; +} \ No newline at end of file