From 2af2c3dfb0cce419fd7c91fae1258ad9462331e4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 07:37:19 -0400 Subject: [PATCH 1/2] Stop deciding a household's UC health element rate by its position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UC rebalancing modifier gave the new-claimant LCWRA rate by a seeded draw over every benefit unit in a simulation, situations included, so a household's award depended on its position among the others (5 of 40 identical households in one situation got £217.26 on main) and no situation could state the claimant's status. Add uc_receives_new_claimant_health_element (benefit unit, default false). A supplied value is used as given; data without one keeps the seeded draw, recorded as an input; a situation without one gets the protected amount. Microsimulation results are unchanged. Fixes #2250 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2250.fixed.md | 1 + policyengine_uk/scenarios/uc_reform.py | 30 ++++- policyengine_uk/tests/test_uc_rebalancing.py | 109 ++++++++++++++++-- ...uc_receives_new_claimant_health_element.py | 19 +++ 4 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2250.fixed.md create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_receives_new_claimant_health_element.py 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/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index a92eb473ba..05ce299dac 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -100,6 +100,31 @@ 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. + """ + supplied = sim.get_holder(NEW_CLAIMANT_STATUS).get_array(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 @@ -114,10 +139,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 2695ee531b..d2308748cd 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -226,19 +226,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, + ] + + +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])) - assert health_element == pytest.approx(expected_health) + 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): 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" From af865c6c2a758e576bdf18d682d2f2e77dead4d9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:13:37 -0400 Subject: [PATCH 2/2] Read a branch's own health element status; document the status input Address the review of #2251: - Look the status up with sim.get_array, which reads the simulation's branch and the branches it inherits from; the holder's get_array read only the default branch, so a branch-supplied status was ignored. A new test sets a branch's status, checks the branch and a nested branch get the new-claimant rate and the parent is unchanged; it fails on the old lookup. - uc-rebalancing.md: the seeded draw applies only to data without a supplied status; situations default to the protected amount and can set the status per year. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 4 +++- policyengine_uk/tests/test_uc_rebalancing.py | 23 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) 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 05ce299dac..95d80b01e4 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -114,7 +114,9 @@ def _new_claimant_health_element_status( there would make a household's award depend on its position among the other benefit units in the simulation. """ - supplied = sim.get_holder(NEW_CLAIMANT_STATUS).get_array(year) + # 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: diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index d2308748cd..98deb237d2 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -350,3 +350,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)