Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 31 additions & 5 deletions policyengine_uk_data/datasets/disability_benefits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
141 changes: 140 additions & 1 deletion policyengine_uk_data/tests/test_disability_benefits.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down
Loading