Public transport fares

This page describes where the model gets the price of a single ride: which fare tables exist, how a ride is matched to a fare, how the fare year is coupled to the GTFS feed, and how price coverage is checked. How ride prices are subsequently combined into a journey price (the regional boarding-fare continuation and NS through-fares) is described on the Public transport page.

All fare definitions live in SourceData/Infrastructure/OVprijzen (cfg/main/SourceData/OVprijzen.dms); the per-ride price lookup is in NetworkSetup/PublicTransport_Prep/OD_extra_attributen.dms and MakeODs.dms.

Contents


Overview

The Dutch fare system has two different structures, and the model follows that split. The network is divided into an R-net (regional transport) and an L-net (NS), see Two fare groups. Each has its own price source:

Network Fare structure Source in the model Ride price attribute
R-net (OD_R) boarding fare + price per km, per concession/operator/line OVprijzen/FareTable (either FareTable_<year> or FareTable_DOVA) Price_I + Price_O
L-net (OD_L) price per number of tariff units between two NS stations, plus a surcharge per ride on some trains OVprijzen/NS (tariff unit network NS_tariefnet_<year>.csv + NS tarieven <year>.csv) Price_L + Price_S

Prices are only computed and checked when they are needed, i.e. when MinimiseCriteria contains a price criterion or Export_PriceInformation is TRUE. This is captured in ModelParameters/Impl/PriceExportsAndIntegrityChecksNeeded.


Coupling the fare year to the GTFS feed

Agency names in GTFS change from feed to feed (mergers, splits, renames), and fare table rows are matched on those names. A fare table is therefore only valid for the feed it was aligned with. To prevent mismatches, the fare year is not set by hand but derived from the loaded feed. All derived parameters are in ModelParameters/Impl:

Parameter Rule Reason
FareTable_year substr(GTFS_file_date, 0, 4) The fare table always belongs to the loaded feed.
PriceMethod_effective 'DOVA' if FareTable_year < 2023, otherwise PriceMethod Detailed fare tables only exist for 2023 to 2026.
DOVA_year FareTable_year, clamped to 2013 to 2026 The DOVA overview has the columns y2013 … y2026.
NS_prices_year FareTable_year, clamped to 2013 to 2026 NS price tables exist for 2013 to 2026.
NS_TariffNetwork_year '2013' if NS_prices_year < 2016, otherwise '2016' NS abolished its joint tariff points with the fares of 2016 (#121).
ConcessionArea_year 'Y2020' if FareTable_year < 2020, otherwise 'Y'+FareTable_year Concession area shapes exist for 2020 to 2026.

For example, the feed 20120917 is priced with the DOVA method using 2013 regional fares, 2013 NS fares on the tariff unit network of 2013 and the 2020 concession areas. The detailed fare tables are aligned with these feeds:

Fare table GTFS feed
FareTable_2023 20231003
FareTable_2024 20241001
FareTable_2025 20251008
FareTable_2026 20260316

Another feed of the same year uses the same table; PT_CheckPriceCoverage shows whether its agency names match (see Price checks).

Note that ConcessionArea_year has no upper clamp: a feed from 2027 or later needs a new concession shape before it can be priced with either method (see Adding a new fare year).


Which rides are priced as regional and which as NS

The split is defined per agency in Classifications/Agencies:

  • IsNS is true for ns, ns_international / ns_int and eu_sleeper / european_sleeper. Both name variants are listed because the agency name differs between feeds (NS International is called NS Int from feed 20251008, Eu Sleeper is called European Sleeper from feed 20260316).
  • IsRO := not(IsNS): every other agency, including foreign carriers such as NMBS, De Lijn, VIAS, DB and Eurobahn, belongs to the R-net and is priced from the fare table.
  • IsLnet is true for ns, and for NS International when Advanced/IsNSInternationalInLnet is TRUE (the default).

DB and Eurobahn are deliberately not in IsNS: they run regional cross-border stopping trains (Enschede to Dortmund, Hengelo to Bielefeld, Venlo to Hamm) that are priced as regional rides; if they were in IsNS their rides would drop out of the network altogether.

Set_L selects the links of the IsLnet agencies, plus the waiting links at NS stations, and leaves out the trains on which an NS ticket is never valid (OVprijzen/NS/TrainsWithoutNSTicket: Eurostar, Thalys, Nightjet and European Sleeper). NS International thus runs in the L-net with the NS fare for domestic journeys; a ride of NS International to or from a station abroad has no NS price and is left out of OD_L/Result (IsDomesticLeg in MakeODs). European Sleeper is in neither network and is not priced, and so is NS International when IsNSInternationalInLnet is FALSE.


Regional transport: two price methods

The parameter ModelParameters/Advanced/PriceMethod selects which fare table OVprijzen/FareTable refers to:

unit<uint8> FareTable := =PriceMethod_effective == 'DOVA'
                          ? 'FareTable_DOVA'
                          : 'FareTable_'+FareTable_year;
  Detailed DOVA
Years 2023 to 2026 2013 to 2026
Granularity mode × operator × concession × line mode class (bus/tram/metro, rail, ferry) × concession
Boarding fare per row (e.g. flat fares, Qliner, islands) one national boarding fare per year
Source primary operator sources per row DOVA overview of km fares

Choosing a price method

Use DOVA when results for different years must be comparable, for example a time series of accessibility from 2013 onwards: it is the only method available for every feed year from 2013 to 2026, and it prices every year with the same concession-wide structure, so differences between years reflect changes in the timetable and the fare level rather than changes in how detailed the fare table is. Use Detailed for an analysis of a single year from 2023 onwards where the price level of individual trips matters: it includes line-specific fares, flat fares (e.g. the Wadden islands, Keukenhof lines), operator-specific boarding fares and foreign carriers, which DOVA approximates with the concession km fare. The default of PriceMethod is 'DOVA'; for feeds before 2023 the model uses DOVA regardless of this setting.

The concession area of a ride

Both methods need the concession area in which a ride is run. For each ride, the model takes the concession polygon that contains the departure stop, and if there is none, the polygon of the arrival stop (point_in_polygon in OD_extra_attributen.dms):

Concessiegebied_rel := MakeDefined(Start_Concessiegebied_rel, End_Concessiegebied_rel);

Rides that lie completely outside all concession areas (IsOD_completely_foreign) are removed from OD_R/Result. Their prices are nevertheless computed and checked, because Price_I and Price_O are defined on the full OD_R before that filter is applied.

The concession areas come from RegioIndelingen/Concessiegebieden/Y<year> (shapes Concessie_<year>.shp for 2020 to 2026).

Method 1: Detailed (FareTable per year)

FareTable_2023, FareTable_2024, FareTable_2025 and FareTable_2026 are inline tables in OVprijzen.dms, one per year, so that every price change is visible in a git diff. The rows differ per year because concessions change operator or are merged. Each row has eight columns:

Column Content
Mode GTFS mode name (Bus, Rail, Tram, Metro, Ferry)
Vervoerder agency name as it appears in the GTFS feed of that year
Provincie province (documentation only)
Concessiegebied concession area name as in the concession shape (empty = all concessions)
Lijn route short name (empty = all lines)
Ritprijs boarding fare in euro (FixedPrice)
Kmprijs price per km in euro (VariablePrice)
Opmerking source of the price for this row

Each price comes from a primary source (operator fare sheets, provincial fare decisions, operator websites via the Wayback Machine) and is cross-checked against DOVA (#112). The Opmerking column holds the source per row; rows for which no public source was found are explicitly marked GEEN BRON GEVONDEN and contain an approximation. The coverage report FareTable_dekkingsrapport.md is kept with the source data, next to the operator fare sheets it refers to.

Some row types need explanation:

  • The NS rows (Rail and Bus under NS) have no price of their own (empty or 0); NS rides are priced via tariff units (see below).
  • Line-specific rows override the concession fare for a single line, e.g. Qliner lines, neighbourhood buses, Keukenhof lines and island flat fares (boarding fare only, Kmprijs 0).
  • Fallback rows catch rides just outside an operator’s own concession polygons, e.g. Overal_Connexxion_ (median of the Connexxion concession fares of that year).
  • Rail replacement buses (Bus mode under a rail agency, “bus instead of train”) are priced at the fare of the train service they replace. They carry [dekkingscheck feed <date>] in Opmerking, the feed whose coverage check requires them.

For every ride, the lookup builds three identifiers from the mode, the agency name (AsItemName(lowercase(agency_name))), the concession area and the route short name. It is tried from most to least specific:

  1. Mode + agency + concession area + route short name.
  2. Mode + agency + concession area.
  3. Mode + agency.

If none of these matches the combined identifier column of the fare table, reverse-lookup variants match the identifier against the concession-level and agency-level identifier columns instead. FareTable_rel is the result of MakeDefined across all these attempts. Because the cascade ends on mode + agency, every (mode, agency) combination in the feed needs at least one row in the fare table. That is exactly what the pre-flight coverage check tests.

Method 2: DOVA (concession-wide km fares)

The DOVA method uses the overview of km fares that DOVA publishes for all concessions, going back to 2013. The model reads data/DOVA_Overzicht km-tarieven 2013-2026_edit.xlsx, which has two sheets:

  • kmtarief: one row per concession and operator with km fares in the columns y2013 … y2026, plus harmonisation columns added for the model: Concessiegebied_model (the concession name exactly as in the concession shape), Mode_klasse (BTM, Rail or Ferry) and use_for_price (1 for the one canonical row per concession and mode class; flat fares, seasonal fares, services with the NS fare structure and duplicates are 0). 38 rows are used: 31 BTM, 6 Rail and 1 Ferry.
  • Basistarief: the national boarding fare per year (€0.86 in 2013 to €1.16 in 2026).

FareTable_DOVA is the combination of all concession areas × mode classes (combine_uint8(Concessiegebieden, ModeClass)), keyed on <mode class>_<concession>. The km fare of each combination is determined with a fallback chain:

  1. the DOVA fare for this concession and mode class;
  2. the BTM fare of the same concession (e.g. a regional train in a concession without its own rail fare);
  3. the national average fare of the mode class (e.g. a concession without any DOVA row).

On top of that, one safety row per mode class with key <mode class>_Buitenland is added, for rides outside all concession polygons, priced at the national average of the class. Every row gets the national boarding fare of the year as FixedPrice.

For each ride the key is built in OD_extra_attributen.dms as the mode class (Rail → Rail, Ferry → Ferry, anything else → BTM) plus the concession name, or Buitenland if there is no concession. The DOVA method has no operator- or line-specific prices, so flat fares, Qliners and island services are priced at the concession km fare.

From fare table row to ride price

The matched fare table row yields two components that are stored separately on each OD_R ride (MakeODs.dms):

  • Price_I: the boarding fare (FixedPrice), converted to euro cents (Ct).
  • Price_O: the distance-dependent fare, VariablePrice * PT_TravelledDistance, where the distance is the accumulated alt_imp (scheduled route length) in km.

They are kept separate because only the boarding fare is affected by the continuation rule when changing between regional rides, see The price of a chain.


NS: tariff units

NS fares depend on the number of tariff units between the boarding and alighting station. The model derives these in OVprijzen/NS:

  1. The tariff unit network data/Tarieven_NS/NS_tariefnet_<NS_TariffNetwork_year>.csv is an edge list of adjacent station pairs with their tariff units. The network of 2013 is tariefnet.dat from the NDOV NS data, with the joint tariff points of NS: edges of 0 tariff units between stations close to each other, such as Hilversum and Hilversum Sportpark. NS abolished these points with the fares of 2016, so in the network of 2016 every station pair is at least 1 tariff unit apart, following the tariff unit map of NS (#121). NS_stations.csv lists the station codes, which are geocoded with the NS station shape.
  2. NS counts the tariff units of a station pair over the classic route, not over the HSL-Zuid; a journey over the HSL pays a trip surcharge instead. The two HSL edges, Hoofddorp to Rotterdam Centraal and Rotterdam Lombardijen to Breda-Prinsenbeek, are therefore left out of the network (TariffNetwork_ExcludedEdges, IsExcluded).
  3. NS_TariffUnits_matrix/ODMatrix runs a bidirectional impedance_matrix over the remaining network (ConnectedNetwork) with tariff units as impedance, which gives the number of tariff units for every pair of NS stations. In the rest of the model this matrix is referred to as TariffUnitsMatrix.
  4. NS_PricePerTariffUnit reads the price per number of tariff units from data/Tarieven_NS/NS tarieven <NS_prices_year>.csv: for 0 to 200 tariff units, the full fare and the fares with 20% and 40% discount (2nd class, including VAT). Journeys of 200 units or more get the maximum price.
  5. ModelParameters/Advanced/NS_TariffChoice selects the fare class: 'Price_FullFare' (default), 'Price_20pct_Discount' or 'Price_40pct_Discount'. The result is stored as Price_ct.

GTFS stops are linked to NS stations via Stops/NS_Stations_rel (see Create routable network from GTFS files). For each OD_L ride, the station pair key c_NS_start_end_rel is used to look up Price_L in TariffUnitsMatrix/Price_ct. The same lookup is used in the chain joiner to compute NS through-fares.

The NS price tables cover 2013 to 2026. 2013 to 2019 are taken from the official NS fare lists (via the Wayback Machine and the NDOV loket), 2020 to 2022 from the official HRN price tables, and 2023 onwards from the NS fare lists. For 2013 to 2018 the discount columns are computed as exactly 0.8× and 0.6× the full fare (the NS rule). Apart from the joint tariff points, tariff units only change when stations are added, so the year-to-year variation is in the price per number of tariff units.

Trip surcharge

Some trains cost a surcharge per ride on top of the NS fare (OVprijzen/NS/TripSurchargeTrains, #3): the Intercity direct and the Eurocity Direct between Schiphol Airport and Rotterdam Centraal, and the ICE on every domestic journey. A route belongs to such a product by its route_short_name (OVprijzen/NS/TripSurchargeRouteNames).

The surcharge depends on the ride, not on the station pair. A ride on the Intercity direct or the Eurocity Direct pays it when it passes both Schiphol Airport and Rotterdam Centraal between boarding and alighting, also without stopping there; a ride on the ICE always pays it (IsTripSurchargeLeg in MakeODs). The amount is the NS surcharge ICE / IC direct of NS_prices_year at full fare (OVprijzen/NS/TripSurcharge/PerYear).

The surcharge is stored per ride in Price_S, apart from Price_L, so that the chain joiner keeps it when it recomputes Price_L as a through fare. Advanced/IncludeTripSurcharge (default TRUE) switches it on or off.


Price checks

Missing prices surface as null prices, and they would otherwise only show up after hours of network building. The model therefore has a cheap check before the run and a conclusive check afterwards. All checks report SKIP (or always pass) when prices are not needed (PriceExportsAndIntegrityChecksNeeded is false).

PT_CheckPriceCoverage, before building the network

/ChecksBeforeRunning/PT_CheckPriceCoverage evaluates in seconds. With Detailed it tests that every (mode, agency) combination of the R-net routes in the loaded feed has at least one row in FareTable_<year>, and that all non-NS rows have filled and plausible prices (boarding fare 0 to 10 euro, km fare 0 to 0.60 euro). A FAIL lists the missing combinations with their number of routes. With DOVA it tests that all prices of the chosen year are filled and plausible; the coverage is then complete by construction.

This check is slightly stricter than PT_CheckPrices: it also counts routes that have no trips on Analysis_date.

CoverageReport, on demand

OVprijzen/PriceCoverage/CoverageReport writes the coverage per (mode, agency) combination to %LocalDataProjDir%/Output/Checks/Prijsdekking_feed<date>_FareTable<year>.csv as an audit trail.

PT_CheckPrices, after building the OD sets

/ChecksBeforeRunning/PT_CheckPrices tests that every OD_R ride matched a fare table row that actually contains prices (Price_IntegrityCheck), and that every ride of NS has a price: its station pair is in the tariff-unit matrix (NS_Station_Price_IntegrityCheck). A ride without a price would drop out of every Pareto step unnoticed, because pareto_optimal_eps never keeps a row with a null criterion.

UniqueCombos, when PT_CheckPrices fails

PublicTransport_Prep/Impl/OD_R/impl/Unpriced/UniqueCombos lists the unique uncovered (mode, agency, concession, line) combinations with the number of affected rides.


Adding a new fare year

For a new GTFS feed year, follow these steps before building the network:

  1. Add the concession areas Concessie_<year>.shp to the source data and extend Concession_years in RegioIndelingen.dms.
  2. For the Detailed method, add FareTable_<year> to OVprijzen.dms: start from the previous year, align the Vervoerder names with agency.txt of the new feed, update the prices and give every row a source in Opmerking. For the DOVA method, add a y<year> column to both sheets of the DOVA overview.
  3. Add the NS fares data/Tarieven_NS/NS tarieven <year>.csv in the same format as the existing years, and the trip surcharge of that year to OVprijzen/NS/TripSurcharge/PerYear.
  4. Extend the upper bound of DOVA_year and NS_prices_year in ModelParameters/Impl.
  5. Point GTFS_file_date at the new feed (in ModelParameters/AnalysisMomentPresets), evaluate /ChecksBeforeRunning/PT_CheckPriceCoverage and fix any gaps until it reports PASS.
  6. Build the network and confirm that /ChecksBeforeRunning/PT_CheckPrices reports PASS.