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
- Coupling the fare year to the GTFS feed
- Which legs are priced as regional and which as NS
- Regional transport: two price methods
- NS: tariff units
- Price checks
- Adding a new fare year
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 y2013 … y2026. |
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:
IsNSis true forns,ns_international/ns_intandeu_sleeper/european_sleeper. Both name variants are listed because the agency was renamed between feeds (NS Internationalis calledNS Intfrom feed20251008,Eu Sleeperis calledEuropean Sleeperfrom feed20260316).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 (
RailandBusunderNS) 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,
Kmprijs0). - 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 (
Busmode 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>]inOpmerking.
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:
- Mode + agency + concession area + route short name.
- Mode + agency + concession area.
- 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 columnsy2013…y2026, plus harmonisation columns added for the model:Concessiegebied_model(the concession name exactly as in the concession shape),Mode_klasse(BTM,RailorFerry) anduse_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:
- the DOVA fare for this concession and mode class;
- the BTM fare of the same concession (e.g. a regional train in a concession without its own rail fare);
- 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 (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 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 accumulatedalt_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:
- Tariff unit network.
NS_tariefnet.csvis an edge list of adjacent station pairs with their tariff units, taken fromtariefnet.datin the NDOV NS data.NS_stations.csvlists the station codes, which are geocoded with the NS station shape. - Tariff units per station pair.
NS_TariffUnits_matrix/ODMatrixruns a bidirectionalimpedance_matrixover 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 asTariffUnitsMatrix. - Price per number of tariff units.
NS_PricePerTariffUnitreadsdata/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. - Fare class.
ModelParameters/Advanced/NS_TariffChoiceselects the column:'Price_FullFare'(default),'Price_20pct_Discount'or'Price_40pct_Discount'. The result is stored asPrijs_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:
- Concession areas. Add
Concessie_<year>.shpto the source data and extendConcessie_jareninRegioIndelingen.dms. - Regional fares. For the Detailed method, add
FareTable_<year>toOVprijzen.dms: start from the previous year, align theVervoerdernames withagency.txtof the new feed, update the prices and give every row a source inOpmerking. For the DOVA method, add ay<year>column to both sheets of the DOVA overview. - NS fares. Add
data/Tarieven_NS/NS tarieven <year>.csvin the same format as the existing years. - Clamps. Extend the upper bound of
DOVA_yearandNS_prijzen_yearinModelParameters/Advanced. - Check. Set
GTFS_file_dateto the new feed, evaluate/ChecksBeforeRunning/PT_CheckPriceCoverageand fix any gaps until it reportsPASS. - Run. Build the network and confirm that
/ChecksBeforeRunning/PT_CheckPricesreportsPASS.