
com.graphhopper.gtfs.RealtimeFeed Maven / Gradle / Ivy
/*
* Licensed to GraphHopper GmbH under one or more contributor
* license agreements. See the NOTICE file distributed with this work for
* additional information regarding copyright ownership.
*
* GraphHopper GmbH licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.graphhopper.gtfs;
import com.carrotsearch.hppc.IntArrayList;
import com.carrotsearch.hppc.IntHashSet;
import com.carrotsearch.hppc.IntLongHashMap;
import com.conveyal.gtfs.GTFSFeed;
import com.conveyal.gtfs.model.Frequency;
import com.conveyal.gtfs.model.StopTime;
import com.conveyal.gtfs.model.Trip;
import com.google.transit.realtime.GtfsRealtime;
import com.graphhopper.routing.util.EncodingManager;
import com.graphhopper.storage.BaseGraph;
import org.mapdb.Fun;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship.NO_DATA;
import static com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship.SKIPPED;
import static java.time.temporal.ChronoUnit.DAYS;
public class RealtimeFeed {
private static final Logger logger = LoggerFactory.getLogger(RealtimeFeed.class);
private final IntHashSet blockedEdges;
private final IntLongHashMap delaysForBoardEdges;
private final IntLongHashMap delaysForAlightEdges;
private final List additionalEdges;
public final Map feedMessages;
private RealtimeFeed(Map feedMessages, IntHashSet blockedEdges,
IntLongHashMap delaysForBoardEdges, IntLongHashMap delaysForAlightEdges, List additionalEdges) {
this.feedMessages = feedMessages;
this.blockedEdges = blockedEdges;
this.delaysForBoardEdges = delaysForBoardEdges;
this.delaysForAlightEdges = delaysForAlightEdges;
this.additionalEdges = additionalEdges;
}
public static RealtimeFeed empty() {
return new RealtimeFeed(Collections.emptyMap(), new IntHashSet(), new IntLongHashMap(), new IntLongHashMap(), Collections.emptyList());
}
public static RealtimeFeed fromProtobuf(BaseGraph baseGraph, EncodingManager encodingManager, GtfsStorage staticGtfs, Map transfers, Map feedMessages) {
final IntHashSet blockedEdges = new IntHashSet();
final IntLongHashMap delaysForBoardEdges = new IntLongHashMap();
final IntLongHashMap delaysForAlightEdges = new IntLongHashMap();
final LinkedList additionalEdges = new LinkedList<>();
final GtfsReader.PtGraphOut overlayGraph = new GtfsReader.PtGraphOut() {
int nextEdge = staticGtfs.getPtGraph().getEdgeCount();
int nextNode = staticGtfs.getPtGraph().getNodeCount();
@Override
public int createEdge(int src, int dest, PtEdgeAttributes attrs) {
int edgeId = nextEdge++;
additionalEdges.add(new PtGraph.PtEdge(edgeId, src, dest, attrs));
return edgeId;
}
@Override
public int createNode() {
return nextNode++;
}
};
feedMessages.forEach((feedKey, feedMessage) -> {
GTFSFeed feed = staticGtfs.getGtfsFeeds().get(feedKey);
ZoneId timezone = ZoneId.of(feed.agency.values().stream().findFirst().get().agency_timezone);
PtGraph ptGraphNodesAndEdges = staticGtfs.getPtGraph();
final GtfsReader gtfsReader = new GtfsReader(feedKey, baseGraph, encodingManager, ptGraphNodesAndEdges, overlayGraph, staticGtfs, null, transfers.get(feedKey), null);
Instant timestamp = Instant.ofEpochSecond(feedMessage.getHeader().getTimestamp());
LocalDate dateToChange = timestamp.atZone(timezone).toLocalDate(); //FIXME
BitSet validOnDay = new BitSet();
LocalDate startDate = feed.getStartDate();
validOnDay.set((int) DAYS.between(startDate, dateToChange));
feedMessage.getEntityList().stream()
.filter(GtfsRealtime.FeedEntity::hasTripUpdate)
.map(GtfsRealtime.FeedEntity::getTripUpdate)
.filter(tripUpdate -> tripUpdate.getTrip().getScheduleRelationship() == GtfsRealtime.TripDescriptor.ScheduleRelationship.SCHEDULED)
.forEach(tripUpdate -> {
Collection frequencies = feed.getFrequencies(tripUpdate.getTrip().getTripId());
int timeOffset = (tripUpdate.getTrip().hasStartTime() && !frequencies.isEmpty()) ? LocalTime.parse(tripUpdate.getTrip().getStartTime()).toSecondOfDay() : 0;
final int[] boardEdges = findBoardEdgesForTrip(staticGtfs, feedKey, feed, tripUpdate);
final int[] leaveEdges = findLeaveEdgesForTrip(staticGtfs, feedKey, feed, tripUpdate);
if (boardEdges == null || leaveEdges == null) {
logger.warn("Trip not found: {}", tripUpdate.getTrip());
return;
}
tripUpdate.getStopTimeUpdateList().stream()
.filter(stopTimeUpdate -> stopTimeUpdate.getScheduleRelationship() == SKIPPED)
.mapToInt(GtfsRealtime.TripUpdate.StopTimeUpdate::getStopSequence)
.forEach(skippedStopSequenceNumber -> {
blockedEdges.add(boardEdges[skippedStopSequenceNumber]);
blockedEdges.add(leaveEdges[skippedStopSequenceNumber]);
});
GtfsReader.TripWithStopTimes tripWithStopTimes = toTripWithStopTimes(feed, tripUpdate);
tripWithStopTimes.stopTimes.forEach(stopTime -> {
if (stopTime.stop_sequence > leaveEdges.length - 1) {
logger.warn("Stop sequence number too high {} vs {}", stopTime.stop_sequence, leaveEdges.length);
return;
}
final StopTime originalStopTime = feed.stop_times.get(new Fun.Tuple2(tripUpdate.getTrip().getTripId(), stopTime.stop_sequence));
int arrivalDelay = stopTime.arrival_time - originalStopTime.arrival_time;
delaysForAlightEdges.put(leaveEdges[stopTime.stop_sequence], arrivalDelay * 1000);
int departureDelay = stopTime.departure_time - originalStopTime.departure_time;
if (departureDelay > 0) {
int boardEdge = boardEdges[stopTime.stop_sequence];
int departureNode = ptGraphNodesAndEdges.edge(boardEdge).getAdjNode();
int delayedBoardEdge = gtfsReader.addDelayedBoardEdge(timezone, tripUpdate.getTrip(), stopTime.stop_sequence, stopTime.departure_time + timeOffset, departureNode, validOnDay);
delaysForBoardEdges.put(delayedBoardEdge, departureDelay * 1000);
}
});
});
feedMessage.getEntityList().stream()
.filter(GtfsRealtime.FeedEntity::hasTripUpdate)
.map(GtfsRealtime.FeedEntity::getTripUpdate)
.filter(tripUpdate -> tripUpdate.getTrip().getScheduleRelationship() == GtfsRealtime.TripDescriptor.ScheduleRelationship.ADDED)
.forEach(tripUpdate -> {
Trip trip = new Trip();
trip.trip_id = tripUpdate.getTrip().getTripId();
trip.route_id = tripUpdate.getTrip().getRouteId();
final List stopTimes = tripUpdate.getStopTimeUpdateList().stream()
.map(stopTimeUpdate -> {
final StopTime stopTime = new StopTime();
stopTime.stop_sequence = stopTimeUpdate.getStopSequence();
stopTime.stop_id = stopTimeUpdate.getStopId();
stopTime.trip_id = trip.trip_id;
final ZonedDateTime arrival_time = Instant.ofEpochSecond(stopTimeUpdate.getArrival().getTime()).atZone(timezone);
stopTime.arrival_time = (int) Duration.between(arrival_time.truncatedTo(ChronoUnit.DAYS), arrival_time).getSeconds();
final ZonedDateTime departure_time = Instant.ofEpochSecond(stopTimeUpdate.getArrival().getTime()).atZone(timezone);
stopTime.departure_time = (int) Duration.between(departure_time.truncatedTo(ChronoUnit.DAYS), departure_time).getSeconds();
return stopTime;
})
.collect(Collectors.toList());
GtfsReader.TripWithStopTimes tripWithStopTimes = new GtfsReader.TripWithStopTimes(trip, stopTimes, validOnDay, Collections.emptySet(), Collections.emptySet());
gtfsReader.addTrip(timezone, 0, new ArrayList<>(), tripWithStopTimes, tripUpdate.getTrip());
});
gtfsReader.wireUpAdditionalDeparturesAndArrivals(timezone);
});
return new RealtimeFeed(feedMessages, blockedEdges, delaysForBoardEdges, delaysForAlightEdges, additionalEdges);
}
private static int[] findLeaveEdgesForTrip(GtfsStorage staticGtfs, String feedKey, GTFSFeed feed, GtfsRealtime.TripUpdate tripUpdate) {
Trip trip = feed.trips.get(tripUpdate.getTrip().getTripId());
StopTime next = feed.getOrderedStopTimesForTrip(trip.trip_id).iterator().next();
int station = staticGtfs.getStationNodes().get(new GtfsStorage.FeedIdWithStopId(feedKey, next.stop_id));
Optional firstBoarding = StreamSupport.stream(staticGtfs.getPtGraph().backEdgesAround(station).spliterator(), false)
.flatMap(e -> StreamSupport.stream(staticGtfs.getPtGraph().backEdgesAround(e.getAdjNode()).spliterator(), false))
.flatMap(e -> StreamSupport.stream(staticGtfs.getPtGraph().backEdgesAround(e.getAdjNode()).spliterator(), false))
.filter(e -> e.getType() == GtfsStorage.EdgeType.ALIGHT)
.filter(e -> normalize(e.getAttrs().tripDescriptor).equals(tripUpdate.getTrip()))
.findAny();
int n = firstBoarding.get().getAdjNode();
Stream boardEdges = evenIndexed(nodes(hopDwellChain(staticGtfs, n)))
.mapToObj(e -> alightForBaseNode(staticGtfs, e));
return collectWithPadding(boardEdges);
}
private static int[] findBoardEdgesForTrip(GtfsStorage staticGtfs, String feedKey, GTFSFeed feed, GtfsRealtime.TripUpdate tripUpdate) {
Trip trip = feed.trips.get(tripUpdate.getTrip().getTripId());
StopTime next = feed.getOrderedStopTimesForTrip(trip.trip_id).iterator().next();
int station = staticGtfs.getStationNodes().get(new GtfsStorage.FeedIdWithStopId(feedKey, next.stop_id));
Optional firstBoarding = StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(station).spliterator(), false)
.flatMap(e -> StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(e.getAdjNode()).spliterator(), false))
.flatMap(e -> StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(e.getAdjNode()).spliterator(), false))
.filter(e -> e.getType() == GtfsStorage.EdgeType.BOARD)
.filter(e -> normalize(e.getAttrs().tripDescriptor).equals(tripUpdate.getTrip()))
.findAny();
int n = firstBoarding.get().getAdjNode();
Stream boardEdges = evenIndexed(nodes(hopDwellChain(staticGtfs, n)))
.mapToObj(e -> boardForAdjNode(staticGtfs, e));
return collectWithPadding(boardEdges);
}
private static int[] collectWithPadding(Stream boardEdges) {
IntArrayList result = new IntArrayList();
boardEdges.forEach(boardEdge -> {
while (result.size() < boardEdge.getAttrs().stop_sequence) {
result.add(-1); // Padding, so that index == stop_sequence
}
result.add(boardEdge.getId());
});
return result.toArray();
}
private static PtGraph.PtEdge alightForBaseNode(GtfsStorage staticGtfs, int n) {
return StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(n).spliterator(), false)
.filter(e -> e.getType() == GtfsStorage.EdgeType.ALIGHT)
.findAny()
.get();
}
private static PtGraph.PtEdge boardForAdjNode(GtfsStorage staticGtfs, int n) {
return StreamSupport.stream(staticGtfs.getPtGraph().backEdgesAround(n).spliterator(), false)
.filter(e -> e.getType() == GtfsStorage.EdgeType.BOARD)
.findAny()
.get();
}
private static IntStream evenIndexed(IntStream nodes) {
int[] ints = nodes.toArray();
IntStream.Builder builder = IntStream.builder();
for (int i = 0; i < ints.length; i++) {
if (i % 2 == 0)
builder.add(ints[i]);
}
return builder.build();
}
private static IntStream nodes(Stream path) {
List edges = path.collect(Collectors.toList());
IntStream.Builder builder = IntStream.builder();
builder.accept(edges.get(0).getBaseNode());
for (PtGraph.PtEdge edge : edges) {
builder.accept(edge.getAdjNode());
}
return builder.build();
}
private static Stream hopDwellChain(GtfsStorage staticGtfs, int n) {
Stream.Builder builder = Stream.builder();
Optional any = StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(n).spliterator(), false)
.filter(e -> e.getType() == GtfsStorage.EdgeType.HOP || e.getType() == GtfsStorage.EdgeType.DWELL)
.findAny();
while (any.isPresent()) {
builder.accept(any.get());
any = StreamSupport.stream(staticGtfs.getPtGraph().edgesAround(any.get().getAdjNode()).spliterator(), false)
.filter(e -> e.getType() == GtfsStorage.EdgeType.HOP || e.getType() == GtfsStorage.EdgeType.DWELL)
.findAny();
}
return builder.build();
}
boolean isBlocked(int edgeId) {
return blockedEdges.contains(edgeId);
}
List getAdditionalEdges() {
return additionalEdges;
}
public Optional getTripUpdate(GTFSFeed staticFeed, GtfsRealtime.TripDescriptor tripDescriptor, Instant boardTime) {
try {
logger.trace("getTripUpdate {}", tripDescriptor);
if (!isThisRealtimeUpdateAboutThisLineRun(boardTime)) {
return Optional.empty();
} else {
GtfsRealtime.TripDescriptor normalizedTripDescriptor = normalize(tripDescriptor);
return feedMessages.values().stream().flatMap(feedMessage -> feedMessage.getEntityList().stream()
.filter(e -> e.hasTripUpdate())
.map(e -> e.getTripUpdate())
.filter(tu -> normalize(tu.getTrip()).equals(normalizedTripDescriptor))
.map(tu -> toTripWithStopTimes(staticFeed, tu)))
.findFirst();
}
} catch (RuntimeException e) {
feedMessages.forEach((name, feed) -> {
try (OutputStream s = new FileOutputStream(name+".gtfsdump")) {
feed.writeTo(s);
} catch (IOException e1) {
throw new RuntimeException();
}
});
return Optional.empty();
}
}
public static GtfsRealtime.TripDescriptor normalize(GtfsRealtime.TripDescriptor tripDescriptor) {
return GtfsRealtime.TripDescriptor.newBuilder(tripDescriptor).clearRouteId().build();
}
public static GtfsReader.TripWithStopTimes toTripWithStopTimes(GTFSFeed feed, GtfsRealtime.TripUpdate tripUpdate) {
ZoneId timezone = ZoneId.of(feed.agency.values().stream().findFirst().get().agency_timezone);
logger.trace("{}", tripUpdate.getTrip());
final List stopTimes = new ArrayList<>();
Set cancelledArrivals = new HashSet<>();
Set cancelledDepartures = new HashSet<>();
Trip originalTrip = feed.trips.get(tripUpdate.getTrip().getTripId());
Trip trip = new Trip();
if (originalTrip != null) {
trip.trip_id = originalTrip.trip_id;
trip.route_id = originalTrip.route_id;
} else {
trip.trip_id = tripUpdate.getTrip().getTripId();
trip.route_id = tripUpdate.getTrip().getRouteId();
}
int delay = 0;
int time = -1;
List stopTimeUpdateListWithSentinel = new ArrayList<>(tripUpdate.getStopTimeUpdateList());
Iterable interpolatedStopTimesForTrip;
try {
interpolatedStopTimesForTrip = feed.getInterpolatedStopTimesForTrip(tripUpdate.getTrip().getTripId());
} catch (GTFSFeed.FirstAndLastStopsDoNotHaveTimes firstAndLastStopsDoNotHaveTimes) {
throw new RuntimeException(firstAndLastStopsDoNotHaveTimes);
}
int stopSequenceCeiling = Math.max(stopTimeUpdateListWithSentinel.isEmpty() ? 0 : stopTimeUpdateListWithSentinel.get(stopTimeUpdateListWithSentinel.size() - 1).getStopSequence(),
StreamSupport.stream(interpolatedStopTimesForTrip.spliterator(), false).mapToInt(stopTime -> stopTime.stop_sequence).max().orElse(0)
) + 1;
stopTimeUpdateListWithSentinel.add(GtfsRealtime.TripUpdate.StopTimeUpdate.newBuilder().setStopSequence(stopSequenceCeiling).setScheduleRelationship(NO_DATA).build());
for (GtfsRealtime.TripUpdate.StopTimeUpdate stopTimeUpdate : stopTimeUpdateListWithSentinel) {
int nextStopSequence = stopTimes.isEmpty() ? 1 : stopTimes.get(stopTimes.size() - 1).stop_sequence + 1;
for (int i = nextStopSequence; i < stopTimeUpdate.getStopSequence(); i++) {
StopTime previousOriginalStopTime = feed.stop_times.get(new Fun.Tuple2(tripUpdate.getTrip().getTripId(), i));
if (previousOriginalStopTime == null) {
continue; // This can and does happen. Stop sequence numbers can be left out.
}
StopTime updatedPreviousStopTime = previousOriginalStopTime.clone();
updatedPreviousStopTime.arrival_time = Math.max(previousOriginalStopTime.arrival_time + delay, time);
logger.trace("stop_sequence {} scheduled arrival {} updated arrival {}", i, previousOriginalStopTime.arrival_time, updatedPreviousStopTime.arrival_time);
time = updatedPreviousStopTime.arrival_time;
updatedPreviousStopTime.departure_time = Math.max(previousOriginalStopTime.departure_time + delay, time);
logger.trace("stop_sequence {} scheduled departure {} updated departure {}", i, previousOriginalStopTime.departure_time, updatedPreviousStopTime.departure_time);
time = updatedPreviousStopTime.departure_time;
stopTimes.add(updatedPreviousStopTime);
logger.trace("Number of stop times: {}", stopTimes.size());
}
final StopTime originalStopTime = feed.stop_times.get(new Fun.Tuple2(tripUpdate.getTrip().getTripId(), stopTimeUpdate.getStopSequence()));
if (originalStopTime != null) {
StopTime updatedStopTime = originalStopTime.clone();
if (stopTimeUpdate.getScheduleRelationship() == NO_DATA) {
delay = 0;
}
if (stopTimeUpdate.hasArrival()) {
delay = stopTimeUpdate.getArrival().getDelay();
}
updatedStopTime.arrival_time = Math.max(originalStopTime.arrival_time + delay, time);
logger.trace("stop_sequence {} scheduled arrival {} updated arrival {}", stopTimeUpdate.getStopSequence(), originalStopTime.arrival_time, updatedStopTime.arrival_time);
time = updatedStopTime.arrival_time;
if (stopTimeUpdate.hasDeparture()) {
delay = stopTimeUpdate.getDeparture().getDelay();
}
updatedStopTime.departure_time = Math.max(originalStopTime.departure_time + delay, time);
logger.trace("stop_sequence {} scheduled departure {} updated departure {}", stopTimeUpdate.getStopSequence(), originalStopTime.departure_time, updatedStopTime.departure_time);
time = updatedStopTime.departure_time;
stopTimes.add(updatedStopTime);
logger.trace("Number of stop times: {}", stopTimes.size());
if (stopTimeUpdate.getScheduleRelationship() == SKIPPED) {
cancelledArrivals.add(stopTimeUpdate.getStopSequence());
cancelledDepartures.add(stopTimeUpdate.getStopSequence());
}
} else if (stopTimeUpdate.getScheduleRelationship() == NO_DATA) {
} else if (tripUpdate.getTrip().getScheduleRelationship() == GtfsRealtime.TripDescriptor.ScheduleRelationship.ADDED) {
final StopTime stopTime = new StopTime();
stopTime.stop_sequence = stopTimeUpdate.getStopSequence();
stopTime.stop_id = stopTimeUpdate.getStopId();
stopTime.trip_id = trip.trip_id;
final ZonedDateTime arrival_time = Instant.ofEpochSecond(stopTimeUpdate.getArrival().getTime()).atZone(timezone);
stopTime.arrival_time = (int) Duration.between(arrival_time.truncatedTo(ChronoUnit.DAYS), arrival_time).getSeconds();
final ZonedDateTime departure_time = Instant.ofEpochSecond(stopTimeUpdate.getArrival().getTime()).atZone(timezone);
stopTime.departure_time = (int) Duration.between(departure_time.truncatedTo(ChronoUnit.DAYS), departure_time).getSeconds();
stopTimes.add(stopTime);
logger.trace("Number of stop times: {}", stopTimes.size());
} else {
// http://localhost:3000/route?point=45.51043713898763%2C-122.68381118774415&point=45.522104713562825%2C-122.6455307006836&weighting=fastest&pt.earliest_departure_time=2018-08-24T16%3A56%3A17Z&arrive_by=false&pt.max_walk_distance_per_leg=1000&pt.limit_solutions=5&locale=en-US&profile=pt&elevation=false&use_miles=false&points_encoded=false&pt.profile=true
// long query:
// http://localhost:3000/route?point=45.518526513612244%2C-122.68612861633302&point=45.52908004573869%2C-122.6862144470215&weighting=fastest&pt.earliest_departure_time=2018-08-24T16%3A51%3A20Z&arrive_by=false&pt.max_walk_distance_per_leg=10000&pt.limit_solutions=4&locale=en-US&profile=pt&elevation=false&use_miles=false&points_encoded=false&pt.profile=true
throw new RuntimeException();
}
}
logger.trace("Number of stop times: {}", stopTimes.size());
BitSet validOnDay = new BitSet(); // Not valid on any day. Just a template.
return new GtfsReader.TripWithStopTimes(trip, stopTimes, validOnDay, cancelledArrivals, cancelledDepartures);
}
public long getDelayForBoardEdge(PtGraph.PtEdge edge, Instant now) {
if (isThisRealtimeUpdateAboutThisLineRun(now)) {
return delaysForBoardEdges.getOrDefault(edge.getId(), 0);
} else {
return 0;
}
}
public long getDelayForAlightEdge(PtGraph.PtEdge edge, Instant now) {
if (isThisRealtimeUpdateAboutThisLineRun(now)) {
return delaysForAlightEdges.getOrDefault(edge.getId(), 0);
} else {
return 0;
}
}
boolean isThisRealtimeUpdateAboutThisLineRun(Instant now) {
if (Duration.between(feedTimestampOrNow(), now).toHours() > 24) {
return false;
} else {
return true;
}
}
private Instant feedTimestampOrNow() {
return feedMessages.values().stream().map(feedMessage -> {
if (feedMessage.getHeader().hasTimestamp()) {
return Instant.ofEpochSecond(feedMessage.getHeader().getTimestamp());
} else {
return Instant.now();
}
}).findFirst().orElse(Instant.now());
}
public StopTime getStopTime(GTFSFeed staticFeed, GtfsRealtime.TripDescriptor tripDescriptor, Label.Transition t, Instant boardTime, int stopSequence) {
StopTime stopTime = staticFeed.stop_times.get(new Fun.Tuple2<>(tripDescriptor.getTripId(), stopSequence));
if (stopTime == null) {
return getTripUpdate(staticFeed, tripDescriptor, boardTime).get().stopTimes.get(stopSequence - 1);
} else {
return stopTime;
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy