Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* TODO: Add frequency based trips
*
Expand Down Expand Up @@ -225,11 +226,6 @@ private Queue<TripTimeOnDate> listTripTimeOnDatesForPatternAtStop(
Comparator<TripTimeOnDate> 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<LocalDate> 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
Expand All @@ -246,8 +242,11 @@ private Queue<TripTimeOnDate> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice usage of 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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})$");
Expand Down Expand Up @@ -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<LocalDate> 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);
Copy link
Contributor

@habrahamsson-skanetrafiken habrahamsson-skanetrafiken Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you need to decrease ld0 in a corresponding way if any of the days from ld0 - 1 until t0 - 1 are 23 hour days?

}
// +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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand All @@ -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() {
Expand Down Expand Up @@ -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 day start at 23:00, 1h overlap
/// - 25. October 2026 03:00 -> 02:00 Fist winter day start at 01:00, 1h gap
Comment on lines +158 to +159
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - 29. March 2026 02:00 -> 03:00 First summer day start at 23:00, 1h overlap
/// - 25. October 2026 03:00 -> 02:00 Fist winter day start at 01:00, 1h gap
/// - 29. March 2026 02:00 -> 03:00 First summer service-day start at 23:00, 1h overlap
/// - 25. October 2026 03:00 -> 02:00 Fist winter service-day start at 01:00, 1h gap

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also fist -> first

///
@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;
Expand Down
Loading