diff --git a/changelog.d/severe-disability-flag-tax-credit-definition.fixed.md b/changelog.d/severe-disability-flag-tax-credit-definition.fixed.md new file mode 100644 index 00000000..8222631c --- /dev/null +++ b/changelog.d/severe-disability-flag-tax-credit-definition.fixed.md @@ -0,0 +1 @@ +Set the stored `is_severely_disabled_for_benefits` flag to the tax credit severe disability condition (CTC Regs 2002 reg 8(3)-(5); WTC Regs 2002 reg 17(2)-(4)): DLA care highest rate, PIP daily living enhanced rate, higher-rate Attendance Allowance. It is derived with the category rule, so it agrees with the stored benefit categories by construction. Lower-rate Attendance Allowance and FRS code 8 no longer set it; code 8 covers every Armed Forces Compensation Scheme and war disablement pension payment, and Armed Forces Independence Payment cannot be separated from it. policyengine-uk also keys the Universal Credit higher disabled child addition on this flag, although UC Regs 2013 reg 24(2)(b) differs (it adds blindness and has no AFIP limb). From policyengine-uk's release with PolicyEngine/policyengine-uk#1946, the legacy severe disability premium reads the benefit categories instead of this flag. diff --git a/policyengine_uk_data/datasets/disability_benefits.py b/policyengine_uk_data/datasets/disability_benefits.py index 5aec6c05..6dd83461 100644 --- a/policyengine_uk_data/datasets/disability_benefits.py +++ b/policyengine_uk_data/datasets/disability_benefits.py @@ -112,6 +112,18 @@ def _category_from_reported_amount( return category +def _reaches_weekly_rate(reported_amount: pd.Series, weekly_rate: float) -> pd.Series: + """Whether reported amounts reach a weekly rate under the category rule. + + Uses the category derivation itself, so a flag built from this agrees with + the categories by construction, including at the tolerance boundary. + """ + category = _category_from_reported_amount( + reported_amount, (("REACHED", weekly_rate),) + ) + return pd.Series(category == "REACHED", index=reported_amount.index) + + def add_disability_benefit_categories_from_reported_amounts( person: pd.DataFrame, year: int, @@ -193,7 +205,6 @@ def add_disability_benefit_flags_from_reported_amounts( attendance_allowance = _reported_amount(person, "attendance_allowance_reported") dla_sc = _reported_amount(person, "dla_sc_reported") pip_dl = _reported_amount(person, "pip_dl_reported") - afcs = _reported_amount(person, "afcs_reported") person["is_disabled_for_benefits"] = ( _reported_amount_sum(person, BASE_DISABILITY_FLAG_REPORTED_AMOUNT_COLUMNS) > 0 @@ -218,11 +229,26 @@ def add_disability_benefit_flags_from_reported_amounts( | (dla_sc > dla_sc_higher) | (pip_dl >= pip_dl_enhanced) ) + # The tax credit severe disability condition (CTC Regs 2002 reg 8(3)-(5); + # WTC Regs 2002 reg 17(2)-(4)): DLA care at the highest rate, PIP daily + # living at the enhanced rate, higher-rate Attendance Allowance (a WTC + # condition; children cannot receive it), or armed forces independence + # payment. policyengine-uk also keys the Universal Credit higher disabled + # child addition on this flag, although UC Regs 2013 reg 24(2)(b) differs + # (it adds blindness and has no AFIP limb). FRS code 8 (`afcs_reported`) + # covers every Armed Forces Compensation Scheme and war disablement pension + # payment, including the guaranteed income payment every AFIP recipient + # also receives; AFIP has no code of its own and cannot be separated out, + # so AFIP recipients are not flagged (a known under-count). The legacy + # severe disability premium's wider list (any Attendance Allowance, DLA + # care at the middle rate, PIP daily living at the standard rate) is read + # by policyengine-uk from the benefit categories, not from this flag. The + # flag uses the category rule, so it agrees with the stored categories by + # construction. person["is_severely_disabled_for_benefits"] = ( - (attendance_allowance > 0) - | (dla_sc >= dla_sc_higher) - | (pip_dl >= pip_dl_enhanced) - | (afcs > 0) + _reaches_weekly_rate(attendance_allowance, dwp.attendance_allowance.higher) + | _reaches_weekly_rate(dla_sc, dwp.dla.self_care.higher) + | _reaches_weekly_rate(pip_dl, dwp.pip.daily_living.enhanced) ) return person diff --git a/policyengine_uk_data/tests/test_disability_benefits.py b/policyengine_uk_data/tests/test_disability_benefits.py index e946a876..ba0e2aa4 100644 --- a/policyengine_uk_data/tests/test_disability_benefits.py +++ b/policyengine_uk_data/tests/test_disability_benefits.py @@ -1,5 +1,7 @@ from __future__ import annotations +from itertools import product + import pandas as pd from policyengine_uk import CountryTaxBenefitSystem from policyengine_uk.data import UKSingleYearDataset @@ -163,7 +165,144 @@ def test_attendance_allowance_feeds_stronger_disability_flags(): assert result["is_disabled_for_benefits"].tolist() == [True, True] assert result["is_enhanced_disabled_for_benefits"].tolist() == [False, True] - assert result["is_severely_disabled_for_benefits"].tolist() == [True, True] + # WTC Regs 2002 reg 17(2): only the higher rate is a severe disability. + assert result["is_severely_disabled_for_benefits"].tolist() == [False, True] + + +def test_armed_forces_compensation_scheme_is_not_a_severe_disability(): + # FRS code 8 covers every Armed Forces Compensation Scheme payment + # (including war disablement pensions and guaranteed income payments); + # only armed forces independence payment is a severe disability under CTC + # Regs 2002 reg 8(5) and WTC Regs 2002 reg 17(4), and the FRS has no code + # for it. + person = pd.DataFrame({"afcs_reported": [1_000.0]}) + + result = add_disability_benefit_flags_from_reported_amounts(person, 2025) + + assert result["is_disabled_for_benefits"].tolist() == [True] + assert result["is_severely_disabled_for_benefits"].tolist() == [False] + + +SEVERE_GRID_YEARS = range(2019, 2027) +# Offsets in GBP a week around each rate, including the GBP 1 tolerance edge. +SEVERE_GRID_OFFSETS = (0.0, 0.5, -0.5, -0.99, -1.0, -1.01, -1.5) + + +def _severe_grid(year): + dwp = CountryTaxBenefitSystem().parameters(year).gov.dwp + weeks = SURVEY_REPORTED_AMOUNT_WEEKS_IN_YEAR + + def amounts(rates): + return sorted( + {0.0} + | { + max(0.0, (float(rate) + offset) * weeks) + for rate in rates + for offset in SEVERE_GRID_OFFSETS + } + ) + + return pd.DataFrame( + list( + product( + amounts( + [dwp.attendance_allowance.lower, dwp.attendance_allowance.higher] + ), + amounts( + [ + dwp.dla.self_care.lower, + dwp.dla.self_care.middle, + dwp.dla.self_care.higher, + ] + ), + amounts([dwp.pip.daily_living.standard, dwp.pip.daily_living.enhanced]), + ) + ), + columns=[ + "attendance_allowance_reported", + "dla_sc_reported", + "pip_dl_reported", + ], + ) + + +def _tax_credit_severe_condition(categories): + return ( + (categories["aa_category"] == "HIGHER") + | (categories["dla_sc_category"] == "HIGHER") + | (categories["pip_dl_category"] == "ENHANCED") + ) + + +def test_severe_flag_is_the_tax_credit_condition_on_the_categories(): + # The stored flag must equal the tax credit severe disability condition + # (DLA care highest, PIP daily living enhanced, Attendance Allowance + # higher) read off the categories the same amounts map to, for every + # combination of rates and amounts around each rate, including the + # GBP 1/week tolerance edge, in every survey year. DLA care at the middle + # rate and PIP daily living at the standard rate are not severe for tax + # credits; the legacy severe disability premium reads them from the + # categories in policyengine-uk. + for year in SEVERE_GRID_YEARS: + person = _severe_grid(year) + categories = add_disability_benefit_categories_from_reported_amounts( + person, year + ) + flags = add_disability_benefit_flags_from_reported_amounts(person, year) + + expected = _tax_credit_severe_condition(categories) + assert (flags["is_severely_disabled_for_benefits"] == expected).all(), year + assert (categories["dla_sc_category"] == "MIDDLE").any() + assert (categories["pip_dl_category"] == "STANDARD").any() + assert expected.any() and not expected.all() + + +def test_severe_flag_matches_the_policyengine_uk_formula(): + # Differential: the stored flag and policyengine-uk's formula for + # is_severely_disabled_for_benefits, given the same categories, must agree. + # policyengine-uk releases before PolicyEngine/policyengine-uk#1946 count + # any AFCS payment and omit higher-rate Attendance Allowance, so this test + # fails until the lock includes that release; that failure is what keeps + # the new flag from shipping with an older model. + from policyengine_uk import Simulation + + year = 2025 + + def model_flags(combos): + people = { + f"p{i}": { + "aa_category": {year: aa}, + "dla_sc_category": {year: dla}, + "pip_dl_category": {year: pip}, + } + for i, (aa, dla, pip) in enumerate(combos) + } + simulation = Simulation(situation={"people": people}) + return simulation.calculate("is_severely_disabled_for_benefits", year) + + person = _severe_grid(year) + categories = add_disability_benefit_categories_from_reported_amounts(person, year) + flags = add_disability_benefit_flags_from_reported_amounts(person, year) + combos = sorted( + set( + zip( + categories["aa_category"], + categories["dla_sc_category"], + categories["pip_dl_category"], + ) + ) + ) + model = dict(zip(combos, model_flags(combos))) + stored = flags["is_severely_disabled_for_benefits"] + for combo, value in zip( + zip( + categories["aa_category"], + categories["dla_sc_category"], + categories["pip_dl_category"], + ), + stored, + ): + assert bool(model[combo]) == bool(value), combo def test_categories_and_flags_share_the_survey_fiscal_year_rates():