From f1f2dc6a62682b3aaf121ac25250092b39ed751a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 07:30:23 -0400 Subject: [PATCH 1/3] Pay pre-2026 UC health element claimants the legislated protected amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SI 2026/113 reg. 3(3)(b) sets the protected LCWRA amount at £429.80 a month for 2026-27, one amount for every pre-2026, severe conditions criteria or terminally ill claimant (UC Regs 2013 reg. 36). The model computed £426.51 (single, 25+) and £426.84 (single, under 25) from a CPI index that rose 3.4% where the 2026-27 up-rating used 3.8%. Store the legislated amount, keep its statutory dates, and project later tax years at the lowest amount the Universal Credit Act 2025 s. 4 duty allows across every standard allowance pairing. Fixes #2247 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2247.fixed.md | 1 + docs/book/policy/uc-rebalancing.md | 7 +- .../rebalancing/protected_health_element.yaml | 19 +++ policyengine_uk/scenarios/uc_reform.py | 130 ++++++++++----- policyengine_uk/tests/test_uc_rebalancing.py | 157 ++++++++++++++---- 5 files changed, 229 insertions(+), 85 deletions(-) create mode 100644 changelog.d/2247.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml 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..e29800c70f 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 (never below 0%). 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: 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..50ec2c3958 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/protected_health_element.yaml @@ -0,0 +1,19 @@ +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 +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..858620d641 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,55 +1,103 @@ 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(3)). 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(3)): + the September-on-September change in the benefit uprating index.""" + parameters = sim.tax_benefit_system.parameters + return float(parameters(str(year)).gov.benefit_uprating_cpi) / float( + parameters(str(year - 1)).gov.benefit_uprating_cpi ) -def _protected_single_young_health_element_monthly( +# 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 _legislated_protected_years(sim: Microsimulation) -> set: + """Tax years with a legislated protected LCWRA amount: the years the + parameter's dated values fall in (it keeps its statutory dates).""" + return { + int(entry.instant_str[:4]) + for entry in _protected_amount_parameter(sim).values_list + if entry.value is not None + } + + +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) - ) - return protected_combined_award - _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_YOUNG" + """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 with no + legislated amount takes the s. 4 floor from the year before. + """ + legislated = _legislated_protected_years(sim) + if year in legislated: + return float( + _protected_amount_parameter(sim)(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + ) + if not legislated or year < min(legislated): + raise ValueError( + f"No protected LCWRA amount is legislated for {year} or earlier." + ) + 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 +122,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..9a9d947908 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -1,5 +1,7 @@ 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 @@ -36,54 +38,139 @@ 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 _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 _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 _cpi_factor(sim: Simulation, year: int) -> float: + parameters = sim.tax_benefit_system.parameters + return float(parameters(str(year)).gov.benefit_uprating_cpi) / float( + parameters(str(year - 1)).gov.benefit_uprating_cpi ) -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 +@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" - 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 - ) + 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, abs=1e-3) or max( + shortfalls + ) == pytest.approx(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 + + +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) or max(shortfalls) == pytest.approx( + 0, abs=tolerance + ) - assert health_element > new_claimant_health_element - assert standard_allowance + health_element == pytest.approx( - _cpi_protected_uc_award_monthly(sim, year, claimant_type) - ) + +@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): From 9e07406ac58dcfec70b379e728ca1cf9f5f3f558 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:02:38 -0400 Subject: [PATCH 2/3] Project the protected LCWRA amount from September CPI; respect one-year reforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review of #2248: - The relevant CPI percentage (UC Act 2025 s. 4(4)(a)) is the September rate, so read gov.economic_assumptions.statutory_uprating_inputs.cpi_september rather than the lagged benefit uprating index; cite s. 4(4)(a). - End the legislated amounts with a null from 2027-04-01, so a reform that sets one tax year restores null and the next year projects from it, not from the carried £429.80. - Tests: an overridden September 2026 CPI moves the 2027-28 floor; a one-year £500 override is projected from; tolerances use rel=0. Both new tests fail without their fix. - Docs: describe the September CPI input; drop the stale parameter count. Co-Authored-By: Claude Opus 5.5 --- docs/book/policy/uc-rebalancing.md | 4 +- .../rebalancing/protected_health_element.yaml | 3 + policyengine_uk/scenarios/uc_reform.py | 13 ++-- policyengine_uk/tests/test_uc_rebalancing.py | 73 ++++++++++++++++--- 4 files changed, 74 insertions(+), 19 deletions(-) diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index e29800c70f..189eea7073 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -22,7 +22,7 @@ From April 2026, new Universal Credit claimants who qualify for the Limited Capa 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`. -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 (never below 0%). 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. +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: @@ -48,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 index 50ec2c3958..6efecca026 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 @@ -7,6 +7,9 @@ values: 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. diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 858620d641..a92eb473ba 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -23,7 +23,7 @@ def protected_lcwra_floor( 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(3)). s. 3 + 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. """ @@ -47,12 +47,11 @@ def _standard_allowances_monthly(sim: Microsimulation, year: int) -> dict: def _relevant_cpi_factor(sim: Microsimulation, year: int) -> float: - """1 + the relevant CPI percentage for a tax year (UC Act 2025 s. 4(3)): - the September-on-September change in the benefit uprating index.""" - parameters = sim.tax_benefit_system.parameters - return float(parameters(str(year)).gov.benefit_uprating_cpi) / float( - parameters(str(year - 1)).gov.benefit_uprating_cpi - ) + """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 diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 9a9d947908..2695ee531b 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -4,7 +4,7 @@ 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) @@ -71,10 +71,9 @@ def _standard_allowances(sim: Simulation, year: int) -> dict: def _cpi_factor(sim: Simulation, year: int) -> float: - parameters = sim.tax_benefit_system.parameters - return float(parameters(str(year)).gov.benefit_uprating_cpi) / float( - parameters(str(year - 1)).gov.benefit_uprating_cpi - ) + # 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]) @@ -109,9 +108,9 @@ def test_projected_years_take_the_section_4_floor(monkeypatch, age_2025): 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, abs=1e-3) or max( + assert amount == pytest.approx(previous, rel=0, abs=1e-3) or max( shortfalls - ) == pytest.approx(0, abs=1e-3) + ) == pytest.approx(0, rel=0, abs=1e-3) previous = amount @@ -125,6 +124,60 @@ def test_section_4_floor_reproduces_the_legislated_2026_27_amount(): 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 + + 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} @@ -144,9 +197,9 @@ def test_section_4_floor_properties(previous, before, now, factor): tolerance = 1e-9 * max(1.0, previous + max(before.values())) assert max(shortfalls) <= tolerance assert floor >= previous - assert floor == pytest.approx(previous) or max(shortfalls) == pytest.approx( - 0, abs=tolerance - ) + 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) From 2e8bed526360c04d44d2f2bb06aa5c418005cc17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:25:21 -0400 Subject: [PATCH 3/3] Use any non-null protected amount; project only from a null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address round 2 of the #2248 review: read the parameter at each tax year's 30 April sampling date and use any non-null amount, legislated or set by a reform, projecting only from a null year. A scalar override (which starts in 2000) or a range override across several years no longer mis-detects the legislated years; a scalar override broke simulation construction before. Tests: a scalar £500 override applies in every year (fails on the previous code); a two-year override is used, then projected from. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/scenarios/uc_reform.py | 33 ++++++++---------- policyengine_uk/tests/test_uc_rebalancing.py | 35 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index a92eb473ba..459e6c18b9 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -63,16 +63,6 @@ def _protected_amount_parameter(sim: Microsimulation): return sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing.protected_health_element -def _legislated_protected_years(sim: Microsimulation) -> set: - """Tax years with a legislated protected LCWRA amount: the years the - parameter's dated values fall in (it keeps its statutory dates).""" - return { - int(entry.instant_str[:4]) - for entry in _protected_amount_parameter(sim).values_list - if entry.value is not None - } - - def _protected_existing_health_element_monthly( sim: Microsimulation, year: int ) -> float: @@ -80,17 +70,22 @@ 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 with no - legislated amount takes the s. 4 floor from the year before. + (£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. """ - legislated = _legislated_protected_years(sim) - if year in legislated: - return float( - _protected_amount_parameter(sim)(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") - ) - if not legislated or year < min(legislated): + 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 + ) + if year <= first: raise ValueError( - f"No protected LCWRA amount is legislated for {year} or earlier." + 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), diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 2695ee531b..dbf81ad300 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -178,6 +178,41 @@ def test_a_one_year_override_is_projected_from(monkeypatch): 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): + assert _protected_monthly(sim, year) == pytest.approx(500, rel=0, abs=1e-3) + + +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}