From 9bfcb4de97dabe89e24d98fc2bcbb2b5e3ae0eeb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 11:59:57 -0400 Subject: [PATCH 1/3] Fix UC rebalancing protection for existing claimants --- .../uc-rebalancing-protection.fixed.md | 1 + docs/book/policy/model-baseline.md | 4 +- docs/book/policy/uc-rebalancing.md | 27 ++-- docs/book/usage/scenarios.md | 12 +- policyengine_uk/scenarios/uc_reform.py | 84 ++++++++++++- policyengine_uk/tests/test_uc_rebalancing.py | 115 ++++++++++++++++++ 6 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 changelog.d/uc-rebalancing-protection.fixed.md create mode 100644 policyengine_uk/tests/test_uc_rebalancing.py diff --git a/changelog.d/uc-rebalancing-protection.fixed.md b/changelog.d/uc-rebalancing-protection.fixed.md new file mode 100644 index 000000000..298dc24ef --- /dev/null +++ b/changelog.d/uc-rebalancing-protection.fixed.md @@ -0,0 +1 @@ +Corrected Universal Credit rebalancing so existing health-element claimants keep their combined award CPI-protected and single claimants under 25 receive the matching standard allowance top-up. diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 18ccded4a..f9d9c3ebe 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -12,7 +12,7 @@ The government increased the [employer National Insurance rate from 13.8% to 15% ### Universal Credit rebalancing -Parliament passed legislation to implement Universal Credit rebalancing reforms, with the [rebalancing switch activated in fiscal year 2025-26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/active.yaml#L3). The reforms include [graduated standard allowance uplifts](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml) above inflation: 2.3% in 2026-27, 3.1% in 2027-28, 4.0% in 2028-29, and 4.8% in 2029-30. A [new health element of £217.26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml#L3) will be introduced for new claimants in fiscal year 2026-27. +Parliament passed legislation to implement Universal Credit rebalancing reforms, with the [rebalancing switch activated in fiscal year 2025-26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/active.yaml#L3). The reforms include [graduated standard allowance uplifts](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml) above inflation: 2.3% in 2026-27, 3.1% in 2027-28, 4.0% in 2028-29, and 4.8% in 2029-30. A [new health element of £217.26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml#L3) applies to most new claimants in fiscal year 2026-27, while existing health-element claimants keep the combined value of their standard allowance and health element at least in line with CPI. Single claimants under 25 receive an additional standard allowance top-up to preserve that protection. ### Benefit uprating @@ -113,4 +113,4 @@ Notable exclusions: - **Non-UK resident stamp duty surcharge**: 2% additional rate from fiscal year 2021-22 is not modelled - **Some devolved tax policies**: Beyond property transaction taxes, other devolved policies may have limited coverage -All parameter values include references to primary legislation and can be found in the [PolicyEngine UK parameters directory](https://github.com/PolicyEngine/policyengine-uk/tree/master/policyengine_uk/parameters). \ No newline at end of file +All parameter values include references to primary legislation and can be found in the [PolicyEngine UK parameters directory](https://github.com/PolicyEngine/policyengine-uk/tree/master/policyengine_uk/parameters). diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index cfc280c69..405ed8f05 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -7,16 +7,25 @@ The Universal Credit rebalancing reforms represent changes to Universal Credit p ## Overview ```{important} -The reforms consist of two main components: health element changes for new claimants and standard allowance uplifts. +The reforms combine a higher standard allowance, protected awards for existing health-element recipients, and a lower fixed health element for most new claimants. ``` -1. **Health element changes for new claimants**: New Universal Credit claimants from April 2026 onwards receive a fixed health element amount, while existing claimants continue to receive inflation-linked increases. +1. **Protected awards for existing claimants**: Existing recipients of the health element keep the combined value of their standard allowance and health element at least in line with CPI inflation through 2029-30. -2. **Standard allowance uplifts**: The standard allowance receives additional uplifts beyond the annual inflationary increase from 2026-2029. +2. **Health element changes for new claimants**: New Universal Credit claimants from April 2026 onwards receive a fixed monthly health element amount of £217.26, rather than the protected existing-claimant amount. + +3. **Standard allowance uplifts**: The standard allowance receives additional uplifts beyond the annual inflationary increase from 2026-2029. Single claimants under 25 receive a further top-up so their protected combined award also keeps pace with inflation. ## Health element changes -From April 2026, new Universal Credit claimants who qualify for the Limited Capacity for Work-Related Activity (LCWRA) element receive a fixed monthly amount of £217.26, rather than the inflation-adjusted amount that pre-2026 claimants continue to receive. +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: + +- the single-over-25 standard allowance, and +- the health element + +rises at least in line with CPI inflation. Because that flat protected health element is calibrated against the single-over-25 rate, single claimants under 25 receive an additional standard allowance top-up to preserve the same real-terms protection. 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: @@ -34,7 +43,7 @@ The standard allowance receives additional percentage uplifts beyond the normal - 2028: 4.0% additional uplift (cumulative) - 2029: 4.8% additional uplift (cumulative) -These uplifts are applied to the previous year's standard allowance amount and compound over time. +These uplifts are applied to the CPI-uprated standard allowance for each year. In other words, the model first applies the usual CPI uprating and then applies the rebalancing uplift on top. ## Implementation @@ -43,7 +52,7 @@ The reforms are implemented through parameters, scenario modifiers, and scenario ``` - **Parameters**: Three YAML files define the reform's activation status, health element amount for new claimants, and standard allowance uplift rates. -- **Scenario modifier**: The `add_universal_credit_reform` function applies the changes to Universal Credit calculations during microsimulation. +- **Scenario modifier**: The `add_universal_credit_reform` function applies the protected existing-claimant health-element path and the single-under-25 top-up during microsimulation. - **Scenario**: The `universal_credit_july_2025_reform` scenario enables the reforms in policy analysis. ## Examples @@ -98,4 +107,8 @@ sim = Simulation(scenario=scenario) ## Legislative reference -The reforms are based on provisions in the Universal Credit Bill, available at: https://bills.parliament.uk/publications/62123/documents/6889. \ No newline at end of file +The reforms are based on the Universal Credit Bill and its impact assessment: + +- https://bills.parliament.uk/publications/62123/documents/6889 +- https://bills.parliament.uk/publications/62124/documents/6892 +- https://assets.publishing.service.gov.uk/media/689ca49e1c63de6de5bb1298/withdrawn-universal-credit-bill-uc-rebalancing-impact-assessment.pdf diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 04b71efb9..801d58998 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -391,14 +391,14 @@ for year in [2025, 2027, 2029]: ### Building Universal Credit scenarios with dynamic changes -Some scenarios need to make changes that depend on the simulation's own data. Here's how to create a UC scenario that adjusts payments based on claimant characteristics: +Some scenarios need to make changes that depend on the simulation's own data. Here's how to create a UC scenario that adjusts health-element payments based on claimant characteristics: ```python from policyengine_uk import Scenario, Microsimulation import numpy as np def modify_uc_for_new_claimants(sim: Microsimulation): - """Reduce health elements for new UC claimants while increasing standard allowances""" + """Reduce health elements for new UC claimants while preserving claimant protections""" # Access the parameter system to check if reforms are active rebalancing_params = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing @@ -434,11 +434,9 @@ def modify_uc_for_new_claimants(sim: Microsimulation): sim.set_input("uc_LCWRA_element", year, current_health_element) - # Increase standard allowances for everyone - uplift_rate = rebalancing_params.standard_allowance_uplift(year) - previous_allowance = sim.calculate("uc_standard_allowance", year - 1) - new_allowance = previous_allowance * (1 + uplift_rate) - sim.set_input("uc_standard_allowance", year, new_allowance) + # General standard allowance uplifts are already handled in the + # uc_standard_allowance formula. Scenario modifiers only need to add + # claimant-specific overrides such as protected top-ups. # Create the UC rebalancing scenario uc_rebalancing = Scenario(simulation_modifier=modify_uc_for_new_claimants) diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 1de17dcf9..47de7bb90 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,15 +1,73 @@ 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 + + +def _cpi_protected_uc_award_monthly( + sim: Microsimulation, year: int, claimant_type: str +) -> float: + parameters = sim.tax_benefit_system.parameters + baseline = parameters(str(BASELINE_UC_REBALANCING_YEAR)) + current = parameters(str(year)) + cpi_factor = float(current.gov.benefit_uprating_cpi) / float( + baseline.gov.benefit_uprating_cpi + ) + baseline_standard_allowance = float( + baseline.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] + ) + baseline_health_element = float( + baseline.gov.dwp.universal_credit.elements.disabled.amount + ) + return (baseline_standard_allowance + baseline_health_element) * cpi_factor + + +def _rebalanced_standard_allowance_monthly( + sim: Microsimulation, year: int, claimant_type: str +) -> float: + current = sim.tax_benefit_system.parameters(str(year)) + standard_allowance = float( + current.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] + ) + uplift = float( + current.gov.dwp.universal_credit.rebalancing.standard_allowance_uplift + ) + return standard_allowance * (1 + uplift) + + +def _protected_existing_health_element_monthly( + sim: Microsimulation, year: int +) -> float: + return _cpi_protected_uc_award_monthly( + sim, year, "SINGLE_OLD" + ) - _rebalanced_standard_allowance_monthly(sim, year, "SINGLE_OLD") + + +def _single_young_standard_allowance_topup_monthly( + sim: Microsimulation, year: int +) -> float: + protected_young_award = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_YOUNG") + standard_allowance = _rebalanced_standard_allowance_monthly( + sim, year, "SINGLE_YOUNG" + ) + protected_health_element = _protected_existing_health_element_monthly(sim, year) + return max( + 0.0, protected_young_award - standard_allowance - protected_health_element + ) + + def add_universal_credit_reform(sim: Microsimulation): rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing generator = np.random.default_rng(43) uc_seed = generator.random(len(sim.calculate("benunit_id"))) - p_uc_post_2026_status = { + post_2025_claimant_share = { 2025: 0, 2026: 0.11, 2027: 0.13, @@ -20,11 +78,29 @@ def add_universal_credit_reform(sim: Microsimulation): for year in range(2026, 2030): if not rebalancing.active(year): continue - is_post_25_claimant = uc_seed < p_uc_post_2026_status[year] + is_post_2025_claimant = uc_seed < post_2025_claimant_share[year] + + claimant_type = sim.calculate("uc_standard_allowance_claimant_type", year) + current_standard_allowance = sim.calculate("uc_standard_allowance", year) + single_young_topup = ( + _single_young_standard_allowance_topup_monthly(sim, year) * 12 + ) + current_standard_allowance[ + claimant_type == UCClaimantType.SINGLE_YOUNG.name + ] += single_young_topup + sim.set_input("uc_standard_allowance", year, current_standard_allowance) + current_health_element = sim.calculate("uc_LCWRA_element", year) - # Set new claimants to £217.26/month from April 2026 (pre-2026 claimaints keep inflation-linked increases) + has_health_element = current_health_element > 0 + protected_health_element = ( + _protected_existing_health_element_monthly(sim, year) * 12 + ) + current_health_element[has_health_element & ~is_post_2025_claimant] = ( + protected_health_element + ) + # Set post-April 2026 claimants to £217.26/month. # https://bills.parliament.uk/publications/62123/documents/6889#page=16 - current_health_element[(current_health_element > 0) & is_post_25_claimant] = ( + current_health_element[has_health_element & is_post_2025_claimant] = ( new_claimant_health_element(year) * 12 ) # Monthly amount * 12 sim.set_input("uc_LCWRA_element", year, current_health_element) diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py new file mode 100644 index 000000000..6fec5f48a --- /dev/null +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -0,0 +1,115 @@ +import numpy as np +import pytest + +import policyengine_uk.scenarios.uc_reform as uc_reform +from policyengine_uk import Simulation + +BASELINE_YEAR = 2025 + + +def _uc_claimant(age: int) -> dict: + return { + "people": { + "person": { + "age": {year: age for year in range(2025, 2030)}, + "employment_income": {year: 0 for year in range(2025, 2030)}, + "uc_limited_capability_for_WRA": { + year: True for year in range(2025, 2030) + }, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + +class _FixedRng: + def __init__(self, values): + self.values = np.array(values, dtype=float) + + def random(self, size): + assert size == len(self.values) + return self.values + + +def _force_uc_seed(monkeypatch, values): + monkeypatch.setattr( + uc_reform.np.random, "default_rng", lambda seed: _FixedRng(values) + ) + + +def _cpi_protected_uc_award_monthly( + sim: Simulation, year: int, claimant_type: str +) -> float: + parameters = sim.tax_benefit_system.parameters + baseline = parameters(str(BASELINE_YEAR)) + current = parameters(str(year)) + cpi_factor = float(current.gov.benefit_uprating_cpi) / float( + baseline.gov.benefit_uprating_cpi + ) + baseline_standard_allowance = float( + baseline.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] + ) + baseline_health_element = float( + baseline.gov.dwp.universal_credit.elements.disabled.amount + ) + return (baseline_standard_allowance + baseline_health_element) * cpi_factor + + +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] + ) * ( + 1 + + float(current.gov.dwp.universal_credit.rebalancing.standard_allowance_uplift) + ) + + +def test_existing_single_over_25_claimant_combined_award_tracks_cpi(monkeypatch): + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation(situation=_uc_claimant(30)) + + 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 + expected_total = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_OLD") + expected_health = expected_total - _rebalanced_standard_allowance_monthly( + sim, year, "SINGLE_OLD" + ) + + assert standard_allowance + health_element == pytest.approx(expected_total) + assert health_element == pytest.approx(expected_health) + + +def test_existing_single_under_25_claimant_gets_extra_topup(monkeypatch): + _force_uc_seed(monkeypatch, [0.99]) + sim = Simulation(situation=_uc_claimant(22)) + + 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 + expected_total = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_YOUNG") + baseline_rebalanced_standard_allowance = _rebalanced_standard_allowance_monthly( + sim, year, "SINGLE_YOUNG" + ) + + assert standard_allowance + health_element == pytest.approx(expected_total) + assert standard_allowance > baseline_rebalanced_standard_allowance + + +def test_new_claimants_use_fixed_health_element(monkeypatch): + _force_uc_seed(monkeypatch, [0.0]) + sim = Simulation(situation=_uc_claimant(30)) + + 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(expected_health) From ce1d3f5df7aa1de26579af2686242c904a0884df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 12:11:49 -0400 Subject: [PATCH 2/3] Refine UC rebalancing protection modeling --- .../uc-rebalancing-protection.fixed.md | 2 +- docs/book/policy/model-baseline.md | 2 +- docs/book/policy/uc-rebalancing.md | 10 +- docs/book/usage/scenarios.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 67 +++++----- policyengine_uk/tests/test_uc_rebalancing.py | 116 ++++++++++-------- 6 files changed, 103 insertions(+), 96 deletions(-) diff --git a/changelog.d/uc-rebalancing-protection.fixed.md b/changelog.d/uc-rebalancing-protection.fixed.md index 298dc24ef..7db1d28c9 100644 --- a/changelog.d/uc-rebalancing-protection.fixed.md +++ b/changelog.d/uc-rebalancing-protection.fixed.md @@ -1 +1 @@ -Corrected Universal Credit rebalancing so existing health-element claimants keep their combined award CPI-protected and single claimants under 25 receive the matching standard allowance top-up. +Corrected Universal Credit rebalancing so existing health-element claimants keep their combined standard allowance and health element award CPI-protected. diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index f9d9c3ebe..6c42fd8dd 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -12,7 +12,7 @@ The government increased the [employer National Insurance rate from 13.8% to 15% ### Universal Credit rebalancing -Parliament passed legislation to implement Universal Credit rebalancing reforms, with the [rebalancing switch activated in fiscal year 2025-26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/active.yaml#L3). The reforms include [graduated standard allowance uplifts](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml) above inflation: 2.3% in 2026-27, 3.1% in 2027-28, 4.0% in 2028-29, and 4.8% in 2029-30. A [new health element of £217.26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml#L3) applies to most new claimants in fiscal year 2026-27, while existing health-element claimants keep the combined value of their standard allowance and health element at least in line with CPI. Single claimants under 25 receive an additional standard allowance top-up to preserve that protection. +Parliament passed legislation to implement Universal Credit rebalancing reforms, with the [rebalancing switch activated in fiscal year 2025-26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/active.yaml#L3). The reforms include [graduated standard allowance uplifts](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/standard_allowance_uplift.yaml) above inflation: 2.3% in 2026-27, 3.1% in 2027-28, 4.0% in 2028-29, and 4.8% in 2029-30. A [new health element of £217.26](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml#L3) applies to most new claimants in fiscal year 2026-27, while existing health-element claimants keep the combined value of their standard allowance and health element at least in line with CPI. ### Benefit uprating diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 405ed8f05..0101818aa 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -14,7 +14,7 @@ The reforms combine a higher standard allowance, protected awards for existing h 2. **Health element changes for new claimants**: New Universal Credit claimants from April 2026 onwards receive a fixed monthly health element amount of £217.26, rather than the protected existing-claimant amount. -3. **Standard allowance uplifts**: The standard allowance receives additional uplifts beyond the annual inflationary increase from 2026-2029. Single claimants under 25 receive a further top-up so their protected combined award also keeps pace with inflation. +3. **Standard allowance uplifts**: The standard allowance receives additional uplifts beyond the annual inflationary increase from 2026-2029. ## Health element changes @@ -22,10 +22,10 @@ From April 2026, new Universal Credit claimants who qualify for the Limited Capa Existing recipients are treated differently. Their LCWRA amount is uprated so that the combined value of: -- the single-over-25 standard allowance, and -- the health element +- their standard allowance, and +- their health element -rises at least in line with CPI inflation. Because that flat protected health element is calibrated against the single-over-25 rate, single claimants under 25 receive an additional standard allowance top-up to preserve the same real-terms protection. +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. 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: @@ -52,7 +52,7 @@ The reforms are implemented through parameters, scenario modifiers, and scenario ``` - **Parameters**: Three YAML files define the reform's activation status, health element amount for new claimants, and standard allowance uplift rates. -- **Scenario modifier**: The `add_universal_credit_reform` function applies the protected existing-claimant health-element path and the single-under-25 top-up during microsimulation. +- **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. ## Examples diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 801d58998..e99a5008b 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -436,7 +436,7 @@ def modify_uc_for_new_claimants(sim: Microsimulation): # General standard allowance uplifts are already handled in the # uc_standard_allowance formula. Scenario modifiers only need to add - # claimant-specific overrides such as protected top-ups. + # claimant-specific overrides such as protected health elements. # Create the UC rebalancing scenario uc_rebalancing = Scenario(simulation_modifier=modify_uc_for_new_claimants) diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 47de7bb90..17bcd4d93 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -9,22 +9,13 @@ BASELINE_UC_REBALANCING_YEAR = 2025 -def _cpi_protected_uc_award_monthly( - sim: Microsimulation, year: int, claimant_type: str -) -> float: +def _benefit_uprating_ratio(sim: Microsimulation, year: int) -> float: parameters = sim.tax_benefit_system.parameters - baseline = parameters(str(BASELINE_UC_REBALANCING_YEAR)) - current = parameters(str(year)) - cpi_factor = float(current.gov.benefit_uprating_cpi) / float( - baseline.gov.benefit_uprating_cpi - ) - baseline_standard_allowance = float( - baseline.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] - ) - baseline_health_element = float( - baseline.gov.dwp.universal_credit.elements.disabled.amount + current_index = float(parameters(str(year)).gov.benefit_uprating_cpi) + baseline_index = float( + parameters(str(BASELINE_UC_REBALANCING_YEAR)).gov.benefit_uprating_cpi ) - return (baseline_standard_allowance + baseline_health_element) * cpi_factor + return current_index / baseline_index def _rebalanced_standard_allowance_monthly( @@ -43,21 +34,28 @@ def _rebalanced_standard_allowance_monthly( def _protected_existing_health_element_monthly( sim: Microsimulation, year: int ) -> float: - return _cpi_protected_uc_award_monthly( + 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" - ) - _rebalanced_standard_allowance_monthly(sim, year, "SINGLE_OLD") + ) -def _single_young_standard_allowance_topup_monthly( +def _protected_single_young_health_element_monthly( sim: Microsimulation, year: int ) -> float: - protected_young_award = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_YOUNG") - standard_allowance = _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_YOUNG" + 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) ) - protected_health_element = _protected_existing_health_element_monthly(sim, year) - return max( - 0.0, protected_young_award - standard_allowance - protected_health_element + return protected_combined_award - _rebalanced_standard_allowance_monthly( + sim, year, "SINGLE_YOUNG" ) @@ -79,24 +77,19 @@ def add_universal_credit_reform(sim: Microsimulation): if not rebalancing.active(year): continue is_post_2025_claimant = uc_seed < post_2025_claimant_share[year] - - claimant_type = sim.calculate("uc_standard_allowance_claimant_type", year) - current_standard_allowance = sim.calculate("uc_standard_allowance", year) - single_young_topup = ( - _single_young_standard_allowance_topup_monthly(sim, year) * 12 - ) - current_standard_allowance[ - claimant_type == UCClaimantType.SINGLE_YOUNG.name - ] += single_young_topup - sim.set_input("uc_standard_allowance", year, current_standard_allowance) - current_health_element = 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 = ( - _protected_existing_health_element_monthly(sim, year) * 12 + 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 current_health_element[has_health_element & ~is_post_2025_claimant] = ( - protected_health_element + protected_health_element[has_health_element & ~is_post_2025_claimant] ) # 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 6fec5f48a..df6be43fb 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -4,18 +4,16 @@ import policyengine_uk.scenarios.uc_reform as uc_reform from policyengine_uk import Simulation -BASELINE_YEAR = 2025 +YEARS = range(2025, 2030) -def _uc_claimant(age: int) -> dict: +def _uc_claimant(age_2025: int) -> dict: return { "people": { "person": { - "age": {year: age for year in range(2025, 2030)}, - "employment_income": {year: 0 for year in range(2025, 2030)}, - "uc_limited_capability_for_WRA": { - year: True for year in range(2025, 2030) - }, + "age": {year: age_2025 + year - 2025 for year in YEARS}, + "employment_income": {year: 0 for year in YEARS}, + "uc_limited_capability_for_WRA": {year: True for year in YEARS}, } }, "benunits": {"benunit": {"members": ["person"]}}, @@ -38,66 +36,62 @@ def _force_uc_seed(monkeypatch, values): ) -def _cpi_protected_uc_award_monthly( - sim: Simulation, year: int, claimant_type: str -) -> float: +def _benefit_uprating_factor(sim: Simulation, year: int) -> float: parameters = sim.tax_benefit_system.parameters - baseline = parameters(str(BASELINE_YEAR)) - current = parameters(str(year)) - cpi_factor = float(current.gov.benefit_uprating_cpi) / float( - baseline.gov.benefit_uprating_cpi - ) - baseline_standard_allowance = float( - baseline.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] - ) - baseline_health_element = float( - baseline.gov.dwp.universal_credit.elements.disabled.amount - ) - return (baseline_standard_allowance + baseline_health_element) * cpi_factor + current_index = float(parameters(str(year)).gov.benefit_uprating_cpi) + baseline_index = float(parameters("2025").gov.benefit_uprating_cpi) + return current_index / baseline_index def _rebalanced_standard_allowance_monthly( sim: Simulation, year: int, claimant_type: str ) -> float: current = sim.tax_benefit_system.parameters(str(year)) - return float( + standard_allowance = float( current.gov.dwp.universal_credit.standard_allowance.amount[claimant_type] - ) * ( - 1 - + float(current.gov.dwp.universal_credit.rebalancing.standard_allowance_uplift) ) + uplift = float( + current.gov.dwp.universal_credit.rebalancing.standard_allowance_uplift + ) + return standard_allowance * (1 + uplift) -def test_existing_single_over_25_claimant_combined_award_tracks_cpi(monkeypatch): - _force_uc_seed(monkeypatch, [0.99]) - sim = Simulation(situation=_uc_claimant(30)) - - 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 - expected_total = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_OLD") - expected_health = expected_total - _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_OLD" - ) - - assert standard_allowance + health_element == pytest.approx(expected_total) - assert health_element == pytest.approx(expected_health) +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 test_existing_single_under_25_claimant_gets_extra_topup(monkeypatch): +@pytest.mark.parametrize("age_2025", [20, 30]) +def test_existing_claimants_keep_combined_award_cpi_protected( + monkeypatch, age_2025 +): _force_uc_seed(monkeypatch, [0.99]) - sim = Simulation(situation=_uc_claimant(22)) + 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 - expected_total = _cpi_protected_uc_award_monthly(sim, year, "SINGLE_YOUNG") - baseline_rebalanced_standard_allowance = _rebalanced_standard_allowance_monthly( - sim, year, "SINGLE_YOUNG" + new_claimant_health_element = ( + float( + sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element + ) ) - assert standard_allowance + health_element == pytest.approx(expected_total) - assert standard_allowance > baseline_rebalanced_standard_allowance + 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_new_claimants_use_fixed_health_element(monkeypatch): @@ -106,10 +100,30 @@ def test_new_claimants_use_fixed_health_element(monkeypatch): 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 + expected_health = ( + float( + sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element + ) ) assert health_element == pytest.approx(expected_health) + + +def test_standard_allowance_reforms_still_change_standard_allowance(monkeypatch): + _force_uc_seed(monkeypatch, [0.99]) + baseline = Simulation(situation=_uc_claimant(30)) + reformed = Simulation( + situation=_uc_claimant(30), + reform={ + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": { + "2025-01-01.2100-12-31": 800 + } + }, + ) + + baseline_standard_allowance = baseline.calculate("uc_standard_allowance", 2026)[0] + reformed_standard_allowance = reformed.calculate("uc_standard_allowance", 2026)[0] + + assert reformed_standard_allowance / baseline_standard_allowance > 1.5 From b26d4af66321bce3dd8cdc15996178f49bae3f6f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 7 Apr 2026 12:13:30 -0400 Subject: [PATCH 3/3] Format UC rebalancing follow-up changes --- policyengine_uk/scenarios/uc_reform.py | 10 ++++---- policyengine_uk/tests/test_uc_rebalancing.py | 24 ++++++++------------ 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 17bcd4d93..524d6e1d7 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -49,9 +49,7 @@ def _protected_single_young_health_element_monthly( ) -> 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.standard_allowance.amount.SINGLE_YOUNG) + float(baseline.gov.dwp.universal_credit.elements.disabled.amount) ) return protected_combined_award - _rebalanced_standard_allowance_monthly( @@ -85,9 +83,9 @@ def add_universal_credit_reform(sim: Microsimulation): _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 + protected_health_element[claimant_type == UCClaimantType.SINGLE_YOUNG.name] = ( + _protected_single_young_health_element_monthly(sim, year) * 12 + ) current_health_element[has_health_element & ~is_post_2025_claimant] = ( protected_health_element[has_health_element & ~is_post_2025_claimant] ) diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index df6be43fb..b7d294976 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -70,9 +70,7 @@ def _cpi_protected_uc_award_monthly( @pytest.mark.parametrize("age_2025", [20, 30]) -def test_existing_claimants_keep_combined_award_cpi_protected( - monkeypatch, age_2025 -): +def test_existing_claimants_keep_combined_award_cpi_protected(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" @@ -80,12 +78,10 @@ def test_existing_claimants_keep_combined_award_cpi_protected( 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 - ) + new_claimant_health_element = float( + sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element ) assert health_element > new_claimant_health_element @@ -100,12 +96,10 @@ def test_new_claimants_use_fixed_health_element(monkeypatch): 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 - ) + expected_health = float( + sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.rebalancing.new_claimant_health_element ) assert health_element == pytest.approx(expected_health)