From ed1a7ecc31f07817289449778b797a72070cb202 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:33:18 -0400 Subject: [PATCH] Grow council tax bills by receipts growth net of household weight growth Each country's council tax series is OBR receipts growth, which already includes growth in the number of Band D equivalent dwellings, while household weights grow with population too. Divide each bill's growth by the growth of the series uprating_indices.yaml projects household_weight with, so each country's weighted total tracks its receipts (#2067). Restate the 2023 and 2024 values, which held Band D level growth, as receipts outturns: MHCLG's council tax requirement (England), the Scottish Government's net amount billed and the Welsh Government's net collectable debit, each net of council tax reduction. Co-Authored-By: Claude Opus 5.5 --- ...uncil-tax-population-double-count.fixed.md | 1 + docs/book/assumptions/growthfactors.md | 10 +- docs/book/validation/hbai.md | 2 +- policyengine_uk/data/economic_assumptions.py | 38 ++- .../gov/economic_assumptions/README.md | 8 + .../gov/economic_assumptions/yoy_growth.yaml | 63 ++++- .../tests/test_council_tax_uprating.py | 260 ++++++++++++++++-- .../tests/test_projection_horizon.py | 8 +- 8 files changed, 337 insertions(+), 53 deletions(-) create mode 100644 changelog.d/council-tax-population-double-count.fixed.md diff --git a/changelog.d/council-tax-population-double-count.fixed.md b/changelog.d/council-tax-population-double-count.fixed.md new file mode 100644 index 0000000000..1b27d68bd6 --- /dev/null +++ b/changelog.d/council-tax-population-double-count.fixed.md @@ -0,0 +1 @@ +Stop projected council tax counting the growth in households twice (#2067). Each country's council tax series is the growth in its receipts, which already includes growth in the number of Band D equivalent dwellings, while household weights grow with population as well. Each household's bill now grows by its country's receipts growth divided by the growth in household weights, so each country's weighted total grows with its receipts; before, totals outgrew receipts by population growth, 0.4% to 0.7% a year from 2025-26 (2.8% by 2030-31). The 2023-24 and 2024-25 values, which held Band D level growth, now hold receipts outturns: MHCLG's council tax requirement for England, the Scottish Government's net amount billed and the Welsh Government's net collectable debit. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 3e8b4e1700..bb3419f5e7 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -66,13 +66,15 @@ We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr` ## Council tax -We use the OBR's Council Tax receipts and projections. +We project each country's council tax receipts, net of council tax reduction, with the OBR's forecasts (March 2026 EFO, Expenditure Table 4.1) from 2025-26, and official outturns before then: MHCLG's council tax requirement for England, the Scottish Government's net amount billed and the Welsh Government's net collectable debit. -| Fiscal year | 2022 | 2023 | 2024 | 2025 | 2026 | 2027 | 2028 | 2029 | +| Fiscal year | 2023 | 2024 | 2025 | 2026 | 2027 | 2028 | 2029 | 2030 | |-------------|------|------|------|------|------|------|------|------| -| Growth rate | 5.3% | 5.6% | 6.4% | 4.6% | 4.5% | 4.6% | 4.5% | 4.5% | +| England | 6.6% | 6.5% | 7.0% | 5.5% | 6.1% | 6.2% | 5.6% | 5.5% | +| Scotland | 6.4% | 2.4% | 3.8% | 3.8% | 3.8% | 3.8% | 3.8% | 3.8% | +| Wales | 7.1% | 9.9% | 7.7% | 7.4% | 7.4% | 7.4% | 7.4% | 7.4% | -We apply this to: `council_tax` +Receipts are the Band D level times the tax base, the number of Band D equivalent dwellings, so they grow with the number of homes as well as with bills. Household weights grow with population, so each household's `council_tax` grows by its country's receipts growth divided by population growth: (1 + receipts growth) / (1 + population growth) − 1. Each country's weighted total then grows with its receipts. Northern Ireland has no council tax (it has domestic rates), so any `council_tax` a dataset gives its households is held flat. ## Mortgage interest diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 557ea35f81..39129deca1 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -101,7 +101,7 @@ Each of the variables above is either derived from other variables, or is a dire | `free_tv_licence_value` | Not directly uprated | Depends on policy parameters | | `income_tax` | Derived | Calculated based on income and tax policy | | `national_insurance` | Derived | Calculated based on income and NI policy | -| `council_tax` | gov.obr.council_tax | Uprated based on outturn CT levels by region growth to 2025, then OBR projections of levels by region onwardss | +| `council_tax` | gov.economic_assumptions.yoy_growth.obr.council_tax | Uprated by each country's council tax receipts growth (official outturns to 2024-25, then OBR forecasts) divided by population growth, so each country's weighted total tracks its receipts; held flat in Northern Ireland | | `domestic_rates` | gov.economic_assumptions.yoy_growth.finance_ni.domestic_rates | Uprated by growth in Northern Ireland's combined regional and district domestic rate poundage to 2026-27, then by an assumed 4.33% a year (2026-27's growth) | | `employee_pension_contributions` | gov.obr.per_capita.employment_income | Uprated based on per capita employment income growth | | `personal_pension_contributions` | gov.obr.per_capita.employment_income | Uprated based on per capita employment income growth | diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index a59ebd9920..bf157d2678 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -407,12 +407,34 @@ def uprate_residential_property( return current_year +def household_weight_growth_path(uprating: dict | None = None) -> str: + """The growth series that projects household weights. + + The one ``uprating_indices.yaml`` lists ``household_weight`` under. + """ + uprating = _uprating_index_map() if uprating is None else uprating + paths = [path for path, names in uprating.items() if "household_weight" in names] + if len(paths) != 1: + raise ValueError( + "uprating_indices.yaml must list household_weight under exactly one " + f"growth series, not {paths}" + ) + return paths[0] + + def uprate_council_tax( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, parameters: ParameterNode, ): # Uprate council tax for a single year dataset. + # + # Each country's series is the growth in its council tax receipts: the + # Band D level times the tax base, the number of Band D equivalent + # dwellings. Household weights grow too, by their own series, so each bill + # grows by receipts growth net of the weights' growth. A country's + # weighted total then grows by its receipts growth. Growing the bills by + # receipts growth counted the growth in households twice (#2067). council_tax = parameters.gov.economic_assumptions.yoy_growth.obr.council_tax region = pd.Series(np.asarray(current_year.household["region"]).astype(str)) @@ -422,16 +444,20 @@ def uprate_council_tax( "Cannot uprate council tax for unrecognised regions: " f"{sorted(unrecognised)}" ) - growth_by_region = { - name: 0.0 + weight_growth = parameters.get_child(household_weight_growth_path())( + current_year.time_period + ) + factor_by_region = { + name: 1.0 if series is None - else council_tax.get_child(series)(current_year.time_period) + else (1 + council_tax.get_child(series)(current_year.time_period)) + / (1 + weight_growth) for name, series in _COUNCIL_TAX_GROWTH_SERIES.items() } - growth_rates = region.map(growth_by_region).to_numpy() + factors = region.map(factor_by_region).to_numpy() - current_year.household["council_tax"] = previous_year.household["council_tax"] * ( - 1 + growth_rates + current_year.household["council_tax"] = ( + previous_year.household["council_tax"] * factors ) return current_year diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 9cbaae3c9f..9e58726032 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -217,6 +217,14 @@ threshold by the same rises, up to the last year of The EFO refresh does not touch `finance_ni.domestic_rates`; OBR does not publish a Northern Ireland domestic rates bill series (see below). +For `obr.council_tax`, take each country's growth in receipts from +Expenditure Table 4.1, not its "council tax level" row: dataset projection +divides the receipts growth by the growth in household weights itself +(#2067). When a forecast year becomes an outturn, replace it with the growth +in the published liability the comments in `yoy_growth.yaml` name, and add the +figures to `PUBLISHED_OUTTURNS` in +`policyengine_uk/tests/test_council_tax_uprating.py`. + ## Northern Ireland domestic rates `finance_ni.domestic_rates` is the growth in the average Northern Ireland diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 0c3d6284ca..f79eb1081a 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -596,13 +596,32 @@ obr: - title: OBR EFO March 2026 (detailed forecast tables, economy, Table 1.12) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + # Growth in each country's council tax receipts, net of council tax + # reduction. Receipts are the Band D level times the tax base (Band D + # equivalent dwellings), so they grow with the number of homes as well as + # with bills. Dataset projection grows household weights separately, so it + # grows each household's bill by this series net of the weights' growth + # (uprate_council_tax, #2067). The fiscal year starting in year t is stored + # at t-01-01, as growth over the year before, rounded to 4 decimals. + # + # 2025-2030 are OBR's receipts forecasts. 2023 and 2024 are outturns of the + # liability behind each country's OBR line, net of council tax reduction, + # from the official statistics named below + # (policyengine_uk/tests/test_council_tax_uprating.py recomputes them). They + # are amounts due, not cash collected, so changes in in-year collection + # rates do not move them. Before #2067 these two years held Band D level + # growth instead. council_tax: england: - description: Growth in the level of council tax receipts in England. + description: Growth in council tax receipts in England. values: - # Outturn (2023-2024) - 2023-01-01: 0.051 - 2024-01-01: 0.051 + # Outturn (2023-2024): growth in the council tax requirement including + # adult social care and parish precepts (MHCLG, 2025-26 release, Table + # 1): £36,313.2m in 2022-23, £38,718.3m in 2023-24, £41,222.4m in + # 2024-25. OBR's England line is this requirement: its 2024-25 outturn + # is £41.2225bn. + 2023-01-01: 0.0662 + 2024-01-01: 0.0647 # Forecast (2025-2030) from OBR EFO March 2026, Expenditure Table 4.1 2025-01-01: 0.0702 2026-01-01: 0.0545 @@ -613,14 +632,21 @@ obr: metadata: unit: /1 reference: + - title: MHCLG, Council Tax levels set by local authorities in England 2025 to 2026, Table 1 (2023 and 2024) + href: https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026 - title: OBR EFO March 2026 (detailed forecast tables, expenditure, Table 4.1) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ scotland: - description: Growth in the level of council tax receipts in Scotland. + description: Growth in council tax receipts in Scotland. values: - # Outturn (2023-2024) - 2023-01-01: 0.052 - 2024-01-01: 0.001 + # Outturn (2023-2024): growth in the net amount of council tax billed, + # after council tax reduction and excluding water and sewerage charges + # (Scottish Government, Council Tax Collection Statistics 2024-25, + # Figure 1): £2,825.6m in 2022-23, £3,005.8m in 2023-24, £3,076.8m in + # 2024-25. OBR's 2024-25 outturn, £2.9384bn, is the amount of that + # year's bills received by 31 March 2025. + 2023-01-01: 0.0638 + 2024-01-01: 0.0236 # Forecast (2025-2030) from OBR EFO March 2026, Expenditure Table 4.1 2025-01-01: 0.0384 2026-01-01: 0.0384 @@ -631,14 +657,21 @@ obr: metadata: unit: /1 reference: + - title: Scottish Government, Council Tax Collection Statistics 2024-25, Figure 1 (2023 and 2024) + href: https://www.gov.scot/publications/council-tax-collection-statistics-2024-25/pages/3/ - title: OBR EFO March 2026 (detailed forecast tables, expenditure, Table 4.1) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ wales: - description: Growth in the level of council tax receipts in Wales. + description: Growth in council tax receipts in Wales. values: - # Outturn (2023-2024) - 2023-01-01: 0.058 - 2024-01-01: 0.077 + # Outturn (2023-2024): growth in the net collectable council tax debit, + # net of the council tax reduction scheme (Welsh Government, Council + # tax collection rates, Table 2 total debits of each year's release): + # £1,981.6m in 2022-23, £2,122.1m in 2023-24, £2,331.6m in 2024-25. + # OBR's 2024-25 outturn, £2.2284bn, is the amount of that year's bills + # collected in the year. + 2023-01-01: 0.0709 + 2024-01-01: 0.0987 # Forecast (2025-2030) from OBR EFO March 2026, Expenditure Table 4.1 2025-01-01: 0.0773 2026-01-01: 0.0743 @@ -649,6 +682,12 @@ obr: metadata: unit: /1 reference: + - title: Welsh Government, Council tax collection rates April 2022 to March 2023 (2023) + href: https://www.gov.wales/council-tax-collection-rates-april-2022-march-2023 + - title: Welsh Government, Council tax collection rates April 2023 to March 2024 (2023 and 2024) + href: https://www.gov.wales/council-tax-collection-rates-april-2023-march-2024 + - title: Welsh Government, Council tax collection rates April 2024 to March 2025 (2024) + href: https://www.gov.wales/council-tax-collection-rates-april-2024-march-2025 - title: OBR EFO March 2026 (detailed forecast tables, expenditure, Table 4.1) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ diff --git a/policyengine_uk/tests/test_council_tax_uprating.py b/policyengine_uk/tests/test_council_tax_uprating.py index 92ec4d0922..d0700fb4d9 100644 --- a/policyengine_uk/tests/test_council_tax_uprating.py +++ b/policyengine_uk/tests/test_council_tax_uprating.py @@ -1,16 +1,19 @@ import functools +from pathlib import Path from types import SimpleNamespace import numpy as np import pandas as pd import pytest +import yaml from hypothesis import given, settings from hypothesis import strategies as st from policyengine_uk import Microsimulation, Simulation, parameters -from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data import UKSingleYearDataset, economic_assumptions from policyengine_uk.data.economic_assumptions import ( extend_single_year_dataset, + household_weight_growth_path, uprate_council_tax, ) from policyengine_uk.variables.household.demographic.geography import Region @@ -19,6 +22,47 @@ REGIONS = [region.name for region in Region] UPRATED_YEARS = list(range(2023, 2031)) COUNCIL_TAX_GROWTH = parameters.gov.economic_assumptions.yoy_growth.obr.council_tax +# Household weights grow by the series uprating_indices.yaml lists them under. +# It is read from the file here, not from the code under test. +_UPRATING_INDICES = yaml.safe_load( + (Path(economic_assumptions.__file__).parent / "uprating_indices.yaml").read_text() +) +(WEIGHT_GROWTH_PATH,) = [ + path for path, names in _UPRATING_INDICES.items() if "household_weight" in names +] +WEIGHT_GROWTH = parameters.get_child(WEIGHT_GROWTH_PATH) + +# OBR March 2026 EFO, detailed forecast tables, expenditure, Table 4.1: +# council tax receipts in £ billion, 2024-25 (outturn) to 2030-31. +OBR_RECEIPTS_YEARS = range(2024, 2031) +OBR_RECEIPTS = { + "england": [41.2225, 44.1172, 46.5197, 49.3324, 52.3663, 55.308, 58.3527], + "scotland": [2.9384, 3.0512, 3.1684, 3.2901, 3.4164, 3.5476, 3.6839], + "wales": [2.2284, 2.4008, 2.5793, 2.771, 2.9771, 3.1984, 3.4362], +} + +# The liability behind each country's OBR line, net of council tax reduction, +# by the year the fiscal year starts in (2022 is 2022-23). +PUBLISHED_OUTTURNS = { + # MHCLG, Council Tax levels set by local authorities in England 2025 to + # 2026, Table 1: council tax requirement including adult social care and + # parish precepts, £ million. OBR's 2024-25 outturn is £41.2225bn. + "england": { + 2022: 36_313.215616, + 2023: 38_718.300063, + 2024: 41_222.393749, + 2025: 44_118.104184, + }, + # Scottish Government, Council Tax Collection Statistics 2024-25, Figure + # 1: net amount billed, after council tax reduction and excluding water + # and sewerage charges, £ thousand. + "scotland": {2022: 2_825_646, 2023: 3_005_845, 2024: 3_076_770}, + # Welsh Government, Council tax collection rates for April 2022 to March + # 2023, April 2023 to March 2024 and April 2024 to March 2025, Table 2: + # in-year total debits, net of the council tax reduction scheme, + # £ thousand. + "wales": {2022: 1_981_608, 2023: 2_122_104, 2024: 2_331_585}, +} @functools.cache @@ -37,15 +81,24 @@ def _country_of_each_region(): return dict(zip(REGIONS, countries)) -def _expected_growth(region, year): +def _series(region): # Council tax exists in England, Scotland and Wales; OBR forecasts it for - # each. Northern Ireland levies domestic rates instead, so its council tax - # has no growth. An unknown region takes England's. + # each. Northern Ireland levies domestic rates instead, so it has none. An + # unknown region takes England's. country = _country_of_each_region()[region] if country == "NORTHERN_IRELAND": - return 0.0 - series = "england" if country == "UNKNOWN" else country.lower() - return COUNCIL_TAX_GROWTH.get_child(series)(year) + return None + return "england" if country == "UNKNOWN" else country.lower() + + +def _expected_factor(region, year): + # A bill grows by its country's receipts growth net of the growth in + # household weights, so the country's weighted total grows by its + # receipts growth (#2067). Northern Ireland's bills stay flat. + series = _series(region) + if series is None: + return 1.0 + return (1 + COUNCIL_TAX_GROWTH.get_child(series)(year)) / (1 + WEIGHT_GROWTH(year)) def _uprated_council_tax(regions, council_tax, year): @@ -63,20 +116,41 @@ def _uprated_council_tax(regions, council_tax, year): def test_uprate_council_tax_holds_northern_ireland_flat(): - # Northern Ireland used to fall through to England's growth (1,070.2), - # because the code compared the region with "NORTHERN IRELAND" (a space) - # while the dataset label is NORTHERN_IRELAND. + # In 2025-26 OBR's council tax receipts grow 7.02% in England, 7.73% in + # Wales and 3.84% in Scotland (March 2026 EFO, Table 4.1), and household + # weights grow 0.72%, with population. So a £1,000 bill becomes + # £1,000 x 1.0702 / 1.0072 = £1,062.55 in London, + # £1,000 x 1.0773 / 1.0072 = £1,069.60 in Wales and + # £1,000 x 1.0384 / 1.0072 = £1,030.98 in Scotland. Northern Ireland has + # no council tax series and keeps £1,000; it used to fall through to + # England's growth, because the code compared the region with + # "NORTHERN IRELAND" (a space) while the dataset label is NORTHERN_IRELAND. council_tax = _uprated_council_tax( ["LONDON", "WALES", "SCOTLAND", "NORTHERN_IRELAND"], [1_000.0] * 4, 2025 ) - assert council_tax == pytest.approx([1_070.2, 1_077.3, 1_038.4, 1_000.0]) + assert council_tax == pytest.approx( + [1_062.55, 1_069.60, 1_030.98, 1_000.0], abs=0.005 + ) def test_uprate_council_tax_gives_unknown_region_england_growth(): council_tax = _uprated_council_tax(["UNKNOWN"], [1_000.0], 2025) - assert council_tax == pytest.approx([1_070.2]) + assert council_tax == pytest.approx([1_062.55], abs=0.005) + + +def test_scottish_bills_grow_with_the_amount_billed_in_2024_25(): + # Scotland's average Band D rose 0.1% in 2024-25, from £1,416.50 to + # £1,418.11 (Scottish Government, Band D Council Tax 1996-97 to 2026-27), + # but the net amount billed rose 2.36%, from £3,005.8m to £3,076.8m, and + # household weights grow 1.07%. So a £1,000 bill becomes + # £1,000 x 1.0236 / 1.0107 = £1,012.76. Dividing the Band D growth the + # series used to hold for 2024 (0.001) by the weights' growth would have + # cut it to £990.40, and Scotland's total would have grown 0.1%, not 2.36%. + council_tax = _uprated_council_tax(["SCOTLAND"], [1_000.0], 2024) + + assert council_tax == pytest.approx([1_012.76], abs=0.005) @pytest.mark.parametrize("region", ["NORTHERN IRELAND", "ATLANTIS", ""]) @@ -87,6 +161,42 @@ def test_uprate_council_tax_rejects_unrecognised_regions(region): _uprated_council_tax(["LONDON", region], [1_000.0, 1_000.0], 2025) +@pytest.mark.parametrize( + "series, year", + [ + (series, year) + for series, years in PUBLISHED_OUTTURNS.items() + for year in sorted(years)[1:] + ], +) +def test_council_tax_growth_matches_published_outturns(series, year): + """Each outturn year is the growth in the published liability, rounded to + 4 decimals. England's 2025 value is an OBR forecast, and matches MHCLG's + 2025-26 requirement too: both lines measure the same thing.""" + outturns = PUBLISHED_OUTTURNS[series] + published = outturns[year] / outturns[year - 1] - 1 + + assert COUNCIL_TAX_GROWTH.get_child(series)(year) == pytest.approx( + round(published, 4), abs=1e-12 + ) + + +def test_household_weight_growth_path_is_the_index_that_grows_weights(): + assert household_weight_growth_path() == WEIGHT_GROWTH_PATH + + +@pytest.mark.parametrize( + "uprating", + [ + {"a": ["rent"]}, + {"a": ["household_weight"], "b": ["household_weight", "rent"]}, + ], +) +def test_household_weight_growth_path_needs_exactly_one_index(uprating): + with pytest.raises(ValueError, match="exactly one"): + household_weight_growth_path(uprating) + + @settings(max_examples=200, deadline=None, derandomize=True) @given( households=st.lists( @@ -104,10 +214,10 @@ def test_uprate_council_tax_properties(households, year, scale): """For every region in the Region enum, any council tax of zero or more and every uprated year: - - council tax grows by its country's OBR series, with the country taken - from the model's `country` variable: England's for English regions and - unknown regions, Scotland's and Wales's for those countries, and none - for Northern Ireland; + - council tax grows by its country's OBR series net of the growth in + household weights, with the country taken from the model's `country` + variable: England's for English regions and unknown regions, Scotland's + and Wales's for those countries; - a Northern Ireland household's council tax is unchanged; - each household's result depends only on its own row; - scaling every previous-year amount scales every result by the same @@ -118,7 +228,7 @@ def test_uprate_council_tax_properties(households, year, scale): council_tax = _uprated_council_tax(regions, amounts, year) expected = [ - amount * (1 + _expected_growth(region, year)) for region, amount in households + amount * _expected_factor(region, year) for region, amount in households ] assert council_tax == pytest.approx(expected) northern_ireland = np.array(regions) == "NORTHERN_IRELAND" @@ -132,14 +242,8 @@ def test_uprate_council_tax_properties(households, year, scale): assert scaled == pytest.approx(scale * council_tax) -# A dataset with an unknown-region household cannot be projected forward until -# rent uprating handles Region.UNKNOWN (PolicyEngine/policyengine-uk#1985), so -# the dataset-level tests below use the twelve known regions. -KNOWN_REGIONS = [region for region in REGIONS if region != "UNKNOWN"] - - -def _one_household_per_known_region(council_tax): - ids = np.arange(1, len(KNOWN_REGIONS) + 1) +def _dataset(regions, council_tax, weights=1.0, fiscal_year=2023): + ids = np.arange(1, len(regions) + 1) return UKSingleYearDataset( person=pd.DataFrame( { @@ -153,20 +257,118 @@ def _one_household_per_known_region(council_tax): household=pd.DataFrame( { "household_id": ids, - "household_weight": 1.0, - "region": KNOWN_REGIONS, + "household_weight": weights, + "region": regions, "tenure_type": "OWNED_OUTRIGHT", "rent": 0.0, "council_tax": council_tax, } ), - fiscal_year=2023, + fiscal_year=fiscal_year, ) +def _weighted_totals(dataset): + """Each council tax series' weighted council tax total.""" + household = dataset.household + series = household["region"].astype(str).map(_series) + weighted = household["household_weight"] * household["council_tax"] + return weighted.groupby(series.fillna("none")).sum().to_dict() + + +def test_country_totals_track_obr_receipts(): + """Project a dataset from 2024-25 to 2030-31 with unequal weights and + bills. Each country's weighted council tax total grows as OBR forecasts + its receipts to grow. Each year's growth is within 0.0001 of OBR's: the + series rounds each year to 4 decimals, and holds Wales at 0.0743 from + 2026, where Table 4.1 implies 0.0743 to 0.0744. Before #2067 the totals + also grew with the weights, by population, and were 2.8% too high by + 2030-31. + """ + regions = ["LONDON", "NORTH_EAST", "SOUTH_WEST", "SCOTLAND", "SCOTLAND", "WALES"] + dataset = _dataset( + regions, + council_tax=[2_100.0, 1_500.0, 2_400.0, 1_400.0, 1_900.0, 2_000.0], + weights=[3_000.0, 1_000.0, 2_500.0, 1_200.0, 800.0, 1_500.0], + fiscal_year=2024, + ) + projected = extend_single_year_dataset(dataset, parameters, end_year=2030) + totals = {year: _weighted_totals(projected[year]) for year in OBR_RECEIPTS_YEARS} + + for series, receipts in OBR_RECEIPTS.items(): + receipts = dict(zip(OBR_RECEIPTS_YEARS, receipts)) + for year in OBR_RECEIPTS_YEARS[1:]: + growth = totals[year][series] / totals[year - 1][series] - 1 + obr_growth = receipts[year] / receipts[year - 1] - 1 + assert growth == pytest.approx(obr_growth, abs=1e-4), (series, year) + assert totals[2030][series] / totals[2024][series] == pytest.approx( + receipts[2030] / receipts[2024], rel=5e-4 + ), series + + +@settings(max_examples=30, deadline=None, derandomize=True) +@given( + households=st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.floats(0, 50_000, allow_nan=False), + st.floats(0.01, 10_000, allow_nan=False), + ), + min_size=1, + max_size=12, + ), + base_year=st.integers(2022, 2030), + years=st.integers(1, 2), +) +def test_each_country_total_grows_by_its_receipts_growth(households, base_year, years): + """For any households in any regions, with any council tax of zero or + more and any positive weights, any base year from 2022 and every year it + is projected to: + + - each country's weighted council tax total grows by that country's + receipts growth, with unknown regions counted with England; + - each bill grows by its country's receipts growth net of the growth in + household weights; + - Northern Ireland's bills stay flat, so its weighted total grows with + the weights alone. + """ + regions, amounts, weights = (list(column) for column in zip(*households)) + dataset = _dataset(regions, amounts, weights, fiscal_year=base_year) + + projected = extend_single_year_dataset( + dataset, parameters, end_year=base_year + years + ) + + for year in range(base_year + 1, base_year + years + 1): + previous, current = projected[year - 1], projected[year] + before, after = _weighted_totals(previous), _weighted_totals(current) + for series, total in after.items(): + growth = ( + WEIGHT_GROWTH(year) + if series == "none" + else COUNCIL_TAX_GROWTH.get_child(series)(year) + ) + assert total == pytest.approx( + before[series] * (1 + growth), rel=1e-9, abs=1e-6 + ), (series, year) + assert current.household["council_tax"].to_numpy() == pytest.approx( + previous.household["council_tax"].to_numpy() + * [_expected_factor(region, year) for region in regions] + ) + + +# The tests below use one household in each of the twelve regions that have a +# country. +KNOWN_REGIONS = [region for region in REGIONS if region != "UNKNOWN"] + + +def _one_household_per_known_region(council_tax): + return _dataset(KNOWN_REGIONS, council_tax) + + def _compounded(amount, region, year, base_year=2023): return amount * np.prod( - [1 + _expected_growth(region, y) for y in range(base_year + 1, year + 1)] + [_expected_factor(region, y) for y in range(base_year + 1, year + 1)] ) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 2b6303b898..10b3bb020c 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -146,8 +146,14 @@ def _rent_growth(region, tenure, year): def _council_tax_growth(region, year): + # Bills grow by their country's receipts growth net of the growth in + # household weights, so each country's total tracks its receipts (#2067). series = COUNCIL_TAX_SERIES.get(region) - return 0.0 if series is None else GROWTH.obr.council_tax.get_child(series)(year) + if series is None: + return 0.0 + receipts = GROWTH.obr.council_tax.get_child(series)(year) + weights = parameters.get_child(INDEX_OF["household_weight"])(year) + return (1 + receipts) / (1 + weights) - 1 RESIDENTIAL_STOCKS = (