diff --git a/changelog.d/2640.fixed.md b/changelog.d/2640.fixed.md new file mode 100644 index 0000000000..d629724541 --- /dev/null +++ b/changelog.d/2640.fixed.md @@ -0,0 +1 @@ +Corrected England outside London's bus fare yield to exclude only older and disabled concessionary journeys, and refitted the cap-savings table using the same priced boardings. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index cda8b82730..68f81df4fa 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -43,17 +43,17 @@ household output explicitly. ## Published price benchmark -Microcosm prices journeys using FY2024–25 published facts. The model uses the -same inputs. For each area, receipts `R`, boardings `B`, concessionary -boardings `C`, annual trips per resident `T` and population `P` give: +The model uses FY2024–25 published facts. For each area, receipts `R`, +boardings `B`, concessionary boardings `C`, annual trips per resident `T` and +population `P` give: -- Fare-paying yield per boarding: `y = R / (B - C)`. +- Yield per priced boarding: `y = R / (B - C)`. - Boardings per resident trip: `k = B / (T * P)`. | Area | R (£) | B | C | T | P | y (£) | k | |---|---:|---:|---:|---:|---:|---:|---:| | London | 1,347,434,943.01459 | 1,821,485,000 | 497,523,765.08908 | 13.1155774642333 | 58,620,101 | 1.017729905895 | 2.369144880275 | -| England outside London | 2,069,953,713.42079 | 1,850,509,887.26131 | 519,107,608.59444 | 28.0685543170643 | 58,620,101 | 1.554716967657 | 1.124669202640 | +| England outside London | 2,069,953,713.42079 | 1,850,509,887.26131 | 402,846,350.14539 | 28.0685543170643 | 58,620,101 | 1.429858292587 | 1.124669202640 | | Scotland | 391,000,000 | 334,000,000 | 183,599,374.01433 | 28.0685543170643 | 5,546,900 | 2.599723222144 | 2.145240985363 | | Northern Ireland | 150,082,817.49 | 67,794,551.35 | 8,960,000 | 28.0685543170643 | 1,927,855 | 2.550929922065 | 1.252853758464 | @@ -69,6 +69,26 @@ 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. +Outside London, `C` uses BUS01c's older-and-disabled concessionary journeys. +The other concessions remain in the priced denominator, so the yield absorbs +their discounts. BUS01c's total would also remove 116.3 million youth-scheme +journeys (BUS01c note 4) that the dataset's `bus_pass_eligible` flag does not +generally make free. The older-and-disabled count approximates that flag: +Microcosm flags older-person eligibility and under-5s outside London, but does +not flag disabled pass holders. London uses total concessionary journeys; +Scotland and Northern Ireland retain their own counts. + +Charging disabled pass holders raises modelled spending; making under-5s free +lowers it. [Microcosm issue 1385](https://github.com/PolicyEngine/microcosm/issues/1385) +reports that this denominator change moves outside-London fares from 6.8% +above BUS05ai to about 1.8% below it on the certified build +`microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`, using its existing +weights and model year 2024. + +Microcosm's fact selectors and model lock must use the same concessionary +definition as this benchmark. The companion update follows the model release +and is tracked in [Microcosm issue 1385](https://github.com/PolicyEngine/microcosm/issues/1385). + ## Fares by year In other years each area's yield moves with its published fares index: @@ -97,7 +117,7 @@ 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. +by 0.4% in 2027–28 and 0.7% 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. @@ -180,9 +200,9 @@ 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 | +| `LOW`, at or below £2 | 0.56 | £1.70 | +| `MIDDLE`, above £2 and up to £3 | 0.31 | £2.21 | +| `HIGH`, above £3 | 0.13 | £3.96 | 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 @@ -198,44 +218,43 @@ 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 | +| A full year at £2, 2023–24 | Fare cap support, DfT BUS05bi | £278.95m ± 10% | £282.8m | | 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 | +| The £3 cap in 2025 | ["over £150 million"](https://www.gov.uk/government/speeches/national-bus-fare-cap) | £150m ± 20% | £144.2m | +| The £2 cap in 2027, against no cap | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £420.2m | | 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 | +| Share of single tickets above £2 | Table 6: £0.32 / (£2.73 − £2.00) | 0.44 ± 0.05 | 0.44 | +| Their mean pre-cap price | Table 6 | £2.73 ± £0.10 | £2.73 | +| 2024–25: £2 for 270 days, then £3 | Fare cap support, DfT BUS05bi | £515.69m, not fitted | £272.7m | 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. +£3 cap, the table gives £252.5m, 37% 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 +the table puts it at about half. Grant per priced boarding rose 77.5% on +2023–24, while the outside-London index rose 6.7%, receipts per priced +boarding rose 2.6%, 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. +The costs use the 0.70 default single share and priced boardings outside +London (BUS01a journeys less BUS01c older-and-disabled concessionary journeys: +1,390m in 2023–24 and 1,448m 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 40% 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. +At FY2025–26 prices the table saves £0.36 per single ticket for a full year at +£2 against no cap, and £0.14 at £3: about £0.37bn and £0.14bn a year at +2024–25 boardings. 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 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 index 9b87a44996..2bd7dce827 100644 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml @@ -22,8 +22,8 @@ metadata: - 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 + '2024-01-01': 1.7 MIDDLE: '2024-01-01': 2.21 HIGH: - '2024-01-01': 3.87 + '2024-01-01': 3.96 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 index 20cdc48e8c..577dd6664e 100644 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml @@ -21,8 +21,8 @@ metadata: - 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 + '2024-01-01': 0.56 MIDDLE: - '2024-01-01': 0.3 + '2024-01-01': 0.31 HIGH: - '2024-01-01': 0.15 + '2024-01-01': 0.13 diff --git a/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml b/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml index 99a47ec245..bfcde214cb 100644 --- a/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml @@ -1,13 +1,19 @@ -description: Passenger receipts divided by fare-paying boardings in 2024-25, including - single and season tickets. This is a benchmark yield, before subsequent cap changes and - explicit fare-index reforms; held nominally constant unless reformed. +description: Passenger receipts divided by priced boardings in 2024-25, including + single and season tickets. England outside London excludes only older and disabled + concessionary boardings, leaving other concessions in the priced denominator to match + the dataset's bus_pass_eligible flag approximately. This is a benchmark yield before + subsequent fare-index and cap changes. metadata: unit: currency-GBP period: year label: bus yield per boarding reference: - - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + - title: DfT bus statistics, BUS01a journeys, BUS01c older and disabled concessions, and BUS05ai receipts, 2024-25 href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT BUS01a journeys and BUS01c concessions, March 2026 revised release + href: https://assets.publishing.service.gov.uk/media/69c11f97cfa346b9d4704a50/bus01.ods + - title: DfT BUS05ai passenger fare receipts, March 2026 revised release + href: https://assets.publishing.service.gov.uk/media/69c11f97b920af63be1c7631/bus05i.ods - title: DfT National Travel Survey, NTS0705a, 2024 href: https://www.gov.uk/government/statistical-data-sets/nts07-car-ownership-and-access - title: Scottish Transport Statistics 2025, tables 2.2a and 2.8 @@ -19,7 +25,7 @@ metadata: LONDON: '2024-01-01': 1.017729905895 ENGLAND_OUTSIDE_LONDON: - '2024-01-01': 1.554716967657 + '2024-01-01': 1.429858292587 SCOTLAND: '2024-01-01': 2.599723222144 NORTHERN_IRELAND: 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 791dba5f6f..ad1f35619d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -142,8 +142,8 @@ 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 + # 100 trips * 1.12466920264 boardings * GBP 1.429858292587. + person_bus_fare_spending: 160.81 - name: Case 10, the 2025-26 yield follows the fares index, not a cap term. period: 2025 @@ -154,9 +154,9 @@ 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 + # 160.8118 * 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 + person_bus_fare_spending: 181.06 - name: Case 11, London fares follow the flat London index. period: 2025 @@ -196,11 +196,11 @@ 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 +# gov.dft.bus.uncapped_single_fares: 56% of singles at GBP 1.70, 31% at +# GBP 2.21 and 13% at GBP 3.96 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. +# a GBP 181.06 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 @@ -211,9 +211,9 @@ 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 + # Only the high band (3.96 * 1.0340 = 4.0946) is above GBP 3: + # 181.0594 + 78.7268 * 0.13 * 1.0946. + person_bus_fare_spending: 192.26 - name: Case 15, a GBP 2 cap in 2025-26 saves the middle and high bands. period: 2025 @@ -226,5 +226,22 @@ 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 + # from 3 to 2: 181.0594 - 78.7268 * (0.31 * 0.2851 + 0.13 * 1). + person_bus_fare_spending: 163.87 + +- name: Case 16, paying youth journeys remain priced and free-pass journeys cost nothing. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: {age: 12, other_local_bus_trips: 100, bus_pass_eligible: false} + person2: {age: 30, other_local_bus_trips: 100, bus_pass_eligible: false} + person3: {age: 70, other_local_bus_trips: 100, bus_pass_eligible: true} + households: + household: {members: [person1, person2, person3], region: SOUTH_EAST} + output: + # BUS05ai receipts / (BUS01a total - BUS01c older/disabled boardings) + # = GBP 1.429858292587. Each paying passenger: 100 * 1.12466920264 + # boardings per trip * 1.429858292587 = GBP 160.8118. + person_bus_fare_spending: [160.81, 160.81, 0] + bus_fare_spending: 321.62 diff --git a/policyengine_uk/tests/test_bus_fare_parameters.py b/policyengine_uk/tests/test_bus_fare_parameters.py index 511082de76..f21de88211 100644 --- a/policyengine_uk/tests/test_bus_fare_parameters.py +++ b/policyengine_uk/tests/test_bus_fare_parameters.py @@ -1,6 +1,7 @@ """Bus fare parameters agree with each other and with the facts they encode.""" import datetime +import runpy from pathlib import Path import pytest @@ -52,9 +53,35 @@ def test_reference_cap_is_the_cap_the_published_index_reflects(): 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 +# DfT BUS01a journeys less BUS01c older/disabled journeys, England outside +# London. Other concessions remain in priced boardings, with their discounts +# reflected in the average yield. +# https://assets.publishing.service.gov.uk/media/69c11f97cfa346b9d4704a50/bus01.ods +PRICED_BOARDINGS_2023_24 = 1_780_912_900.01636 - 391_017_149.37439 +PRICED_BOARDINGS_2024_25 = 1_850_509_887.26131 - 402_846_350.14539 + + +def test_outside_london_yield_uses_older_disabled_concessions(): + # BUS05ai FY2024-25 receipts divided by BUS01a boardings less only the + # older/disabled BUS01c journeys. Subtracting every concessionary journey + # also excludes other concessions and inflates this yield. + yield_per_boarding = system.parameters.gov.dft.bus.yield_per_boarding + assert yield_per_boarding.ENGLAND_OUTSIDE_LONDON("2024-01-01") == pytest.approx( + 2_069_953_713.42079 / PRICED_BOARDINGS_2024_25, abs=1e-12 + ) + + +@pytest.mark.parametrize( + "area, expected", + [ + ("LONDON", 1.017729905895), + ("SCOTLAND", 2.599723222144), + ("NORTHERN_IRELAND", 2.550929922065), + ], +) +def test_other_area_yields_are_unchanged(area, expected): + yield_per_boarding = system.parameters.gov.dft.bus.yield_per_boarding + assert yield_per_boarding.children[area]("2024-01-01") == expected def cpi(year): @@ -109,18 +136,18 @@ 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 + # index level (1.429858292587 * 185.9 / 190.2282). + december_yield = 1.429858292587 * 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. + # over 1,390m priced boardings. Fares at 2023-24 prices. grant = ( SINGLE_SHARE - * FARE_PAYING_2023_24 + * PRICED_BOARDINGS_2023_24 * saving(2, price_ratio=cpi(2023) / cpi(2024)) ) assert grant == pytest.approx(278_951_028, rel=0.15) @@ -129,9 +156,9 @@ def test_table_matches_2023_24_fare_cap_grant(): 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) + # at the 2024-25 number of priced boardings. + cap_3 = SINGLE_SHARE * PRICED_BOARDINGS_2024_25 * saving(3, year=2025) + cap_2 = SINGLE_SHARE * PRICED_BOARDINGS_2024_25 * saving(2, year=2027) assert cap_3 == pytest.approx(150e6, rel=0.20) assert cap_2 == pytest.approx(400e6, rel=0.20) @@ -162,3 +189,15 @@ def test_removed_uncapped_single_fare_gives_directions(): 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 + + +def test_uncapped_table_matches_fit(pytestconfig): + # The fit uses published DfT facts and the current OBR CPI path. Changes + # to those inputs require refitting both shares and fares. + # CI tests installed wheels from the checkout's pyproject.toml too. + script = pytestconfig.inipath.parent / "scripts" / "fit_bus_cap_savings.py" + fit = runpy.run_path(str(script))["fit"] + _, (shares, fares) = fit() + for (share, fare), fitted_share, fitted_fare in zip(bands(), shares, fares): + assert share == pytest.approx(fitted_share, abs=1e-12) + assert fare == pytest.approx(fitted_fare, abs=1e-12) diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index 1fd116473e..8baff05763 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -88,7 +88,7 @@ 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) + outside = 2_069.953713 / (1.12466920264 * 1.429858292587) simulation = Simulation( dataset=projected_dataset(london_trips=(london, 0), other_trips=(0, outside)) ) @@ -131,14 +131,14 @@ def test_a_three_pound_cap_binds_and_cap_reforms_follow_the_table(): 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 + # FY2025-26 fares are the FY2024-25 table times CPI (1.034). Only the 13% + # at GBP 3.96 is above GBP 3; a GBP 2 cap also takes the 31% at GBP 2.21 # down to GBP 2. assert uncapped - baseline == pytest.approx( - SINGLES * 0.15 * (3.87 * 1.034 - 3), rel=1e-3 + SINGLES * 0.13 * (3.96 * 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 + SINGLES * (0.31 * (2.21 * 1.034 - 2) + 0.13 * 1), rel=1e-3 ) @@ -151,7 +151,7 @@ def test_scheduled_caps_enter_the_projected_baseline(): 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 + return 100 * 1.12466920264 * 1.429858292587 * ratio def fare(band, year): return table.children[band](f"{year}-01-01") @@ -161,7 +161,7 @@ def spending(year): # 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 + change = -(0.31 * (middle - 2) + 0.13 * (3 - 2)) * 95 / 365 assert spending(2026) == pytest.approx( index_path(2026) + SINGLES * change, rel=1e-5 ) @@ -169,13 +169,13 @@ def spending(year): # 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 + -(0.31 * (middle - 2) + 0.13 * (3 - 2)) * 270 + 0.13 * (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) + # From 2028-29 there is no cap, so the 13% above GBP 3 pay in full. + change = 0.13 * (fare("HIGH", 2028) - 3) assert spending(2028) == pytest.approx( index_path(2028) + SINGLES * change, rel=1e-5 ) @@ -191,7 +191,7 @@ def test_a_supplied_uncapped_fare_replaces_the_table(): 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. + # saves GBP 1 on each, against GBP 0.22 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) @@ -208,13 +208,13 @@ def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): dataset=bus_dataset(), scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2024: np.inf}}), ) - # Published 2024-25 receipts / fare-paying boardings, translated from trips. + # Published 2024-25 receipts / priced boardings, translated from trips. 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 + priced_boardings = 1_850_509_887.26131 - 402_846_350.14539 + benchmark = 2_069_953_713.42079 / priced_boardings # 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) + # the 31% of singles at GBP 2.21 and the 13% at GBP 3.96 pay in full. + saving = 0.31 * 0.21 * 270 / 365 + 0.13 * (1.96 * 270 / 365 + 0.96 * 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 diff --git a/scripts/fit_bus_cap_savings.py b/scripts/fit_bus_cap_savings.py index 97e7706c2d..381dd4acfe 100644 --- a/scripts/fit_bus_cap_savings.py +++ b/scripts/fit_bus_cap_savings.py @@ -14,12 +14,19 @@ 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 +# DfT BUS01a journeys less BUS01c older and disabled concessionary journeys, +# England outside London: the same concession definition as the benchmark yield. +# https://assets.publishing.service.gov.uk/media/69c11f97cfa346b9d4704a50/bus01.ods +PRICED_BOARDINGS_2023_24 = 1_780_912_900.01636 - 391_017_149.37439 +PRICED_BOARDINGS_2024_25 = 1_850_509_887.26131 - 402_846_350.14539 # 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 +# January 2025 step. Read the model's benchmark so the fit uses the same yield. +BENCHMARK = system.parameters.gov.dft.bus("2024-01-01") +YIELD_DECEMBER_2024 = ( + BENCHMARK.yield_per_boarding.ENGLAND_OUTSIDE_LONDON + * 185.9 + / BENCHMARK.local_bus_fares_index.ENGLAND_OUTSIDE_LONDON +) # Each fact: target and tolerance, relative (a share of the target) or absolute. FACTS = { @@ -34,7 +41,7 @@ # 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). + # fitted table gives about GBP 253m, 37% 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, @@ -49,7 +56,7 @@ # 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 + # days, then GBP 3. Grant per priced boarding rose 77.5% 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 @@ -72,9 +79,12 @@ def unfitted(shares, fares, ratios): for s, f in zip(shares, fares) ) return { - "grant_2024_25": SINGLE_SHARE * FARE_PAYING_2024_25 / 1e6 * in_force_2024_25, + "grant_2024_25": SINGLE_SHARE + * PRICED_BOARDINGS_2024_25 + / 1e6 + * in_force_2024_25, "cap_2_against_3_in_2027": SINGLE_SHARE - * FARE_PAYING_2024_25 + * PRICED_BOARDINGS_2024_25 / 1e6 * ( saving(shares, fares, 2, ratios[2027]) @@ -93,18 +103,18 @@ def facts(shares, fares, ratios): above = middle + high return { "grant_2023_24": SINGLE_SHARE - * FARE_PAYING_2023_24 + * PRICED_BOARDINGS_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 + * PRICED_BOARDINGS_2024_25 / 1e6 * saving(shares, fares, 3, ratios[2025]), "cap_2_in_2027": SINGLE_SHARE - * FARE_PAYING_2024_25 + * PRICED_BOARDINGS_2024_25 / 1e6 * saving(shares, fares, 2, ratios[2027]), "mean_single_fare": sum(s * f for s, f in zip(shares, fares)),