From 577f549265fa2cc4432def4e12eb307e895fc404 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 09:32:11 -0400 Subject: [PATCH 1/6] Keep the protected LCWRA amount from 2030-31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebalancing scenario set the UC health element only for 2026-27 to 2029-30. From 2030-31 every LCWRA claimant fell to the new-claimant amount, a protected claimant from £457.15 to £235.86 a month. Universal Credit Act 2025 s. 2 relabels the reg. 36 rows for good; s. 3 disapplies the SSAA 1992 s. 150 up-rating only in the tax years ending 5 April 2026 to 5 April 2029; s. 4's floor covers only 2026-27 to 2029-30. So both amounts continue and are uprated again from 2030-31. - The scenario now sets the element to the end of the data's projection (PROJECTION_END_YEAR, 2032). - A protected amount with no stated value takes the s. 4 floor to 2029-30 and, from 2030-31, the year before's amount uprated by elements.disabled.uprating, the index the new-claimant amount follows. - The new-claimant share is the parameter rebalancing.new_claimant_share: WPI Economics' 2026-27 to 2029-30 shares, held at 22% after, a stated assumption. Tests: no drop from 2029-30 to 2030-31 for a protected or new claimant; the protected amount's path from 2030-31; a 2030-31 reform to either amount moves Universal Credit; the element never falls for any CPI path and Universal Credit never falls for non-negative CPI; the draw reads the share in every year it sets. Ending the loop at 2029 fails 5 tests; keeping the s. 4 floor after 2029 fails 1. Fixes #2282. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-lcwra-post-2029.fixed.md | 1 + docs/book/policy/uc-rebalancing.md | 8 +- .../rebalancing/new_claimant_share.yaml | 14 + .../rebalancing/protected_health_element.yaml | 2 +- policyengine_uk/scenarios/uc_reform.py | 54 +++- .../test_uc_health_element_after_freeze.py | 259 ++++++++++++++++++ 6 files changed, 320 insertions(+), 18 deletions(-) create mode 100644 changelog.d/uc-lcwra-post-2029.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_share.yaml create mode 100644 policyengine_uk/tests/test_uc_health_element_after_freeze.py diff --git a/changelog.d/uc-lcwra-post-2029.fixed.md b/changelog.d/uc-lcwra-post-2029.fixed.md new file mode 100644 index 0000000000..51eebf9afc --- /dev/null +++ b/changelog.d/uc-lcwra-post-2029.fixed.md @@ -0,0 +1 @@ +- From 2030-31, keep paying pre-2026 (protected) Universal Credit LCWRA claimants the protected amount, uprated from its 2029-30 level by the LCWRA element's uprating index, and new claimants the new-claimant amount. The rebalancing scenario used to stop in 2029-30, so every claimant fell to the new-claimant amount (a protected claimant from £457.15 to £235.86 a month). The new-claimant share is now the parameter `gov.dwp.universal_credit.rebalancing.new_claimant_share`, held at 22% after 2029-30. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 23054209fa..30761c786a 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,7 +24,7 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di Two parameters hold this amount, and both are uprated by `gov.dwp.universal_credit.elements.disabled.uprating`, so they follow the same path: -- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it from 2030-31, and whenever the rebalancing reforms are inactive. +- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and for years after the data's projection ends (2032-33 on), when the rebalancing scenario no longer sets the element. - `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants for 2026-27 to 2029-30. A change to the freeze or to the benefit uprating index moves both. A reform to either amount itself does not change the other. @@ -35,13 +35,17 @@ Existing recipients are treated differently. Universal Credit Regulations 2013 r For a tax year without a legislated amount, the model uses the lowest amount the Universal Credit Act 2025 s. 4 duty allows. For every standard allowance amount, the protected amount plus that allowance must be at least the previous year's sum, increased by the relevant CPI percentage: the CPI 12-month rate in the September before the tax year, never below 0% (s. 4(4)(a)). The model reads that rate from `gov.economic_assumptions.statutory_uprating_inputs.cpi_september`. Section 3 switches off the element's ordinary uprating, so the amount otherwise stays where it was. Run from the 2025-26 amounts at the 3.8% September 2025 CPI rate, this rule gives the legislated £429.80. -Whether a benefit unit gets the new-claimant rate is the benefit unit's `uc_receives_new_claimant_health_element` for the year. A household situation can set it, for example `{"uc_receives_new_claimant_health_element": {"2026": True}}` on the benefit unit; otherwise it is false, which pays the protected amount. A dataset can supply it too. A simulation built from data that does not supply it assigns it by a seeded draw, using transition probabilities based on WPI Economics analysis for the Trussell Trust, derived from administrative Personal Independence Payment data. The probability of being a new claimant varies by year: +The s. 4 duty covers 2026-27 to 2029-30 only, and s. 3 disapplies the annual up-rating only in the tax years ending 5 April 2026 to 5 April 2029. The protected row stays in reg. 36: s. 2 only relabels it. So from 2030-31 the protected amount is the year before's amount uprated by the same index as the new-claimant amount, `gov.dwp.universal_credit.elements.disabled.uprating`, and a protected claimant keeps the higher amount. + +Whether a benefit unit gets the new-claimant rate is the benefit unit's `uc_receives_new_claimant_health_element` for the year. A household situation can set it, for example `{"uc_receives_new_claimant_health_element": {"2026": True}}` on the benefit unit; otherwise it is false, which pays the protected amount. A dataset can supply it too. A simulation built from data that does not supply it assigns it by a seeded draw against `gov.dwp.universal_credit.rebalancing.new_claimant_share`, from 2026-27 to the end of the data's projection. The shares to 2029-30 are WPI Economics' estimates for the Trussell Trust, derived from administrative Personal Independence Payment data: - 2026: 11% - 2027: 13% - 2028: 16% - 2029: 22% +Later years hold 22%. That is an assumption, not a source: the share keeps rising as the caseload turns over, so holding it understates new claimants from 2030-31. + ## Standard allowance uplifts The standard allowance receives additional percentage uplifts beyond the normal inflationary increase: diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_share.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_share.yaml new file mode 100644 index 0000000000..5b4e7dfb9a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_share.yaml @@ -0,0 +1,14 @@ +description: >- + Share of benefit units with the Universal Credit LCWRA element that are new claimants, paid the new-claimant amount rather than the protected (pre-2026) amount. The rebalancing scenario uses it to assign the status by a seeded draw when the data do not supply it; a household situation without the status gets the protected amount. The shares for 2026-27 to 2029-30 are WPI Economics' estimates for the Trussell Trust from administrative Personal Independence Payment data (2025). Later years hold the 2029-30 share. That is an assumption, not a source: the share keeps rising as the caseload turns over, so holding it understates new claimants and overstates protected amounts from 2030-31. +values: + 2025-04-06: 0 + 2026-04-06: 0.11 + 2027-04-06: 0.13 + 2028-04-06: 0.16 + 2029-04-06: 0.22 +metadata: + unit: /1 + label: Universal Credit LCWRA new-claimant share + reference: + - title: Universal Credit Act 2025 s. 2 (new-claimant and protected LCWRA amounts) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/2 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml index 6efecca026..980f3be7cb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml @@ -1,4 +1,4 @@ -description: Monthly LCWRA element for a pre-2026 claimant, a severe conditions criteria claimant or a claimant who is terminally ill (the protected LCWRA amount). Each dated value is the legislated amount for the tax year it falls in; a later tax year without one takes the lowest amount the Universal Credit Act 2025 s. 4 duty allows. +description: Monthly LCWRA element for a pre-2026 claimant, a severe conditions criteria claimant or a claimant who is terminally ill (the protected LCWRA amount). Each dated value is the legislated amount for the tax year it falls in. A later tax year without one takes, to 2029-30, the lowest amount the Universal Credit Act 2025 s. 4 duty allows, and from 2030-31 the year before's amount uprated by the LCWRA element's uprating index (s. 4 ends with 2029-30 and s. 3 no longer disapplies the annual up-rating). values: 2026-04-01: value: 429.80 diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index de6d0233d6..74f769cfb4 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,9 +1,20 @@ from policyengine_uk.model_api import Scenario from policyengine_uk import Microsimulation +from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR from policyengine_uk.utils.supplied_inputs import set_model_value import numpy as np +# Universal Credit Act 2025: the first tax year of the rebalancing (2026-27, +# ss. 2 to 4) and the last the s. 4 duty covers (2029-30). +FIRST_REBALANCING_YEAR = 2026 +SECTION_4_LAST_YEAR = 2029 +# The scenario sets the element to the end of the data's projection. Later +# years fall to the uc_LCWRA_element formula, which pays every claimant the +# new-claimant amount. +LAST_SET_YEAR = PROJECTION_END_YEAR + + # The standard allowance amounts the protected LCWRA amount is paired with in # the Universal Credit Act 2025 s. 4(2) duty: every amount of the allowance. STANDARD_ALLOWANCE_TYPES = ( @@ -71,9 +82,19 @@ def _protected_existing_health_element_monthly( UC Regs 2013 reg. 36 gives one amount to every pre-2026, severe conditions criteria or terminally ill claimant, whatever their age or couple status - (£429.80 for 2026-27, SI 2026/113 reg. 3(3)(b)). A tax year whose amount is - null (not yet legislated) takes the s. 4 floor from the year before; any - other amount, legislated or set by a reform, is used as given. + (£429.80 for 2026-27, SI 2026/113 reg. 3(3)(b)). Any non-null amount, + legislated or set by a reform, is used as given. A tax year whose amount + is null (not yet legislated): + + - to 2029-30 takes the s. 4 floor from the year before; + - from 2030-31 takes the year before's amount uprated by the element's + uprating index. The row stays in reg. 36 (s. 2 only relabels it), s. 4 + covers only 2026-27 to 2029-30, and s. 3 disapplies the SSAA 1992 + s. 150 up-rating only in the tax years ending 5 April 2026 to 5 April + 2029, so the review in 2029-30 governs the April 2030 rate again. The + index is the one the new-claimant amount follows + (``rebalancing/create_health_element_uprating.py``): the benefit + uprating index, held in any year the freeze is in force, never falling. """ parameter = _protected_amount_parameter(sim) amount = parameter(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") @@ -88,8 +109,15 @@ def _protected_existing_health_element_monthly( raise ValueError( f"No protected LCWRA amount is set for {year} or any earlier year." ) + previous = _protected_existing_health_element_monthly(sim, year - 1) + if year > SECTION_4_LAST_YEAR: + index = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.elements.disabled.uprating + return previous * ( + index(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + / index(f"{year - 1}-{FISCAL_YEAR_SAMPLE_DATE}") + ) return protected_lcwra_floor( - _protected_existing_health_element_monthly(sim, year - 1), + previous, _standard_allowances_monthly(sim, year - 1), _standard_allowances_monthly(sim, year), _relevant_cpi_factor(sim, year), @@ -129,20 +157,16 @@ def add_universal_credit_reform(sim: Microsimulation): generator = np.random.default_rng(43) uc_seed = generator.random(len(sim.calculate("benunit_id"))) - post_2025_claimant_share = { - 2025: 0, - 2026: 0.11, - 2027: 0.13, - 2028: 0.16, - 2029: 0.22, - } # WPI Economics for Trussell Trust based on admin PIP data, 2025 + # rebalancing.new_claimant_share: WPI Economics' estimates for 2026-27 to + # 2029-30, held after that (an assumption; see the parameter). + new_claimant_share = rebalancing.new_claimant_share new_claimant_health_element = rebalancing.new_claimant_health_element built_from_data = getattr(sim, "built_from_dataset", False) - for year in range(2026, 2030): + for year in range(FIRST_REBALANCING_YEAR, LAST_SET_YEAR + 1): if not rebalancing.active(year): continue is_post_2025_claimant = _new_claimant_health_element_status( - sim, year, uc_seed < post_2025_claimant_share[year], built_from_data + sim, year, uc_seed < new_claimant_share(year), built_from_data ) # Copy: calculate returns the simulation's cached array, and the writes # below must reach the cache only through set_input. @@ -152,8 +176,8 @@ def add_universal_credit_reform(sim: Microsimulation): current_health_element[has_health_element & ~is_post_2025_claimant] = ( _protected_existing_health_element_monthly(sim, year) * 12 ) - # Set post-April 2026 claimants to £217.26/month. - # https://bills.parliament.uk/publications/62123/documents/6889#page=16 + # New claimants: £217.26 a month from April 2026 (UC Act 2025 s. 2), + # frozen to 2029-30 (s. 3) and uprated after. current_health_element[has_health_element & is_post_2025_claimant] = ( new_claimant_health_element(year) * 12 ) # Monthly amount * 12 diff --git a/policyengine_uk/tests/test_uc_health_element_after_freeze.py b/policyengine_uk/tests/test_uc_health_element_after_freeze.py new file mode 100644 index 0000000000..0b9ca1b39e --- /dev/null +++ b/policyengine_uk/tests/test_uc_health_element_after_freeze.py @@ -0,0 +1,259 @@ +"""Universal Credit LCWRA element from 2030-31 (issue #2282). + +Universal Credit Act 2025 s. 2 relabels the reg. 36 rows for good: the +new-claimant amount and the protected amount (pre-2026, severe conditions +criteria and terminally ill claimants) both continue. s. 3 disapplies the +SSAA 1992 s. 150 up-rating only in the tax years ending 5 April 2026 to +5 April 2029, and s. 4's floor for the protected amount covers only 2026-27 to +2029-30. So from 2030-31 both amounts are uprated again under s. 150. + +The rebalancing scenario used to set the element only to 2029-30; from +2030-31 every claimant fell to the new-claimant amount, a protected claimant +from £457.15 to £235.86 a month. + +Invariants: + +1. No drop: from 2026-27 to the end of the data's projection, neither a + protected nor a new claimant's element falls from one year to the next, + for any CPI path; with non-negative CPI their Universal Credit does not + fall either (the standard allowance follows CPI down when it falls). +2. From 2030-31 the protected amount is the year before's times the growth + of the element's uprating index, the index the new-claimant amount + follows. +3. Reforms take effect: a 2030-31 change to either amount moves that + claimant's element and Universal Credit by the same amount. The + scenario projects a protected amount from the changed one; a one-year + change to the new-claimant amount, an uprated parameter, gives way to + prior law's path the next year (``policyengine_uk.utils.dated_reforms``). + The new-claimant share is a parameter the draw reads in every year it + sets. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +import policyengine_uk.scenarios.uc_reform as uc_reform +from policyengine_uk import Scenario, Simulation + +YEARS = range(uc_reform.FIRST_REBALANCING_YEAR, uc_reform.LAST_SET_YEAR + 1) +FIRST_UPRATED_YEAR = uc_reform.SECTION_4_LAST_YEAR + 1 +REBALANCING = "gov.dwp.universal_credit.rebalancing" +PROTECTED = f"{REBALANCING}.protected_health_element" +NEW_CLAIMANT = f"{REBALANCING}.new_claimant_health_element" +SHARE = f"{REBALANCING}.new_claimant_share" +OBR_CPI = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" + + +def at(year: int) -> str: + return f"{year}-04-30" + + +def claimant(new_claimant: bool) -> dict: + """A single claimant aged 30 with LCWRA and no income, stating whether + they are a new claimant.""" + return { + "people": { + "person": { + "age": {year: 30 for year in YEARS}, + "employment_income": {year: 0 for year in YEARS}, + "uc_limited_capability_for_WRA": {year: True for year in YEARS}, + } + }, + "benunits": { + "benunit": { + "members": ["person"], + "uc_receives_new_claimant_health_element": { + year: new_claimant for year in YEARS + }, + } + }, + "households": {"household": {"members": ["person"]}}, + } + + +def monthly(sim: Simulation, variable: str, year: int) -> float: + return float(sim.calculate(variable, year)[0]) / 12 + + +def index_growth(sim: Simulation, year: int) -> float: + index = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.elements.disabled.uprating + return index(at(year)) / index(at(year - 1)) + + +def assert_never_falls( + sim: Simulation, variables=("uc_LCWRA_element", "universal_credit") +) -> None: + """Invariant 1.""" + for variable in variables: + path = [monthly(sim, variable, year) for year in YEARS] + for year, previous, current in zip(YEARS[1:], path, path[1:]): + assert current >= previous - 1e-6, (variable, year, path) + + +@pytest.mark.parametrize("new_claimant", [False, True]) +def test_no_drop_from_2029_30_to_2030_31(new_claimant): + sim = Simulation(situation=claimant(new_claimant)) + assert_never_falls(sim) + assert monthly(sim, "uc_LCWRA_element", FIRST_UPRATED_YEAR) > monthly( + sim, "uc_LCWRA_element", FIRST_UPRATED_YEAR - 1 + ) + + +def test_protected_amount_is_uprated_by_the_element_index_from_2030_31(): + """Invariant 2.""" + sim = Simulation(situation=claimant(False)) + assert monthly(sim, "uc_LCWRA_element", 2026) == pytest.approx(429.80, rel=1e-6) + for year in range(FIRST_UPRATED_YEAR, YEARS[-1] + 1): + assert monthly(sim, "uc_LCWRA_element", year) == pytest.approx( + monthly(sim, "uc_LCWRA_element", year - 1) * index_growth(sim, year), + rel=1e-6, + ) + + +def test_new_claimant_amount_follows_the_same_index(): + sim = Simulation(situation=claimant(True)) + for year in range(FIRST_UPRATED_YEAR - 3, FIRST_UPRATED_YEAR): + assert monthly(sim, "uc_LCWRA_element", year) == pytest.approx(217.26, rel=1e-6) + for year in range(FIRST_UPRATED_YEAR, YEARS[-1] + 1): + assert monthly(sim, "uc_LCWRA_element", year) == pytest.approx( + monthly(sim, "uc_LCWRA_element", year - 1) * index_growth(sim, year), + rel=1e-6, + ) + + +@pytest.mark.parametrize( + "new_claimant, parameter, amount", + [(False, PROTECTED, 500.0), (True, NEW_CLAIMANT, 300.0)], +) +def test_a_2030_31_reform_moves_universal_credit(new_claimant, parameter, amount): + """Invariant 3: the change reaches the element and Universal Credit, and + the next year projects from it.""" + baseline = Simulation(situation=claimant(new_claimant)) + reformed = Simulation( + situation=claimant(new_claimant), + scenario=Scenario(parameter_changes={parameter: {"2030": amount}}), + ) + year = FIRST_UPRATED_YEAR + assert monthly(reformed, "uc_LCWRA_element", year) == pytest.approx( + amount, rel=1e-6 + ) + change = amount - monthly(baseline, "uc_LCWRA_element", year) + assert monthly(reformed, "universal_credit", year) - monthly( + baseline, "universal_credit", year + ) == pytest.approx(change, rel=1e-5) + next_year = monthly(reformed, "uc_LCWRA_element", year + 1) + if parameter == PROTECTED: + # The scenario projects the protected amount from the changed one. + assert next_year == pytest.approx( + amount * index_growth(reformed, year + 1), rel=1e-6 + ) + else: + # A one-year change to an uprated parameter gives way to prior law. + assert next_year == pytest.approx( + monthly(baseline, "uc_LCWRA_element", year + 1), rel=1e-6 + ) + # Earlier years are unchanged. + assert monthly(reformed, "uc_LCWRA_element", year - 1) == pytest.approx( + monthly(baseline, "uc_LCWRA_element", year - 1), rel=1e-6 + ) + + +CPI_SETTINGS = settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +def cpi_path_simulation(new_claimant: bool, growth) -> Simulation: + """A claimant under an OBR CPI path for 2024 to 2032 (the benefit index + lags it a year; September CPI drives the s. 4 floor).""" + changes = { + OBR_CPI: {f"year:{year}": rate for year, rate in zip(range(2024, 2033), growth)} + } + return Simulation( + situation=claimant(new_claimant), + scenario=Scenario(parameter_changes=changes, applied_before_data_load=True), + ) + + +@CPI_SETTINGS +@given(st.lists(st.floats(-0.05, 0.15, allow_nan=False), min_size=9, max_size=9)) +def test_the_protected_amount_never_falls_for_any_cpi_path(growth): + """Invariant 1 for the element: the s. 4 floor never falls, and the + uprating index never falls.""" + assert_never_falls(cpi_path_simulation(False, growth), ("uc_LCWRA_element",)) + + +@CPI_SETTINGS +@given(st.lists(st.floats(0, 0.15, allow_nan=False), min_size=9, max_size=9)) +def test_no_claimant_loses_from_one_year_to_the_next_with_non_negative_cpi(growth): + """Invariant 1 for the element and Universal Credit.""" + for new_claimant in (False, True): + assert_never_falls(cpi_path_simulation(new_claimant, growth)) + + +class _FixedDraw: + def __init__(self, values): + self.values = np.array(values, dtype=float) + + def random(self, size): + assert size == len(self.values) + return self.values + + +def _two_lcwra_claimants(): + from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + import pandas as pd + + single_year = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 2], + "person_household_id": [1, 2], + "age": [30, 30], + "uc_limited_capability_for_WRA": [True, True], + } + ), + benunit=pd.DataFrame({"benunit_id": [1, 2]}), + household=pd.DataFrame( + { + "household_id": [1, 2], + "household_weight": [1.0, 1.0], + "region": ["NORTH_EAST", "NORTH_EAST"], + } + ), + fiscal_year=2026, + ) + return UKMultiYearDataset(datasets=[single_year]) + + +def test_the_draw_reads_the_new_claimant_share_in_every_year_it_sets(monkeypatch): + """Invariant 3: data without a status takes the seeded draw against + ``rebalancing.new_claimant_share``, from 2026-27 to the end of the + projection; the share holds at its 2029-30 value after.""" + monkeypatch.setattr( + uc_reform.np.random, "default_rng", lambda seed: _FixedDraw([0.2, 0.99]) + ) + baseline = Simulation(dataset=_two_lcwra_claimants()) + status = { + year: list(baseline.calculate("uc_receives_new_claimant_health_element", year)) + for year in YEARS + } + # 0.2 is below the 22% share from 2029-30, above the earlier shares. + for year in YEARS: + assert status[year] == [year >= 2029, False], year + reformed = Simulation( + dataset=_two_lcwra_claimants(), + scenario=Scenario(parameter_changes={SHARE: {"2031": 0.1}}), + ) + assert list( + reformed.calculate("uc_receives_new_claimant_health_element", 2031) + ) == [False, False] + assert list( + reformed.calculate("uc_receives_new_claimant_health_element", 2032) + ) == [True, False] From 8fd4a11cded3f26e78bff0a3a1e7fc090f60b085 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 09:46:43 -0400 Subject: [PATCH 2/6] Name the scenario's years; protected amount never below new-claimant amount - uc_reform.REBALANCING_YEARS: the tax years in which the rebalancing scenario sets the element (2026 to PROJECTION_END_YEAR), one public name other code can read (the UK hub's guard for elements.disabled.amount reforms). - The non-negative-CPI property also checks that the protected amount is never below the new-claimant amount in any year (UK hub's spec on #2282). - Two of #2284's tests assumed the formula decides 2030-31 awards. With the scenario setting 2030-31, a household without a stated claim status gets the protected amount, so they now state a new claimant, and the reform-dict test changes new_claimant_health_element, the amount the scenario pays. Refs #2282. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/scenarios/uc_reform.py | 12 ++++---- .../test_uc_health_element_after_freeze.py | 17 ++++++++--- .../tests/test_uc_health_element_freeze.py | 29 +++++++++++++++---- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 74f769cfb4..4bb4c340c3 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -9,10 +9,12 @@ # ss. 2 to 4) and the last the s. 4 duty covers (2029-30). FIRST_REBALANCING_YEAR = 2026 SECTION_4_LAST_YEAR = 2029 -# The scenario sets the element to the end of the data's projection. Later -# years fall to the uc_LCWRA_element formula, which pays every claimant the -# new-claimant amount. -LAST_SET_YEAR = PROJECTION_END_YEAR +# The tax years in which the scenario sets the element: from the first year of +# the rebalancing to the end of the data's projection. Other code reads this +# (for example to warn that a reform to elements.disabled.amount does not reach +# these years' awards). Later years fall to the uc_LCWRA_element formula, which +# pays every claimant the new-claimant amount. +REBALANCING_YEARS = range(FIRST_REBALANCING_YEAR, PROJECTION_END_YEAR + 1) # The standard allowance amounts the protected LCWRA amount is paired with in @@ -162,7 +164,7 @@ def add_universal_credit_reform(sim: Microsimulation): new_claimant_share = rebalancing.new_claimant_share new_claimant_health_element = rebalancing.new_claimant_health_element built_from_data = getattr(sim, "built_from_dataset", False) - for year in range(FIRST_REBALANCING_YEAR, LAST_SET_YEAR + 1): + for year in REBALANCING_YEARS: if not rebalancing.active(year): continue is_post_2025_claimant = _new_claimant_health_element_status( diff --git a/policyengine_uk/tests/test_uc_health_element_after_freeze.py b/policyengine_uk/tests/test_uc_health_element_after_freeze.py index 0b9ca1b39e..9015101637 100644 --- a/policyengine_uk/tests/test_uc_health_element_after_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_after_freeze.py @@ -16,7 +16,8 @@ 1. No drop: from 2026-27 to the end of the data's projection, neither a protected nor a new claimant's element falls from one year to the next, for any CPI path; with non-negative CPI their Universal Credit does not - fall either (the standard allowance follows CPI down when it falls). + fall either (the standard allowance follows CPI down when it falls), and + the protected amount is never below the new-claimant amount. 2. From 2030-31 the protected amount is the year before's times the growth of the element's uprating index, the index the new-claimant amount follows. @@ -37,7 +38,7 @@ import policyengine_uk.scenarios.uc_reform as uc_reform from policyengine_uk import Scenario, Simulation -YEARS = range(uc_reform.FIRST_REBALANCING_YEAR, uc_reform.LAST_SET_YEAR + 1) +YEARS = uc_reform.REBALANCING_YEARS FIRST_UPRATED_YEAR = uc_reform.SECTION_4_LAST_YEAR + 1 REBALANCING = "gov.dwp.universal_credit.rebalancing" PROTECTED = f"{REBALANCING}.protected_health_element" @@ -191,9 +192,17 @@ def test_the_protected_amount_never_falls_for_any_cpi_path(growth): @CPI_SETTINGS @given(st.lists(st.floats(0, 0.15, allow_nan=False), min_size=9, max_size=9)) def test_no_claimant_loses_from_one_year_to_the_next_with_non_negative_cpi(growth): - """Invariant 1 for the element and Universal Credit.""" + """Invariant 1 for the element and Universal Credit, and the protected + amount against the new-claimant amount.""" + elements = {} for new_claimant in (False, True): - assert_never_falls(cpi_path_simulation(new_claimant, growth)) + sim = cpi_path_simulation(new_claimant, growth) + assert_never_falls(sim) + elements[new_claimant] = [ + monthly(sim, "uc_LCWRA_element", year) for year in YEARS + ] + for year, protected, new in zip(YEARS, elements[False], elements[True]): + assert protected >= new - 1e-6, year class _FixedDraw: diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py index 788c4e3759..54da833220 100644 --- a/policyengine_uk/tests/test_uc_health_element_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -172,10 +172,23 @@ def test_a_benefit_index_change_after_30_april_reaches_the_next_tax_year(): assert_one_path(parameters, range(2026, HORIZON + 1)) +# The same claimant, stated to be a new claimant (#2250). +NEW_CLAIMANT_SITUATION = { + **SITUATION, + "benunits": { + "benunit": { + "members": ["person"], + "uc_receives_new_claimant_health_element": {2030: True}, + } + }, +} + + def test_household_element_after_the_freeze(): - """From 2030-31 no scenario modifier sets the element, so a household - with LCWRA gets twelve times the uprated monthly amount.""" - simulation = Simulation(situation=SITUATION) + """In 2030-31 a new claimant with LCWRA gets twelve times the uprated + monthly amount: the rebalancing scenario pays the new-claimant amount, + which follows the same index (#2282).""" + simulation = Simulation(situation=NEW_CLAIMANT_SITUATION) monthly = system.parameters.get_child(AMOUNT)(at(2030)) # The formula returns float32. assert float(simulation.calculate("uc_LCWRA_element", 2030)[0]) == pytest.approx( @@ -292,8 +305,14 @@ def test_a_changed_amount_is_held_through_the_freeze(): def test_a_reform_dict_changes_the_year_it_names(): - simulation = Simulation(situation=SITUATION, reform={AMOUNT: {"2030": 300}}) - assert simulation.calculate("uc_LCWRA_element", 2030)[0] == pytest.approx(3_600) + """The scenario pays a new claimant ``new_claimant_health_element`` in the + years it sets, so a reform dict on that amount reaches the award.""" + simulation = Simulation( + situation=NEW_CLAIMANT_SITUATION, reform={NEW_CLAIMANT_AMOUNT: {"2030": 300}} + ) + assert float(simulation.calculate("uc_LCWRA_element", 2030)[0]) == pytest.approx( + 3_600, rel=1e-6 + ) CPI_GROWTH = st.floats(min_value=-0.05, max_value=0.15, allow_nan=False) From b6f3f3f5c75df7e85e5f6bea10d69a98b8d60b64 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 10:43:01 -0400 Subject: [PATCH 3/6] Read the LCWRA uprating index at its new path #2284 moved it to gov.economic_assumptions.indices.uc_health_element. Refs #2282. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 2 +- policyengine_uk/tests/test_uc_health_element_after_freeze.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 5426ce6130..c21e20043e 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -35,7 +35,7 @@ Existing recipients are treated differently. Universal Credit Regulations 2013 r For a tax year without a legislated amount, the model uses the lowest amount the Universal Credit Act 2025 s. 4 duty allows. For every standard allowance amount, the protected amount plus that allowance must be at least the previous year's sum, increased by the relevant CPI percentage: the CPI 12-month rate in the September before the tax year, never below 0% (s. 4(4)(a)). The model reads that rate from `gov.economic_assumptions.statutory_uprating_inputs.cpi_september`. Section 3 switches off the element's ordinary uprating, so the amount otherwise stays where it was. Run from the 2025-26 amounts at the 3.8% September 2025 CPI rate, this rule gives the legislated £429.80. -The s. 4 duty covers 2026-27 to 2029-30 only, and s. 3 disapplies the annual up-rating only in the tax years ending 5 April 2026 to 5 April 2029. The protected row stays in reg. 36: s. 2 only relabels it. So from 2030-31 the protected amount is the year before's amount uprated by the same index as the new-claimant amount, `gov.dwp.universal_credit.elements.disabled.uprating`, and a protected claimant keeps the higher amount. +The s. 4 duty covers 2026-27 to 2029-30 only, and s. 3 disapplies the annual up-rating only in the tax years ending 5 April 2026 to 5 April 2029. The protected row stays in reg. 36: s. 2 only relabels it. So from 2030-31 the protected amount is the year before's amount uprated by the same index as the new-claimant amount, `gov.economic_assumptions.indices.uc_health_element`, and a protected claimant keeps the higher amount. Whether a benefit unit gets the new-claimant rate is the benefit unit's `uc_receives_new_claimant_health_element` for the year. A household situation can set it, for example `{"uc_receives_new_claimant_health_element": {"2026": True}}` on the benefit unit; otherwise it is false, which pays the protected amount. A dataset can supply it too. A simulation built from data that does not supply it assigns it by a seeded draw against `gov.dwp.universal_credit.rebalancing.new_claimant_share`, from 2026-27 to the end of the data's projection. The shares to 2029-30 are WPI Economics' estimates for the Trussell Trust, derived from administrative Personal Independence Payment data: diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 4bb4c340c3..0260469ef3 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -113,7 +113,7 @@ def _protected_existing_health_element_monthly( ) previous = _protected_existing_health_element_monthly(sim, year - 1) if year > SECTION_4_LAST_YEAR: - index = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.elements.disabled.uprating + index = sim.tax_benefit_system.parameters.gov.economic_assumptions.indices.uc_health_element return previous * ( index(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") / index(f"{year - 1}-{FISCAL_YEAR_SAMPLE_DATE}") diff --git a/policyengine_uk/tests/test_uc_health_element_after_freeze.py b/policyengine_uk/tests/test_uc_health_element_after_freeze.py index 9015101637..6add0f9d89 100644 --- a/policyengine_uk/tests/test_uc_health_element_after_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_after_freeze.py @@ -79,7 +79,7 @@ def monthly(sim: Simulation, variable: str, year: int) -> float: def index_growth(sim: Simulation, year: int) -> float: - index = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.elements.disabled.uprating + index = sim.tax_benefit_system.parameters.gov.economic_assumptions.indices.uc_health_element return index(at(year)) / index(at(year - 1)) From 555dc5ffbf723e14565b321e56905a9a1a5cb5aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 14:53:44 -0400 Subject: [PATCH 4/6] Document reforms to the index; correct the scenario's stated years - #2284 review round 7: a reform can name the generated index directly. It is a calendar-year series written just after the builder and before uprating, so it replaces the freeze and the no-fall floor for the years it names. The builder docstring, the index's description and the docs say so, and a test checks it for "2028" and "2028-01-01". - #2428 review round 1: the scenario sets the element to 2032-33, so the formula decides from 2033-34; the docs and the new-claimant amount's description said 2032-33 and 2029-30. Refs #2282. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 6 +++--- .../create_health_element_uprating.py | 19 ++++++++++++++----- .../new_claimant_health_element.yaml | 2 +- .../tests/test_uc_health_element_freeze.py | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index c21e20043e..80f9162c97 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,10 +24,10 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di Two parameters hold this amount, and both are uprated by `gov.economic_assumptions.indices.uc_health_element`, so they follow the same path: -- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and for years after the data's projection ends (2032-33 on), when the rebalancing scenario no longer sets the element. -- `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants for 2026-27 to 2029-30. +- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and from 2033-34, after the data's projection ends, when the rebalancing scenario no longer sets the element. +- `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants in the years it sets the element, `uc_reform.REBALANCING_YEARS` (2026-27 to 2032-33). -A change to the freeze or to the benefit uprating index moves both. A reform to either amount itself does not change the other. +A change to the freeze or to the benefit uprating index moves both, and so does a reform that sets the index, `gov.economic_assumptions.indices.uc_health_element`, directly (it replaces the freeze and the no-fall floor for the years it names). A reform to either amount itself does not change the other. `rebalancing/create_health_element_uprating.py` builds the index when parameters are processed: the benefit uprating index, held flat in each year in which `rebalancing.health_element_freeze` and `rebalancing.active` are both true. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py index 909688f41c..f33f621bcd 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -52,8 +52,16 @@ by a year or a ``start.stop`` range. So a change to the freeze, to ``rebalancing.active``, to the benefit index or the forecasts behind it, or to an amount itself reaches the element, which is then held or uprated from the -changed value. A ``reform=`` dict key naming a single day still edits the -annualised parameter on that day only and does not re-run uprating. +changed value. That is the default, generated path. + +A reform can also name the index itself. Processing writes such a change just +after this step and before uprating, so it replaces the generated value, the +freeze and the no-fall floor included, for the years it names. The index is a +calendar-year series (it inherits ``calendar_year_series`` from +``gov.economic_assumptions.indices``), so on it a bare year or a 1 January +key names that calendar year and re-runs processing. On other parameters a +``reform=`` dict key naming a single day still edits the annualised parameter +on that day only and does not re-run uprating. """ from typing import Dict @@ -187,9 +195,10 @@ def add_uc_health_element_uprating(parameters: ParameterNode) -> ParameterNode: "the benefit uprating index, held flat in the tax years " "for which the Universal Credit Act 2025 freezes the " "element and never falling. It is built when parameters " - "are processed; to change it, change " - "gov.dwp.universal_credit.rebalancing." - "health_element_freeze or the benefit uprating index." + "are processed from gov.dwp.universal_credit.rebalancing." + "health_element_freeze and the benefit uprating index; a " + "reform that sets it directly replaces that path, freeze " + "and floor included, for the years it names." ), "values": health_element_index_values(parameters), "metadata": { diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml index 4c9c14d4dd..b14b5c0bf6 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml @@ -1,4 +1,4 @@ -description: Monthly value of the Universal Credit health element (the LCWRA element) for new claimants from April 2026. The rebalancing scenario pays it to new claimants for 2026-27 to 2029-30. It follows the same path as the LCWRA element amount, frozen for 2026-27 to 2029-30 by Universal Credit Act 2025 s. 3 and uprated afterwards. +description: Monthly value of the Universal Credit health element (the LCWRA element) for new claimants from April 2026. The rebalancing scenario pays it to new claimants in the years it sets the element (2026-27 to 2032-33, the end of the data's projection). It follows the same path as the LCWRA element amount, frozen for 2026-27 to 2029-30 by Universal Credit Act 2025 s. 3 and uprated afterwards. values: 2026-01-01: 217.26 metadata: diff --git a/policyengine_uk/tests/test_uc_health_element_freeze.py b/policyengine_uk/tests/test_uc_health_element_freeze.py index a7f2aa04d4..02a9e74884 100644 --- a/policyengine_uk/tests/test_uc_health_element_freeze.py +++ b/policyengine_uk/tests/test_uc_health_element_freeze.py @@ -291,6 +291,24 @@ def test_lifting_the_freeze_reaches_a_simulated_new_claimant(monkeypatch): ) == pytest.approx(12 * ACT_AMOUNT * growth, rel=1e-6), route +INDEX = "gov.economic_assumptions.indices.uc_health_element" + + +@pytest.mark.parametrize("key", ["2028", "2028-01-01"]) +def test_a_reform_to_the_index_replaces_the_generated_path_for_its_year(key): + """The index is a calendar-year series generated before uprating, so a + reform that sets it reaches both amounts for the year it names, freeze + included, and prior law resumes after.""" + parameters = scenario_parameters({INDEX: {key: 2.0}}) + index = parameters.get_child(INDEX) + assert index(at(2028)) == pytest.approx(2.0) + path = amount_path(parameters, range(2026, 2031)) + assert path[2027] == pytest.approx(ACT_AMOUNT) + assert path[2028] == pytest.approx(ACT_AMOUNT * 2.0 / index(at(2027)), rel=1e-12) + assert path[2029] == pytest.approx(ACT_AMOUNT) + assert_one_path(parameters, range(2026, 2031)) + + def test_a_changed_amount_is_held_through_the_freeze(): """A one-year change applies to its tax year; the stated amount resumes after it and is held, then uprated.""" From d2d05e509b708d7da1b1e96cd2489117299f91bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 15:44:55 -0400 Subject: [PATCH 5/6] Limit the single-day reform caveat to non-calendar-year parameters #2428 review round 2: other calendar-year series also read a 1 January key as the whole year. Refs #2282. Co-Authored-By: Claude Opus 5.5 --- .../rebalancing/create_health_element_uprating.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py index f33f621bcd..916f9bcefd 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/create_health_element_uprating.py @@ -59,9 +59,10 @@ freeze and the no-fall floor included, for the years it names. The index is a calendar-year series (it inherits ``calendar_year_series`` from ``gov.economic_assumptions.indices``), so on it a bare year or a 1 January -key names that calendar year and re-runs processing. On other parameters a -``reform=`` dict key naming a single day still edits the annualised parameter -on that day only and does not re-run uprating. +key names that calendar year and re-runs processing. On parameters that are +not calendar-year series, a ``reform=`` dict key naming a single day still +edits the annualised parameter on that day only and does not re-run +uprating. """ from typing import Dict From a66d1adbc0b859f40622d2eec305d6127803f499 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 00:57:07 -0400 Subject: [PATCH 6/6] Derive the rebalancing years in tests from REBALANCING_YEARS The dataframe export test and two rebalancing tests hard-coded 2026 to 2029, so the protected LCWRA amount running on to 2030-31 (#2428) failed CI. They now read REBALANCING_YEARS, the scenario's own range. Co-Authored-By: Claude Code --- ...nefit_cap_specified_benefits_properties.py | 5 ++-- .../tests/test_dataframe_dataset.py | 26 +++++++++++-------- policyengine_uk/tests/test_uc_rebalancing.py | 8 +++--- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 3b1c383785..a9e2a893f6 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -73,6 +73,7 @@ from policyengine_core.reforms import Reform from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable +from policyengine_uk.scenarios.uc_reform import REBALANCING_YEARS from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input YEAR = 2025 @@ -675,7 +676,7 @@ def rebalanced(): return simulation, float(simulation.calculate("uc_LCWRA_element", year)[0]) -REBALANCED_YEARS = [2026, 2027, 2028, 2029] +REBALANCED_YEARS = list(REBALANCING_YEARS) @pytest.mark.parametrize( @@ -699,7 +700,7 @@ def rebalanced(): def test_an_input_makes_the_element_an_entry_only_in_years_it_stores( rebalanced, input_period, amount, entered_years ): - """The rebalancing stores the model's element for 2026-2029. An input + """The rebalancing stores the model's element for each rebalancing year. An input for another period that leaves a year's stored value in place leaves it the model's, so the non-claimant's limited capability still does not lift the cap that year; an input stored for the year is an entry, even diff --git a/policyengine_uk/tests/test_dataframe_dataset.py b/policyengine_uk/tests/test_dataframe_dataset.py index 6ba665b8fa..0f803680d0 100644 --- a/policyengine_uk/tests/test_dataframe_dataset.py +++ b/policyengine_uk/tests/test_dataframe_dataset.py @@ -33,7 +33,7 @@ This holds for frames that name the identifiers and give values for different years, and for simulations given inputs for other years once built, as the Universal Credit rebalancing gives each benefit unit's - health element status for 2026 to 2029. The export gives every + health element status for each rebalancing year. The export gives every identifier for each whole year a column names, which is where a simulation built from it holds them, and person_id and the benefit unit and household identifiers exactly. @@ -87,6 +87,7 @@ with_identifiers_in_each_year, ) from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.scenarios.uc_reform import REBALANCING_YEARS pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -1207,11 +1208,12 @@ def test_members_may_all_leave_a_group_value_missing(tax_benefit_system): ) ROUND_TRIP_SETTINGS = settings(SIMULATION_SETTINGS, max_examples=15) # The smallest example of the round trip failing once the Universal Credit -# rebalancing recorded each benefit unit's health element status for 2026 to -# 2029 (#2251). The export gave that status for those years but the person -# and their groups for 2025 alone. Loaded back, the frame was a dataset with a -# year for each of 2025 to 2029, which holds the identifiers in each, so the -# second export gave them for 2026 to 2029 as well: 38 columns, not 18. +# rebalancing recorded each benefit unit's health element status for its +# rebalancing years (2026 to 2029 when #2251 landed). The export gave that +# status for those years but the person and their groups for 2025 alone. +# Loaded back, the frame was a dataset with a year for each of 2025 to 2029, +# which holds the identifiers in each, so the second export gave them for +# those years as well: 38 columns, not 18. ONE_PERSON = Population( person_ids=np.array([1]), groups={"benunit": np.array([1]), "household": np.array([1])}, @@ -1283,16 +1285,18 @@ def columns_of(frame_: pd.DataFrame, variable: str) -> list: def test_an_export_names_the_people_in_each_year_the_rebalancing_records(): # The rebalancing records the status of a simulation built from data for - # 2025 for 2026 to 2029, where it holds its people and groups by carrying - # them forward from 2025. + # 2025 for each rebalancing year, where it holds its people and groups by + # carrying them forward from 2025. exported = Simulation(dataset=frame(ONE_PERSON)).to_input_dataframe() status = "uc_receives_new_claimant_health_element" - years = range(YEAR, 2030) - assert columns_of(exported, status) == [f"{status}__{year}" for year in years[1:]] + identifier_years = range(YEAR, REBALANCING_YEARS[-1] + 1) + assert columns_of(exported, status) == [ + f"{status}__{year}" for year in REBALANCING_YEARS + ] for identifier in IDENTIFIER_VARIABLES: columns = columns_of(exported, identifier) - assert columns == [f"{identifier}__{year}" for year in years] + assert columns == [f"{identifier}__{year}" for year in identifier_years] # Together, where the exporter puts an input set for each year. start = exported.columns.get_loc(columns[0]) assert exported.columns[start : start + len(columns)].tolist() == columns diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 302bd3c6dd..5340e91e53 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -6,7 +6,7 @@ import policyengine_uk.scenarios.uc_reform as uc_reform from policyengine_uk import Scenario, Simulation -YEARS = range(2025, 2030) +YEARS = range(2025, uc_reform.REBALANCING_YEARS[-1] + 1) def _uc_claimant(age_2025: int) -> dict: @@ -186,7 +186,7 @@ def test_a_scalar_override_applies_in_every_year(monkeypatch): parameter_changes={PROTECTED: 500}, applied_before_data_load=True ), ) - for year in range(2026, 2030): + for year in uc_reform.REBALANCING_YEARS: assert _protected_monthly(sim, year) == pytest.approx(500, rel=0, abs=1e-3) @@ -276,7 +276,7 @@ def test_new_claimants_use_fixed_health_element(): } sim = Simulation(situation=situation) - for year in range(2026, 2030): + for year in uc_reform.REBALANCING_YEARS: health_element = sim.calculate("uc_LCWRA_element", year)[0] / 12 assert health_element == pytest.approx(_new_claimant_rate(sim, year)) @@ -308,7 +308,7 @@ def test_household_award_does_not_depend_on_its_position(): sim = Simulation(situation=_many_uc_claimants(40)) alone = Simulation(situation=_uc_claimant(30)) - for year in range(2026, 2030): + for year in uc_reform.REBALANCING_YEARS: health_elements = sim.calculate("uc_LCWRA_element", year) assert np.all(health_elements == health_elements[0]) assert health_elements[0] == pytest.approx(