diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md new file mode 100644 index 0000000000..0285a05397 --- /dev/null +++ b/changelog.d/2283.fixed.md @@ -0,0 +1 @@ +When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 89ac3d4db3..0c5cb4382f 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,7 +24,7 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di Two parameters hold this amount, and both are uprated by `gov.economic_assumptions.indices.uc_health_element`, so they follow the same path: -- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and from 2033-34, after the data's projection ends, when the rebalancing scenario no longer sets the element. +- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. For an inactive year processed by the rebalancing scenario, the scenario replaces that paid amount with the pre-Act counterfactual described below. From 2033-34, after the data's projection ends, the rebalancing scenario no longer sets the element and the formula reads this parameter. - `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants in the years it sets the element, `uc_reform.REBALANCING_YEARS` (2026-27 to 2032-33). A change to the freeze or to the benefit uprating index moves both, and so does a reform that sets the index, `gov.economic_assumptions.indices.uc_health_element`, directly (it replaces the freeze and the no-fall floor for the years it names). A reform to either amount itself does not change the other. @@ -92,6 +92,8 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +For the health component, the inactive 2026-27 amount is a declared CPI-only counterfactual: the 2025-26 monthly LCWRA amount (£423.27) increased by September 2025 CPI (3.8%) and rounded to monthly pennies (£439.35, or £5,272.20 a year). SSAA 1992 section 150 allows discretion over Universal Credit uprating and rounding; this convention is not an enacted LCWRA rate or the only legally possible amount. From 2027-28, the model's ordinary benefit uprating index projects growth from that 2026-27 anchor. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that fixed 2026-27 anchor, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. The scenario covers tax years through 2032-33; from 2033-34 the inactive setting falls back to the underlying amount path, so it does not provide a continuous long-run “Act off” projection. + The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. ### Changing the standard allowance uplift parameters diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index c159821941..eaa03ea02d 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,11 +1,12 @@ -from policyengine_uk.model_api import Scenario +from policyengine_uk.model_api import Scenario, Variable from policyengine_uk import Microsimulation from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR -from policyengine_uk.utils.supplied_inputs import set_model_value +from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input import numpy as np import warnings -from policyengine_core.periods import instant +from weakref import WeakKeyDictionary +from policyengine_core.periods import instant, period as period_ # Universal Credit Act 2025: the first tax year of the rebalancing (2026-27, @@ -214,7 +215,97 @@ def _new_claimant_health_element_status( return status +def _lcwra_amount_is_overridden(sim, year: int) -> bool: + """An explicit LCWRA amount keeps precedence over the inactive path.""" + from policyengine_uk.utils.dated_reforms import legacy_override_bounds + from policyengine_uk.tax_benefit_system import ( + reform_parameter_defines, + parameter_reads_calendar_years, + ) + + system = sim.tax_benefit_system + overrides = list(getattr(system, "legacy_overrides", ())) + # Single-day constructor overrides run after this modifier. Respect + # their requested window before caching an inactive award. + for dated in getattr(sim, "_building_dated_reforms", None) or (): + _, pending = dated.read( + reform_parameter_defines(), + calendar_years=parameter_reads_calendar_years(), + ) + overrides.extend(pending) + statutory_sample = instant(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + annual_sample = instant(f"{year}-01-01") + return any( + change.path == LCWRA_AMOUNT and change.start <= statutory_sample <= change.stop + for change in getattr(system, "dated_assignments", ()) + ) or any( + change.path == LCWRA_AMOUNT and start <= annual_sample <= stop + for change in overrides + for start, stop in [legacy_override_bounds(change, system.parameters)] + ) + + +def _inactive_health_element_monthly(sim, year: int) -> float: + """The CPI-only counterfactual, independent of previous paid awards. + + Anchor 2026-27 to the pre-Act amount increased by September 2025 CPI, + rounded to monthly pennies. This is a scenario convention, not an + enacted LCWRA rate or a uniquely required SSAA 1992 s.150 rounding rule. + Later years follow the ordinary benefit index from that 2026 anchor. + """ + parameters = sim.tax_benefit_system.parameters + before = parameters(str(FIRST_REBALANCING_YEAR - 1)).gov + anchor = round( + float(before.dwp.universal_credit.elements.disabled.amount) + * _relevant_cpi_factor(sim, FIRST_REBALANCING_YEAR), + 2, + ) + anchor_index = parameters(str(FIRST_REBALANCING_YEAR)).gov.benefit_uprating_cpi + return anchor * parameters(str(year)).gov.benefit_uprating_cpi / anchor_index + + +# Only functions registered here are this internal amount wrapper. External +# wrappers, even ones copying function attributes, keep their own provenance. +_INACTIVE_HEALTH_ELEMENT_ORIGINS = WeakKeyDictionary() + + +def lcwra_formula_origin(formula, period): + """Resolve the formula delegated by our internal LCWRA amount wrapper.""" + if formula is None: + return None + original = _INACTIVE_HEALTH_ELEMENT_ORIGINS.get(formula) + return original.get_formula(period) if original is not None else formula + + +def _add_inactive_health_element_formula(sim): + """Keep the counterfactual as calculated output, invalidated by reforms.""" + system = sim.tax_benefit_system + original = system.get_variable("uc_LCWRA_element") + if getattr(original, "_inactive_health_element_formula", False): + return + + class uc_LCWRA_element(Variable): + def formula(benunit, period, parameters): + current = original.get_formula(period)(benunit, period, parameters) + year = period.start.year + if year not in REBALANCING_YEARS: + return current + simulation = benunit.simulation + p = parameters(period).gov.dwp.universal_credit + if p.rebalancing.active or _lcwra_amount_is_overridden(simulation, year): + return current + amount = p.elements.disabled.amount + if amount == 0: + return current + return current * _inactive_health_element_monthly(simulation, year) / amount + + _INACTIVE_HEALTH_ELEMENT_ORIGINS[uc_LCWRA_element.formula] = original + variable = system.update_variable(uc_LCWRA_element) + variable._inactive_health_element_formula = True + + def add_universal_credit_reform(sim: Microsimulation): + _add_inactive_health_element_formula(sim) rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing generator = np.random.default_rng(43) @@ -227,6 +318,14 @@ def add_universal_credit_reform(sim: Microsimulation): is_data = built_from_data(sim) for year in REBALANCING_YEARS: if not rebalancing.active(year): + population = sim.get_variable_population("uc_LCWRA_element") + if ( + supplied_input(population, "uc_LCWRA_element", period_(year)) + is not None + ): + continue + # Recalculate through the formula, preserving user-supplied awards. + sim.delete_arrays("uc_LCWRA_element", year) continue is_post_2025_claimant = _new_claimant_health_element_status( sim, year, uc_seed < new_claimant_share(year), is_data diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 7d428435e8..9648177f44 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -940,3 +940,94 @@ def test_an_element_is_entered_exactly_when_its_last_store_was_supplied( assert is_entered(simulation, year) == entered, operations for clone_source, was_entered in earlier: assert is_entered(clone_source, year) == was_entered, operations + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("holder_role", ["claimant", "other"]) +def test_internal_lcwra_wrapper_keeps_the_model_claimant_filter( + year, active, holder_role +): + """An internal amount wrapper does not turn nonclaimant circumstances into an award.""" + from policyengine_uk.model_api import Scenario + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + claimant, other = blank("claimant", 40), blank("other", 30) + (claimant if holder_role == "claimant" else other)[ + "uc_limited_capability_for_WRA" + ] = True + sim = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})], year), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", year)[0] > 0 + assert not is_entered(sim, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("amount", [0, 5_000]) +def test_internal_lcwra_wrapper_keeps_explicit_positive_and_nil_entries(active, amount): + from policyengine_uk.model_api import Scenario + + sim = Simulation( + situation=element_situation(2026, amount), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert is_entered(sim, 2026) + assert bool(sim.calculate(UC_SPECIFIED, 2026)[0]) == (amount > 0) + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("amount", [0, 6_000]) +@pytest.mark.parametrize("when", ["constructor", "after_internal_wrapper"]) +def test_internal_lcwra_wrapper_preserves_real_external_formula_origin( + year, amount, when +): + from policyengine_uk.model_api import Scenario + from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.is_uc_benefit_cap_exempt_specified_benefit import ( + calculated_by, + uc_LCWRA_element as model_lcwra_element, + ) + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + reform = element_reform("uc_LCWRA_element", amount) + sim = Simulation( + situation=element_situation(year), + reform=reform if when == "constructor" else None, + ) + if when == "after_internal_wrapper": + Scenario.from_reform(reform).apply(sim) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 5340e91e53..25ce75d108 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -408,3 +408,290 @@ def test_a_branch_can_state_its_own_status(): _new_claimant_rate(sim, 2026) ) assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(parent) + + +# The inactive health-component counterfactual starts from pre-Act 2025 law, +# independently of the new/protected claimant split (issue #2283). +# The independently sourced 2026 CPI-only monthly-penny anchor is £439.35. +def _inactive_lcwra_expected(sim, year): + parameters = sim.tax_benefit_system.parameters + + def index(y): + return float(parameters(str(y)).gov.benefit_uprating_cpi) + + return 439.35 * index(year) / index(2026) * 12 + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_inactive_health_element_restores_pre_act_uprated_amount(year): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", year)[0] + # Nonzero verifies that this is the explicit-year claimant, not the + # nondiagnostic scalar-input/default-2025 situation from preflight. + assert actual > 0 + assert actual == pytest.approx(_inactive_lcwra_expected(sim, year), rel=0, abs=1e-3) + + +@pytest.mark.parametrize("new_claimant", [False, True]) +def test_inactive_health_amount_is_independent_of_claimant_cohort(new_claimant): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_receives_new_claimant_health_element"] = { + year: new_claimant for year in YEARS + } + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + _inactive_lcwra_expected(sim, 2026), rel=0, abs=1e-3 + ) + + +def test_mixed_toggle_uses_independent_inactive_years_and_retains_active_path(): + baseline = Simulation(situation=_uc_claimant(30)) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": { + "2026": False, + "2027": True, + "2028": False, + }, + }, + applied_before_data_load=True, + ), + ) + for year in [2026, 2028]: + assert sim.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + _inactive_lcwra_expected(sim, year), rel=0, abs=1e-3 + ) + assert sim.calculate("uc_LCWRA_element", 2027)[0] == pytest.approx( + baseline.calculate("uc_LCWRA_element", 2027)[0], rel=0, abs=1e-3 + ) + + +@pytest.mark.parametrize("route", ["scenario", "legacy_reform"]) +def test_inactive_health_element_keeps_explicit_parameter_amount(route): + changes = { + "gov.dwp.universal_credit.rebalancing.active": False, + "gov.dwp.universal_credit.elements.disabled.amount": {"2026": 300}, + } + kwargs = { + "scenario": Scenario(parameter_changes=changes, applied_before_data_load=True) + } + if route == "legacy_reform": + kwargs = { + "reform": { + "gov.dwp.universal_credit.rebalancing.active": {"2026": False}, + "gov.dwp.universal_credit.elements.disabled.amount": { + "2026-01-01": 300 + }, + } + } + sim = Simulation(situation=_uc_claimant(30), **kwargs) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +def test_inactive_health_element_keeps_directly_supplied_award(): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_LCWRA_element"] = {"2026": 7_200} + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == 7_200 + + +def test_inactive_health_element_reaches_final_uc_without_forced_intermediates(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # No earnings, other benefits, housing or children: the standard allowance + # plus the independent inactive health amount is the whole UC award. + expected = STANDARD_ALLOWANCE_2026["SINGLE_OLD"] * 12 + _inactive_lcwra_expected( + sim, 2026 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_modifier_is_idempotent(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + expected = _inactive_lcwra_expected(sim, 2026) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_amount_reform_on_a_built_simulation_keeps_precedence(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +@pytest.mark.parametrize("repeat_modifier", [False, True]) +def test_inactive_cached_awards_follow_public_late_amount_reform(repeat_modifier): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # Prime both caches before using the public parameter-change lifecycle. + assert sim.calculate("uc_LCWRA_element", 2026)[0] > 3_600 + assert sim.calculate("universal_credit", 2026)[0] > 8_698.80 + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + if repeat_modifier: + uc_reform.add_universal_credit_reform(sim) + # The 2026 single-25+ standard allowance is £424.90/month (SI 2026/113), + # plus the explicitly reformed £300/month LCWRA amount, with no reductions. + assert [ + sim.calculate("uc_LCWRA_element", 2026)[0], + sim.calculate("universal_credit", 2026)[0], + ] == pytest.approx([3_600, 8_698.80], rel=0, abs=0.01) + + +def test_inactive_2026_uses_penny_rounded_september_cpi_anchor(): + # Declared CPI-only counterfactual: DWP 2025 LCWRA £423.27, September + # 2025 CPI 3.8%; round the monthly £439.35426 to £439.35, then annualise. + # SSAA 1992 s.150 permits discretion; this is not an enacted LCWRA rate. + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", 2026)[0] + assert actual > 0 + assert actual == pytest.approx(5_272.20, rel=0, abs=0.01) + assert actual / 12 == pytest.approx(439.35, rel=0, abs=0.001) + # Existing legislated SA stays separate: single25+ £424.90/month. + assert sim.calculate("uc_standard_allowance", 2026)[0] == pytest.approx( + 424.90 * 12, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 10_371.00, rel=0, abs=0.01 + ) + + +@pytest.mark.parametrize("protected_floor", [0, 0.85]) +def test_inactive_health_cash_reconciles_with_currentmain_deductions(protected_floor): + # Natural LCWRA circumstances make this claimant benefit-cap exempt. + # The 15% deduction rate is an explicit interface input, not imputed + # debt incidence. A protected floor is a separate modeled reform. + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_deduction_rate"] = {2026: 0.15} + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + "gov.dwp.universal_credit.deductions.protected_floor": protected_floor, + }, + applied_before_data_load=True, + ), + ) + # Declared CPI-only monthly-penny counterfactual, not an enacted rate: + # LCWRA 5272.20 + legislated SA 5098.80 = 10371; debt is 764.82. + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + 5_272.20, rel=0, abs=0.01 + ) + assert sim.calculate("uc_standard_allowance", 2026)[0] == pytest.approx( + 5_098.80, rel=0, abs=0.01 + ) + assert sim.calculate("uc_benefit_cap_reduction", 2026)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] == 0 + assert sim.calculate("universal_credit_pre_benefit_cap", 2026)[0] == pytest.approx( + 10_371.00, rel=0, abs=0.01 + ) + assert sim.calculate("uc_deductions", 2026)[0] == pytest.approx( + 764.82, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 9_606.18, rel=0, abs=0.01 + ) + assert ( + sim.calculate("uc_deductions", 2026)[0] + + sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] + ) == pytest.approx( + sim.calculate("universal_credit_pre_benefit_cap", 2026)[0] + - sim.calculate("universal_credit", 2026)[0], + rel=0, + abs=0.01, + ) + # Both caches are now primed. The public late amount reform retains + # precedence while the new deduction components still reconcile. + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + 3_600, rel=0, abs=0.01 + ) + assert sim.calculate("uc_deductions", 2026)[0] == pytest.approx( + 764.82, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 7_933.98, rel=0, abs=0.01 + ) + assert sim.calculate("uc_benefit_cap_reduction", 2026)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] == 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index 531c812ba6..ddbd0abac2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -52,8 +52,10 @@ def calculated_by(population, variable_name, period, model_variable): formula (``model_variable``'s), rather than one a reform put in its place. The tax-benefit system loads each variable file as its own module, so the formula is matched by where its code is, not by identity.""" + from policyengine_uk.scenarios.uc_reform import lcwra_formula_origin + variable = population.simulation.tax_benefit_system.get_variable(variable_name) - formula = variable.get_formula(period) + formula = lcwra_formula_origin(variable.get_formula(period), period) code = getattr(formula, "__code__", None) model = model_variable.formula.__code__ return code is not None and (