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
- Coupling the fare year to the GTFS feed
- Which rides 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 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:
IsNSis true forns,ns_international/ns_intandeu_sleeper/european_sleeper. Both name variants are listed because the agency name differs 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.IsLnetis true forns, and for NS International whenAdvanced/IsNSInternationalInLnetisTRUE(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 (
RailandBusunderNS) 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,
Kmprijs0). - 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 (
Busmode under a rail agency, “bus instead of train”) are priced at the fare of the train service they replace. They carry[dekkingscheck feed <date>]inOpmerking, 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:
- 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). 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:
- 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 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 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 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:
- The tariff unit network
data/Tarieven_NS/NS_tariefnet_<NS_TariffNetwork_year>.csvis an edge list of adjacent station pairs with their tariff units. The network of 2013 istariefnet.datfrom 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.csvlists the station codes, which are geocoded with the NS station shape. - 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). NS_TariffUnits_matrix/ODMatrixruns a bidirectionalimpedance_matrixover 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 asTariffUnitsMatrix.NS_PricePerTariffUnitreads the price per number of tariff units fromdata/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.ModelParameters/Advanced/NS_TariffChoiceselects the fare class:'Price_FullFare'(default),'Price_20pct_Discount'or'Price_40pct_Discount'. The result is stored asPrice_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:
- Add the concession areas
Concessie_<year>.shpto the source data and extendConcession_yearsinRegioIndelingen.dms. - 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. - Add the NS fares
data/Tarieven_NS/NS tarieven <year>.csvin the same format as the existing years, and the trip surcharge of that year toOVprijzen/NS/TripSurcharge/PerYear. - Extend the upper bound of
DOVA_yearandNS_prices_yearinModelParameters/Impl. - Point
GTFS_file_dateat the new feed (inModelParameters/AnalysisMomentPresets), evaluate/ChecksBeforeRunning/PT_CheckPriceCoverageand fix any gaps until it reportsPASS. - Build the network and confirm that
/ChecksBeforeRunning/PT_CheckPricesreportsPASS.