diff --git a/changelog.d/1780.md b/changelog.d/1780.md new file mode 100644 index 000000000..e56744d74 --- /dev/null +++ b/changelog.d/1780.md @@ -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. diff --git a/changelog.d/1781.md b/changelog.d/1781.md new file mode 100644 index 000000000..33e5cb1c7 --- /dev/null +++ b/changelog.d/1781.md @@ -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. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 7686f2946..b8a0ad7b9 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -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 diff --git a/policyengine_uk/parameters/gov/dft/bus/fare_allocation_weight_by_age.yaml b/policyengine_uk/parameters/gov/dft/bus/fare_allocation_weight_by_age.yaml new file mode 100644 index 000000000..ebef79055 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/fare_allocation_weight_by_age.yaml @@ -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 diff --git a/policyengine_uk/parameters/gov/dft/bus/young_person_fare/age_limit.yaml b/policyengine_uk/parameters/gov/dft/bus/young_person_fare/age_limit.yaml new file mode 100644 index 000000000..af7e093f7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/young_person_fare/age_limit.yaml @@ -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/ diff --git a/policyengine_uk/parameters/gov/dft/bus/young_person_fare/rate.yaml b/policyengine_uk/parameters/gov/dft/bus/young_person_fare/rate.yaml new file mode 100644 index 000000000..e7081169c --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/young_person_fare/rate.yaml @@ -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 diff --git a/policyengine_uk/tests/test_bus_fare_age_allocation.py b/policyengine_uk/tests/test_bus_fare_age_allocation.py new file mode 100644 index 000000000..290afcb4e --- /dev/null +++ b/policyengine_uk/tests/test_bus_fare_age_allocation.py @@ -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) diff --git a/policyengine_uk/tests/test_road_fuel_volume_uprating.py b/policyengine_uk/tests/test_road_fuel_volume_uprating.py index 9010a0607..13fc0eb7a 100644 --- a/policyengine_uk/tests/test_road_fuel_volume_uprating.py +++ b/policyengine_uk/tests/test_road_fuel_volume_uprating.py @@ -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)) + ) diff --git a/policyengine_uk/variables/gov/dft/bus_fare_relief.py b/policyengine_uk/variables/gov/dft/bus_fare_relief.py new file mode 100644 index 000000000..3585d151b --- /dev/null +++ b/policyengine_uk/variables/gov/dft/bus_fare_relief.py @@ -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) diff --git a/policyengine_uk/variables/gov/dft/dft_subsidy_spending.py b/policyengine_uk/variables/gov/dft/dft_subsidy_spending.py index 55a2d537f..0fd5a4405 100644 --- a/policyengine_uk/variables/gov/dft/dft_subsidy_spending.py +++ b/policyengine_uk/variables/gov/dft/dft_subsidy_spending.py @@ -11,4 +11,5 @@ class dft_subsidy_spending(Variable): adds = [ "rail_subsidy_spending", "bus_subsidy_spending", + "bus_fare_relief", ] diff --git a/policyengine_uk/variables/gov/dft/household_bus_fare_weight.py b/policyengine_uk/variables/gov/dft/household_bus_fare_weight.py new file mode 100644 index 000000000..9c59c3e41 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/household_bus_fare_weight.py @@ -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) diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py new file mode 100644 index 000000000..19b663353 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -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 diff --git a/policyengine_uk/variables/input/consumption/bus_fare_spending.py b/policyengine_uk/variables/input/consumption/bus_fare_spending.py new file mode 100644 index 000000000..2d80c1b9e --- /dev/null +++ b/policyengine_uk/variables/input/consumption/bus_fare_spending.py @@ -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"