Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2250.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/book/policy/uc-rebalancing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand Down
32 changes: 31 additions & 1 deletion policyengine_uk/scenarios/uc_reform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))
Expand Down
132 changes: 124 additions & 8 deletions policyengine_uk/tests/test_uc_rebalancing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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"
Loading