Skip to content

After-midnight departures are counted inconsistently across report surfaces #456

Description

@irees

GTFS counts seconds from the service day's midnight, so a trip departing after midnight is stated as 24:00:00 or later on the service date it belongs to. StopDepartureCache files such a departure under the calendar date it actually falls on but keeps the feed's seconds unreduced, so departureTime >= 86400 is an exact marker for "this departure belongs to the previous service day".

Consumers disagree about what to do with those, and none of them says so.

Surface After-midnight departures
Frequency and headway statistics — route-headway.ts filters depTime <= endTime, and endTime defaults to parseHMS('24:00:00') = 86400 dropped
Departures-by-hour chart — buckets are created only for h < endHour, which defaults to 24, so Math.floor(86400/3600) = 24 finds no bucket dropped
Scenario filter time window — scenario-filter.ts defaults endTimeValue to '24:00:00' dropped
Report summary earliest/latest trip times, and the included-trips total counted
StopDepartureCache.hasService counted

So a route's owl service raises its trip count and sets "Latest trip start: 24:00:00" in the summary, while contributing nothing to the frequency figures or the hourly histogram directly above it. Nothing in the interface accounts for the difference.

This predates the trip-oriented departures work; that branch deliberately preserves the behaviour so the change stays reviewable.

Scale

Route 20 Burnside/Stark, Portland bounding box, against the docker fixture: 1,631 of 211,142 stop times at in-bounds stops depart at hour 24 or later, about 0.8%. Measured at one representative stop per direction per day that is roughly one departure per day, which is enough to move a headline number.

What happens if they are simply included

Including them without any further rule was tried and is worse, which is the substance of this issue.

They land in hour 0 of the calendar day, in the same day bucket as the following morning's service, and headways are never paired across day buckets. So the stretch from the last owl trip to the first morning trip becomes a legitimate within-day gap. On TriMet FX2 that produced a 4h 27m gap where the previous longest was minutes, dragging average headway from 11m 29s to 17m 08s while the median stayed at 12m. It also tripped the IRREGULAR_MIN_LARGEST_GAP_SECONDS caveat on routes that run consistently all day, which is not what that check was written to catch.

The gap is real: FX2 genuinely has no service between roughly 00:33 and 05:00. The question is whether an overnight break should be reported as a headway.

Proposed rule

Do not pair departures across a service-day boundary. departureTime >= 86400 identifies a departure as belonging to the previous service day, so the gap between the last such departure and the first departure of the current service day is by definition a break between service days rather than a wait between consecutive trips.

Stated for the interface, alongside the existing noise floor:

Gaps under 120 seconds are treated as noise, and the overnight break between service days is not counted. Both are shown struck through below.

This is worth preferring over a magnitude threshold such as "ignore gaps over three hours". A magnitude rule is unexplainable — it asserts that a rural route's genuine four-hour midday headway is not a headway — and needs a constant chosen by taste. The service-day rule needs no constant, is derived from the data, and degrades safely: a genuine 24-hour route crosses the service-day boundary with an ordinary gap, so excluding it changes almost nothing.

Communicating it needs no new mechanism. The report already states the 120-second noise floor and renders excluded gaps struck through, so the reader can see exactly which gap was dropped and why.

Also worth considering

Average headway is the statistic a single gap can move; the median is not. In the FX2 case the median was 12m before and after. Leading with the median, and presenting average and slowest as secondary diagnostics, removes most of the sensitivity without any filtering at all. "Slowest gap" then becomes genuinely useful when correctly named as the overnight break rather than presented as a headway.

Implementation note

The stored seconds do two jobs: wall-clock ordering and service-day identity. Sorting a calendar day's departures by the stored value puts 05:00 (18000) before 24:30 (88200), which is backwards in wall-clock terms. This is currently moot because the 86400 window bound drops the later ones before any sort, but any change that includes them has to sort by seconds % 86400 while still reading seconds >= 86400 as the service-day marker. It is easy to get one of those two right and the other wrong.

Out of scope

Departures more than 48 hours into a service day are a separate problem. The pinned transitland-lib build reaches back one service date, so a trip like Amtrak's Empire Builder, whose stop times run 61 to 63 hours into its service day, is not returned at all. Raising that reach is an upstream change, noted in transitland-lib PR 721's limitations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions