diff --git a/changelog.d/2250.fixed.md b/changelog.d/2250.fixed.md new file mode 100644 index 0000000000..507c4a168c --- /dev/null +++ b/changelog.d/2250.fixed.md @@ -0,0 +1 @@ +- Let a household situation state whether its Universal Credit health element is at the new-claimant rate (`uc_receives_new_claimant_health_element`, default false), so a household's award no longer depends on its position among the other benefit units in a simulation; simulations built from data keep the seeded draw. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 189eea7073..b58e3170e0 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,7 +24,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 implementation uses 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: +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: - 2026: 11% - 2027: 13% diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 459e6c18b9..de15332e5a 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -95,6 +95,33 @@ def _protected_existing_health_element_monthly( ) +NEW_CLAIMANT_STATUS = "uc_receives_new_claimant_health_element" + + +def _new_claimant_health_element_status( + sim: Microsimulation, year: int, drawn: np.ndarray, built_from_data: bool +) -> np.ndarray: + """Which benefit units get the new-claimant LCWRA rate in a year. + + A value the situation or dataset supplies is used as given. Data without + one takes the seeded draw, recorded as an input. A household situation + without one gets the protected amount (the variable's default): a draw + there would make a household's award depend on its position among the + other benefit units in the simulation. + """ + # sim.get_array reads the simulation's own branch (and the branches it + # inherits from); the holder's get_array reads only the default branch. + supplied = sim.get_array(NEW_CLAIMANT_STATUS, year) + if supplied is not None: + return np.asarray(supplied, dtype=bool) + if built_from_data: + status = np.asarray(drawn, dtype=bool) + else: + status = np.zeros(len(drawn), dtype=bool) + sim.set_input(NEW_CLAIMANT_STATUS, year, status) + return status + + def add_universal_credit_reform(sim: Microsimulation): rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing @@ -109,10 +136,13 @@ def add_universal_credit_reform(sim: Microsimulation): 2029: 0.22, } # WPI Economics for Trussell Trust based on admin PIP data, 2025 new_claimant_health_element = rebalancing.new_claimant_health_element + built_from_data = getattr(sim, "built_from_dataset", False) for year in range(2026, 2030): if not rebalancing.active(year): continue - is_post_2025_claimant = uc_seed < post_2025_claimant_share[year] + is_post_2025_claimant = _new_claimant_health_element_status( + sim, year, uc_seed < post_2025_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. current_health_element = np.array(sim.calculate("uc_LCWRA_element", year)) diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index dbf81ad300..302bd3c6dd 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -261,19 +261,112 @@ def test_section_4_floor_rises_with_the_previous_amount( ) >= uc_reform.protected_lcwra_floor(previous, before, now, factor) -def test_new_claimants_use_fixed_health_element(monkeypatch): - _force_uc_seed(monkeypatch, [0.0]) - sim = Simulation(situation=_uc_claimant(30)) +def _new_claimant_rate(sim: Simulation, year: int) -> float: + return float( + sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element + ) + + +def test_new_claimants_use_fixed_health_element(): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_receives_new_claimant_health_element"] = { + year: True for year in YEARS + } + sim = Simulation(situation=situation) for year in range(2026, 2030): health_element = sim.calculate("uc_LCWRA_element", year)[0] / 12 - expected_health = float( - sim.tax_benefit_system.parameters( - str(year) - ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element + assert health_element == pytest.approx(_new_claimant_rate(sim, year)) + + +def _many_uc_claimants(count: int) -> dict: + people = { + f"person_{i}": { + "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}, + } + for i in range(count) + } + return { + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(count) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} for i in range(count) + }, + } + + +def test_household_award_does_not_depend_on_its_position(): + # Before the fix, a seeded draw over the benefit units in the simulation + # gave about 11% of these identical households the new-claimant rate in + # 2026, depending only on their position. + sim = Simulation(situation=_many_uc_claimants(40)) + alone = Simulation(situation=_uc_claimant(30)) + + for year in range(2026, 2030): + health_elements = sim.calculate("uc_LCWRA_element", year) + assert np.all(health_elements == health_elements[0]) + assert health_elements[0] == pytest.approx( + alone.calculate("uc_LCWRA_element", year)[0] ) + assert health_elements[0] / 12 > _new_claimant_rate(sim, year) + + +def _dataset(new_claimant_status=None): + from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset + import pandas as pd + + benunit = {"benunit_id": [1, 2]} + if new_claimant_status is not None: + benunit["uc_receives_new_claimant_health_element"] = new_claimant_status + 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), + 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_data_without_a_status_keeps_the_seeded_draw(monkeypatch): + _force_uc_seed(monkeypatch, [0.0, 0.99]) + sim = Simulation(dataset=_dataset()) + + health_elements = np.asarray(sim.calculate("uc_LCWRA_element", 2026)) / 12 + assert health_elements[0] == pytest.approx(_new_claimant_rate(sim, 2026)) + assert health_elements[1] == pytest.approx(429.80) + assert list(sim.calculate("uc_receives_new_claimant_health_element", 2026)) == [ + True, + False, + ] + - assert health_element == pytest.approx(expected_health) +def test_data_that_supplies_the_status_overrides_the_draw(monkeypatch): + _force_uc_seed(monkeypatch, [0.0, 0.99]) + sim = Simulation(dataset=_dataset(new_claimant_status=[False, True])) + + health_elements = np.asarray(sim.calculate("uc_LCWRA_element", 2026)) / 12 + assert health_elements[0] == pytest.approx(429.80) + assert health_elements[1] == pytest.approx(_new_claimant_rate(sim, 2026)) def test_standard_allowance_reforms_still_change_standard_allowance(monkeypatch): @@ -292,3 +385,26 @@ def test_standard_allowance_reforms_still_change_standard_allowance(monkeypatch) reformed_standard_allowance = reformed.calculate("uc_standard_allowance", 2026)[0] assert reformed_standard_allowance / baseline_standard_allowance > 1.5 + + +def test_a_branch_can_state_its_own_status(): + sim = Simulation(situation=_uc_claimant(30)) + parent = sim.calculate("uc_LCWRA_element", 2026)[0] + branch = sim.get_branch("new_claimant") + branch.set_input("uc_receives_new_claimant_health_element", 2026, [True]) + branch.delete_arrays("uc_LCWRA_element", 2026) + uc_reform.universal_credit_july_2025_reform.simulation_modifier(branch) + + assert branch.calculate("uc_LCWRA_element", 2026)[0] / 12 == pytest.approx( + _new_claimant_rate(sim, 2026) + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(parent) + + # A branch of that branch inherits its status. + nested = branch.get_branch("nested") + nested.delete_arrays("uc_LCWRA_element", 2026) + uc_reform.universal_credit_july_2025_reform.simulation_modifier(nested) + assert nested.calculate("uc_LCWRA_element", 2026)[0] / 12 == pytest.approx( + _new_claimant_rate(sim, 2026) + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(parent) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_receives_new_claimant_health_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_receives_new_claimant_health_element.py new file mode 100644 index 0000000000..23f5f9d682 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_receives_new_claimant_health_element.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class uc_receives_new_claimant_health_element(Variable): + value_type = bool + entity = BenUnit + label = "Universal Credit LCWRA element at the new-claimant rate" + documentation = ( + "Whether the award's LCWRA element is paid at the rate for claimants " + "other than a pre-2026 claimant, a severe conditions criteria claimant " + "or a claimant who is terminally ill (UC Regs 2013 reg. 36, from 6 " + "April 2026). False pays the protected LCWRA amount. A household " + "situation can set it; otherwise it is false. Simulations built from " + "data that do not supply it assign it by a seeded draw at the share of " + "health-element claimants expected to be new claimants each year." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/36"