-
Notifications
You must be signed in to change notification settings - Fork 0
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.
- 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
- 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 and cycling
See also: Create routable network from GTFS files · Public transport: script reference · Decay
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 distinct operator sets, calculates single-operator OD matrices for each, and then chains these together while re-computing prices at every join. This structure is loosely inspired by the RAPTOR algorithm, which was developed independently and whose core ideas were later recognised in the literature.
The overall flow of the model is as follows:
- Build two operator-specific subsets of the GTFS network within the relevant time window, one for non-NS public transport (
Set_R) and one for NS (Set_L), via theGet_Windowed_Agency_Set_Ttemplate (PublicTransport_Prep.dms). - 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 origins. - Cache the full chain result to an FSS intermediate file (
PublicTransport_Prep/x/Write_Result), then read it back asPublicTransport_Prep/PT. - For each departure moment, join pre-transport legs, filter PT chains to the relevant time window, join post-transport legs, add direct cycling or walking, and condense to Pareto-optimal results per OD pair (
PerDepartureMoment_T.dms).
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 filtered 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. Because the nodes are ordered by time, Dijkstra naturally propagates forward in time only, ensuring that a traveller can never board a vehicle before it arrives.
The combined key used throughout the model to identify a time-stop event is c_Time_Stop, a uint64 value constructed as:
c_Time_Stop = 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.
The model creates two distinct OD matrices by running the impedance_matrix operator over the two filtered network subsets. Both use the same call signature but differ in their input set and in how prices are subsequently computed.
OD_R covers all regional, non-NS public transport. The Set_R filter selects links from agencies where Agency_rel->IsRO is true (i.e. not NS and not foreign carriers such as DB, NMBS, and De Lijn) and where the scheduled duration is positive. The IsRO flag is derived in Classifications/Agencies: it is true for all agencies that are not classified as NS, DB, Eurobahn, NS International, or EU Sleeper.
OD_L covers NS train services. The Set_L filter selects links belonging to the NS agency, plus any waiting links at NS stations (links whose mode is Waiting and for which a c_NS_start_end_rel is defined). This is necessary because the Dijkstra traversal needs to be able to wait at an intermediate NS station when a connecting NS train departs later.
Both subsets are restricted to the relevant time window. The window runs from FirstDepartureMoment (the earliest departure moment in MeasureMoments) to LastArrivalMoment, which is computed as the latest departure moment plus MaxPTTime plus, optionally, MaxWaitingTimeAtOrigin if waiting at home is not counted towards travel time.
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.
After running Dijkstra, each OD result is filtered to remove travel-to-self (IsTravelToSameStop) and to remove pairs whose origin and destination fall within the same stop cluster (IsStartEndWithinSameStopCluster). Stop clusters are computed by identifying pairs of stops within a configured distance of each other; different platforms of the same station typically form such a cluster. For OD_R only, pairs that lie entirely outside any Dutch concession area are also removed (IsOD_completely_foreign), because such foreign pairs cannot be priced.
Each result row also receives two important 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.
The two OD matrices store prices differently because the underlying fare structures differ.
For OD_R, prices are looked up in a FareTable using a hierarchical identifier that is tried in order from most specific to least specific. The lookup proceeds as follows, with each level tried only if the previous one yields no match:
- Mode + agency + concession area + route short name combined into a single identifier string.
- Mode + agency + concession area, dropping the route.
- Mode + agency only.
If none of these exact lookups succeeds, a further fallback uses reverse-lookup variants that match the identifier directly against the concession-area and agency columns of the fare table rather than against the combined identifier column. The final FareTable_rel is the result of MakeDefined across all these attempts.
Each matched fare table 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.
The separation between Price_I and Price_O is essential for the R-to-O distinction (see below).
For OD_L, prices are looked up in the NS tariff units matrix (TariffUnitsMatrix). 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 then used to look up the price column in TariffUnitsMatrix, which contains the pre-computed price in cents for every station pair. The resulting price is stored as Price_L.
Within regional transport, a key pricing rule applies: if a traveller transfers from one regional operator to another within MaxTransferTimeForBoardingSurcharge (by default 35 minutes) of the first check-in, the boarding cost of the second operator is waived. 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. 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 stores 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.
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.
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.
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). Before the spatial join, both domains are reduced to their unique stops:
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 is done only over the distinct stops that appear as arrival or departure events, not over every individual trip.
The spatial join join_near_values_uint32 then finds all pairs of stops where the exit stop lies within MaxTransferDist metres (straight line, crow-fly distance) 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).
A transfer is valid (IsValidTransfer) if all four of the following conditions hold:
-
Transfer_Time_rel >= Walking_Time_rel: there is enough time to complete the walk. -
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.
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.
At this point all building blocks are in place: two single-operator OD matrices (OD_R for regional transport and OD_L for NS), 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. The approach is loosely based on the RAPTOR algorithm; the core idea of expanding reachable stops in rounds, one transfer per round, was developed independently and later recognised in the literature.
The chain generation is implemented in ChainJoiner_T and orchestrated by ChainGeneration_PerBlock_T, both defined in PublicTransport_Prep.dms.
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 previously computed result 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 currently cover the full Chain set, meaning all combinations up to the configured maximum are both computed and included in the output. This is a deliberate simplification: earlier versions of the code had explicit exclusion rules (such as disallowing K directly after K, or requiring that O always follow R), but these were removed because the pricing logic in ChainJoiner_T itself already handles invalid cost situations correctly through the R-to-O transfer time check.
The full set of stops is randomly permuted using rnd_permutation and divided into NumberOfStopBlocks chunks of up to MaxStopBlockSize stops each, forming the BlockDomain. For each block, a separate instance of ChainGeneration_PerBlock_T is created via for_each_ne. Each instance restricts the left-side OD rows to only those trips whose origin stop falls in that block:
L/Result := select_with_attr_by_cond(OD_L/Result,
BlockSelections/rnd_rel[OD_L/Result/From_Stop_rel]
/ MaxStopBlockSize == BlockDomain_id)
R/Result := select_with_attr_by_cond(OD_R/Result,
BlockSelections/rnd_rel[OD_R/Result/From_Stop_rel]
/ MaxStopBlockSize == BlockDomain_id)
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.
After all blocks complete, their results are unioned in x/Write_Result and written to a single FSS file. The filename encodes all parameters that affect the result, including the analysis date, time window, Pareto criteria, maximum number of transfers, and maximum PT time. The PT unit reads back from this file in read-only mode. This decoupling between writing and reading is required to avoid circular dependencies in the GeoDMS calculation graph (see issue 965).
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_Transf := 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.
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).
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 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 IsGelijkvloers flag is defined per mode in Modes.dms.
NeedsTranferTimePenalty :=
Modes/IsGelijkvloers[from_Mode_rel] && Modes/IsGelijkvloers[to_Mode_rel]
? FALSE
: TRUE
Traveltime := NeedsTranferTimePenalty
? Traveltime_wo_Penalty + GradeSeparatedTransferPenalty
: Traveltime_wo_Penalty
Before Pareto filtering, two basic validity conditions are applied to every combined path in Join_LeftT_Right:
-
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 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 that are no longer subject to recalculation. -
Price_T: accumulated monetary cost of transfer walking and waiting time.
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.
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, no longer subject to recalculation in subsequent steps), 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 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_augm := Price + Price_T (adds transfer walking and waiting costs)
Price_augm is the attribute used for Pareto comparison, not Price directly, because it incorporates the time cost of transfers. This ensures that when minimising price, the model does not select routes with unrealistically long or costly transfers simply because the ticket price is marginally lower.
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 before 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 identical in Price_augm at the current joining step, but one of them may have a later NS alight time that would prevent a favourable through-pricing option from being applied in the next step. Without the rest-time attributes in the Pareto comparison, that option would 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.
The Pareto filtering inside ChainJoiner_T operates on the Valid1 set and uses ChainJoinerParetoAttr as the set of comparison dimensions: Price_augm, remaining_time_excl_RO_transfer, and remaining_time_excl_NS_transfer.
The filtering proceeds in two stages to avoid a full pairwise comparison across the entire Valid1 set, which can be very large:
Stage 1: pre-selection
For each OD pair (identified by the combined uq_c_FromTime_Stop_rel and uq_c_ToTime_Stop_rel), a single good candidate is identified using min_index on the first Pareto attribute:
good_candidate_of_OD[OD_pair] := min_index(Price_augm, OD_pair_rel)
Every option that is not better than this candidate on at least one Pareto dimension is eliminated:
pe_compared_to_good_candidate :=
Price_augm < good_candidate -> Price_augm
OR remaining_time_excl_RO_transfer < good_candidate -> remaining_time_excl_RO_transfer
OR remaining_time_excl_NS_transfer < good_candidate -> remaining_time_excl_NS_transfer
Note that for rest-time attributes, smaller is better (less remaining time is worse), so the pre-selection retains options that are better, i.e. have smaller values, on at least one dimension compared to the good candidate. Options that are strictly dominated by the good candidate on all dimensions are discarded. This pre-selection dramatically reduces the size of Valid2, the set passed to the full Pareto comparison.
Stage 2: full pairwise Pareto comparison
A self-join of Valid2 on OD_pair_rel is performed via join_equal_values_uint64. For every pair (A, B) belonging to the same OD pair, the relation checks whether A dominates B: A is at least as good as B on all Pareto dimensions and strictly better on at least one. Ties are broken by index to ensure a unique winner when two options are identical on all dimensions:
is_less_or_equal := Price_augm[A] <= Price_augm[B]
AND remaining_time_RO[A] <= remaining_time_RO[B]
AND remaining_time_NS[A] <= remaining_time_NS[B]
is_equal := Price_augm[A] == Price_augm[B]
AND remaining_time_RO[A] == remaining_time_RO[B]
AND remaining_time_NS[A] == remaining_time_NS[B]
is_less := is_less_or_equal AND (NOT is_equal OR index(A) < index(B))
Option B is removed if any other option A exists with is_less true for the pair (A, B). The surviving options form the Pareto-optimal front for each OD pair.
Within each block, after all ToBeCalculated chains have been computed and their Allowed results unioned into AllowedChains, a further Pareto selection is applied before writing to the FSS file. 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/MinimiseCriteria. Typical values are Price_augm and TravelTime, or just one of the two. This is the configuration that ultimately determines what "optimal" means in the output.
After the block-level Pareto selection, results from all blocks are unioned and written to the FSS file. The PT unit then reads this file as the input for the per-departure-moment analysis in PerDepartureMoment_T.
Pre-transport (voortransport) and post-transport (natransport) connections represent the legs at the beginning and end of a journey that are not on public transport: walking or cycling from an origin to a public transport stop, and walking or cycling from a stop to a destination. These are computed once as time-invariant OD matrices over the OpenStreetMap pedestrian or cycling network, using Dijkstra as in the private transport analyses.
The set of connection types to compute is defined in Classifications/TimeInvariantTypes, which is the Cartesian product of TimeInvariant_ConnectionTypes and TimeInvariant_Modes. The connection types define which origin and destination sets are used:
-
OrgtoStops: walking or cycling from origins to all stops. -
StopstoDest: walking or cycling from all stops to destinations. -
OrgtoRMT_Stops: walking or cycling to rail, metro, or tram stops specifically. -
OrgtoRM_Stops: walking or cycling to rail or metro stops. -
OVF_StopstoDest: cycling from OV-fiets stations to destinations. -
RMT_StopstoDest: walking or cycling from rail, metro, or tram stops to destinations. -
OrgtoRic_Stops: cycling to intercity station stops.
For each combination, Create_TimeInvariantConnection_T in PublicTransport_Prep.dms calls CreateNetwork_Pedestrian_Bike_T using the appropriate OSM network (walking or cycling). Three optimised networks are pre-computed and reused: Create_Optimised_Network_Walking, Create_Optimised_Network_Cycling, and Create_Optimised_Network_EBike.
The maximum travel time per connection type and mode is controlled by ModelParameters/Max{Mode}Time_{ConnectionType}, for example MaxWalkingTime_Org2Stops and MaxCyclingTime_Stops2Dest.
Which V and N options the model is actually allowed to evaluate is controlled by two user-configurable lists in ModelParameters/Advanced:
-
OV_PreTransport_Typen— the pre-transport options the model is allowed to use. -
OV_PostTransport_Typen— the post-transport options the model is allowed to use.
Each list element is a reference into Classifications/TimeInvariantTypes. Only the combinations listed here are computed and made available to the chain joiner; other combinations defined in TimeInvariantTypes exist as classifications but are not exercised unless the lists include them.
For each configured V or N option, an ExportName is constructed from the mode abbreviation (W for Walking, C for Cycling) and the stop class. The order of the two parts differs between pre- and post-transport, mirroring the actual direction of travel:
-
Pre-transport (V):
<Mode_abbrev>_<StopClass>-
W— walking to any stop (Org2Stops_Walking) -
C— cycling to any stop (Org2Stops_Cycling) -
C_RMT— cycling to a Rail/Metro/Tram stop (Org2RMT_Stops_Cycling) -
C_Ric— cycling to an InterCity station (Org2Ric_Stops_Cycling)
-
-
Post-transport (N):
<StopClass>_<Mode_abbrev>-
W— walking from any stop to destination (Stops2Dest_Walking) -
OVf_C— cycling from an OV-fiets station to destination (OVF_Stops2Dest_Cycling) -
RMT_C— cycling from a Rail/Metro/Tram stop to destination (RMT_Stops2Dest_Cycling)
-
When no StopClass applies (i.e. for Org2Stops and Stops2Dest), the underscore is omitted and only the mode abbreviation remains. These ExportName values are what surface as ModeUsed strings in the output files. For example, C_RMT_Bus_W denotes a trip with cycling-to-RMT pre-transport, a bus PT leg, and walking post-transport.
A common point of confusion: the Pareto-optimal selection at the end of the chain construction picks the non-dominated alternatives from the set of options that were actually configured. It does not invent alternatives. In particular:
- If
OV_PostTransport_Typenonly containsOVF_Stops2Dest_Cycling, the model only considers post-transport by OV-fiets. Destinations that cannot be reached from any OV-fiets station withinMaxCyclingTime_OVF_Stops2Destsimply have no valid trip — they are filtered out, with no walking fallback. - If
OV_PostTransport_Typencontains bothStops2Dest_WalkingandOVF_Stops2Dest_Cycling, the model evaluates both as parallel options. The Pareto step then keeps the non-dominated combinations across the full V → PT → N chain — for example an OV-fiets variant that is fast but expensive alongside a walking variant that is cheaper but slower.
The same applies to pre-transport via OV_PreTransport_Typen.
The MinimiseCriteria parameter (Price, Time, Price-Time, etc.) only controls which attributes the Pareto step considers when filtering non-dominated options; it does not introduce or remove modalities. To guarantee a walking fallback, walking must be added explicitly to the relevant configuration list.
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/Advanced/MeasureMoments. For each such moment, PerDepartureMoment_T is instantiated via for_each_ne in PublicTransport.dms.
The cached PublicTransport_Prep/PT is filtered to Within_VW_Window, containing only chains whose PT departure time satisfies:
PT/From_Time_rel <= inTime + Max_V_Time + MaxWaitingTimeAtOrigin
where Max_V_Time is the maximum pre-transport time across all configured pre-transport types. This is the latest possible 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 pre-transport unit V (a union of all pre-transport OD matrices) 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:
-
IsWachttijdValid: the traveller arrives at the stop before the PT vehicle departs (the waiting time is non-negative). -
Waiting_Time_rel <= MaxWaitingTimeAtOrigin: the waiting time at the first stop does not exceed the configured maximum. -
IsVTimeNotTooLong: the pre-transport travel time 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.
If the pre-transport mode is cycling, an additional Cycling_PreTransport_Penalty is added to the cycling time. 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). The c_fromTime_Place_rel of the resulting combined leg is assigned from the pre-transport origin at departure time inTime + Waiting_Time_rel.
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 post-transport leg must be within MaxWalkingTime_Stops2Dest or MaxCyclingTime_Stops2Dest for its mode.
The Direct_OD unit (direct cycling or walking OD matrix, computed in PublicTransport_Prep) is unioned with Add_N_to_VPT/Result to form Union_with_DirectOD. 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. This allows direct travel to compete with public transport options on equal footing.
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 FinalParetoAttr. 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.
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 with at most one Pareto-optimal row per origin-destination pair.
The final result is exported from CreateExports as a semicolon-delimited CSV file per block of origins. The file 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). Which combination of columns is exported is controlled by ModelParameters/Export_TravelledDistance and ModelParameters/Export_PriceInformation.
When no public transport option is viable, or when walking or cycling directly is faster, a direct connection provides a fallback and a competitor. PublicTransport_Prep/Direct_OD references the result of the private transport walking or cycling OD matrix, depending on AllowDirectCyclingOverWalking. The price is zero unless IncludeCyclingCostsInPrice or IncludeWalkingCostsInPrice is set, in which case the augmented price is Impedance [min] * Direct_Cycling_Time_Costs or the walking equivalent. Direct connections have PT_time, N_time, and W_time equal to zero; their full duration is recorded as V_time. They compete with PT-based connections in both condensation steps.
Object Vision B.V.