All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opentripplanner.ext.siri.TimetableHelper Maven / Gradle / Ivy

package org.opentripplanner.ext.siri;

import static org.opentripplanner.model.PickDrop.CANCELLED;
import static org.opentripplanner.model.PickDrop.NONE;
import static org.opentripplanner.model.PickDrop.SCHEDULED;
import static org.opentripplanner.model.UpdateError.UpdateErrorType.INVALID_INPUT_STRUCTURE;
import static org.opentripplanner.model.UpdateError.UpdateErrorType.TOO_FEW_STOPS;
import static org.opentripplanner.model.UpdateError.UpdateErrorType.TRIP_NOT_FOUND_IN_PATTERN;
import static org.opentripplanner.model.UpdateError.UpdateErrorType.UNKNOWN;

import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import javax.xml.datatype.Duration;
import org.opentripplanner.model.PickDrop;
import org.opentripplanner.model.StopTime;
import org.opentripplanner.model.Timetable;
import org.opentripplanner.model.TimetableSnapshot;
import org.opentripplanner.model.UpdateError;
import org.opentripplanner.transit.model.basic.NonLocalizedString;
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.TripPattern;
import org.opentripplanner.transit.model.site.StopLocation;
import org.opentripplanner.transit.model.timetable.OccupancyStatus;
import org.opentripplanner.transit.model.timetable.RealTimeState;
import org.opentripplanner.transit.model.timetable.TripTimes;
import org.opentripplanner.util.time.ServiceDateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.org.siri.siri20.ArrivalBoardingActivityEnumeration;
import uk.org.siri.siri20.CallStatusEnumeration;
import uk.org.siri.siri20.DepartureBoardingActivityEnumeration;
import uk.org.siri.siri20.EstimatedCall;
import uk.org.siri.siri20.EstimatedVehicleJourney;
import uk.org.siri.siri20.MonitoredCallStructure;
import uk.org.siri.siri20.MonitoredVehicleJourneyStructure;
import uk.org.siri.siri20.NaturalLanguageStringStructure;
import uk.org.siri.siri20.OccupancyEnumeration;
import uk.org.siri.siri20.RecordedCall;
import uk.org.siri.siri20.VehicleActivityStructure;

public class TimetableHelper {

  private static final Logger LOG = LoggerFactory.getLogger(TimetableHelper.class);

  /**
   * Apply the TripUpdate to the appropriate TripTimes from this Timetable. The existing TripTimes
   * must not be modified directly because they may be shared with the underlying
   * scheduledTimetable, or other updated Timetables. The {@link TimetableSnapshot} performs the
   * protective copying of this Timetable. It is not done in this update method to avoid repeatedly
   * cloning the same Timetable when several updates are applied to it at once. We assume here that
   * all trips in a timetable are from the same feed, which should always be the case.
   *
   * @param journey SIRI-ET EstimatedVehicleJourney
   * @return new copy of updated TripTimes after TripUpdate has been applied on TripTimes of trip
   * with the id specified in the trip descriptor of the TripUpdate; null if something went wrong
   */
  public static Result createUpdatedTripTimes(
    Timetable timetable,
    EstimatedVehicleJourney journey,
    FeedScopedId tripId,
    Function getStopById,
    ZoneId zoneId,
    Deduplicator deduplicator
  ) {
    if (journey == null) {
      return null;
    }

    final TripTimes existingTripTimes = timetable.getTripTimes(tripId);
    if (existingTripTimes == null) {
      LOG.debug("tripId {} not found in pattern.", tripId);
      return UpdateError.result(tripId, TRIP_NOT_FOUND_IN_PATTERN);
    }

    TripTimes oldTimes = new TripTimes(existingTripTimes);

    if (journey.isCancellation() != null && journey.isCancellation()) {
      oldTimes.cancelTrip();
      return Result.success(oldTimes);
    }

    List estimatedCalls = getEstimatedCalls(journey);
    List recordedCalls = getRecordedCalls(journey);

    EstimatedCall lastEstimatedCall = estimatedCalls.isEmpty()
      ? null
      : estimatedCalls.get(estimatedCalls.size() - 1);

    RecordedCall lastRecordedCall = recordedCalls.isEmpty()
      ? null
      : recordedCalls.get(recordedCalls.size() - 1);

    boolean stopPatternChanged = false;

    TripPattern pattern = timetable.getPattern();
    List modifiedStopTimes = createModifiedStopTimes(
      pattern,
      oldTimes,
      journey,
      getStopById
    );
    if (modifiedStopTimes == null) {
      return UpdateError.result(tripId, UNKNOWN);
    }
    TripTimes newTimes = new TripTimes(oldTimes.getTrip(), modifiedStopTimes, deduplicator);

    //Populate missing data from existing TripTimes
    newTimes.setServiceCode(oldTimes.getServiceCode());

    OccupancyEnumeration journeyOccupancy = journey.getOccupancy();

    int callCounter = 0;

    LocalDate serviceDate = getServiceDate(journey, zoneId, oldTimes);
    ZonedDateTime startOfService = ServiceDateUtils.asStartOfService(serviceDate, zoneId);
    Set alreadyVisited = new HashSet<>();

    boolean isJourneyPredictionInaccurate =
      (journey.isPredictionInaccurate() != null && journey.isPredictionInaccurate());

    int departureFromPreviousStop = 0;
    int lastArrivalDelay = 0;
    int lastDepartureDelay = 0;
    for (var stop : pattern.getStops()) {
      boolean foundMatch = false;

      for (RecordedCall recordedCall : recordedCalls) {
        if (alreadyVisited.contains(recordedCall)) {
          continue;
        }
        //Current stop is being updated
        foundMatch = stop.getId().getId().equals(recordedCall.getStopPointRef().getValue());

        if (!foundMatch && stop.isPartOfStation()) {
          var alternativeStop = getStopById.apply(
            new FeedScopedId(stop.getId().getFeedId(), recordedCall.getStopPointRef().getValue())
          );
          if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
            foundMatch = true;
            stopPatternChanged = true;
          }
        }

        if (foundMatch) {
          int arrivalTime = newTimes.getArrivalTime(callCounter);

          Integer realtimeArrivalTime = getAvailableTime(
            startOfService,
            recordedCall::getActualArrivalTime,
            callCounter == 0 ? recordedCall::getActualDepartureTime : () -> null,
            recordedCall::getExpectedArrivalTime,
            callCounter == 0 ? recordedCall::getExpectedDepartureTime : () -> null,
            recordedCall::getAimedArrivalTime,
            callCounter == 0 ? recordedCall::getAimedDepartureTime : () -> null
          );

          if (realtimeArrivalTime == null) {
            realtimeArrivalTime = arrivalTime;
          }

          int arrivalDelay = realtimeArrivalTime - arrivalTime;
          newTimes.updateArrivalDelay(callCounter, arrivalDelay);
          lastArrivalDelay = arrivalDelay;

          int departureTime = newTimes.getDepartureTime(callCounter);

          boolean isLastStop = estimatedCalls.isEmpty() && lastRecordedCall == recordedCall;

          Integer realtimeDepartureTime = getAvailableTime(
            startOfService,
            recordedCall::getActualDepartureTime,
            // Do not use actual arrival time for departure time, as the vehicle can be currently at the stop
            recordedCall::getExpectedDepartureTime,
            isLastStop ? recordedCall::getExpectedArrivalTime : () -> null,
            recordedCall::getAimedDepartureTime,
            isLastStop ? recordedCall::getAimedArrivalTime : () -> null
          );

          if (realtimeDepartureTime == null) {
            realtimeDepartureTime = departureTime;
          }

          boolean isCallPredictionInaccurate = Boolean.TRUE.equals(
            recordedCall.isPredictionInaccurate()
          );

          if (recordedCall.isCancellation() != null && recordedCall.isCancellation()) {
            modifiedStopTimes.get(callCounter).cancel();
            newTimes.setCancelled(callCounter);
          } else if (isJourneyPredictionInaccurate | isCallPredictionInaccurate) {
            // Set flag for inaccurate prediction if either call OR journey has inaccurate-flag
            // set if stop is not cancelled. Setting recorded if stop is cancelled would
            // override the cancellation information.
            newTimes.setPredictionInaccurate(callCounter);
          } else if (
            recordedCall.getActualArrivalTime() != null ||
            recordedCall.getActualDepartureTime() != null
          ) {
            // Flag as recorded if stop is not cancelled, setting recorded if stop is cancelled would
            // override the cancellation information.
            newTimes.setRecorded(callCounter);
          }

          int departureDelay = realtimeDepartureTime - departureTime;

          newTimes.updateDepartureDelay(callCounter, departureDelay);
          lastDepartureDelay = departureDelay;
          departureFromPreviousStop = newTimes.getDepartureTime(callCounter);

          OccupancyEnumeration callOccupancy = recordedCall.getOccupancy() != null
            ? recordedCall.getOccupancy()
            : journeyOccupancy;

          if (callOccupancy != null) {
            newTimes.setOccupancyStatus(callCounter, resolveOccupancyStatus(callOccupancy));
          }

          alreadyVisited.add(recordedCall);
          break;
        }
      }
      if (!foundMatch) {
        for (EstimatedCall estimatedCall : estimatedCalls) {
          if (alreadyVisited.contains(estimatedCall)) {
            continue;
          }
          //Current stop is being updated
          foundMatch = stop.getId().getId().equals(estimatedCall.getStopPointRef().getValue());

          if (!foundMatch && stop.isPartOfStation()) {
            var alternativeStop = getStopById.apply(
              new FeedScopedId(stop.getId().getFeedId(), estimatedCall.getStopPointRef().getValue())
            );
            if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
              foundMatch = true;
              stopPatternChanged = true;
            }
          }

          if (foundMatch) {
            boolean isCallPredictionInaccurate =
              estimatedCall.isPredictionInaccurate() != null &&
              estimatedCall.isPredictionInaccurate();

            if (estimatedCall.isCancellation() != null && estimatedCall.isCancellation()) {
              modifiedStopTimes.get(callCounter).cancel();
              newTimes.setCancelled(callCounter);
            } else if (isJourneyPredictionInaccurate | isCallPredictionInaccurate) {
              // Set flag for inaccurate prediction if either call OR journey has inaccurate-flag
              // set if stop is not cancelled. Setting recorded if stop is cancelled would
              // override the cancellation information.
              newTimes.setPredictionInaccurate(callCounter);
            }

            // Update dropoff-/pickuptype only if status is cancelled
            CallStatusEnumeration arrivalStatus = estimatedCall.getArrivalStatus();
            if (arrivalStatus == CallStatusEnumeration.CANCELLED) {
              modifiedStopTimes.get(callCounter).cancelDropOff();
            }

            CallStatusEnumeration departureStatus = estimatedCall.getDepartureStatus();
            if (departureStatus == CallStatusEnumeration.CANCELLED) {
              modifiedStopTimes.get(callCounter).cancelPickup();
            }

            int arrivalTime = newTimes.getArrivalTime(callCounter);

            Integer realtimeArrivalTime = getAvailableTime(
              startOfService,
              estimatedCall::getExpectedArrivalTime,
              callCounter == 0 ? estimatedCall::getExpectedDepartureTime : () -> null,
              estimatedCall::getAimedArrivalTime,
              callCounter == 0 ? estimatedCall::getAimedDepartureTime : () -> null
            );

            int departureTime = newTimes.getDepartureTime(callCounter);

            boolean isLastStop = lastEstimatedCall == estimatedCall;

            Integer realtimeDepartureTime = getAvailableTime(
              startOfService,
              estimatedCall::getExpectedDepartureTime,
              isLastStop ? estimatedCall::getExpectedArrivalTime : () -> null,
              estimatedCall::getAimedDepartureTime,
              isLastStop ? estimatedCall::getAimedArrivalTime : () -> null
            );

            if (realtimeDepartureTime == null) {
              realtimeDepartureTime = departureTime;
            }

            if (realtimeArrivalTime == null) {
              realtimeArrivalTime = realtimeDepartureTime;
            }

            int arrivalDelay = realtimeArrivalTime - arrivalTime;
            newTimes.updateArrivalDelay(callCounter, arrivalDelay);
            lastArrivalDelay = arrivalDelay;

            int departureDelay = realtimeDepartureTime - departureTime;
            newTimes.updateDepartureDelay(callCounter, departureDelay);
            lastDepartureDelay = departureDelay;

            departureFromPreviousStop = newTimes.getDepartureTime(callCounter);

            OccupancyEnumeration callOccupancy = estimatedCall.getOccupancy() != null
              ? estimatedCall.getOccupancy()
              : journeyOccupancy;

            if (callOccupancy != null) {
              newTimes.setOccupancyStatus(callCounter, resolveOccupancyStatus(callOccupancy));
            }

            alreadyVisited.add(estimatedCall);
            break;
          }
        }
      }
      if (!foundMatch) {
        if (pattern.isBoardAndAlightAt(callCounter, NONE)) {
          // When newTimes contains stops without pickup/dropoff - set both arrival/departure to previous stop's departure
          // This necessary to accommodate the case when delay is reduced/eliminated between to stops with pickup/dropoff, and
          // multiple non-pickup/dropoff stops are in between.
          newTimes.updateArrivalTime(callCounter, departureFromPreviousStop);
          newTimes.updateDepartureTime(callCounter, departureFromPreviousStop);
        } else {
          int arrivalDelay = lastArrivalDelay;
          int departureDelay = lastDepartureDelay;

          if (lastArrivalDelay == 0 && lastDepartureDelay == 0) {
            //No match has been found yet (i.e. still in RecordedCalls) - keep existing delays
            arrivalDelay = existingTripTimes.getArrivalDelay(callCounter);
            departureDelay = existingTripTimes.getDepartureDelay(callCounter);
          }

          newTimes.updateArrivalDelay(callCounter, arrivalDelay);
          newTimes.updateDepartureDelay(callCounter, departureDelay);
        }

        departureFromPreviousStop = newTimes.getDepartureTime(callCounter);
      }
      callCounter++;
    }

    if (stopPatternChanged) {
      // This update modified stopPattern
      newTimes.setRealTimeState(RealTimeState.MODIFIED);
    } else {
      // This is the first update, and StopPattern has not been changed
      newTimes.setRealTimeState(RealTimeState.UPDATED);
    }

    if (journey.isCancellation() != null && journey.isCancellation()) {
      LOG.debug("Trip is cancelled");
      newTimes.cancelTrip();
    }

    var result = newTimes.validateNonIncreasingTimes();
    if (result.isFailure()) {
      var updateError = result.failureValue();
      LOG.info(
        "TripTimes are non-increasing after applying SIRI delay propagation - LineRef {}, TripId {}. Stop index {}",
        journey.getLineRef().getValue(),
        tripId,
        updateError.stopIndex()
      );
      return Result.failure(updateError);
    }

    if (newTimes.getNumStops() != pattern.numberOfStops()) {
      return UpdateError.result(tripId, TOO_FEW_STOPS);
    }

    LOG.debug("A valid TripUpdate object was applied using the Timetable class update method.");
    return Result.success(newTimes);
  }

  private static int calculateDayOffset(TripTimes oldTimes) {
    if (oldTimes.getDepartureTime(0) > 86400) {
      // The "departure-date" for this trip is set to "yesterday" (or before) even though it actually departs "today"

      return oldTimes.getDepartureTime(0) / 86400; // calculate number of offset-days
    } else {
      return 0;
    }
  }

  /**
   * Maps the (very limited) SIRI 2.0 OccupancyEnum to internal OccupancyStatus
   * @param occupancy
   * @return
   */
  private static OccupancyStatus resolveOccupancyStatus(OccupancyEnumeration occupancy) {
    if (occupancy != null) {
      return switch (occupancy) {
        case SEATS_AVAILABLE -> OccupancyStatus.MANY_SEATS_AVAILABLE;
        case STANDING_AVAILABLE -> OccupancyStatus.STANDING_ROOM_ONLY;
        case FULL -> OccupancyStatus.FULL;
      };
    }
    return OccupancyStatus.NO_DATA;
  }

  /**
   * Apply the SIRI ET to the appropriate TripTimes from this Timetable. Calculate new stoppattern
   * based on single stop cancellations
   *
   * @param journey SIRI-ET EstimatedVehicleJourney
   * @return new copy of updated TripTimes after TripUpdate has been applied on TripTimes of trip
   * with the id specified in the trip descriptor of the TripUpdate; null if something went wrong
   */
  public static List createModifiedStops(
    TripPattern pattern,
    EstimatedVehicleJourney journey,
    Function getStopForId
  ) {
    if (journey == null) {
      return null;
    }

    List estimatedCalls = getEstimatedCalls(journey);
    List recordedCalls = getRecordedCalls(journey);

    // Keeping track of visited stop-objects to allow multiple visits to a stop.
    List alreadyVisited = new ArrayList<>();

    List modifiedStops = new ArrayList<>();

    for (int i = 0; i < pattern.numberOfStops(); i++) {
      StopLocation stop = pattern.getStop(i);

      boolean foundMatch = false;
      if (i < recordedCalls.size()) {
        for (RecordedCall recordedCall : recordedCalls) {
          if (alreadyVisited.contains(recordedCall)) {
            continue;
          }
          //Current stop is being updated
          boolean stopsMatchById = stop
            .getId()
            .getId()
            .equals(recordedCall.getStopPointRef().getValue());

          if (!stopsMatchById && stop.isPartOfStation()) {
            var alternativeStop = getStopForId.apply(
              new FeedScopedId(stop.getId().getFeedId(), recordedCall.getStopPointRef().getValue())
            );
            if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
              stopsMatchById = true;
              stop = alternativeStop;
            }
          }

          if (stopsMatchById) {
            foundMatch = true;
            modifiedStops.add(stop);
            alreadyVisited.add(recordedCall);
            break;
          }
        }
      } else {
        for (EstimatedCall estimatedCall : estimatedCalls) {
          if (alreadyVisited.contains(estimatedCall)) {
            continue;
          }
          //Current stop is being updated
          boolean stopsMatchById = stop
            .getId()
            .getId()
            .equals(estimatedCall.getStopPointRef().getValue());

          if (!stopsMatchById && stop.isPartOfStation()) {
            var alternativeStop = getStopForId.apply(
              new FeedScopedId(stop.getId().getFeedId(), estimatedCall.getStopPointRef().getValue())
            );
            if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
              stopsMatchById = true;
              stop = alternativeStop;
            }
          }

          if (stopsMatchById) {
            foundMatch = true;
            modifiedStops.add(stop);
            alreadyVisited.add(estimatedCall);
            break;
          }
        }
      }
      if (!foundMatch) {
        modifiedStops.add(stop);
      }
    }

    return modifiedStops;
  }

  /**
   * Apply the SIRI ET to the appropriate TripTimes from this Timetable. Calculate new stoppattern
   * based on single stop cancellations
   *
   * @param journey SIRI-ET EstimatedVehicleJourney
   * @return new copy of updated TripTimes after TripUpdate has been applied on TripTimes of trip
   * with the id specified in the trip descriptor of the TripUpdate; null if something went wrong
   */
  public static List createModifiedStopTimes(
    TripPattern pattern,
    TripTimes oldTimes,
    EstimatedVehicleJourney journey,
    Function getStopForId
  ) {
    if (journey == null) {
      return null;
    }

    List estimatedCalls = getEstimatedCalls(journey);
    List recordedCalls = getRecordedCalls(journey);

    var stops = createModifiedStops(pattern, journey, getStopForId);

    List modifiedStops = new ArrayList<>();

    int numberOfRecordedCalls = recordedCalls.size();
    Set alreadyVisited = new HashSet<>();
    // modify updated stop-times
    for (int i = 0; i < stops.size(); i++) {
      StopLocation stop = stops.get(i);

      final StopTime stopTime = new StopTime();
      stopTime.setStop(stop);
      stopTime.setTrip(oldTimes.getTrip());
      stopTime.setStopSequence(i);
      stopTime.setDropOffType(pattern.getAlightType(i));
      stopTime.setPickupType(pattern.getBoardType(i));
      stopTime.setArrivalTime(oldTimes.getScheduledArrivalTime(i));
      stopTime.setDepartureTime(oldTimes.getScheduledDepartureTime(i));
      stopTime.setStopHeadsign(oldTimes.getHeadsign(i));
      stopTime.setHeadsignVias(oldTimes.getHeadsignVias(i));
      stopTime.setTimepoint(oldTimes.isTimepoint(i) ? 1 : 0);

      // TODO: Do we need to set the StopTime.id?
      //stopTime.setId(oldTimes.getStopTimeIdByIndex(i));

      boolean foundMatch = false;
      if (i < numberOfRecordedCalls) {
        for (RecordedCall recordedCall : recordedCalls) {
          if (alreadyVisited.contains(recordedCall)) {
            continue;
          }

          //Current stop is being updated
          var callStopRef = recordedCall.getStopPointRef().getValue();
          boolean stopsMatchById = stop.getId().getId().equals(callStopRef);

          if (!stopsMatchById && stop.isPartOfStation()) {
            var alternativeStop = getStopForId.apply(
              new FeedScopedId(stop.getId().getFeedId(), callStopRef)
            );
            if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
              stopsMatchById = true;
              stopTime.setStop(alternativeStop);
            }
          }

          if (stopsMatchById) {
            foundMatch = true;

            if (recordedCall.isCancellation() != null && recordedCall.isCancellation()) {
              stopTime.cancel();
            }

            modifiedStops.add(stopTime);
            alreadyVisited.add(recordedCall);
            break;
          }
        }
      } else {
        for (EstimatedCall estimatedCall : estimatedCalls) {
          if (alreadyVisited.contains(estimatedCall)) {
            continue;
          }

          //Current stop is being updated
          boolean stopsMatchById = stop
            .getId()
            .getId()
            .equals(estimatedCall.getStopPointRef().getValue());

          if (!stopsMatchById && stop.isPartOfStation()) {
            var alternativeStop = getStopForId.apply(
              new FeedScopedId(stop.getId().getFeedId(), estimatedCall.getStopPointRef().getValue())
            );
            if (alternativeStop != null && stop.isPartOfSameStationAs(alternativeStop)) {
              stopsMatchById = true;
              stopTime.setStop(alternativeStop);
            }
          }

          if (stopsMatchById) {
            foundMatch = true;

            CallStatusEnumeration arrivalStatus = estimatedCall.getArrivalStatus();
            if (arrivalStatus == CallStatusEnumeration.CANCELLED) {
              stopTime.cancelDropOff();
            }
            var dropOffType = mapDropOffType(
              stopTime.getDropOffType(),
              estimatedCall.getArrivalBoardingActivity()
            );
            dropOffType.ifPresent(stopTime::setDropOffType);

            CallStatusEnumeration departureStatus = estimatedCall.getDepartureStatus();
            if (departureStatus == CallStatusEnumeration.CANCELLED) {
              stopTime.cancelPickup();
            }
            var pickUpType = mapPickUpType(
              stopTime.getPickupType(),
              estimatedCall.getDepartureBoardingActivity()
            );
            pickUpType.ifPresent(stopTime::setPickupType);

            if (estimatedCall.isCancellation() != null && estimatedCall.isCancellation()) {
              stopTime.cancel();
            }

            if (
              estimatedCall.getDestinationDisplaies() != null &&
              !estimatedCall.getDestinationDisplaies().isEmpty()
            ) {
              NaturalLanguageStringStructure destinationDisplay = estimatedCall
                .getDestinationDisplaies()
                .get(0);
              stopTime.setStopHeadsign(new NonLocalizedString(destinationDisplay.getValue()));
            }

            modifiedStops.add(stopTime);
            alreadyVisited.add(estimatedCall);
            break;
          }
        }
      }

      if (!foundMatch) {
        modifiedStops.add(stopTime);
      }
    }

    return modifiedStops;
  }

  /**
   * Apply the TripUpdate to the appropriate TripTimes from this Timetable. The existing TripTimes
   * must not be modified directly because they may be shared with the underlying
   * scheduledTimetable, or other updated Timetables. The {@link TimetableSnapshot} performs the
   * protective copying of this Timetable. It is not done in this update method to avoid repeatedly
   * cloning the same Timetable when several updates are applied to it at once. We assume here that
   * all trips in a timetable are from the same feed, which should always be the case.
   *
   * @param activity SIRI-VM VehicleActivity
   * @return a Result with a copy of updated TripTimes after TripUpdate has been applied on TripTimes of trip
   * with the id specified in the trip descriptor of the TripUpdate; a failed Result if something went wrong
   */
  public static Result createUpdatedTripTimes(
    Timetable timetable,
    VehicleActivityStructure activity,
    FeedScopedId tripId,
    Function getStopById
  ) {
    if (activity == null) {
      return Result.failure(new UpdateError(tripId, INVALID_INPUT_STRUCTURE));
    }

    MonitoredVehicleJourneyStructure mvj = activity.getMonitoredVehicleJourney();

    final TripTimes existingTripTimes = timetable.getTripTimes(tripId);
    if (existingTripTimes == null) {
      LOG.trace("tripId {} not found in pattern.", tripId);
      return Result.failure(new UpdateError(tripId, TRIP_NOT_FOUND_IN_PATTERN));
    }

    TripTimes newTimes = new TripTimes(existingTripTimes);

    MonitoredCallStructure update = mvj.getMonitoredCall();
    if (update == null) {
      return Result.failure(new UpdateError(tripId, INVALID_INPUT_STRUCTURE));
    }

    MonitoredVehicleJourneyStructure monitoredVehicleJourney = activity.getMonitoredVehicleJourney();

    if (monitoredVehicleJourney != null) {
      Duration delay = monitoredVehicleJourney.getDelay();
      int updatedDelay = 0;
      if (delay != null) {
        updatedDelay =
          delay.getSign() *
          (delay.getHours() * 3600 + delay.getMinutes() * 60 + delay.getSeconds());
      }

      MonitoredCallStructure monitoredCall = monitoredVehicleJourney.getMonitoredCall();
      if (monitoredCall != null && monitoredCall.getStopPointRef() != null) {
        boolean matchFound = false;

        int arrivalDelay = 0;
        int departureDelay = 0;
        var pattern = timetable.getPattern();

        for (int index = 0; index < newTimes.getNumStops(); ++index) {
          if (!matchFound) {
            // Delay is set on a single stop at a time. When match is found - propagate delay on all following stops
            final var stop = pattern.getStop(index);

            matchFound = stop.getId().getId().equals(monitoredCall.getStopPointRef().getValue());

            if (!matchFound && stop.isPartOfStation()) {
              FeedScopedId alternativeId = new FeedScopedId(
                stop.getId().getFeedId(),
                monitoredCall.getStopPointRef().getValue()
              );
              var alternativeStop = getStopById.apply(alternativeId);
              if (alternativeStop != null && alternativeStop.isPartOfStation()) {
                matchFound = stop.isPartOfSameStationAs(alternativeStop);
              }
            }

            if (matchFound) {
              arrivalDelay = departureDelay = updatedDelay;
            } else {
              /*
               * If updated delay is less than previously set delay, the existing delay needs to be adjusted to avoid
               * non-increasing times causing updates to be rejected. Will only affect historical data.
               */
              arrivalDelay = Math.min(existingTripTimes.getArrivalDelay(index), updatedDelay);
              departureDelay = Math.min(existingTripTimes.getDepartureDelay(index), updatedDelay);
            }
          }
          newTimes.updateArrivalDelay(index, arrivalDelay);
          newTimes.updateDepartureDelay(index, departureDelay);
        }
      }
    }

    var result = newTimes.validateNonIncreasingTimes();
    if (result.isFailure()) {
      var error = result.failureValue();
      LOG.info(
        "TripTimes are non-increasing after applying SIRI delay propagation - LineRef {}, TripId {}. Stop index {}",
        timetable.getPattern().getRoute().getId(),
        tripId,
        error.stopIndex()
      );
      return Result.failure(error);
    }

    //If state is already MODIFIED - keep existing state
    if (newTimes.getRealTimeState() != RealTimeState.MODIFIED) {
      // Make sure that updated trip times have the correct real time state
      newTimes.setRealTimeState(RealTimeState.UPDATED);
    }

    return Result.success(newTimes);
  }

  /**
   * Get the first non-null time from a list of suppliers, and convert that to seconds past start of
   * service time. If none of the suppliers provide a time, return null.
   */
  @SafeVarargs
  private static Integer getAvailableTime(
    ZonedDateTime startOfService,
    Supplier... timeSuppliers
  ) {
    for (var supplier : timeSuppliers) {
      final ZonedDateTime time = supplier.get();
      if (time != null) {
        return ServiceDateUtils.secondsSinceStartOfService(startOfService, time);
      }
    }
    return null;
  }

  /**
   * Get the list of recorded calls for a EstimatedVehicleJourney. Return an empty list if no
   * recorded calls exist.
   */
  private static List getRecordedCalls(EstimatedVehicleJourney journey) {
    EstimatedVehicleJourney.RecordedCalls journeyRecordedCalls = journey.getRecordedCalls();
    if (journeyRecordedCalls != null) {
      return journeyRecordedCalls.getRecordedCalls();
    } else {
      return List.of();
    }
  }

  /**
   * Get the list of estimated calls for a EstimatedVehicleJourney. Return an empty list if no
   * estimated calls exist.
   */
  private static List getEstimatedCalls(EstimatedVehicleJourney journey) {
    EstimatedVehicleJourney.EstimatedCalls journeyEstimatedCalls = journey.getEstimatedCalls();
    if (journeyEstimatedCalls != null) {
      return journeyEstimatedCalls.getEstimatedCalls();
    } else {
      return List.of();
    }
  }

  /**
   * This method maps an ArrivalBoardingActivity to a pick drop type.
   *
   * The Siri ArrivalBoardingActivity includes less information than the pick drop type, therefore is it only
   * changed if routability has changed.
   *
   * @param currentValue The current pick drop value on a stopTime
   * @param arrivalBoardingActivityEnumeration The incoming boardingActivity to be mapped
   * @return Mapped PickDrop type, empty if routability is not changed.
   */
  public static Optional mapDropOffType(
    PickDrop currentValue,
    ArrivalBoardingActivityEnumeration arrivalBoardingActivityEnumeration
  ) {
    if (arrivalBoardingActivityEnumeration == null) {
      return Optional.empty();
    }

    return switch (arrivalBoardingActivityEnumeration) {
      case ALIGHTING -> currentValue.isNotRoutable() ? Optional.of(SCHEDULED) : Optional.empty();
      case NO_ALIGHTING -> Optional.of(NONE);
      case PASS_THRU -> Optional.of(CANCELLED);
    };
  }

  /**
   * This method maps an departureBoardingActivity to a pick drop type.
   *
   * The Siri DepartureBoardingActivity includes less information than the planned data, therefore is it only
   * changed if routability has changed.
   *
   * @param currentValue The current pick drop value on a stopTime
   * @param departureBoardingActivityEnumeration The incoming departureBoardingActivityEnumeration to be mapped
   * @return Mapped PickDrop type, empty if routability is not changed.
   */
  public static Optional mapPickUpType(
    PickDrop currentValue,
    DepartureBoardingActivityEnumeration departureBoardingActivityEnumeration
  ) {
    if (departureBoardingActivityEnumeration == null) {
      return Optional.empty();
    }

    return switch (departureBoardingActivityEnumeration) {
      case BOARDING -> currentValue.isNotRoutable() ? Optional.of(SCHEDULED) : Optional.empty();
      case NO_BOARDING -> Optional.of(NONE);
      case PASS_THRU -> Optional.of(CANCELLED);
    };
  }

  private static LocalDate getServiceDate(
    EstimatedVehicleJourney journey,
    ZoneId zoneId,
    TripTimes oldTimes
  ) {
    if (
      journey.getFramedVehicleJourneyRef() != null &&
      journey.getFramedVehicleJourneyRef().getDataFrameRef() != null
    ) {
      var dataFrame = journey.getFramedVehicleJourneyRef().getDataFrameRef();
      if (dataFrame != null) {
        try {
          return LocalDate.parse(dataFrame.getValue());
        } catch (DateTimeParseException ignored) {
          LOG.warn("Invalid dataFrame format: {}", dataFrame.getValue());
        }
      }
    }

    var recordedCalls = journey.getRecordedCalls();
    var estimatedCalls = journey.getEstimatedCalls();
    ZonedDateTime firstDeparture;
    if (recordedCalls.getRecordedCalls().isEmpty()) {
      firstDeparture = estimatedCalls.getEstimatedCalls().get(0).getAimedDepartureTime();
    } else {
      firstDeparture = recordedCalls.getRecordedCalls().get(0).getAimedDepartureTime();
    }

    return firstDeparture
      .minusDays(calculateDayOffset(oldTimes))
      .withZoneSameInstant(zoneId)
      .toLocalDate();
  }
}