diff --git a/src/ext/graphql/transmodelapi/schema.graphql b/src/ext/graphql/transmodelapi/schema.graphql index 21bfc6dd856..a8d405d6443 100644 --- a/src/ext/graphql/transmodelapi/schema.graphql +++ b/src/ext/graphql/transmodelapi/schema.graphql @@ -771,6 +771,8 @@ type QueryType { ignoreRealtimeUpdates: Boolean = false, "When true, service journeys cancelled in scheduled route data will be included during this search." includePlannedCancellations: Boolean = false, + "When true, service journeys cancelled by real-time updates will be included during this search." + includeRealtimeCancellations: Boolean = false, "Configure the itinerary-filter-chain. NOTE! THESE PARAMETERS ARE USED FOR SERVER-SIDE TUNING AND IS AVAILABLE HERE FOR TESTING ONLY." itineraryFilters: ItineraryFilters, "The preferable language to use for text targeted the end user. Note! The data quality is limited, only stop and quay names are translates, and not in all places of the API." diff --git a/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java b/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java index d5a69df4849..a79126444b8 100644 --- a/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java +++ b/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java @@ -987,13 +987,13 @@ private Result> handleModifiedTrip( if (tripTimes.getNumStops() == pattern.numberOfStops()) { // All tripTimes should be handled the same way to always allow latest realtime-update to // replace previous update regardless of realtimestate - cancelScheduledTrip(trip, serviceDate); + markScheduledTripAsDeleted(trip, serviceDate); // Also check whether trip id has been used for previously ADDED/MODIFIED trip message and // remove the previously created trip removePreviousRealtimeUpdate(trip, serviceDate); - if (!tripTimes.isCanceled()) { + if (!tripTimes.isDeleted()) { // Calculate modified stop-pattern var modifiedStops = createModifiedStops( pattern, @@ -1008,6 +1008,7 @@ private Result> handleModifiedTrip( ); if (modifiedStops != null && modifiedStops.isEmpty()) { + // Empty modified stops means that there is no calls for the trip, cancel it tripTimes.cancelTrip(); } else { // Add new trip @@ -1167,26 +1168,26 @@ private void addTripOnServiceDateToBuffer( } /** - * Cancel scheduled trip in buffer given trip on service date + * Mark the scheduled trip in the buffer as deleted, given trip on service date * * @param serviceDate service date - * @return true if scheduled trip was cancelled + * @return true if scheduled trip was marked as deleted */ - private boolean cancelScheduledTrip(Trip trip, final LocalDate serviceDate) { + private boolean markScheduledTripAsDeleted(Trip trip, final LocalDate serviceDate) { boolean success = false; final TripPattern pattern = transitService.getPatternForTrip(trip); if (pattern != null) { - // Cancel scheduled trip times for this trip in this pattern + // Mark scheduled trip times for this trip in this pattern as deleted final Timetable timetable = pattern.getScheduledTimetable(); final TripTimes tripTimes = timetable.getTripTimes(trip); if (tripTimes == null) { - LOG.warn("Could not cancel scheduled trip {}", trip.getId()); + LOG.warn("Could not mark scheduled trip as deleted {}", trip.getId()); } else { final TripTimes newTripTimes = new TripTimes(tripTimes); - newTripTimes.cancelTrip(); + newTripTimes.deleteTrip(); buffer.update(pattern, newTripTimes, serviceDate); success = true; } diff --git a/src/ext/java/org/opentripplanner/ext/transmodelapi/TransmodelGraphQLPlanner.java b/src/ext/java/org/opentripplanner/ext/transmodelapi/TransmodelGraphQLPlanner.java index 82bd03f614d..8890c18e9a7 100644 --- a/src/ext/java/org/opentripplanner/ext/transmodelapi/TransmodelGraphQLPlanner.java +++ b/src/ext/java/org/opentripplanner/ext/transmodelapi/TransmodelGraphQLPlanner.java @@ -445,6 +445,7 @@ private void mapPreferences( }); callWith.argument("ignoreRealtimeUpdates", tr::setIgnoreRealtimeUpdates); callWith.argument("includePlannedCancellations", tr::setIncludePlannedCancellations); + callWith.argument("includeRealtimeCancellations", tr::setIncludeRealtimeCancellations); callWith.argument( "relaxTransitSearchGeneralizedCostAtDestination", (Double value) -> tr.withRaptor(it -> it.withRelaxGeneralizedCostAtDestination(value)) diff --git a/src/ext/java/org/opentripplanner/ext/transmodelapi/model/plan/TripQuery.java b/src/ext/java/org/opentripplanner/ext/transmodelapi/model/plan/TripQuery.java index 61a1617a0d9..2f13856cf41 100644 --- a/src/ext/java/org/opentripplanner/ext/transmodelapi/model/plan/TripQuery.java +++ b/src/ext/java/org/opentripplanner/ext/transmodelapi/model/plan/TripQuery.java @@ -184,6 +184,17 @@ public static GraphQLFieldDefinition create( .defaultValue(preferences.transit().includePlannedCancellations()) .build() ) + .argument( + GraphQLArgument + .newArgument() + .name("includeRealtimeCancellations") + .description( + "When true, service journeys cancelled by real-time updates will be included during this search." + ) + .type(Scalars.GraphQLBoolean) + .defaultValue(preferences.transit().includeRealtimeCancellations()) + .build() + ) .argument( GraphQLArgument .newArgument() diff --git a/src/main/java/org/opentripplanner/model/TripTimeOnDate.java b/src/main/java/org/opentripplanner/model/TripTimeOnDate.java index 92be7ee2e73..e3f846166bf 100644 --- a/src/main/java/org/opentripplanner/model/TripTimeOnDate.java +++ b/src/main/java/org/opentripplanner/model/TripTimeOnDate.java @@ -13,6 +13,8 @@ import org.opentripplanner.transit.model.timetable.StopTimeKey; import org.opentripplanner.transit.model.timetable.Trip; import org.opentripplanner.transit.model.timetable.TripTimes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Represents a Trip at a specific stop index and on a specific service day. This is a read-only @@ -20,6 +22,8 @@ */ public class TripTimeOnDate { + private static final Logger LOG = LoggerFactory.getLogger(TripTimeOnDate.class); + public static final int UNDEFINED = -1; private final TripTimes tripTimes; @@ -169,7 +173,7 @@ public boolean isPredictionInaccurate() { public boolean isCanceledEffectively() { return ( isCancelledStop() || - tripTimes.isCanceled() || + tripTimes.isCanceledOrDeleted() || tripTimes.getTrip().getNetexAlteration().isCanceledOrReplaced() ); } @@ -213,12 +217,34 @@ public List getHeadsignVias() { } public PickDrop getPickupType() { + if (tripTimes.isDeleted()) { + LOG.warn( + "Returning pickup type for a deleted trip {} on pattern {} on date {}. This indicates a bug.", + tripTimes.getTrip().getId(), + tripPattern.getId(), + serviceDate + ); + + return tripPattern.getBoardType(stopIndex); + } + return tripTimes.isCanceled() || tripTimes.isCancelledStop(stopIndex) ? PickDrop.CANCELLED : tripPattern.getBoardType(stopIndex); } public PickDrop getDropoffType() { + if (tripTimes.isDeleted()) { + LOG.warn( + "Returning dropoff type for a deleted trip {} on pattern {} on date {}. This indicates a bug.", + tripTimes.getTrip().getId(), + tripPattern.getId(), + serviceDate + ); + + return tripPattern.getAlightType(stopIndex); + } + return tripTimes.isCanceled() || tripTimes.isCancelledStop(stopIndex) ? PickDrop.CANCELLED : tripPattern.getAlightType(stopIndex); diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/TripPatternForDateMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/TripPatternForDateMapper.java index 23e674d10dd..ef28b43d8d1 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/TripPatternForDateMapper.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/mappers/TripPatternForDateMapper.java @@ -13,7 +13,6 @@ import org.opentripplanner.model.Timetable; import org.opentripplanner.routing.algorithm.raptoradapter.transit.TripPatternForDate; import org.opentripplanner.transit.model.timetable.FrequencyEntry; -import org.opentripplanner.transit.model.timetable.RealTimeState; import org.opentripplanner.transit.model.timetable.TripTimes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -72,7 +71,7 @@ public TripPatternForDate map(Timetable timetable, LocalDate serviceDate) { if (!serviceCodesRunning.contains(tripTimes.getServiceCode())) { continue; } - if (tripTimes.getRealTimeState() == RealTimeState.CANCELED) { + if (tripTimes.isDeleted()) { continue; } diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilter.java b/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilter.java index 1304fb2adf5..b6b8659648f 100644 --- a/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilter.java +++ b/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilter.java @@ -29,6 +29,8 @@ public class RouteRequestTransitDataProviderFilter implements TransitDataProvide private final boolean includePlannedCancellations; + private final boolean includeRealtimeCancellations; + private final List filters; private final Set bannedTrips; @@ -46,6 +48,7 @@ public RouteRequestTransitDataProviderFilter( request.wheelchair(), request.preferences().wheelchair(), request.preferences().transit().includePlannedCancellations(), + request.preferences().transit().includeRealtimeCancellations(), Set.copyOf(request.journey().transit().bannedTrips()), bannedRoutes(request.journey().transit().filters(), transitService.getAllRoutes()), request.journey().transit().filters() @@ -58,6 +61,7 @@ public RouteRequestTransitDataProviderFilter( boolean wheelchairEnabled, WheelchairPreferences wheelchairPreferences, boolean includePlannedCancellations, + boolean includeRealtimeCancellations, Set bannedTrips, Set bannedRoutes, List filters @@ -66,6 +70,7 @@ public RouteRequestTransitDataProviderFilter( this.wheelchairEnabled = wheelchairEnabled; this.wheelchairPreferences = wheelchairPreferences; this.includePlannedCancellations = includePlannedCancellations; + this.includeRealtimeCancellations = includeRealtimeCancellations; this.bannedRoutes = Set.copyOf(bannedRoutes); this.bannedTrips = bannedTrips; this.filters = filters; @@ -110,12 +115,17 @@ public boolean tripTimesPredicate(TripTimes tripTimes, boolean withFilters) { } if (!includePlannedCancellations) { - //noinspection RedundantIfStatement if (trip.getNetexAlteration().isCanceledOrReplaced()) { return false; } } + if (!includeRealtimeCancellations) { + if (tripTimes.isCanceled()) { + return false; + } + } + if (bannedTrips.contains(trip.getId())) { return false; } diff --git a/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java b/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java index 984794c983f..e8b96065b8a 100644 --- a/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java +++ b/src/main/java/org/opentripplanner/routing/api/request/preference/TransitPreferences.java @@ -31,6 +31,7 @@ public final class TransitPreferences implements Serializable { private final DoubleAlgorithmFunction unpreferredCost; private final boolean ignoreRealtimeUpdates; private final boolean includePlannedCancellations; + private final boolean includeRealtimeCancellations; private final RaptorPreferences raptor; private TransitPreferences() { @@ -40,6 +41,7 @@ private TransitPreferences() { this.unpreferredCost = createLinearFunction(0.0, DEFAULT_ROUTE_RELUCTANCE); this.ignoreRealtimeUpdates = false; this.includePlannedCancellations = false; + this.includeRealtimeCancellations = false; this.raptor = RaptorPreferences.DEFAULT; } @@ -51,6 +53,7 @@ private TransitPreferences(Builder builder) { this.unpreferredCost = requireNonNull(builder.unpreferredCost); this.ignoreRealtimeUpdates = builder.ignoreRealtimeUpdates; this.includePlannedCancellations = builder.includePlannedCancellations; + this.includeRealtimeCancellations = builder.includeRealtimeCancellations; this.raptor = requireNonNull(builder.raptor); } @@ -138,6 +141,13 @@ public boolean includePlannedCancellations() { return includePlannedCancellations; } + /** + * When true, trips cancelled in by real-time updates are included in this search. + */ + public boolean includeRealtimeCancellations() { + return includeRealtimeCancellations; + } + /** * Set of options to use with Raptor. These are available here for testing purposes. */ @@ -154,6 +164,7 @@ public boolean equals(Object o) { otherThanPreferredRoutesPenalty == that.otherThanPreferredRoutesPenalty && ignoreRealtimeUpdates == that.ignoreRealtimeUpdates && includePlannedCancellations == that.includePlannedCancellations && + includeRealtimeCancellations == that.includeRealtimeCancellations && boardSlack.equals(that.boardSlack) && alightSlack.equals(that.alightSlack) && reluctanceForMode.equals(that.reluctanceForMode) && @@ -172,6 +183,7 @@ public int hashCode() { unpreferredCost, ignoreRealtimeUpdates, includePlannedCancellations, + includeRealtimeCancellations, raptor ); } @@ -197,6 +209,10 @@ public String toString() { "includePlannedCancellations", includePlannedCancellations != DEFAULT.includePlannedCancellations ) + .addBoolIfTrue( + "includeRealtimeCancellations", + includeRealtimeCancellations != DEFAULT.includeRealtimeCancellations + ) .addObj("raptor", raptor, DEFAULT.raptor) .toString(); } @@ -213,6 +229,7 @@ public static class Builder { private DoubleAlgorithmFunction unpreferredCost; private boolean ignoreRealtimeUpdates; private boolean includePlannedCancellations; + private boolean includeRealtimeCancellations; private RaptorPreferences raptor; public Builder(TransitPreferences original) { @@ -224,6 +241,7 @@ public Builder(TransitPreferences original) { this.unpreferredCost = original.unpreferredCost; this.ignoreRealtimeUpdates = original.ignoreRealtimeUpdates; this.includePlannedCancellations = original.includePlannedCancellations; + this.includeRealtimeCancellations = original.includeRealtimeCancellations; this.raptor = original.raptor; } @@ -279,6 +297,11 @@ public Builder setIncludePlannedCancellations(boolean includePlannedCancellation return this; } + public Builder setIncludeRealtimeCancellations(boolean includeRealtimeCancellations) { + this.includeRealtimeCancellations = includeRealtimeCancellations; + return this; + } + public Builder withRaptor(Consumer body) { this.raptor = raptor.copyOf().apply(body).build(); return this; diff --git a/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java b/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java index e61b43753c3..4426122d84d 100644 --- a/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java +++ b/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java @@ -295,6 +295,10 @@ private static boolean isReplacedByAnotherPattern( } public static boolean skipByTripCancellation(TripTimes tripTimes, boolean includeCancellations) { + if (tripTimes.isDeleted()) { + return true; + } + return ( (tripTimes.isCanceled() || tripTimes.getTrip().getNetexAlteration().isCanceledOrReplaced()) && !includeCancellations diff --git a/src/main/java/org/opentripplanner/transit/model/timetable/RealTimeState.java b/src/main/java/org/opentripplanner/transit/model/timetable/RealTimeState.java index 86d335db3cc..b0c818f9138 100644 --- a/src/main/java/org/opentripplanner/transit/model/timetable/RealTimeState.java +++ b/src/main/java/org/opentripplanner/transit/model/timetable/RealTimeState.java @@ -31,4 +31,10 @@ public enum RealTimeState { * trip pattern of the scheduled trip. */ MODIFIED, + + /** + * The trip should not be visible to the end user. Either it has been set as deleted in the + * real-time feed, or it has been replaced by another trip on another pattern. + */ + DELETED, } diff --git a/src/main/java/org/opentripplanner/transit/model/timetable/TripTimes.java b/src/main/java/org/opentripplanner/transit/model/timetable/TripTimes.java index 823fa976742..c3fc4aaccf1 100644 --- a/src/main/java/org/opentripplanner/transit/model/timetable/TripTimes.java +++ b/src/main/java/org/opentripplanner/transit/model/timetable/TripTimes.java @@ -336,6 +336,13 @@ public boolean isScheduled() { return realTimeState == RealTimeState.SCHEDULED; } + /** + * @return true if this TripTimes is canceled or soft-deleted + */ + public boolean isCanceledOrDeleted() { + return isCanceled() || isDeleted(); + } + /** * @return true if this TripTimes is canceled */ @@ -343,6 +350,13 @@ public boolean isCanceled() { return realTimeState == RealTimeState.CANCELED; } + /** + * @return true if this TripTimes is soft-deleted, and should not be visible to the user + */ + public boolean isDeleted() { + return realTimeState == RealTimeState.DELETED; + } + /** * @return the real-time state of this TripTimes */ @@ -384,6 +398,11 @@ public void cancelTrip() { realTimeState = RealTimeState.CANCELED; } + /** Soft delete the entire trip */ + public void deleteTrip() { + realTimeState = RealTimeState.DELETED; + } + public void updateDepartureTime(final int stop, final int time) { prepareForRealTimeUpdates(); departureTimes[stop] = time; diff --git a/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java b/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java index 330b55c4e5a..7a79a8dd25a 100644 --- a/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java +++ b/src/main/java/org/opentripplanner/updater/trip/TimetableSnapshotSource.java @@ -290,7 +290,8 @@ public UpdateResult applyTripUpdates( tripId, serviceDate ); - case CANCELED -> handleCanceledTrip(tripId, serviceDate); + case CANCELED -> handleCanceledTrip(tripId, serviceDate, CancelationType.CANCEL); + case DELETED -> handleCanceledTrip(tripId, serviceDate, CancelationType.DELETE); case REPLACEMENT -> validateAndHandleModifiedTrip( tripUpdate, tripDescriptor, @@ -412,9 +413,10 @@ private Result handleScheduledTrip( return UpdateError.result(tripId, NO_UPDATES); } - // If this trip_id has been used for previously ADDED/MODIFIED trip message (e.g. when the sequence of stops has - // changed, and is now changing back to the originally scheduled one) cancel that previously created trip. - cancelPreviouslyAddedTrip(tripId, serviceDate); + // If this trip_id has been used for previously ADDED/MODIFIED trip message (e.g. when the + // sequence of stops has changed, and is now changing back to the originally scheduled one), + // mark that previously created trip as DELETED. + cancelPreviouslyAddedTrip(tripId, serviceDate, CancelationType.DELETE); // Get new TripTimes based on scheduled timetable var result = pattern @@ -451,7 +453,7 @@ private Result handleScheduledTrip( pattern ); - cancelScheduledTrip(tripId, serviceDate); + cancelScheduledTrip(tripId, serviceDate, CancelationType.DELETE); return buffer.update(newPattern, updatedTripTimes, serviceDate); } else { // Set the updated trip times in the buffer @@ -658,9 +660,9 @@ private Result handleAddedTrip( "number of stop should match the number of stop time updates" ); - // Check whether trip id has been used for previously ADDED trip message and cancel - // previously created trip - cancelPreviouslyAddedTrip(tripId, serviceDate); + // Check whether trip id has been used for previously ADDED trip message and mark previously + // created trip as DELETED + cancelPreviouslyAddedTrip(tripId, serviceDate, CancelationType.DELETE); Route route = getOrCreateRoute(tripDescriptor, tripId); @@ -908,7 +910,11 @@ private Result addTripToGraphAndBuffer( * @param serviceDate service date * @return true if scheduled trip was cancelled */ - private boolean cancelScheduledTrip(final FeedScopedId tripId, final LocalDate serviceDate) { + private boolean cancelScheduledTrip( + final FeedScopedId tripId, + final LocalDate serviceDate, + CancelationType cancelationType + ) { boolean success = false; final TripPattern pattern = getPatternForTripId(tripId); @@ -921,7 +927,10 @@ private boolean cancelScheduledTrip(final FeedScopedId tripId, final LocalDate s debug(tripId, "Could not cancel scheduled trip because it's not in the timetable"); } else { final TripTimes newTripTimes = new TripTimes(timetable.getTripTimes(tripIndex)); - newTripTimes.cancelTrip(); + switch (cancelationType) { + case CANCEL -> newTripTimes.cancelTrip(); + case DELETE -> newTripTimes.deleteTrip(); + } buffer.update(pattern, newTripTimes, serviceDate); success = true; } @@ -944,7 +953,8 @@ private boolean cancelScheduledTrip(final FeedScopedId tripId, final LocalDate s */ private boolean cancelPreviouslyAddedTrip( final FeedScopedId tripId, - final LocalDate serviceDate + final LocalDate serviceDate, + CancelationType cancelationType ) { boolean success = false; @@ -957,7 +967,10 @@ private boolean cancelPreviouslyAddedTrip( debug(tripId, "Could not cancel previously added trip on {}", serviceDate); } else { final TripTimes newTripTimes = new TripTimes(timetable.getTripTimes(tripIndex)); - newTripTimes.cancelTrip(); + switch (cancelationType) { + case CANCEL -> newTripTimes.cancelTrip(); + case DELETE -> newTripTimes.deleteTrip(); + } buffer.update(pattern, newTripTimes, serviceDate); success = true; } @@ -1054,13 +1067,13 @@ private Result handleModifiedTrip( "number of stop should match the number of stop time updates" ); - // Cancel scheduled trip + // Mark scheduled trip as DELETED var tripId = trip.getId(); - cancelScheduledTrip(tripId, serviceDate); + cancelScheduledTrip(tripId, serviceDate, CancelationType.DELETE); - // Check whether trip id has been used for previously ADDED/REPLACEMENT trip message and cancel - // previously created trip - cancelPreviouslyAddedTrip(tripId, serviceDate); + // Check whether trip id has been used for previously ADDED/REPLACEMENT trip message and mark it + // as DELETED + cancelPreviouslyAddedTrip(tripId, serviceDate, CancelationType.DELETE); // Add new trip return addTripToGraphAndBuffer( @@ -1075,13 +1088,18 @@ private Result handleModifiedTrip( private Result handleCanceledTrip( FeedScopedId tripId, - final LocalDate serviceDate + final LocalDate serviceDate, + CancelationType markAsDeleted ) { // Try to cancel scheduled trip - final boolean cancelScheduledSuccess = cancelScheduledTrip(tripId, serviceDate); + final boolean cancelScheduledSuccess = cancelScheduledTrip(tripId, serviceDate, markAsDeleted); // Try to cancel previously added trip - final boolean cancelPreviouslyAddedSuccess = cancelPreviouslyAddedTrip(tripId, serviceDate); + final boolean cancelPreviouslyAddedSuccess = cancelPreviouslyAddedTrip( + tripId, + serviceDate, + markAsDeleted + ); if (!cancelScheduledSuccess && !cancelPreviouslyAddedSuccess) { debug(tripId, "No pattern found for tripId. Skipping cancellation."); @@ -1126,4 +1144,9 @@ private static void debug(String feedId, String tripId, String message, Object.. String m = "[feedId: %s, tripId: %s] %s".formatted(feedId, tripId, message); LOG.debug(m, params); } + + private enum CancelationType { + CANCEL, + DELETE, + } } diff --git a/src/main/proto/gtfs-realtime.proto b/src/main/proto/gtfs-realtime.proto index 21cc9a0ff53..6c3e11ee53f 100644 --- a/src/main/proto/gtfs-realtime.proto +++ b/src/main/proto/gtfs-realtime.proto @@ -216,9 +216,9 @@ 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. enum ScheduleRelationship { // The vehicle is proceeding in accordance with its static schedule of @@ -250,7 +250,7 @@ message TripUpdate { UNSCHEDULED = 3; } optional ScheduleRelationship schedule_relationship = 5 - [default = SCHEDULED]; + [default = SCHEDULED]; // Provides the updated values for the stop time. // NOTE: This message is still experimental, and subject to change. It may be formally adopted in the future. @@ -340,7 +340,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,7 +374,7 @@ 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; // The extensions namespace allows 3rd-party developers to extend the @@ -450,7 +450,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 +504,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 +539,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 +554,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; @@ -583,7 +583,7 @@ message Alert { // Entities whose users we should notify of this alert. repeated EntitySelector informed_entity = 5; - // Cause of this alert. + // Cause of this alert. If cause_detail is included, then Cause must also be included. enum Cause { UNKNOWN_CAUSE = 1; OTHER_CAUSE = 2; // Not machine-representable. @@ -600,7 +600,7 @@ message Alert { } optional Cause cause = 6 [default = UNKNOWN_CAUSE]; - // What is the effect of this problem on the affected entity. + // What is the effect of this problem on the affected entity. If effect_detail is included, then Effect must also be included. enum Effect { NO_SERVICE = 1; REDUCED_SERVICE = 2; @@ -639,24 +639,33 @@ message Alert { // Severity of this alert. enum SeverityLevel { - UNKNOWN_SEVERITY = 1; - INFO = 2; - WARNING = 3; - SEVERE = 4; + UNKNOWN_SEVERITY = 1; + INFO = 2; + WARNING = 3; + SEVERE = 4; } 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; + optional TranslatedImage image = 15; // Text describing the appearance of the linked image in the `image` field (e.g., in case the image can't be displayed // or the user can't see the image for accessibility reasons). See the HTML spec for alt image text - https://html.spec.whatwg.org/#alt. // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional TranslatedString image_alternative_text = 16; + + // Description of the cause of the alert that allows for agency-specific language; more specific than the Cause. If cause_detail is included, then Cause must also be included. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional TranslatedString cause_detail = 17; + + // Description of the effect of the alert that allows for agency-specific language; more specific than the Effect. If effect_detail is included, then Effect must also be included. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + optional TranslatedString effect_detail = 18; + // 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. @@ -799,7 +808,7 @@ message TripDescriptor { CANCELED = 3; // Should not be used - for backwards-compatibility only. - REPLACEMENT = 5 [deprecated=true]; + REPLACEMENT = 5 [deprecated = true]; // 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, @@ -808,8 +817,8 @@ message TripDescriptor { // (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. 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 + // TripUpdate with the value of CANCELED or DELETED. 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. // Existing producers and consumers that were using the ADDED enumeration to represent duplicated trips must follow @@ -817,6 +826,17 @@ message TripDescriptor { // to transition to the DUPLICATED enumeration. // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. DUPLICATED = 6; + + + // A trip that existed in the schedule but was removed and must not be shown to users. + // DELETED should be used instead of CANCELED to indicate that a transit provider would like to entirely remove + // information about the corresponding trip from consuming applications, so the trip is not shown as cancelled to + // riders, e.g. a trip that is entirely being replaced by another trip. + // This designation becomes particularly important if several trips are cancelled and replaced with substitute service. + // If consumers were to show explicit information about the cancellations it would distract from the more important + // real-time predictions. + // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. + DELETED = 7; } optional ScheduleRelationship schedule_relationship = 4; @@ -950,12 +970,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; @@ -997,7 +1017,7 @@ message Shape { // See https://developers.google.com/protocol-buffers/docs/proto#specifying_field_rules // NOTE: This field is still experimental, and subject to change. It may be formally adopted in the future. optional string shape_id = 1; - + // Encoded polyline representation of the shape. This polyline must contain at least two points. // For more information about encoded polylines, see https://developers.google.com/maps/documentation/utilities/polylinealgorithm // This field is required as per reference.md, but needs to be specified here optional because "Required is Forever" diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilterTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilterTest.java index 82649a44d06..1fa9a8c9af7 100644 --- a/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilterTest.java +++ b/src/test/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RouteRequestTransitDataProviderFilterTest.java @@ -86,6 +86,7 @@ public void testWheelchairAccess(boolean wheelchair) { true, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), List.of(AllowAllTransitFilter.of()) @@ -110,6 +111,7 @@ public void notFilteringExpectedTripPatternForDateTest() { false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -129,6 +131,7 @@ public void bannedRouteFilteringTest() { false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(ROUTE.getId()), filterForMode(TransitMode.BUS) @@ -156,6 +159,7 @@ public void bannedTripFilteringTest() { false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(TRIP_ID), Set.of(), filterForMode(TransitMode.BUS) @@ -209,6 +213,7 @@ public void notFilteringExpectedTripTimesTest() { false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -236,6 +241,7 @@ public void bikesAllowedFilteringTest() { true, WheelchairPreferences.DEFAULT, false, + false, Set.of(), Set.of(), List.of(AllowAllTransitFilter.of()) @@ -263,6 +269,7 @@ public void removeInaccessibleTrip() { true, WheelchairPreferences.DEFAULT, false, + false, Set.of(), Set.of(), List.of(AllowAllTransitFilter.of()) @@ -290,6 +297,7 @@ public void keepAccessibleTrip() { true, WheelchairPreferences.DEFAULT, false, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -317,6 +325,7 @@ public void keepRealTimeAccessibleTrip() { true, WheelchairPreferences.DEFAULT, false, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -356,6 +365,7 @@ public void includePlannedCancellationsTest() { false, WheelchairPreferences.DEFAULT, true, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -377,6 +387,7 @@ public void includePlannedCancellationsTest() { false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), List.of(AllowAllTransitFilter.of()) @@ -393,6 +404,74 @@ public void includePlannedCancellationsTest() { assertFalse(valid4); } + @Test + public void includeRealtimeCancellationsTest() { + TripTimes tripTimes = createTestTripTimes( + TRIP_ID, + ROUTE, + BikeAccess.NOT_ALLOWED, + TransitMode.BUS, + null, + Accessibility.NOT_POSSIBLE, + TripAlteration.PLANNED + ); + + TripTimes tripTimesWithCancellation = createTestTripTimes( + TRIP_ID, + ROUTE, + BikeAccess.NOT_ALLOWED, + TransitMode.BUS, + null, + Accessibility.NOT_POSSIBLE, + TripAlteration.PLANNED + ); + tripTimesWithCancellation.cancelTrip(); + + // Given + var filter1 = new RouteRequestTransitDataProviderFilter( + false, + false, + WheelchairPreferences.DEFAULT, + false, + true, + Set.of(), + Set.of(), + filterForMode(TransitMode.BUS) + ); + + // When + boolean valid1 = filter1.tripTimesPredicate(tripTimes, true); + // Then + assertTrue(valid1); + + // When + boolean valid2 = filter1.tripTimesPredicate(tripTimesWithCancellation, true); + // Then + assertTrue(valid2); + + // Given + var filter2 = new RouteRequestTransitDataProviderFilter( + false, + false, + DEFAULT_ACCESSIBILITY, + false, + false, + Set.of(), + Set.of(), + List.of(AllowAllTransitFilter.of()) + ); + + // When + boolean valid3 = filter2.tripTimesPredicate(tripTimes, true); + // Then + assertTrue(valid3); + + // When + boolean valid4 = filter2.tripTimesPredicate(tripTimesWithCancellation, true); + // Then + assertFalse(valid4); + } + @Test public void testBikesAllowed() { RouteBuilder routeBuilder = TransitModelForTest.route("1"); @@ -491,6 +570,7 @@ public void multipleFilteringTest() { true, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), filterForMode(TransitMode.BUS) @@ -514,6 +594,7 @@ private boolean validateModesOnTripTimes( false, DEFAULT_ACCESSIBILITY, false, + false, Set.of(), Set.of(), filterForModes(allowedModes) diff --git a/src/test/java/org/opentripplanner/routing/api/request/preference/TransitPreferencesTest.java b/src/test/java/org/opentripplanner/routing/api/request/preference/TransitPreferencesTest.java index 4658d39b887..8b1c93f2953 100644 --- a/src/test/java/org/opentripplanner/routing/api/request/preference/TransitPreferencesTest.java +++ b/src/test/java/org/opentripplanner/routing/api/request/preference/TransitPreferencesTest.java @@ -31,6 +31,7 @@ class TransitPreferencesTest { private static final SearchDirection RAPTOR_SEARCH_DIRECTION = SearchDirection.REVERSE; private static final boolean IGNORE_REALTIME_UPDATES = true; private static final boolean INCLUDE_PLANNED_CANCELLATIONS = true; + private static final boolean INCLUDE_REALTIME_CANCELLATIONS = true; private final TransitPreferences subject = TransitPreferences .of() @@ -41,6 +42,7 @@ class TransitPreferencesTest { .withAlightSlack(b -> b.withDefault(D15s).with(TransitMode.AIRPLANE, D25m)) .setIgnoreRealtimeUpdates(IGNORE_REALTIME_UPDATES) .setIncludePlannedCancellations(INCLUDE_PLANNED_CANCELLATIONS) + .setIncludeRealtimeCancellations(INCLUDE_REALTIME_CANCELLATIONS) .withRaptor(b -> b.withSearchDirection(RAPTOR_SEARCH_DIRECTION)) .build(); @@ -83,6 +85,12 @@ void includePlannedCancellations() { assertTrue(subject.includePlannedCancellations()); } + @Test + void includeRealtimeCancellations() { + assertFalse(TransitPreferences.DEFAULT.includeRealtimeCancellations()); + assertTrue(subject.includeRealtimeCancellations()); + } + @Test void raptorOptions() { assertEquals(RAPTOR_SEARCH_DIRECTION, subject.raptor().searchDirection()); @@ -111,6 +119,7 @@ void testToString() { "unpreferredCost: f(x) = 300 + 1.15 x, " + "ignoreRealtimeUpdates, " + "includePlannedCancellations, " + + "includeRealtimeCancellations, " + "raptor: RaptorPreferences{searchDirection: REVERSE}" + "}", subject.toString() diff --git a/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java b/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java index 884e80a9596..6e552a81bd7 100644 --- a/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java +++ b/src/test/java/org/opentripplanner/updater/trip/TimetableSnapshotSourceTest.java @@ -167,6 +167,51 @@ public void testHandleCanceledTrip() throws InvalidProtocolBufferException { assertEquals(RealTimeState.CANCELED, tripTimes.getRealTimeState()); } + @Test + public void testHandleDeletedTrip() throws InvalidProtocolBufferException { + final FeedScopedId tripId = new FeedScopedId(feedId, "1.1"); + final FeedScopedId tripId2 = new FeedScopedId(feedId, "1.2"); + final Trip trip = transitModel.getTransitModelIndex().getTripForId().get(tripId); + final TripPattern pattern = transitModel.getTransitModelIndex().getPatternForTrip().get(trip); + final int tripIndex = pattern.getScheduledTimetable().getTripIndex(tripId); + final int tripIndex2 = pattern.getScheduledTimetable().getTripIndex(tripId2); + + var updater = new TimetableSnapshotSource( + TimetableSnapshotSourceParameters.DEFAULT, + transitModel + ); + + final TripDescriptor.Builder tripDescriptorBuilder = TripDescriptor.newBuilder(); + + tripDescriptorBuilder.setTripId("1.1"); + tripDescriptorBuilder.setScheduleRelationship(ScheduleRelationship.DELETED); + + final TripUpdate.Builder tripUpdateBuilder = TripUpdate.newBuilder(); + + tripUpdateBuilder.setTrip(tripDescriptorBuilder); + + var deletion = tripUpdateBuilder.build().toByteArray(); + + updater.applyTripUpdates( + TRIP_MATCHER_NOOP, + REQUIRED_NO_DATA, + fullDataset, + List.of(TripUpdate.parseFrom(deletion)), + feedId + ); + + final TimetableSnapshot snapshot = updater.getTimetableSnapshot(); + final Timetable forToday = snapshot.resolve(pattern, serviceDate); + final Timetable schedule = snapshot.resolve(pattern, null); + assertNotSame(forToday, schedule); + assertNotSame(forToday.getTripTimes(tripIndex), schedule.getTripTimes(tripIndex)); + assertSame(forToday.getTripTimes(tripIndex2), schedule.getTripTimes(tripIndex2)); + + final TripTimes tripTimes = forToday.getTripTimes(tripIndex); + + assertEquals(RealTimeState.DELETED, tripTimes.getRealTimeState()); + } + /** * This test just asserts that invalid trip ids don't throw an exception and are ignored instead */ @@ -357,7 +402,7 @@ public void testHandleModifiedTrip() { originalTripIndexScheduled ); assertFalse( - originalTripTimesScheduled.isCanceled(), + originalTripTimesScheduled.isCanceledOrDeleted(), "Original trip times should not be canceled in scheduled time table" ); assertEquals(RealTimeState.SCHEDULED, originalTripTimesScheduled.getRealTimeState()); @@ -371,10 +416,10 @@ public void testHandleModifiedTrip() { originalTripIndexForToday ); assertTrue( - originalTripTimesForToday.isCanceled(), - "Original trip times should be canceled in time table for service date" + originalTripTimesForToday.isDeleted(), + "Original trip times should be deleted in time table for service date" ); - assertEquals(RealTimeState.CANCELED, originalTripTimesForToday.getRealTimeState()); + assertEquals(RealTimeState.DELETED, originalTripTimesForToday.getRealTimeState()); } // New trip pattern @@ -525,7 +570,7 @@ public void scheduled() { originalTripIndexScheduled ); assertFalse( - originalTripTimesScheduled.isCanceled(), + originalTripTimesScheduled.isCanceledOrDeleted(), "Original trip times should not be canceled in scheduled time table" ); assertEquals(RealTimeState.SCHEDULED, originalTripTimesScheduled.getRealTimeState()); @@ -610,7 +655,7 @@ public void scheduledTripWithSkippedAndNoData() { originalTripIndexScheduled ); assertFalse( - originalTripTimesScheduled.isCanceled(), + originalTripTimesScheduled.isCanceledOrDeleted(), "Original trip times should not be canceled in scheduled time table" ); assertEquals(RealTimeState.SCHEDULED, originalTripTimesScheduled.getRealTimeState()); @@ -623,8 +668,12 @@ public void scheduledTripWithSkippedAndNoData() { final TripTimes originalTripTimesForToday = originalTimetableForToday.getTripTimes( originalTripIndexForToday ); - // original trip should be canceled - assertEquals(RealTimeState.CANCELED, originalTripTimesForToday.getRealTimeState()); + assertTrue( + originalTripTimesForToday.isDeleted(), + "Original trip times should be deleted in time table for service date" + ); + // original trip should be deleted + assertEquals(RealTimeState.DELETED, originalTripTimesForToday.getRealTimeState()); } // New trip pattern @@ -731,8 +780,12 @@ public void scheduledTripWithSkippedAndScheduled() { final TripTimes originalTripTimesForToday = originalTimetableForToday.getTripTimes( originalTripIndexForToday ); + assertTrue( + originalTripTimesForToday.isDeleted(), + "Original trip times should be deleted in time table for service date" + ); // original trip should be canceled - assertEquals(RealTimeState.CANCELED, originalTripTimesForToday.getRealTimeState()); + assertEquals(RealTimeState.DELETED, originalTripTimesForToday.getRealTimeState()); } // New trip pattern