Public transport

This page describes the methodology, algorithms, and configuration of the public transport (PT) component of the model. The presentation loosely follows the data flow: from input GTFS data via operator-specific OD matrices and transfers through the chain joiner to the final per-departure-moment output.

Contents

See also: Create routable network from GTFS files · Public transport fares · Script reference


Introduction

To simulate public transport trips, the model combines timetables with pre- and post-transport legs (for example, walking from the origin to the first public transport stop, and walking from the last stop to the destination), and it facilitates waiting at stops and transferring from one line or operator to another. Simulating public transport differs fundamentally from simulating car or bicycle travel, because departure time matters: there is a third dimension of time on top of the 2D spatial component. A traveller cannot board a bus that has already left, and waiting costs time.

Because of the non-linear cost structure of the Dutch public transport fare system, a single Dijkstra run over a mixed network would not yield correct prices. Instead, the model separates the network into two operator sets, calculates single-operator OD matrices for each, and then chains these together while re-computing prices at every join. The approach resembles the RAPTOR algorithm: reachable stops are expanded in rounds, one transfer per round.

The overall flow of the model is as follows:

  1. Build two subsets of the GTFS network within the relevant time window via the Get_Windowed_Agency_Set_T template (PublicTransport_Prep.dms): one for regional transport outside the NS fare network (Set_R) and one for the NS fare network (Set_L).
  2. Run the GeoDMS impedance_matrix operator (Dijkstra) over each subset to get single-operator OD matrices OD_R and OD_L (MakeODs.dms).
  3. Enumerate all valid transfers between stops of different OD sets (CreateTransfers_T.dms, Transfers.dms).
  4. Iteratively combine operator legs with transfers into multi-leg chains (ChainJoiner_T.dms), applying Pareto-optimal filtering per block of boarding stops.
  5. Cache the chain result in the chain cache, one MMD cache per stop block (PublicTransport_Prep/PT_Chains_Blocks, written by batch\WriteChainBlocks.ps1), then read it back as PublicTransport_Prep/PT. The output step reads only the chains that board at a stop where a pre-transport leg of the component ends (PublicTransport_Prep/PT_V).
  6. For each block of origins and each departure moment, join pre-transport legs, filter PT chains to the relevant time window, join post-transport legs, add the direct trips of a direct component, and condense to Pareto-optimal results per OD pair (PerDepartureMoment_T.dms).

The time-expanded graph and the NodeSet

The public transport network in this model is represented as a time-expanded graph. In this representation, each node corresponds to a unique combination of a time step, a stop, and a route. This construction happens inside Get_Windowed_Agency_Set_T.

The GTFS scheduled links each connect two consecutive stops: a vehicle departs from a stop at a given time and arrives at the next stop at a later time. Each such link thus has a departure event and an arrival event. The template doubles each selected link into its two endpoint events and takes the unique set, which produces the NodeSet: the set of all distinct (time, stop, route) triples that occur in the selected time window and agency set.

NodeSet := unique(doubledLinks/c_Time_Stop_Route_rel)

The F1 and F2 attributes on each link are foreign keys into this NodeSet, pointing respectively to the departure node and the arrival node of that link. These two attributes are passed directly to the GeoDMS impedance_matrix function as the from-node and to-node of each arc. Both subsets keep only links with a positive duration, so every arc runs from an earlier to a later event: Dijkstra propagates forward in time only, and a leg can never run back in time.

The combined key used throughout the model to identify a time-stop event is c_Time_Stop_rel, a uint64 value of the unit c_Time_Stops, constructed as:

c_Time_Stop_rel = uint64(time_index) * uint64(#Stops) + uint64(stop_index)

The time index and stop index can be recovered by integer division and modulo respectively. This compact encoding allows all joins across OD results, transfer sets, and chain joiners to be done via efficient equality joins, without materialising explicit time-expanded edges between consecutive time steps.


Two OD sets: OD_R and OD_L

The model creates two OD matrices by running the impedance_matrix operator over the two network subsets. Both use the same call signature but differ in their input set, in the legs they keep and in how prices are subsequently computed.

OD_R: regional transport

OD_R covers all public transport outside the NS fare network. The Set_R filter selects links from agencies where Agency_rel->IsRO is true and where the scheduled duration is positive. The IsRO flag is derived in Classifications/Agencies as not(IsNS), where IsNS is true for NS, NS International (NS Int from the feed of 8 October 2025) and European Sleeper. All other agencies, including foreign carriers such as NMBS, De Lijn, VIAS, DB and Eurobahn, are part of OD_R and are priced via the fare table.

OD_L: the NS fare network

OD_L covers the trains with the NS fare. The Set_L filter selects the links of the agencies in Agency_rel->IsLnet: NS, and NS International for domestic journeys when Advanced/IsNSInternationalInLnet is set (default TRUE, #3). Trains on which an NS ticket is never valid are left out (Route_rel->IsWithoutNSTicket, the route names in Advanced/TrainsWithoutNSTicket: Eurostar, Thalys, Nightjet, European Sleeper). European Sleeper is therefore in neither set.

The filter also selects the waiting links at NS stations (links whose mode is Waiting and for which a c_NS_start_end_rel is defined). A waiting link is the time a vehicle stands still at a stop within its trip (GetWaitingAtStop, one link per trip and time); without these links an NS leg could not continue beyond a station where the train stops.

Time window and impedance

Both subsets are restricted to the relevant time window. The window runs from FirstDepartureMoment (the earliest departure moment in MeasureMoments) to LastArrivalMoment (Impl/PT_LastArrivalMoment): the latest departure moment plus MaxPTTime, plus MaxWaitingTimeAtOrigin when waiting at home is not counted towards travel time. A link is selected when it departs at or after the start of the window, arrives within the window and takes at most MaxPTTime.

The impedance_matrix call uses Duration as the arc impedance and Length as the alternative impedance (alt_imp), which accumulates the geographic distance travelled in kilometres along the scheduled route. The result is cut at MaxPTTime. For OD_R the call has the pareto option (Advanced/ParetoLegs_R, default on, #19, #115): among routes with the same travel time between two nodes it keeps the shortest, which is the cheapest in the regional fare. OD_L does not need it (ParetoLegs_L off), because the NS fare depends on the first and last station, not on the distance travelled.

Filters on the legs

After running Dijkstra, each OD result (OD_R/Result, OD_L/Result) keeps only the legs that pass these filters:

  • Origin and destination stop do not lie in the same stop cluster (IsStartEndWithinSameStopCluster). Stop clusters are the pairs of stops within Advanced/DistanceStopClusters (100 m) of each other, each stop with itself included, so this also removes travel to the same stop. Different platforms of the same station typically form such a cluster.
  • The GTFS allows boarding at the start of the leg and alighting at its end (IsBoardAlightAllowed: pickup_type and drop_off_type in stop_times.txt are not 1; #3).
  • For OD_R only: the leg does not lie entirely outside the Dutch concession areas (IsOD_completely_foreign), because such foreign pairs cannot be priced.
  • For OD_L only: the leg is domestic (IsDomesticLeg): a leg of NS, or a leg of NS International between two NS stations in the Netherlands that has an NS price.
  • With extra time on flex legs (OD_R) or on legs with a ride surcharge (OD_L), the travel time including that extra time is at most MaxPTTime (see Demand-responsive transport (flex)).

Index attributes

Each result row also receives two index attributes: uq_c_FromTime_Stop_rel and uq_c_ToTime_Stop_rel. These are references into the global unique indices uq_c_FromTime_Stop and uq_c_ToTime_Stop, which are computed in PublicTransport_Prep as the unique set of all departure and arrival time-stop events that appear across both OD_L/Result and OD_R/Result. These indices are the keys used for all subsequent joins in the chain generation step.


Price calculation per operator type

The two OD matrices cache prices differently because the underlying fare structures differ. The fare tables, the two price methods for regional transport (Detailed and DOVA), the coupling of the fare year to the GTFS feed and the price checks are described on the separate Public transport fares page. In short:

Regional legs (OD_R)

Each leg is matched to a row of OVprijzen/FareTable based on its mode, agency, concession area and route short name (Detailed method), or on its mode class and concession area (DOVA method). The matched row yields two components that are stored separately on the OD result:

  • Price_I: the fixed boarding cost (instaptarief), converted to Ct (euro-cents).
  • Price_O: the variable distance cost, computed as FareTable/VariablePrice * PT_TravelledDistance, where the distance is the accumulated alt_imp in kilometres, plus FlexCallCosts for a leg on a flex route.

The separation between Price_I and Price_O is essential for the R-to-O distinction (see below).

NS legs (OD_L)

The from-stop and to-stop are first mapped to their associated NS stations via Stops/NS_Stations_rel, producing a combined c_NS_start_end_rel key. This key is used to look up the price in TariffUnitsMatrix, which holds the number of tariff units between every pair of NS stations and the corresponding price in cents (Price_ct) for the chosen year and fare class. The resulting price is stored as Price_L.

A leg on a train with a ride surcharge also gets Price_S (#3). The products are listed in Advanced/TripSurchargeTrains: the ICE costs the surcharge on every domestic leg, the Intercity direct and the Eurocity Direct when their trip passes both Schiphol and Rotterdam between boarding and alighting. Price_S stays separate from Price_L, so that the chain joiner keeps it when it replaces Price_L by a through fare. Advanced/IncludeTripSurcharge switches the surcharge off.


The R and O pricing distinction within regional transport

Within regional transport, a key pricing rule applies: if a traveller boards the next regional leg within Advanced/MaxTransferTimeForBoardingSurcharge (by default 35 minutes) of alighting from the previous regional leg, only the higher of the two boarding costs is charged. The second leg is then treated as an O (continuation) leg instead of a new R (boarding) leg.

This distinction is not represented by separate OD sets. Both R and O legs are computed as part of OD_R. The decision about whether a subsequent regional leg is R or O is made inside the ChainJoiner_T chain joiner, at the moment of joining two legs together, with MaxTransferTimeWithinFareJourney, the value of MaxTransferTimeForBoardingSurcharge on the time axis of the chains. The check is:

RO_continuation_allowed := Left_rel->Last_RO_AlightTime
                         + MaxTransferTimeWithinFareJourney
                         >= Right_rel->BoardingTime_RO

If this is true, then the combined boarding cost is max(left Price_I, right Price_I) rather than the sum of both. The variable cost Price_O is always summed regardless of transfer time.

To make this check possible, each OD result row caches BoardingTime_RO (the departure time of the first stop, or null if the leg is NS) and Last_RO_AlightTime (the arrival time of the last stop, or null if the leg is NS). These timestamps are propagated through the chain result so that subsequent joiners can check whether the time constraint is still met.

Similarly, for NS legs, BoardingTime_NS and Last_NS_AlightTime are tracked to support the NS through-pricing logic described below.


NS through-pricing in multi-leg chains

When a chain contains two or more NS legs with an NS-to-NS transfer in between, it may be cheaper to price the entire NS segment as a single through-journey from the first NS station to the last NS station, rather than as two separate tickets. The ChainJoiner_T template handles this by:

  1. Re-computing a combined NS price (Price_NS) by looking up c_NS_start_end_rel for the combination of Left_rel->First_NS_Station and Right_rel->Last_NS_Station in TariffUnitsMatrix.
  2. Checking whether an NS-to-NS transfer is permitted within the allowed time window (NS_transfer_allowed).
  3. Determining whether the combined price is actually cheaper than the sum of the two individual prices (NS_through_fare_cheaper).

If NS_through_fare_certain is true (meaning the left chain ends with an L leg and the right chain starts with an L leg), the combined price is always used without checking whether it is cheaper. If only NS_transfer_allowed is true, the cheaper of the combined price and the sum is chosen.

The First_NS_Station and Last_NS_Station attributes are propagated through the chain result at every step, so that they remain available for re-pricing in subsequent joining steps.


Demand-responsive transport (flex)

Area-based demand-responsive services (U-flex, Bravoflex, SyntusFlex, FlexRRReis, HWGO Flex, Texelhopper) and dial-a-ride buses appear in the GTFS feeds as ordinary routes with trips, although a passenger has to book them in advance. The model recognises them by route name: SourceData/Infrastructure/GTFS/RelevantSelection/Routes/IsFlex is true when lowercase(route_short_name + ' ' + route_long_name) contains one of the terms in ModelParameters/Advanced/FlexRouteTerms (flex, texelhopper, opstapper, belbus).

Flex parameters

Four parameters in ModelParameters/Advanced set how these routes take part:

Parameter Default Effect
IncludeFlex TRUE FALSE removes all links of flex routes, ride and waiting links, from the public transport network.
FlexMinInterval 15 min Minimum interval between the trips of a flex route. Per trip pattern (route, first stop, last stop, number of stops) only the first trip in each interval, counted from 00:00 of the GTFS day, enters the network (SourceData/Infrastructure/GTFS/LoadFeeds/FlexOptions). 0 keeps all trips.
FlexExtraWaitingTime 30 min Added to the travel time of every leg on a flex route: the time to book and the uncertainty about the pickup time. It counts towards MaxPTTime, like GradeSeparatedTransferPenalty, and makes TravelTime a criterion of the chain joiner (see Travel time and the level-crossing penalty).
FlexCallCosts EUR 0.00 Added to the price of every leg on a flex route, in Price_O: the chain joiner adds Price_O over the legs of a journey, whereas it takes Price_I once per fare journey.

The signatures of the chain cache and of the public transport output carry the setting (Impl/Flex_FileTag): _noflex, or _flex<interval>i<waiting time>w<call costs in ct>ct (_flex15i30w0ct for the defaults, chosen with PBL in #116), or nothing when all three are 0.

Why the minimum interval

The feeds of 2026 schedule part of U-flex as a trip every minute: the main patterns of U-flex Maarssen/Utrecht-Noordwest and Houten-Schalkwijk run 296 to 728 trips per direction per day, where the other patterns of the same services run every 15 to 20 minutes. Taken literally that is a bus every minute.

Without thinning, on 6 October 2026 (feed of 25 September 2026), a stop of such a service had about 550 legs per departure, against a median of 6 over all stops, and 15 of the 486 stop blocks needed 150 to 200 GB each and took 3 to 7 minutes; in 13 of them a single U-flex stop carried 88% or more of the candidates. With the defaults, stop block 108 has 210 million four-leg candidates instead of 2.46 billion and takes 15 seconds instead of 404.

Limitations

  • Recognition by name misses demand-responsive services without a flex term in their name, such as Transdev OV-op-Maat, Qbuzz BestelBuzz and the two-stop routes in Friesland, the RRReis 80x dial-a-ride buses, Arriva Vlinder and EBS Maasvlaktehopper: 458 routes with 10,494 trips on 6 October 2026. The feed of 25 September 2026 marks their stops with pickup_type and drop_off_type 2 (booking required), but not every feed does: in the feeds of 16 March and 15 June 2026 U-flex had no such marking. The model reads these columns only to forbid boarding or alighting (value 1, IsBoardAlightAllowed); the value 2 counts as allowed and does not mark a route as flex.
  • The OV nodes (OV_Knooppunten) count all trips, including the flex trips that the minimum interval removes from the network, so that the destination set, and the car caches that carry its name, do not depend on these parameters. A per-minute flex service therefore still adds its departures to a node.

Transfers

Transfers allow a traveller to move from the arrival stop of one leg to the departure stop of the next leg, potentially at a different physical location, and to wait for the connecting vehicle. Transfer generation is performed in CreateTransfers_T, which is instantiated four times in Transfers.dms for all combinations of operator pairs:

  • Transfers_R_R: from a regional arrival to a regional departure.
  • Transfers_R_L: from a regional arrival to an NS departure.
  • Transfers_L_L: from an NS arrival to an NS departure.
  • Transfers_L_R: from an NS arrival to a regional departure.

Candidate pairs

The template works on two input domains: FromDomain (the OD result whose arrival events form the exit side of the transfer) and ToDomain (the OD result whose departure events form the entry side). Both domains are first reduced to their distinct time-stop events:

Uq_From := unique_uint32(FromDomain/c_ToTime_Stop_rel)
Uq_To   := unique_uint32(ToDomain/c_FromTime_Stop_rel)

This reduction is important for performance: the spatial join runs over the distinct arrival and departure events, not over every leg, and many legs share an event.

The spatial join join_near_values_uint32 then finds all pairs of events where the exit stop lies within Advanced/MaxTransferDist (500 m, straight line) of the entry stop. For each such candidate pair, the following attributes are computed:

  • Walking_Dist: the arc length of the straight line between the two stop geometries, in decametres.
  • Walking_Time_rel: Walking_Dist [m] / TransferEffectiveSpeed, converted to time steps.
  • Transfer_Time_rel: the difference between the departure time at the entry stop and the arrival time at the exit stop, computed as sub_or_null(To_Time_rel, From_Time_rel).
  • WaitingAtStop_Time_rel: the time spent waiting at the transfer stop after walking, computed as sub_or_null(Transfer_Time_rel, Walking_Time_rel).

Valid transfers

A transfer is valid (IsValidTransfer) if all four of the following conditions hold:

  • Transfer_Time_rel >= MinTransfer_Time_rel: there is enough time to complete the walk, and within an NS station to make the minimum transfer time of the station (see below).
  • To_Time_rel > From_Time_rel: the departure of the connecting vehicle is strictly after the arrival of the previous vehicle (no backward time travel).
  • Transfer_Time_rel > 0: the transfer is not instantaneous.
  • Transfer_time <= MaxPTTime: the transfer duration does not already exceed the maximum permitted journey time.

Minimum transfer time within an NS station

A transfer between two different rail stops (platforms) of the same NS station takes at least the transfer time of that station in the NS timetable (#123). The walking time over the straight distance alone is too short within a station: 0.7 minutes from platform 1 to platform 5 at Utrecht Centraal, where 9292 requires between 4 and 6 minutes.

The source is stations.dat of the IFF timetable from the NDOV Loket (SourceData/Infrastructure/Infrastructure/IFF_Stations, the delivery of 13 October 2026 in Advanced/IFF_file_date): 5 minutes at the 44 major hubs, such as Utrecht Centraal, Amsterdam Centraal and Zwolle, and 4 or 2 minutes elsewhere.

  • MinTransfer_Time_rel is the larger of the walking time and the IFF time of the station (Stops/IFF_TransferTime_rel).
  • The station of a stop is its NS station (Stops/NS_Stations_rel), whose code is the IFF code. A stop counts as a rail stop when its mode is rail.
  • On the same stop, and between rail and bus, tram or metro, the walking time is the lower bound.
  • The switch is Advanced/IFF_TransferTime (default on). The chain cache carries _iff<date> in its signature.

Example: with the IFF times, 6,643 of the 154,354 NS-to-NS transfers of Y2023, morning (4.3%), are invalid that the walking time alone would allow, and 904 transfers that involve regional trains.

The exceptions per pair of trains in changes.dat are not used. They include shorter transfers across one platform island, so the model may reject such designed transfers.

Transfer costs and join keys

Each valid transfer receives a monetary cost that is added to Price_augm during chain generation. The cost is split into a walking component and a waiting component:

Walking_Time_cost := Walking_time [min] * Transfer_Walking_Time_Costs
Waiting_Time_cost := WaitingAtStop_time [min] * Transfer_Waiting_Time_Costs
Time_cost         := Walking_Time_cost + Waiting_Time_cost

This Time_cost is accumulated in the Price_T attribute of the chain result at every transfer step.

Each valid transfer also stores uq_c_Exit_rel (referencing uq_c_ToTime_Stop, the arrival side) and uq_c_Reboard_rel (referencing uq_c_FromTime_Stop, the departure side). These are the join keys used in ChainJoiner_T.


Chain generation

Overview

At this point all building blocks are in place: two single-operator OD matrices (OD_R for regional transport and OD_L for the NS fare network), four sets of valid transfers connecting them, and a block-based parallelisation structure to keep memory under control. The chain generation step combines these building blocks to produce multi-leg public transport journeys, in rounds that each add one transfer, as in the RAPTOR algorithm.

The chain generation is implemented in ChainJoiner_T (ChainJoiner_T.dms) and orchestrated by ChainGeneration_PerBlock_T, both part of PublicTransport_Prep.dms.

Defining which chain combinations to compute

The set of chain combinations to be computed is generated algorithmically in Classifications/Chains. The two base operator types are R (regional) and L (NS). The Chain_T template iteratively extends a growing table of chain names by combining each existing chain with each base operator, up to Max_transfers times. This produces all strings of length one up to Max_transfers + 1 over the two-letter alphabet {R, L}: R, L, RR, RL, LR, LL, RRR, RRL, and so on.

Each chain name encodes its join structure. The string is split at chain_breakpoint_pos into a left and a right substring. The left substring identifies which result of an earlier join step forms the left operand of the next join step. The right substring identifies which single-operator OD set forms the right operand. The transfer_name is derived from the last character of the left string and the first character of the right string, determining which of the four transfer sets to use. For example, the chain RL has left R, right L, and transfer Transfers_R_L.

The ToBeCalculated and Allowed units cover the full Chain set, meaning all combinations up to the configured maximum are both computed and included in the output. There are no exclusion rules between operator types: the pricing logic in ChainJoiner_T itself handles every combination correctly, through the R-to-O transfer time check and the NS through fare.

Block-based parallelisation

The full set of stops is randomly permuted using rnd_permutation (BlockSelections/rnd_rel) and divided into NumberOfStopBlocks (500) chunks of up to MaxStopBlockSize (104) stops each, forming the BlockDomain. For each block, a separate instance of ChainGeneration_PerBlock_T is created via for_each_ne (ChainGeneration_PerBlock). Each instance restricts the left-side OD rows to the legs whose origin stop falls in that block and that board no later than LastBoardingMoment (see Keeping the chain step small):

L/Result := select_with_attr_by_cond(OD_L/Result,
    BlockSelections/Block_rel[OD_L/Result/From_Stop_rel] == BlockDomain_id
    && OD_L/Result/From_Time_rel <= impl/LastBoardingMoment)
R/Result := select_with_attr_by_cond(OD_R/Result,
    BlockSelections/Block_rel[OD_R/Result/From_Stop_rel] == BlockDomain_id
    && OD_R/Result/From_Time_rel <= impl/LastBoardingMoment)

where BlockSelections/Block_rel is rnd_rel / MaxStopBlockSize. The random permutation ensures an approximately equal distribution of stops across blocks regardless of geographic clustering. Each block computes its chains independently and in full, with access to the complete transfer sets and right-side OD sets. Only the origin side is partitioned. This means that within a block, a traveller originating from one of the block’s stops can transfer to any stop in the full network and reach any destination. In the OD-pair mode (Impl/IsOD, see Routes of an OD pair) there is one block, with the stops where a pre-transport leg of the origin ends.

Each block writes its own MMD cache, <chain cache>/<n>of<N>.mmd, and a block that already has a cache is skipped (PT_Chains_Blocks/ToDo), so that an interrupted chain step resumes (batch\WriteChainBlocks.ps1). The name of the chain cache carries the analysis date, the time window and a hash of its signature, which holds all parameters that affect the result, including the Pareto criteria, maximum number of transfers and maximum PT time (see Cache names). The PT unit reads back from the block caches in read-only mode, and PT_V reads only the chains that board at a stop where a pre-transport leg of the component ends. This decoupling between writing and reading is required to avoid circular dependencies in the GeoDMS calculation graph (see issue 965).

The two-step join inside ChainJoiner_T

The join in ChainJoiner_T proceeds in two stages using join_equal_values_uint64, which produces a sparse representation of the Cartesian product: only pairs with matching keys are materialised.

Step 1: Join_Left_Transf

Join_Left_Transf0 := join_equal_values_uint64(
    Left/uq_c_ToTime_Stop_rel,
    Transfers/uq_c_Exit_rel)

This finds, for every left-side trip, all transfers whose exit event matches the arrival event of that trip. The result contains one row per (left trip, matching transfer) pair. Join_Left_Transf keeps the pairs that can still stay within MaxPTTime (see Keeping the chain step small).

Step 2: Join_LeftT_Right

Join_LeftT_Right := join_equal_values_uint64(
    Join_Left_Transf/Transfers_rel -> uq_c_Reboard_rel,
    Right/uq_c_FromTime_Stop_rel)

This finds, for every (left trip, transfer) pair from step 1, all right-side trips whose departure event matches the reboard event of that transfer. The result contains one row per (left trip, transfer, right trip) combination, representing a complete two-leg journey.

The combined journey inherits uq_c_FromTime_Stop_rel from the left leg (the departure of the first leg) and uq_c_ToTime_Stop_rel from the right leg (the arrival of the last leg). These carry forward correctly through multiple chain steps because ChainJoiner_T is applied iteratively: the result of one R-L join becomes the left input for the next join step (e.g., producing RL-R or RL-L chains).

Travel time and the level-crossing penalty

The raw combined travel time sums the travel times of the three components:

Traveltime_wo_Penalty := Left/traveltime + Transfers/Transfer_Time + Right/traveltime

where Transfers/Transfer_Time includes both the walking time to reach the boarding stop and the waiting time until the connecting vehicle departs.

An additional GradeSeparatedTransferPenalty (default 0) is added when the transfer involves a change in vertical level. The rule is: if both the alighting mode and the boarding mode are at-grade (gelijkvloers: bus, tram, or walking), no penalty applies. In all other cases, including transfers from or to metro, rail, or ferry, the penalty is added. This models the extra time required to navigate stairs, escalators, or lifts at stations. The IsStreetLevel flag is defined per mode in Modes.dms.

NeedsTranferTimePenalty :=
    Modes/IsStreetLevel[from_Mode_rel] && Modes/IsStreetLevel[to_Mode_rel]
    ? FALSE
    : TRUE

Traveltime := NeedsTranferTimePenalty
              ? Traveltime_wo_Penalty + GradeSeparatedTransferPenalty
              : Traveltime_wo_Penalty

Without a time penalty every chain of a chain-joiner group (same boarding and alighting time-stop) has the same travel time. With a penalty it has not: a chain with more level changes, with a flex leg (FlexExtraWaitingTime on the leg, see Demand-responsive transport (flex)), or with a leg that has the extra time of a ride surcharge (TripSurchargeTrains/ExtraTime, 0 by default) has a longer Traveltime. TravelTime is then also a criterion of the chain joiner, when the final front uses the travel time (Classifications/ChainJoinerParetoAttr_Candidates/HasTimePenalty); otherwise a chain with a longer penalised travel time could survive in place of a shorter one.

Example: with a GradeSeparatedTransferPenalty of 3 minutes, on the first stop block of 1 October 2024, a chain joiner without this criterion loses 14,615 front rows in 14,274 of 661,664 groups, and 389 groups are reachable only with it; the criterion makes the block 6% slower.

With the default FlexExtraWaitingTime of 30 minutes the criterion is on; with all penalties at 0 it is off.

Validity checks

Two basic validity conditions are applied to every combined path in Join_LeftT_Right, ahead of the Pareto filtering:

  • IsTravelToSameStop: the origin stop of the left leg must differ from the destination stop of the right leg. A journey from and to the same stop is discarded even if the route in between is different.
  • IsJourneyLongerThanAllowed: the total travel time must not exceed MaxPTTime. Because each individual leg is already capped at MaxPTTime by the Dijkstra cut, this check mainly filters combined journeys where the transfer itself pushes the total over the limit.

Only paths where both conditions are false proceed to the Valid1 container and to Pareto filtering.

Price calculation

Price calculation for combined chains is the most intricate part of the model, because the Dutch fare system is non-linear and context-dependent. The relevant price components carried on every result row are:

  • Price_I: the fixed boarding surcharge for regional (non-NS) transport. Depends on whether the R-to-O continuation discount applies.
  • Price_O: the variable distance cost for regional transport. Always summed across legs.
  • Price_L: the NS fare, based on the tariff units matrix for the station pair. May be recalculated as a through-fare.
  • Price_F: finalised NS costs from earlier chain steps, which later chain steps do not recalculate.
  • Price_S: the ride surcharges of special trains, per leg. Always summed across legs.
  • Price_T: accumulated monetary cost of transfer walking and waiting time.

Regional boarding cost: R versus O

When two regional legs are chained together, the model checks whether the transfer between them qualifies for the continuation discount. The condition is:

RO_continuation_allowed :=
    Left_rel -> Last_RO_AlightTime
    + MaxTransferTimeWithinFareJourney
    >= Right_rel -> BoardingTime_RO

If the transfer happens within MaxTransferTimeForBoardingSurcharge of the previous alight event (the default is 35 minutes), the second leg is treated as a continuation (O) rather than a new journey (R). In that case the combined boarding cost is the maximum of the two individual boarding costs rather than their sum, because the passenger only pays the higher of the two surcharges once:

Price_I := RO_continuation_allowed
           ? max(Left_rel -> Price_I, Right_rel -> Price_I)
           : Left_rel -> Price_I + Right_rel -> Price_I

The variable distance component Price_O is always summed regardless of transfer time.

To make this check possible, every result row carries BoardingTime_RO (the time of the first boarding event of that leg, or null if the leg is NS) and Last_RO_AlightTime (the time of the last alighting event, or null if NS). These timestamps are propagated through every chain step so that the check can always be made against the most recent regional alight event.

NS through-pricing

When a chain contains two or more NS legs, pricing them as a single through-journey may be cheaper than pricing them separately. This is because the NS tariff units matrix already encodes the distance-based discount for long journeys. The model handles this as follows.

First, c_NS_start_end_rel is constructed for the combination of the first NS station of the left chain and the last NS station of the right chain. This key is used to look up Price_NS in TariffUnitsMatrix. The check for whether the through-price should be used has two cases:

  • If NS_through_fare_certain is true (the left chain ends with L and the right chain starts with L, meaning consecutive NS legs with no regional leg in between), the through-price is always used: Price_L := Price_NS.
  • Otherwise, if NS_transfer_possible is true (both sides have NS legs but the chain is mixed), the through-price is used only if the transfer is within MaxTransferTimeWithinFareJourney and the through-price is actually lower: NS_through_fare_cheaper := NS_transfer_allowed && IsDefined(Price_NS) && Price_NS < Left_rel->Price_L + Right_rel->Price_L.

When the through-price is not used, the left NS cost is moved into Price_F (finalised costs, which later steps do not recalculate), and Price_L takes the value of the right leg only.

The First_NS_Station and Last_NS_Station attributes are propagated through every chain step so that through-pricing can be applied again in the next joining step if another NS leg is appended.

The chain joiner also groups on First_NS_Station. The through fare is degressive, so two chains to the same time-stop that started their NS journey at different stations can change places in price when a next NS leg is appended at the through fare; the one that is dearer at this step must not be pruned. Without the station in the group, the block front of the first stop block of 1 October 2024 is too expensive in 218 of 754,029 groups, on average 33 cents and at most 1.61 euro; the extra grouping costs no measurable time.

Final price assembly

The total monetary price and augmented price are assembled as:

Price      := Price_I + Price_O    (regional components)
           + Price_L               (NS component)
           + Price_F               (finalised NS from earlier steps)
           + Price_S               (ride surcharges of special trains)

Price_augm := Price + Price_T      (adds transfer walking and waiting costs)

In the chain joiner the price criterion is the one of the final front: Price, or Price_augm when MinimiseCriteria names it. When the final front uses Price, Price_augm, which adds the time cost of transfer walking and waiting, only decides between chains that are otherwise equal: it is the last criterion of pareto_optimal_eps, with a relative epsilon of 1, so it only orders and adds no rows; among the chains in one box of the other criteria the one with the lowest raw values wins, in criterion order. The alternatives are worse:

  • Comparing on Price_augm alone can prune the cheapest chain of a group against a dearer one with less transfer time, although the final front on price and travel time should keep it. On the first stop block of 1 October 2024 with the departure moments 07:00 to 07:45 (754,029 groups of boarding time-stop and destination stop), the block front is then dearer in 432,542 groups, by 31 cents on average and at most 4.18 euro.
  • Comparing on Price and Price_augm as two full criteria keeps the cheapest chain, but also the dearer chains with less transfer time. These have the same travel time and so can never reach the final front, but every chain that survives is joined again with the next leg, which makes the chain joining more than twice as slow.

The rest-time attributes

Two additional attributes, remaining_time_excl_NS_transfer and remaining_time_excl_RO_transfer, are included in the Pareto comparison at the chain-joining stage. These represent the amount of time remaining in the journey window after the last potential NS or regional transfer deadline expires. Concretely:

remaining_time_excl_NS_transfer :=
    LastArrivalMoment - (Last_NS_AlightTime + MaxTransferTimeWithinFareJourney)

The rationale for including these is that two chains may be equal in price at the current joining step while one of them alights from its last NS or regional leg later, so that a next leg can still get the through fare or the continuation discount. Without the rest-time attributes in the Pareto comparison, that chain could be pruned too early. By including rest time as a Pareto dimension, the chain joiner retains options that are slightly worse in price but preserve the possibility of advantageous pricing later in the chain. Smaller is better: a smaller rest time means a later alighting.

Pareto filtering within ChainJoiner_T

The Pareto filtering inside ChainJoiner_T operates on the Valid1 set, per group of boarding time-stop, alighting time-stop and first NS station of the current NS journey (Part_key, see NS through-pricing above).

The criteria

The comparison dimensions are ChainJoinerParetoAttr (Classifications/ChainJoinerParetoAttr_Candidates), in this order:

  1. the price criterion of the final front: Price, or Price_augm when MinimiseCriteria names it;
  2. remaining_time_excl_RO_transfer and remaining_time_excl_NS_transfer;
  3. TravelTime, when there is a time penalty and the final front uses the travel time (see the level-crossing penalty above);
  4. Price_augm as a tie-break, when the price criterion is Price.

One sweep with pareto_optimal_eps

The filter is one call of pareto_optimal_eps (GeoDMS #1282), which sorts the rows per group and sweeps them once. Its arguments come from Classifications/ChainJoinerParetoAttr/ParetoArgs:

pareto_optimal_eps(Part_key, Price,
    remaining_time_excl_RO_transfer, 0.0,
    remaining_time_excl_NS_transfer, 0.0,
    TravelTime, ChainParetoTimeEpsilon,
    Price_augm, 1.0)

The first criterion has no epsilon: it only orders. With all epsilons 0 the operator keeps exactly the Pareto front: a row drops out when another row of its group is at least as good on every criterion, and of identical rows the first index stays.

The epsilons are relative, and the price comes first because the reporting theme is accessibility poverty: the cheapest row of a group always stays, and a more expensive row only when it is more than Advanced/ChainParetoTimeEpsilon (0.01) faster than the fastest cheaper row kept, or better on another criterion. Every row that drops out thus has a cheaper row that stays and is at most 1% slower. An epsilon on the first criterion has no effect, so the price gets none. The remaining times are compared exactly, because they measure the distance to the end of the analysis window rather than a travel time, and Price_augm as a tie-break gets the epsilon 1, a criterion that only orders.

The block-level condensation, the two condensations per departure moment and the merge of the components use the same operator on Price and TravelTime with the same epsilons (Classifications carries the epsilon per criterion in EpsilonExpr). The signatures of the chain cache and of the output carry _chainrel1pct when the epsilon is active (Impl/ChainPareto_FileTag).

Keeping the chain step small

The chain step is dominated by the four-leg patterns, above all RRRR: on the first stop block of 1 October 2024 (departure moments 07:00 to 07:45) that pattern joins 268 million candidate combinations, of which 112 million are valid and 1 million survive. These measures keep that step small; all but the last leave the chains unchanged:

  • x/Result/ModeCode (and Mode_rel) is built from Left_rel and Right_rel for the surviving chains only, not for every candidate combination in Join_LeftT_Right.
  • The modes of the legs of a chain are a uint32, ModeCode: 4 bits per leg holding the mode id + 1 (0 is no leg), the first leg in the highest bits, so five legs (at most four transfers) fit in 20 bits. A leg gets uint32(Mode_rel) + 1 (OD_extra_attributen), and the chain joiner puts two parts together as Left_rel->ModeCode * Right/ModeFactor + Right_rel->ModeCode. ModeFactor is a uint32 parameter per chain: 16 for a single leg (OD_R/Result, OD_L/Result, and the single chains R and L per stop block), and for a joined chain the product of the factors of its left and right part (x/Result/ModeFactor in ChainJoiner_T), so 16 to the power of its number of legs in integer arithmetic only. The right part is always a single leg, so the left part shifts by one digit per join. The letters come back only per departure moment, once per distinct code (Within_VW_Window/UqModeCode, with Classifications/GTFS_Modes/CodeDigits), and in the validation against 9292. A string of mode letters would cost about 420 bytes of memory per row and 21 bytes per chain in the cache (an index of 16 and the characters); ModeCode costs 4.
  • The Pareto group is a 64-bit key, Part_key (boarding time-stop, alighting time-stop, first NS station), passed to pareto_optimal_eps directly; an integrity check guards that the key fits in 64 bits.
  • Join_Left_Transf keeps only the combinations of left chain and transfer that stay within MaxPTTime with the shortest right leg (MinRightTraveltime), so the second join is smaller (RRRR on the first stop block: 166 instead of 268 million rows) with the same valid chains.
  • A chain starts only at a boarding time up to Impl/PT_LastBoardingMoment: the last departure moment plus Impl/PT_Max_V_Time, the longest pre-transport leg, plus MaxWaitingTimeAtOrigin. That is the upper bound of Within_VW_Window in the per-departure-moment analysis, so a chain that boards later is never used; on the first stop block such chains would be 9.9% of the front rows. The chain cache signature therefore carries _v<PT_Max_V_Time> as well.

On that block most of the time of the chain step is in pareto_optimal_eps itself (62%), whose preparation and sweep run on one thread (GeoDMS #1286).

Grouping the chains per alighting stop instead of per alighting time-stop, with the arrival time as an extra criterion, would be exact within the epsilons when the final front uses Price and there is no time penalty, because a chain that reaches the same stop earlier from the same boarding time-stop can make every connection a later one can, at the same total travel time. On the first stop block, however, it removes only 6% of the combinations and makes pareto_optimal_eps slower on the larger groups, so that the block takes 57 instead of 48 seconds. The model therefore groups per alighting time-stop.

Block-level condensation and final Pareto selection

Within each block, after all ToBeCalculated chains have been computed and their Allowed results unioned into Allowed_Chains, a further Pareto selection is applied, and then the block writes its cache. At this level, the OD key is:

OD_pair_key := combine_data(StarEvent2Stop,
    uq_c_FromTime_Stop_rel, To_Stop_rel)

Note that this key uses To_Stop_rel (the destination stop) rather than uq_c_ToTime_Stop_rel (the destination time-stop event). This means the block-level condensation collapses across different arrival times at the same destination stop, retaining only Pareto-optimal options per (departure event, destination stop) combination. The arrival time is still preserved in uq_c_ToTime_Stop_rel and carried through to the per-departure-moment analysis.

The Pareto attributes at the block level are FinalParetoAttr, which is derived from CandidateParetoAttr according to ModelParameters/Advanced/MinimiseCriteria. With the default 'Price,Time' these are Price and TravelTime; Price_augm replaces Price when MinimiseCriteria names it, and one of the two criteria alone is possible as well. This is the configuration that ultimately determines what “optimal” means in the output; the condensations per departure moment add the ownership criteria NeedsCar and NeedsBike when MinimiseCriteria names Car and Bike (see Car and bicycle ownership as criteria).

After the block-level Pareto selection, each block writes its cache. The PT and PT_V units then read the block caches as the input for the per-departure-moment analysis in PerDepartureMoment_T.


Pre- and post-transport connections

The legs before and after the public transport part of a journey, walking or cycling from the origin to the first stop and from the last stop to the destination, are described step by step on Walking and cycling legs: the network, its preparation, the pre- and post-transport caches, and the leg types of the components.

The legs of a component

A public transport component has one pre-transport type (PreTransport) and one post-transport type (PostTransport); see Components and merging. The output step joins only the legs of those two types to the chains (NetworkSetup/impl/V_org and N, from PublicTransport_Prep/Legs/<type>/ODMatrix). A destination that no leg of the post-transport type reaches within its maximum time has no option in that component. Options of other kinds, such as walking after an OV-fiets component, come from other components when they are merged.

Codes in ModeUsed

The code of a type is the mode letter (W walking, C cycling) with the stop class: <mode>_<stop class> for pre-transport (W, C, C_RM, C_RMT, C_Ric) and <stop class>_<mode> for post-transport (W, C, RM_C, RMT_C, OVf_C). In ModeUsed the two codes surround the modes of the chain, for example W_BBR_W (walking, two buses and a train, walking) or C_RM_R_W (cycling to a rail or metro stop, a train, walking).


Per-departure-moment analysis

All previous steps produce a time-windowed result covering the full range of departure moments. The goal of the analysis is to compute results for specific departure times defined in ModelParameters/Impl/MeasureMoments. For each block of origins (ConfigurationPerOrgBlock_T) and each such moment, PerDepartureMoment_T is instantiated via for_each_ne in PublicTransport.dms (PerDepartureMoment).

Step 1: select PT chains within the pre-transport window

The chains of the origin block (PublicTransport/PT_InBlock: the chains of PublicTransport_Prep/PT_V that board at a stop where a pre-transport leg of the block ends) are filtered to Within_VW_Window, containing only chains whose PT departure time satisfies:

inTime <= PT_InBlock/From_Time_rel <= inTime + Max_V_Time + MaxWaitingTimeAtOrigin

where Max_V_Time (Impl/PT_Max_V_Time) is the longest pre-transport leg that can still be valid: the largest maximum time of the pre-transport types plus the start and parking times of a cycling leg. The upper bound is the latest public transport departure that a traveller leaving at inTime could still board, given the maximum walking or cycling time and the maximum time they are allowed to wait at the first stop. The lower bound holds because a chain that departs earlier than inTime can never be boarded; without it, such a chain would be joined with every pre-transport leg and removed only in step 2 (with the chain cache of 07:00 to 09:29, at the departure moment 07:45, 59% of the selected chains on the first origin block).

Step 2: join pre-transport to PT chains (Add_V_to_PT)

The pre-transport unit V (the pre-transport legs of the block) is joined with Within_VW_Window via join_equal_values_uint64 on V/To_Place_rel and Within_VW_Window/From_Place_rel. Every combination of a pre-transport leg arriving at a stop and a PT chain departing from that same stop is considered.

For each such combination, the following validity conditions are checked:

  • IsWaitingTimeValid: the traveller arrives at the stop no later than the PT vehicle departs. Arriving exactly at the departure counts, as it does for a transfer (Transfer_Time >= Walking_Time).
  • WaitingTime_rel <= MaxWaitingTimeAtOrigin: the waiting time at the first stop does not exceed the configured maximum.
  • IsVTimeNotTooLong: the network time of the pre-transport leg (V_time_net) is within the maximum for its mode (MaxWalkingTime_Org2Stops or MaxCyclingTime_Org2Stops).
  • IsPT_departureEvent_ActualPT: the PT departure event is a genuine scheduled link departure, not a waiting or transfer link. This is verified by looking up PT_c_fromTime_Stop_rel in ScheduledLinks/c_fromTime_Stop_rel. This check guards against combining pre-transport with an intermediate waiting arc in the PT chain.

The pre-transport time V_time includes the start and parking times of a cycling leg (see Walking and cycling legs, step 4). Total travel time is computed as the sum of W_time (waiting time at home, included only if IncludeWaitingAtHomeInTravelTime is true), V_time (pre-transport time), and PT_time (PT travel time). Price adds the price of the pre-transport leg as far as it counts (Price_V, see step 7), Price_augm always adds it. The c_fromTime_Place_rel of the resulting combined leg is assigned from the pre-transport origin at departure time inTime + WaitingTime_rel.

Step 3: join post-transport to V+PT chains (Add_N_to_VPT)

The post-transport unit N is joined with Add_V_to_PT/Result on Add_V_to_PT/Result/To_Place_rel and N/From_Place_rel. Validity: total journey time (V + PT + N) must not exceed MaxTravelTime, and the network time of the post-transport leg must be within MaxWalkingTime_Stops2Dest or MaxCyclingTime_Stops2Dest for its mode.

Step 4: add the direct trips

The direct trips of the origin block (PublicTransport/Direct_OD, from PublicTransport_Prep/Direct_OD) are unioned with Add_N_to_VPT/Result to form Union_with_DirectOD (see Direct walking, cycling and car below). With the time-dependent car trip (Impl/CarTimeDependent, default with the TomTom network) the car trips of the block come from the cache of this departure moment (DirectCar_M), and Direct_OD_M is the union of both. A public transport component has no direct trips, and a direct component (WW, CC, Car) has no chains (Impl/WithPT): its Union_with_DirectOD holds the direct trips only. For direct journeys, the departure time is inTime, the arrival time is inTime + Duration_seconds, PT_time and N_time are zero, and V_time equals the full journey duration. Direct trips and chains thus have the same form in the two condensation steps and in the output.

What each kind of option brings into the front:

option time price distance
public transport chain with pre- and post-transport per leg per leg per leg, and per public transport mode
direct walking (WW) and cycling (CC) yes yes yes
direct car (AA) yes yes yes

The car front is a front on travel time and distance, and the price is the distance times Car_CostPerKm (see The (time, distance) front per origin-destination pair).

Step 5: first condensation over (departure time-place, destination)

Union_with_DirectOD may contain multiple paths for the same traveller departing at the same time from the same origin towards the same destination, via different routes or with different departure moments from the first stop. The first condensation step partitions the set by (uq_c_FromTime_Place_rel, To_Place_rel), which identifies a unique combination of origin departure event and destination place. Within each partition, Pareto-optimal filtering is applied using OutputParetoAttr: the price and time criteria of FinalParetoAttr and the selected ownership criteria NeedsCar and NeedsBike (see Car and bicycle ownership as criteria below). The result is Result_AfterFirstCondensation. This set may still contain multiple rows per origin-destination pair, because the same origin and destination could be served by different departure times.

Step 6: second condensation over (origin, destination)

A second Pareto-optimal filter is applied over (From_Place_rel, To_Place_rel), collapsing across all departure times. This produces Result_AfterSecondCondensation, the final result set: per origin-destination pair the Pareto front over all departure times, on the same criteria as step 5. A pair can have several rows, for instance a cheap and slow option next to a fast and expensive one.

Step 7: export

The final result is written per block of origins and departure moment by CreateExports, as <OutputDir>/PerBlock/tt_<moment>_<n>of<N>.parquet or .csv (ModelParameters/OutputFormat, default 'parquet'). <OutputDir> is %LocalDataProjDir%/Output/<Analysis_date>_<OutputLabel>_<component> (Impl/OutputDir). The settings that determine the content are in the signature of the output folder (signature.txt, NetworkSetup/GenerateOutput/Output_Signature), with the public transport settings in Impl/PT_Output_ParamTag, not in the file name (#118). The writer refuses a folder with another signature; the readers that merge the blocks per departure moment and the moments per date, and Output/PostAnalysis/FindMedian, check it. See Cache names in How to run the model.

The parquet files

A parquet file (zstd) holds, per kind of component (Impl/ExportClass), only the columns that vary within the component and do not follow linearly from other columns:

kind components columns after OrgName and DestName
Direct WW, CC Traveltime
Car Car Traveltime, TravelDist_Car
PT the public transport components Traveltime, Price, Price_Augm, ModeUsed, WaitingTime, PretransTime, PosttransTime, TravelDist_Bus … TravelDist_Ferry

Times are in minutes, prices in euro and distances in metres; an unknown value is null, and the departure moment is in the file name, not in the column names. The other columns are constant within a component (ModeUsed of a direct trip, NeedsCar, NeedsBike, WaitingTime 0) or follow from these columns, and the merge of the components fills them in (see Components and merging).

The csv files

A csv file is semicolon-delimited, with the departure moment after each column name. It includes origin name, destination name, total travel time in minutes, price, augmented price, modes used, distances per mode, and the separate time components (waiting at home, pre-transport, PT, post-transport). Two columns follow ModeUsed: NeedsCar and NeedsBike, 0 or 1 (see Car and bicycle ownership as criteria). They are written whatever MinimiseCriteria says, and the files merged per departure moment and per date carry them too. A reader that takes the columns by position must skip them; the readers of the model read by column name and leave a column that a file does not have empty.

The files with prices also split Price like the travel time, in three columns after PosttransTime:

  • PretransPrice: the pre-transport leg, as far as it counts in Price. A cycling leg counts with Advanced/IncludeCyclingCostsInPrice, a walking leg with Advanced/IncludeWalkingCostsInPrice (default off, so 0). For a direct trip (WW, CC, AA) this column holds the price of the whole trip, as PretransTime and TravelDist_V hold its time and distance.
  • PTPrice: the public transport chain, fares and surcharges; 0 for a direct trip.
  • PosttransPrice: the post-transport leg, under the same rules as the pre-transport leg; an OV-fiets counts with its rental price.

The three add up to Price exactly, in cents. Price_Augm is not split.

Distance, time and price are also given per mode, after these columns:

category distance time price
pre-transport TravelDist_V PretransTime PretransPrice
public transport TravelDist_PT (also per PT mode in TravelDist_Bus … TravelDist_Ferry) PTTime PTPrice
post-transport TravelDist_N PosttransTime PosttransPrice
walking TravelDist_Walking WalkingTime WalkingPrice
cycling TravelDist_Cycling CyclingTime CyclingPrice
car TravelDist_Car (the last column) CarTime CarPrice

Public transport as a mode is the public transport leg, including walking and waiting at transfers. Walking, cycling and car are the pre- and post-transport legs and the direct trip of that mode; an OV-fiets counts as cycling. Waiting at home (WaitingTime) belongs to none of the modes. For that, Result_AfterSecondCondensation carries the mode of the pre- and post-transport leg (V_mode_rel, N_mode_rel; a direct trip has its mode in V_mode_rel and none in N_mode_rel).

Which columns are written, and in which order, is set by CreateExports/Columns: one row per column with its name (also the name of the item that holds its text) and its kind (dist only with ModelParameters/Export_TravelledDistance, price only with ModelParameters/Export_PriceInformation; both parameters apply to the csv files only). The header and the lines are both built from that table. Further columns go at the end, so that a reader by position keeps the existing ones.

The merged csv files

The csv files of the blocks can be merged per departure moment and per date (Merge_Regions_OV_T, ReadMergeExports_OV_perType_T), with the same columns. The merged files are written through GDAL with the numbers as numbers (float64, and uint8 for NeedsCar and NeedsBike) rather than text. A number takes 8 bytes or less per row instead of a text, which matters for the merge per date (for example four departure moments of 39 million rows each), and float64 writes the text of the per-block files back unchanged. Numbers are unquoted, empty values are empty fields, and a column that a file does not have, such as the prices without Export_PriceInformation, is read as empty.


Direct walking, cycling and car

The direct trips are the trips without public transport: walking (WW), cycling (CC) and the car (Car, AA in ModeUsed). Each is a component of its own. PublicTransport_Prep/Direct holds one unit per mode, each built on the private transport OD matrix of that mode:

Mode Source Duration Price
Walking PrivateTransport/Walking (the OSM pedestrian network, up to MaxWalkingTime_Org2Dest, 90 min) network time 0, or the time valuation Direct_Walking_Time_Costs when IncludeWalkingCostsInPrice is set
Cycling PrivateTransport/Cycling (the cycling network of Advanced/CyclingNetwork, OSM or the Fietsersbond, up to MaxCyclingTime_Org2Dest, 90 min) network time + Cycling_StartTime the time valuation Direct_Cycling_Time_Costs when IncludeCyclingCostsInPrice is set, else 0
Car PrivateTransport/Car (TomTom or OSM, see UseTomTomNetworkForCars, up to MaxCarTime; with Impl/CarTimeDependent a cache per departure moment) network time + Car_StartTime + Car_ParkingTime distance times Car_CostPerKm, plus Car_FixedCosts_PerTrip, when IncludeCarCostsInPrice is set; see Private transport

How walking and cycling are computed, step by step, is on Walking and cycling legs; the car, with its front on time and distance, on Private transport.

Price of walking

With the default IncludeWalkingCostsInPrice FALSE walking is free, as a direct trip and as a pre- or post-transport leg. The direct walking trip is then in the front of every pair within walking range, and it dominates every alternative that is not faster. With TRUE the time valuation of walking counts in Price, for the direct trip and for the legs alike. Of rows with the same price and time the condensations keep the one with the lowest price and then the shortest travel time (pareto_optimal_eps); only exact duplicates fall back to the order of the union, in which the public transport rows come before the direct trips.

The direct trips in the output

Direct_OD is the union of the direct trips of the component (AllowDirectWalking, AllowDirectCycling, AllowDirectCar, which follow from Component), with Mode_rel, Duration_seconds, Distance, Price and Price_Augm. With Impl/CarTimeDependent the car trips are not in Direct_OD: each departure moment reads them from its own cache (see step 4). A public transport component has no direct trip. In the output the direct trips carry ModeUsed WW, CC or AA.

Preparation for all origin blocks

The direct trips and the pre- and post-transport legs are prepared once for all origin blocks, in NetworkSetup/impl:

  1. Direct_OD, with the places of origin and destination, the ownership the trip needs (NeedsCar, NeedsBike) and the origin block of each row;
  2. V_org, the pre-transport legs of the component’s type, with the origin block of each leg;
  3. N, the post-transport legs.

The origin block of an origin is OrgBlockSelections/OrgBlock_rel (rnd_rel / MaxOrgBlockSize), on which ConfigurationPerOrgBlock_T/org also selects its origins. Each block then only selects its rows (PublicTransport/Direct_OD and PublicTransport/V, on OrgBlock_rel == BlockDomain_id) and refers to N; the text columns (ModeUsed of the direct trips, OrgName of the pre-transport legs) are made for the selected rows only.

The car front

For the car the OD matrix can be a Pareto front by itself: with Advanced/ParetoLegs_Car (default on) the Dijkstra returns, per origin-destination pair, every route that is optimal on travel time and distance, so a fast motorway route and a short local route both reach the condensation steps. The details are on the Private transport page.

Car and bicycle ownership as criteria

MinimiseCriteria, and in a merge MergeCriteria, can also name Car and Bike (#117). Each adds a Boolean criterion to the two condensations per departure moment and to the merge of the components:

  • NeedsCar: the direct car trip (AA);
  • NeedsBike: the direct cycling trip (CC), and a chain with a pre- or post-transport leg cycled on one’s own bicycle.

In a run of a single component they are criteria when Advanced/MinimiseCriteria names them (default 'Price,Time', so not); in a merge when MergeCriteria names them (default 'Price,Time,Car,Bike'), see Components and merging below.

The criteria are minimised, false before true. An option that needs neither a car nor a bicycle is therefore never dominated by one that does:

  • a public transport chain that is more expensive and slower than the car trip stays in the front next to it;
  • a car trip that is more expensive and slower than a chain still drops out.

Without these criteria such chains drop out, and an analysis without a car finds no option on those pairs, or a slower one than exists. Output/PostAnalysis/FindMedian, for example, leaves out the car trips (ExcludedModes = 'AA') to describe accessibility without a car.

Example: on origin block 1 of 450 at 07:00, with the chain cache of 6 October 2026 (31,907 origin-destination pairs), the two criteria:

  • enlarge the front by 1.9%, from 96,797 to 98,625 rows, while every row of the front without them stays;
  • without a car, make 13 pairs reachable and 65 faster, by 3.2 minutes in the median;
  • without car and bicycle, make 227 pairs reachable and 155 faster, by 22 minutes in the median.

The run time and the memory peak are the same with and without them.

One front serves every group of travellers:

  • with car and bicycle: all rows;
  • without a car: the rows with NeedsCar 0;
  • without car and bicycle: the rows with NeedsCar and NeedsBike both 0.

Both are columns of the csv output and of the merged output of the components. In the parquet output of a single component they are constant and left out.

Rules:

  • An OV-fiets leg counts as rented, not as one’s own bicycle (Classifications/TimeInvariantTypes/NeedsOwnBike). Only OVF_Stops2Dest_Cycling gets the OV-fiets rental price.
  • A criterion that is equal for all options is left out (Classifications/OwnershipParetoAttr_Candidates/IsRelevant), also from the signature of the output:
    • NeedsCar needs AllowDirectCar; there is no park-and-ride pre-transport;
    • NeedsBike needs AllowDirectCycling, or a pre- or post-transport type cycled on one’s own bicycle; in a merge, a merged component that needs one’s own bicycle (Advanced/Components/NeedsBike).
  • The chain cache does not depend on them. The chain joiner computes public transport only and keeps FinalParetoAttr, so its signature keeps _min-Price_Time, and a change of the ownership criteria only needs the output step to be rerun.
  • The signature of the output carries the extra criteria in the MinCrit part, as the mode letters of ModeUsed: MinCrit-Price_Time_A_C. A change of criteria changes the signature, so the readers of the output (ReadExport_OV_perTime_T, Merge_Regions_OV_T, FindMedian) stop on the signature of the output folder; rerun the output step, or run it under another OutputLabel to keep the existing output.
  • The Boolean criteria need the bool criteria of pareto_optimal_eps (GeoDMS #1287), which every engine that the model accepts has (GeoDMS 20.24.2 or later).
  • The car price is the distance times Car_CostPerKm (0.24 euro per km, #88) plus Car_FixedCosts_PerTrip (0 by default), in Price with Advanced/IncludeCarCostsInPrice.

Components and merging

Walking, cycling and car are separate runs, the components, each with its own output folder (#117). A component is set per part in ModelParameters, by its pre- and post-transport types, the way the variants of the 2023 delivery are defined:

  • Component: 'WW' (the direct walking trip), 'CC' (the direct cycling trip), 'Car' (the direct car trip, with start and parking time) or 'PT' (a public transport journey);
  • with 'PT': PreTransport, one pre-transport type, and PostTransport, one post-transport type, each a reference to Classifications/TimeInvariantTypes/V/..., with the alternatives as comments below it:
pre-transport (PreTransport) code   post-transport (PostTransport) code
Org2Stops_Walking: walking to any stop W   Stops2Dest_Walking: walking W
Org2Stops_Cycling: cycling to any stop C   Stops2Dest_Cycling: cycling from any stop C
Org2RM_Stops_Cycling: cycling to a rail or metro stop C_RM   RM_Stops2Dest_Cycling: cycling from a rail or metro stop RM_C
Org2RMT_Stops_Cycling: cycling to a rail, metro or tram stop C_RMT   RMT_Stops2Dest_Cycling: cycling from a rail, metro or tram stop RMT_C
Org2Ric_Stops_Cycling: cycling to an InterCity station C_Ric   OVF_Stops2Dest_Cycling: the OV-fiets, only from a stop near an OV-fiets location OVf_C

The name of a public transport component is <pre-transport code>_PT_<post-transport code>, for example W_PT_W, C_PT_OVf_C or C_RM_PT_W (Impl/ComponentName); all 25 combinations, with WW, CC and Car, are listed in Advanced/Components, generated from Advanced/PreTransportTypes and PostTransportTypes. The direct trips are separate components, because they treat some Pareto attributes and the front differently (the car keeps more routes per pair, ParetoLegs_Car); a public transport component has no direct trip.

The output folder is <date>_<label>_<component>, with _fb after the component when it cycles on the Fietsersbond network (Impl/CyclingNetwork_DirTag). The files in its subfolder PerBlock are named tt_<moment>_<n>of<N>.parquet (or .csv): the date and the component are in the folder name, which keeps the paths short. WW and CC, and Car without Impl/CarTimeDependent, do not depend on the departure moment and write one moment per block (Advanced/Components/SingleDepartureMoment). The output of 2023 is delivered as W_PT, the merge of WW and W_PT_W, and C_PT, the merge of CC, C_PT_W and C_PT_OVf_C.

Only the OV-fiets (OVf_C) rents a bicycle, at OVFiets_RentalPrice and without one’s own bicycle. The other cycling post-transports (C, RM_C, RMT_C) use one’s own bicycle at the last stop, at the same costs as one’s own bicycle in the pre-transport (PostTransport_Cycling_Time_Costs = PreTransport_Cycling_Time_Costs), and need one’s own bicycle (Classifications/TimeInvariantTypes/NeedsOwnBike).

For every component, pre- and post-transport take at most 15 minutes, and a direct trip and the whole journey at most 90 minutes, without waiting at home. Each component chooses on Advanced/MinimiseCriteria ('Price,Time'). An e-bike can be set through the cycling parameters; alternative bicycle investments are not weighed.

  • The public transport components share the chain cache when their pre-transport types have the same maximum time: the signature of the chain cache carries the longest pre-transport leg (_v<PT_Max_V_Time>), not the types.
  • WW, CC and Car compute no chains: the final selection per departure moment takes only the direct trips (Union_with_DirectOD without Add_N_to_VPT). batch\UpdateAll.cmd skips the chain cache for them (Impl/WithPT), and the car caches for every component but Car (AllowDirectCar).
  • The output signature mentions the chain cache and the public transport tags only when public transport takes part, and the car tags only with the direct car trip.

Merging the components

Merging is optional and is not a component: a run with MergeOutput set to TRUE computes nothing itself but merges, per origin block and departure moment, the output of the components in MergeComponents (default 'WW,W_PT_W,CC,C_PT_W,C_PT_OVf_C,Car', the options of the 2023 delivery; any names from Advanced/Components) into one Pareto front (NetworkSetup/ComponentMerge). The variant with walking to any stop or cycling to rail and metro, walking after public transport, and direct cycling) is the merge of W_PT_W, C_RM_PT_W and CC.

The merge has its own criteria, MergeCriteria (default 'Price,Time,Car,Bike'), apart from Advanced/MinimiseCriteria of the components: any subset of Price (or Price_Augm), Time, Car and Bike, down to a single criterion, with at least Price or Time (integrity check). With 'Time' or 'Price' alone each pair and departure moment keeps exactly one row, the fastest or the cheapest. Car and bicycle ownership (NeedsCar, NeedsBike) count only as far as a merged component can need them: bicycle ownership when a merged component needs one’s own bicycle (Advanced/Components/NeedsBike). An example of how many options of each component these two criteria keep is under MergeCriteria.

The merge reads the parquet files of the components, with a reader per kind of component (Advanced/Components/ExportClass: Direct, Car, PT), so the components must be written with OutputFormat 'parquet'. It fills in the columns that are constant or follow from others: the price of WW and CC from the travel time and that of Car from the distance, as in PublicTransport_Prep/Direct; ModeUsed WW, CC or AA; NeedsCar and NeedsBike from the component; zeros for the missing times and distances. Every row gets a column Component, the component it comes from. The merge keeps per origin-destination pair the rows that are not dominated, with pareto_optimal_eps on Classifications/OutputParetoAttr (the same operator and epsilons as in a run, with price and time in cents and seconds).

It writes them in OutputFormat, parquet or csv (with the departure moment after the column names, like a component), to <date>_<label>_Merged<MergeRunNr>, one block per phase, with the counts per block in Tellingen, which batch\OdTellingen.py adds up. The number keeps the folder name short; the merged components are listed in the signature of the folder, which carries the components, the criteria, the format (<OutputFormat>-m2) and the signatures of the components themselves. batch\UpdateAll.cmd output with MergeOutput runs it (NetworkSetup/GenerateOutput/Generate_Output).

Because the 1% rule of the front is not transitive, merging can keep or drop a few options that lie within about 2% of a cheaper one, compared with one run over the same options. Example: CC, C_PT_W and C_PT_OVf_C merged, against one run over the same options (Dronten, 5 blocks, 2023 morning), differed in 275 of 359,127 lines; every pair kept an option, and every differing line was within 2.5% of travel time of an equal or cheaper option in both.


Table of contents