
org.opentripplanner.updater.trip.TimetableSnapshotSource Maven / Gradle / Ivy
Show all versions of otp Show documentation
package org.opentripplanner.updater.trip;
import static com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship.SCHEDULED;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.INVALID_ARRIVAL_TIME;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.INVALID_DEPARTURE_TIME;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.INVALID_INPUT_STRUCTURE;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NOT_IMPLEMENTED_DUPLICATED;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NOT_IMPLEMENTED_UNSCHEDULED;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_SERVICE_ON_DATE;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_START_DATE;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_TRIP_FOR_CANCELLATION_FOUND;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_UPDATES;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_VALID_STOPS;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.TOO_FEW_STOPS;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.TRIP_ALREADY_EXISTS;
import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.TRIP_NOT_FOUND;
import static org.opentripplanner.updater.trip.UpdateIncrementality.DIFFERENTIAL;
import static org.opentripplanner.updater.trip.UpdateIncrementality.FULL_DATASET;
import com.google.common.base.Preconditions;
import com.google.common.collect.Multimaps;
import com.google.transit.realtime.GtfsRealtime;
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.StopTimeUpdate;
import de.mfdz.MfdzRealtimeExtensions;
import java.text.ParseException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import javax.annotation.Nonnull;
import org.opentripplanner.framework.i18n.I18NString;
import org.opentripplanner.framework.i18n.NonLocalizedString;
import org.opentripplanner.framework.lang.StringUtils;
import org.opentripplanner.framework.time.ServiceDateUtils;
import org.opentripplanner.gtfs.mapping.TransitModeMapper;
import org.opentripplanner.model.RealTimeTripUpdate;
import org.opentripplanner.model.StopTime;
import org.opentripplanner.model.Timetable;
import org.opentripplanner.model.TimetableSnapshot;
import org.opentripplanner.model.TimetableSnapshotProvider;
import org.opentripplanner.transit.model.basic.TransitMode;
import org.opentripplanner.transit.model.framework.DataValidationException;
import org.opentripplanner.transit.model.framework.Deduplicator;
import org.opentripplanner.transit.model.framework.FeedScopedId;
import org.opentripplanner.transit.model.framework.Result;
import org.opentripplanner.transit.model.network.Route;
import org.opentripplanner.transit.model.network.StopPattern;
import org.opentripplanner.transit.model.network.TripPattern;
import org.opentripplanner.transit.model.organization.Agency;
import org.opentripplanner.transit.model.site.StopLocation;
import org.opentripplanner.transit.model.timetable.RealTimeState;
import org.opentripplanner.transit.model.timetable.RealTimeTripTimes;
import org.opentripplanner.transit.model.timetable.Trip;
import org.opentripplanner.transit.model.timetable.TripTimesFactory;
import org.opentripplanner.transit.service.DefaultTransitService;
import org.opentripplanner.transit.service.TransitEditorService;
import org.opentripplanner.transit.service.TransitModel;
import org.opentripplanner.updater.GtfsRealtimeFuzzyTripMatcher;
import org.opentripplanner.updater.GtfsRealtimeMapper;
import org.opentripplanner.updater.TimetableSnapshotSourceParameters;
import org.opentripplanner.updater.spi.DataValidationExceptionMapper;
import org.opentripplanner.updater.spi.ResultLogger;
import org.opentripplanner.updater.spi.UpdateError;
import org.opentripplanner.updater.spi.UpdateResult;
import org.opentripplanner.updater.spi.UpdateSuccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class should be used to create snapshots of lookup tables of realtime data. This is
* necessary to provide planning threads a consistent constant view of a graph with realtime data at
* a specific point in time.
*/
public class TimetableSnapshotSource implements TimetableSnapshotProvider {
private static final Logger LOG = LoggerFactory.getLogger(TimetableSnapshotSource.class);
/**
* Maximum time in seconds since midnight for arrivals and departures
*/
private static final long MAX_ARRIVAL_DEPARTURE_TIME = 48 * 60 * 60;
/** A synchronized cache of trip patterns added to the graph due to GTFS-realtime messages. */
private final TripPatternCache tripPatternCache = new TripPatternCache();
private final ZoneId timeZone;
/**
* Long-lived transit editor service that has access to the timetable snapshot buffer.
* This differs from the usual use case where the transit service refers to the latest published
* timetable snapshot.
*/
private final TransitEditorService transitEditorService;
private final Deduplicator deduplicator;
private final Map serviceCodes;
private final TimetableSnapshotManager snapshotManager;
private final Supplier localDateNow;
public TimetableSnapshotSource(
TimetableSnapshotSourceParameters parameters,
TransitModel transitModel
) {
this(parameters, transitModel, () -> LocalDate.now(transitModel.getTimeZone()));
}
/**
* Constructor is package local to allow unit-tests to provide their own clock, not using system
* time.
*/
TimetableSnapshotSource(
TimetableSnapshotSourceParameters parameters,
TransitModel transitModel,
Supplier localDateNow
) {
this.snapshotManager =
new TimetableSnapshotManager(transitModel.getTransitLayerUpdater(), parameters, localDateNow);
this.timeZone = transitModel.getTimeZone();
this.transitEditorService =
new DefaultTransitService(transitModel, snapshotManager.getTimetableSnapshotBuffer());
this.deduplicator = transitModel.getDeduplicator();
this.serviceCodes = transitModel.getServiceCodes();
this.localDateNow = localDateNow;
// Inject this into the transit model
transitModel.initTimetableSnapshotProvider(this);
}
/**
* Method to apply a trip update list to the most recent version of the timetable snapshot. A
* GTFS-RT feed is always applied against a single static feed (indicated by feedId).
*
* However, multi-feed support is not completed and we currently assume there is only one static
* feed when matching IDs.
*
* @param backwardsDelayPropagationType Defines when delays are propagated to previous stops and
* if these stops are given the NO_DATA flag.
* @param updateIncrementality Determines the incrementality of the updates. FULL updates clear the buffer
* of all previous updates for the given feed id.
* @param updates GTFS-RT TripUpdate's that should be applied atomically
*/
public UpdateResult applyTripUpdates(
GtfsRealtimeFuzzyTripMatcher fuzzyTripMatcher,
BackwardsDelayPropagationType backwardsDelayPropagationType,
UpdateIncrementality updateIncrementality,
List updates,
String feedId
) {
if (updates == null) {
LOG.warn("updates is null");
return UpdateResult.empty();
}
Map failuresByRelationship = new HashMap<>();
List> results = new ArrayList<>();
if (updateIncrementality == FULL_DATASET) {
// Remove all updates from the buffer
snapshotManager.clearBuffer(feedId);
}
LOG.debug("message contains {} trip updates", updates.size());
int uIndex = 0;
for (TripUpdate tripUpdate : updates) {
if (!tripUpdate.hasTrip()) {
debug(feedId, "", "Missing TripDescriptor in gtfs-rt trip update: \n{}", tripUpdate);
continue;
}
if (fuzzyTripMatcher != null) {
final TripDescriptor trip = fuzzyTripMatcher.match(feedId, tripUpdate.getTrip());
tripUpdate = tripUpdate.toBuilder().setTrip(trip).build();
}
final TripDescriptor tripDescriptor = tripUpdate.getTrip();
if (!tripDescriptor.hasTripId() || tripDescriptor.getTripId().isBlank()) {
debug(feedId, "", "No trip id found for gtfs-rt trip update: \n{}", tripUpdate);
results.add(Result.failure(UpdateError.noTripId(INVALID_INPUT_STRUCTURE)));
continue;
}
FeedScopedId tripId = new FeedScopedId(feedId, tripUpdate.getTrip().getTripId());
LocalDate serviceDate;
if (tripDescriptor.hasStartDate()) {
try {
serviceDate = ServiceDateUtils.parseString(tripDescriptor.getStartDate());
} catch (final ParseException e) {
debug(
tripId,
"Failed to parse start date in gtfs-rt trip update: {}",
tripDescriptor.getStartDate()
);
continue;
}
} else {
// TODO: figure out the correct service date. For the special case that a trip
// starts for example at 40:00, yesterday would probably be a better guess.
serviceDate = localDateNow.get();
}
// Determine what kind of trip update this is
var scheduleRelationship = Objects.requireNonNullElse(
tripDescriptor.getScheduleRelationship(),
SCHEDULED
);
if (updateIncrementality == DIFFERENTIAL) {
purgePatternModifications(scheduleRelationship, tripId, serviceDate);
}
uIndex += 1;
LOG.debug("trip update #{} ({} updates) :", uIndex, tripUpdate.getStopTimeUpdateCount());
LOG.trace("{}", tripUpdate);
Result result;
try {
result =
switch (scheduleRelationship) {
case SCHEDULED -> handleScheduledTrip(
tripUpdate,
tripId,
serviceDate,
backwardsDelayPropagationType
);
case ADDED -> validateAndHandleAddedTrip(
tripUpdate,
tripDescriptor,
tripId,
serviceDate
);
case CANCELED -> handleCanceledTrip(
tripId,
serviceDate,
CancelationType.CANCEL,
updateIncrementality
);
case DELETED -> handleCanceledTrip(
tripId,
serviceDate,
CancelationType.DELETE,
updateIncrementality
);
case REPLACEMENT -> validateAndHandleModifiedTrip(
tripUpdate,
tripDescriptor,
tripId,
serviceDate
);
case UNSCHEDULED -> UpdateError.result(tripId, NOT_IMPLEMENTED_UNSCHEDULED);
case DUPLICATED -> UpdateError.result(tripId, NOT_IMPLEMENTED_DUPLICATED);
};
} catch (DataValidationException e) {
result = DataValidationExceptionMapper.toResult(e);
}
results.add(result);
if (result.isFailure()) {
debug(tripId, "Failed to apply TripUpdate.");
LOG.trace(" Contents: {}", tripUpdate);
if (failuresByRelationship.containsKey(scheduleRelationship)) {
var c = failuresByRelationship.get(scheduleRelationship);
failuresByRelationship.put(scheduleRelationship, ++c);
} else {
failuresByRelationship.put(scheduleRelationship, 1);
}
}
}
var updateResult = UpdateResult.ofResults(results);
if (updateIncrementality == FULL_DATASET) {
logUpdateResult(feedId, failuresByRelationship, updateResult);
}
return updateResult;
}
/**
* 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.
*/
private void purgePatternModifications(
ScheduleRelationship tripScheduleRelationship,
FeedScopedId tripId,
LocalDate serviceDate
) {
final TripPattern pattern = snapshotManager.getRealtimeAddedTripPattern(tripId, serviceDate);
if (
!isPreviouslyAddedTrip(tripId, pattern, serviceDate) ||
(
tripScheduleRelationship != ScheduleRelationship.CANCELED &&
tripScheduleRelationship != ScheduleRelationship.DELETED
)
) {
// 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.
this.snapshotManager.revertTripToScheduledTripPattern(tripId, serviceDate);
}
}
private boolean isPreviouslyAddedTrip(
FeedScopedId tripId,
TripPattern pattern,
LocalDate serviceDate
) {
if (pattern == null) {
return false;
}
var timetable = snapshotManager.resolve(pattern, serviceDate);
if (timetable == null) {
return false;
}
var tripTimes = timetable.getTripTimes(tripId);
if (tripTimes == null) {
return false;
}
return tripTimes.getRealTimeState() == RealTimeState.ADDED;
}
@Override
public TimetableSnapshot getTimetableSnapshot() {
return snapshotManager.getTimetableSnapshot();
}
/**
* @return the current timetable snapshot buffer that contains pending changes (not yet published
* in a snapshot). This should be used in the context of an updater to build a TransitEditorService
* that sees all the changes applied so far by real-time updates.
*/
public TimetableSnapshot getTimetableSnapshotBuffer() {
return snapshotManager.getTimetableSnapshotBuffer();
}
private static void logUpdateResult(
String feedId,
Map failuresByRelationship,
UpdateResult updateResult
) {
ResultLogger.logUpdateResult(feedId, "gtfs-rt-trip-updates", updateResult);
if (!failuresByRelationship.isEmpty()) {
LOG.info("[feedId: {}] Failures by scheduleRelationship {}", feedId, failuresByRelationship);
}
var warnings = Multimaps.index(updateResult.warnings(), w -> w);
warnings
.keySet()
.forEach(key -> {
var count = warnings.get(key).size();
LOG.info("[feedId: {}] {} warnings of type {}", feedId, count, key);
});
}
private Result handleScheduledTrip(
TripUpdate tripUpdate,
FeedScopedId tripId,
LocalDate serviceDate,
BackwardsDelayPropagationType backwardsDelayPropagationType
) {
final TripPattern pattern = getPatternForTripId(tripId);
if (pattern == null) {
debug(tripId, "No pattern found for tripId, skipping TripUpdate.");
return UpdateError.result(tripId, TRIP_NOT_FOUND);
}
if (tripUpdate.getStopTimeUpdateCount() < 1) {
debug(tripId, "TripUpdate contains no updates, skipping.");
return UpdateError.result(tripId, NO_UPDATES);
}
final FeedScopedId serviceId = transitEditorService.getTripForId(tripId).getServiceId();
final Set serviceDates = transitEditorService
.getCalendarService()
.getServiceDatesForServiceId(serviceId);
if (!serviceDates.contains(serviceDate)) {
debug(
tripId,
"SCHEDULED trip has service date {} for which trip's service is not valid, skipping.",
serviceDate.toString()
);
return UpdateError.result(tripId, NO_SERVICE_ON_DATE);
}
// Get new TripTimes based on scheduled timetable
var result = pattern
.getScheduledTimetable()
.createUpdatedTripTimesFromGTFSRT(
tripUpdate,
timeZone,
serviceDate,
backwardsDelayPropagationType
);
if (result.isFailure()) {
// necessary so the success type is correct
return result.toFailureResult();
}
var tripTimesPatch = result.successValue();
List skippedStopIndices = tripTimesPatch.getSkippedStopIndices();
var updatedTripTimes = tripTimesPatch.getTripTimes();
// Make sure that updated trip times have the correct real time state
updatedTripTimes.setRealTimeState(RealTimeState.UPDATED);
// If there are skipped stops, we need to change the pattern from the scheduled one
if (skippedStopIndices.size() > 0) {
StopPattern newStopPattern = pattern
.copyPlannedStopPattern()
.cancelStops(skippedStopIndices)
.build();
final Trip trip = transitEditorService.getTripForId(tripId);
// Get cached trip pattern or create one if it doesn't exist yet
final TripPattern newPattern = tripPatternCache.getOrCreateTripPattern(
newStopPattern,
trip,
pattern
);
cancelScheduledTrip(tripId, serviceDate, CancelationType.DELETE);
return snapshotManager.updateBuffer(
new RealTimeTripUpdate(newPattern, updatedTripTimes, serviceDate)
);
} else {
// Set the updated trip times in the buffer
return snapshotManager.updateBuffer(
new RealTimeTripUpdate(pattern, updatedTripTimes, serviceDate)
);
}
}
/**
* Validate and handle GTFS-RT TripUpdate message containing an ADDED trip.
*
* @param tripUpdate GTFS-RT TripUpdate message
* @param tripDescriptor GTFS-RT TripDescriptor
* @return empty Result if successful or one containing an error
*/
private Result validateAndHandleAddedTrip(
final TripUpdate tripUpdate,
final TripDescriptor tripDescriptor,
final FeedScopedId tripId,
final LocalDate serviceDate
) {
// Preconditions
Objects.requireNonNull(tripUpdate);
Objects.requireNonNull(serviceDate);
//
// Validate added trip
//
// Check whether trip id already exists in graph
final Trip trip = transitEditorService.getScheduledTripForId(tripId);
if (trip != null) {
// TODO: should we support this and add a new instantiation of this trip (making it
// frequency based)?
debug(tripId, "Graph already contains trip id of ADDED 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, "ADDED trip doesn't have a start date in TripDescriptor, skipping.");
return UpdateError.result(tripId, NO_START_DATE);
}
final List stopTimeUpdates = removeUnknownStops(tripUpdate, tripId);
var warnings = new ArrayList(0);
if (stopTimeUpdates.size() < tripUpdate.getStopTimeUpdateCount()) {
warnings.add(UpdateSuccess.WarningType.UNKNOWN_STOPS_REMOVED_FROM_ADDED_TRIP);
}
// check if after filtering the stops we still have at least 2
if (stopTimeUpdates.size() < 2) {
debug(tripId, "ADDED trip has fewer than two known stops, skipping.");
return UpdateError.result(tripId, TOO_FEW_STOPS);
}
// Check whether all stop times are available and all stops exist
final var stops = checkNewStopTimeUpdatesAndFindStops(tripId, stopTimeUpdates);
if (stops == null) {
return UpdateError.result(tripId, NO_VALID_STOPS);
}
//
// Handle added trip
//
return handleAddedTrip(tripUpdate, stopTimeUpdates, tripDescriptor, stops, tripId, serviceDate)
.mapSuccess(s -> s.addWarnings(warnings));
}
/**
* Remove any stop that is not know in the static transit data.
*/
@Nonnull
private List removeUnknownStops(TripUpdate tripUpdate, FeedScopedId tripId) {
return tripUpdate
.getStopTimeUpdateList()
.stream()
.filter(StopTimeUpdate::hasStopId)
.filter(st -> {
var stopId = new FeedScopedId(tripId.getFeedId(), st.getStopId());
var stopFound = transitEditorService.getRegularStop(stopId) != null;
if (!stopFound) {
debug(tripId, "Stop '{}' not found in graph. Removing from ADDED trip.", st.getStopId());
}
return stopFound;
})
.toList();
}
/**
* Check stop time updates of trip update that results in a new trip (ADDED or MODIFIED) and find
* all stops of that trip.
*
* @return stops when stop time updates are correct; null if there are errors
*/
private List checkNewStopTimeUpdatesAndFindStops(
final FeedScopedId tripId,
final List stopTimeUpdates
) {
Integer previousStopSequence = null;
Long previousTime = null;
final List stops = new ArrayList<>(stopTimeUpdates.size());
for (int index = 0; index < stopTimeUpdates.size(); ++index) {
final StopTimeUpdate stopTimeUpdate = stopTimeUpdates.get(index);
// Check stop sequence
if (stopTimeUpdate.hasStopSequence()) {
final Integer stopSequence = stopTimeUpdate.getStopSequence();
// Check non-negative
if (stopSequence < 0) {
debug(tripId, "Trip update contains negative stop sequence, skipping.");
return null;
}
// Check whether sequence is increasing
if (previousStopSequence != null && previousStopSequence > stopSequence) {
debug(tripId, "Trip update contains decreasing stop sequence, skipping.");
return null;
}
previousStopSequence = stopSequence;
} else {
// Allow missing stop sequences for ADDED and MODIFIED trips
}
// Find stops
if (stopTimeUpdate.hasStopId()) {
// Find stop
final var stop = transitEditorService.getRegularStop(
new FeedScopedId(tripId.getFeedId(), stopTimeUpdate.getStopId())
);
if (stop != null) {
// Remember stop
stops.add(stop);
} else {
debug(
tripId,
"Graph doesn't contain stop id '{}' of trip update, skipping.",
stopTimeUpdate.getStopId()
);
return null;
}
} else {
debug(tripId, "Trip update misses a stop id at stop time list index {}, skipping.", index);
return null;
}
// Check arrival time
if (stopTimeUpdate.hasArrival() && stopTimeUpdate.getArrival().hasTime()) {
// Check for increasing time
final Long time = stopTimeUpdate.getArrival().getTime();
if (previousTime != null && previousTime > time) {
debug(tripId, "Trip update contains decreasing times, skipping.");
return null;
}
previousTime = time;
} else {
debug(tripId, "Trip update misses arrival time, skipping.");
return null;
}
// Check departure time
if (stopTimeUpdate.hasDeparture() && stopTimeUpdate.getDeparture().hasTime()) {
// Check for increasing time
final Long time = stopTimeUpdate.getDeparture().getTime();
if (previousTime != null && previousTime > time) {
debug(tripId, "Trip update contains decreasing times, skipping.");
return null;
}
previousTime = time;
} else {
debug(tripId, "Trip update misses departure time, skipping.");
return null;
}
}
return stops;
}
/**
* Handle GTFS-RT TripUpdate message containing an ADDED trip.
*
* @param stopTimeUpdates GTFS-RT stop time updates
* @param tripDescriptor GTFS-RT TripDescriptor
* @param stops the stops of each StopTimeUpdate in the TripUpdate message
* @param serviceDate service date for added trip
* @return empty Result if successful or one containing an error
*/
private Result handleAddedTrip(
final TripUpdate tripUpdate,
final List stopTimeUpdates,
final TripDescriptor tripDescriptor,
final List stops,
final FeedScopedId tripId,
final LocalDate serviceDate
) {
// Preconditions
Objects.requireNonNull(stops);
Preconditions.checkArgument(
stopTimeUpdates.size() == stops.size(),
"number of stop should match the number of stop time updates"
);
Route route;
boolean routeExists = routeExists(tripId.getFeedId(), tripDescriptor);
if (routeExists) {
route =
transitEditorService.getRouteForId(
new FeedScopedId(tripId.getFeedId(), tripDescriptor.getRouteId())
);
} else {
route = createRoute(tripDescriptor, tripId);
}
// Create new Trip
// TODO: which Agency ID to use? Currently use feed id.
var tripBuilder = Trip.of(tripId);
tripBuilder.withRoute(route);
// Find service ID running on this service date
final Set serviceIds = transitEditorService
.getCalendarService()
.getServiceIdsOnDate(serviceDate);
if (serviceIds.isEmpty()) {
// No service id exists: return error for now
debug(
tripId,
"ADDED trip has service date {} for which no service id is available, skipping.",
serviceDate.toString()
);
return UpdateError.result(tripId, NO_SERVICE_ON_DATE);
} else {
// Just use first service id of set
tripBuilder.withServiceId(serviceIds.iterator().next());
}
return addTripToGraphAndBuffer(
tripBuilder.build(),
tripUpdate.getVehicle(),
stopTimeUpdates,
stops,
serviceDate,
RealTimeState.ADDED,
!routeExists
);
}
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 (
tripDescriptor.hasExtension(MfdzRealtimeExtensions.tripDescriptor) &&
!routeExists(tripId.getFeedId(), tripDescriptor)
) {
FeedScopedId routeId = new FeedScopedId(tripId.getFeedId(), tripDescriptor.getRouteId());
var builder = Route.of(routeId);
var addedRouteExtension = AddedRoute.ofTripDescriptor(tripDescriptor);
var agency = transitEditorService
.findAgencyById(new FeedScopedId(tripId.getFeedId(), addedRouteExtension.agencyId()))
.orElseGet(() -> fallbackAgency(tripId.getFeedId()));
builder.withAgency(agency);
builder.withGtfsType(addedRouteExtension.routeType());
var mode = TransitModeMapper.mapMode(addedRouteExtension.routeType());
builder.withMode(mode);
// Create route name
var name = Objects.requireNonNullElse(addedRouteExtension.routeLongName(), tripId.toString());
builder.withLongName(new NonLocalizedString(name));
builder.withUrl(addedRouteExtension.routeUrl());
return builder.build();
}
// no information about the rout is given, so we create a dummy one
else {
var builder = Route.of(tripId);
builder.withAgency(fallbackAgency(tripId.getFeedId()));
// Guess the route type as it doesn't exist yet in the specifications
// Bus. Used for short- and long-distance bus routes.
builder.withGtfsType(3);
builder.withMode(TransitMode.BUS);
// Create route name
I18NString longName = NonLocalizedString.ofNullable(tripDescriptor.getTripId());
builder.withLongName(longName);
return builder.build();
}
}
/**
* Create dummy agency for added trips.
*/
private Agency fallbackAgency(String feedId) {
return Agency
.of(new FeedScopedId(feedId, "autogenerated-gtfs-rt-added-route"))
.withName("Agency automatically added by GTFS-RT update")
.withTimezone(transitEditorService.getTimeZone().toString())
.build();
}
private boolean routeExists(String feedId, TripDescriptor tripDescriptor) {
if (tripDescriptor.hasRouteId() && StringUtils.hasValue(tripDescriptor.getRouteId())) {
var routeId = new FeedScopedId(feedId, tripDescriptor.getRouteId());
return Objects.nonNull(transitEditorService.getRouteForId(routeId));
} else {
return false;
}
}
/**
* Add a (new) trip to the graph and the buffer
*
* @param trip trip
* @param vehicleDescriptor accessibility information of the vehicle
* @param stops list of stops corresponding to stop time updates
* @param serviceDate service date of trip
* @param realTimeState real-time state of new trip
* @return empty Result if successful or one containing an error
*/
private Result addTripToGraphAndBuffer(
final Trip trip,
final GtfsRealtime.VehicleDescriptor vehicleDescriptor,
final List stopTimeUpdates,
final List stops,
final LocalDate serviceDate,
final RealTimeState realTimeState,
final boolean isAddedRoute
) {
// Preconditions
Objects.requireNonNull(stops);
Preconditions.checkArgument(
stopTimeUpdates.size() == stops.size(),
"number of stop should match the number of stop time updates"
);
// Calculate seconds since epoch on GTFS midnight (noon minus 12h) of service date
final long midnightSecondsSinceEpoch = ServiceDateUtils
.asStartOfService(serviceDate, timeZone)
.toEpochSecond();
// 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 stop = stops.get(index);
// Create stop time
final StopTime stopTime = new StopTime();
stopTime.setTrip(trip);
stopTime.setStop(stop);
// Set arrival time
if (stopTimeUpdate.hasArrival() && stopTimeUpdate.getArrival().hasTime()) {
final long arrivalTime = stopTimeUpdate.getArrival().getTime() - midnightSecondsSinceEpoch;
if (arrivalTime < 0 || arrivalTime > MAX_ARRIVAL_DEPARTURE_TIME) {
debug(
trip.getId(),
"ADDED trip has invalid arrival time (compared to start date in " +
"TripDescriptor), skipping."
);
return UpdateError.result(trip.getId(), INVALID_ARRIVAL_TIME);
}
stopTime.setArrivalTime((int) arrivalTime);
}
// Set departure time
if (stopTimeUpdate.hasDeparture() && stopTimeUpdate.getDeparture().hasTime()) {
final long departureTime =
stopTimeUpdate.getDeparture().getTime() - midnightSecondsSinceEpoch;
if (departureTime < 0 || departureTime > MAX_ARRIVAL_DEPARTURE_TIME) {
debug(
trip.getId(),
"ADDED trip has invalid departure time (compared to start date in " +
"TripDescriptor), skipping."
);
return UpdateError.result(trip.getId(), INVALID_DEPARTURE_TIME);
}
stopTime.setDepartureTime((int) departureTime);
}
stopTime.setTimepoint(1); // Exact time
if (stopTimeUpdate.hasStopSequence()) {
stopTime.setStopSequence(stopTimeUpdate.getStopSequence());
}
var added = AddedStopTime.ofStopTime(stopTimeUpdate);
stopTime.setPickupType(added.pickup());
stopTime.setDropOffType(added.dropOff());
// Add stop time to list
stopTimes.add(stopTime);
}
// TODO: filter/interpolate stop times like in PatternHopFactory?
// Create StopPattern
final StopPattern stopPattern = new StopPattern(stopTimes);
final TripPattern originalTripPattern = transitEditorService.getPatternForTrip(trip);
// Get cached trip pattern or create one if it doesn't exist yet
final TripPattern pattern = tripPatternCache.getOrCreateTripPattern(
stopPattern,
trip,
originalTripPattern
);
// Create new trip times
final RealTimeTripTimes newTripTimes = TripTimesFactory.tripTimes(
trip,
stopTimes,
deduplicator
);
// Update all times to mark trip times as realtime
// TODO: should we incorporate the delay field if present?
for (int stopIndex = 0; stopIndex < newTripTimes.getNumStops(); stopIndex++) {
newTripTimes.updateArrivalTime(stopIndex, newTripTimes.getScheduledArrivalTime(stopIndex));
newTripTimes.updateDepartureTime(
stopIndex,
newTripTimes.getScheduledDepartureTime(stopIndex)
);
}
// Set service code of new trip times
final int serviceCode = serviceCodes.get(trip.getServiceId());
newTripTimes.setServiceCode(serviceCode);
// Make sure that updated trip times have the correct real time state
newTripTimes.setRealTimeState(realTimeState);
if (vehicleDescriptor != null) {
if (vehicleDescriptor.hasWheelchairAccessible()) {
GtfsRealtimeMapper
.mapWheelchairAccessible(vehicleDescriptor.getWheelchairAccessible())
.ifPresent(newTripTimes::updateWheelchairAccessibility);
}
}
LOG.trace(
"Trip pattern added with mode {} on {} from {} to {}",
trip.getRoute().getMode(),
serviceDate,
pattern.firstStop().getName(),
pattern.lastStop().getName()
);
// Add new trip times to the buffer
// TODO add support for TripOnServiceDate for GTFS-RT
return snapshotManager.updateBuffer(
new RealTimeTripUpdate(
pattern,
newTripTimes,
serviceDate,
null,
realTimeState == RealTimeState.ADDED,
isAddedRoute
)
);
}
/**
* Cancel scheduled trip in buffer given trip id on service date
*
* @return true if scheduled trip was cancelled
*/
private boolean cancelScheduledTrip(
final FeedScopedId tripId,
final LocalDate serviceDate,
CancelationType cancelationType
) {
boolean success = false;
final TripPattern pattern = getPatternForTripId(tripId);
if (pattern != null) {
// Cancel scheduled trip times for this trip in this pattern
final Timetable timetable = pattern.getScheduledTimetable();
final int tripIndex = timetable.getTripIndex(tripId);
if (tripIndex == -1) {
debug(tripId, "Could not cancel scheduled trip because it's not in the timetable");
} else {
final RealTimeTripTimes newTripTimes = timetable
.getTripTimes(tripIndex)
.copyScheduledTimes();
switch (cancelationType) {
case CANCEL -> newTripTimes.cancelTrip();
case DELETE -> newTripTimes.deleteTrip();
}
snapshotManager.updateBuffer(new RealTimeTripUpdate(pattern, newTripTimes, serviceDate));
success = true;
}
}
return success;
}
/**
* Cancel previously added trip from buffer if there is a previously added trip with given trip id
* on service date. This does not remove the added trip from the buffer, it just marks it as
* canceled or deleted. Any TripPattern that was created for the added trip continues to exist,
* and will be reused if a similar added trip message is received with the same route and stop
* sequence.
*
* @return true if a previously added trip was cancelled
*/
private boolean cancelPreviouslyAddedTrip(
final FeedScopedId tripId,
final LocalDate serviceDate,
CancelationType cancelationType
) {
boolean cancelledAddedTrip = false;
final TripPattern pattern = snapshotManager.getRealtimeAddedTripPattern(tripId, serviceDate);
if (isPreviouslyAddedTrip(tripId, pattern, serviceDate)) {
// Cancel trip times for this trip in this pattern
final Timetable timetable = snapshotManager.resolve(pattern, serviceDate);
final int tripIndex = timetable.getTripIndex(tripId);
if (tripIndex == -1) {
debug(tripId, "Could not cancel previously added trip on {}", serviceDate);
} else {
final RealTimeTripTimes newTripTimes = timetable
.getTripTimes(tripIndex)
.copyScheduledTimes();
switch (cancelationType) {
case CANCEL -> newTripTimes.cancelTrip();
case DELETE -> newTripTimes.deleteTrip();
}
snapshotManager.updateBuffer(new RealTimeTripUpdate(pattern, newTripTimes, serviceDate));
cancelledAddedTrip = true;
}
}
return cancelledAddedTrip;
}
/**
* Validate and handle GTFS-RT TripUpdate message containing a MODIFIED trip.
*
* @param tripUpdate GTFS-RT TripUpdate message
* @param tripDescriptor GTFS-RT TripDescriptor
* @return empty Result if successful or one containing an error
*/
private Result validateAndHandleModifiedTrip(
final TripUpdate tripUpdate,
final TripDescriptor tripDescriptor,
final FeedScopedId tripId,
final LocalDate serviceDate
) {
// Preconditions
Objects.requireNonNull(tripUpdate);
Objects.requireNonNull(serviceDate);
//
// Validate modified trip
//
// Check whether trip id already exists in graph
Trip trip = transitEditorService.getTripForId(tripId);
if (trip == null) {
// TODO: should we support this and consider it an ADDED trip?
debug(tripId, "Feed does not contain trip id of MODIFIED trip, skipping.");
return UpdateError.result(tripId, TRIP_NOT_FOUND);
}
// Check whether a start date exists
if (!tripDescriptor.hasStartDate()) {
// TODO: should we support this and apply update to all days?
debug(tripId, "REPLACEMENT trip doesn't have a start date in TripDescriptor, skipping.");
return UpdateError.result(tripId, NO_START_DATE);
} else {
// Check whether service date is served by trip
final Set serviceIds = transitEditorService
.getCalendarService()
.getServiceIdsOnDate(serviceDate);
if (!serviceIds.contains(trip.getServiceId())) {
// TODO: should we support this and change service id of trip?
debug(tripId, "REPLACEMENT trip has a service date that is not served by trip, skipping.");
return UpdateError.result(tripId, NO_SERVICE_ON_DATE);
}
}
// Check whether at least two stop updates exist
if (tripUpdate.getStopTimeUpdateCount() < 2) {
debug(tripId, "REPLACEMENT trip has less then two stops, skipping.");
return UpdateError.result(tripId, TOO_FEW_STOPS);
}
// Check whether all stop times are available and all stops exist
var stops = checkNewStopTimeUpdatesAndFindStops(tripId, tripUpdate.getStopTimeUpdateList());
if (stops == null) {
return UpdateError.result(tripId, NO_VALID_STOPS);
}
//
// Handle modified trip
//
return handleModifiedTrip(trip, tripUpdate, stops, serviceDate);
}
/**
* Handle GTFS-RT TripUpdate message containing a REPLACEMENT trip.
*
* @param trip trip that is modified
* @param tripUpdate GTFS-RT TripUpdate message
* @param stops the stops of each StopTimeUpdate in the TripUpdate message
* @param serviceDate service date for modified trip
* @return empty Result if successful or one containing an error
*/
private Result handleModifiedTrip(
final Trip trip,
final TripUpdate tripUpdate,
final List stops,
final LocalDate serviceDate
) {
// Preconditions
Objects.requireNonNull(stops);
Preconditions.checkArgument(
tripUpdate.getStopTimeUpdateCount() == stops.size(),
"number of stop should match the number of stop time updates"
);
// Mark scheduled trip as DELETED
var tripId = trip.getId();
cancelScheduledTrip(tripId, serviceDate, CancelationType.DELETE);
// Add new trip
return addTripToGraphAndBuffer(
trip,
tripUpdate.getVehicle(),
tripUpdate.getStopTimeUpdateList(),
stops,
serviceDate,
RealTimeState.MODIFIED,
false
);
}
private Result handleCanceledTrip(
FeedScopedId tripId,
final LocalDate serviceDate,
CancelationType cancelationType,
UpdateIncrementality incrementality
) {
var canceledPreviouslyAddedTrip =
incrementality != FULL_DATASET &&
cancelPreviouslyAddedTrip(tripId, serviceDate, cancelationType);
// if previously an added trip was removed, there can't be a scheduled trip to remove
if (canceledPreviouslyAddedTrip) {
return Result.success(UpdateSuccess.noWarnings());
}
// Try to cancel scheduled trip
final boolean cancelScheduledSuccess = cancelScheduledTrip(
tripId,
serviceDate,
cancelationType
);
if (!cancelScheduledSuccess) {
debug(tripId, "No pattern found for tripId. Skipping cancellation.");
return UpdateError.result(tripId, NO_TRIP_FOR_CANCELLATION_FOUND);
}
return Result.success(UpdateSuccess.noWarnings());
}
/**
* Retrieve a trip pattern given a trip id.
*
* @param tripId trip id
* @return trip pattern or null if no trip pattern was found
*/
private TripPattern getPatternForTripId(FeedScopedId tripId) {
Trip trip = transitEditorService.getTripForId(tripId);
return transitEditorService.getPatternForTrip(trip);
}
private static void debug(FeedScopedId id, String message, Object... params) {
debug(id.getFeedId(), id.getId(), message, params);
}
private static void debug(String feedId, String tripId, String message, Object... params) {
String m = "[feedId: %s, tripId: %s] %s".formatted(feedId, tripId, message);
LOG.debug(m, params);
}
private enum CancelationType {
CANCEL,
DELETE,
}
public void flushBuffer() {
snapshotManager.purgeAndCommit();
}
}