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;