diff --git a/changelog.d/2542.fixed.md b/changelog.d/2542.fixed.md new file mode 100644 index 0000000000..99f62e1071 --- /dev/null +++ b/changelog.d/2542.fixed.md @@ -0,0 +1,2 @@ +- Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. +- Cap changes are priced from a three-band table of uncapped single fares (`gov.dft.bus.uncapped_single_fares`), fitted to DfT's evaluation of the £2 cap, the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for the £3 cap in 2025 and the £2 cap in 2027, and uprated with CPI, instead of one £2.748 fare for every single ticket. A £3 cap now binds, and a full year at £2 against no cap saves about £0.36bn a year at 2024-25 boardings instead of £0.70bn. A supplied `local_bus_uncapped_single_fare` replaces the table for that person. diff --git a/changelog.d/2542.removed.md b/changelog.d/2542.removed.md new file mode 100644 index 0000000000..918c987f03 --- /dev/null +++ b/changelog.d/2542.removed.md @@ -0,0 +1 @@ +- Removed `gov.dft.bus.reference_year` and `gov.dft.bus.uncapped_single_fare`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`; the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year; and cap changes are priced from `gov.dft.bus.uncapped_single_fares`. A reform naming a removed parameter fails with directions. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index 57547f47ac..cda8b82730 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -27,8 +27,9 @@ false and the person pays fares even if eligible for a statutory concession. For fare-paying passengers, `local_bus_single_fare_share` is the share of boardings bought as singles, bounded to [0, 1]. Its fallback is 0.70, the 2024 NTS other-local-bus estimate. -An optional `local_bus_uncapped_single_fare` overrides the representative -uncapped fare described below. Negative quantities and fares price at zero. +An optional `local_bus_uncapped_single_fare` replaces the table of uncapped +single fares described below for that person. Negative quantities and fares +price at zero. On loading a UK `Simulation`, a supplied household `bus_fare_spending` moves to `bus_fare_spending_reported`. The stored total therefore cannot freeze the @@ -66,6 +67,59 @@ residents because non-residents also travel there. Scotland and Northern Ireland use the English other-local-bus trip rate as a proxy, as Microcosm does. These translations include interchanges and differences between operator boardings and resident trips; they are not counts of legs for a known trip. +`gov.dft.bus.benchmark_year` (2024) names the fiscal year these facts describe. + +## Fares by year + +In other years each area's yield moves with its published fares index: + +``` +yield(t) = benchmark_yield * index(t) / index(benchmark_year) +``` + +`gov.dft.bus.local_bus_fares_index` holds DfT's +[BUS0415a](https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue) +quarter-end values (March 2005 = 100) for London, England outside London and +Scotland, from DfT's June 2026 release (quarters to March 2026; +[archived copy](https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods)), +the vintage Chronicle and Microcosm vendor. DfT's September 2026 release, now +served at the same address, revises the last two quarters and adds June 2026; +move to it together with Microcosm. Each value is dated at the start of the +quarter it ends, and `fiscal_year_blend` day-weights the quarters over 6 April +to 5 April, the model's year. FY2024–25 outside London is therefore +(86 × 181.6 + 92 × 185.7 + 92 × 185.9 + 90 × 206.4 + 5 × 210.5) / 365 = 190.23, +within 0.2% of the mean of its four quarter ends (189.9). After the last +published quarter the index grows with the OBR consumer price index each +January, as `private_rent_index` does. Northern Ireland has no published index, +so its yield grows with CPI from the benchmark year. Wales stays unpriced. + +The CPI projection also raises the fares in the index that the £3 cap holds at +£3, while the cap term below measures changes from a nominal £3. From January +2027 a ticket held at a cap therefore costs about £3 times the CPI growth since +March 2026 more than the cap allows. That overstates the outside-London yield +by 0.4% in 2027–28 and 0.8% in 2028–29, about 0.3 points more each year after. +A cap reform is measured against the same reference as the baseline, so its +cost does not move. + +| Area | FY2024–25 | FY2025–26 | FY2026–27 | FY2027–28 | +|---|---:|---:|---:|---:| +| London | 196.10 | 196.10 | 197.13 | 201.08 | +| England outside London | 190.23 | 214.18 | 219.09 | 223.48 | +| Scotland | 210.63 | 229.94 | 235.90 | 240.63 | + +FY2025–26 and earlier are published; later years are CPI projections. The +index records prices paid, so it already contains each cap in force while it +is published, including the rise to £3 in January 2025 (outside London 185.9 +in December 2024, 206.4 in March 2025). The cap term below adds only what the +index cannot contain. + +Each area follows its own index, so the England total moves with the +receipts-weighted mix of the London and outside-London indices: +7.6% in +FY2025–26. DfT's England index rose 6.4%. Its step from December 2024 to +March 2025 was 5.6%, against 11.0% outside London and none in London, so it +weights outside London at about one half, where receipts weight it at 61%. +A receipts target moved by the England index therefore sits below the model's +England total. ## Caps and the uncapped counterfactual @@ -79,45 +133,114 @@ representing the end of the announced scheme. These dates follow the The cap does not affect the London series, Scottish or Northern Irish fares. England outside London uses a representative participating-service assumption; route-level participation and local lower caps require caller-supplied fares. +The table of uncapped fares below is fitted to national figures and applied +the same way in every English region outside London. Where a combined authority +already runs its own lower single fare, a national cap reform therefore +overstates the savings, and elsewhere it understates them. National totals +hold, but regional and distributional splits of a cap reform lean the wrong way. -The published yield already includes ticket discounts and the benchmark cap. -For an English fare-paying person's other-local-bus boardings, the model uses: +The published yield and index already include ticket discounts and the cap in +force. `gov.dft.bus.reference_fares.cap` records that cap: the cap in force up +to the last published quarter of the index (£3 from January 2025), held after +it. For an English fare-paying person's other-local-bus boardings, the model +uses: ``` -current_yield = max(0, benchmark_yield + single_share * - (annual_min(uncapped_fare, current_cap) - - annual_min(uncapped_fare, reference_cap))) -spending = trips * boardings_per_trip * current_yield +yield(t) = max(0, benchmark_yield * index(t) / index(benchmark_year) + + single_share * sum over bands b of share_b * + (annual_min(fare_b, fares.cap, t) - + annual_min(fare_b, reference_fares.cap, t))) +spending = trips * boardings_per_trip * yield(t) ``` +Both schedules are read in the same year. While the index is published they +agree and the cap term is zero, so the baseline is the published path. After +the last published quarter the term adds the scheduled changes: the £2 cap from +January 2027 and its end in 2028. A reform of `fares.cap` adds its own +difference from the reference. Leave `reference_fares` unchanged in reforms. +When a newly published quarter is added to the index, extend +`reference_fares.cap` with the cap then in force; +`policyengine_uk/tests/test_bus_fare_parameters.py` fails until the two +schedules agree up to the last published quarter. + `annual_min` caps the fare in each date interval, then weights the intervals from 6 April to 5 April, assuming uniform journeys through the year. It does -not cap a fare using the average cap. Both `fares` and `reference_fares` preserve -calendar dates. `reference_year` is 2024 and `reference_fares` holds the benchmark -schedule fixed when the policy cap changes. The source receipts use 1 April to -31 March; this approximation treats them as the corresponding model year's -benchmark. Annual Scenario changes apply to the full model fiscal year. - -The default uncapped fare is £2.748019953976. This is an aggregate assumption, -not an observed fare distribution. The FY2024–25 cap grant is £515,693,216.60. -With `F = B - C` outside London and single share `s = 0.70`, the assumption -solves `s * (u - annual_min(u, reference_cap)) = grant / F`. Since `2 < u < 3`, -the reference year has 270 days at £2 and 95 at `u`, giving: +not cap a fare using the average cap. Both `fares` and `reference_fares` keep +calendar dates (their index files propagate `preserve_calendar_dates` to the +cap leaves), so a cap change part-way through a year applies from its date. +The source receipts use 1 April to 31 March; this approximation treats them as +the corresponding model year's benchmark. Annual Scenario changes apply to the +full model fiscal year. -``` -u = 2 + grant / (F * 0.70 * 270/365) -``` +### Uncapped single fares + +What a cap saves depends on how uncapped single fares are spread, not on their +average. `gov.dft.bus.uncapped_single_fares` holds three bands, each with its +share of single tickets and its mean uncapped fare at FY2024–25 prices: + +| Band | Share | Mean fare | +|---|---:|---:| +| `LOW`, at or below £2 | 0.55 | £1.65 | +| `MIDDLE`, above £2 and up to £3 | 0.30 | £2.21 | +| `HIGH`, above £3 | 0.15 | £3.87 | + +The fares rise with the OBR CPI after FY2024–25 and the shares stay, so a fixed +cap saves more as fares rise. The model caps each band's mean fare under both +schedules and weights the bands by their shares, which must sum to one. With +the bands at their means, the table is exact for caps of £2 and £3 at +FY2024–25 prices and approximate for other caps; a cap below the low band's +mean needs more bands. + +`scripts/fit_bus_cap_savings.py` fits the table by least squares to the facts +below, each scaled by its tolerance. It searches shares in steps of one point +and fares in steps of one penny, keeps each band's mean at least 10% above its +lower edge, and sets the low band's fare so all singles average £2.15: + +| Fact | Source | Target | Table gives | +|---|---|---:|---:| +| A full year at £2, 2023–24 | Fare cap support, DfT BUS05bi | £278.95m ± 10% | £280.9m | +| Part of the December 2024 to March 2025 index step outside London due to the £3 cap | BUS0415a: +11.0%; neighbouring quarters 0.1% to 2.3% | 10% ± 1 point of the yield | 9.8% | +| The £3 cap in 2025 | ["over £150 million"](https://www.gov.uk/government/speeches/national-bus-fare-cap) | £150m ± 20% | £140.0m | +| The £2 cap in 2027, against no cap | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £411.6m | +| Mean pre-cap price of single tickets | [DfT evaluation](https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap), Table 6 | £2.15 ± £0.10 | £2.15 | +| Share of single tickets above £2 | Table 6: £0.32 / (£2.73 − £2.00) | 0.44 ± 0.05 | 0.45 | +| Their mean pre-cap price | Table 6 | £2.73 ± £0.10 | £2.76 | +| 2024–25: £2 for 270 days, then £3 | Fare cap support, DfT BUS05bi | £515.69m, not fitted | £268.5m | + +The £400 million is read as £2 against no cap: the £3 scheme is funded only to +December 2026, and the £2 scheme replaces it. Read as £2 against a continued +£3 cap, the table gives £247.3m, 38% below. The script prints both. + +The 2024–25 grant, for the benchmark year itself, is reported and not fitted; +the table puts it at about half. Grant per fare-paying boarding rose 78% on +2023–24, while the outside-London index rose 6.7%, receipts per fare-paying +boarding rose 2.8%, and the cap rose only for the last quarter. The figure may +therefore hold more than that year's forgone fares (timing or coverage of +payments, not checked). The 2023–24 grant and the two announced figures all +put a full year at £2 near £0.3bn, not £0.6bn. The single £2.748 fare this +table replaced was calibrated to the 2024–25 grant. + +The costs use the 0.70 default single share and fare-paying boardings outside +London (BUS01 journeys less BUS01c concessionary journeys: 1,281m in 2023–24 +and 1,331m in 2024–25, which also stands for 2025 and 2027), with fares moved +to each year by CPI. Table 6 compares January to October 2022 with the same +months of 2023 and is read at face value, for the table's shape. Deflated to +2022 prices it would put the 2023–24 saving about a third above the grant. +Possible reasons, not checked, are that the grant pays operators for revenue +lost net of the journeys the cap added, and that Table 6 covers the larger +participating operators only. The two announced figures are budgets; the £2 +cap's first ten months paid £210m against £245m planned. The table is a fitted +central estimate, not an observed fare distribution. + +At FY2025–26 prices the table saves £0.39 per single ticket for a full year at +£2 against no cap, and £0.15 at £3: about £0.36bn and £0.14bn a year at +2024–25 boardings. The single £2.748 fare this replaced saved £0.75 at £2 +(about £0.70bn) and nothing at £3. -Removing the cap therefore raises the benchmark yield from about £1.55 to -£1.94 at the representative share, matching the grant-addback calculation -in issue #1871. This assumes full grant pass-through and no demand response. -Individual shares can differ, so a dataset aggregate need not recover the -published grant. Caps at or above £2.75, including the £3 cap, have no effect -relative to uncapped travel under the default £2.748 representative fare. -The default has no fare distribution above these caps, so it cannot estimate -the savings those caps give to passengers with higher fares. Supply -person-specific `local_bus_uncapped_single_fare` values above a cap to model -its effect on those passengers. Very large reductions can reach the zero-yield +A dataset or calculator can supply `local_bus_uncapped_single_fare` for each +person. Where it is supplied, that person's single tickets cost that one fare +before the cap, and the table is not used. Single-year datasets extended by +`Simulation` uprate it with CPI. Very large reductions can reach the zero-yield floor. ## Other reform controls and limits @@ -127,8 +250,8 @@ spending. Both default to one; they are neutral reform controls rather than historical index series. Keep the denominator positive. `ridership_index` defaults to one and multiplies journey-priced spending, leaving supplied journey inputs intact. The model includes no demand elasticity or automatic -ridership growth. Benchmark yields and trip translations stay nominally -constant after 2024 unless changed explicitly. Dataset CPI-uprated household +ridership growth. Trip translations stay constant after 2024 unless changed +explicitly; yields move with the fares index. Dataset CPI-uprated household bus totals do not determine prices where journeys are available. A proportional scenario can set `gov.dft.bus.fare_index` to 0.9 for a 10% diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ed2d334e12..abac27042a 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -65,6 +65,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - income_support_reported - jsa_contrib_reported - jsa_income_reported +- local_bus_uncapped_single_fare - maintenance_expenses - maternity_allowance_reported - miscellaneous_consumption diff --git a/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml new file mode 100644 index 0000000000..5139c967dc --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml @@ -0,0 +1,15 @@ +description: Fiscal year of the published bus fare benchmark. yield_per_boarding and + boardings_per_trip describe this year (FY2024-25 receipts and boardings), and the yield in + another model year is the benchmark yield times the local bus fares index in that year over + its value in this one. +values: + '0001-01-01': 2024 +metadata: + unit: year + period: year + label: bus fare benchmark year + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml index 35741aa6db..dd8d8e4786 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml @@ -1,5 +1,6 @@ description: Proportional multiplier on bus fares; a neutral reform lever, not a measured - historical fare index. + historical fare index. The published DfT fares index that moves the baseline is + gov.dft.bus.local_bus_fares_index. values: '0001-01-01': 1 metadata: diff --git a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml new file mode 100644 index 0000000000..5eb5a3b58c --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml @@ -0,0 +1,65 @@ +description: DfT local bus fares index (table BUS0415a, March 2005 = 100) for each area + whose bus fares the model prices from journeys, from DfT's June 2026 release (quarters to + March 2026). Each value is the quarter-end figure DfT publishes, dated at the start of the + quarter it ends. fiscal_year_blend day-weights the quarters over each 6 April to 5 April + model year. After the last published quarter the index grows with the OBR consumer price + index. The yield per fare-paying boarding in a model year is the benchmark yield times this + index in that year over its value in the benchmark year. Northern Ireland has no published + index. +metadata: + label: local bus fares index + propagate_metadata_to_children: true + fiscal_year_blend: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + # The June 2026 file (sha256 04721e9ade3b39c415ce2a0bcc045ec6d4ccb8afb6e33d525f619cf452bc7adf, + # 23,434 bytes) is the vintage Chronicle and Microcosm vendor. Its DfT address now serves + # the September 2026 release, which revises the December 2025 and March 2026 quarters and + # adds June 2026; move to it together with Microcosm. The archived copy does not change. + reference: + - title: DfT bus statistics, table BUS0415a, local bus fares index by metropolitan area status + and country, June 2026 release (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics data tables, BUS04 costs, fares and revenue (current release) + href: https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue +LONDON: + 2023-01-01: 194.3 # quarter ending March 2023 + 2023-04-01: 194.3 # quarter ending June 2023 + 2023-07-01: 194.3 # quarter ending September 2023 + 2023-10-01: 194.3 # quarter ending December 2023 + 2024-01-01: 196.1 # quarter ending March 2024 + 2024-04-01: 196.1 # quarter ending June 2024 + 2024-07-01: 196.1 # quarter ending September 2024 + 2024-10-01: 196.1 # quarter ending December 2024 + 2025-01-01: 196.1 # quarter ending March 2025 + 2025-04-01: 196.1 # quarter ending June 2025 + 2025-07-01: 196.1 # quarter ending September 2025 + 2025-10-01: 196.1 # quarter ending December 2025 + 2026-01-01: 196.10649454164795 # quarter ending March 2026, unrounded in the DfT file +ENGLAND_OUTSIDE_LONDON: + 2023-01-01: 173.1 # quarter ending March 2023 + 2023-04-01: 175.6 # quarter ending June 2023 + 2023-07-01: 177.8 # quarter ending September 2023 + 2023-10-01: 178.8 # quarter ending December 2023 + 2024-01-01: 179.9 # quarter ending March 2024 + 2024-04-01: 181.6 # quarter ending June 2024 + 2024-07-01: 185.7 # quarter ending September 2024 + 2024-10-01: 185.9 # quarter ending December 2024 + 2025-01-01: 206.4 # quarter ending March 2025 + 2025-04-01: 210.5 # quarter ending June 2025 + 2025-07-01: 213.6 # quarter ending September 2025 + 2025-10-01: 214.3 # quarter ending December 2025 + 2026-01-01: 217.95569364121366 # quarter ending March 2026, unrounded in the DfT file +SCOTLAND: + 2023-01-01: 185.2 # quarter ending March 2023 + 2023-04-01: 203.1 # quarter ending June 2023 + 2023-07-01: 203.1 # quarter ending September 2023 + 2023-10-01: 203.1 # quarter ending December 2023 + 2024-01-01: 203.3 # quarter ending March 2024 + 2024-04-01: 207.7 # quarter ending June 2024 + 2024-07-01: 210.9 # quarter ending September 2024 + 2024-10-01: 211.3 # quarter ending December 2024 + 2025-01-01: 211.5 # quarter ending March 2025 + 2025-04-01: 228 # quarter ending June 2025 + 2025-07-01: 228.4 # quarter ending September 2025 + 2025-10-01: 228.4 # quarter ending December 2025 + 2026-01-01: 234.6832699716523 # quarter ending March 2026, unrounded in the DfT file diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml index 79092767e7..7ff2c77e18 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml @@ -1,18 +1,20 @@ -description: Fixed national cap schedule for the bus-fare yield benchmark. Read in reference_year; leave unchanged when reforming fares.cap. +description: National single bus fare cap that the published local bus fares index already + reflects. It follows gov.dft.bus.fares.cap up to the last published quarter of + gov.dft.bus.local_bus_fares_index and holds the cap then in force after it. The model adds + the effect of any difference between the two schedules, so the scheduled 2027 GBP 2 cap and + its end in 2028 enter the baseline, and a reform of fares.cap adds its own effect. Leave + this schedule unchanged in reforms; extend it when a newly published quarter is added to + the index. values: 0001-01-01: .inf 2023-01-01: 2 2025-01-01: 3 - 2027-01-01: 2 - 2028-01-01: .inf metadata: unit: currency-GBP period: year - label: benchmark single bus fare cap + label: single bus fare cap in the published fares index reference: - title: DfT evaluation of the two-pound bus fare cap, 2023 to 2024 href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap - title: DfT three-pound national bus fare cap, through December 2026 href: https://www.gov.uk/guidance/3-national-bus-fare-cap - - title: DfT two-pound bus fares from January to December 2027 - href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml deleted file mode 100644 index c166df37d5..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Fiscal year of the published bus-fare yield benchmark, used only to price - the reference single-fare cap. -values: - '0001-01-01': 2024 -metadata: - unit: year - period: year - label: reference year - reference: - - title: DfT bus statistics data tables, BUS01 and BUS05i - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, NTS and published-receipts pricing evidence - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml deleted file mode 100644 index 1730a733c3..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: Representative uncapped single fare inferred from the 2024-25 fare-cap grant, - fare-paying boardings and a 70 percent single-ticket share. Assumes no demand response - and full grant pass-through. This is a modelling assumption, not an observed individual - fare. Caps at or above GBP 2.75, including the GBP 3 cap, have no effect relative to - uncapped travel with this default GBP 2.748 fare. Supply person-specific - local_bus_uncapped_single_fare inputs to represent fares above those caps. - See docs/engineering/bus-fares.md. -values: - '2024-01-01': 2.748019953976 -metadata: - unit: currency-GBP - period: year - label: assumed uncapped single bus fare - reference: - - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, published-fact extraction and pricing derivation - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml new file mode 100644 index 0000000000..9b87a44996 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml @@ -0,0 +1,29 @@ +description: Mean uncapped single bus fare in England outside London in each band, at + FY2024-25 prices and uprated with the OBR consumer price index after that, so a fixed cap + saves more as fares rise. +metadata: + unit: currency-GBP + period: year + label: mean uncapped single bus fare by band + propagate_metadata_to_children: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 1.65 +MIDDLE: + '2024-01-01': 2.21 +HIGH: + '2024-01-01': 3.87 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml new file mode 100644 index 0000000000..b1efbc0cdf --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml @@ -0,0 +1,9 @@ +description: Uncapped single bus fares in England outside London in three bands, each with + its share of single tickets and its mean fare. The model prices a change in the national + single fare cap as the change in the mean capped fare over the bands, for the single-ticket + share of fare-paying boardings. The table is fitted to DfT's evaluation of the GBP 2 cap, + the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for + the GBP 3 cap in 2025 and the GBP 2 cap in 2027. See docs/engineering/bus-fares.md and + scripts/fit_bus_cap_savings.py. +metadata: + label: uncapped single bus fares diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml new file mode 100644 index 0000000000..20cdc48e8c --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml @@ -0,0 +1,28 @@ +description: Share of single bus tickets in England outside London in each band of uncapped + fare at FY2024-25 prices. LOW is at or below GBP 2, MIDDLE above GBP 2 and up to GBP 3, and + HIGH above GBP 3. The shares must sum to one. +metadata: + unit: /1 + period: year + label: share of single tickets by uncapped fare band + propagate_metadata_to_children: true + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 0.55 +MIDDLE: + '2024-01-01': 0.3 +HIGH: + '2024-01-01': 0.15 diff --git a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml index 52d193adeb..791dba5f6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -4,7 +4,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -23,7 +23,7 @@ - name: Case 2, pass holders pay nothing even when they make journeys. period: 2025 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -36,7 +36,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST bus_in_london_trips: 100 @@ -51,7 +51,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST local_bus_trips: 100 @@ -66,7 +66,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 @@ -85,7 +85,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -101,7 +101,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -116,7 +116,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SCOTLAND other_local_bus_trips: 100 @@ -126,3 +126,105 @@ gov.dft.bus.fares.cap: 0 output: person_bus_fare_spending: 200 + +# Default parameters. The yield moves from the FY2024-25 benchmark with DfT's +# BUS0415a fares index, day-weighted over 6 April to 5 April. Quarter-end +# values date from the start of their quarter: FY2024-25 for England outside +# London is (86 * 181.6 + 92 * 185.7 + 92 * 185.9 + 90 * 206.4 + 5 * 210.5) +# / 365 = 190.2282 and FY2025-26 is (86 * 210.5 + 92 * 213.6 + 92 * 214.3 + +# 95 * 217.9557) / 365 = 214.1797. Benchmarks: docs/engineering/bus-fares.md. +- name: Case 9, the 2024-25 benchmark prices journeys at the published yield. + period: 2024 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 100 trips * 1.12466920264 boardings * GBP 1.554716967657. + person_bus_fare_spending: 174.85 + +- name: Case 10, the 2025-26 yield follows the fares index, not a cap term. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 174.8542 * 214.1797 / 190.2282 (+12.6%). The cap is GBP 3 all year + # both in the index and in the cap schedule, so nothing is added. + person_bus_fare_spending: 196.87 + +- name: Case 11, London fares follow the flat London index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: LONDON + bus_in_london_trips: 100 + bus_pass_eligible: false + output: + # 100 * 2.369144880275 * GBP 1.017729905895 * 196.1017 / 196.1. + person_bus_fare_spending: 241.12 + +- name: Case 12, Scottish fares follow the Scottish index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SCOTLAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # FY2024-25 (86 * 207.7 + 92 * 210.9 + 92 * 211.3 + 90 * 211.5 + 5 * 228) + # / 365 = 210.6290; FY2025-26 (86 * 228 + 92 * 228.4 + 92 * 228.4 + 95 * + # 234.6833) / 365 = 229.9411. 100 * 2.145240985363 * GBP 2.599723222144 + # * 229.9411 / 210.6290. + person_bus_fare_spending: 608.84 + +- name: Case 13, Northern Ireland has no fares index, so its yield follows CPI. + period: 2025 + absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # 100 * 1.252853758464 * GBP 2.550929922065 * 1.034 (OBR CPI, 2025 over + # 2024, as the engine's index rounds it: 1.61059 / 1.55763). + person_bus_fare_spending: 330.46 + +# Cap changes use the table of uncapped single fares in +# gov.dft.bus.uncapped_single_fares: 55% of singles at GBP 1.65, 30% at +# GBP 2.21 and 15% at GBP 3.87 at FY2024-25 prices, uprated with OBR CPI +# (FY2025-26 x 1.61059 / 1.55763 = 1.0340). Case 10's passenger has 100 trips +# * 1.12466920264 boardings * 0.7 single share = 78.7268 single boardings, at +# a GBP 196.87 baseline. +- name: Case 14, without a cap in 2025-26 the singles above GBP 3 pay in full. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: .inf + output: + # Only the high band (3.87 * 1.0340 = 4.0016) is above GBP 3: + # 196.87 + 78.7268 * 0.15 * 1.0016. + person_bus_fare_spending: 208.70 + +- name: Case 15, a GBP 2 cap in 2025-26 saves the middle and high bands. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: 2 + output: + # Middle band 2.21 * 1.0340 = 2.2851 falls to 2; the high band falls + # from 3 to 2: 196.87 - 78.7268 * (0.30 * 0.2851 + 0.15 * 1). + person_bus_fare_spending: 178.33 diff --git a/policyengine_uk/tests/test_bus_fare_parameters.py b/policyengine_uk/tests/test_bus_fare_parameters.py new file mode 100644 index 0000000000..511082de76 --- /dev/null +++ b/policyengine_uk/tests/test_bus_fare_parameters.py @@ -0,0 +1,164 @@ +"""Bus fare parameters agree with each other and with the facts they encode.""" + +import datetime +from pathlib import Path + +import pytest +import yaml + +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario +from policyengine_uk import Simulation + +BUS_PARAMETERS = Path(__file__).parents[1] / "parameters" / "gov" / "dft" / "bus" + + +def last_published_quarter(): + """The first day of the last quarter in the published fares index, and + the day after it ends.""" + raw = yaml.safe_load((BUS_PARAMETERS / "local_bus_fares_index.yaml").read_text()) + areas = {key: values for key, values in raw.items() if key.isupper()} + starts = {max(values) for values in areas.values()} + # DfT publishes every area's quarter in the same release. + assert len(starts) == 1, starts + (start,) = starts + month = start.month + 3 + end = datetime.date(start.year + month // 13, (month - 1) % 12 + 1, 1) + return start, end + + +def test_reference_cap_is_the_cap_the_published_index_reflects(): + start, end = last_published_quarter() + assert (start, end) == (datetime.date(2026, 1, 1), datetime.date(2026, 4, 1)) + bus = system.parameters.gov.dft.bus + changes = sorted( + { + datetime.date.fromisoformat(value.instant_str) + for schedule in (bus.fares.cap, bus.reference_fares.cap) + for value in schedule.values_list + } + ) + held = bus.reference_fares(start.isoformat()).cap + for day in changes + [start, end]: + reference = bus.reference_fares(day.isoformat()).cap + if day < end: + # Up to the last published quarter, the index reflects the cap + # in force. + assert reference == bus.fares(day.isoformat()).cap, day + else: + # After it, the index holds the cap of that quarter. + assert reference == held, day + + +BANDS = ("LOW", "MIDDLE", "HIGH") +SINGLE_SHARE = 0.70 +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 + + +def cpi(year): + indices = system.parameters.gov.economic_assumptions.indices.obr + return indices.consumer_price_index(f"{year}-01-01") + + +def bands(year=2024): + """(share, fare) of each band, as the model reads them in a year.""" + table = system.parameters.gov.dft.bus.uncapped_single_fares + at = f"{year}-01-01" + return [ + (table.share.children[band](at), table.fare.children[band](at)) + for band in BANDS + ] + + +def saving(cap, year=2024, price_ratio=1.0): + """Mean saving per single ticket from a cap held all year.""" + return sum(share * max(price_ratio * fare - cap, 0) for share, fare in bands(year)) + + +def test_uncapped_fare_bands(): + (low_share, low), (middle_share, middle), (high_share, high) = bands() + assert low_share + middle_share + high_share == pytest.approx(1) + # Bands at FY2024-25 prices: at or below GBP 2, up to GBP 3, above GBP 3. + assert low <= 2 < middle <= 3 < high + # Fares move with CPI after FY2024-25; shares stay. + for year in (2025, 2027): + for (share, fare), (later_share, later_fare) in zip(bands(), bands(year)): + assert later_share == share + assert later_fare == pytest.approx(fare * cpi(year) / cpi(2024)) + + +def test_table_matches_dft_cap_evaluation(): + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap and GBP 1.83 after (a GBP 0.32 + # saving); those above GBP 2 averaged GBP 2.73, which puts their share + # near 0.32 / 0.73 = 0.44. Read at face value, as the fit does. + table = bands() + above = [(share, fare) for share, fare in table if fare > 2] + above_share = sum(share for share, _ in above) + assert sum(share * fare for share, fare in table) == pytest.approx(2.15, abs=0.03) + assert above_share == pytest.approx(0.44, abs=0.05) + assert sum(share * fare for share, fare in above) / above_share == pytest.approx( + 2.73, abs=0.05 + ) + assert saving(2) == pytest.approx(0.32, abs=0.03) + + +def test_table_matches_january_2025_index_step(): + # BUS0415a outside London rose 11.0% from December 2024 to March 2025, + # when the cap rose from GBP 2 to GBP 3; neighbouring quarters moved 0.1% + # to 2.3%. The cap's part, 9% to 11% of the yield at the December 2024 + # index level (1.554716967657 * 185.9 / 190.2282). + december_yield = 1.554716967657 * 185.9 / 190.2282 + step = SINGLE_SHARE * (saving(2) - saving(3)) / december_yield + assert 0.09 <= step <= 0.11 + + +def test_table_matches_2023_24_fare_cap_grant(): + # BUS05bi fare cap support for 2023-24, a full year at GBP 2: GBP 278.95m, + # over 1,281m fare-paying boardings. Fares at 2023-24 prices. + grant = ( + SINGLE_SHARE + * FARE_PAYING_2023_24 + * saving(2, price_ratio=cpi(2023) / cpi(2024)) + ) + assert grant == pytest.approx(278_951_028, rel=0.15) + + +def test_table_matches_announced_cap_funding(): + # Over GBP 150m for the GBP 3 cap in 2025 (DfT written statement, 29 + # October 2024) and GBP 400m for the GBP 2 cap in 2027 (DfT, 22 July 2026), + # at the 2024-25 number of fare-paying boardings. + cap_3 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(3, year=2025) + cap_2 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(2, year=2027) + assert cap_3 == pytest.approx(150e6, rel=0.20) + assert cap_2 == pytest.approx(400e6, rel=0.20) + + +def test_removed_reference_year_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.reference_year": {"2025": 2025}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.reference_year has been removed" in message + assert "gov.dft.bus.benchmark_year" in message + assert "gov.dft.bus.fares.cap" in message + + +def test_removed_uncapped_single_fare_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.uncapped_single_fare": {"2025": 4}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.uncapped_single_fare has been removed" in message + assert "gov.dft.bus.uncapped_single_fares" in message + assert "local_bus_uncapped_single_fare" in message diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index e1614e9c38..1fd116473e 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -46,6 +46,163 @@ def bus_dataset(region="SOUTH_EAST", journeys=True, zero=False): ) +def projected_dataset( + region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100, 20), fares=None +): + """A 2024 dataset that Simulation extends to later years, so journeys are + supplied in each year. A UKMultiYearDataset supplies only its own years.""" + person = pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 1], + "person_household_id": [1, 1], + "age": [30, 30], + "bus_in_london_trips": list(london_trips), + "other_local_bus_trips": list(other_trips), + "bus_pass_eligible": [False, False], + "local_bus_single_fare_share": [0.7, 0.7], + } + ) + if fares is not None: + person["local_bus_uncapped_single_fare"] = list(fares) + household = pd.DataFrame( + { + "household_id": [1], + "region": [region], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [1_500.0], + "rent": [0.0], + "household_weight": [1.0], + "bus_fare_spending": [999.0], + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=household, + fiscal_year=2024, + ) + + +def test_england_fares_follow_each_area_index(): + # Trips set so 2024-25 spending is in the ratio of published receipts + # (BUS05ai: London GBP 1,347.43m, England outside London GBP 2,069.95m). + london = 1_347.434943 / (2.369144880275 * 1.017729905895) + outside = 2_069.953713 / (1.12466920264 * 1.554716967657) + simulation = Simulation( + dataset=projected_dataset(london_trips=(london, 0), other_trips=(0, outside)) + ) + total = { + year: simulation.calculate("bus_fare_spending", year)[0] + for year in (2024, 2025) + } + assert total[2024] == pytest.approx(1_347.434943 + 2_069.953713, rel=1e-5) + # Each area moves with its own BUS0415a index, FY2025-26 over FY2024-25: + # London 196.1017 / 196.1 and England outside London 214.1797 / 190.2282. + assert total[2025] == pytest.approx( + 1_347.434943 * 1.0000086 + 2_069.953713 * 1.1259092, rel=1e-5 + ) + # So England rises 7.6%, more than DfT's England index (205.616 / 193.297, + # +6.4%). Its December 2024 to March 2025 step is 5.6%, against 11.0% + # outside London and none in London, so it weights outside London at about + # one half where receipts weight it at 61%. + assert total[2025] / total[2024] == pytest.approx(1.0763, abs=1e-4) + + +# 100 trips by one passenger outside London: 112.47 boardings, 70% singles. +SINGLES = 100 * 1.12466920264 * 0.7 + + +def spending_2025(**parameter_changes): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0)), + scenario=Scenario( + parameter_changes={ + path: {2025: value} for path, value in parameter_changes.items() + } + ), + ) + return simulation.calculate("person_bus_fare_spending", 2025)[0] + + +def test_a_three_pound_cap_binds_and_cap_reforms_follow_the_table(): + cap = "gov.dft.bus.fares.cap" + baseline = spending_2025() + uncapped = spending_2025(**{cap: np.inf}) + two_pounds = spending_2025(**{cap: 2}) + assert uncapped > baseline > two_pounds + # FY2025-26 fares are the FY2024-25 table times CPI (1.034). Only the 15% + # at GBP 3.87 is above GBP 3; a GBP 2 cap also takes the 30% at GBP 2.21 + # down to GBP 2. + assert uncapped - baseline == pytest.approx( + SINGLES * 0.15 * (3.87 * 1.034 - 3), rel=1e-3 + ) + assert baseline - two_pounds == pytest.approx( + SINGLES * (0.30 * (2.21 * 1.034 - 2) + 0.15 * 1), rel=1e-3 + ) + + +def test_scheduled_caps_enter_the_projected_baseline(): + simulation = Simulation(dataset=projected_dataset(other_trips=(100, 0))) + bus = system.parameters.gov.dft.bus + index = bus.local_bus_fares_index.ENGLAND_OUTSIDE_LONDON + table = bus.uncapped_single_fares.fare + + def index_path(year): + # The fares index projected with CPI, which holds the GBP 3 cap. + ratio = index(f"{year}-01-01") / index("2024-01-01") + return 100 * 1.12466920264 * 1.554716967657 * ratio + + def fare(band, year): + return table.children[band](f"{year}-01-01") + + def spending(year): + return simulation.calculate("person_bus_fare_spending", year)[0] + + # 2026-27: GBP 2 for the last 95 days, from 1 January 2027. + middle = fare("MIDDLE", 2026) + change = -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 95 / 365 + assert spending(2026) == pytest.approx( + index_path(2026) + SINGLES * change, rel=1e-5 + ) + # 2027-28 runs to 5 April 2028, 366 days: GBP 2 for 270, then no cap + # from 1 January 2028 for 96. + middle, high = fare("MIDDLE", 2027), fare("HIGH", 2027) + change = ( + -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 270 + 0.15 * (high - 3) * 96 + ) / 366 + assert spending(2027) == pytest.approx( + index_path(2027) + SINGLES * change, rel=1e-5 + ) + # From 2028-29 there is no cap, so the 15% above GBP 3 pay in full. + change = 0.15 * (fare("HIGH", 2028) - 3) + assert spending(2028) == pytest.approx( + index_path(2028) + SINGLES * change, rel=1e-5 + ) + + +def test_a_supplied_uncapped_fare_replaces_the_table(): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0), fares=(4, 4)), + scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2025: 2}}), + ) + # The dataset's fare rises with CPI like other prices (2025: +3.4%). + assert simulation.calculate("local_bus_uncapped_single_fare", 2025)[ + 0 + ] == pytest.approx(4 * 1.034) + # Every single ticket costs GBP 4.14, so a GBP 2 cap in place of GBP 3 + # saves GBP 1 on each, against GBP 0.24 on average with the table. + reformed = simulation.calculate("person_bus_fare_spending", 2025)[0] + baseline = simulation.baseline.calculate("person_bus_fare_spending", 2025)[0] + assert baseline == pytest.approx(spending_2025(), rel=1e-6) + assert baseline - reformed == pytest.approx(SINGLES * 1, rel=1e-5) + + +def test_uncapped_fare_shares_must_sum_to_one(): + with pytest.raises(ValueError, match="sum to one"): + spending_2025(**{"gov.dft.bus.uncapped_single_fares.share.HIGH": 0.5}) + + def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): simulation = Simulation( dataset=bus_dataset(), @@ -55,7 +212,10 @@ def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): k = 1_850_509_887.26131 / (28.0685543170643 * 58_620_101) fare_paying = 1_850_509_887.26131 - 519_107_608.59444 benchmark = 2_069_953_713.42079 / fare_paying - uncapped_yield = benchmark + 515_693_216.6 / fare_paying + # In 2024-25 the cap was GBP 2 for 270 days and GBP 3 for 95. Without it, + # the 30% of singles at GBP 2.21 and the 15% at GBP 3.87 pay in full. + saving = 0.30 * 0.21 * 270 / 365 + 0.15 * (1.87 * 270 / 365 + 0.87 * 95 / 365) + uncapped_yield = benchmark + 0.7 * saving baseline_people = np.array([100, 20]) * k * benchmark reformed_people = np.array([100, 20]) * k * uncapped_yield assert simulation.baseline.calculate( @@ -140,6 +300,25 @@ def test_caps_apply_before_annual_averaging_and_preserve_january_changes(): ) +def test_a_cap_change_part_way_through_a_year_keeps_its_date(): + simulation = Simulation( + dataset=bus_dataset(), + scenario=Scenario( + parameter_changes={"gov.dft.bus.fares.cap": {"2026-10-01.2100-12-31": 1}} + ), + ) + bus = simulation.tax_benefit_system.parameters.gov.dft.bus + assert bus.fares.cap.metadata["preserve_calendar_dates"] + assert bus.reference_fares.cap.metadata["preserve_calendar_dates"] + assert bus.fares("2026-09-30").cap == 3 + assert bus.fares("2026-10-01").cap == 1 + # 178 days at £3 from 6 April, then 187 at £1. Annualised, the change + # would have waited for the 2027 calendar year. + assert annual_capped_fare(4, bus.fares, 2026) == pytest.approx( + (178 * 3 + 187 * 1) / 365 + ) + + def test_clone_does_not_lose_journey_input_provenance(): original = Simulation(dataset=bus_dataset()) expected = original.calculate("bus_fare_spending", 2024)[0] diff --git a/policyengine_uk/utils/bus_fares.py b/policyengine_uk/utils/bus_fares.py index f52a6cfc7e..06e3f0c0f6 100644 --- a/policyengine_uk/utils/bus_fares.py +++ b/policyengine_uk/utils/bus_fares.py @@ -19,3 +19,17 @@ def annual_capped_fare(uncapped, schedule, year): np.minimum(uncapped, max(rates.cap, 0)) * share for rates, share in fiscal_year_segments(schedule, year) ) + + +def single_fare_cap_change(fares, shares, schedule, reference, year): + """Mean change in the price of a single ticket over the fiscal year when + the cap follows ``schedule`` instead of ``reference``. Each uncapped fare + is capped under both and weighted by its share of single tickets.""" + return sum( + share + * ( + annual_capped_fare(fare, schedule, year) + - annual_capped_fare(fare, reference, year) + ) + for fare, share in zip(fares, shares) + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 66f93aa66a..7410e59535 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -186,6 +186,21 @@ class RemovedParameter(NamedTuple): "income that is not qualifying income for Savings Credit (SPC Regs " "2002 reg 9), instead.", ), + "gov.dft.bus.reference_year": RemovedParameter( + "removed bus fare reference year", + "Bus fares now follow gov.dft.bus.local_bus_fares_index from the " + "benchmark year gov.dft.bus.benchmark_year, and the cap term compares " + "gov.dft.bus.fares.cap with gov.dft.bus.reference_fares.cap in the same " + "year. Change gov.dft.bus.fares.cap to model a cap.", + ), + "gov.dft.bus.uncapped_single_fare": RemovedParameter( + "removed single uncapped bus fare", + "Cap changes are priced from a table of uncapped single fares, " + "gov.dft.bus.uncapped_single_fares, with the share of single tickets " + "and the mean fare of each band (LOW, MIDDLE, HIGH). To price every " + "single ticket at one fare, set that band's share to 1 and the others " + "to 0, or supply local_bus_uncapped_single_fare for each person.", + ), } diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index a6a7600e6c..4007c9cbb6 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -44,6 +44,7 @@ "bus_in_london_trips", "other_local_bus_trips", "local_bus_trips", + "local_bus_uncapped_single_fare", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", diff --git a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py index 36a43e8967..0ffd93b844 100644 --- a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py +++ b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py @@ -3,11 +3,14 @@ class local_bus_uncapped_single_fare(Variable): label = "local bus uncapped single fare" - documentation = "Representative uncapped single fare for other-local-bus journeys, in pounds. Override to model a known fare; the default is an aggregate no-demand-response counterfactual." + documentation = ( + "Optional uncapped single fare for a person's other-local-bus journeys, " + "in pounds. When supplied, the person's single tickets are capped at " + "this one fare; otherwise the model uses the table of uncapped single " + "fares in gov.dft.bus.uncapped_single_fares. Where nothing is supplied " + "it reads 0, which the model does not use as a fare." + ) entity = Person definition_period = YEAR value_type = float unit = GBP - - def formula(person, period, parameters): - return parameters(period).gov.dft.bus.uncapped_single_fare diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py index 208e33512b..34ecb063d8 100644 --- a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -1,15 +1,16 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.bus_fares import annual_capped_fare, has_bus_journeys +from policyengine_uk.utils.bus_fares import has_bus_journeys, single_fare_cap_change from policyengine_uk.utils.supplied_inputs import supplied_input class person_bus_fare_spending(Variable): label = "personal bus and coach fare spending" documentation = ( - "Fare-paying bus journeys priced at the published yield, with changes " - "in the single-fare cap applied only to single-ticket boardings in " - "England outside London. Without usable journey data, allocates " - "reported household spending by age. See docs/engineering/bus-fares.md." + "Fare-paying bus journeys priced at the published yield, moved by each " + "area's published fares index, with changes in the single-fare cap " + "applied only to single-ticket boardings in England outside London. " + "Without usable journey data, allocates reported household spending " + "by age. See docs/engineering/bus-fares.md." ) entity = Person definition_period = YEAR @@ -56,28 +57,61 @@ def formula(person, period, parameters): [p.boardings_per_trip.SCOTLAND, p.boardings_per_trip.NORTHERN_IRELAND], default=p.boardings_per_trip.ENGLAND_OUTSIDE_LONDON, ) + # Each area's benchmark yield moves with its published fares index. + # Northern Ireland has no index, so its yield moves with CPI. + benchmark = parameters(f"{int(p.benchmark_year)}-01-01").gov + index = p.local_bus_fares_index + benchmark_index = benchmark.dft.bus.local_bus_fares_index + cpi_growth = ( + parameters(period).gov.economic_assumptions.indices.obr.consumer_price_index + / benchmark.economic_assumptions.indices.obr.consumer_price_index + ) + london_yield = ( + p.yield_per_boarding.LONDON * index.LONDON / benchmark_index.LONDON + ) other_yield = select( [in_scotland, in_ni], - [p.yield_per_boarding.SCOTLAND, p.yield_per_boarding.NORTHERN_IRELAND], - default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON, + [ + p.yield_per_boarding.SCOTLAND + * index.SCOTLAND + / benchmark_index.SCOTLAND, + p.yield_per_boarding.NORTHERN_IRELAND * cpi_growth, + ], + default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON + * index.ENGLAND_OUTSIDE_LONDON + / benchmark_index.ENGLAND_OUTSIDE_LONDON, ) - uncapped = max_(person("local_bus_uncapped_single_fare", period), 0) - current_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.fares, period.start.year - ) - reference_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.reference_fares, int(p.reference_year) + # The index already reflects the cap in reference_fares. Add the + # effect of the cap differing from it in the same year: scheduled + # changes after the last published quarter, and reforms. Single + # tickets cost the person's supplied uncapped fare, or else follow + # the table of uncapped single fares. + supplied_fare = supplied_input(person, "local_bus_uncapped_single_fare", period) + if supplied_fare is None: + bands = p.uncapped_single_fares + shares = [bands.share.LOW, bands.share.MIDDLE, bands.share.HIGH] + if min(shares) < 0 or abs(sum(shares) - 1) > 1e-6: + raise ValueError( + "The shares in gov.dft.bus.uncapped_single_fares.share must " + "be non-negative and sum to one" + ) + fares = [bands.fare.LOW, bands.fare.MIDDLE, bands.fare.HIGH] + else: + shares, fares = [1], [supplied_fare] + cap_change = single_fare_cap_change( + [max_(fare, 0) for fare in fares], + shares, + parameters.gov.dft.bus.fares, + parameters.gov.dft.bus.reference_fares, + period.start.year, ) single_share = clip(person("local_bus_single_fare_share", period), 0, 1) other_yield = max_( - other_yield - + in_england * single_share * (current_single - reference_single), + other_yield + in_england * single_share * cap_change, 0, ) spending = ( - max_(london_trips, 0) - * p.boardings_per_trip.LONDON - * p.yield_per_boarding.LONDON + max_(london_trips, 0) * p.boardings_per_trip.LONDON * london_yield + max_(other_trips, 0) * other_boardings * other_yield ) spending *= ( diff --git a/scripts/fit_bus_cap_savings.py b/scripts/fit_bus_cap_savings.py new file mode 100644 index 0000000000..97e7706c2d --- /dev/null +++ b/scripts/fit_bus_cap_savings.py @@ -0,0 +1,161 @@ +"""Fit the table of uncapped single bus fares to public facts. + +Prints the three-band table for gov.dft.bus.uncapped_single_fares (the share +of single tickets in each band and its mean uncapped fare, at FY2024-25 +prices) that best matches the facts below, and the value of each fact under +it. Fares move between years with the engine's OBR CPI index, as the +parameter's uprating does. See docs/engineering/bus-fares.md. + +Run with ``uv run python scripts/fit_bus_cap_savings.py``. +""" + +import numpy as np + +from policyengine_uk.system import system + +SINGLE_SHARE = 0.70 # gov.dft.bus.default_single_fare_share +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 +# The benchmark yield at the December 2024 BUS0415a level, just before the +# January 2025 step: 1.554716967657 * 185.9 / 190.2282. +YIELD_DECEMBER_2024 = 1.554716967657 * 185.9 / 190.2282 + +# Each fact: target and tolerance, relative (a share of the target) or absolute. +FACTS = { + # BUS05bi fare cap support, 2023-24, a full year at GBP 2 (GBP m). + "grant_2023_24": (278.951028, 0.10, "relative"), + # Part of the December 2024 to March 2025 BUS0415a step outside London + # (+11.0%; neighbouring quarters moved 0.1% to 2.3%) due to the cap rising + # from GBP 2 to GBP 3, as a share of the yield. + "january_2025_step": (0.10, 0.01, "absolute"), + # Funding of "over GBP 150 million" for the GBP 3 cap in 2025 (GBP m). + "cap_3_in_2025": (150, 0.20, "relative"), + # GBP 400 million backing the GBP 2 cap in 2027 (GBP m), read as GBP 2 + # against no cap: the GBP 3 scheme is funded only to December 2026 and the + # GBP 2 scheme replaces it. Read as GBP 2 against a continued GBP 3 cap, the + # fitted table gives about GBP 247m, 38% below (see UNFITTED below). + "cap_2_in_2027": (400, 0.20, "relative"), + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap; those above GBP 2 averaged GBP 2.73, + # and GBP 0.32 / (2.73 - 2.00) puts their share near 0.44. Used for shape, + # at face value. + "mean_single_fare": (2.15, 0.10, "absolute"), + "share_above_2": (0.44, 0.05, "absolute"), + "mean_above_2": (2.73, 0.10, "absolute"), +} + + +# Published figures the fit reports but does not target (GBP m). +UNFITTED = { + # BUS05bi fare cap support for 2024-25, the benchmark year: GBP 2 for 270 + # days, then GBP 3. Grant per fare-paying boarding rose 78% on 2023-24 + # while fares rose 3% to 7% and the cap rose for the last quarter, so the + # figure may hold more than that year's forgone fares (timing or coverage + # of payments, not checked). The 2023-24 grant and the two announced + # figures all put a full year at GBP 2 near GBP 0.3bn, not GBP 0.6bn. + "grant_2024_25": 515.693217, + # The GBP 400m read the other way, as GBP 2 against a continued GBP 3 cap. + "cap_2_against_3_in_2027": 400, +} + + +def price_ratios(): + cpi = system.parameters.gov.economic_assumptions.indices.obr.consumer_price_index + base = cpi("2024-01-01") + return {year: cpi(f"{year}-01-01") / base for year in (2023, 2025, 2027)} + + +def unfitted(shares, fares, ratios): + in_force_2024_25 = sum( + s * (f - (270 * min(f, 2) + 95 * min(f, 3)) / 365) + for s, f in zip(shares, fares) + ) + return { + "grant_2024_25": SINGLE_SHARE * FARE_PAYING_2024_25 / 1e6 * in_force_2024_25, + "cap_2_against_3_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * ( + saving(shares, fares, 2, ratios[2027]) + - saving(shares, fares, 3, ratios[2027]) + ), + } + + +def saving(shares, fares, cap, ratio=1.0): + """Mean saving per single ticket from a cap: sum of share * (fare - cap)+.""" + return sum(s * np.maximum(ratio * f - cap, 0) for s, f in zip(shares, fares)) + + +def facts(shares, fares, ratios): + low, middle, high = shares + above = middle + high + return { + "grant_2023_24": SINGLE_SHARE + * FARE_PAYING_2023_24 + / 1e6 + * saving(shares, fares, 2, ratios[2023]), + "january_2025_step": SINGLE_SHARE + * (saving(shares, fares, 2) - saving(shares, fares, 3)) + / YIELD_DECEMBER_2024, + "cap_3_in_2025": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 3, ratios[2025]), + "cap_2_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 2, ratios[2027]), + "mean_single_fare": sum(s * f for s, f in zip(shares, fares)), + "share_above_2": above, + "mean_above_2": (middle * fares[1] + high * fares[2]) / above, + } + + +def loss(values): + total = 0 + for name, (target, tolerance, kind) in FACTS.items(): + gap = values[name] / target - 1 if kind == "relative" else values[name] - target + total = total + (gap / tolerance) ** 2 + return total + + +def fit(): + """Grid search at one point of share and one penny of fare. Each band's + mean sits at least 10% above its lower edge (GBP 2.20 and GBP 3.30), and + the low band's fare closes the Table 6 mean of GBP 2.15.""" + ratios = price_ratios() + middle_fare, high_fare = np.meshgrid( + np.arange(220, 300) / 100, np.arange(330, 600) / 100, indexing="ij" + ) + best = (np.inf, None) + for middle in np.arange(10, 61) / 100: + for high in np.arange(2, 41) / 100: + low = round(1 - middle - high, 2) + if low <= 0: + continue + low_fare = (2.15 - middle * middle_fare - high * high_fare) / low + low_fare = np.round(low_fare, 2) + valid = (low_fare > 0) & (low_fare <= 2) + shares = (low, middle, high) + values = facts(shares, (low_fare, middle_fare, high_fare), ratios) + score = np.where(valid, loss(values), np.inf) + index = np.unravel_index(np.argmin(score), score.shape) + if score[index] < best[0]: + fares = (low_fare[index], middle_fare[index], high_fare[index]) + best = (score[index], (shares, fares)) + return best + + +if __name__ == "__main__": + score, (shares, fares) = fit() + print(f"loss {score:.3f}") + for band, share, fare in zip(("LOW", "MIDDLE", "HIGH"), shares, fares): + print(f"{band:7s} share {share:.2f} fare GBP {fare:.2f}") + values = facts(shares, fares, price_ratios()) + for name, (target, tolerance, kind) in FACTS.items(): + print(f"{name:18s} {values[name]:9.4f} target {target}") + checks = unfitted(shares, fares, price_ratios()) + for name, published in UNFITTED.items(): + print(f"{name:24s} {checks[name]:9.4f} published {published}, not fitted")