Create routable network from GTFS files

Back to Public transport

This page describes how the model constructs a routable public transport network from raw GTFS source files. The relevant scripts are LoadFeeds.dms, RelevantSelection.dms, and GTFS.dms, all under SourceData/Infrastructuur/GTFS.


GTFS data model

GTFS (General Transit Feed Specification) is a widely used open standard for public transport timetables. The model reads the following GTFS files:

File Contents
agency.txt Transit agencies whose services are included in the feed
calendar.txt Weekly service schedules with start and end dates
calendar_dates.txt Exceptions to the calendar, such as added or removed service days
routes.txt Transit routes; a route groups trips that are presented to passengers as a single service
shapes.txt Geographic alignments of vehicle paths
stop_times.txt Scheduled arrival and departure times at each stop for each trip
stops.txt Stop locations with coordinates, names, and identifiers
trips.txt Individual trips, each belonging to a route and operating on a service calendar

For the Netherlands, GTFS feeds are publicly available from the national data portal gtfs.openov.nl. Multiple regional or national feeds may be combined; the model loads them via a consistent file structure defined in LoadFeeds/File_Structure.


Step 1: loading GTFS text files into FSS

GTFS files are plain text CSV files. Reading these repeatedly during analysis would be slow, so the model first converts them to GeoDMS FSS (Feature Storage Server) binary format using LoadFeeds/WriteFSS. This conversion is done once via Templates/LoadCSVThroughGDAL_T and the result is stored per file type in a directory determined by ModelParameters/GTFS_file_date.

All subsequent steps read from FSS via LoadFeeds/ReadFSS, using the same date-based path:

'%NetworkModel_Dir%/Infrastructuur/GTFS/' + GTFS_file_date + '/fss/' + filename + '.fss'

The GTFS_file_date parameter (e.g. '20231003') identifies the feed download date. It should be set to the date of the downloaded GTFS snapshot.


Step 2: date selection and service filtering

The model performs its analysis for a specific calendar date D, set via ModelParameters/Analysis_date (e.g. '20241001'). All times are seconds since 00:00 of D on one axis of 48 hours (Time), without wrapping at midnight: a time after midnight is 86,400 or more, and a period that crosses midnight, such as 23:00 to 01:00, is one interval. Departure moments always lie on D itself.

Since 25 September 2026 the network is built from three service days (LoadFeeds/ServiceDays):

service day which trips position on the axis
D−1 the part after midnight: stop times of 24:00 or later GTFS time − 24 h
D all trips, including their times of 24:00 or later GTFS time
D+1 whole trips that start before the end of the analysis window GTFS time + 24 h

The analysis window runs from Advanced/PT_FirstDepartureMoment, the first departure moment, to Advanced/PT_LastArrivalMoment, the last departure moment plus MaxPTTime and the waiting time at home. D+1 therefore only contributes when the window crosses midnight; with departures at 23:00 to 23:45 on 1 October 2024 it adds 117 links. In a GTFS feed a service day continues after midnight with times of 24:00 and later: on a Tuesday in the feed of 16 March 2026, 41,700 of the 2.31 million departures are after midnight (35,500 between 24:00 and 25:00), while the day itself has only 33 departures before 01:00. Public transport between midnight and about 02:00 therefore comes almost entirely from D−1.

D−1 and D+1 are computed by date arithmetic: Templates/DayNr_T converts yyyymmdd to a day number (days since 1 January 1970) and Templates/CivilDate_T converts back. Until 25 September 2026 the day before was uint32(Analysis_date) - 1, which on the first day of a month is a date that does not exist (20241001 − 1 = 20241000), so on the default date not a single trip of the day before was used, and D+1 was not loaded at all (audit 3.5). With the correct day before, 1 October 2024 gets 32,301 links of 30 September, 7 of them in the window of the 07:00 departure, and 45,651 instead of 45,033 stops.

When the network is built, three checks on the chosen date are computed (ExplicitSuppliers of RelevantSelection/ScheduledLinks); each stops the run with a failed integrity check:

  • Check_ServiceDaysComplete: every service day the window needs has at least Advanced/MinServiceDayTripShare (0.95) of the trips of the busiest day of the same weekday in the feed. D is always needed, D−1 when the window starts before Advanced/PrevServiceDayTailEnd (06:00), D+1 when it ends after midnight. A feed is complete for only a few weeks: in the feed of 16 March 2026 a Tuesday three weeks later has 6% and four weeks later 9% fewer trips than the first Tuesday, and from July less than half. A public holiday runs a Sunday service and fails as well. ServiceDays/Report lists the numbers per day.
  • Check_WeekdayMatchesCongestionDay: the weekday of D matches Advanced/CongestionSpeed_DaySelection, the day of the TomTom car speeds ('tuesday' by default; 'Weekday', 'Weekend' and 'Week' accept any day of that kind).
  • Check_NoDSTChange: D, and D+1 when the window crosses midnight, is not the last Sunday of March or October. GTFS counts the times of a service day from noon minus 12 hours, which on those days is not midnight.

These replace the earlier integrity check on Analysis_date, which only required it to lie within GTFS_file_date + 100, computed on the digits (at the end of the year that bound rejected all of January, and within a month it accepted dates with 9% fewer trips).

The Services unit aggregates the calendar_dates.txt exceptions per service identifier and day (Today_exc, Yesterday_exc, Tomorrow_exc). A value of 1 means service is added; a value of 2 means service is removed. The model reads only calendar_dates.txt, not the weekly patterns of calendar.txt; the OVapi feeds put every service day in calendar_dates.txt.


Step 3: constructing protoStops and the filtered Stops set

All stop records from stops.txt are first loaded into protoStops. Here, WGS84 coordinates (stop_lat, stop_lon) are converted to Dutch Rijksdriehoek (rdc) coordinates using LatLongWgs842RD. The StopUsed attribute then marks each stop as active if at least one stop time record in protoStopTimes links to it on one of the three service days:

StopUsed := any(protoStopTimes/SelToday, protoStopTimes/protoStop_rel)
         || any(protoStopTimes/SelYesterday, protoStopTimes/protoStop_rel)
         || any(protoStopTimes/SelTomorrow, protoStopTimes/protoStop_rel)

The public transport nodes (OV_Knooppunten) cluster only the stops with a departure on D, so a stop used only by a night trip of D−1 does not join two clusters.

The filtered Stops unit in RelevantSelection is derived from protoStops by selecting only those stops where StopUsed is true. For each stop, a mode is assigned based on the most frequent transport mode serving it across all StopTimes (modus(StopTimes/Mode_rel, StopTimes/Stop_rel)). Several additional attributes are computed on this stop set that are later needed in the OV model:

Stop clusters for identifying adjacent platforms of the same station: Find_Halte_Clusters uses join_near_values to find all pairs of stops within DistanceStopClusters metres of each other, producing a c_Stop_Stop_rel set used later to filter out intra-cluster OD pairs.

NS station mapping: Find_Trainstations_Clusters spatially joins each stop against a list of known train stations (within DistanceTrainStationsSelection metres), then looks up the corresponding NS_Stations_rel from the NS pricing data via the station code. Since 27 September 2026 a rail stop without a station within that distance is matched on its name instead (Trainstation_byname_rel; rail platforms carry the name of their station, bus stops do not). This was needed for Vught: in the feed of 25 September 2026 its platforms lie about 600 m south of the station point after the railway was lowered, so 118 NS legs had no station to price and the chain step stopped on NS_Station_Price_IntegrityCheck. In the feeds of 1 October 2024, 30 September 2025 and 15 June 2026 the name match changes no stop. The IsNSICStation flag is derived from the distance match only. These attributes are needed for NS pricing and through-pricing.

OV-fiets stations: Find_OVFiets_Stops spatially joins stops against known OV-fiets locations (within DistanceOVFietsStops metres), producing the IsOVFietsStation flag. Stops with this flag can be used as origins or destinations for the OV-fiets post-transport connection type.


protoStopTimes enriches the raw stop times with the trip’s service selection flags and computes the NextStopId for each stop-time record: the record in the same trip with the next sequential stop sequence number. Each non-final stop-time record thus forms one link from stop N to stop N+1.

Links are built per service day by the template DayLinks_T, in protoStopTimes/Yesterday/Links, protoStopTimes/Today/Links and protoStopTimes/Tomorrow/Links, with the times shifted by −24, 0 and +24 hours. Link length is taken from shape_dist_traveled where it is more than 100 m, falling back to the Euclidean arc length otherwise; until 25 September 2026 a link of the day before without it had length 0.

The ScheduledLinks unit in RelevantSelection is the union of the links of the three service days. Each link has:

  • FromStop_rel and ToStop_rel: references into the filtered Stops set.
  • fromTime_rel and toTime_rel: departure and arrival times as time step indices.
  • Duration: travel time in seconds (with midnight-crossing handled via sub_or_null with wrap-around).
  • Length: route distance in kilometres.
  • Mode_rel, Route_rel, Agency_rel, Trip_rel: references to the relevant GTFS entities.
  • c_FromTime_Stop_rel and c_ToTime_Stop_rel: the c_Time_Stop combined keys used throughout the OV model.

The unique set of all departure and arrival time-stop events across all ScheduledLinks is also computed here as uq_TimeStop (aka Scheduled Space-Time Events, SSTE), and the unique set of stop locations that appear in the link set forms SL_Places, which feeds into the global Places domain.


The scheduled links alone do not yet form a routable network. A traveller sitting in a train at an intermediate stop must wait until the train departs again; this waiting time is not represented by any scheduled link. To make the network routable, waiting-at-stop links are added between consecutive time-stop events on the same trip.

This is done in GTFS/GetWaitingAtStop, using CreateWaitingAtStopSet_T. The construction proceeds as follows:

  1. All scheduled links are doubled into their from-events and to-events, producing a set of (trip, time) pairs called doubledLinks.
  2. The unique set of these pairs, WaitingAtStop, gives one node per moment that a vehicle is present at a stop within a trip.
  3. For each node, NextMoment identifies the next node on the same trip at the same stop, using add_or_null(id(.), 1) where both the trip and stop match.
  4. A waiting link is created from each node to its NextMoment, with Duration equal to the time difference between them. Midnight crossings are handled in the same way as for scheduled links.

Waiting links have Length = 0, Mode_rel = Waiting, and no Agency_rel. For Set_L (the NS network subset), waiting links at NS stations are included alongside the scheduled NS links, so that the Dijkstra traversal can wait for a later NS departure.


Step 6: the complete PublicTransportNet

GTFS/PublicTransportNet is the union of RelevantSelection/ScheduledLinks and GetWaitingAtStop/WaitingAtStop. This is the full routable network. All links carry a LinkType_rel attribute (Scheduled or Waiting_at_Stop) from Classifications/LinkTypes.

Two further attributes are computed on this union for NS pricing:

  • NS_StartPoint_rel and NS_EndPoint_rel: the NS station references for the from-stop and to-stop, via Stops/NS_Stations_rel.
  • c_NS_start_end_rel: the combined station-pair key used to look up NS prices and through-prices in TariffUnitsMatrix (see Public transport fares).

This network is the input to Get_Windowed_Agency_Set_T in PublicTransport_Prep, which applies time-window and agency filters to produce the Set_R and Set_L subsets used for the OD matrix calculations. See the Script reference for details on those steps.


Routes and TimeInvariantTypes

The Routes unit in RelevantSelection is not only built from the GTFS routes.txt but also includes a row for each Classifications/TimeInvariantType. This means that pre- and post-transport connection types (walking, cycling) are represented as routes in the same domain as scheduled public transport routes. This unified representation simplifies attribute lookup by mode and route throughout the model.