Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1862-rail-ridership-orr.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Correct `gov.dft.rail.ridership_index` to ORR Table 1220 passenger journeys (2021-22 is 2.55 and 2024-25 4.46 on a 2020-21 base, not 0.40 and 0.95), hold it flat from 2026-27, and project `rail_usage` by its growth per person, which it was not projected by before.
4 changes: 4 additions & 0 deletions policyengine_uk/data/uprating_indices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,9 @@ gov.economic_assumptions.yoy_growth.obr.private_pension_index:
- private_pension_income
gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings:
- housing_service_charges
# ORR passenger journeys per person, as household weights grow by population
# (#1862).
gov.economic_assumptions.yoy_growth.orr.per_capita.rail_passenger_journeys:
- rail_usage
gov.economic_assumptions.yoy_growth.ons.population:
- household_weight
46 changes: 21 additions & 25 deletions policyengine_uk/parameters/gov/dft/rail/ridership_index.yaml
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
description: Cumulative index of rail ridership (passenger journeys). Base year 2020 = 1.0. Used for uprating the quantity component of rail subsidy spending.
description: Index of passenger journeys on Great Britain's railways. Financial year 2020-21 = 1.0.
# ORR Table 1220, passenger journeys in millions (published 18 June 2026),
# divided by the 2020-21 figure, 387.885m, rounded to 4 decimals. The
# financial year starting in April of year t is stored at t-01-01. ORR marks
# every year from 2020-21 provisional.
#
# No official body forecasts passenger journeys, so from 2026-27 the index is
# held at its 2025-26 level: journeys are assumed to stay flat.
#
# Dataset projection grows rail_usage by this index per household
# (gov.economic_assumptions.yoy_growth.orr.per_capita.rail_passenger_journeys),
# as household weights already grow by ons.population (#1862).
values:
# Base year (COVID-affected)
2020-01-01: 1.000
# 2021: Recovery year
2021-01-01: 0.400
# 2022: Continued recovery
2022-01-01: 0.700
# 2023: Near full recovery
2023-01-01: 0.900
# 2024: Full recovery plus growth
2024-01-01: 0.950
# 2025: +1.9% growth (ORR/Steer projected trend)
2025-01-01: 0.968
# 2026: +1.9% growth
2026-01-01: 0.986
# 2027: +1.9% growth
2027-01-01: 1.005
# 2028: +1.9% growth
2028-01-01: 1.024
# 2029: +1.9% growth
2029-01-01: 1.044
2020-01-01: 1.0 # 387.885m
2021-01-01: 2.5525 # 990.062 / 387.885
2022-01-01: 3.5700 # 1,384.752 / 387.885
2023-01-01: 4.1559 # 1,612.028 / 387.885
2024-01-01: 4.4567 # 1,728.704 / 387.885
2025-01-01: 4.7165 # 1,829.468 / 387.885
2026-01-01: 4.7165 # held flat (assumption)
metadata:
unit: /1
label: Rail ridership index
reference:
- title: ORR Passenger Rail Usage Statistics
href: https://dataportal.orr.gov.uk/statistics/usage/passenger-rail-usage/
- title: Steer - UK Rail Demand Forecasting
href: https://www.steergroup.com/
- title: ORR, Table 1220 Passenger journeys, Great Britain, January 1872 to March 2026 (18 June 2026)
href: https://dataportal.orr.gov.uk/statistics/usage/passenger-rail-usage/table-1220-passenger-journeys/
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,80 @@ finance_ni:
- title: ONS, Country and regional public sector finances methodology guide, sections 29 and 30
href: https://www.ons.gov.uk/economy/governmentpublicsectorandtaxes/publicsectorfinance/methodologies/countryandregionalpublicsectorfinancesmethodologyguide

orr:
per_capita:
rail_passenger_journeys:
description: Growth in rail passenger journeys per person.
# Growth in gov.dft.rail.ridership_index (ORR Table 1220 passenger
# journeys, Great Britain) net of ons.population growth:
# (index_t / index_t-1) / (1 + population_t) - 1, rounded to 4
# decimals. Household weights grow by ons.population, so growing each
# household's rail_usage by this keeps the weighted total on ORR's
# journeys (#1862). The financial year starting in April of year t is
# stored at t-01-01. From 2026 journeys are held flat (no official
# forecast), so this is minus population growth, for every year
# ons.population has a value. Re-derive it whenever either series
# changes (policyengine_uk/tests/test_rail_usage_uprating.py checks).
values:
2021-01-01: 1.5433 # 2.5525 / 1.0036 - 1
2022-01-01: 0.3851 # (3.5700 / 2.5525) / 1.0098 - 1
2023-01-01: 0.1490 # (4.1559 / 3.5700) / 1.0132 - 1
2024-01-01: 0.0607 # (4.4567 / 4.1559) / 1.0110 - 1
2025-01-01: 0.0551 # (4.7165 / 4.4567) / 1.0030 - 1
2026-01-01: -0.0035 # 1 / 1.0035 - 1
2027-01-01: -0.0029 # 1 / 1.0029 - 1
2028-01-01: -0.0032 # 1 / 1.0032 - 1
2029-01-01: -0.0034 # 1 / 1.0034 - 1
2030-01-01: -0.0036 # 1 / 1.0036 - 1
2031-01-01: -0.0024 # 1 / 1.0024 - 1
2032-01-01: -0.0022 # 1 / 1.0022 - 1
2033-01-01: -0.0021 # 1 / 1.0021 - 1
2034-01-01: -0.0019 # 1 / 1.0019 - 1
2035-01-01: -0.0018 # 1 / 1.0018 - 1
2036-01-01: -0.0017 # 1 / 1.0017 - 1
2037-01-01: -0.0016 # 1 / 1.0016 - 1
2038-01-01: -0.0015 # 1 / 1.0015 - 1
2039-01-01: -0.0015 # 1 / 1.0015 - 1
2040-01-01: -0.0014 # 1 / 1.0014 - 1
2041-01-01: -0.0014 # 1 / 1.0014 - 1
2042-01-01: -0.0013 # 1 / 1.0013 - 1
2043-01-01: -0.0013 # 1 / 1.0013 - 1
2044-01-01: -0.0012 # 1 / 1.0012 - 1
2045-01-01: -0.0011 # 1 / 1.0011 - 1
2046-01-01: -0.0011 # 1 / 1.0011 - 1
2047-01-01: -0.0010 # 1 / 1.0010 - 1
2048-01-01: -0.0009 # 1 / 1.0009 - 1
2049-01-01: -0.0008 # 1 / 1.0008 - 1
2050-01-01: -0.0006 # 1 / 1.0006 - 1
2051-01-01: -0.0005 # 1 / 1.0005 - 1
2052-01-01: -0.0003 # 1 / 1.0003 - 1
2053-01-01: -0.0002 # 1 / 1.0002 - 1
2054-01-01: 0.0000 # 1 / 1.0000 - 1
2055-01-01: 0.0001 # 1 / 0.9999 - 1
2056-01-01: 0.0003 # 1 / 0.9997 - 1
2057-01-01: 0.0004 # 1 / 0.9996 - 1
2058-01-01: 0.0005 # 1 / 0.9995 - 1
2059-01-01: 0.0006 # 1 / 0.9994 - 1
2060-01-01: 0.0007 # 1 / 0.9993 - 1
2061-01-01: 0.0008 # 1 / 0.9992 - 1
2062-01-01: 0.0008 # 1 / 0.9992 - 1
2063-01-01: 0.0008 # 1 / 0.9992 - 1
2064-01-01: 0.0009 # 1 / 0.9991 - 1
2065-01-01: 0.0009 # 1 / 0.9991 - 1
2066-01-01: 0.0009 # 1 / 0.9991 - 1
2067-01-01: 0.0009 # 1 / 0.9991 - 1
2068-01-01: 0.0009 # 1 / 0.9991 - 1
2069-01-01: 0.0009 # 1 / 0.9991 - 1
2070-01-01: 0.0009 # 1 / 0.9991 - 1
2071-01-01: 0.0010 # 1 / 0.9990 - 1
2072-01-01: 0.0010 # 1 / 0.9990 - 1
2073-01-01: 0.0010 # 1 / 0.9990 - 1
metadata:
unit: /1
label: Rail passenger journeys growth per person
reference:
- title: ORR, Table 1220 Passenger journeys, Great Britain, January 1872 to March 2026 (18 June 2026)
href: https://dataportal.orr.gov.uk/statistics/usage/passenger-rail-usage/table-1220-passenger-journeys/
carbon_intensity:
description: Carbon intensity year-over-year growth (decline).
values:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@
# Variables that declare an index but are deliberately not in the YAML, with
# the reason. Anything else failing this test is a genuine gap.
KNOWN_UNUPRATED = {
# gov.dft.rail.ridership_index is a CUMULATIVE index (2020 = 1.0), not a
# year-on-year growth rate, so it cannot go in a yoy_growth list without
# compounding wrongly: doing so uprates rail_usage by ~684% over 2024-27.
# Uprating this needs a yoy series, not a YAML entry (#1862).
"rail_usage",
# Reported counterpart of employee_pension_contributions, which IS
# uprated; the reported variable feeds imputation rather than results.
"employee_pension_contributions_reported",
Expand Down
12 changes: 5 additions & 7 deletions policyengine_uk/tests/test_projection_horizon.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,12 @@ def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected):
"""Previously unmapped columns keep their old values through 2030, then
grow by the same cumulative-index ratio Core used beyond that year.
Columns uprating_indices.yaml now lists (the debts, ISAs, lodger and
boarder rent, gross receipts, salary sacrifice and service charges), and
water bills, which grow by country, grow every year instead, so they are
no longer fallbacks.
boarder rent, gross receipts, salary sacrifice, service charges and rail
usage), and water bills, which grow by country, grow every year instead,
so they are no longer fallbacks.
"""
assert {
"rail_usage",
"employee_pension_contributions_reported",
} <= set(FALLBACK_INDEX_OF)
assert "employee_pension_contributions_reported" in FALLBACK_INDEX_OF
assert "rail_usage" not in FALLBACK_INDEX_OF
for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1):
for variable, index_name in FALLBACK_INDEX_OF.items():
previous = _table(projected[year - 1], variable)[variable].to_numpy()
Expand Down
129 changes: 129 additions & 0 deletions policyengine_uk/tests/test_rail_usage_uprating.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Rail usage is projected by ORR passenger journeys per person (#1862).

ORR Table 1220 (published 18 June 2026) gives passenger journeys on Great
Britain's railways in millions, by financial year. gov.dft.rail.ridership_index
divides them by the 2020-21 figure. No official body forecasts journeys, so
from 2026-27 they are held flat.

Household weights grow by ons.population, so each household's rail_usage
grows by journeys growth net of population growth. Growing it by journeys
growth itself would count population growth twice.
"""

import numpy as np
import pandas as pd
import pytest

from policyengine_uk import parameters
from policyengine_uk.data import UKSingleYearDataset
from policyengine_uk.data.economic_assumptions import (
PROJECTION_END_YEAR,
extend_single_year_dataset,
)

RIDERSHIP = parameters.gov.dft.rail.ridership_index
POPULATION = parameters.gov.economic_assumptions.yoy_growth.ons.population
PER_PERSON = (
"gov.economic_assumptions.yoy_growth.orr.per_capita.rail_passenger_journeys"
)

# ORR Table 1220, passenger journeys (million), by the year each financial
# year starts in (2020 is April 2020 to March 2021).
JOURNEYS = {
2020: 387.885467,
2021: 990.062033,
2022: 1_384.752483,
2023: 1_612.028410,
2024: 1_728.704107,
2025: 1_829.468293,
}


@pytest.mark.parametrize("year", sorted(JOURNEYS))
def test_ridership_index_is_orr_journeys_on_a_2020_21_base(year):
# 2021-22: 990.062 / 387.885 = 2.5525; 2024-25: 1,728.704 / 387.885 =
# 4.4567. Before #1862 these were 0.400 and 0.950.
expected = JOURNEYS[year] / JOURNEYS[2020]
assert RIDERSHIP(f"{year}-01-01") == pytest.approx(expected, abs=5e-5)


@pytest.mark.parametrize("year", range(2026, 2031))
def test_journeys_are_held_flat_after_2025_26(year):
assert RIDERSHIP(f"{year}-01-01") == RIDERSHIP("2025-01-01")


# Every year ons.population has a value for from 2021, so the offset runs as
# far as the population series does and never stops while population growth
# carries on changing.
POPULATION_YEARS = [
int(value.instant_str[:4])
for value in POPULATION.values_list
if int(value.instant_str[:4]) >= 2021
]


@pytest.mark.parametrize("year", POPULATION_YEARS)
def test_per_person_growth_is_journeys_growth_net_of_population_growth(year):
# 2025-26: (1,829.468 / 1,728.704) / 1.0030 - 1 = 0.0551. Fails if either
# input series changes without re-deriving this one.
instant, previous = f"{year}-01-01", f"{year - 1}-01-01"
expected = RIDERSHIP(instant) / RIDERSHIP(previous) / (1 + POPULATION(instant)) - 1
assert parameters.get_child(PER_PERSON)(instant) == pytest.approx(
expected, abs=5e-5
)


def _dataset(rail_usage):
ids = np.arange(len(rail_usage))
return UKSingleYearDataset(
person=pd.DataFrame(
{
"person_id": ids,
"person_benunit_id": ids,
"person_household_id": ids,
"age": 40,
}
),
benunit=pd.DataFrame({"benunit_id": ids}),
household=pd.DataFrame(
{
"household_id": ids,
"household_weight": 1.0,
"region": "LONDON",
"tenure_type": "OWNED_OUTRIGHT",
"rent": 0.0,
"council_tax": 0.0,
"rail_usage": rail_usage,
}
),
fiscal_year=2024,
)


def _total(dataset):
household = dataset.household
return (household["rail_usage"] * household["household_weight"]).sum()


def test_projected_rail_usage_tracks_orr_journeys():
# Each household's usage grows by 1.0583 / 1.0030 = 1.0551 into 2025-26
# and the weights by 1.0030, so the weighted total grows by ORR's 5.83%.
# From 2026-27 the total stays flat in every projected year, past the end
# of the OBR forecast too. Before #1862 rail_usage was not projected at
# all.
projected = extend_single_year_dataset(
_dataset([100.0, 40.0]), parameters, end_year=PROJECTION_END_YEAR
)
datasets = projected.datasets
assert datasets[2025].household["rail_usage"].tolist() == pytest.approx(
[105.51, 42.204], rel=1e-4
)
assert _total(datasets[2025]) / _total(datasets[2024]) == pytest.approx(
JOURNEYS[2025] / JOURNEYS[2024], abs=1e-4
)
# Four-decimal rounding of the two growth series moves the total by at
# most 0.005% a year.
for year in range(2026, PROJECTION_END_YEAR + 1):
assert _total(datasets[year]) == pytest.approx(
_total(datasets[2025]), rel=5e-5 * (year - 2025)
), year
7 changes: 5 additions & 2 deletions policyengine_uk/tests/test_uprating_inputs_reach_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
from policyengine_uk.utils.scenario import Scenario

AVERAGE_EARNINGS = "gov.economic_assumptions.yoy_growth.obr.average_earnings"
# The index employee_pension_contributions_reported declares. The YAML does
# not list that variable, so only the post-2030 fallback reads its index.
UNMAPPED_INDEX = "gov.economic_assumptions.indices.obr.average_earnings"
PERSONAL_ALLOWANCE = "gov.hmrc.income_tax.allowances.personal_allowance.amount"
HF_URL = "hf://policyengine/policyengine-uk-data-private/mock_frs_2025_26.h5"
GCS_URL = "gs://policyengine-uk-data-private/mock_frs_2025_26.h5"
Expand Down Expand Up @@ -394,7 +397,7 @@ def recording_node_at(self, instant):

covered = uprating_input_paths()
# The unmapped indices, read past the legacy horizon, are in the set.
assert "gov.dft.rail.ridership_index" in read
assert UNMAPPED_INDEX in read
for name in read:
assert any(name == path or name.startswith(f"{path}.") for path in covered), (
name
Expand All @@ -403,7 +406,7 @@ def recording_node_at(self, instant):

def test_the_read_set_names_inputs_outside_the_growth_rates():
paths = uprating_input_paths()
assert "gov.dft.rail.ridership_index" in paths
assert UNMAPPED_INDEX in paths
assert economic_assumptions.CGT_INDEX_PATH in paths
assert AVERAGE_EARNINGS in paths

Expand Down
7 changes: 5 additions & 2 deletions policyengine_uk/variables/gov/dft/rail_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ class rail_usage(Variable):
"Rail usage quantity in base year (2020) price terms. "
"Should be provided by policyengine-uk-data, derived from "
"rail_subsidy_spending / fare_index at survey year. "
"Uprated by rail ridership growth trends (~1.9% annually)."
"Projected by ORR passenger journeys per person, held flat from "
"2026-27."
)
entity = Household
definition_period = YEAR
value_type = float
unit = GBP
uprating = "gov.dft.rail.ridership_index"
# Per person, because household weights already grow by population; the
# growth is that of gov.dft.rail.ridership_index (#1862).
uprating = "gov.economic_assumptions.indices.orr.per_capita.rail_passenger_journeys"
Loading