Public transport fares

This page describes where the model gets the price of a single public transport leg: which fare tables exist, how a leg is matched to a fare, how the fare year is coupled to the GTFS feed, and how price coverage is checked. How leg 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-leg 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 OD sets. Each has its own price source:

Network Fare structure Source in the model Leg 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 OVprijzen/NS (tariff unit network + NS tarieven <year>.csv) Price_L

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/Advanced/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/Advanced:

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–2026.
DOVA_year FareTable_year, clamped to 2013–2026 The DOVA overview has the columns y2013y2026.
NS_prijzen_year FareTable_year, clamped to 2013–2026 NS price tables exist for 2013–2026.
Concessiegebied_year 'Y2020' if FareTable_year < 2020, otherwise 'Y'+FareTable_year Concession area shapes exist for 2020–2026.

For example, the feed 20120917 is priced with the DOVA method using 2013 regional fares, 2013 NS fares and the 2020 concession areas. The detailed fare tables are aligned as follows:

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

Note that Concessiegebied_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 legs 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 was renamed 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.

DB and Eurobahn are deliberately not in IsNS: they run regional cross-border stopping trains (Enschede–Dortmund, Hengelo–Bielefeld, Venlo–Hamm) that are priced as regional legs; if they were in IsNS their legs would drop out of the network altogether. Set_L only selects the agency ns (plus waiting links at NS stations), so NS International and the night train are in neither network and are not priced.


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–2026 2013–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 configuration currently sets PriceMethod to 'DOVA'; for feeds before 2023 the model uses DOVA regardless of this setting.

The concession area of a leg

Both methods need the concession area in which a leg is run. For each OD leg, 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, Eind_Concessiegebied_rel);

Legs 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–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

Every price was checked against primary sources in July 2026 (operator fare sheets, provincial fare decisions, operator websites via the Wayback Machine, cross-checked against DOVA). 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 full audit report is FareTable_dekkingsrapport.md in the source data folder Infrastructuur/Tarieven, next to the operator fare sheets it refers to.

Some row types need explanation:

  • NS rows (Rail and Bus under NS) have empty prices; NS legs 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 legs 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. These rows were added after the coverage check and carry [dekkingscheck feed <date>] in Opmerking.

Lookup. For every leg, three identifiers are built from the mode, the agency name (AsItemName(lowercase(agency_name))), the concession area and the route short name. The lookup 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 y2013y2026, 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). Currently 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, ModeKlasse)), 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 legs 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 leg the key is built in OD_extra_attributen.dms as the mode class (RailRail, FerryFerry, 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 leg price

The matched fare table row yields two components that are stored separately on each OD_R leg (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 legs, see Regional boarding cost: R versus O.


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. Tariff unit network. NS_tariefnet.csv is an edge list of adjacent station pairs with their tariff units, taken from tariefnet.dat in the NDOV NS data. NS_stations.csv lists the station codes, which are geocoded with the NS station shape.
  2. Tariff units per station pair. NS_TariffUnits_matrix/ODMatrix runs a bidirectional impedance_matrix over this network 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.
  3. Price per number of tariff units. NS_PricePerTariffUnit reads data/Tarieven_NS/NS tarieven <NS_prijzen_year>.csv: for 0–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.
  4. Fare class. ModelParameters/Advanced/NS_TariffChoice selects the column: 'Price_FullFare' (default), 'Price_20pct_Discount' or 'Price_40pct_Discount'. The result is stored as Prijs_Ct.

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

The NS price tables cover 2013–2026. 2013–2019 are taken from the official NS fare lists (via the Wayback Machine and the NDOV loket), 2020–2022 from the official HRN price tables, and 2023 onwards from the NS fare lists. For 2013–2018 the discount columns are computed as exactly 0.8× and 0.6× the full fare (the NS rule). The tariff unit network is a single file for all years: tariff units only change when stations are added, which rarely happened between 2023 and 2026. The year-to-year variation is in the price per number of tariff units.


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).

Check When What it tests
/ChecksBeforeRunning/PT_CheckPriceCoverage before building the network; evaluates in seconds Detailed: every (mode, agency) combination of the R-net routes in the loaded feed has at least one row in FareTable_<year>, and all non-NS rows have filled and plausible prices (boarding fare €0–10, km fare €0–0.60). A FAIL lists the missing combinations with their number of routes. DOVA: all prices of the chosen year are filled and plausible (coverage is complete by construction).
OVprijzen/PrijsDekking/Rapport on demand Writes the coverage per (mode, agency) combination to %LocalDataProjDir%/Output/Checks/Prijsdekking_feed<date>_FareTable<year>.csv as an audit trail.
/ChecksBeforeRunning/PT_CheckPrices after building the OD sets Every OD_R leg matched a fare table row that actually contains prices (Price_IntegrityCheck), and every NS leg has a station pair to price (NS_Station_Price_IntegrityCheck).
PublicTransport_Prep/Impl/OD_R/impl/Ongeprijsd/uniek when PT_CheckPrices fails The unique uncovered (mode, agency, concession, line) combinations with the number of affected legs.

The pre-flight check is slightly stricter than the OD-level check: it also counts routes that have no trips on Analysis_date.


Adding a new fare year

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

  1. Concession areas. Add Concessie_<year>.shp to the source data and extend Concessie_jaren in RegioIndelingen.dms.
  2. Regional fares. 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. NS fares. Add data/Tarieven_NS/NS tarieven <year>.csv in the same format as the existing years.
  4. Clamps. Extend the upper bound of DOVA_year and NS_prijzen_year in ModelParameters/Advanced.
  5. Check. Set GTFS_file_date to the new feed, evaluate /ChecksBeforeRunning/PT_CheckPriceCoverage and fix any gaps until it reports PASS.
  6. Run. Build the network and confirm that /ChecksBeforeRunning/PT_CheckPrices reports PASS.