diff --git a/changelog.d/2247.fixed.md b/changelog.d/2247.fixed.md new file mode 100644 index 0000000000..1459129774 --- /dev/null +++ b/changelog.d/2247.fixed.md @@ -0,0 +1 @@ +- Pay pre-2026 Universal Credit health element claimants the legislated protected LCWRA amount (£429.80 a month in 2026-27, one amount for every claimant type), and project later years at the Universal Credit Act 2025 s. 4 floor. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 5082996119..189eea7073 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -20,12 +20,9 @@ The reforms combine a higher standard allowance, protected awards for existing h From April 2026, new Universal Credit claimants who qualify for the Limited Capability for Work-Related Activity (LCWRA) element receive a fixed monthly amount of £217.26. -Existing recipients are treated differently. Their LCWRA amount is uprated so that the combined value of: +Existing recipients are treated differently. Universal Credit Regulations 2013 reg. 36 gives one amount, the protected LCWRA amount, to every pre-2026 claimant, severe conditions criteria claimant or terminally ill claimant, whatever their age or couple status. For 2026-27 it is £429.80 a month ([SI 2026/113](https://www.legislation.gov.uk/uksi/2026/113/made) reg. 3(3)(b)), stored in `gov.dwp.universal_credit.rebalancing.protected_health_element`. -- their standard allowance, and -- their health element - -rises at least in line with CPI inflation. The model implements that protection through the health element itself, preserving the combined award outcome without separately modelling the small administrative split between protected LCWRA amounts and any under-25 standard allowance supplement. +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: @@ -51,7 +48,7 @@ These uplifts are applied to the CPI-uprated standard allowance for each year. I The reforms are implemented through parameters, scenario modifiers, and scenarios that work together to enable policy analysis. ``` -- **Parameters**: Three YAML files define the reform's activation status, health element amount for new claimants, and standard allowance uplift rates. +- **Parameters**: YAML files define the reform's activation status, the health element amounts for new claimants and for protected (pre-2026) claimants, and the standard allowance uplift rates. - **Scenario modifier**: The `add_universal_credit_reform` function applies the protected existing-claimant health-element path during microsimulation. - **Scenario**: The `universal_credit_july_2025_reform` scenario enables the reforms in policy analysis. 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 new file mode 100644 index 0000000000..6efecca026 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml @@ -0,0 +1,22 @@ +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. +values: + 2026-04-01: + value: 429.80 + reference: + - title: The Universal Credit and Employment and Support Allowance (Rates of Allowances) (Amendment) Regulations 2026, reg. 3(3)(b) + href: https://www.legislation.gov.uk/uksi/2026/113/made + - title: Universal Credit Regulations 2013, reg. 36 table (LCWRA element) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/36 + # Not yet legislated: from here each tax year takes the s. 4 floor. The null + # also ends a reform that sets one year, so the next year projects from it. + 2027-04-01: null +metadata: + # Keep the statutory dates: uc_reform.py reads a tax year's legislated amount + # and projects the years that have none. + preserve_calendar_dates: true + unit: currency-GBP + period: month + label: Universal Credit protected LCWRA element amount + reference: + - title: Universal Credit Act 2025, s. 4 (increase of the protected LCWRA amount) + href: https://www.legislation.gov.uk/ukpga/2025/22/section/4 diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 4252775847..459e6c18b9 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,55 +1,97 @@ from policyengine_uk.model_api import Scenario from policyengine_uk import Microsimulation -from policyengine_uk.variables.gov.dwp.universal_credit.standard_allowance.uc_standard_allowance_claimant_type import ( - UCClaimantType, -) import numpy as np -BASELINE_UC_REBALANCING_YEAR = 2025 +# 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 = ( + "SINGLE_YOUNG", + "SINGLE_OLD", + "COUPLE_YOUNG", + "COUPLE_OLD", +) -def _benefit_uprating_ratio(sim: Microsimulation, year: int) -> float: - parameters = sim.tax_benefit_system.parameters - current_index = float(parameters(str(year)).gov.benefit_uprating_cpi) - baseline_index = float( - parameters(str(BASELINE_UC_REBALANCING_YEAR)).gov.benefit_uprating_cpi +def protected_lcwra_floor( + previous_amount: float, + previous_standard_allowances: dict, + standard_allowances: dict, + cpi_factor: float, +) -> float: + """The lowest protected LCWRA amount the Universal Credit Act 2025 allows. + + s. 4(2): for each standard allowance amount, the protected LCWRA amount + plus that allowance must be at least the previous tax year's sum, + increased by the relevant CPI percentage (never below 0%, s. 4(4)(a)). s. 3 + switches off the element's ordinary uprating, so the amount otherwise stays + where it was. All amounts are monthly. + """ + factor = max(float(cpi_factor), 1.0) + required = max( + (previous_amount + previous_standard_allowances[claimant_type]) * factor + - standard_allowances[claimant_type] + for claimant_type in STANDARD_ALLOWANCE_TYPES ) - return current_index / baseline_index + return max(float(previous_amount), required) -def _rebalanced_standard_allowance_monthly( - sim: Microsimulation, year: int, claimant_type: str -) -> float: - current = sim.tax_benefit_system.parameters(str(year)) - return float( - current.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] - ) +def _standard_allowances_monthly(sim: Microsimulation, year: int) -> dict: + amounts = sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.standard_allowance.amount + return { + claimant_type: float(amounts[claimant_type]) + for claimant_type in STANDARD_ALLOWANCE_TYPES + } -def _protected_existing_health_element_monthly( - sim: Microsimulation, year: int -) -> float: - baseline = sim.tax_benefit_system.parameters(str(BASELINE_UC_REBALANCING_YEAR)) - protected_combined_award = _benefit_uprating_ratio(sim, year) * ( - float(baseline.gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD) - + float(baseline.gov.dwp.universal_credit.elements.disabled.amount) - ) - return protected_combined_award - _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_OLD" - ) +def _relevant_cpi_factor(sim: Microsimulation, year: int) -> float: + """1 + the relevant CPI percentage for a tax year (UC Act 2025 s. 4(4)(a)): + the CPI 12-month rate in the September before it. The 0% floor is applied + in :func:`protected_lcwra_floor`.""" + inputs = sim.tax_benefit_system.parameters.gov.economic_assumptions.statutory_uprating_inputs + return 1 + float(inputs.cpi_september(f"{year - 1}-09-01")) + + +# The date the fiscal-year annualisation samples a parameter at +# (policyengine_uk.utils.parameters). +FISCAL_YEAR_SAMPLE_DATE = "04-30" + + +def _protected_amount_parameter(sim: Microsimulation): + return sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing.protected_health_element -def _protected_single_young_health_element_monthly( +def _protected_existing_health_element_monthly( sim: Microsimulation, year: int ) -> float: - baseline = sim.tax_benefit_system.parameters(str(BASELINE_UC_REBALANCING_YEAR)) - protected_combined_award = _benefit_uprating_ratio(sim, year) * ( - float(baseline.gov.dwp.universal_credit.standard_allowance.amount.SINGLE_YOUNG) - + float(baseline.gov.dwp.universal_credit.elements.disabled.amount) + """The protected LCWRA amount for a tax year, 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. + """ + parameter = _protected_amount_parameter(sim) + amount = parameter(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + if amount is not None: + return float(amount) + first = min( + int(entry.instant_str[:4]) + for entry in parameter.values_list + if entry.value is not None ) - return protected_combined_award - _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_YOUNG" + if year <= first: + raise ValueError( + f"No protected LCWRA amount is set for {year} or any earlier year." + ) + return protected_lcwra_floor( + _protected_existing_health_element_monthly(sim, year - 1), + _standard_allowances_monthly(sim, year - 1), + _standard_allowances_monthly(sim, year), + _relevant_cpi_factor(sim, year), ) @@ -74,18 +116,10 @@ def add_universal_credit_reform(sim: Microsimulation): # 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)) - claimant_type = sim.calculate("uc_standard_allowance_claimant_type", year) has_health_element = current_health_element > 0 - protected_health_element = np.full( - current_health_element.shape, - _protected_existing_health_element_monthly(sim, year) * 12, - dtype=current_health_element.dtype, - ) - protected_health_element[claimant_type == UCClaimantType.SINGLE_YOUNG.name] = ( - _protected_single_young_health_element_monthly(sim, year) * 12 - ) + # One protected amount for every pre-2026 claimant (reg. 36). current_health_element[has_health_element & ~is_post_2025_claimant] = ( - protected_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 diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 1055a922b7..dbf81ad300 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -1,8 +1,10 @@ import numpy as np import pytest +from hypothesis import given, settings +from hypothesis import strategies as st import policyengine_uk.scenarios.uc_reform as uc_reform -from policyengine_uk import Simulation +from policyengine_uk import Scenario, Simulation YEARS = range(2025, 2030) @@ -36,54 +38,227 @@ def _force_uc_seed(monkeypatch, values): ) -def _benefit_uprating_factor(sim: Simulation, year: int) -> float: - parameters = sim.tax_benefit_system.parameters - current_index = float(parameters(str(year)).gov.benefit_uprating_cpi) - baseline_index = float(parameters("2025").gov.benefit_uprating_cpi) - return current_index / baseline_index +# UC Regs 2013 reg. 36 table, monthly: 2025-26 as up-rated in April 2025, and +# 2026-27 as SI 2026/113 reg. 3(3) substitutes it +# (https://www.legislation.gov.uk/uksi/2026/113/made). +STANDARD_ALLOWANCE_2025 = { + "SINGLE_YOUNG": 316.98, + "SINGLE_OLD": 400.14, + "COUPLE_YOUNG": 497.55, + "COUPLE_OLD": 628.10, +} +STANDARD_ALLOWANCE_2026 = { + "SINGLE_YOUNG": 338.58, + "SINGLE_OLD": 424.90, + "COUPLE_YOUNG": 528.34, + "COUPLE_OLD": 666.97, +} +LCWRA_2025 = 423.27 +PROTECTED_LCWRA_2026 = 429.80 +# Relevant CPI percentage for 2026-27: CPI rose 3.8% in the year to September +# 2025, the rate the 2026-27 up-rating applied. +CPI_FACTOR_2026 = 1.038 -def _rebalanced_standard_allowance_monthly( - sim: Simulation, year: int, claimant_type: str -) -> float: - current = sim.tax_benefit_system.parameters(str(year)) - return float( - current.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] - ) +def _standard_allowances(sim: Simulation, year: int) -> dict: + amounts = sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.standard_allowance.amount + return { + claimant_type: float(amounts[claimant_type]) + for claimant_type in uc_reform.STANDARD_ALLOWANCE_TYPES + } -def _cpi_protected_uc_award_monthly( - sim: Simulation, year: int, claimant_type: str -) -> float: - baseline = sim.tax_benefit_system.parameters("2025").gov.dwp.universal_credit - baseline_standard_allowance = float( - baseline.standard_allowance.amount[claimant_type] - ) - baseline_health_element = float(baseline.elements.disabled.amount) - return _benefit_uprating_factor(sim, year) * ( - baseline_standard_allowance + baseline_health_element +def _cpi_factor(sim: Simulation, year: int) -> float: + # s. 4(4)(a): the CPI 12-month rate in the September before the tax year. + inputs = sim.tax_benefit_system.parameters.gov.economic_assumptions.statutory_uprating_inputs + return 1 + float(inputs.cpi_september(f"{year - 1}-09-01")) + + +@pytest.mark.parametrize("age_2025", [20, 30]) +def test_pre_2026_claimants_get_the_legislated_2026_27_amount(monkeypatch, age_2025): + # SI 2026/113 reg. 3(3)(b) sets one amount, £429.80, for every pre-2026 + # claimant, whatever their age. + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation(situation=_uc_claimant(age_2025)) + + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + PROTECTED_LCWRA_2026 * 12 ) @pytest.mark.parametrize("age_2025", [20, 30]) -def test_existing_claimants_keep_combined_award_cpi_protected(monkeypatch, age_2025): +def test_projected_years_take_the_section_4_floor(monkeypatch, age_2025): _force_uc_seed(monkeypatch, [0.99]) sim = Simulation(situation=_uc_claimant(age_2025)) - claimant_type = "SINGLE_YOUNG" if age_2025 < 25 else "SINGLE_OLD" + previous = sim.calculate("uc_LCWRA_element", 2026)[0] / 12 + for year in range(2027, 2030): + amount = sim.calculate("uc_LCWRA_element", year)[0] / 12 + factor = max(_cpi_factor(sim, year), 1.0) + before = _standard_allowances(sim, year - 1) + now = _standard_allowances(sim, year) + shortfalls = [ + (previous + before[t]) * factor - (amount + now[t]) + for t in uc_reform.STANDARD_ALLOWANCE_TYPES + ] + # Simulation outputs are float32, so compare to a tenth of a penny. + # s. 4(2): every pairing keeps up with CPI, and the amount never falls. + assert max(shortfalls) <= 1e-3 + assert amount >= previous - 1e-3 + # The lowest such amount: it stayed put, or some pairing binds. + assert amount == pytest.approx(previous, rel=0, abs=1e-3) or max( + shortfalls + ) == pytest.approx(0, rel=0, abs=1e-3) + previous = amount + + +def test_section_4_floor_reproduces_the_legislated_2026_27_amount(): + # Differential against the published figure: the s. 4 floor from the + # 2025-26 amounts at the 3.8% relevant CPI percentage is SI 2026/113's + # £429.80. + floor = uc_reform.protected_lcwra_floor( + LCWRA_2025, STANDARD_ALLOWANCE_2025, STANDARD_ALLOWANCE_2026, CPI_FACTOR_2026 + ) + assert round(floor, 2) == PROTECTED_LCWRA_2026 + + +CPI_SEPTEMBER = "gov.economic_assumptions.statutory_uprating_inputs.cpi_september" +PROTECTED = "gov.dwp.universal_credit.rebalancing.protected_health_element" + + +def _protected_monthly(sim: Simulation, year: int) -> float: + return sim.calculate("uc_LCWRA_element", year)[0] / 12 + + +def test_projection_uses_the_september_cpi_rate(monkeypatch): + # s. 4(4)(a): September 2026 CPI sets the 2027-28 floor. A 9% September + # 2026 rate, far above any standard allowance growth, must lift the + # protected amount by exactly the floor. + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation( + situation=_uc_claimant(30), + # The rebalancing modifier runs when the simulation is built, so the + # change must apply before then. + scenario=Scenario( + parameter_changes={CPI_SEPTEMBER: {"2026-09-01": 0.09}}, + applied_before_data_load=True, + ), + ) + expected = uc_reform.protected_lcwra_floor( + PROTECTED_LCWRA_2026, + _standard_allowances(sim, 2026), + _standard_allowances(sim, 2027), + 1.09, + ) + assert expected > PROTECTED_LCWRA_2026 * 1.05 + assert _protected_monthly(sim, 2027) == pytest.approx(expected, rel=0, abs=1e-3) + + +def test_a_one_year_override_is_projected_from(monkeypatch): + # A reform that sets 2027-28 only: 2028-29 is not legislated and takes the + # floor from the reformed £500, not the carried £429.80. + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={PROTECTED: {"2027": 500}}, + applied_before_data_load=True, + ), + ) + assert _protected_monthly(sim, 2027) == pytest.approx(500, rel=0, abs=1e-3) + expected = uc_reform.protected_lcwra_floor( + 500, + _standard_allowances(sim, 2027), + _standard_allowances(sim, 2028), + _cpi_factor(sim, 2028), + ) + assert _protected_monthly(sim, 2028) == pytest.approx(expected, rel=0, abs=1e-3) + assert _protected_monthly(sim, 2028) >= 500 - 1e-3 + + +def test_a_scalar_override_applies_in_every_year(monkeypatch): + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={PROTECTED: 500}, applied_before_data_load=True + ), + ) for year in range(2026, 2030): - standard_allowance = sim.calculate("uc_standard_allowance", year)[0] / 12 - health_element = sim.calculate("uc_LCWRA_element", year)[0] / 12 - new_claimant_health_element = float( - sim.tax_benefit_system.parameters( - str(year) - ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element - ) + assert _protected_monthly(sim, year) == pytest.approx(500, rel=0, abs=1e-3) - assert health_element > new_claimant_health_element - assert standard_allowance + health_element == pytest.approx( - _cpi_protected_uc_award_monthly(sim, year, claimant_type) - ) + +def test_a_two_year_override_is_used_then_projected_from(monkeypatch): + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={PROTECTED: {"2027": 500, "2028": 520}}, + applied_before_data_load=True, + ), + ) + assert _protected_monthly(sim, 2026) == pytest.approx( + PROTECTED_LCWRA_2026, rel=0, abs=1e-3 + ) + assert _protected_monthly(sim, 2027) == pytest.approx(500, rel=0, abs=1e-3) + assert _protected_monthly(sim, 2028) == pytest.approx(520, rel=0, abs=1e-3) + expected = uc_reform.protected_lcwra_floor( + 520, + _standard_allowances(sim, 2028), + _standard_allowances(sim, 2029), + _cpi_factor(sim, 2029), + ) + assert _protected_monthly(sim, 2029) == pytest.approx(expected, rel=0, abs=1e-3) + + +money = st.floats(min_value=0, max_value=5_000, allow_nan=False) +allowances = st.fixed_dictionaries( + {claimant_type: money for claimant_type in uc_reform.STANDARD_ALLOWANCE_TYPES} +) +factors = st.floats(min_value=0.8, max_value=1.3, allow_nan=False) + + +@settings(max_examples=300, deadline=None) +@given(previous=money, before=allowances, now=allowances, factor=factors) +def test_section_4_floor_properties(previous, before, now, factor): + floor = uc_reform.protected_lcwra_floor(previous, before, now, factor) + growth = max(factor, 1.0) + shortfalls = [ + (previous + before[t]) * growth - (floor + now[t]) + for t in uc_reform.STANDARD_ALLOWANCE_TYPES + ] + tolerance = 1e-9 * max(1.0, previous + max(before.values())) + assert max(shortfalls) <= tolerance + assert floor >= previous + assert floor == pytest.approx(previous, rel=0, abs=tolerance) or max( + shortfalls + ) == pytest.approx(0, rel=0, abs=tolerance) + + +@settings(max_examples=200, deadline=None) +@given(previous=money, before=allowances, now=allowances, factor=st.floats(0.5, 1.0)) +def test_section_4_floor_treats_a_cpi_fall_as_zero(previous, before, now, factor): + assert uc_reform.protected_lcwra_floor( + previous, before, now, factor + ) == uc_reform.protected_lcwra_floor(previous, before, now, 1.0) + + +@settings(max_examples=200, deadline=None) +@given( + previous=money, + extra=st.floats(0, 1_000), + before=allowances, + now=allowances, + factor=factors, +) +def test_section_4_floor_rises_with_the_previous_amount( + previous, extra, before, now, factor +): + assert uc_reform.protected_lcwra_floor( + previous + extra, before, now, factor + ) >= uc_reform.protected_lcwra_floor(previous, before, now, factor) def test_new_claimants_use_fixed_health_element(monkeypatch):