Skip to content
Open
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/1780.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add `bus_fare_spending` Household input variable (COICOP 7.3.2 bus & coach fares), the companion to the LCFS bus fare imputation in policyengine-uk-data, providing the passenger fare households pay (distinct from the ETB-based `bus_subsidy_spending`) as a building block for modelling bus fare reforms. Resolves #1779.
1 change: 1 addition & 0 deletions changelog.d/1781.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add age-based allocation of household `bus_fare_spending` to people (`person_bus_fare_spending`, via a provisional NTS-derived `gov.dft.bus.fare_allocation_weight_by_age`) and a young-person bus fare policy (`gov.dft.bus.young_person_fare.{age_limit,rate}` with `bus_fare_relief`), enabling age-targeted reforms such as free bus travel for under-22s. Inert by default; routed through `dft_subsidy_spending` so it counts as both an in-kind household benefit and government spending.
1 change: 1 addition & 0 deletions policyengine_uk/data/uprating_indices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ gov.economic_assumptions.yoy_growth.obr.average_earnings:
gov.economic_assumptions.yoy_growth.obr.consumer_price_index:
- afcs_reported
- alcohol_and_tobacco_consumption
- bus_fare_spending
- bsp_reported
- carers_allowance_reported
- child_benefit_reported
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
description: Provisional relative per-person weight, by age, for allocating household bus & coach fare spending across household members. Based on the National Travel Survey (England) bus trip age profile, adjusted with modelled concessionary (free-pass) paid-fare shares so it tracks fares paid rather than trips. Used only to apportion household bus_fare_spending to people (e.g. for age-targeted fare reforms); it is not a direct estimate of bus trips or fare incidence. Weights are relative (only ratios matter); normalised to 30-39 = 1.0.
brackets:
# 0-16: low paid use (child fares; some free school transport)
- threshold:
2020-01-01: 0
amount:
2020-01-01: 0.5
# 17-20: verified NTS peak (~79-90 bus trips/person/year), full fare-payers
- threshold:
2020-01-01: 17
amount:
2020-01-01: 3.9
# 21-29
- threshold:
2020-01-01: 21
amount:
2020-01-01: 1.8
# 30-39 (reference band)
- threshold:
2020-01-01: 30
amount:
2020-01-01: 1.0
# 40-49
- threshold:
2020-01-01: 40
amount:
2020-01-01: 0.8
# 50-59
- threshold:
2020-01-01: 50
amount:
2020-01-01: 0.7
# 60-69: concessionary onset (England free pass at state pension age 66)
- threshold:
2020-01-01: 60
amount:
2020-01-01: 0.3
# 70+: almost all travel free on concessionary passes
- threshold:
2020-01-01: 70
amount:
2020-01-01: 0.07
metadata:
amount_unit: /1
threshold_unit: year
type: single_amount
label: bus fare allocation weight by age
reference:
- title: NTS 2023 - bus trips by age (17-20 peak; mean ~25 trips/person/year, England)
href: https://www.gov.uk/government/statistics/national-travel-survey-2023/nts-2023-trips-by-purpose-age-mode-and-sex
- title: NTS0621 - frequency of local bus use, aged 60 and over (concessionary)
href: https://www.gov.uk/government/statistical-data-sets/nts06-age-gender-and-modal-breakdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
description: Age below which people are eligible for the young-person bus fare policy (e.g. 22 for the Scotland-style under-22 free bus travel scheme). Defaults to 0 so no one is eligible and the baseline is unaffected; a reform raises it to switch the policy on.
values:
2020-01-01: 0
metadata:
unit: year
label: young-person bus fare age limit
reference:
- title: Free bus travel for under-22s (Transport Scotland)
href: https://www.transport.gov.scot/concessionary-travel/young-persons-free-bus-travel-scheme/
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
description: Fraction of the normal bus & coach fare that eligible young people still pay under the young-person fare policy. 0 means free travel (the Scotland-style scheme); 0.5 a half-price discount; 1 no change. Combined with age_limit, which gates eligibility.
values:
2020-01-01: 0
metadata:
unit: /1
label: young-person bus fare rate paid
48 changes: 48 additions & 0 deletions policyengine_uk/tests/test_bus_fare_age_allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pytest

from policyengine_uk import Simulation

# One household with a 19-year-old (fare-paying peak) and a 40-year-old, plus a
# household bus fare total to apportion.
SITUATION = {
"people": {
"young": {"age": {2026: 19}},
"adult": {"age": {2026: 40}},
},
"benunits": {"bu": {"members": ["young", "adult"]}},
"households": {
"home": {
"members": ["young", "adult"],
"bus_fare_spending": {2026: 470},
}
},
}

# Allocation weights from gov.dft.bus.fare_allocation_weight_by_age: 19 -> 3.9,
# 40 -> 0.8.
YOUNG_W, ADULT_W = 3.9, 0.8
TOTAL_W = YOUNG_W + ADULT_W


class TestBusFareAgeAllocation:
def test_allocation_splits_by_age_weight(self):
sim = Simulation(situation=SITUATION)
person_fare = sim.calculate("person_bus_fare_spending", 2026)
assert person_fare[0] == pytest.approx(470 * YOUNG_W / TOTAL_W)
assert person_fare[1] == pytest.approx(470 * ADULT_W / TOTAL_W)
# Allocation conserves the household total.
assert person_fare.sum() == pytest.approx(470)

def test_baseline_has_no_relief(self):
sim = Simulation(situation=SITUATION)
assert sim.calculate("bus_fare_relief", 2026)[0] == pytest.approx(0)

def test_under_22_free_fare_relieves_eligible_member(self):
reformed = Simulation(
situation=SITUATION,
reform={"gov.dft.bus.young_person_fare.age_limit": {"2026": 22}},
)
# Only the 19-year-old is eligible; their allocated fare is relieved in
# full (rate defaults to 0 -> free travel).
relief = reformed.calculate("bus_fare_relief", 2026)[0]
assert relief == pytest.approx(470 * YOUNG_W / TOTAL_W)
39 changes: 39 additions & 0 deletions policyengine_uk/tests/test_road_fuel_volume_uprating.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,42 @@ def weighted_litres(household, spending_variable, price_parameter, year):
weighted_litres(household_2034, "diesel_spending", diesel_price, 2034)
* (1 + road_fuel_volume(2035))
)


def test_bus_fare_spending_uses_cpi_uprating():
parameters = system.parameters
cpi = parameters.gov.economic_assumptions.yoy_growth.obr.consumer_price_index

dataset = UKSingleYearDataset(
person=pd.DataFrame(
{
"person_id": [1],
"person_benunit_id": [1],
"person_household_id": [1],
"age": [40],
}
),
benunit=pd.DataFrame({"benunit_id": [1]}),
household=pd.DataFrame(
{
"household_id": [1],
"region": ["LONDON"],
"tenure_type": ["OWNED_OUTRIGHT"],
"council_tax": [1_500.0],
"rent": [0.0],
"household_weight": [1.0],
"bus_fare_spending": [100.0],
}
),
fiscal_year=2025,
)

extended = extend_single_year_dataset(
dataset,
tax_benefit_system_parameters=parameters,
end_year=2026,
)

assert extended[2026].household["bus_fare_spending"].iloc[0] == pytest.approx(
100 * (1 + cpi(2026))
)
24 changes: 24 additions & 0 deletions policyengine_uk/variables/gov/dft/bus_fare_relief.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from policyengine_uk.model_api import *


class bus_fare_relief(Variable):
label = "young-person bus fare relief"
documentation = (
"Value of bus & coach fares met by government for eligible young "
"people under the young-person fare policy (gov.dft.bus.young_person_fare). "
"Treated as a transport subsidy: it counts as an in-kind household "
"benefit and as government spending. Zero under baseline, where the "
"age limit defaults to 0 (no one eligible)."
)
entity = Household
definition_period = YEAR
value_type = float
unit = GBP

def formula(household, period, parameters):
policy = parameters(period).gov.dft.bus.young_person_fare
age = household.members("age", period)
person_fare = household.members("person_bus_fare_spending", period)
eligible = age < policy.age_limit
relief = eligible * person_fare * (1 - policy.rate)
return household.sum(relief)
1 change: 1 addition & 0 deletions policyengine_uk/variables/gov/dft/dft_subsidy_spending.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ class dft_subsidy_spending(Variable):
adds = [
"rail_subsidy_spending",
"bus_subsidy_spending",
"bus_fare_relief",
]
18 changes: 18 additions & 0 deletions policyengine_uk/variables/gov/dft/household_bus_fare_weight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from policyengine_uk.model_api import *


class household_bus_fare_weight(Variable):
label = "household bus fare allocation weight"
documentation = (
"Sum across household members of the age-based bus fare allocation "
"weight, used as the denominator when apportioning household "
"bus_fare_spending to people."
)
entity = Household
definition_period = YEAR
value_type = float

def formula(household, period, parameters):
age = household.members("age", period)
weight = parameters(period).gov.dft.bus.fare_allocation_weight_by_age.calc(age)
return household.sum(weight)
24 changes: 24 additions & 0 deletions policyengine_uk/variables/gov/dft/person_bus_fare_spending.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from policyengine_uk.model_api import *


class person_bus_fare_spending(Variable):
label = "person bus and coach fare spending"
documentation = (
"Household bus_fare_spending apportioned to this person using an "
"age-based bus usage profile (NTS-derived, concessionary-adjusted). "
"This is a modelling allocation, not a direct measurement: LCFS "
"records bus fares only at household level. Lets age-targeted fare "
"reforms (e.g. free travel for under-22s) act on the relevant members."
)
entity = Person
definition_period = YEAR
value_type = float
unit = GBP

def formula(person, period, parameters):
age = person("age", period)
weight = parameters(period).gov.dft.bus.fare_allocation_weight_by_age.calc(age)
total_weight = person.household("household_bus_fare_weight", period)
household_fare = person.household("bus_fare_spending", period)
share = where(total_weight > 0, weight / total_weight, 0)
return household_fare * share
17 changes: 17 additions & 0 deletions policyengine_uk/variables/input/consumption/bus_fare_spending.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from policyengine_uk.model_api import *

# COICOP 7.3.2 (passenger transport by road: bus & coach fares). A
# sub-component of transport_consumption, imputed separately from LCFS by
# policyengine-uk-data so bus fare reforms can be modelled. Not added into the
# `consumption` total, which already counts it via transport_consumption.


class bus_fare_spending(Variable):
label = "bus and coach fare spending"
documentation = "Household spending on bus and coach fares (COICOP 7.3.2)."
entity = Household
definition_period = YEAR
value_type = float
unit = GBP
quantity_type = FLOW
uprating = "gov.economic_assumptions.indices.obr.consumer_price_index"