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
- Introduction
- The time-expanded graph and the NodeSet
- Two OD sets: OD_R and OD_L
- Price calculation per operator type
- The R and O pricing distinction within regional transport
- NS through-pricing in multi-leg chains
- Demand-responsive transport (flex)
- Transfers
- Chain generation
- Overview
- Defining which chain combinations to compute
- Block-based parallelisation
- The two-step join inside ChainJoiner_T
- Travel time and the level-crossing penalty
- Validity checks
- Price calculation
- The rest-time attributes
- Pareto filtering within ChainJoiner_T
- Block-level condensation and final Pareto selection
- Pre- and post-transport connections
- Per-departure-moment analysis
- Direct walking, cycling and car
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:
- Build two subsets of the GTFS network within the relevant time window via the
Get_Windowed_Agency_Set_Ttemplate (PublicTransport_Prep.dms): one for regional transport outside the NS fare network (Set_R) and one for the NS fare network (Set_L). - Run the GeoDMS
impedance_matrixoperator (Dijkstra) over each subset to get single-operator OD matricesOD_RandOD_L(MakeODs.dms). - Enumerate all valid transfers between stops of different OD sets (
CreateTransfers_T.dms,Transfers.dms). - Iteratively combine operator legs with transfers into multi-leg chains (
ChainJoiner_T.dms), applying Pareto-optimal filtering per block of boarding stops. - Cache the chain result in the chain cache, one MMD cache per stop block (
PublicTransport_Prep/PT_Chains_Blocks, written bybatch\WriteChainBlocks.ps1), then read it back asPublicTransport_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). - 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 withinAdvanced/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_typeanddrop_off_typeinstop_times.txtare not 1; #3). - For
OD_Ronly: the leg does not lie entirely outside the Dutch concession areas (IsOD_completely_foreign), because such foreign pairs cannot be priced. - For
OD_Lonly: 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 mostMaxPTTime(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 toCt(euro-cents).Price_O: the variable distance cost, computed asFareTable/VariablePrice * PT_TravelledDistance, where the distance is the accumulatedalt_impin kilometres, plusFlexCallCostsfor 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:
- Re-computing a combined NS price (
Price_NS) by looking upc_NS_start_end_relfor the combination ofLeft_rel->First_NS_StationandRight_rel->Last_NS_StationinTariffUnitsMatrix. - Checking whether an NS-to-NS transfer is permitted within the allowed time window (
NS_transfer_allowed). - 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_typeanddrop_off_type2 (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 assub_or_null(To_Time_rel, From_Time_rel).WaitingAtStop_Time_rel: the time spent waiting at the transfer stop after walking, computed assub_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_relis 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 exceedMaxPTTime. Because each individual leg is already capped atMaxPTTimeby 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_certainis 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_possibleis true (both sides have NS legs but the chain is mixed), the through-price is used only if the transfer is withinMaxTransferTimeWithinFareJourneyand 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_augmalone 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
PriceandPrice_augmas 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:
- the price criterion of the final front:
Price, orPrice_augmwhenMinimiseCriterianames it; remaining_time_excl_RO_transferandremaining_time_excl_NS_transfer;TravelTime, when there is a time penalty and the final front uses the travel time (see the level-crossing penalty above);Price_augmas a tie-break, when the price criterion isPrice.
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(andMode_rel) is built fromLeft_relandRight_relfor the surviving chains only, not for every candidate combination inJoin_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 getsuint32(Mode_rel) + 1(OD_extra_attributen), and the chain joiner puts two parts together asLeft_rel->ModeCode * Right/ModeFactor + Right_rel->ModeCode.ModeFactoris auint32parameter per chain: 16 for a single leg (OD_R/Result,OD_L/Result, and the single chainsRandLper stop block), and for a joined chain the product of the factors of its left and right part (x/Result/ModeFactorinChainJoiner_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, withClassifications/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);ModeCodecosts 4. - The Pareto group is a 64-bit key,
Part_key(boarding time-stop, alighting time-stop, first NS station), passed topareto_optimal_epsdirectly; an integrity check guards that the key fits in 64 bits. Join_Left_Transfkeeps only the combinations of left chain and transfer that stay withinMaxPTTimewith 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 plusImpl/PT_Max_V_Time, the longest pre-transport leg, plusMaxWaitingTimeAtOrigin. That is the upper bound ofWithin_VW_Windowin 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_Org2StopsorMaxCyclingTime_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 upPT_c_fromTime_Stop_relinScheduledLinks/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 inPrice. A cycling leg counts withAdvanced/IncludeCyclingCostsInPrice, a walking leg withAdvanced/IncludeWalkingCostsInPrice(default off, so 0). For a direct trip (WW,CC,AA) this column holds the price of the whole trip, asPretransTimeandTravelDist_Vhold 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:
Direct_OD, with the places of origin and destination, the ownership the trip needs (NeedsCar,NeedsBike) and the origin block of each row;V_org, the pre-transport legs of the component’s type, with the origin block of each leg;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
NeedsCar0; - without car and bicycle: the rows with
NeedsCarandNeedsBikeboth 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). OnlyOVF_Stops2Dest_Cyclinggets 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:NeedsCarneedsAllowDirectCar; there is no park-and-ride pre-transport;NeedsBikeneedsAllowDirectCycling, 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
MinCritpart, as the mode letters ofModeUsed: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 anotherOutputLabelto 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) plusCar_FixedCosts_PerTrip(0 by default), inPricewithAdvanced/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, andPostTransport, one post-transport type, each a reference toClassifications/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,CCandCarcompute no chains: the final selection per departure moment takes only the direct trips (Union_with_DirectODwithoutAdd_N_to_VPT).batch\UpdateAll.cmdskips the chain cache for them (Impl/WithPT), and the car caches for every component butCar(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.