diff --git a/application/src/main/java/org/opentripplanner/transit/model/network/TripPattern.java b/application/src/main/java/org/opentripplanner/transit/model/network/TripPattern.java index 4b35bf5e89a..a258d0c7252 100644 --- a/application/src/main/java/org/opentripplanner/transit/model/network/TripPattern.java +++ b/application/src/main/java/org/opentripplanner/transit/model/network/TripPattern.java @@ -2,6 +2,7 @@ import static java.util.Objects.requireNonNull; import static org.opentripplanner.utils.lang.ObjectUtils.requireNotInitialized; +import static org.opentripplanner.utils.time.ServiceDateUtils.wholeDays; import java.util.ArrayList; import java.util.Collection; @@ -108,7 +109,7 @@ public final class TripPattern private final boolean realTimeTripPattern; private final boolean stopPatternModifiedInRealTime; - + private final int maxTripSpanDays; private final RoutingTripPattern routingTripPattern; TripPattern(TripPatternBuilder builder) { @@ -139,6 +140,7 @@ public final class TripPattern this.originalTripPattern = builder.getOriginalTripPattern(); this.hopGeometries = builder.hopGeometries(); + this.maxTripSpanDays = computeMaxTripSpanDays(); this.routingTripPattern = new RoutingTripPattern(this); getId().requireSameFeedId(route.getId()); @@ -412,6 +414,16 @@ public Timetable getScheduledTimetable() { return scheduledTimetable; } + /** + * The maximum number of whole days that any trip in this pattern spans from its service date + * midnight to the latest arrival at the last stop. For most patterns this is zero(0) - all times + * are on the same service-day(operation day). For a nightbus which ends at 02:45+1d this is 1. + * And for a multi-day services like coastal ferries it can span several days. + */ + public int getMaxTripSpanDays() { + return maxTripSpanDays; + } + /** * The original TripPattern this replaces at least for one modified trip. * @@ -558,4 +570,20 @@ private I18NString getTripHeadsignFromOriginalPattern() { } return null; } + + /** + * Compute the maximum number of whole days a trip schedule lasts. This method + * will use the last stop arrival time of the last trip. Return zero if the + * arrival time is negative. + */ + private int computeMaxTripSpanDays() { + var tripTimesList = scheduledTimetable.getTripTimes(); + if (tripTimesList.isEmpty()) { + return 0; + } + + var lastTrip = tripTimesList.getLast(); + // We ignore overtaking and return 0 for negative values + return wholeDays(lastTrip.getArrivalTime(lastTrip.getNumStops() - 1)); + } } diff --git a/application/src/main/java/org/opentripplanner/transit/service/StopTimesHelper.java b/application/src/main/java/org/opentripplanner/transit/service/StopTimesHelper.java index 0167c9cfd72..898c62b7f0f 100644 --- a/application/src/main/java/org/opentripplanner/transit/service/StopTimesHelper.java +++ b/application/src/main/java/org/opentripplanner/transit/service/StopTimesHelper.java @@ -2,6 +2,7 @@ import static org.opentripplanner.transit.service.ArrivalDeparture.ARRIVALS; import static org.opentripplanner.transit.service.ArrivalDeparture.DEPARTURES; +import static org.opentripplanner.utils.time.ServiceDateUtils.calculateRunningDates; import com.google.common.collect.MinMaxPriorityQueue; import java.time.Duration; @@ -35,11 +36,11 @@ class StopTimesHelper { } /** - * Fetch upcoming vehicle departures from a stop. It goes though all patterns passing the stop for - * the previous, current and next service date. It uses a priority queue to keep track of the next - * departures. The queue is shared between all dates, as services from the previous service date - * can visit the stop later than the current service date's services. This happens eg. with - * sleeper trains. + * Fetch upcoming vehicle departures from a stop. It goes through all patterns passing the stop + * for the given time-window `[startTime, startTime+timeRange]`. It uses a priority queue to keep + * track of the next departures. The queue is shared between all dates, as services from earlier + * service dates can visit the stop later than the current service date's services. This happens + * with sleeper trains and multi-day services. *

* TODO: Add frequency based trips * @@ -225,11 +226,6 @@ private Queue listTripTimeOnDatesForPatternAtStop( Comparator sortOrder ) { ZoneId zoneId = transitService.getTimeZone(); - LocalDate startDate = startTime.atZone(zoneId).toLocalDate().minusDays(1); - LocalDate endDate = startTime.plus(timeRange).atZone(zoneId).toLocalDate(); - - // datesUntil is exclusive in the end, so need to add one day - List serviceDates = startDate.datesUntil(endDate.plusDays(1)).toList(); // The bounded priority Q is used to keep a sorted short list of trip times. We can not // rely on the trip times to be in order because of real-time updates. This code can @@ -246,8 +242,11 @@ private Queue listTripTimeOnDatesForPatternAtStop( int timeRangeSeconds = (int) timeRange.toSeconds(); + int maxTripSpanDays = pattern.getMaxTripSpanDays(); + var runningDates = calculateRunningDates(startTime, timeRange, zoneId, maxTripSpanDays); + // Loop through all possible days - for (LocalDate serviceDate : serviceDates) { + for (LocalDate serviceDate : runningDates) { Timetable timetable = transitService.findTimetable(pattern, serviceDate); ZonedDateTime midnight = ServiceDateUtils.asStartOfService(serviceDate, zoneId); int secondsSinceMidnight = ServiceDateUtils.secondsSinceStartOfService( diff --git a/application/src/test/java/org/opentripplanner/transit/model/network/TripPatternTest.java b/application/src/test/java/org/opentripplanner/transit/model/network/TripPatternTest.java index 2baba7183a6..bf241a5b47b 100644 --- a/application/src/test/java/org/opentripplanner/transit/model/network/TripPatternTest.java +++ b/application/src/test/java/org/opentripplanner/transit/model/network/TripPatternTest.java @@ -11,10 +11,13 @@ import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.locationtech.jts.geom.LineString; import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; import org.opentripplanner.transit.model.basic.TransitMode; import org.opentripplanner.transit.model.site.RegularStop; +import org.opentripplanner.transit.model.timetable.ScheduledTripTimes; class TripPatternTest { @@ -137,4 +140,39 @@ void containsAnyStopId() { assertTrue(subject.containsAnyStopId(List.of(STOP_A.getId()))); assertTrue(subject.containsAnyStopId(List.of(STOP_A.getId(), id("not-in-pattern")))); } + + @Test + void maxTripSpanDaysWithNoTrips() { + assertEquals(0, subject.getMaxTripSpanDays()); + } + + @ParameterizedTest + @CsvSource( + value = """ + Description | Timetable | Expected number of days + Same day | 08:00 22:00 | 0 + Same day, exact limit | 08:00 23:59 | 0 + Night bus | 22:00 1:00+1d | 1 + Overnight exact limit | 22:59 23:59+1d | 1 + 2 overnights | 1:00 1:00+2d | 2 + """, + delimiter = '|', + useHeadersInDisplayName = true + ) + void maxTripSpanDays(String testCaseName, String schedule, int expectedNumberOfDays) { + var pattern = TripPattern.of(id(testCaseName)) + .withRoute(ROUTE) + .withStopPattern(TimetableRepositoryForTest.stopPattern(STOP_A, STOP_C)) + .withScheduledTimeTableBuilder(builder -> + builder.addTripTimes( + ScheduledTripTimes.of() + .withTrip(TimetableRepositoryForTest.trip("t1").build()) + .withDepartureTimes(schedule) + .build() + ) + ) + .build(); + + assertEquals(expectedNumberOfDays, pattern.getMaxTripSpanDays()); + } } diff --git a/utils/src/main/java/org/opentripplanner/utils/time/ServiceDateUtils.java b/utils/src/main/java/org/opentripplanner/utils/time/ServiceDateUtils.java index d3ef71c1075..29cda436d86 100644 --- a/utils/src/main/java/org/opentripplanner/utils/time/ServiceDateUtils.java +++ b/utils/src/main/java/org/opentripplanner/utils/time/ServiceDateUtils.java @@ -10,6 +10,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -22,6 +23,7 @@ */ public class ServiceDateUtils { + public static final int SECONDS_IN_A_DAY = (int) Duration.ofDays(1).toSeconds(); private static final String MAX_TEXT = "MAX"; private static final String MIN_TEXT = "MIN"; private static final Pattern PATTERN = Pattern.compile("^(\\d{4})-?(\\d{2})-?(\\d{2})$"); @@ -111,6 +113,52 @@ public static int secondsSinceStartOfService( return (int) Duration.between(startOfService, dateTime).toSeconds(); } + /// List all running dates given a time window `[startTime, startTime+window]` and the service + /// `serviceZoneId`. The given `serviceMaxTripSpanDays` is used to expand the running dates ahead. + /// This accounts for night-busses(a service running past midnight) and for a multi-day services + /// like coastal ferries spanning several days. + /// + /// The time-window is inclusive - inclusive. + /// + /// > **NOTE! Day-light-saving(DST) handling** + /// > + /// > In case DST is used for the given `serviceZoneId`, then the service-day overlap when + /// > the transition from winter-time to summer-time occours. This method checks for this and + /// > includes the first service-day of summer-time, when the time is last-service-day in + /// > winter-time and the clock is between 23:00 to 23:59:59. There is **no** such check performed + /// > in fall. The method returns the first-day of winter-time, when the time is in the 1 hour gap + /// > between last day of summer and first day of winter. This overselection should not cause any + /// > problems. + /// + public static List calculateRunningDates( + Instant startTime, + Duration window, + ZoneId serviceZoneId, + int serviceMaxTripSpanDays + ) { + var t0 = startTime.atZone(serviceZoneId); + var t1 = t0.plus(window); + + // Account for over-night services like night-busses and multi-day trips + var ld0 = t0.toLocalDate().minusDays(serviceMaxTripSpanDays); + var ld1 = t1.toLocalDate(); + + // Adjust for overlapping service-days. This happens when we go from winter-time to summer time. + if (t1.getHour() == 23 && t1.plusHours(12).getHour() == 12) { + ld1 = ld1.plusDays(1); + } + // +1 to end, to include the end. The `datesUntil` is [inclusive, exclusive) + return ld0.datesUntil(ld1.plusDays(1)).toList(); + } + + /** + * Calculate the number of whole days from a duration in seconds. Returns 0 if the given input + * is negative. + */ + public static int wholeDays(int seconds) { + return seconds < 0 ? 0 : (seconds / SECONDS_IN_A_DAY); + } + /** * Parse given input string in the "YYYYMMDD" or "YYYY-MM-DD" format. * diff --git a/utils/src/test/java/org/opentripplanner/utils/time/ServiceDateUtilsTest.java b/utils/src/test/java/org/opentripplanner/utils/time/ServiceDateUtilsTest.java index 028b53b064a..dec207a357b 100644 --- a/utils/src/test/java/org/opentripplanner/utils/time/ServiceDateUtilsTest.java +++ b/utils/src/test/java/org/opentripplanner/utils/time/ServiceDateUtilsTest.java @@ -7,6 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.opentripplanner.utils.time.ServiceDateUtils.asStartOfService; +import static org.opentripplanner.utils.time.ServiceDateUtils.calculateRunningDates; +import static org.opentripplanner.utils.time.ServiceDateUtils.wholeDays; import java.text.ParseException; import java.time.Duration; @@ -16,7 +18,10 @@ import java.time.Month; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Arrays; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; public class ServiceDateUtilsTest { @@ -37,6 +42,7 @@ public class ServiceDateUtilsTest { LocalTime.MIDNIGHT, ZONE_ID ); + public static final ZoneId SERVICE_ZONE_ID = ZoneId.of("Europe/Oslo"); @Test public void testAsStartOfServiceWithLocalDatesAndZoneAroundDST() { @@ -143,6 +149,65 @@ public void secondsSinceStartOfTimeWithZoneId() { ); } + /// The input time is in UTC and the the transit service time zone(Europe/Oslo) which is + /// +1 hour in winter and +2 in summer. This means that we switch to a new service-day at + /// 23:00Z in winter time and at 22:00Z in summer time. + /// + /// Note! The last test cases test the transition from summer-time to winter-time and back. + /// Day light savings is adjusted: + /// - 29. March 2026 02:00 -> 03:00 First summer service-day start at 23:00, 1h overlap + /// - 25. October 2026 03:00 -> 02:00 First winter service-day start at 01:00, 1h gap + /// + @ParameterizedTest + @CsvSource( + value = """ + Start time | Window | days | Expected + 2026-02-10T12:00:00Z | 60m | 0 | 2026-02-10 + 2026-02-10T12:00:00Z | 60m | 1 | 2026-02-09, 2026-02-10 + 2026-02-10T12:00:00Z | 60m | 3 | 2026-02-07, 2026-02-08, 2026-02-09, 2026-02-10 + 2026-02-10T22:29:59Z | 30m | 0 | 2026-02-10 + 2026-02-10T22:30:00Z | 30m | 0 | 2026-02-10, 2026-02-11 + 2026-02-10T22:59:59Z | 30m | 0 | 2026-02-10, 2026-02-11 + 2026-02-10T23:00:00Z | 30m | 0 | 2026-02-11 + 2026-08-10T21:39:59Z | 20m | 0 | 2026-08-10 + 2026-08-10T21:40:00Z | 20m | 0 | 2026-08-10, 2026-08-11 + 2026-08-10T21:59:59Z | 20m | 0 | 2026-08-10, 2026-08-11 + 2026-08-10T22:00:00Z | 20m | 0 | 2026-08-11 + 2026-03-28T21:49:59Z | 10m | 0 | 2026-03-28 + 2026-03-28T21:50:00Z | 10m | 0 | 2026-03-28, 2026-03-29 + 2026-10-23T21:49:59Z | 10m | 0 | 2026-10-23 + 2026-10-23T21:50:00Z | 10m | 0 | 2026-10-23, 2026-10-24 + """, + delimiter = '|', + useHeadersInDisplayName = true + ) + public void testCalculateRunningDates( + Instant startTime, + String window, + int maxTripSpanDays, + String expectedInput + ) { + var win = DurationUtils.duration(window); + + var result = calculateRunningDates(startTime, win, SERVICE_ZONE_ID, maxTripSpanDays); + + var expected = Arrays.stream(expectedInput.split(", ")).map(LocalDate::parse).toList(); + assertEquals(expected, result); + } + + @Test + public void testWholeDays() { + int secondsInOneDay = 86400; + int secondsInTenDays = 10 * secondsInOneDay; + assertEquals(0, wholeDays(-secondsInTenDays)); + assertEquals(0, wholeDays(-1)); + assertEquals(0, wholeDays(0)); + assertEquals(0, wholeDays(secondsInOneDay - 1)); + assertEquals(1, wholeDays(secondsInOneDay)); + assertEquals(9, wholeDays(secondsInTenDays - 1)); + assertEquals(10, wholeDays(secondsInTenDays)); + } + @Test public void parse() throws ParseException { LocalDate subject;