-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Account for DST and multi-day trips in departure boards #7250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev-2.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| // +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. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice usage of CsvSource.