From 4bce9cc9b05cce06d20831675d9270857f61f9d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:54:57 -0400 Subject: [PATCH 01/12] Lift the benefit cap for ESA only when it includes the support component HB Regs 2006 reg 75F(1)(a) and UC Regs 2013 reg 83(1)(a) lift the cap where "the claimant or the claimant's partner is receiving an employment and support allowance ... which includes a support component". The model lifted it for any income-related ESA of the claimant or partner and for any member's contributory ESA, in both is_benefit_cap_exempt_health_disability and is_benefit_cap_exempt_other. Add has_own_esa_award (Person): the person claims ESA themselves (a reported contributory or income-related award, or a directly entered income-related award attributed to the claimant who heads the benefit unit). Add esa_includes_support_component (Person): whether that ESA includes the support component (WRA 2007 ss.2(2), 4(4)). Datasets or users can supply it; otherwise it is assumed for anyone with an ESA award of their own, as for about nine in ten ESA recipients in the Family Resources Survey 2024-25. Co-Authored-By: Claude Opus 5.5 --- .../legacy_award_readers/passports.yaml | 7 +- .../benefit_cap_specified_benefits.yaml | 326 ++++++++++++++++++ .../gov/dwp/esa_includes_support_component.py | 26 ++ .../variables/gov/dwp/has_own_esa_award.py | 36 ++ ...is_benefit_cap_exempt_health_disability.py | 18 +- .../gov/dwp/is_benefit_cap_exempt_other.py | 8 +- 6 files changed, 409 insertions(+), 12 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml create mode 100644 policyengine_uk/variables/gov/dwp/esa_includes_support_component.py create mode 100644 policyengine_uk/variables/gov/dwp/has_own_esa_award.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index b04004fe15..e563b69d91 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -335,8 +335,7 @@ targeted_childcare_entitlement_eligible: true # Benefit cap: no cap where "the claimant or the claimant's partner" receives -# ESA with the support component (HB Regs 2006 reg 75F(1)(a)). The model -# exempts any income-related ESA of the claimant or partner. +# ESA with the support component (HB Regs 2006 reg 75F(1)(a)). - name: Another member's income-related ESA does not exempt the family from the benefit cap period: 2025 @@ -350,6 +349,7 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + esa_includes_support_component: true benunits: benunit: members: [claimant, other] @@ -360,7 +360,7 @@ output: is_benefit_cap_exempt_health_disability: false -- name: The claimant's own income-related ESA exempts the family from the benefit cap +- name: The claimant's own income-related ESA with the support component exempts the family from the benefit cap period: 2025 input: people: @@ -368,6 +368,7 @@ age: 40 is_claimant_or_partner: true esa_income_reported: 5_000 + esa_includes_support_component: true other: age: 30 is_claimant_or_partner: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml new file mode 100644 index 0000000000..155d9fcc7b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -0,0 +1,326 @@ +# Benefit cap exceptions for receipt of specified benefits. +# +# HB Regs 2006 (SI 2006/213) reg 75F(1): "The benefit cap does not apply +# where— (a) the claimant or the claimant's partner is receiving an +# employment and support allowance under Part 1 of the Welfare Reform Act +# which includes a support component; (b) the claimant or the claimant's +# partner is receiving an industrial injuries benefit ...; (c) ... an +# attendance allowance; ... (d) ... a war pension; (e) the claimant, the +# claimant's partner or a child or young person for whom the claimant or the +# claimant's partner is responsible, is receiving a disability living +# allowance; ... (ea) the claimant, the claimant's partner or a young person +# for whom the claimant or the claimant's partner is responsible, is +# receiving a personal independence payment, adult disability payment or an +# armed forces independence payment ...; ... (h) the claimant, the claimant's +# partner, or a young person for whom the claimant or the claimant's partner +# is responsible, is entitled to a carer's allowance ...; (ha) ... carer +# support payment". Reg 75F(2)(a): a war pension includes "a guaranteed +# income payment". +# +# UC Regs 2013 (SI 2013/376) reg 83(1) draws the same lines with "a +# claimant", adding "(a) the LCWRA element is included in the award" and +# "(j) the carer element is included in the award". The model applies one +# cap to both schemes, so either scheme's exception counts. +# +# 2025-26 caps (UC reg 80A(2); HB reg 75CA): single outside London 14,753; +# couple or family outside London 22,020. Each family lives in the North +# East. Benefit amounts are set directly, so the expected values follow from +# the cap arithmetic alone: reduction = capped benefits - cap (UC reg 81; +# HB reg 75D). +# +# A "non-dependent adult" below is a benefit-unit member aged 30 (50 in the +# attendance allowance case) who is neither the claimant, the partner, nor a +# child or young person they are responsible for. Their own benefits and +# circumstances never lift the family's cap. (Their age and earnings still +# can, through is_benefit_cap_exempt_other and _earnings: out of scope here.) + +# --- ESA with the support component: HB reg 75F(1)(a), UC reg 83(1)(a) --- + +- name: Claimant's contributory ESA with the support component lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: true + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + is_benefit_cap_exempt: true + benefit_cap_reduction: 0 + universal_credit: 18_000 + +- name: Claimant's contributory ESA without the support component does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: false + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + benefit_cap: 14_753 + # 18,000 UC + 7,000 ESA - 14,753 = 10,247 + benefit_cap_reduction: 10_247 + # 18,000 - 10,247 + universal_credit: 7_753 + +- name: Claimant's income-related ESA with the support component lifts the Housing Benefit cap + period: 2025 + input: + people: + claimant: + age: 40 + esa_income_reported: 6_000 + esa_includes_support_component: true + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + esa_income: 6_000 + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + housing_benefit: 9_000 + +- name: Claimant's income-related ESA without the support component does not lift the Housing Benefit cap + period: 2025 + input: + people: + claimant: + age: 40 + esa_income_reported: 6_000 + esa_includes_support_component: false + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + esa_income: 6_000 + is_benefit_cap_exempt_health_disability: false + benefit_cap: 14_753 + # 9,000 HB + 6,000 ESA - 14,753 = 247 (HB reg 75D(1)) + benefit_cap_reduction: 247 + housing_benefit: 8_753 + +- name: Partner's contributory ESA with the support component lifts the couple's cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + esa_contrib_reported: 7_000 + esa_includes_support_component: true + benunits: + family: + members: [claimant, partner] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_couple: true + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: Partner's contributory ESA without the support component leaves the couple capped + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + esa_contrib_reported: 7_000 + esa_includes_support_component: false + benunits: + family: + members: [claimant, partner] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + benefit_cap: 22_020 + # 18,000 + 7,000 - 22,020 = 2,980 + benefit_cap_reduction: 2_980 + +- name: Income-related ESA that the capital test stops is not received, so it does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + esa_income_reported: 6_000 + esa_includes_support_component: true + # Isolate the ESA head. Universal Credit would still give this person + # limited capability for work-related activity from the ESA + # assessment (UC Regs 2013 reg 40(1)(a)(ii)), and the model's one cap + # takes UC reg 83(1)(a) for Housing Benefit families too. + uc_limited_capability_for_WRA: false + benunits: + family: + members: [claimant] + would_claim_uc: false + esa_income_eligible: false + households: + home: + members: [claimant] + region: NORTH_EAST + output: + esa_income: 0 + is_on_income_related_esa: false + is_benefit_cap_exempt_health_disability: false + +- name: With no support-component input, a person's own contributory ESA is taken to include it + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + esa_includes_support_component: true + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: A person without ESA has no support component + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + esa_includes_support_component: false + is_benefit_cap_exempt_health_disability: false + +- name: An income-related award entered for the family is the claimant's, with the support component + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + esa_income: 6_000 + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_on_income_related_esa: true + esa_includes_support_component: true + is_benefit_cap_exempt_health_disability: true + housing_benefit: 9_000 + +- name: A partner who reports the couple's income-related ESA has the support component + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + esa_income_reported: 6_000 + benunits: + family: + members: [claimant, partner] + would_claim_uc: false + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + # The award is the partner's: the claimant has no allowance of their own + # (WRA 2007 s.4(4)(b): "the claimant has limited capability for + # work-related activity" refers to the person whose allowance it is). + is_on_income_related_esa: [true, true] + esa_includes_support_component: [false, true] + is_benefit_cap_exempt_health_disability: true + +- name: An income-related award entered for a couple is the award of the claimant who heads the family + period: 2025 + input: + people: + claimant: + age: 38 + is_claimant_or_partner: true + is_benunit_head: true + partner: + age: 40 + is_claimant_or_partner: true + is_benunit_head: false + benunits: + family: + members: [claimant, partner] + esa_income: 6_000 + would_claim_uc: false + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_on_income_related_esa: [true, true] + has_own_esa_award: [true, false] + esa_includes_support_component: [true, false] + is_benefit_cap_exempt_health_disability: true diff --git a/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py b/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py new file mode 100644 index 0000000000..c3cec6cdf4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class esa_includes_support_component(Variable): + value_type = bool + entity = Person + label = "ESA includes the support component" + documentation = ( + "Whether this person's employment and support allowance includes the " + "support component: the assessment phase has ended and they have " + "limited capability for work-related activity (Welfare Reform Act 2007 " + "s.2(2) for the contributory allowance, s.4(4) for the income-related " + "allowance). Surveys record this as being in the ESA support group. " + "Datasets or users can supply it. Otherwise it is assumed for anyone " + "with an ESA award of their own (has_own_esa_award): in the Family " + "Resources Survey 2024-25 about nine in ten ESA recipients who answer " + "say they are in the support group." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2007/5/section/2", + "https://www.legislation.gov.uk/ukpga/2007/5/section/4", + ) + + def formula(person, period, parameters): + return person("has_own_esa_award", period) diff --git a/policyengine_uk/variables/gov/dwp/has_own_esa_award.py b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py new file mode 100644 index 0000000000..4115d97b11 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class has_own_esa_award(Variable): + value_type = bool + entity = Person + label = "has an ESA award of their own" + documentation = ( + "Whether this person claims an employment and support allowance " + "themselves: they report contributory ESA or an income-related award. " + "When the family's income-related award is entered directly " + "(esa_income) rather than reported, it is attributed to one claimant: " + "the claimant or partner who heads the benefit unit, or else the " + "eldest of them. The award's support component " + "(esa_includes_support_component) and the work capability assessment " + "behind it belong to this person." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2007/5/section/2", + "https://www.legislation.gov.uk/ukpga/2007/5/section/4", + ) + + def formula(person, period, parameters): + income_related = person("esa_income_reported", period) + own_report = (person("esa_contrib", period) > 0) | (income_related > 0) + claimant = person("is_claimant_or_partner", period) + reported_by_couple = person.benunit.sum(income_related * claimant) > 0 + entered_for_couple = ( + person.benunit("claimant_or_partner_esa_income", period) > 0 + ) & ~reported_by_couple + head_first = person("is_benunit_head", period) * 1_000 + person("age", period) + attributed = claimant & ( + person.get_rank(person.benunit, -head_first, condition=claimant) == 0 + ) + return own_report | (entered_for_couple & attributed) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 473155bfd2..636216094f 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -43,10 +43,8 @@ def formula(benunit, period, parameters): "iidb", # Industrial injuries disability benefit ] - # ESA and Working Tax Credit. HB Regs 2006 reg 75F(1)(a) exempts a - # claimant or partner receiving ESA, so only their income-related award. + # Working Tax Credit QUAL_BENUNIT_BENEFITS = [ - "claimant_or_partner_esa_income", # Income-based ESA "working_tax_credit", # If getting WTC, likely working enough ] @@ -56,8 +54,18 @@ def formula(benunit, period, parameters): # Check for Armed Forces Compensation Scheme payments afcs = benunit("afcs", period) > 0 - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 + # HB Regs 2006 reg 75F(1)(a), UC Regs 2013 reg 83(1)(a): "the claimant + # or the claimant's partner is receiving an employment and support + # allowance ... which includes a support component". Contributory and + # income-related allowances both carry it; another member's allowance + # does not count. + claimant = person("is_claimant_or_partner", period) + receiving_esa = (person("esa_contrib", period) > 0) | person( + "is_on_income_related_esa", period + ) + esa_support_component = benunit.any( + claimant & receiving_esa & person("esa_includes_support_component", period) + ) return ( has_lcwra diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py index 0fb1547506..4d5728fbfe 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py @@ -54,7 +54,7 @@ def formula(benunit, period, parameters): # Check for Armed Forces Compensation Scheme payments afcs = benunit("afcs", period) > 0 - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 - - return has_pensioner | afcs | esa_support_component + # The ESA support component exception is in + # is_benefit_cap_exempt_health_disability, limited to the claimant and + # partner. + return has_pensioner | afcs From 6764375204cb761891e58656b135332b16eaec5d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 05:55:31 -0400 Subject: [PATCH 02/12] Lift the benefit cap only for the people HB reg 75F and UC reg 83 name HB Regs 2006 reg 75F(1) and UC Regs 2013 reg 83(1) name, for each benefit, whose receipt lifts the cap. is_benefit_cap_exempt_health_disability summed every benefit-unit member's attendance allowance, DLA, PIP, industrial injuries benefit, Carer's Allowance and Carer Support Payment, and is_benefit_cap_exempt_other exempted any member's armed forces compensation. Any member with the LCWRA flag or caring responsibilities also lifted it. Now: - the claimant or partner only: attendance allowance, industrial injuries benefit and armed forces compensation (75F(1)(b)-(d); 83(1)(b)-(e)); - also a child or young person they are responsible for: DLA (75F(1)(e); 83(1)(f)); - also a young person: PIP, Carer's Allowance and Carer Support Payment (75F(1)(ea), (h), (ha); 83(1)(g), (i), (ia)); armed forces independence payment for a Housing Benefit young person (75F(1)(ea); UC reg 2 counts it as attendance allowance); - the UC LCWRA and carer elements: a claimant with limited capability or caring responsibilities (83(1)(a), (j); regs 27(1), 29(1)), or an element entered directly. The cap serves both schemes, so a child or young person under either scheme's definition counts. A 16-year-old who receives benefits in their own right or is looked after counts for neither (UC regs 4(6), 5(5); HB reg 19(2)). Datasets flag every ESA recipient as disabled, so the LCWRA exception lifted the cap for every ESA recipient whatever their ESA group. Universal Credit takes the ESA assessment (UC Regs 2013 reg 40(1)(a)(ii)), so for a person with an ESA award of their own uc_limited_capability_for_WRA is now inferred from the support component. The work allowance and the reduced minimum age need only limited capability for work (regs 8(1)(a), 22, 39), which an ESA award carries, so they read the new uc_limited_capability_for_work. is_benefit_cap_exempt_earnings and is_benefit_cap_exempt_other lose their unused benefit sums and keep their results. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.added.md | 3 + .../benefit-cap-75f-claimant-partner.fixed.md | 5 + ...enefit-cap-75f-claimant-partner.removed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../policy/baseline/gov/dwp/benefit_cap.yaml | 24 + .../benefit_cap_specified_benefits.yaml | 932 ++++++++++++++++++ ...nefit_cap_specified_benefits_properties.py | 364 +++++++ .../gov/dwp/is_benefit_cap_exempt.py | 2 +- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 43 +- ...is_benefit_cap_exempt_health_disability.py | 197 +++- .../gov/dwp/is_benefit_cap_exempt_other.py | 58 +- .../uc_limited_capability_for_WRA.py | 25 +- .../uc_limited_capability_for_work.py | 28 + .../meets_uc_minimum_age_condition.py | 7 +- .../is_uc_work_allowance_eligible.py | 6 +- 15 files changed, 1543 insertions(+), 154 deletions(-) create mode 100644 changelog.d/benefit-cap-75f-claimant-partner.added.md create mode 100644 changelog.d/benefit-cap-75f-claimant-partner.fixed.md create mode 100644 changelog.d/benefit-cap-75f-claimant-partner.removed.md create mode 100644 policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py diff --git a/changelog.d/benefit-cap-75f-claimant-partner.added.md b/changelog.d/benefit-cap-75f-claimant-partner.added.md new file mode 100644 index 0000000000..3352ec6103 --- /dev/null +++ b/changelog.d/benefit-cap-75f-claimant-partner.added.md @@ -0,0 +1,3 @@ +- Add `esa_includes_support_component`: whether a person's employment and support allowance includes the support component. Datasets and users can supply it; otherwise it is assumed for anyone with an ESA award of their own. Household calculations with ESA outside the support group should set it to false. +- Add `has_own_esa_award`: whether a person claims ESA themselves (a reported award, or a directly entered income-related award attributed to the claimant who heads the benefit unit). +- Add `uc_limited_capability_for_work`. diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md new file mode 100644 index 0000000000..eeccf04837 --- /dev/null +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -0,0 +1,5 @@ +- Lift the benefit cap for employment and support allowance only where the claimant's or partner's allowance includes the support component (HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg 83(1)(a)), for contributory and income-related ESA alike. Any income-related ESA, and any member's contributory ESA, used to lift it. Without other information the support component is assumed for a person with their own allowance, as for about nine in ten ESA recipients in the Family Resources Survey. +- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap; their age and earnings still do, through the age and earnings exceptions. +- Count armed forces independence payment towards the benefit cap exception. +- Infer limited capability for work-related activity (`uc_limited_capability_for_WRA`) for a person with their own ESA from whether it includes the support component, since Universal Credit takes the ESA assessment (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. +- Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity, or an ESA award of the person's own. diff --git a/changelog.d/benefit-cap-75f-claimant-partner.removed.md b/changelog.d/benefit-cap-75f-claimant-partner.removed.md new file mode 100644 index 0000000000..82cc23cbf0 --- /dev/null +++ b/changelog.d/benefit-cap-75f-claimant-partner.removed.md @@ -0,0 +1 @@ +- Remove the unused benefit sums from `is_benefit_cap_exempt_earnings` and `is_benefit_cap_exempt_other`; the age exception keeps its result and the armed forces and ESA exceptions move, limited to the claimant and partner, into `is_benefit_cap_exempt_health_disability`. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 5581288a93..a9fc13e736 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus its unearned income. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus its unearned income. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The cap does not apply where the family earns enough, a member has reached State Pension age or the family gets working tax credit, or where the claimant, their partner or, for some benefits, a child or young person they are responsible for receives a benefit listed in UC Regs 2013 reg 83 (for example ESA with the support component, disability living allowance or personal independence payment). Another member's benefits do not count (`is_benefit_cap_exempt`).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml index fcdafae7eb..4b681f87d7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml @@ -135,6 +135,30 @@ # Change = 14,753 - 22,020 = -7,267 per year. benefit_cap: 14_753 +- name: A 16-year-old outside education gives the family cap (UC reg 5(1)(a)). + period: 2025 + input: + people: + claimant: + age: 30 + is_benunit_head: true + is_parent: true + young_person: + age: 16 + is_benunit_head: false + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [claimant, young_person] + is_benefit_cap_exempt: false + households: + home: + members: [claimant, young_person] + region: SOUTH_EAST + output: + is_responsible_for_child_or_young_person_for_uc_or_housing_benefit: true + benefit_cap: 22_020 + - name: A looked-after foster child does not give the claimant the family cap. period: 2025 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 155d9fcc7b..6fce025ccf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -184,6 +184,33 @@ # 18,000 + 7,000 - 22,020 = 2,980 benefit_cap_reduction: 2_980 +- name: A non-dependent adult's ESA with the support component does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_contrib_reported: 7_000 + esa_includes_support_component: true + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt_other: false + is_benefit_cap_exempt: false + benefit_cap: 14_753 + - name: Income-related ESA that the capital test stops is not received, so it does not lift the cap period: 2025 input: @@ -298,6 +325,145 @@ esa_includes_support_component: [false, true] is_benefit_cap_exempt_health_disability: true +- name: An ESA recipient without the support component has no LCWRA, whatever their disability flag + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: false + is_disabled_for_benefits: true + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # UC Regs 2013 reg 40(1)(a)(ii): Universal Credit takes the ESA work + # capability determination, which found limited capability for work only. + uc_limited_capability_for_WRA: false + uc_LCWRA_element: 0 + is_benefit_cap_exempt_health_disability: false + # 18,000 + 7,000 - 14,753 + benefit_cap_reduction: 10_247 + +- name: An ESA recipient with the support component has LCWRA without a disability benefit + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: true + is_disabled_for_benefits: false + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + uc_limited_capability_for_WRA: true + # 2025-26 LCWRA element: 423.27 a month x 12 + uc_LCWRA_element: 5_079.24 + +- name: An ESA recipient without the support component still has limited capability for work + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: false + is_disabled_for_benefits: true + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # UC Regs 2013 reg 39(1)(a): Universal Credit takes the ESA determination + # of limited capability for work, which keeps the work allowance + # (reg 22(1)(b)). + uc_limited_capability_for_WRA: false + uc_limited_capability_for_work: true + is_uc_work_allowance_eligible: true + +- name: A 17-year-old ESA recipient without the support component meets the reduced UC minimum age + period: 2025 + input: + people: + claimant: + age: 17 + is_benunit_head: true + esa_contrib_reported: 3_000 + esa_includes_support_component: false + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # UC Regs 2013 reg 8(1)(a): limited capability for work. + uc_limited_capability_for_WRA: false + uc_limited_capability_for_work: true + meets_uc_minimum_age_condition: true + +- name: An income-related award entered for a single claimant gives them the ESA assessment + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + esa_income: 6_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + has_own_esa_award: true + esa_includes_support_component: true + uc_limited_capability_for_WRA: true + # 2025-26 LCWRA element: 423.27 a month x 12 + uc_LCWRA_element: 5_079.24 + +- name: An income-related award entered for a single claimant without the support component gives no LCWRA + period: 2025 + input: + people: + claimant: + age: 40 + esa_includes_support_component: false + is_disabled_for_benefits: true + benunits: + family: + members: [claimant] + esa_income: 6_000 + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + uc_limited_capability_for_WRA: false + is_benefit_cap_exempt_health_disability: false + # 9,000 + 6,000 - 14,753 + benefit_cap_reduction: 247 + - name: An income-related award entered for a couple is the award of the claimant who heads the family period: 2025 input: @@ -324,3 +490,769 @@ has_own_esa_award: [true, false] esa_includes_support_component: [true, false] is_benefit_cap_exempt_health_disability: true + +- name: Without ESA, LCWRA still follows the disability flag + period: 2025 + input: + people: + claimant: + age: 40 + is_disabled_for_benefits: true + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + uc_limited_capability_for_WRA: true + +# --- Claimant or partner only: HB reg 75F(1)(b)-(d), UC reg 83(1)(b)-(e) --- + +- name: Partner's industrial injuries benefit lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + iidb_reported: 3_000 + benunits: + family: + members: [claimant, partner] + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A young person's industrial injuries benefit does not lift the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + iidb_reported: 3_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + region: NORTH_EAST + output: + is_child_or_young_person_for_legacy_benefits: [false, true] + is_benefit_cap_exempt_health_disability: false + +- name: A non-dependent adult's industrial injuries benefit does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + iidb_reported: 3_000 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: Claimant's attendance allowance lifts the cap + period: 2025 + input: + people: + claimant: + age: 50 + attendance_allowance: 4_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's attendance allowance does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 50 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + attendance_allowance: 4_000 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: Claimant's armed forces compensation payment lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + afcs_reported: 6_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's armed forces compensation payment does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + afcs_reported: 6_000 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt_other: false + is_benefit_cap_exempt: false + +# --- Disability living allowance: HB reg 75F(1)(e), UC reg 83(1)(f) --- + +- name: A child's DLA lifts the cap + period: 2025 + input: + people: + parent: + age: 35 + is_parent: true + child: + age: 10 + dla: 4_000 + benunits: + family: + members: [parent, child] + households: + home: + members: [parent, child] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A young person's DLA lifts the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + dla: 4_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A 16-year-old's DLA lifts the cap whatever their education (UC reg 5(1)(a)) + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + sixteen: + age: 16 + current_education: NOT_IN_EDUCATION + dla: 4_000 + benunits: + family: + members: [parent, sixteen] + households: + home: + members: [parent, sixteen] + region: NORTH_EAST + output: + is_child_or_young_person_for_legacy_benefits: [false, false] + is_benefit_cap_exempt_health_disability: true + +- name: A 19-year-old outside education is not a young person, so their DLA does not lift the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + adult_child: + age: 19 + current_education: NOT_IN_EDUCATION + dla: 4_000 + benunits: + family: + members: [parent, adult_child] + households: + home: + members: [parent, adult_child] + region: NORTH_EAST + output: + is_claimant_or_partner: [true, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + is_benefit_cap_exempt_health_disability: false + +- name: A looked-after child's DLA does not lift the cap + period: 2025 + input: + people: + claimant: + age: 35 + child: + age: 10 + is_looked_after_by_local_authority: true + dla: 4_000 + benunits: + family: + members: [claimant, child] + households: + home: + members: [claimant, child] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: A non-dependent adult's DLA does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + dla: 4_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + benefit_cap: 14_753 + # 18,000 - 14,753 + benefit_cap_reduction: 3_247 + +# --- PIP, AFIP, Carer's Allowance, Carer Support Payment: claimant, partner +# or a young person (HB reg 75F(1)(ea), (h), (ha); UC reg 83(1)(g), (i), (ia)) --- + +- name: A young person's PIP lifts the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + pip_dl: 5_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: Claimant's PIP lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + pip_dl: 5_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's PIP does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + pip_dl: 5_000 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: Claimant's armed forces independence payment lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + armed_forces_independence_payment: 8_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's armed forces independence payment does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + armed_forces_independence_payment: 8_000 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: A young person's Carer's Allowance lifts the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + carers_allowance: 4_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A young person's Carer Support Payment lifts the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + carer_support_payment: 4_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + country: SCOTLAND + region: SCOTLAND + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's Carer's Allowance and caring do not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + carers_allowance: 4_000 + care_hours: 40 + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_carer_for_benefits: [false, true] + is_benefit_cap_exempt_health_disability: false + +- name: A young person's armed forces independence payment lifts the cap (HB reg 75F(1)(ea)) + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + armed_forces_independence_payment: 8_000 + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A 16-year-old out of education with armed forces independence payment does not lift the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + sixteen: + age: 16 + current_education: NOT_IN_EDUCATION + armed_forces_independence_payment: 8_000 + benunits: + family: + members: [parent, sixteen] + households: + home: + members: [parent, sixteen] + region: NORTH_EAST + output: + # Not a Housing Benefit young person (not in education), and Universal + # Credit counts the payment for a claimant only (UC Regs 2013 reg 2). + is_benefit_cap_exempt_health_disability: false + +- name: A 16-year-old receiving benefits in their own right is not a young person, so their DLA does not lift the cap + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + sixteen: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + dla: 4_000 + benunits: + family: + members: [parent, sixteen] + households: + home: + members: [parent, sixteen] + region: NORTH_EAST + output: + # UC Regs 2013 reg 5(5); HB Regs 2006 reg 19(2). + is_benefit_cap_exempt_health_disability: false + +# --- UC LCWRA and carer elements: UC reg 83(1)(a), (j); regs 27(1), 29(1) --- + +- name: Claimant's limited capability for work-related activity lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult's limited capability for work-related activity does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: A disabled child's limited capability does not lift the cap without DLA + period: 2025 + input: + people: + parent: + age: 35 + is_parent: true + child: + age: 10 + uc_limited_capability_for_WRA: true + benunits: + family: + members: [parent, child] + households: + home: + members: [parent, child] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + +- name: A claimant caring 40 hours a week lifts the cap through the UC carer element + period: 2025 + input: + people: + claimant: + age: 40 + care_hours: 40 + would_claim_carers_allowance: false + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + carers_allowance: 0 + is_carer_for_benefits: true + is_benefit_cap_exempt_health_disability: true + +- name: A non-dependent adult with every exempting benefit and circumstance does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_contrib_reported: 7_000 + esa_income_reported: 2_000 + esa_includes_support_component: true + iidb_reported: 3_000 + attendance_allowance: 4_000 + afcs_reported: 6_000 + dla: 4_000 + pip_dl: 5_000 + armed_forces_independence_payment: 8_000 + carers_allowance: 4_000 + carer_support_payment: 4_000 + care_hours: 40 + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt_other: false + is_benefit_cap_exempt: false + benefit_cap: 14_753 + +- name: A carer element entered for the award lifts the cap (UC reg 83(1)(j)) + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + uc_carer_element: 2_417.52 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: An LCWRA element entered for the award lifts the cap (UC reg 83(1)(a)) + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + uc_LCWRA_element: 5_079.24 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +# --- Working tax credit: HB reg 75E(2) --- + +- name: The family's working tax credit lifts the cap + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + working_tax_credit: 1_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_health_disability: true + +# --- The other two exception variables keep their results --- + +- name: Earnings of 20,000 a year meet the model's earnings exception + period: 2025 + input: + people: + claimant: + age: 40 + employment_income: 20_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # Income tax (20,000 - 12,570) x 20% = 1,486; NI (20,000 - 12,570) x 8% + # = 594.40; 20,000 - 1,486 - 594.40 = 17,919.60 >= 10,152. + is_benefit_cap_exempt_earnings: true + +- name: Earnings of 9,000 a year do not meet the model's earnings exception + period: 2025 + input: + people: + claimant: + age: 40 + employment_income: 9_000 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # Below the personal allowance and primary threshold: 9,000 < 10,152. + is_benefit_cap_exempt_earnings: false + +- name: A member over State Pension age keeps the age exception + period: 2025 + input: + people: + claimant: + age: 70 + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + is_benefit_cap_exempt_other: true + is_benefit_cap_exempt: true diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py new file mode 100644 index 0000000000..d875c7dfa8 --- /dev/null +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -0,0 +1,364 @@ +"""The benefit cap's specified-benefit exceptions follow the people the law names. + +HB Regs 2006 reg 75F(1) and UC Regs 2013 reg 83(1) lift the cap where: + +- the claimant or partner receives ESA that includes the support component + (75F(1)(a); 83(1)(a)), industrial injuries benefit, attendance allowance + or an armed forces compensation payment (75F(1)(b)-(d); 83(1)(b)-(e)); +- the claimant, partner or a child or young person they are responsible for + receives disability living allowance (75F(1)(e); 83(1)(f)); +- the claimant, partner or a young person they are responsible for receives + personal independence payment, or is entitled to Carer's Allowance or + Carer Support Payment (75F(1)(ea), (h), (ha); 83(1)(g), (i), (ia)), or, + for a Housing Benefit young person, armed forces independence payment + (75F(1)(ea); UC counts it as attendance allowance, UC Regs 2013 reg 2); +- a Universal Credit claimant has limited capability for work and + work-related activity or caring responsibilities, so the award includes the + LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)). A person with + ESA of their own has limited capability for work-related activity exactly + when their ESA includes the support component (reg 40(1)(a)(ii)); +- the claimant or couple is entitled to working tax credit (HB reg 75E(2)). + +The model applies one cap to both schemes, so either scheme's exception +counts. A child or young person is a child under 16, a young person in +non-advanced education, or a 16-year-old (UC reg 5(1)(a)) who does not +receive benefits in their own right (reg 5(5)) and is not looked after +(reg 4(6)). + +Invariants: + +1. Adding a member who is neither the claimant, the partner nor a child or + young person they are responsible for never changes whether the family is + exempt or its cap, whatever disability or carer benefits, ESA, disability + flag or caring that member has. (Their age and earnings can: the State + Pension age and earnings exceptions read every member. The draws keep the + member under State Pension age and without earnings; see #1944, #1907, + #1999 and #1820.) +2. The exemption equals a reference written directly from the regulations, + with roles fixed by construction (differential test). +3. An exempt family has no cap and no reduction; any other family has the + single cap if the claimant has no partner and no child or young person, + and the couple-and-family cap otherwise (UC reg 80A(2); HB reg 75CA). +4. Each head alone: for every role and every circumstance, a family whose + only circumstance is that one, held by that member, is exempt exactly when + the reference says (an exhaustive table, for each support-component mode). + +The properties draw families at random; invariant 4 enumerates them. Each +example builds many families in one simulation, in separate households +and benefit units. Roles are given explicitly (is_claimant_or_partner). The +support component is set for every person in a simulation or for none, since +an input set for some people is given its default for the rest. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 +SINGLE_CAP, FAMILY_CAP = 14_753, 22_020 # Outside London, 2025-26. + +CLAIMANT_OR_PARTNER = ["attendance_allowance", "iidb_reported", "afcs_reported"] +CHILD_OR_YOUNG_PERSON = ["dla"] +YOUNG_PERSON = ["pip_dl", "carers_allowance", "carer_support_payment"] +HOUSING_BENEFIT_YOUNG_PERSON = ["armed_forces_independence_payment"] +AMOUNTS = ( + CLAIMANT_OR_PARTNER + + CHILD_OR_YOUNG_PERSON + + YOUNG_PERSON + + HOUSING_BENEFIT_YOUNG_PERSON +) +CIRCUMSTANCES = AMOUNTS + ["esa_contrib", "esa_income", "caring", "disabled"] + +ROLES = { + "child": {"current_education": "PRIMARY"}, + "looked_after_child": { + "current_education": "PRIMARY", + "is_looked_after_by_local_authority": True, + }, + "young_person": {"current_education": "UPPER_SECONDARY"}, + "sixteen": {"age": 16, "current_education": "NOT_IN_EDUCATION"}, + "sixteen_own_right": { + "age": 16, + "current_education": "NOT_IN_EDUCATION", + "receives_benefits_in_own_right": True, + }, +} + + +def blank(role, age): + person = {name: 0 for name in AMOUNTS} + person.update( + { + "age": age, + "esa_contrib_reported": 0, + "esa_income_reported": 0, + "care_hours": 0, + "is_disabled_for_benefits": False, + "current_education": "NOT_IN_EDUCATION", + "_role": role, + } + ) + person.update(ROLES.get(role, {})) + return person + + +def give(person, circumstance): + set_circumstance(person, circumstance) + event(f"{person['_role']} {circumstance}") + + +def set_circumstance(person, circumstance): + if circumstance in AMOUNTS: + person[circumstance] = 3_000 + elif circumstance == "esa_contrib": + person["esa_contrib_reported"] = 5_000 + elif circumstance == "esa_income": + person["esa_income_reported"] = 4_000 + elif circumstance == "caring": + person["care_hours"] = 35 + else: + person["is_disabled_for_benefits"] = True + + +@st.composite +def families(draw, supplied): + """A claimant, an optional partner, up to three dependants and a + non-dependent adult, with at most two circumstances among them.""" + members = [blank("claimant", draw(st.integers(25, 60)))] + if draw(st.booleans()): + members.append(blank("claimant", draw(st.integers(25, 60)))) + for _ in range(draw(st.integers(0, 3))): + role = draw(st.sampled_from(sorted(ROLES))) + if role in ("child", "looked_after_child"): + age = draw(st.integers(0, 15)) + elif role == "young_person": + age = draw(st.integers(16, 18)) + else: + age = 16 + members.append(blank(role, age)) + has_dependants = any(m["_role"] != "claimant" for m in members) + for member in members: + if member["_role"] == "claimant": + member["is_parent"] = has_dependants + other = blank("other", draw(st.integers(17, 60))) + for _ in range(draw(st.integers(0, 2))): + give(draw(st.sampled_from(members)), draw(st.sampled_from(CIRCUMSTANCES))) + for _ in range(draw(st.integers(1, 2))): + give(other, draw(st.sampled_from(CIRCUMSTANCES))) + if supplied: + for person in members + [other]: + person["esa_includes_support_component"] = draw(st.booleans()) + working_tax_credit = draw(st.sampled_from([0] * 9 + [500])) + return members, other, working_tax_credit + + +@st.composite +def draws(draw): + """Families in one simulation, sharing whether the support component is + supplied.""" + supplied = draw(st.booleans()) + event(f"support component supplied: {supplied}") + return draw(st.lists(families(supplied), min_size=1, max_size=6)) + + +def situation(units, year=YEAR, extra_benunit=None): + people, benunits, households = {}, {}, {} + for i, (members, working_tax_credit) in enumerate(units): + names = [] + for j, inputs in enumerate(members): + name = f"p{i}_{j}" + people[name] = { + k: {year: v} for k, v in inputs.items() if not k.startswith("_") + } + people[name]["is_claimant_or_partner"] = { + year: inputs["_role"] == "claimant" + } + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "working_tax_credit": {year: working_tax_credit}, + **{k: {year: v} for k, v in (extra_benunit or {}).items()}, + } + households[f"h{i}"] = { + "members": names, + "region": {year: "NORTH_EAST"}, + "country": {year: "ENGLAND"}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def own_esa(m): + return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 + + +def reference_exempt(members, working_tax_credit): + """HB Regs 2006 regs 75E(2), 75F(1) and UC Regs 2013 reg 83(1), read + directly, with roles known by construction.""" + claimants = [m for m in members if m["_role"] == "claimant"] + young_persons = [m for m in members if m["_role"] in ("young_person", "sixteen")] + hb_young_persons = [m for m in members if m["_role"] == "young_person"] + children = [m for m in members if m["_role"] == "child"] + # No capital, so the couple's income-related award is their reports. + couple_award = any(m["esa_income_reported"] > 0 for m in claimants) + + def support_component(m): + # Supplied, or assumed for anyone with their own allowance. + return m.get("esa_includes_support_component", own_esa(m)) + + def receives_esa_with_support_component(m): + receiving = m["esa_contrib_reported"] > 0 or couple_award + return receiving and support_component(m) + + def lcwra(m): + # UC Regs 2013 reg 40(1)(a)(ii): the ESA assessment decides. + return support_component(m) if own_esa(m) else m["is_disabled_for_benefits"] + + def carer(m): + return ( + m["care_hours"] >= 35 + or m["carers_allowance"] > 0 + or m["carer_support_payment"] > 0 + ) + + def any_receives(people, benefits): + return any(m[b] > 0 for m in people for b in benefits) + + return ( + any(receives_esa_with_support_component(m) for m in claimants) + or any_receives(claimants, CLAIMANT_OR_PARTNER) + or any_receives(claimants + children + young_persons, CHILD_OR_YOUNG_PERSON) + or any_receives(claimants + young_persons, YOUNG_PERSON) + or any_receives(claimants + hb_young_persons, HOUSING_BENEFIT_YOUNG_PERSON) + or any(lcwra(m) for m in claimants) + or any(carer(m) for m in claimants) + or working_tax_credit > 0 + ) + + +def reference_cap(members): + claimants = sum(m["_role"] == "claimant" for m in members) + dependants = any( + m["_role"] in ("child", "young_person", "sixteen") for m in members + ) + return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP + + +SETTINGS = settings( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(draws()) +def test_non_dependent_adults_never_change_the_exemption(drawn): + without = [(members, wtc) for members, _, wtc in drawn] + with_other = [(members + [other], wtc) for members, other, wtc in drawn] + sim = Simulation(situation=situation(without + with_other)) + k = len(drawn) + claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) + legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) + uc = sim.calculate("is_child_or_qualifying_young_person_for_universal_credit", YEAR) + sizes = [len(members) for members, _ in without + with_other] + starts = np.cumsum([0] + sizes[:-1]) + for i in range(k): + # The added member is the last of its benefit unit: neither claimant, + # partner nor anyone's dependant. + last = starts[k + i] + sizes[k + i] - 1 + assert not (claimant_or_partner[last] or legacy[last] or uc[last]), drawn[i] + for variable in [ + "is_benefit_cap_exempt_health_disability", + "is_benefit_cap_exempt_other", + "is_benefit_cap_exempt", + "benefit_cap", + ]: + values = sim.calculate(variable, YEAR) + for i in range(k): + assert values[i] == values[k + i], (variable, drawn[i]) + + +@SETTINGS +@given(draws()) +def test_exemption_matches_regulations(drawn): + units = [(members + [other], wtc) for members, other, wtc in drawn] + sim = Simulation(situation=situation(units)) + exempt = sim.calculate("is_benefit_cap_exempt_health_disability", YEAR) + for i, (members, wtc) in enumerate(units): + expected = reference_exempt(members, wtc) + event(f"reference exempt: {expected}") + assert exempt[i] == expected, units[i] + + +@SETTINGS +@given(draws()) +def test_exempt_families_have_no_cap_and_others_the_statutory_cap(drawn): + units = [(members + [other], wtc) for members, other, wtc in drawn] + # 30,000 of Universal Credit alone exceeds either cap. The other capped + # benefits are set to nil; they only add to the excess. + sim = Simulation( + situation=situation( + units, + extra_benunit={ + "universal_credit_pre_benefit_cap": 30_000, + "housing_benefit_pre_benefit_cap": 0, + "child_tax_credit": 0, + "child_benefit": 0, + "income_support": 0, + }, + ) + ) + exempt = sim.calculate("is_benefit_cap_exempt", YEAR) + cap = sim.calculate("benefit_cap", YEAR) + reduction = sim.calculate("benefit_cap_reduction", YEAR) + for i, (members, _) in enumerate(units): + if exempt[i]: + assert np.isinf(cap[i]) and reduction[i] == 0, units[i] + else: + assert cap[i] == reference_cap(members), units[i] + assert reduction[i] >= 30_000 - cap[i], units[i] + + +SINGLE_HEAD_ROLES = ["claimant", "partner", *sorted(ROLES), "other"] + + +def single_head_units(mode): + """One family per (role, circumstance): a claimant and, unless the role is + the claimant, one more member holding the circumstance.""" + units = [] + for role in SINGLE_HEAD_ROLES: + for circumstance in CIRCUMSTANCES: + claimant = blank("claimant", 40) + if role == "claimant": + holder, members = claimant, [claimant] + else: + age = {"partner": 38, "other": 30, "young_person": 17}.get(role, 10) + holder = blank("claimant" if role == "partner" else role, age) + members = [claimant, holder] + set_circumstance(holder, circumstance) + claimant["is_parent"] = role not in ("claimant", "partner", "other") + if mode is not None: + for person in members: + person["esa_includes_support_component"] = mode + units.append(((role, circumstance), members)) + return units + + +@pytest.mark.parametrize("mode", [None, True, False]) +def test_each_head_alone_matches_regulations(mode): + units = single_head_units(mode) + sim = Simulation(situation=situation([(members, 0) for _, members in units])) + exempt = sim.calculate("is_benefit_cap_exempt_health_disability", YEAR) + mismatches = [ + (key, bool(exempt[i])) + for i, (key, members) in enumerate(units) + if exempt[i] != reference_exempt(members, 0) + ] + assert not mismatches, mismatches + # Every role meets every circumstance, and both outcomes occur. + assert len(units) == len(SINGLE_HEAD_ROLES) * len(CIRCUMSTANCES) + assert exempt.any() and not exempt.all() diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py index 563b340854..a0303a5257 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py @@ -4,7 +4,7 @@ class is_benefit_cap_exempt(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap because of health or disability" + label = "Exempt from the benefit cap" definition_period = YEAR reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index c27e13c39d..e4bc1ffccc 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -4,23 +4,11 @@ class is_benefit_cap_exempt_earnings(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap for non-health/disability reasons" + label = "Exempt from the benefit cap because of earnings" definition_period = YEAR reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" def formula(benunit, period, parameters): - # Check if anyone in benefit unit is over state pension age - person = benunit.members - over_pension_age = person("is_SP_age", period) - has_pensioner = benunit.any(over_pension_age) - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - # Earnings exemption for UC (£846/month = £10,152/year) # Note: Only check earned income, not UC amount itself to avoid circular dependency uc_earned = benunit.sum( @@ -30,31 +18,4 @@ def formula(benunit, period, parameters): - benunit.members("national_insurance", period) ) earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # ESA and Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 - - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 - - return meets_earnings_test + return uc_earned >= earnings_threshold diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 636216094f..361149edc2 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -1,65 +1,123 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.inputs import entered_directly + +# HB Regs 2006 reg 75F(1) and UC Regs 2013 reg 83(1) name, for each benefit, +# whose receipt (or entitlement) lifts the cap. + +# "the claimant or the claimant's partner is receiving" (HB reg 75F(1)(b)-(d)); +# "a claimant is receiving" (UC reg 83(1)(b)-(e)). Armed Forces Compensation +# Scheme payments are the guaranteed income payments that count as a war +# pension (HB reg 75F(2)(a); UC reg 83(1)(e)). +CLAIMANT_OR_PARTNER_BENEFITS = [ + "attendance_allowance", + "iidb", + "afcs", +] + +# "the claimant, the claimant's partner or a child or young person for whom +# the claimant or the claimant's partner is responsible" (HB reg 75F(1)(e)); +# "a claimant, or a child or qualifying young person for whom a claimant is +# responsible" (UC reg 83(1)(f)). +CHILD_OR_YOUNG_PERSON_BENEFITS = [ + "dla", +] + +# "the claimant, the claimant's partner or a young person for whom the +# claimant or the claimant's partner is responsible" (HB reg 75F(1)(ea), (h), +# (ha)); "a claimant, or a qualifying young person for whom a claimant is +# responsible" (UC reg 83(1)(g), (i), (ia)). +YOUNG_PERSON_BENEFITS = [ + "pip", + "carers_allowance", + "carer_support_payment", +] + +# Armed forces independence payment: Housing Benefit lists it with personal +# independence payment, for a young person too (HB reg 75F(1)(ea)); Universal +# Credit counts it as attendance allowance, a claimant's only (UC Regs 2013 +# reg 2, reg 83(1)(c)). Either scheme: the claimant, partner or a Housing +# Benefit young person. +HOUSING_BENEFIT_YOUNG_PERSON_BENEFITS = [ + "armed_forces_independence_payment", +] class is_benefit_cap_exempt_health_disability(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap because of health or disability" + label = "Exempt from the benefit cap through a specified benefit, UC element or working tax credit" + documentation = ( + "Whether the family is outside the benefit cap because the claimant, " + "their partner or, for some benefits, a child or young person they " + "are responsible for receives or is entitled to a benefit listed in " + "HB Regs 2006 reg 75F(1) or UC Regs 2013 reg 83(1), or because the " + "claimant or couple is entitled to working tax credit (HB Regs 2006 " + "reg 75E(2)). Another member of the benefit unit, such as a " + "non-dependent adult, does not lift the cap with their own benefit. " + "The model applies one cap to Universal Credit and Housing Benefit, so " + "an exception in either scheme counts: Universal Credit's LCWRA and " + "carer elements, and Housing Benefit's wider young-person tests." + ) definition_period = YEAR - reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/75F", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/75E", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/83", + "https://www.gov.uk/benefit-cap/when-youre-not-affected", + ) def formula(benunit, period, parameters): - # Check if anyone in benefit unit is over state pension age person = benunit.members - over_pension_age = person("is_SP_age", period) - has_pensioner = benunit.any(over_pension_age) - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 + # The claimant and partner (HB Regs 2006 reg 2(1)); each of joint + # claimants (Welfare Reform Act 2012 s.40). + claimant = person("is_claimant_or_partner", period) - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) + # A child or young person the claimant or partner is responsible for. + # Housing Benefit counts a child under 16 or a Child Benefit + # qualifying young person (HB Regs 2006 regs 2(1), 19); Universal + # Credit a child or qualifying young person (UC Regs 2013 regs 4-5), + # which includes every 16-year-old until the 1 September after their + # birthday (reg 5(1)(a)), unless they receive Universal Credit, ESA + # or JSA themselves (reg 5(5)). Annual ages cannot place that date, so + # any such 16-year-old counts, as for the family rate in + # is_responsible_for_child_or_young_person_for_uc_or_housing_benefit + # (which does not yet apply reg 5(5)). + age = person("age", period) + legacy_child_or_young_person = person( + "is_child_or_young_person_for_legacy_benefits", period + ) + housing_benefit_young_person = legacy_child_or_young_person & person( + "is_qualifying_young_person_for_child_benefit", period + ) + uc_child_or_young_person = ~claimant & person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) + uc_sixteen = ( + (age >= 16) + & (age < 17) + & ~claimant + & ~person("is_looked_after_by_local_authority", period) + & ~person("receives_benefits_in_own_right", period) + ) + child_or_young_person = ( + legacy_child_or_young_person | uc_child_or_young_person | uc_sixteen + ) + young_person = ( + housing_benefit_young_person + | ( + uc_child_or_young_person + & person("is_qualifying_young_person_for_universal_credit", period) + ) + | uc_sixteen ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "carer_support_payment", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 + def received_by(benefits, members): + return add_for_members(benunit, period, benefits, members) > 0 - # HB Regs 2006 reg 75F(1)(a), UC Regs 2013 reg 83(1)(a): "the claimant - # or the claimant's partner is receiving an employment and support - # allowance ... which includes a support component". Contributory and - # income-related allowances both carry it; another member's allowance - # does not count. - claimant = person("is_claimant_or_partner", period) + # HB reg 75F(1)(a), UC reg 83(1)(a): "the claimant or the claimant's + # partner is receiving an employment and support allowance ... which + # includes a support component". Contributory and income-related + # allowances both carry it; another member's allowance does not count. receiving_esa = (person("esa_contrib", period) > 0) | person( "is_on_income_related_esa", period ) @@ -67,11 +125,42 @@ def formula(benunit, period, parameters): claimant & receiving_esa & person("esa_includes_support_component", period) ) + # UC reg 83(1)(a), (j): the LCWRA or carer element is included in the + # award. For calculated elements that is a claimant who has limited + # capability for work and work-related activity or caring + # responsibilities (UC Regs 2013 regs 27(1), 29(1)); the model does + # not have the reg 28 waiting period or regs 29(5)-(6), 30(3). Where + # reg 29(4) leaves a carer with limited capability only the LCWRA + # element, that element exempts. An element entered directly counts + # as entered. + def element_entered(element): + if not entered_directly(benunit, element, period): + return False + return benunit(element, period) > 0 + + lcwra_element = benunit.any( + claimant & person("uc_limited_capability_for_WRA", period) + ) | element_entered("uc_LCWRA_element") + carer_element = benunit.any( + claimant & person("is_carer_for_benefits", period) + ) | element_entered("uc_carer_element") + + # HB reg 75E(2): the claimant is, or the couple are jointly, entitled + # to working tax credit. + working_tax_credit = benunit("working_tax_credit", period) > 0 + return ( - has_lcwra - | gets_uc_carer_element - | (qualifying_personal_benefits > 0) - | (qualifying_benunit_benefits > 0) - | afcs - | esa_support_component + esa_support_component + | received_by(CLAIMANT_OR_PARTNER_BENEFITS, claimant) + | received_by( + CHILD_OR_YOUNG_PERSON_BENEFITS, claimant | child_or_young_person + ) + | received_by(YOUNG_PERSON_BENEFITS, claimant | young_person) + | received_by( + HOUSING_BENEFIT_YOUNG_PERSON_BENEFITS, + claimant | housing_benefit_young_person, + ) + | lcwra_element + | carer_element + | working_tax_credit ) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py index 4d5728fbfe..396e31a25f 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py @@ -4,57 +4,15 @@ class is_benefit_cap_exempt_other(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap for non-health/disability reasons" + label = "Exempt from the benefit cap because of age" + documentation = ( + "Whether anyone in the benefit unit has reached State Pension age. " + "The armed forces compensation and support-component ESA exceptions " + "are in is_benefit_cap_exempt_health_disability, which limits them " + "to the claimant and partner." + ) definition_period = YEAR reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" def formula(benunit, period, parameters): - # Check if anyone in benefit unit is over state pension age - person = benunit.members - over_pension_age = person("is_SP_age", period) - has_pensioner = benunit.any(over_pension_age) - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) - ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # ESA and Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 - - # The ESA support component exception is in - # is_benefit_cap_exempt_health_disability, limited to the claimant and - # partner. - return has_pensioner | afcs + return benunit.any(benunit.members("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py index 964b1780aa..99e3a62b9e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py @@ -5,8 +5,29 @@ class uc_limited_capability_for_WRA(Variable): value_type = bool entity = Person label = "Assessed to have limited capability for work-related activity" - documentation = "Whether this person has been assessed by the DWP as having limited capability for work or work-related activity" + documentation = ( + "Whether this person has been assessed by the DWP as having limited " + "capability for work and work-related activity. Universal Credit takes " + "an ESA determination of limited capability for work-related activity " + "(UC Regs 2013 reg 40(1)(a)(ii); UC (Transitional Provisions) Regs " + "2014 reg 19(4)), and that determination is what gives an ESA award " + "its support component. So for a person with an ESA award of their " + "own (has_own_esa_award) the model infers it from " + "esa_includes_support_component: with the component they have it; " + "without it they are taken not to, though someone still in the ESA " + "assessment phase may yet be found to have it. For anyone else it " + "defaults to is_disabled_for_benefits, which datasets derive from " + "disability benefit receipt. Users can set it directly." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/40", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/19", + ) def formula(person, period, parameters): - return person("is_disabled_for_benefits", period) + return where( + person("has_own_esa_award", period), + person("esa_includes_support_component", period), + person("is_disabled_for_benefits", period), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py new file mode 100644 index 0000000000..19f66bf941 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class uc_limited_capability_for_work(Variable): + value_type = bool + entity = Person + label = "Assessed to have limited capability for work" + documentation = ( + "Whether this person has limited capability for work (UC Regs 2013 " + "reg 39), which the work allowance (reg 22) and the reduced minimum " + "age (reg 8(1)(a)) need. Limited capability for work and work-related " + "activity includes it (uc_limited_capability_for_WRA). So does an ESA " + "award of the person's own (has_own_esa_award), however it is made " + "up: Universal Credit takes an ESA determination of limited capability " + "for work (reg 39(1)(a)). Someone still in the ESA assessment phase " + "has not yet been assessed, but the model counts them too." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/39", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/8", + ) + + def formula(person, period, parameters): + return person("uc_limited_capability_for_WRA", period) | person( + "has_own_esa_award", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py index cd1ad10c7f..db48b6960a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py @@ -15,8 +15,9 @@ class meets_uc_minimum_age_condition(Variable): in specified circumstances. Observable proxies cover limited capability for work, caring, and responsibility for a child. - uc_limited_capability_for_WRA is the existing LCWRA-named proxy (it defaults - to is_disabled_for_benefits), not an observation of LCW or a pending + uc_limited_capability_for_work is a proxy (limited capability for work and + work-related activity, which defaults to is_disabled_for_benefits, or an + ESA award of the person's own), not an observation of LCW or a pending assessment supported by medical evidence under regulation 8(1)(a)-(b). is_carer_for_benefits means receipt of Carer's Allowance or Scottish Carer Support Payment, or at least the Carer's Allowance qualifying hours of @@ -41,7 +42,7 @@ def formula(person, period, parameters): child = person("is_child_for_universal_credit", period) responsible_for_child = claimant & person.benunit.any(child & ~claimant) reduced_age_exception = ( - person("uc_limited_capability_for_WRA", period) + person("uc_limited_capability_for_work", period) | person("is_carer_for_benefits", period) | responsible_for_child ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py index 46dbed7c6d..f977ba70d8 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/work_allowance/is_uc_work_allowance_eligible.py @@ -10,9 +10,11 @@ class is_uc_work_allowance_eligible(Variable): def formula(benunit, period, parameters): person = benunit.members - has_LCWRA = benunit.any(person("uc_limited_capability_for_WRA", period)) + # UC Regs 2013 reg 22(1)(b): responsibility for a child or qualifying + # young person, or limited capability for work. + has_LCW = benunit.any(person("uc_limited_capability_for_work", period)) has_children = benunit( "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", period, ) - return has_LCWRA | has_children + return has_LCW | has_children From c6cf6478a6049af804487cb5badd8ed752bf3dc5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 06:07:00 -0400 Subject: [PATCH 03/12] Infer LCWRA from ESA only where the support component is missing Inferring limited capability for work-related activity from the ESA support component in both directions gave a non-dependant's own ESA an LCWRA element in the family's Universal Credit, because uc_LCWRA_element and the work allowance still read every member of the benefit unit (#2027's legacy-award invariance test caught it). Now a person whose own ESA lacks the support component is taken not to have LCWRA (UC Regs 2013 reg 40(1)(a)(ii)), and otherwise uc_limited_capability_for_WRA keeps its is_disabled_for_benefits default. uc_limited_capability_for_work is LCWRA or the disability flag, so the work allowance and reduced minimum age are as before for every input that does not set the new variables. The cap property's family-rate reference now counts any 16-year-old, as the family-rate helper does. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.added.md | 2 +- .../benefit-cap-75f-claimant-partner.fixed.md | 4 +- .../benefit_cap_specified_benefits.yaml | 40 ++++++++++++++----- ...nefit_cap_specified_benefits_properties.py | 15 ++++--- .../uc_limited_capability_for_WRA.py | 33 ++++++++------- .../uc_limited_capability_for_work.py | 15 ++++--- .../meets_uc_minimum_age_condition.py | 6 +-- 7 files changed, 73 insertions(+), 42 deletions(-) diff --git a/changelog.d/benefit-cap-75f-claimant-partner.added.md b/changelog.d/benefit-cap-75f-claimant-partner.added.md index 3352ec6103..bab0fbc012 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.added.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.added.md @@ -1,3 +1,3 @@ - Add `esa_includes_support_component`: whether a person's employment and support allowance includes the support component. Datasets and users can supply it; otherwise it is assumed for anyone with an ESA award of their own. Household calculations with ESA outside the support group should set it to false. - Add `has_own_esa_award`: whether a person claims ESA themselves (a reported award, or a directly entered income-related award attributed to the claimant who heads the benefit unit). -- Add `uc_limited_capability_for_work`. +- Add `uc_limited_capability_for_work`: limited capability for work, for the Universal Credit work allowance and reduced minimum age. diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md index eeccf04837..38ec762c0c 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -1,5 +1,5 @@ - Lift the benefit cap for employment and support allowance only where the claimant's or partner's allowance includes the support component (HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg 83(1)(a)), for contributory and income-related ESA alike. Any income-related ESA, and any member's contributory ESA, used to lift it. Without other information the support component is assumed for a person with their own allowance, as for about nine in ten ESA recipients in the Family Resources Survey. - Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap; their age and earnings still do, through the age and earnings exceptions. - Count armed forces independence payment towards the benefit cap exception. -- Infer limited capability for work-related activity (`uc_limited_capability_for_WRA`) for a person with their own ESA from whether it includes the support component, since Universal Credit takes the ESA assessment (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. -- Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity, or an ESA award of the person's own. +- Take a person whose own ESA lacks the support component not to have limited capability for work-related activity (`uc_limited_capability_for_WRA`), whatever their disability flag, since that determination is what gives ESA the support component and Universal Credit takes it (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. +- Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 6fce025ccf..0af245a6d3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -351,7 +351,7 @@ # 18,000 + 7,000 - 14,753 benefit_cap_reduction: 10_247 -- name: An ESA recipient with the support component has LCWRA without a disability benefit +- name: An ESA recipient with the support component and the disability flag has LCWRA period: 2025 input: people: @@ -359,7 +359,7 @@ age: 40 esa_contrib_reported: 7_000 esa_includes_support_component: true - is_disabled_for_benefits: false + is_disabled_for_benefits: true benunits: family: members: [claimant] @@ -372,7 +372,27 @@ # 2025-26 LCWRA element: 423.27 a month x 12 uc_LCWRA_element: 5_079.24 -- name: An ESA recipient without the support component still has limited capability for work +- name: The support component alone does not give LCWRA (it is only ever inferred away) + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: true + is_disabled_for_benefits: false + benunits: + family: + members: [claimant] + households: + home: + members: [claimant] + region: NORTH_EAST + output: + uc_limited_capability_for_WRA: false + # The ESA head still lifts the cap. + is_benefit_cap_exempt_health_disability: true +- name: An ESA recipient without the support component, flagged disabled, keeps limited capability for work period: 2025 input: people: @@ -389,14 +409,14 @@ members: [claimant] region: NORTH_EAST output: - # UC Regs 2013 reg 39(1)(a): Universal Credit takes the ESA determination - # of limited capability for work, which keeps the work allowance - # (reg 22(1)(b)). + # The ESA award removes limited capability for work-related activity + # (no support component) but not for work, which keeps the work allowance + # (UC Regs 2013 regs 22(1)(b), 39(1)(a)). uc_limited_capability_for_WRA: false uc_limited_capability_for_work: true is_uc_work_allowance_eligible: true -- name: A 17-year-old ESA recipient without the support component meets the reduced UC minimum age +- name: A 17-year-old ESA recipient without the support component, flagged disabled, meets the reduced UC minimum age period: 2025 input: people: @@ -405,6 +425,7 @@ is_benunit_head: true esa_contrib_reported: 3_000 esa_includes_support_component: false + is_disabled_for_benefits: true benunits: family: members: [claimant] @@ -417,13 +438,13 @@ uc_limited_capability_for_WRA: false uc_limited_capability_for_work: true meets_uc_minimum_age_condition: true - -- name: An income-related award entered for a single claimant gives them the ESA assessment +- name: An income-related award entered for a single claimant is theirs, with the support component period: 2025 input: people: claimant: age: 40 + is_disabled_for_benefits: true benunits: family: members: [claimant] @@ -438,7 +459,6 @@ uc_limited_capability_for_WRA: true # 2025-26 LCWRA element: 423.27 a month x 12 uc_LCWRA_element: 5_079.24 - - name: An income-related award entered for a single claimant without the support component gives no LCWRA period: 2025 input: 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 d875c7dfa8..9452b3c75f 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -15,8 +15,8 @@ - a Universal Credit claimant has limited capability for work and work-related activity or caring responsibilities, so the award includes the LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)). A person with - ESA of their own has limited capability for work-related activity exactly - when their ESA includes the support component (reg 40(1)(a)(ii)); + ESA of their own whose ESA lacks the support component is inferred not to + have limited capability for work-related activity (reg 40(1)(a)(ii)); - the claimant or couple is entitled to working tax credit (HB reg 75E(2)). The model applies one cap to both schemes, so either scheme's exception @@ -213,8 +213,10 @@ def receives_esa_with_support_component(m): return receiving and support_component(m) def lcwra(m): - # UC Regs 2013 reg 40(1)(a)(ii): the ESA assessment decides. - return support_component(m) if own_esa(m) else m["is_disabled_for_benefits"] + # UC Regs 2013 reg 40(1)(a)(ii): an ESA award without the support + # component means the ESA assessment found no LCWRA. + no_support = own_esa(m) and not support_component(m) + return m["is_disabled_for_benefits"] and not no_support def carer(m): return ( @@ -239,9 +241,12 @@ def any_receives(people, benefits): def reference_cap(members): + # The family-rate helper counts any 16-year-old (UC reg 5(1)(a)); it does + # not yet apply reg 5(5) to one receiving benefits in their own right. claimants = sum(m["_role"] == "claimant" for m in members) dependants = any( - m["_role"] in ("child", "young_person", "sixteen") for m in members + m["_role"] in ("child", "young_person", "sixteen", "sixteen_own_right") + for m in members ) return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py index 99e3a62b9e..8016ba9a63 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py @@ -7,17 +7,19 @@ class uc_limited_capability_for_WRA(Variable): label = "Assessed to have limited capability for work-related activity" documentation = ( "Whether this person has been assessed by the DWP as having limited " - "capability for work and work-related activity. Universal Credit takes " - "an ESA determination of limited capability for work-related activity " - "(UC Regs 2013 reg 40(1)(a)(ii); UC (Transitional Provisions) Regs " - "2014 reg 19(4)), and that determination is what gives an ESA award " - "its support component. So for a person with an ESA award of their " - "own (has_own_esa_award) the model infers it from " - "esa_includes_support_component: with the component they have it; " - "without it they are taken not to, though someone still in the ESA " - "assessment phase may yet be found to have it. For anyone else it " - "defaults to is_disabled_for_benefits, which datasets derive from " - "disability benefit receipt. Users can set it directly." + "capability for work and work-related activity. It defaults to " + "is_disabled_for_benefits, which datasets derive from disability " + "benefit receipt, ESA included. A determination of limited capability " + "for work-related activity is what gives ESA its support component, " + "and Universal Credit takes the ESA determination (UC Regs 2013 reg " + "40(1)(a)(ii)). So a person whose own ESA award (has_own_esa_award) " + "does not include the support component (esa_includes_support_" + "component) is taken not to have it, whatever the flag, though someone " + "still in the ESA assessment phase may yet be found to. The model does " + "not infer it the other way, from the support component alone: the " + "Universal Credit LCWRA element and work allowance still read every " + "member of the benefit unit, not only the claimants. Users can set it " + "directly." ) definition_period = YEAR reference = ( @@ -26,8 +28,9 @@ class uc_limited_capability_for_WRA(Variable): ) def formula(person, period, parameters): - return where( - person("has_own_esa_award", period), - person("esa_includes_support_component", period), - person("is_disabled_for_benefits", period), + esa_without_support_component = person("has_own_esa_award", period) & ~person( + "esa_includes_support_component", period + ) + return ( + person("is_disabled_for_benefits", period) & ~esa_without_support_component ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py index 19f66bf941..f0082e6f46 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py @@ -9,11 +9,14 @@ class uc_limited_capability_for_work(Variable): "Whether this person has limited capability for work (UC Regs 2013 " "reg 39), which the work allowance (reg 22) and the reduced minimum " "age (reg 8(1)(a)) need. Limited capability for work and work-related " - "activity includes it (uc_limited_capability_for_WRA). So does an ESA " - "award of the person's own (has_own_esa_award), however it is made " - "up: Universal Credit takes an ESA determination of limited capability " - "for work (reg 39(1)(a)). Someone still in the ESA assessment phase " - "has not yet been assessed, but the model counts them too." + "activity (uc_limited_capability_for_WRA) includes it, and otherwise " + "it defaults to is_disabled_for_benefits. An ESA award without the " + "support component therefore removes limited capability for " + "work-related activity but not for work. Universal Credit also takes " + "an ESA determination of limited capability for work (reg 39(1)(a)), " + "so any ESA award of the person's own would imply it; the model does " + "not infer that yet, because the work allowance still reads every " + "member of the benefit unit, not only the claimants." ) definition_period = YEAR reference = ( @@ -24,5 +27,5 @@ class uc_limited_capability_for_work(Variable): def formula(person, period, parameters): return person("uc_limited_capability_for_WRA", period) | person( - "has_own_esa_award", period + "is_disabled_for_benefits", period ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py index db48b6960a..9bf3020124 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py @@ -16,9 +16,9 @@ class meets_uc_minimum_age_condition(Variable): for work, caring, and responsibility for a child. uc_limited_capability_for_work is a proxy (limited capability for work and - work-related activity, which defaults to is_disabled_for_benefits, or an - ESA award of the person's own), not an observation of LCW or a pending - assessment supported by medical evidence under regulation 8(1)(a)-(b). + work-related activity, or is_disabled_for_benefits), not an observation of + LCW or a pending assessment supported by medical evidence under regulation + 8(1)(a)-(b). is_carer_for_benefits means receipt of Carer's Allowance or Scottish Carer Support Payment, or at least the Carer's Allowance qualifying hours of care (currently 35 weekly). Its hours limb does not verify the recipient's From b31895064bd78dd13e91fdd94f8068c5fe4bbcbd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:08:45 -0400 Subject: [PATCH 04/12] Find a directly entered ESA award without the capital test or the input record The Universal Credit reform hook calculates uc_LCWRA_element while the simulation is being built, and uc_limited_capability_for_WRA now reads has_own_esa_award. That went through claimant_or_partner_esa_income and so entered_directly, which read Simulation._user_input_keys before any input had created it (test_child_and_adult_definitions_properties builds its situations without inputs). has_own_esa_award now takes an income-related award with no member's report behind it as entered for the family, and entered_directly treats a simulation with no recorded inputs as having none. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/inputs.py | 3 ++- .../variables/gov/dwp/has_own_esa_award.py | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py index 828ce93aaa..e9e7229ece 100644 --- a/policyengine_uk/utils/inputs.py +++ b/policyengine_uk/utils/inputs.py @@ -22,7 +22,8 @@ def entered_directly(population, variable, period): """ simulation = population.simulation period = periods.period(period) - keys = simulation._user_input_keys + # A simulation that no input has been set on yet has no record. + keys = getattr(simulation, "_user_input_keys", ()) stored = set(simulation.get_holder(variable).get_known_branch_periods()) for branch in simulation._get_visible_branch_names(): if (branch, period) in stored: diff --git a/policyengine_uk/variables/gov/dwp/has_own_esa_award.py b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py index 4115d97b11..f474fe3561 100644 --- a/policyengine_uk/variables/gov/dwp/has_own_esa_award.py +++ b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py @@ -25,12 +25,13 @@ def formula(person, period, parameters): income_related = person("esa_income_reported", period) own_report = (person("esa_contrib", period) > 0) | (income_related > 0) claimant = person("is_claimant_or_partner", period) - reported_by_couple = person.benunit.sum(income_related * claimant) > 0 - entered_for_couple = ( - person.benunit("claimant_or_partner_esa_income", period) > 0 - ) & ~reported_by_couple + # An income-related award with no member's report behind it was + # entered for the family (esa_income). + entered_for_family = (person.benunit("esa_income", period) > 0) & ( + person.benunit.sum(income_related) == 0 + ) head_first = person("is_benunit_head", period) * 1_000 + person("age", period) attributed = claimant & ( person.get_rank(person.benunit, -head_first, condition=claimant) == 0 ) - return own_report | (entered_for_couple & attributed) + return own_report | (entered_for_family & attributed) From 8303062114880996ac1bb997a88febcd7f002370 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:48:16 -0400 Subject: [PATCH 05/12] Count only the claimant's or couple's benefits in the benefit cap UC Regs 2013 reg 80(1) caps "the amount to which the single person or couple is entitled", and HB Regs 2006 reg 75A the welfare benefits to which the claimant or couple is entitled. benefit_cap_reduction summed every benefit-unit member's contributory ESA and JSA, incapacity benefit and SDA, and the whole unit's income-related ESA and income-based JSA, so another member's own award reduced the family's Universal Credit or Housing Benefit: with UC of 20,000 before the cap, a non-dependent adult's 5,000 of ESA cut the family's UC from 14,753 to 9,753 (#2027 review r2). The capped total now takes the claimant's and partner's person-level awards, claimant_or_partner_esa_income, claimant_or_partner_jsa_income, and the family's Child Benefit, CTC, Income Support, UC and Housing Benefit. Household income keeps the full awards. YAML tests check UC and Housing Benefit amounts; the non-dependant invariance property now also covers benefit_cap_reduction and universal_credit, with JSA, incapacity benefit and SDA among the circumstances. #2027's legacy-award invariance reads the benefit cap exemption again. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.fixed.md | 1 + .../benefit_cap/benefit_cap_capped_total.yaml | 144 ++++++++++++++++++ .../benefit_cap_specified_benefits.yaml | 4 + ...nefit_cap_specified_benefits_properties.py | 60 +++++--- .../test_legacy_award_readers_properties.py | 3 + .../gov/dwp/benefit_cap_reduction.py | 57 +++++-- 6 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md index 38ec762c0c..8da6e4c661 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -3,3 +3,4 @@ - Count armed forces independence payment towards the benefit cap exception. - Take a person whose own ESA lacks the support component not to have limited capability for work-related activity (`uc_limited_capability_for_WRA`), whatever their disability flag, since that determination is what gives ESA the support component and Universal Credit takes it (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. - Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. +- Count in the benefit cap only the welfare benefits the claimant or couple is entitled to (UC Regs 2013 reg 80(1); HB Regs 2006 reg 75A): the claimant's and partner's contributory ESA, JSA, incapacity benefit and SDA, their income-related ESA and income-based JSA (`claimant_or_partner_esa_income`, `claimant_or_partner_jsa_income`), and the family's Child Benefit, CTC, Income Support, Universal Credit and Housing Benefit. Another member's own award no longer reduces the family's Universal Credit or Housing Benefit; it still counts in household income. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml new file mode 100644 index 0000000000..1691897269 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -0,0 +1,144 @@ +# The benefits the cap counts. +# +# UC Regs 2013 reg 80(1): "the amount of a welfare benefit to be used when +# determining total entitlement to welfare benefits is the amount to which the +# single person or couple is entitled". HB Regs 2006 reg 75A: "the total amount +# of welfare benefits to which— (a) where the claimant is a member of a couple, +# each member of the couple is or jointly are entitled; or (b) in any other +# case, the claimant is entitled". Welfare benefits: Welfare Reform Act 2012 +# s.96(10). +# +# 2025-26 single cap outside London: 14,753 (UC reg 80A(2)). A non-dependent +# adult (30, not in education, neither claimant nor partner) claims their own +# benefits, which the family's cap does not count. Families live in the North +# East; benefit amounts are set directly. + +- name: A non-dependent adult's income-related ESA is not in the family's capped total + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + esa_includes_support_component: false + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 20_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + is_benefit_cap_exempt: false + # 20,000 - 14,753 (the adult's 5,000 is theirs, not the family's) + benefit_cap_reduction: 5_247 + universal_credit: 14_753 + +- name: A non-dependent adult's contributory ESA, JSA, incapacity benefit and SDA are not in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_contrib_reported: 2_000 + esa_includes_support_component: false + jsa_contrib_reported: 2_000 + incapacity_benefit_reported: 2_000 + sda: 2_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_benefit_cap_exempt: false + # 18,000 - 14,753 + benefit_cap_reduction: 3_247 + universal_credit: 14_753 + +- name: The claimant's own contributory JSA is in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + jsa_contrib_reported: 3_000 + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 15_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # 15,000 + 3,000 - 14,753 + benefit_cap_reduction: 3_247 + universal_credit: 11_753 + +- name: A non-dependent adult's income-based JSA does not reduce the family's Housing Benefit + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 6_000 + benunits: + family: + members: [claimant, adult] + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + claimant_or_partner_jsa_income: 0 + # 9,000 is under 14,753 + benefit_cap_reduction: 0 + housing_benefit: 9_000 + +- name: The claimant's own income-based JSA reduces their Housing Benefit + period: 2025 + input: + people: + claimant: + age: 40 + jsa_income_reported: 6_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_housing_benefit: true + housing_benefit_pre_benefit_cap: 9_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + claimant_or_partner_jsa_income: 6_000 + # 9,000 + 6,000 - 14,753 (HB Regs 2006 reg 75D(1)) + benefit_cap_reduction: 247 + housing_benefit: 8_753 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 0af245a6d3..0e852522b2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -210,6 +210,10 @@ is_benefit_cap_exempt_other: false is_benefit_cap_exempt: false benefit_cap: 14_753 + # Their 7,000 is theirs, not in the family's capped total: + # 18,000 - 14,753. + benefit_cap_reduction: 3_247 + universal_credit: 14_753 - name: Income-related ESA that the capital test stops is not received, so it does not lift the cap period: 2025 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 9452b3c75f..3df71823ee 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -29,8 +29,11 @@ 1. Adding a member who is neither the claimant, the partner nor a child or young person they are responsible for never changes whether the family is - exempt or its cap, whatever disability or carer benefits, ESA, disability - flag or caring that member has. (Their age and earnings can: the State + exempt, its cap, its cap reduction or its Universal Credit, whatever + disability or carer benefits, ESA, JSA, incapacity benefit, SDA, + disability flag or caring that member has. The cap counts only the + welfare benefits "to which the single person or couple is entitled" (UC + Regs 2013 reg 80(1); HB Regs 2006 reg 75A). (Their age and earnings can: the State Pension age and earnings exceptions read every member. The draws keep the member under State Pension age and without earnings; see #1944, #1907, #1999 and #1820.) @@ -70,7 +73,20 @@ + YOUNG_PERSON + HOUSING_BENEFIT_YOUNG_PERSON ) -CIRCUMSTANCES = AMOUNTS + ["esa_contrib", "esa_income", "caring", "disabled"] +# Capped welfare benefits that do not lift the cap (WRA 2012 s.96(10)). +CAPPED_REPORTS = { + "jsa_contrib": "jsa_contrib_reported", + "jsa_income": "jsa_income_reported", + "incapacity_benefit": "incapacity_benefit_reported", + "sda": "sda_reported", +} +CIRCUMSTANCES = AMOUNTS + [ + "esa_contrib", + "esa_income", + "caring", + "disabled", + *CAPPED_REPORTS, +] ROLES = { "child": {"current_education": "PRIMARY"}, @@ -95,6 +111,7 @@ def blank(role, age): "age": age, "esa_contrib_reported": 0, "esa_income_reported": 0, + **{report: 0 for report in CAPPED_REPORTS.values()}, "care_hours": 0, "is_disabled_for_benefits": False, "current_education": "NOT_IN_EDUCATION", @@ -117,6 +134,8 @@ def set_circumstance(person, circumstance): person["esa_contrib_reported"] = 5_000 elif circumstance == "esa_income": person["esa_income_reported"] = 4_000 + elif circumstance in CAPPED_REPORTS: + person[CAPPED_REPORTS[circumstance]] = 2_000 elif circumstance == "caring": person["care_hours"] = 35 else: @@ -251,6 +270,15 @@ def reference_cap(members): return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP +CAPPED_FAMILY = { + "universal_credit_pre_benefit_cap": 30_000, + "housing_benefit_pre_benefit_cap": 0, + "child_tax_credit": 0, + "child_benefit": 0, + "income_support": 0, +} + + SETTINGS = settings( max_examples=40, deadline=None, @@ -264,7 +292,12 @@ def reference_cap(members): def test_non_dependent_adults_never_change_the_exemption(drawn): without = [(members, wtc) for members, _, wtc in drawn] with_other = [(members + [other], wtc) for members, other, wtc in drawn] - sim = Simulation(situation=situation(without + with_other)) + # 30,000 of Universal Credit before the cap; the other family-level + # capped benefits are nil, so the capped total is UC plus whatever the + # members' own benefits add. + sim = Simulation( + situation=situation(without + with_other, extra_benunit=CAPPED_FAMILY) + ) k = len(drawn) claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) @@ -281,6 +314,8 @@ def test_non_dependent_adults_never_change_the_exemption(drawn): "is_benefit_cap_exempt_other", "is_benefit_cap_exempt", "benefit_cap", + "benefit_cap_reduction", + "universal_credit", ]: values = sim.calculate(variable, YEAR) for i in range(k): @@ -303,20 +338,9 @@ def test_exemption_matches_regulations(drawn): @given(draws()) def test_exempt_families_have_no_cap_and_others_the_statutory_cap(drawn): units = [(members + [other], wtc) for members, other, wtc in drawn] - # 30,000 of Universal Credit alone exceeds either cap. The other capped - # benefits are set to nil; they only add to the excess. - sim = Simulation( - situation=situation( - units, - extra_benunit={ - "universal_credit_pre_benefit_cap": 30_000, - "housing_benefit_pre_benefit_cap": 0, - "child_tax_credit": 0, - "child_benefit": 0, - "income_support": 0, - }, - ) - ) + # 30,000 of Universal Credit alone exceeds either cap. The other + # family-level capped benefits are set to nil; they only add to the excess. + sim = Simulation(situation=situation(units, extra_benunit=CAPPED_FAMILY)) exempt = sim.calculate("is_benefit_cap_exempt", YEAR) cap = sim.calculate("benefit_cap", YEAR) reduction = sim.calculate("benefit_cap_reduction", YEAR) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 980f37744d..c53808eeeb 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -12,6 +12,8 @@ exemption: CTR (Default Scheme) (England) Regs 2012, Schedule. - The tax credit income test: TCA 2002 s.7(2), SI 2002/2008 reg 4. - Scottish Child Payment: SSI 2020/351 reg 18(e)-(f). +- The benefit cap exemption: HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg + 83(1)(a). - Targeted childcare: SI 2014/2147 reg 1(2). - Maintenance loans for students entitled to benefits: SI 2011/1986 regs 61(2) and 71(1)(h). @@ -55,6 +57,7 @@ "council_tax_reduction_applicable_income", "council_tax_reduction_relevant_income_based_benefit", "tax_credits_applicable_income", + "is_benefit_cap_exempt_health_disability", "targeted_childcare_entitlement_eligible", "would_claim_IS", "income_support_eligible", diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py index bb0fddc2a4..cd53741062 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py @@ -1,28 +1,53 @@ from policyengine_uk.model_api import * +# Welfare benefits (Welfare Reform Act 2012 s.96(10)) the model has. The cap +# counts those "to which the single person or couple is entitled" (UC Regs +# 2013 regs 79(1), 80(1); HB Regs 2006 reg 75A): the claimant's and partner's +# own awards, and the awards made to the family. Another member of the +# benefit unit, such as a non-dependent adult, is entitled to their own +# contributory or income-related benefit; it still counts in household income. +CLAIMANT_OR_PARTNER_BENEFITS = [ + "jsa_contrib", + "esa_contrib", + "incapacity_benefit", + "sda", +] +FAMILY_BENEFITS = [ + "child_benefit", + "child_tax_credit", + "claimant_or_partner_jsa_income", + "income_support", + "claimant_or_partner_esa_income", + "universal_credit_pre_benefit_cap", + "housing_benefit_pre_benefit_cap", +] + class benefit_cap_reduction(Variable): label = "benefit cap reduction" + documentation = ( + "The amount by which the welfare benefits the claimant or couple is " + "entitled to exceed the benefit cap. Benefits another member of the " + "benefit unit claims in their own right do not count. Maternity " + "allowance and the bereavement benefits, also welfare benefits, are " + "not modelled here." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/96", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/80", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/75A", + ) def formula(benunit, period, parameters): - CAPPED_BENEFITS = [ - "child_benefit", - "child_tax_credit", - "jsa_income", - "income_support", - "esa_income", - "universal_credit_pre_benefit_cap", - "housing_benefit_pre_benefit_cap", - "jsa_contrib", - "incapacity_benefit", - "esa_contrib", - "sda", - ] - return max_( - add(benunit, period, CAPPED_BENEFITS) - benunit("benefit_cap", period), - 0, + claimant = benunit.members("is_claimant_or_partner", period) + capped = add_for_members( + benunit, + period, + CLAIMANT_OR_PARTNER_BENEFITS + FAMILY_BENEFITS, + claimant, ) + return max_(capped - benunit("benefit_cap", period), 0) From c6eabdab267c9b3cff7f98e86813efc47c0ef38c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:12:45 -0400 Subject: [PATCH 06/12] Leave the support component out of the capped-total cases The non-dependent adult's support component cannot lift the family's cap, so the two cases do not need the input and now also run on the base package, where they fail on value. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml index 1691897269..53f322ce2d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -25,7 +25,6 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - esa_includes_support_component: false benunits: family: members: [claimant, adult] @@ -54,7 +53,6 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_contrib_reported: 2_000 - esa_includes_support_component: false jsa_contrib_reported: 2_000 incapacity_benefit_reported: 2_000 sda: 2_000 From a6100a7fca375627c4529a4682ba191a9bda86aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 12:21:02 -0400 Subject: [PATCH 07/12] Read limited capability for work in the work preparation group and the childcare exception The two switches #2081 and #2088 left to this branch, which lands after them (comments on #2076, 2026-10-03 and 2026-10-05): - Welfare Reform Act 2012 s.21(1)(a): uc_work_related_group_apart_from_ earnings reads uc_limited_capability_for_work. The input uc_has_limited_capability_for_work, which defaulted to false, is removed; its test inputs move to uc_limited_capability_for_work. - UC Regs 2013 reg 32(1)(b)(i): uc_unable_to_provide_childcare reads uc_limited_capability_for_work, and its documentation says so. An ESA award without the support component removes limited capability for work-related activity but not for work (UC Regs 2013 regs 39, 40), so such a claimant falls within section 21, outside the minimum income floor, and as a partner still meets the childcare exception. New YAML cases pin both, and the minimum income floor case. Changelog fragments updated for these and for main's age and earnings exceptions. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.added.md | 2 +- .../benefit-cap-75f-claimant-partner.fixed.md | 4 +-- ...enefit-cap-75f-claimant-partner.removed.md | 3 +- .../uc_childcare_work_condition_reg_32.yaml | 29 +++++++++++++++++++ .../income/income_floor/uc_MIF_applies.yaml | 16 +++++++++- .../income_floor/uc_minimum_income_floor.yaml | 4 +-- ...ork_related_group_apart_from_earnings.yaml | 19 ++++++++++-- .../test_uc_work_related_groups_properties.py | 2 +- .../uc_unable_to_provide_childcare.py | 14 +++++---- .../uc_limited_capability_for_work.py | 14 +++++---- .../uc_has_limited_capability_for_work.py | 17 ----------- ..._work_related_group_apart_from_earnings.py | 6 ++-- 12 files changed, 90 insertions(+), 40 deletions(-) delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_has_limited_capability_for_work.py diff --git a/changelog.d/benefit-cap-75f-claimant-partner.added.md b/changelog.d/benefit-cap-75f-claimant-partner.added.md index bab0fbc012..6f92e40f0c 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.added.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.added.md @@ -1,3 +1,3 @@ - Add `esa_includes_support_component`: whether a person's employment and support allowance includes the support component. Datasets and users can supply it; otherwise it is assumed for anyone with an ESA award of their own. Household calculations with ESA outside the support group should set it to false. - Add `has_own_esa_award`: whether a person claims ESA themselves (a reported award, or a directly entered income-related award attributed to the claimant who heads the benefit unit). -- Add `uc_limited_capability_for_work`: limited capability for work, for the Universal Credit work allowance and reduced minimum age. +- Add `uc_limited_capability_for_work`: limited capability for work, for the Universal Credit work allowance, reduced minimum age, work preparation group and the partner's exception to the childcare work condition. diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md index 8da6e4c661..9d5bdeb1be 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -1,6 +1,6 @@ - Lift the benefit cap for employment and support allowance only where the claimant's or partner's allowance includes the support component (HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg 83(1)(a)), for contributory and income-related ESA alike. Any income-related ESA, and any member's contributory ESA, used to lift it. Without other information the support component is assumed for a person with their own allowance, as for about nine in ten ESA recipients in the Family Resources Survey. -- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap; their age and earnings still do, through the age and earnings exceptions. +- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap. - Count armed forces independence payment towards the benefit cap exception. - Take a person whose own ESA lacks the support component not to have limited capability for work-related activity (`uc_limited_capability_for_WRA`), whatever their disability flag, since that determination is what gives ESA the support component and Universal Credit takes it (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. -- Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. +- Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. The work preparation group (Welfare Reform Act 2012 s.21(1)(a)) and the partner's exception to the childcare work condition (UC Regs 2013 reg 32(1)(b)(i)) read it too, so a claimant flagged as disabled without limited capability for work-related activity is in the work preparation group, outside the minimum income floor, and as a partner still lets a working claimant meet the childcare work condition. - Count in the benefit cap only the welfare benefits the claimant or couple is entitled to (UC Regs 2013 reg 80(1); HB Regs 2006 reg 75A): the claimant's and partner's contributory ESA, JSA, incapacity benefit and SDA, their income-related ESA and income-based JSA (`claimant_or_partner_esa_income`, `claimant_or_partner_jsa_income`), and the family's Child Benefit, CTC, Income Support, Universal Credit and Housing Benefit. Another member's own award no longer reduces the family's Universal Credit or Housing Benefit; it still counts in household income. diff --git a/changelog.d/benefit-cap-75f-claimant-partner.removed.md b/changelog.d/benefit-cap-75f-claimant-partner.removed.md index 82cc23cbf0..9d5849c547 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.removed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.removed.md @@ -1 +1,2 @@ -- Remove the unused benefit sums from `is_benefit_cap_exempt_earnings` and `is_benefit_cap_exempt_other`; the age exception keeps its result and the armed forces and ESA exceptions move, limited to the claimant and partner, into `is_benefit_cap_exempt_health_disability`. +- Remove the unused benefit sums from `is_benefit_cap_exempt_earnings` and `is_benefit_cap_exempt_other`; the earnings and age exceptions keep their results and the armed forces and ESA exceptions move, limited to the claimant and partner, into `is_benefit_cap_exempt_health_disability`. +- Remove the input `uc_has_limited_capability_for_work`. The work preparation group reads `uc_limited_capability_for_work`, which datasets and users can set instead. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index 9058ab33ab..6add9f640f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -67,6 +67,35 @@ uc_limited_capability_for_WRA: [false, true] uc_childcare_work_condition: true +- name: Reg. 32(1)(b)(i) - a partner whose own ESA lacks the support component still has limited capability for work + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + employment_income: 15_000 + partner: + age: 34 + is_disabled_for_benefits: true + has_own_esa_award: true + esa_includes_support_component: false + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + # An ESA award without the support component means no limited capability + # for work-related activity (UC Regs 2013 reg. 40(1)(a)(ii)), but the + # partner still has limited capability for work (reg. 39), so limb (i) + # holds. + uc_limited_capability_for_WRA: [false, false] + uc_limited_capability_for_work: [false, true] + uc_unable_to_provide_childcare: [false, true] + uc_childcare_work_condition: true + - name: Reg. 32(1)(b)(ii) - ADM F7013 example, a partner caring full time for her disabled mother period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml index e169936bb9..8bd4119b6c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml @@ -119,10 +119,24 @@ input: age: 30 self_employment_income: 3_000 - uc_has_limited_capability_for_work: true + uc_limited_capability_for_work: true output: uc_mif_applies: false +- name: Limited capability for work with an ESA award lacking the support component (section 21) + period: 2026 + absolute_error_margin: 0 + input: + age: 30 + self_employment_income: 3_000 + is_disabled_for_benefits: true + has_own_esa_award: true + esa_includes_support_component: false + output: + uc_limited_capability_for_WRA: false + uc_work_related_group_apart_from_earnings: WORK_PREPARATION + uc_mif_applies: false + - name: Pregnant claimant within 11 weeks of the expected week of confinement (section 19) period: 2026 absolute_error_margin: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml index 3568e6e7e5..0132b689f6 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml @@ -286,7 +286,7 @@ absolute_error_margin: 0.01 input: age: 19 - uc_has_limited_capability_for_work: true + uc_limited_capability_for_work: true output: # 10.85 x 16 x 52. uc_minimum_income_floor_gross: 9_027.20 @@ -296,7 +296,7 @@ absolute_error_margin: 0.01 input: age: 20 - uc_has_limited_capability_for_work: true + uc_limited_capability_for_work: true output: # "Alison's individual earnings threshold is 5.30 X 16 = 84.80, converted # to a monthly amount - 84.80 x 52 / 12 = 367 per month": 367.47 before diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml index 31793003d7..536f66f1de 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.yaml @@ -305,17 +305,32 @@ period: 2026 input: age: 30 - uc_has_limited_capability_for_work: true + uc_limited_capability_for_work: true output: uc_work_related_group_apart_from_earnings: WORK_PREPARATION +- name: Section 21(1)(a) takes a claimant whose own ESA lacks the support component + period: 2026 + input: + age: 30 + is_disabled_for_benefits: true + has_own_esa_award: true + esa_includes_support_component: false + output: + # No limited capability for work-related activity (UC Regs 2013 reg. + # 40(1)(a)(ii)), so not section 19(2)(a), but limited capability for work + # (reg. 39), so section 21(1)(a). + uc_limited_capability_for_WRA: false + uc_limited_capability_for_work: true + uc_work_related_group_apart_from_earnings: WORK_PREPARATION + - name: Section 21 takes only a claimant who does not fall within section 19 or 20 period: 2026 input: people: parent: age: 30 - uc_has_limited_capability_for_work: true + uc_limited_capability_for_work: true child: age: 1 output: diff --git a/policyengine_uk/tests/test_uc_work_related_groups_properties.py b/policyengine_uk/tests/test_uc_work_related_groups_properties.py index 2b3c643ae7..bbb631832c 100644 --- a/policyengine_uk/tests/test_uc_work_related_groups_properties.py +++ b/policyengine_uk/tests/test_uc_work_related_groups_properties.py @@ -97,7 +97,7 @@ rarely = st.integers(0, 5).map(lambda n: n == 0) CIRCUMSTANCES = [ "uc_limited_capability_for_WRA", - "uc_has_limited_capability_for_work", + "uc_limited_capability_for_work", "uc_is_in_pregnancy_or_post_confinement_period", "uc_is_adopter_in_first_year", "uc_is_student_with_no_work_related_requirements", diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py index 72de5b28f8..d44a0cfef2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py @@ -12,11 +12,13 @@ class uc_unable_to_provide_childcare(Variable): "for work, (ii) have regular and substantial caring responsibilities " "for a severely disabled person, or (iii) are temporarily absent from " "the claimant's household. Each limb is an approximation. Limb (i) " - "reads uc_limited_capability_for_WRA, which defaults to " - "is_disabled_for_benefits (disability benefit receipt in survey " - "data), not an observed work capability assessment; limited " - "capability for work and work-related activity includes limited " - "capability for work. Limb (ii), defined in regulation 30, reads " + "reads uc_limited_capability_for_work, which is limited capability " + "for work and work-related activity (uc_limited_capability_for_WRA) " + "or else is_disabled_for_benefits (disability benefit receipt in " + "survey data), not an observed work capability assessment. An ESA " + "award without the support component removes limited capability for " + "work-related activity but not for work, so it still meets limb (i). " + "Limb (ii), defined in regulation 30, reads " "is_carer_for_benefits: receipt of Carer's Allowance or Carer Support " "Payment, or at least the Carer's Allowance qualifying hours of care " "a week. Its hours branch does not check that the person cared for " @@ -47,7 +49,7 @@ class uc_unable_to_provide_childcare(Variable): def formula(person, period, parameters): return ( - person("uc_limited_capability_for_WRA", period) + person("uc_limited_capability_for_work", period) | person("is_carer_for_benefits", period) | person("uc_is_temporarily_absent_from_claimant_household", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py index f0082e6f46..f7e27613a1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py @@ -7,11 +7,13 @@ class uc_limited_capability_for_work(Variable): label = "Assessed to have limited capability for work" documentation = ( "Whether this person has limited capability for work (UC Regs 2013 " - "reg 39), which the work allowance (reg 22) and the reduced minimum " - "age (reg 8(1)(a)) need. Limited capability for work and work-related " - "activity (uc_limited_capability_for_WRA) includes it, and otherwise " - "it defaults to is_disabled_for_benefits. An ESA award without the " - "support component therefore removes limited capability for " + "reg 39), which the work allowance (reg 22), the reduced minimum age " + "(reg 8(1)(a)), the partner's exception to the childcare work " + "condition (reg 32(1)(b)(i)) and the work preparation group (Welfare " + "Reform Act 2012 s.21(1)(a)) need. Limited capability for work and " + "work-related activity (uc_limited_capability_for_WRA) includes it, " + "and otherwise it defaults to is_disabled_for_benefits. An ESA award " + "without the support component therefore removes limited capability for " "work-related activity but not for work. Universal Credit also takes " "an ESA determination of limited capability for work (reg 39(1)(a)), " "so any ESA award of the person's own would imply it; the model does " @@ -23,6 +25,8 @@ class uc_limited_capability_for_work(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/39", "https://www.legislation.gov.uk/uksi/2013/376/regulation/22", "https://www.legislation.gov.uk/uksi/2013/376/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/32", + "https://www.legislation.gov.uk/ukpga/2012/5/section/21", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_has_limited_capability_for_work.py b/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_has_limited_capability_for_work.py deleted file mode 100644 index c12ca4c1bb..0000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_has_limited_capability_for_work.py +++ /dev/null @@ -1,17 +0,0 @@ -from policyengine_uk.model_api import * - - -class uc_has_limited_capability_for_work(Variable): - value_type = bool - entity = Person - label = "Has limited capability for work, for Universal Credit" - documentation = ( - "Whether the claimant has limited capability for work. A claimant " - "who has it, and does not also have limited capability for " - "work-related activity, is subject to the work preparation " - "requirement. An input: the model identifies limited capability for " - "work-related activity only (uc_limited_capability_for_WRA)." - ) - reference = "https://www.legislation.gov.uk/ukpga/2012/5/section/21" - definition_period = YEAR - default_value = False diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.py index 9b5320d70e..5e981ffe2c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/work_related_requirements/uc_work_related_group_apart_from_earnings.py @@ -91,8 +91,10 @@ def formula(person, period, parameters): | person("uc_is_foster_parent_or_new_friend_or_family_carer", period) ) work_preparation = ( - # s. 21(1)(a): limited capability for work. - person("uc_has_limited_capability_for_work", period) + # s. 21(1)(a): limited capability for work. A claimant with + # limited capability for work-related activity as well falls + # within section 19 first. + person("uc_limited_capability_for_work", period) # s. 21(1)(aa): the responsible carer for a child aged 2 (from 28 # April 2014 to 2 April 2017, reg. 91A: aged 3 or 4). | (responsible_carer & (youngest < child_age.work_preparation)) From a6a3073a5fe84a088cb59e4d74b55a691523099f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:44:26 -0400 Subject: [PATCH 08/12] Answer the after-merge review: entered UC elements, the scheme's couple, migrated single-claim properties Review job 20261010-122211-benefit-cap-75f-review-after-merge on a6100a7fc (REQUEST_CHANGES): 1. An entered LCWRA or carer element lifted the cap only if no member had the matching circumstance, so a non-dependant's flag or caring cancelled it. UC reg 83(1)(a), (j) asks whether the award includes the element. An element supplied as an input (utils/supplied_inputs.supplied_input; both elements registered) now lifts the cap whatever the members' circumstances; the by-value reading stays for reforms that replace the formula. 2. With entered is_uc_claimant identities the capped total mixed two couples: person-level benefits for the union of is_claimant_or_partner and is_uc_assessed_claimant, income-based JSA and ESA for the claimant and partner only. The cap now reads one couple, the scheme's (utils/benefit_cap.benefit_cap_couple): a family on Universal Credit, its claimants (reg 78(2)); otherwise Housing Benefit's claimant and partner (HB reg 75A). The exceptions read the same couple. Income-based JSA and ESA are scoped by it through award_of_members, the reading claimant_or_partner_esa_income and _jsa_income already used, now one helper in utils/benefit_unit.py that both readers call unchanged. 3. test_uc_single_claim_properties (#2084) still read the AFCS and ESA heads in is_benefit_cap_exempt_other. They now check that variable against the pension-age route alone and the claimant's AFCS or ESA against is_benefit_cap_exempt_health_disability, which holds them. Tests: YAML cases for both entered elements beside a non-dependant's circumstance, a non-dependant's own limited capability, a non-dependant over State Pension age or with earnings, and the scheme's couple on UC and on Housing Benefit (four of these fail on a6100a7fc). The cap property test now also draws entered elements, which invariant 1 would have caught. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.fixed.md | 4 +- .../benefit_cap/benefit_cap_capped_total.yaml | 95 ++++++++++++ .../benefit_cap_specified_benefits.yaml | 143 +++++++++++++++++- ...nefit_cap_specified_benefits_properties.py | 57 ++++--- .../tests/test_uc_single_claim_properties.py | 59 ++++---- policyengine_uk/utils/benefit_cap.py | 24 +++ policyengine_uk/utils/benefit_unit.py | 41 +++++ policyengine_uk/utils/supplied_inputs.py | 2 + .../gov/dwp/benefit_cap_reduction.py | 50 ++++-- .../gov/dwp/claimant_or_partner_esa_income.py | 33 +--- .../gov/dwp/claimant_or_partner_jsa_income.py | 33 +--- ...is_benefit_cap_exempt_health_disability.py | 48 +++--- 12 files changed, 451 insertions(+), 138 deletions(-) create mode 100644 policyengine_uk/utils/benefit_cap.py diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md index 9d5bdeb1be..7916d32eb0 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -1,6 +1,6 @@ - Lift the benefit cap for employment and support allowance only where the claimant's or partner's allowance includes the support component (HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg 83(1)(a)), for contributory and income-related ESA alike. Any income-related ESA, and any member's contributory ESA, used to lift it. Without other information the support component is assumed for a person with their own allowance, as for about nine in ten ESA recipients in the Family Resources Survey. -- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap. +- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly, which lifts the cap whatever the members' circumstances. A partner who cannot be a joint claimant (UC Regs 2013 reg 3(3)) lifts no cap with their own benefits. For a family on Universal Credit the claimant and partner are the award's claimants (`is_uc_assessed_claimant`), otherwise Housing Benefit's claimant and partner; the two are the same unless `is_uc_claimant` is entered. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap. - Count armed forces independence payment towards the benefit cap exception. - Take a person whose own ESA lacks the support component not to have limited capability for work-related activity (`uc_limited_capability_for_WRA`), whatever their disability flag, since that determination is what gives ESA the support component and Universal Credit takes it (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. - Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. The work preparation group (Welfare Reform Act 2012 s.21(1)(a)) and the partner's exception to the childcare work condition (UC Regs 2013 reg 32(1)(b)(i)) read it too, so a claimant flagged as disabled without limited capability for work-related activity is in the work preparation group, outside the minimum income floor, and as a partner still lets a working claimant meet the childcare work condition. -- Count in the benefit cap only the welfare benefits the claimant or couple is entitled to (UC Regs 2013 reg 80(1); HB Regs 2006 reg 75A): the claimant's and partner's contributory ESA, JSA, incapacity benefit and SDA, their income-related ESA and income-based JSA (`claimant_or_partner_esa_income`, `claimant_or_partner_jsa_income`), and the family's Child Benefit, CTC, Income Support, Universal Credit and Housing Benefit. Another member's own award no longer reduces the family's Universal Credit or Housing Benefit; it still counts in household income. +- Count in the benefit cap only the welfare benefits the claimant or couple is entitled to (UC Regs 2013 reg 80(1); HB Regs 2006 reg 75A): the claimant's and partner's contributory ESA, JSA, incapacity benefit and SDA, their income-related ESA and income-based JSA (`claimant_or_partner_esa_income`, `claimant_or_partner_jsa_income`), and the family's Child Benefit, CTC, Income Support, Universal Credit and Housing Benefit. For a family on Universal Credit the couple is the award's claimants, including the other member of a couple where one member claims as a single person (UC Regs 2013 reg 78(2)); otherwise it is the claimant and partner. Another member's own award no longer reduces the family's Universal Credit or Housing Benefit; it still counts in household income. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml index 53f322ce2d..9958dcdfff 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -140,3 +140,98 @@ # 9,000 + 6,000 - 14,753 (HB Regs 2006 reg 75D(1)) benefit_cap_reduction: 247 housing_benefit: 8_753 + +# Whose benefits: the couple of the scheme the cap reduces. For a family on +# Universal Credit, its claimants (is_uc_claimant, or the claimant and +# partner unless entered; UC Regs 2013 regs 78(2), 80(1)); otherwise Housing +# Benefit's claimant and partner (HB Regs 2006 reg 75A). Here the second +# adult is entered as a Universal Credit claimant but is not the Housing +# Benefit partner. An input entered for one person takes its default for the +# others, so both claimants are entered. + +- name: On Universal Credit, an entered joint claimant's own contributory JSA is in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + is_uc_claimant: true + adult: + age: 30 + is_claimant_or_partner: false + is_uc_claimant: true + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 2_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 20_000 + housing_benefit_pre_benefit_cap: 0 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_uc_assessed_claimant: [true, true] + # 20,000 + 2,000 - 14,753 (the cap rate follows is_couple, which reads + # the claimant and partner). + benefit_cap_reduction: 7_247 + +- name: On Universal Credit, an entered joint claimant's own income-based JSA is in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + is_uc_claimant: true + adult: + age: 30 + is_claimant_or_partner: false + is_uc_claimant: true + current_education: NOT_IN_EDUCATION + jsa_income_reported: 2_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 20_000 + housing_benefit_pre_benefit_cap: 0 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + jsa_income: 2_000 + claimant_or_partner_jsa_income: 0 + # 20,000 + 2,000 - 14,753. + benefit_cap_reduction: 7_247 + +- name: On Housing Benefit alone, the same adult's contributory JSA is not in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + is_uc_claimant: true + adult: + age: 30 + is_claimant_or_partner: false + is_uc_claimant: true + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 2_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 0 + housing_benefit_pre_benefit_cap: 15_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # The adult is not the claimant's partner (HB Regs 2006 reg 75A): + # 15,000 - 14,753. + benefit_cap_reduction: 247 + diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 9ef5b0b6f1..b656ead3d6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -31,8 +31,10 @@ # A "non-dependent adult" below is a benefit-unit member aged 30 (50 in the # attendance allowance case) who is neither the claimant, the partner, nor a # child or young person they are responsible for. Their own benefits and -# circumstances never lift the family's cap. (Their age and earnings still -# can, through is_benefit_cap_exempt_other and _earnings: out of scope here.) +# circumstances never lift the family's cap, and nor do their age or +# earnings: the age exception reads the claimant and partner (Housing +# Benefit's pension-age regulations, HB Regs 2006 reg 5) and the earnings +# exception the claimants (UC Regs 2013 reg 82(1)(a)). # --- ESA with the support component: HB reg 75F(1)(a), UC reg 83(1)(a) --- @@ -1208,6 +1210,88 @@ is_benefit_cap_exempt_health_disability: true benefit_cap_reduction: 0 +- name: An LCWRA element entered for the award lifts the cap whatever a non-dependant's limited capability + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: false + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + uc_LCWRA_element: 5_079.24 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # The award includes the element (reg 83(1)(a)); who else in the benefit + # unit has limited capability does not matter. + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: A carer element entered for the award lifts the cap whatever a non-dependant's caring + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + care_hours: 35 + benunits: + family: + members: [claimant, adult] + uc_carer_element: 2_417.52 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # Reg 83(1)(j): the award includes the carer element. + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: A non-dependant's limited capability does not lift the cap when no element is entered + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: false + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # No claimant has limited capability (reg 27(1)), so the element the + # model calculates from the adult's flag is not a claimant's: 18,000 - + # 14,753. + is_benefit_cap_exempt_health_disability: false + benefit_cap_reduction: 3_247 + # --- Working tax credit: HB reg 75E(2) --- - name: The family's working tax credit lifts the cap @@ -1266,7 +1350,7 @@ # Below the personal allowance and primary threshold: 9,000 < 10,152. is_benefit_cap_exempt_earnings: false -- name: A member over State Pension age keeps the age exception +- name: A claimant over the qualifying age for State Pension Credit and off Universal Credit is outside the cap (HB pension-age regulations) period: 2025 input: people: @@ -1282,3 +1366,56 @@ output: is_benefit_cap_exempt_other: true is_benefit_cap_exempt: true + +- name: A non-dependent adult over State Pension age does not lift the cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 70 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # The pension-age regulations read the claimant and partner (HB Regs + # 2006 reg 5); Universal Credit has no age exception for a working-age + # claimant. 18,000 - 14,753. + is_benefit_cap_exempt_other: false + is_benefit_cap_exempt: false + benefit_cap_reduction: 3_247 + +- name: A non-dependent adult's earnings do not meet the earnings exception + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + employment_income: 20_000 + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # UC Regs 2013 reg 82(1)(a) reads the claimant's earned income. 18,000 - + # 14,753. + is_benefit_cap_exempt_earnings: false + is_benefit_cap_exempt: false + benefit_cap_reduction: 3_247 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 441e1ad477..c84ce75166 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -16,7 +16,8 @@ (75F(1)(ea); UC counts it as attendance allowance, UC Regs 2013 reg 2); - a Universal Credit claimant has limited capability for work and work-related activity or caring responsibilities, so the award includes the - LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)). A person with + LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)), or the element is + entered for the award, whatever anyone's circumstances. A person with ESA of their own whose ESA lacks the support component is inferred not to have limited capability for work-related activity (reg 40(1)(a)(ii)); - the claimant or couple is entitled to working tax credit (HB reg 75E(2)). @@ -53,7 +54,9 @@ example builds many families in one simulation, in separate households and benefit units. Roles are given explicitly (is_claimant_or_partner). The support component is set for every person in a simulation or for none, since -an input set for some people is given its default for the rest. +an input set for some people is given its default for the rest. For the same +reason, a simulation whose families may enter a UC element enters it as nil +for the families that do not. """ import numpy as np @@ -65,6 +68,8 @@ YEAR = 2025 SINGLE_CAP, FAMILY_CAP = 14_753, 22_020 # Outside London, 2025-26. +# A month's element times 12, 2025-26: LCWRA 423.27, carer 201.46. +ENTERED_ELEMENTS = {"uc_LCWRA_element": 5_079.24, "uc_carer_element": 2_417.52} CLAIMANT_OR_PARTNER = ["attendance_allowance", "iidb_reported", "afcs_reported"] CHILD_OR_YOUNG_PERSON = ["dla"] @@ -147,9 +152,10 @@ def set_circumstance(person, circumstance): @st.composite -def families(draw, supplied): +def families(draw, supplied, enter_elements): """A claimant, an optional partner, up to three dependants and a - non-dependent adult, with at most two circumstances among them.""" + non-dependent adult, with at most two circumstances among them, and the + family's benefit-unit inputs.""" members = [blank("claimant", draw(st.integers(25, 60)))] if draw(st.booleans()): members.append(blank("claimant", draw(st.integers(25, 60)))) @@ -177,22 +183,28 @@ def families(draw, supplied): if supplied: for person in members + [other]: person["esa_includes_support_component"] = draw(st.booleans()) - working_tax_credit = draw(st.sampled_from([0] * 9 + [500])) - return members, other, working_tax_credit + benunit_inputs = {"working_tax_credit": draw(st.sampled_from([0] * 9 + [500]))} + if enter_elements: + entered = draw(st.sampled_from([None, *ENTERED_ELEMENTS])) + event(f"entered element: {entered}") + for element, amount in ENTERED_ELEMENTS.items(): + benunit_inputs[element] = amount if element == entered else 0 + return members, other, benunit_inputs @st.composite def draws(draw): """Families in one simulation, sharing whether the support component is - supplied.""" + supplied and whether UC elements are entered.""" supplied = draw(st.booleans()) + enter_elements = draw(st.booleans()) event(f"support component supplied: {supplied}") - return draw(st.lists(families(supplied), min_size=1, max_size=6)) + return draw(st.lists(families(supplied, enter_elements), min_size=1, max_size=6)) def situation(units, year=YEAR, extra_benunit=None): people, benunits, households = {}, {}, {} - for i, (members, working_tax_credit) in enumerate(units): + for i, (members, benunit_inputs) in enumerate(units): names = [] for j, inputs in enumerate(members): name = f"p{i}_{j}" @@ -205,7 +217,7 @@ def situation(units, year=YEAR, extra_benunit=None): names.append(name) benunits[f"b{i}"] = { "members": names, - "working_tax_credit": {year: working_tax_credit}, + **{k: {year: v} for k, v in benunit_inputs.items()}, **{k: {year: v} for k, v in (extra_benunit or {}).items()}, } households[f"h{i}"] = { @@ -220,7 +232,7 @@ def own_esa(m): return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 -def reference_exempt(members, working_tax_credit): +def reference_exempt(members, benunit_inputs): """HB Regs 2006 regs 75E(2), 75F(1) and UC Regs 2013 reg 83(1), read directly, with roles known by construction.""" claimants = [m for m in members if m["_role"] == "claimant"] @@ -279,7 +291,9 @@ def any_receives(people, benefits): or any_receives(claimants + hb_young_persons, HOUSING_BENEFIT_YOUNG_PERSON) or any(lcwra(m) for m in claimants) or any(carer(m) for m in claimants) - or working_tax_credit > 0 + # Reg 83(1)(a), (j): an element entered for the award is included. + or any(benunit_inputs.get(element, 0) > 0 for element in ENTERED_ELEMENTS) + or benunit_inputs["working_tax_credit"] > 0 ) @@ -314,8 +328,8 @@ def reference_cap(members): @SETTINGS @given(draws()) def test_non_dependent_adults_never_change_the_exemption(drawn): - without = [(members, wtc) for members, _, wtc in drawn] - with_other = [(members + [other], wtc) for members, other, wtc in drawn] + without = [(members, inputs) for members, _, inputs in drawn] + with_other = [(members + [other], inputs) for members, other, inputs in drawn] # 30,000 of Universal Credit before the cap; the other family-level # capped benefits are nil, so the capped total is UC plus whatever the # members' own benefits add. @@ -349,11 +363,11 @@ def test_non_dependent_adults_never_change_the_exemption(drawn): @SETTINGS @given(draws()) def test_exemption_matches_regulations(drawn): - units = [(members + [other], wtc) for members, other, wtc in drawn] + units = [(members + [other], inputs) for members, other, inputs in drawn] sim = Simulation(situation=situation(units)) exempt = sim.calculate("is_benefit_cap_exempt_health_disability", YEAR) - for i, (members, wtc) in enumerate(units): - expected = reference_exempt(members, wtc) + for i, (members, inputs) in enumerate(units): + expected = reference_exempt(members, inputs) event(f"reference exempt: {expected}") assert exempt[i] == expected, units[i] @@ -361,7 +375,7 @@ def test_exemption_matches_regulations(drawn): @SETTINGS @given(draws()) def test_exempt_families_have_no_cap_and_others_the_statutory_cap(drawn): - units = [(members + [other], wtc) for members, other, wtc in drawn] + units = [(members + [other], inputs) for members, other, inputs in drawn] # 30,000 of Universal Credit alone exceeds either cap. The other # family-level capped benefits are set to nil; they only add to the excess. sim = Simulation(situation=situation(units, extra_benunit=CAPPED_FAMILY)) @@ -404,12 +418,15 @@ def single_head_units(mode): @pytest.mark.parametrize("mode", [None, True, False]) def test_each_head_alone_matches_regulations(mode): units = single_head_units(mode) - sim = Simulation(situation=situation([(members, 0) for _, members in units])) + no_entries = {"working_tax_credit": 0} + sim = Simulation( + situation=situation([(members, no_entries) for _, members in units]) + ) exempt = sim.calculate("is_benefit_cap_exempt_health_disability", YEAR) mismatches = [ (key, bool(exempt[i])) for i, (key, members) in enumerate(units) - if exempt[i] != reference_exempt(members, 0) + if exempt[i] != reference_exempt(members, no_entries) ] assert not mismatches, mismatches # Every role meets every circumstance, and both outcomes occur. diff --git a/policyengine_uk/tests/test_uc_single_claim_properties.py b/policyengine_uk/tests/test_uc_single_claim_properties.py index 266cd3a93a..b6c5b5a33d 100644 --- a/policyengine_uk/tests/test_uc_single_claim_properties.py +++ b/policyengine_uk/tests/test_uc_single_claim_properties.py @@ -34,9 +34,12 @@ education, which the generator leaves unset). The other member's disability benefits, LCWRA, caring, AFCS and contributory ESA lift no cap (reg. 83(1)), so both cap exceptions equal the joint claimants' with those - removed. The model takes any contributory ESA as including the support - component (reg. 83(1)(a) needs the support component); that proxy is the - model's, not this change's. The second exception is also met where + removed. The AFCS and ESA heads are in the first exception + (is_benefit_cap_exempt_health_disability), with a claimant's AFCS or + contributory ESA lifting it; the model takes contributory ESA of a + person's own as including the support component unless told otherwise + (reg. 83(1)(a) needs the support component). The second exception + (is_benefit_cap_exempt_other) is met only where Housing Benefit falls under the pension-age regulations, which the model applies to a family with a member over the qualifying age for State Pension Credit and no Universal Credit award. Whether there is an award @@ -46,10 +49,9 @@ (reg. 22(3)), so its award can be the lower. It also keeps the work allowance the other member's limited capability gives (reg. 22(3)), which stripping removes, so its award can be the higher. So each - calculation's second exception is checked against its own inputs (the - route as that calculation gives it, or a claimant's AFCS or contributory - ESA), and the two are compared only for families the pension-age route - reaches in neither (see off_pension_age_route). + calculation's second exception is checked against its own route, and the + two are compared only for families the pension-age route reaches in + neither (see off_pension_age_route). 6. Regulation 3(3)(a): with the flag left to its formula, a member of a couple under 18 is the ineligible partner exactly when none of the generated regulation 8(1) circumstances applies to them (limited @@ -61,7 +63,7 @@ exceptions, the shared accommodation test) as it was without the flag. Such a flag leaves no one able to claim Universal Credit, so for the second cap exception each calculation is again checked against its own - inputs, and families on the pension-age route in either calculation are + route, and families on the pension-age route in either calculation are left out of the comparison (see off_pension_age_route). Each property also runs on EXAMPLE_FAMILIES, built so that the cases the @@ -364,19 +366,26 @@ def off_pension_age_route(*calculations): return ~np.logical_or.reduce([values[PENSION_AGE_ROUTE] for values in calculations]) -def exempt_other_from_inputs(families, values, members): - """is_benefit_cap_exempt_other as the inputs give it, with no mask. +def check_cap_exceptions_against_inputs(families, values, members): + """Each cap exception as the inputs give it. - The pension-age route as the same calculation gives it (taken from the - model; test_benefit_cap_and_ctr_pension_age_properties.py and - benefit_cap_pension_age.yaml test the route itself), or AFCS or - contributory ESA of one of ``members`` (reg. 83(1)(a) and (e); the model - takes any contributory ESA as including the support component). + is_benefit_cap_exempt_other is the pension-age route as the same + calculation gives it (taken from the model; + test_benefit_cap_and_ctr_pension_age_properties.py and + benefit_cap_pension_age.yaml test the route itself). AFCS or contributory + ESA of one of ``members`` lifts is_benefit_cap_exempt_health_disability + (reg. 83(1)(a) and (e); the model takes contributory ESA of a person's + own as including the support component unless told otherwise). ``members`` are the people whose own benefits count: the claimant alone on a single claim, in a stripped calculation (the other member's benefits removed) or with no partner, and both members of a couple neither of whom is excluded. """ + np.testing.assert_array_equal( + values["is_benefit_cap_exempt_other"], + values[PENSION_AGE_ROUTE], + err_msg=f"is_benefit_cap_exempt_other: {families}", + ) own = np.array( [ any( @@ -386,7 +395,7 @@ def exempt_other_from_inputs(families, values, members): for family in families ] ) - return values[PENSION_AGE_ROUTE] | own + assert np.all(values["is_benefit_cap_exempt_health_disability"][own]), families def calculate(families, year, mode, extra=()): @@ -551,11 +560,7 @@ def test_benefit_cap_rate_and_exceptions(families, year): err_msg=f"is_benefit_cap_exempt_health_disability: {families}", ) for values in [single, stripped]: - np.testing.assert_array_equal( - values["is_benefit_cap_exempt_other"], - exempt_other_from_inputs(families, values, ["claimant"]), - err_msg=f"is_benefit_cap_exempt_other: {families}", - ) + check_cap_exceptions_against_inputs(families, values, ["claimant"]) off_route = off_pension_age_route(single, stripped) np.testing.assert_array_equal( single["is_benefit_cap_exempt_other"][off_route], @@ -631,11 +636,7 @@ def test_a_flag_marking_no_single_claim_leaves_shared_rules_alone(families, year (alone_flagged, ["claimant"]), (solo, ["claimant"]), ]: - np.testing.assert_array_equal( - values["is_benefit_cap_exempt_other"], - exempt_other_from_inputs(families, values, members), - err_msg=f"is_benefit_cap_exempt_other: {families}", - ) + check_cap_exceptions_against_inputs(families, values, members) for flagged, unflagged in [(both, joint), (alone_flagged, solo)]: off_route = off_pension_age_route(flagged, unflagged) for variable in shared: @@ -674,11 +675,11 @@ def test_examples_reach_the_cases(): assert not single["is_benefit_cap_exempt_health_disability"][family] assert joint["is_benefit_cap_exempt_health_disability"][family] for family in [i["afcs_partner"], i["esa_partner"]]: - assert not single["is_benefit_cap_exempt_other"][family] - assert joint["is_benefit_cap_exempt_other"][family] assert not single["is_benefit_cap_exempt_health_disability"][family] assert joint["is_benefit_cap_exempt_health_disability"][family] - assert single["is_benefit_cap_exempt_other"][i["afcs_claimant"]] + # The AFCS and ESA heads are not age exceptions. + assert not joint["is_benefit_cap_exempt_other"][family] + assert single["is_benefit_cap_exempt_health_disability"][i["afcs_claimant"]] # Reg. 80A(2) and Sch 4 para 28(2): single rate and shared accommodation. family = i["young_renter"] assert single["is_benefit_cap_single_claimant_rate"][family] diff --git a/policyengine_uk/utils/benefit_cap.py b/policyengine_uk/utils/benefit_cap.py new file mode 100644 index 0000000000..6c9e25989d --- /dev/null +++ b/policyengine_uk/utils/benefit_cap.py @@ -0,0 +1,24 @@ +"""Who the benefit cap reads, for the Universal Credit and Housing Benefit caps.""" + +from policyengine_core.model_api import where + + +def benefit_cap_couple(benunit, period): + """The single person or couple whose benefits and circumstances the cap reads. + + The model applies one cap to Universal Credit and Housing Benefit, and + each scheme names its own couple. For a family on Universal Credit it is + the claimants, the members of the couple whose income the award assesses + (is_uc_assessed_claimant): a single claimant or joint claimants, and the + other member of a couple where one member claims as a single person + (UC Regs 2013 reg 78(2)). Otherwise it is Housing Benefit's claimant and + partner (is_claimant_or_partner; HB Regs 2006 regs 2(1), 75A). The two + are the same unless is_uc_claimant is entered. + """ + person = benunit.members + on_universal_credit = benunit("universal_credit_pre_benefit_cap", period) > 0 + return where( + benunit.project(on_universal_credit), + person("is_uc_assessed_claimant", period), + person("is_claimant_or_partner", period), + ) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 80e936c34b..b20b7a80b6 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -1,5 +1,7 @@ """Benefit-unit aggregation helpers for means tests.""" +import numpy as np + def add_for_members(benunit, period, variables, members): """Sum variables over the benefit-unit members a means test counts. @@ -22,3 +24,42 @@ def add_for_members(benunit, period, variables, members): else: total = total + benunit(name, period) return total + + +def award_of_members(benunit, period, award, report, award_on_reports, members): + """The part of the benefit-unit award ``award`` that ``members`` claim. + + ``report`` is the person-level report behind the award and + ``award_on_reports(benunit, period, reported)`` the award the formula of + ``award`` gives on a reported amount. Which awards ``award`` holds is + decided by value: when it holds the award its formula gives on all + reported awards, this is the same award on the reports of ``members``; + when it holds the plain total of the reports (the + disable_simulated_benefits reform), this is the plain total of their + reports; when it holds anything else (an award entered directly, or a + reform that replaces it), that value is taken to be theirs. Values are + compared to within half a penny after rounding to the precision + ``award`` is stored in. A stored zero is zero. + """ + person = benunit.members + stored_award = benunit(award, period) + reported = person(report, period) + reported_total = benunit.sum(reported) + members_reported = benunit.sum(reported * members) + award_on_all_reports = award_on_reports(benunit, period, reported_total) + award_on_members_reports = award_on_reports(benunit, period, members_reported) + # Compare in the precision the award is stored in (float32), so the + # formula's own award always matches the award recomputed here. + stored = stored_award.dtype + as_formula = np.isclose( + stored_award, award_on_all_reports.astype(stored), rtol=0, atol=0.005 + ) + as_reported_total = np.isclose( + stored_award, reported_total.astype(stored), rtol=0, atol=0.005 + ) + scoped = np.where( + as_formula, + award_on_members_reports, + np.where(as_reported_total, members_reported, stored_award), + ) + return np.where(stored_award > 0, scoped, 0) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 3e7d34caf7..8e2f4a2aee 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -33,6 +33,8 @@ "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", + "uc_carer_element", + "uc_LCWRA_element", } ) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py index 78b786c811..e67feb233b 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_cap import benefit_cap_couple +from policyengine_uk.utils.benefit_unit import award_of_members +from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award # Welfare benefits (Welfare Reform Act 2012 s.96(10)) the model has. The cap # counts those "to which the single person or couple is entitled" (UC Regs @@ -15,9 +19,7 @@ FAMILY_BENEFITS = [ "child_benefit", "child_tax_credit", - "claimant_or_partner_jsa_income", "income_support", - "claimant_or_partner_esa_income", "universal_credit_pre_benefit_cap", "housing_benefit_pre_benefit_cap", ] @@ -27,10 +29,14 @@ class benefit_cap_reduction(Variable): label = "benefit cap reduction" documentation = ( "The amount by which the welfare benefits the claimant or couple is " - "entitled to exceed the benefit cap. Benefits another member of the " - "benefit unit claims in their own right do not count. Maternity " - "allowance and the bereavement benefits, also welfare benefits, are " - "not modelled here." + "entitled to exceed the benefit cap. For a family on Universal Credit " + "the couple is its claimants (is_uc_assessed_claimant), including the " + "other member of a couple where one member claims as a single person; " + "otherwise it is the claimant and partner (is_claimant_or_partner). " + "The two are the same unless is_uc_claimant is entered. Benefits " + "another member of the benefit unit claims in their own right do not " + "count. Maternity allowance and the bereavement benefits, also welfare " + "benefits, are not modelled here." ) entity = BenUnit definition_period = YEAR @@ -38,24 +44,36 @@ class benefit_cap_reduction(Variable): unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2012/5/section/96", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/78", "https://www.legislation.gov.uk/uksi/2013/376/regulation/80", "https://www.legislation.gov.uk/uksi/2006/213/regulation/75A", ) def formula(benunit, period, parameters): - # The claimant and partner (HB Regs 2006 reg 2(1)), or the single - # claimant or joint claimants of a Universal Credit award - # (is_uc_assessed_claimant, WRA 2012 s.40), which include the other - # member of a couple claiming as a single person (UC Regs 2013 reg - # 78(2)). The two sets are the same unless is_uc_claimant is entered. - person = benunit.members - claimant = person("is_claimant_or_partner", period) | person( - "is_uc_assessed_claimant", period + # The couple of the scheme the cap reduces (benefit_cap_couple). + couple = benefit_cap_couple(benunit, period) + # Income-based JSA and income-related ESA are awarded to the benefit + # unit; the couple's part is read from their reports, as in + # claimant_or_partner_jsa_income and claimant_or_partner_esa_income. + income_based = award_of_members( + benunit, + period, + "jsa_income", + "jsa_income_reported", + income_related_jsa_award, + couple, + ) + award_of_members( + benunit, + period, + "esa_income", + "esa_income_reported", + income_related_esa_award, + couple, ) - capped = add_for_members( + capped = income_based + add_for_members( benunit, period, CLAIMANT_OR_PARTNER_BENEFITS + FAMILY_BENEFITS, - claimant, + couple, ) return max_(capped - benunit("benefit_cap", period), 0) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index ae5dabed94..6a8c8c3ff1 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_unit import award_of_members from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award @@ -36,29 +37,11 @@ class claimant_or_partner_esa_income(Variable): ) def formula(benunit, period, parameters): - person = benunit.members - esa_income = benunit("esa_income", period) - reported = person("esa_income_reported", period) - reported_total = benunit.sum(reported) - claimant_or_partner_reported = benunit.sum( - reported * person("is_claimant_or_partner", period) + return award_of_members( + benunit, + period, + "esa_income", + "esa_income_reported", + income_related_esa_award, + benunit.members("is_claimant_or_partner", period), ) - award_on_all_reports = income_related_esa_award(benunit, period, reported_total) - award_on_claimant_or_partner_reports = income_related_esa_award( - benunit, period, claimant_or_partner_reported - ) - # Compare in the precision esa_income is stored in (float32), so the - # formula's own award always matches the award recomputed here. - stored = esa_income.dtype - as_formula = np.isclose( - esa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 - ) - as_reported_total = np.isclose( - esa_income, reported_total.astype(stored), rtol=0, atol=0.005 - ) - scoped = where( - as_formula, - award_on_claimant_or_partner_reports, - where(as_reported_total, claimant_or_partner_reported, esa_income), - ) - return where(esa_income > 0, scoped, 0) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index b528c41a52..9efb7b710d 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_unit import award_of_members from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award @@ -36,29 +37,11 @@ class claimant_or_partner_jsa_income(Variable): ) def formula(benunit, period, parameters): - person = benunit.members - jsa_income = benunit("jsa_income", period) - reported = person("jsa_income_reported", period) - reported_total = benunit.sum(reported) - claimant_or_partner_reported = benunit.sum( - reported * person("is_claimant_or_partner", period) + return award_of_members( + benunit, + period, + "jsa_income", + "jsa_income_reported", + income_related_jsa_award, + benunit.members("is_claimant_or_partner", period), ) - award_on_all_reports = income_related_jsa_award(benunit, period, reported_total) - award_on_claimant_or_partner_reports = income_related_jsa_award( - benunit, period, claimant_or_partner_reported - ) - # Compare in the precision jsa_income is stored in (float32), so the - # formula's own award always matches the award recomputed here. - stored = jsa_income.dtype - as_formula = np.isclose( - jsa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 - ) - as_reported_total = np.isclose( - jsa_income, reported_total.astype(stored), rtol=0, atol=0.005 - ) - scoped = where( - as_formula, - award_on_claimant_or_partner_reports, - where(as_reported_total, claimant_or_partner_reported, jsa_income), - ) - return where(jsa_income > 0, scoped, 0) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 0fb360e1de..a72c296193 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -1,4 +1,6 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_cap import benefit_cap_couple +from policyengine_uk.utils.supplied_inputs import supplied_input from policyengine_uk.utils.uc_work_related_requirements import ( other_member_of_single_claim_in_shared_rules, ) @@ -60,7 +62,9 @@ class is_benefit_cap_exempt_health_disability(Variable): "non-dependent adult, does not lift the cap with their own benefit, " "and nor does a partner who cannot be a joint claimant, so that the " "other member claims Universal Credit as a single person (UC Regs " - "2013 reg 3(3)). " + "2013 reg 3(3)). For a family on Universal Credit the claimant and " + "partner are the award's claimants, otherwise Housing Benefit's " + "claimant and partner (benefit_cap_couple). " "The model applies one cap to Universal Credit and Housing Benefit, so " "an exception in either scheme counts: Universal Credit's LCWRA and " "carer elements, and Housing Benefit's wider young-person tests." @@ -76,13 +80,10 @@ class is_benefit_cap_exempt_health_disability(Variable): def formula(benunit, period, parameters): person = benunit.members - # The claimant and partner (HB Regs 2006 reg 2(1)); the single - # claimant or each of joint claimants (Welfare Reform Act 2012 s.40; - # is_uc_assessed_claimant). The two sets are the same unless - # is_uc_claimant is entered. - claimant_or_partner = person("is_claimant_or_partner", period) | person( - "is_uc_assessed_claimant", period - ) + # The couple of the scheme the cap reduces (benefit_cap_couple): the + # claimants of a Universal Credit award, or Housing Benefit's + # claimant and partner (HB Regs 2006 reg 2(1)). + claimant_or_partner = benefit_cap_couple(benunit, period) # UC reg 83(1) reads "a claimant". A partner who cannot be a joint # claimant, so that the other member claims Universal Credit as a # single person (reg 3(3)), is a member of the couple but not a @@ -153,17 +154,28 @@ def received_by(benefits, members): # responsibilities (UC Regs 2013 regs 27(1), 29(1)); the model does # not have the reg 28 waiting period or regs 29(5)-(6), 30(3). Where # reg 29(4) leaves a carer with limited capability only the LCWRA - # element, that element exempts. An element is taken as entered - # directly, and so included, when it is positive although no member's - # circumstances give it (decided by value, as for esa_income in - # claimant_or_partner_esa_income). - lcwra = person("uc_limited_capability_for_WRA", period) - lcwra_element = benunit.any(claimant & lcwra) | ( - (benunit("uc_LCWRA_element", period) > 0) & ~benunit.any(lcwra) + # element, that element exempts. An element entered directly is + # included whatever the members' circumstances: one supplied as an + # input with a positive amount, or one that is positive although no + # member's circumstances give it (decided by value, for a reform that + # replaces the element's formula). + def element_included(element, circumstance): + amount = benunit(element, period) + entered = supplied_input(benunit, element, period) + entered_positive = ( + np.zeros_like(amount, dtype=bool) if entered is None else entered > 0 + ) + return ( + benunit.any(claimant & circumstance) + | entered_positive + | ((amount > 0) & ~benunit.any(circumstance)) + ) + + lcwra_element = element_included( + "uc_LCWRA_element", person("uc_limited_capability_for_WRA", period) ) - carer = person("is_carer_for_benefits", period) - carer_element = benunit.any(claimant & carer) | ( - (benunit("uc_carer_element", period) > 0) & ~benunit.any(carer) + carer_element = element_included( + "uc_carer_element", person("is_carer_for_benefits", period) ) # HB reg 75E(2): the claimant is, or the couple are jointly, entitled From e756d1b9141a86fa212d50fd0735ebf82f1e3948 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 16:05:11 -0400 Subject: [PATCH 09/12] Answer review r2: the ESA head and the cap rate read the scheme's couple; entered and reform elements by value Review job 20261010-134818-benefit-cap-75f-review-after-merge-r2 on a6a3073a5 (REQUEST_CHANGES): 1. The ESA head read is_on_income_related_esa, whose award and payee are Housing Benefit's claimant and partner, while the capped total read the scheme's couple. The head now takes the couple's income-related award from their reports (award_of_members, as the total does) and its payee within that couple (is_payee_of_couple_award, which now accepts a couple mask; its default is unchanged). 2. is_benefit_cap_single_claimant_rate counted is_couple (two of is_claimant_or_partner), so entered joint UC claimants got the single limit. It now counts benefit_cap_couple, which gives is_couple's answer for every family whose is_uc_claimant is not entered (UC Regs 2013 reg 80A(2)(d)). The capped-total fixtures now expect 22,020. 3. An element a reform's formula gives was read through the members' circumstances whenever any member had them. Elements are now decided by value against what their own formula gives: uc_LCWRA_element and uc_carer_element compute through lcwra_element_from_circumstances and carer_element_from_circumstances, which the exception calls too. The formula's amount is a claimant's element when a claimant has the circumstance; any other amount is included when positive. 4. An element entered for the award decides alone: an entry of nil is not included though a claimant has the circumstance (reg 83(1)(a), (j)). The property reference reads entries the same way. 5. No blank line at the end of benefit_cap_capped_total.yaml. Tests: a YAML case for an LCWRA element entered as nil beside the claimant's limited capability; a pytest for each element under a reform that gives 6,000 beside another member's circumstance, and nil beside the claimant's. Co-Authored-By: Claude Opus 5.5 --- .../benefit_cap/benefit_cap_capped_total.yaml | 23 ++--- .../benefit_cap_specified_benefits.yaml | 22 +++++ ...nefit_cap_specified_benefits_properties.py | 84 +++++++++++++++++-- .../variables/gov/dwp/_legacy_award_payee.py | 10 ++- ...is_benefit_cap_exempt_health_disability.py | 81 +++++++++++++----- .../is_benefit_cap_single_claimant_rate.py | 13 ++- .../carer_element/uc_carer_element.py | 31 ++++--- .../limited_work_ability/uc_LCWRA_element.py | 26 +++--- 8 files changed, 222 insertions(+), 68 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml index 9958dcdfff..25dc353dba 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -166,7 +166,7 @@ benunits: family: members: [claimant, adult] - universal_credit_pre_benefit_cap: 20_000 + universal_credit_pre_benefit_cap: 25_000 housing_benefit_pre_benefit_cap: 0 households: home: @@ -174,9 +174,11 @@ region: NORTH_EAST output: is_uc_assessed_claimant: [true, true] - # 20,000 + 2,000 - 14,753 (the cap rate follows is_couple, which reads - # the claimant and partner). - benefit_cap_reduction: 7_247 + # Joint claimants outside London: 22,020 (UC Regs 2013 reg 80A(2)(d)(i)). + is_benefit_cap_single_claimant_rate: false + benefit_cap: 22_020 + # 25,000 + 2,000 - 22,020. + benefit_cap_reduction: 4_980 - name: On Universal Credit, an entered joint claimant's own income-based JSA is in the capped total period: 2025 @@ -195,7 +197,7 @@ benunits: family: members: [claimant, adult] - universal_credit_pre_benefit_cap: 20_000 + universal_credit_pre_benefit_cap: 25_000 housing_benefit_pre_benefit_cap: 0 households: home: @@ -204,8 +206,9 @@ output: jsa_income: 2_000 claimant_or_partner_jsa_income: 0 - # 20,000 + 2,000 - 14,753. - benefit_cap_reduction: 7_247 + benefit_cap: 22_020 + # 25,000 + 2,000 - 22,020. + benefit_cap_reduction: 4_980 - name: On Housing Benefit alone, the same adult's contributory JSA is not in the capped total period: 2025 @@ -231,7 +234,7 @@ members: [claimant, adult] region: NORTH_EAST output: - # The adult is not the claimant's partner (HB Regs 2006 reg 75A): - # 15,000 - 14,753. + # The adult is not the claimant's partner (HB Regs 2006 reg 75A), so + # the single rate and none of the adult's benefits: 15,000 - 14,753. + benefit_cap: 14_753 benefit_cap_reduction: 247 - diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index b656ead3d6..6219fed13d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -1264,6 +1264,28 @@ is_benefit_cap_exempt_health_disability: true benefit_cap_reduction: 0 +- name: An LCWRA element entered as nil means the award does not include it, though the claimant has limited capability + period: 2025 + input: + people: + claimant: + age: 40 + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant] + uc_LCWRA_element: 0 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # Reg 83(1)(a) asks whether the element is included in the award (for + # example, not during the reg 28 waiting period). 18,000 - 14,753. + is_benefit_cap_exempt_health_disability: false + benefit_cap_reduction: 3_247 + - name: A non-dependant's limited capability does not lift the cap when no element is entered period: 2025 input: 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 c84ce75166..9fe0f2be09 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -16,8 +16,9 @@ (75F(1)(ea); UC counts it as attendance allowance, UC Regs 2013 reg 2); - a Universal Credit claimant has limited capability for work and work-related activity or caring responsibilities, so the award includes the - LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)), or the element is - entered for the award, whatever anyone's circumstances. A person with + LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)). An element + entered for the award decides it alone: included when positive, whatever + anyone's circumstances, and not included when entered as nil. A person with ESA of their own whose ESA lacks the support component is inferred not to have limited capability for work-related activity (reg 40(1)(a)(ii)); - the claimant or couple is entitled to working tax credit (HB reg 75E(2)). @@ -64,7 +65,10 @@ from hypothesis import HealthCheck, event, given, settings from hypothesis import strategies as st +from policyengine_core.periods import YEAR as YEAR_PERIOD +from policyengine_core.reforms import Reform from policyengine_uk import Simulation +from policyengine_uk.model_api import BenUnit, Variable YEAR = 2025 SINGLE_CAP, FAMILY_CAP = 14_753, 22_020 # Outside London, 2025-26. @@ -282,6 +286,14 @@ def carer(m): def any_receives(people, benefits): return any(m[b] > 0 for m in people for b in benefits) + def element_included(element, circumstance): + # Reg 83(1)(a), (j): the element is included in the award. An element + # entered for the award decides it; otherwise a claimant's + # circumstances give it (regs 27(1), 29(1)). + if element in benunit_inputs: + return benunit_inputs[element] > 0 + return any(circumstance(m) for m in claimants) + return ( any(receives_esa_with_support_component(m) for m in claimants) or any_receives(claimants, CLAIMANT_OR_PARTNER) @@ -289,10 +301,8 @@ def any_receives(people, benefits): or any_receives(claimants + young_persons, ["pip_dl"]) or any(entitled_to_carer_benefit(m) for m in claimants + young_persons) or any_receives(claimants + hb_young_persons, HOUSING_BENEFIT_YOUNG_PERSON) - or any(lcwra(m) for m in claimants) - or any(carer(m) for m in claimants) - # Reg 83(1)(a), (j): an element entered for the award is included. - or any(benunit_inputs.get(element, 0) > 0 for element in ENTERED_ELEMENTS) + or element_included("uc_LCWRA_element", lcwra) + or element_included("uc_carer_element", carer) or benunit_inputs["working_tax_credit"] > 0 ) @@ -432,3 +442,65 @@ def test_each_head_alone_matches_regulations(mode): # Every role meets every circumstance, and both outcomes occur. assert len(units) == len(SINGLE_HEAD_ROLES) * len(CIRCUMSTANCES) assert exempt.any() and not exempt.all() + + +def element_reform(element, amount): + """A reform whose formula for ``element`` gives ``amount`` to every family.""" + replacement = type( + element, + (Variable,), + { + "value_type": float, + "entity": BenUnit, + "definition_period": YEAR_PERIOD, + "label": f"{element} (test reform)", + "formula": lambda benunit, period, parameters: benunit.filled_array(amount), + }, + ) + + class ReplaceElement(Reform): + def apply(self): + self.update_variable(replacement) + + return ReplaceElement + + +# The circumstance that gives each element (UC Regs 2013 regs 27(1), 29(1)). +# The carer flag is set directly, with no hours of care, so that no +# entitlement to Carer's Allowance lifts the cap by another route. +ELEMENT_CIRCUMSTANCES = { + "uc_LCWRA_element": "uc_limited_capability_for_WRA", + "uc_carer_element": "is_carer_for_benefits", +} + + +@pytest.mark.parametrize("element", sorted(ELEMENT_CIRCUMSTANCES)) +def test_an_element_a_reform_gives_is_decided_by_its_value(element): + """Reg 83(1)(a), (j): an element a reform's formula puts in the award lifts + the cap whatever another member's circumstances, and a reform that gives + nil removes the exception the claimant's circumstance would give.""" + circumstance = ELEMENT_CIRCUMSTANCES[element] + claimant, other = blank("claimant", 40), blank("other", 30) + other[circumstance] = True + positive = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})]), + reform=element_reform(element, 6_000), + ) + assert positive.calculate(element, YEAR)[0] == 6_000 + assert positive.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] + + claimant = blank("claimant", 40) + claimant[circumstance] = True + nil = Simulation( + situation=situation([([claimant], {"working_tax_credit": 0})]), + reform=element_reform(element, 0), + ) + assert nil.calculate(element, YEAR)[0] == 0 + assert not nil.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] + # Without the reform the claimant's circumstance gives the element and + # lifts the cap. + baseline = Simulation( + situation=situation([([claimant], {"working_tax_credit": 0})]) + ) + assert baseline.calculate(element, YEAR)[0] > 0 + assert baseline.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 651d32fff1..c486072dd9 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -3,7 +3,7 @@ from policyengine_uk.model_api import * -def is_payee_of_couple_award(person, period, reported): +def is_payee_of_couple_award(person, period, reported, couple=None): """Whether the claimant's or partner's family award is payable to this person. A person is on income-based JSA or income-related ESA on a day the allowance "is payable to him" (HB Regs 2006 reg 2(3) and (3A)); a @@ -11,8 +11,12 @@ def is_payee_of_couple_award(person, period, reported): award is paid to the claimant, not the partner. The payee is the claimant or partner who reports the award, or the claimant where neither does (an award entered directly). Another member's report never moves - the payee.""" - claimant_or_partner = person("is_claimant_or_partner", period) + the payee. ``couple`` (a person-level mask) replaces the claimant and + partner (is_claimant_or_partner) where a rule reads another couple, such + as the benefit cap's (benefit_cap_couple).""" + claimant_or_partner = ( + person("is_claimant_or_partner", period) if couple is None else couple + ) reports = (person(reported, period) > 0) & claimant_or_partner head = claimant_or_partner & person("is_benunit_head", period) eldest = claimant_or_partner & ( diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index a72c296193..9e3ba62c44 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -1,9 +1,20 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_cap import benefit_cap_couple +from policyengine_uk.utils.benefit_unit import award_of_members from policyengine_uk.utils.supplied_inputs import supplied_input from policyengine_uk.utils.uc_work_related_requirements import ( other_member_of_single_claim_in_shared_rules, ) +from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( + is_payee_of_couple_award, +) +from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.universal_credit.carer_element.uc_carer_element import ( + carer_element_from_circumstances, +) +from policyengine_uk.variables.gov.dwp.universal_credit.disability_element.limited_work_ability.uc_LCWRA_element import ( + lcwra_element_from_circumstances, +) # HB Regs 2006 reg 75F(1) and UC Regs 2013 reg 83(1) name, for each benefit, # whose receipt (or entitlement) lifts the cap. @@ -140,42 +151,66 @@ def received_by(benefits, members): # partner is receiving an employment and support allowance ... which # includes a support component"; UC reg 83(1)(a) "the claimant". # Contributory and income-related allowances both carry it; another - # member's allowance does not count. - receiving_esa = (person("esa_contrib", period) > 0) | person( - "is_on_income_related_esa", period + # member's allowance does not count. The couple's income-related + # award is the part of the family's award on the couple's reports, as + # in claimant_or_partner_esa_income, and it is received by its payee + # (HB Regs 2006 reg 2(3A); is_on_income_related_esa reads the same for + # Housing Benefit's claimant and partner). + couple_esa_award = award_of_members( + benunit, + period, + "esa_income", + "esa_income_reported", + income_related_esa_award, + claimant_or_partner, + ) + receiving_income_related_esa = benunit.project( + couple_esa_award > 0 + ) & is_payee_of_couple_award( + person, period, "esa_income_reported", couple=claimant_or_partner + ) + receiving_esa = (person("esa_contrib", period) > 0) | ( + receiving_income_related_esa ) esa_support_component = benunit.any( claimant & receiving_esa & person("esa_includes_support_component", period) ) # UC reg 83(1)(a), (j): the LCWRA or carer element is included in the - # award. For calculated elements that is a claimant who has limited - # capability for work and work-related activity or caring - # responsibilities (UC Regs 2013 regs 27(1), 29(1)); the model does - # not have the reg 28 waiting period or regs 29(5)-(6), 30(3). Where - # reg 29(4) leaves a carer with limited capability only the LCWRA - # element, that element exempts. An element entered directly is - # included whatever the members' circumstances: one supplied as an - # input with a positive amount, or one that is positive although no - # member's circumstances give it (decided by value, for a reform that - # replaces the element's formula). - def element_included(element, circumstance): + # award. An element entered for the award is included exactly when it + # is positive, whatever the members' circumstances (an entry of nil + # means the award does not include it). Otherwise the element is + # decided by value. Where it is what its own formula gives on the + # members' circumstances, it is a claimant's element when a claimant + # has limited capability for work and work-related activity or + # caring responsibilities (UC Regs 2013 regs 27(1), 29(1)): the + # formula still reads every member, so another member's flag would + # otherwise give it. Any other amount (a reform that replaces the + # formula) is included when positive. The model does not have the + # reg 28 waiting period or regs 29(5)-(6), 30(3). Where reg 29(4) + # leaves a carer with limited capability only the LCWRA element, that + # element exempts. + def element_included(element, circumstance, from_circumstances): amount = benunit(element, period) - entered = supplied_input(benunit, element, period) - entered_positive = ( - np.zeros_like(amount, dtype=bool) if entered is None else entered > 0 + calculated = from_circumstances(benunit, period, parameters) + as_formula = np.isclose( + amount, calculated.astype(amount.dtype), rtol=0, atol=0.005 ) - return ( - benunit.any(claimant & circumstance) - | entered_positive - | ((amount > 0) & ~benunit.any(circumstance)) + included = where( + as_formula, benunit.any(claimant & circumstance), amount > 0 ) + entered = supplied_input(benunit, element, period) + return included if entered is None else entered > 0 lcwra_element = element_included( - "uc_LCWRA_element", person("uc_limited_capability_for_WRA", period) + "uc_LCWRA_element", + person("uc_limited_capability_for_WRA", period), + lcwra_element_from_circumstances, ) carer_element = element_included( - "uc_carer_element", person("is_carer_for_benefits", period) + "uc_carer_element", + person("is_carer_for_benefits", period), + carer_element_from_circumstances, ) # HB reg 75E(2): the claimant is, or the couple are jointly, entitled diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py index 793001b342..bb8b1bb8be 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_cap import benefit_cap_couple from policyengine_uk.utils.uc_work_related_requirements import ( single_claim_in_rules_shared_with_legacy_benefits, ) @@ -12,7 +13,10 @@ class is_benefit_cap_single_claimant_rate(Variable): "Whether the claimant is a single claimant, not responsible for a " "child or young person. The model uses one benefit cap for Universal " "Credit and Housing Benefit, so the family rate applies if either " - "scheme's child or young-person test is met. Couple status stands for " + "scheme's child or young-person test is met. The couple is the " + "scheme's (benefit_cap_couple): a Universal Credit award's two " + "claimants, otherwise Housing Benefit's claimant and partner, which " + "is is_couple's test. Couple status stands for " "joint-claim status, except that a member of a couple who claims " "Universal Credit as a single person (regulation 3(3)) is a single " "claimant: regulation 80A sets the limits by single claimant and " @@ -33,9 +37,10 @@ class is_benefit_cap_single_claimant_rate(Variable): ) def formula(benunit, period, parameters): - single_claimant = ~benunit( - "is_couple", period - ) | single_claim_in_rules_shared_with_legacy_benefits(benunit, period) + couple = benunit.sum(benefit_cap_couple(benunit, period)) >= 2 + single_claimant = ~couple | single_claim_in_rules_shared_with_legacy_benefits( + benunit, period + ) return single_claimant & ~benunit( "is_responsible_for_child_or_young_person_for_uc_or_housing_benefit", period, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py index 1e5e36cb48..b7f53f95f5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py @@ -4,6 +4,24 @@ ) +def carer_element_from_circumstances(benunit, period, parameters): + """The carer element the members' circumstances give: what + uc_carer_element returns, its defined_for (benunit_has_carer) included.""" + p = parameters(period).gov.dwp.universal_credit.elements.carer + person = benunit.members + # The element is for "a claimant" with regular and substantial caring + # responsibilities (reg. 29(1)). A partner who cannot be a joint + # claimant, so that the other member claims as a single person + # (reg. 3(3)), is not a claimant: their caring gives no element. + not_a_claimant = other_member_of_single_claim(person, period) + carer = person("is_carer_for_benefits", period) + only_carer_is_not_a_claimant = benunit.any(carer & not_a_claimant) & ~benunit.any( + carer & ~not_a_claimant + ) + has_carer = benunit("benunit_has_carer", period) + return has_carer * ~only_carer_is_not_a_claimant * p.amount * MONTHS_IN_YEAR + + class uc_carer_element(Variable): value_type = float entity = BenUnit @@ -23,15 +41,4 @@ class uc_carer_element(Variable): ] def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.universal_credit.elements.carer - person = benunit.members - # The element is for "a claimant" with regular and substantial caring - # responsibilities (reg. 29(1)). A partner who cannot be a joint - # claimant, so that the other member claims as a single person - # (reg. 3(3)), is not a claimant: their caring gives no element. - not_a_claimant = other_member_of_single_claim(person, period) - carer = person("is_carer_for_benefits", period) - only_carer_is_not_a_claimant = benunit.any( - carer & not_a_claimant - ) & ~benunit.any(carer & ~not_a_claimant) - return ~only_carer_is_not_a_claimant * p.amount * MONTHS_IN_YEAR + return carer_element_from_circumstances(benunit, period, parameters) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py index f35e6103f1..4d2d38c560 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py @@ -4,6 +4,21 @@ ) +def lcwra_element_from_circumstances(benunit, period, parameters): + """The LCWRA element the members' circumstances give: what the formula of + uc_LCWRA_element returns.""" + p = parameters(period).gov.dwp.universal_credit.elements.disabled + person = benunit.members + limited_capability = person("uc_limited_capability_for_WRA", period) + # The element is for "a claimant" with limited capability for work and + # work-related activity (reg. 27(1)). A partner who cannot be a joint + # claimant, so that the other member claims as a single person + # (reg. 3(3)), is not a claimant. + not_a_claimant = other_member_of_single_claim(person, period) + person_amounts = (limited_capability & ~not_a_claimant) * p.amount + return benunit.sum(person_amounts) * MONTHS_IN_YEAR + + class uc_LCWRA_element(Variable): value_type = float entity = BenUnit @@ -22,13 +37,4 @@ class uc_LCWRA_element(Variable): ] def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.universal_credit.elements.disabled - person = benunit.members - limited_capability = person("uc_limited_capability_for_WRA", period) - # The element is for "a claimant" with limited capability for work and - # work-related activity (reg. 27(1)). A partner who cannot be a joint - # claimant, so that the other member claims as a single person - # (reg. 3(3)), is not a claimant. - not_a_claimant = other_member_of_single_claim(person, period) - person_amounts = (limited_capability & ~not_a_claimant) * p.amount - return benunit.sum(person_amounts) * MONTHS_IN_YEAR + return lcwra_element_from_circumstances(benunit, period, parameters) From 2f341de77a289227685212a3d6778379da5ea469 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 23:30:16 -0400 Subject: [PATCH 10/12] Answer review r3: include a UC element by the award, keep the rebalancing's elements the model's, and keep main's couple rate - is_benefit_cap_exempt_health_disability: a UC LCWRA or carer element is included in the award (UC Regs 2013 reg 83(1)(a), (j)) as follows: - an element entered for the award decides alone; - an element a reform's replacement formula gives is included when positive; - the model's own formula gives the element for any member's circumstance, so its element is included when positive unless only members who are not claimants have the circumstance (regs 27(1), 29(1)). A positive element given by the entered family flag benunit_has_carer is now included, and a nil one is not. A reform amount equal to the formula's is the reform's. calculated_by matches the formula by its code location, since the tax-benefit system loads each variable file as its own module. The element formulas are main's again. - scenarios/uc_reform.py: the Universal Credit rebalancing rescales uc_LCWRA_element for 2026-2029 in every simulation, with set_input. The cap read that as an entry, so another member's limited capability lifted the cap in 2026-2029 but not in 2025 or 2030. It now stores the element with utils/supplied_inputs.set_model_value. That still uses set_input, so the engine keeps the value when a reform is applied (core replays only recorded inputs), but it remembers the array it stored: supplied_input and supplied_input_periods do not return that value, unless the period was already supplied (a user's entry stays an entry). A later set_input of another value is supplied again. - is_benefit_cap_single_claimant_rate: is_couple decides, as on main (relation_type can be entered), unless the entered claimants of a family on Universal Credit are not Housing Benefit's claimant and partner. Then the award's claimants decide. - The cap's property test calls blank() with keywords: the code-health scan reads a call whose first argument is a string as a cached simulation read, and flagged the test's dict writes. - Tests: YAML cases for each finding and for 2026 (another member's limited capability, an entered element), the couple rate with an entered relation_type on HB and UC, a reform amount equal to the formula's, and the rebalancing's provenance. Co-Authored-By: Claude Opus 5.5 --- .../benefit-cap-75f-claimant-partner.fixed.md | 3 +- policyengine_uk/scenarios/uc_reform.py | 6 +- .../benefit_cap/benefit_cap_capped_total.yaml | 56 ++++++++++ .../benefit_cap_specified_benefits.yaml | 100 ++++++++++++++++++ ...nefit_cap_specified_benefits_properties.py | 61 +++++++++-- policyengine_uk/utils/supplied_inputs.py | 49 +++++++++ ...is_benefit_cap_exempt_health_disability.py | 70 +++++++----- .../is_benefit_cap_single_claimant_rate.py | 27 +++-- .../carer_element/uc_carer_element.py | 31 +++--- .../limited_work_ability/uc_LCWRA_element.py | 26 ++--- 10 files changed, 354 insertions(+), 75 deletions(-) diff --git a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md index 7916d32eb0..f3c46e56c9 100644 --- a/changelog.d/benefit-cap-75f-claimant-partner.fixed.md +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -1,6 +1,7 @@ - Lift the benefit cap for employment and support allowance only where the claimant's or partner's allowance includes the support component (HB Regs 2006 reg 75F(1)(a); UC Regs 2013 reg 83(1)(a)), for contributory and income-related ESA alike. Any income-related ESA, and any member's contributory ESA, used to lift it. Without other information the support component is assumed for a person with their own allowance, as for about nine in ten ESA recipients in the Family Resources Survey. -- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need a claimant with limited capability or caring responsibilities, or an element entered directly, which lifts the cap whatever the members' circumstances. A partner who cannot be a joint claimant (UC Regs 2013 reg 3(3)) lifts no cap with their own benefits. For a family on Universal Credit the claimant and partner are the award's claimants (`is_uc_assessed_claimant`), otherwise Housing Benefit's claimant and partner; the two are the same unless `is_uc_claimant` is entered. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap. +- Lift the benefit cap for a disability or carer benefit only when the person the regulations name receives it: the claimant or partner for attendance allowance, industrial injuries benefit and armed forces compensation; also a child or young person they are responsible for, for disability living allowance; also a young person, for personal independence payment, Carer's Allowance and Carer Support Payment, and a Housing Benefit young person for armed forces independence payment. A 16-year-old who receives benefits in their own right or is looked after is not a young person here. The Universal Credit LCWRA and carer exceptions now need the element in the award: an element entered directly, or given by a reform's formula, lifts the cap when positive whatever the members' circumstances, and the element the model calculates lifts it unless only members who are not claimants have limited capability or caring responsibilities. This holds in 2026-2029 too: the Universal Credit rebalancing's rescaled LCWRA element is the model's own, not an entered one (`utils/supplied_inputs.set_model_value`). A partner who cannot be a joint claimant (UC Regs 2013 reg 3(3)) lifts no cap with their own benefits. For a family on Universal Credit the claimant and partner are the award's claimants (`is_uc_assessed_claimant`), otherwise Housing Benefit's claimant and partner; the two are the same unless `is_uc_claimant` is entered. A non-dependent adult's disability and carer benefits, ESA, disability flag and caring no longer lift the family's cap. - Count armed forces independence payment towards the benefit cap exception. - Take a person whose own ESA lacks the support component not to have limited capability for work-related activity (`uc_limited_capability_for_WRA`), whatever their disability flag, since that determination is what gives ESA the support component and Universal Credit takes it (UC Regs 2013 reg 40(1)(a)(ii)). Datasets flag every ESA recipient as disabled and the support component is assumed, so this changes nothing in the data until a dataset reports the ESA group. - Give the Universal Credit work allowance and reduced minimum age their own limited capability for work test (`uc_limited_capability_for_work`, UC Regs 2013 regs 8(1)(a), 22, 39): limited capability for work-related activity or the disability flag, so an ESA recipient outside the support group keeps both. The work preparation group (Welfare Reform Act 2012 s.21(1)(a)) and the partner's exception to the childcare work condition (UC Regs 2013 reg 32(1)(b)(i)) read it too, so a claimant flagged as disabled without limited capability for work-related activity is in the work preparation group, outside the minimum income floor, and as a partner still lets a working claimant meet the childcare work condition. - Count in the benefit cap only the welfare benefits the claimant or couple is entitled to (UC Regs 2013 reg 80(1); HB Regs 2006 reg 75A): the claimant's and partner's contributory ESA, JSA, incapacity benefit and SDA, their income-related ESA and income-based JSA (`claimant_or_partner_esa_income`, `claimant_or_partner_jsa_income`), and the family's Child Benefit, CTC, Income Support, Universal Credit and Housing Benefit. For a family on Universal Credit the couple is the award's claimants, including the other member of a couple where one member claims as a single person (UC Regs 2013 reg 78(2)); otherwise it is the claimant and partner. Another member's own award no longer reduces the family's Universal Credit or Housing Benefit; it still counts in household income. +- Give the benefit cap's single or couple limit by the award's claimants where the claimants of a family on Universal Credit are entered (`is_uc_claimant`) and are not Housing Benefit's claimant and partner (UC Regs 2013 reg 80A); otherwise `is_couple` decides, as before. diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 459e6c18b9..7500c4e8a5 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import Scenario from policyengine_uk import Microsimulation +from policyengine_uk.utils.supplied_inputs import set_model_value import numpy as np @@ -126,7 +127,10 @@ def add_universal_credit_reform(sim: Microsimulation): 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) + # Stored as an input, so that reforms keep it, but still the model's + # own element unless one was entered (the benefit cap's LCWRA + # exception reads entries differently). + set_model_value(sim, "uc_LCWRA_element", year, current_health_element) # Standard allowance uplift is handled in the formula itself so user # reforms to the base amount are applied before the uplift. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml index 25dc353dba..2cccf22483 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -238,3 +238,59 @@ # the single rate and none of the adult's benefits: 15,000 - 14,753. benefit_cap: 14_753 benefit_cap_reduction: 247 + +# The rate's couple: is_couple (relation_type, which can be entered), unless +# the entered claimants of a Universal Credit award are other members. + +- name: On Housing Benefit alone, a couple entered as such has the couple limit though the model would presume the younger member a child + period: 2025 + input: + people: + claimant: + age: 40 + partner: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [claimant, partner] + relation_type: COUPLE + universal_credit_pre_benefit_cap: 0 + housing_benefit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_claimant_or_partner: [true, false] + is_couple: true + # A couple outside London: 22,020, as is_couple gives. + is_benefit_cap_single_claimant_rate: false + benefit_cap: 22_020 + benefit_cap_reduction: 0 + +- name: On Universal Credit, a couple entered as such has the couple limit when no claimant is entered + period: 2025 + input: + people: + claimant: + age: 40 + partner: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [claimant, partner] + relation_type: COUPLE + universal_credit_pre_benefit_cap: 18_000 + housing_benefit_pre_benefit_cap: 0 + households: + home: + members: [claimant, partner] + region: NORTH_EAST + output: + is_couple: true + # Joint claimants outside London: 22,020 (UC Regs 2013 reg 80A(2)(d)(i)). + is_benefit_cap_single_claimant_rate: false + benefit_cap: 22_020 + benefit_cap_reduction: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 6219fed13d..a7171a05e5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -1314,6 +1314,106 @@ is_benefit_cap_exempt_health_disability: false benefit_cap_reduction: 3_247 +- name: From 2026 too, a non-dependant's limited capability does not lift the cap when no element is entered + period: 2026 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: false + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # The Universal Credit rebalancing rescales the element the model + # calculates from the adult's flag (scenarios/uc_reform.py); it is still + # not a claimant's element (reg 27(1)). 18,000 - 14,753. + is_benefit_cap_exempt_health_disability: false + benefit_cap_reduction: 3_247 + +- name: From 2026 too, an LCWRA element entered for the award lifts the cap whatever a non-dependant's limited capability + period: 2026 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + uc_limited_capability_for_WRA: false + adult: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + uc_limited_capability_for_WRA: true + benunits: + family: + members: [claimant, adult] + uc_LCWRA_element: 5_157.60 + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + # The rebalancing may rescale an entered element, but it stays the + # award's (reg 83(1)(a)). + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: A carer element the family's entered carer flag gives lifts the cap, though no member is flagged + period: 2025 + input: + people: + claimant: + age: 40 + benunits: + family: + members: [claimant] + benunit_has_carer: true + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # The award includes the carer element (reg 83(1)(j)), and no member who + # is not a claimant has caring responsibilities to give it instead. + is_benefit_cap_exempt_health_disability: true + benefit_cap_reduction: 0 + +- name: A nil carer element is not included in the award, though the claimant cares + period: 2025 + input: + people: + claimant: + age: 40 + is_carer_for_benefits: true + benunits: + family: + members: [claimant] + benunit_has_carer: false + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # Reg 83(1)(j) asks whether the award includes the element; the + # family's entered flag gives none. 18,000 - 14,753. + uc_carer_element: 0 + is_benefit_cap_exempt_health_disability: false + benefit_cap_reduction: 3_247 + # --- Working tax credit: HB reg 75E(2) --- - name: The family's working tax credit lifts the cap 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 9fe0f2be09..9ffc201492 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -18,7 +18,10 @@ work-related activity or caring responsibilities, so the award includes the LCWRA or carer element (83(1)(a), (j); regs 27(1), 29(1)). An element entered for the award decides it alone: included when positive, whatever - anyone's circumstances, and not included when entered as nil. A person with + anyone's circumstances, and not included when entered as nil. So does the + amount of an element a reform's formula gives, and the model's own + element is included when positive unless only members who are not + claimants have the circumstance. A person with ESA of their own whose ESA lacks the support component is inferred not to have limited capability for work-related activity (reg 40(1)(a)(ii)); - the claimant or couple is entitled to working tax credit (HB reg 75E(2)). @@ -66,9 +69,11 @@ from hypothesis import strategies as st from policyengine_core.periods import YEAR as YEAR_PERIOD +from policyengine_core.periods import period from policyengine_core.reforms import Reform from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable +from policyengine_uk.utils.supplied_inputs import supplied_input YEAR = 2025 SINGLE_CAP, FAMILY_CAP = 14_753, 22_020 # Outside London, 2025-26. @@ -160,9 +165,9 @@ def families(draw, supplied, enter_elements): """A claimant, an optional partner, up to three dependants and a non-dependent adult, with at most two circumstances among them, and the family's benefit-unit inputs.""" - members = [blank("claimant", draw(st.integers(25, 60)))] + members = [blank(role="claimant", age=draw(st.integers(25, 60)))] if draw(st.booleans()): - members.append(blank("claimant", draw(st.integers(25, 60)))) + members.append(blank(role="claimant", age=draw(st.integers(25, 60)))) for _ in range(draw(st.integers(0, 3))): role = draw(st.sampled_from(sorted(ROLES))) if role in ("child", "looked_after_child"): @@ -176,7 +181,9 @@ def families(draw, supplied, enter_elements): for member in members: if member["_role"] == "claimant": member["is_parent"] = has_dependants - other = blank("other", draw(st.one_of(st.integers(17, 60), st.integers(67, 80)))) + other = blank( + role="other", age=draw(st.one_of(st.integers(17, 60), st.integers(67, 80))) + ) other["employment_income"] = draw(st.sampled_from([0, 0, 0, 25_000])) event(f"other aged 67+: {other['age'] >= 67}") event(f"other earns: {other['employment_income'] > 0}") @@ -409,7 +416,7 @@ def single_head_units(mode): units = [] for role in SINGLE_HEAD_ROLES: for circumstance in CIRCUMSTANCES: - claimant = blank("claimant", 40) + claimant = blank(role="claimant", age=40) if role == "claimant": holder, members = claimant, [claimant] else: @@ -480,7 +487,7 @@ def test_an_element_a_reform_gives_is_decided_by_its_value(element): the cap whatever another member's circumstances, and a reform that gives nil removes the exception the claimant's circumstance would give.""" circumstance = ELEMENT_CIRCUMSTANCES[element] - claimant, other = blank("claimant", 40), blank("other", 30) + claimant, other = blank(role="claimant", age=40), blank(role="other", age=30) other[circumstance] = True positive = Simulation( situation=situation([([claimant, other], {"working_tax_credit": 0})]), @@ -488,8 +495,21 @@ def test_an_element_a_reform_gives_is_decided_by_its_value(element): ) assert positive.calculate(element, YEAR)[0] == 6_000 assert positive.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] + # A reform's amount that equals the model's own (here, what the model's + # formula gives for the other member's circumstance) is still the + # reform's. + model_amount = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})]) + ).calculate(element, YEAR)[0] + assert model_amount > 0 + same = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})]), + reform=element_reform(element, model_amount), + ) + assert same.calculate(element, YEAR)[0] == model_amount + assert same.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] - claimant = blank("claimant", 40) + claimant = blank(role="claimant", age=40) claimant[circumstance] = True nil = Simulation( situation=situation([([claimant], {"working_tax_credit": 0})]), @@ -504,3 +524,30 @@ def test_an_element_a_reform_gives_is_decided_by_its_value(element): ) assert baseline.calculate(element, YEAR)[0] > 0 assert baseline.calculate("is_benefit_cap_exempt_health_disability", YEAR)[0] + + +def test_the_rebalancing_adds_no_entry_and_keeps_one(): + """From 2026 the Universal Credit rebalancing rescales uc_LCWRA_element + (scenarios/uc_reform.py). An element it rescales from the model's own + formula is not an entry, so the other member's limited capability still + does not lift the cap; an element entered for the award stays entered.""" + year = 2026 + claimant, other = blank(role="claimant", age=40), blank(role="other", age=30) + other["uc_limited_capability_for_WRA"] = True + calculated = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})], year) + ) + assert calculated.calculate("uc_LCWRA_element", year)[0] > 0 + benunit = calculated.populations["benunit"] + assert supplied_input(benunit, "uc_LCWRA_element", period(year)) is None + assert not calculated.calculate("is_benefit_cap_exempt_health_disability", year)[0] + + entered = Simulation( + situation=situation( + [([claimant, other], {"working_tax_credit": 0, "uc_LCWRA_element": 5_000})], + year, + ) + ) + benunit = entered.populations["benunit"] + assert supplied_input(benunit, "uc_LCWRA_element", period(year)) is not None + assert entered.calculate("is_benefit_cap_exempt_health_disability", year)[0] diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 4fa7fa9aff..ef3e43ffeb 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -24,6 +24,7 @@ from typing import List, Optional import numpy as np +from policyengine_core import periods from policyengine_core.periods import Period @@ -98,6 +99,11 @@ def supplied_input_periods(population, variable_name: str) -> List[Period]: if name == variable_name and branch_name in branch_names and holder._get_array_from_storage(period, branch_name) is not None + and not _is_model_value( + simulation, + (name, branch_name, period), + holder._get_array_from_storage(period, branch_name), + ) }, key=lambda period: period.start, ) @@ -122,6 +128,12 @@ def supplied_input( # stored array must be ignored even if its key remains. value = holder._get_array_from_storage(period, branch_name) if value is not None: + # A value the model stored itself (set_model_value) is not + # supplied, though the engine keeps it like an input. + if _is_model_value( + simulation, (variable_name, branch_name, period), value + ): + return None # A neutralized variable reads as its default, as it does # through the engine. (PolicyEngine-UK builds no # gov.abolitions parameters, so there is no abolition switch @@ -131,3 +143,40 @@ def supplied_input( return holder.default_array() return value return None + + +def _is_model_value(simulation, key, value) -> bool: + model_values = getattr(simulation, "_model_values", None) or {} + written = model_values.get(key) + return written is not None and np.array_equal(written, value) + + +def set_model_value(simulation, variable_name: str, period, value) -> None: + """Store ``value`` for ``variable_name`` and ``period`` with ``set_input``, + as the model's own value rather than a supplied one, unless the period's + value was supplied already. + + ``set_input`` records the value as an input, and the engine keeps + recorded inputs when a reform is applied, so a scenario that adjusts a + calculated amount must store it that way. ``supplied_input`` and + ``supplied_input_periods`` do not return the stored value while it is + the one stored here; a later ``set_input`` of another value is supplied + again. If the value was supplied before (a scenario adjusting a user's + entry), the new value stays supplied.""" + _check_registered(variable_name) + period = periods.period(period) + population = simulation.get_variable_population(variable_name) + already_supplied = supplied_input(population, variable_name, period) is not None + simulation.set_input(variable_name, period, value) + if already_supplied: + return + branch_name = simulation.branch_name + stored = population.get_holder(variable_name)._get_array_from_storage( + period, branch_name + ) + if stored is not None: + model_values = getattr(simulation, "_model_values", None) + if model_values is None: + model_values = {} + simulation._model_values = model_values + model_values[(variable_name, branch_name, period)] = np.array(stored) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 9e3ba62c44..391673b3dc 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -1,3 +1,5 @@ +import os + from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_cap import benefit_cap_couple from policyengine_uk.utils.benefit_unit import award_of_members @@ -10,10 +12,10 @@ ) from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award from policyengine_uk.variables.gov.dwp.universal_credit.carer_element.uc_carer_element import ( - carer_element_from_circumstances, + uc_carer_element, ) from policyengine_uk.variables.gov.dwp.universal_credit.disability_element.limited_work_ability.uc_LCWRA_element import ( - lcwra_element_from_circumstances, + uc_LCWRA_element, ) # HB Regs 2006 reg 75F(1) and UC Regs 2013 reg 83(1) name, for each benefit, @@ -59,6 +61,21 @@ ] +def calculated_by(population, variable_name, period, model_variable): + """Whether the simulation calculates ``variable_name`` with the model's own + 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.""" + variable = population.simulation.tax_benefit_system.get_variable(variable_name) + formula = variable.get_formula(period) + code = getattr(formula, "__code__", None) + model = model_variable.formula.__code__ + return code is not None and ( + os.path.realpath(code.co_filename), + code.co_firstlineno, + ) == (os.path.realpath(model.co_filename), model.co_firstlineno) + + class is_benefit_cap_exempt_health_disability(Variable): value_type = bool entity = BenUnit @@ -179,38 +196,41 @@ def received_by(benefits, members): # UC reg 83(1)(a), (j): the LCWRA or carer element is included in the # award. An element entered for the award is included exactly when it # is positive, whatever the members' circumstances (an entry of nil - # means the award does not include it). Otherwise the element is - # decided by value. Where it is what its own formula gives on the - # members' circumstances, it is a claimant's element when a claimant - # has limited capability for work and work-related activity or - # caring responsibilities (UC Regs 2013 regs 27(1), 29(1)): the - # formula still reads every member, so another member's flag would - # otherwise give it. Any other amount (a reform that replaces the - # formula) is included when positive. The model does not have the - # reg 28 waiting period or regs 29(5)-(6), 30(3). Where reg 29(4) - # leaves a carer with limited capability only the LCWRA element, that - # element exempts. - def element_included(element, circumstance, from_circumstances): - amount = benunit(element, period) - calculated = from_circumstances(benunit, period, parameters) - as_formula = np.isclose( - amount, calculated.astype(amount.dtype), rtol=0, atol=0.005 - ) - included = where( - as_formula, benunit.any(claimant & circumstance), amount > 0 - ) + # means the award does not include it). An element a reform's formula + # gives is included when positive. The model's own formula still + # gives the element for any member's limited capability for work and + # work-related activity or caring responsibilities, but the element is + # for "a claimant" (UC Regs 2013 regs 27(1), 29(1)). So the element it + # gives is included when positive unless the only members with the + # circumstance are not claimants. A circumstance entered for the + # family alone (benunit_has_carer) names no member, and a nil amount + # (benunit_has_carer entered as false, or a nil rate) is not included. + # The Universal Credit rebalancing rescales the formula's elements + # from 2026 (scenarios/uc_reform.py) and is read the same way. The + # model does not have the reg 28 waiting period or regs 29(5)-(6), + # 30(3). Where reg 29(4) leaves a carer with limited capability only + # the LCWRA element, that element exempts. + def element_included(element, model_variable, circumstance): entered = supplied_input(benunit, element, period) - return included if entered is None else entered > 0 + if entered is not None: + return entered > 0 + positive = benunit(element, period) > 0 + if not calculated_by(benunit, element, period, model_variable): + return positive + only_other_members = benunit.any(~claimant & circumstance) & ~benunit.any( + claimant & circumstance + ) + return positive & ~only_other_members lcwra_element = element_included( "uc_LCWRA_element", + uc_LCWRA_element, person("uc_limited_capability_for_WRA", period), - lcwra_element_from_circumstances, ) carer_element = element_included( "uc_carer_element", + uc_carer_element, person("is_carer_for_benefits", period), - carer_element_from_circumstances, ) # HB reg 75E(2): the claimant is, or the couple are jointly, entitled diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py index bb8b1bb8be..1e9fe02c0b 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_single_claimant_rate.py @@ -13,11 +13,12 @@ class is_benefit_cap_single_claimant_rate(Variable): "Whether the claimant is a single claimant, not responsible for a " "child or young person. The model uses one benefit cap for Universal " "Credit and Housing Benefit, so the family rate applies if either " - "scheme's child or young-person test is met. The couple is the " - "scheme's (benefit_cap_couple): a Universal Credit award's two " - "claimants, otherwise Housing Benefit's claimant and partner, which " - "is is_couple's test. Couple status stands for " - "joint-claim status, except that a member of a couple who claims " + "scheme's child or young-person test is met. Couple status " + "(is_couple) stands for joint-claim status, except that where the " + "claimants of a Universal Credit award are entered (is_uc_claimant) " + "and are not Housing Benefit's claimant and partner, the award's " + "claimants decide (benefit_cap_couple), and that a member of a " + "couple who claims " "Universal Credit as a single person (regulation 3(3)) is a single " "claimant: regulation 80A sets the limits by single claimant and " "joint claimants (ADM E5007 note 3). The welfare benefits capped are " @@ -37,7 +38,21 @@ class is_benefit_cap_single_claimant_rate(Variable): ) def formula(benunit, period, parameters): - couple = benunit.sum(benefit_cap_couple(benunit, period)) >= 2 + person = benunit.members + # Regulation 80A reads the award's claimants: a single claimant or + # joint claimants. Unless is_uc_claimant is entered, the claimants are + # Housing Benefit's claimant and partner, and is_couple (relation_type, + # which can be entered) gives the couple. Where entered claimants of a + # family on Universal Credit are other members, they decide. + couple_of_scheme = benefit_cap_couple(benunit, period) + entered_claimants_differ = benunit.any( + couple_of_scheme != person("is_claimant_or_partner", period) + ) + couple = where( + entered_claimants_differ, + benunit.sum(couple_of_scheme) >= 2, + benunit("is_couple", period), + ) single_claimant = ~couple | single_claim_in_rules_shared_with_legacy_benefits( benunit, period ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py index b7f53f95f5..1e5e36cb48 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/uc_carer_element.py @@ -4,24 +4,6 @@ ) -def carer_element_from_circumstances(benunit, period, parameters): - """The carer element the members' circumstances give: what - uc_carer_element returns, its defined_for (benunit_has_carer) included.""" - p = parameters(period).gov.dwp.universal_credit.elements.carer - person = benunit.members - # The element is for "a claimant" with regular and substantial caring - # responsibilities (reg. 29(1)). A partner who cannot be a joint - # claimant, so that the other member claims as a single person - # (reg. 3(3)), is not a claimant: their caring gives no element. - not_a_claimant = other_member_of_single_claim(person, period) - carer = person("is_carer_for_benefits", period) - only_carer_is_not_a_claimant = benunit.any(carer & not_a_claimant) & ~benunit.any( - carer & ~not_a_claimant - ) - has_carer = benunit("benunit_has_carer", period) - return has_carer * ~only_carer_is_not_a_claimant * p.amount * MONTHS_IN_YEAR - - class uc_carer_element(Variable): value_type = float entity = BenUnit @@ -41,4 +23,15 @@ class uc_carer_element(Variable): ] def formula(benunit, period, parameters): - return carer_element_from_circumstances(benunit, period, parameters) + p = parameters(period).gov.dwp.universal_credit.elements.carer + person = benunit.members + # The element is for "a claimant" with regular and substantial caring + # responsibilities (reg. 29(1)). A partner who cannot be a joint + # claimant, so that the other member claims as a single person + # (reg. 3(3)), is not a claimant: their caring gives no element. + not_a_claimant = other_member_of_single_claim(person, period) + carer = person("is_carer_for_benefits", period) + only_carer_is_not_a_claimant = benunit.any( + carer & not_a_claimant + ) & ~benunit.any(carer & ~not_a_claimant) + return ~only_carer_is_not_a_claimant * p.amount * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py index 4d2d38c560..f35e6103f1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_LCWRA_element.py @@ -4,21 +4,6 @@ ) -def lcwra_element_from_circumstances(benunit, period, parameters): - """The LCWRA element the members' circumstances give: what the formula of - uc_LCWRA_element returns.""" - p = parameters(period).gov.dwp.universal_credit.elements.disabled - person = benunit.members - limited_capability = person("uc_limited_capability_for_WRA", period) - # The element is for "a claimant" with limited capability for work and - # work-related activity (reg. 27(1)). A partner who cannot be a joint - # claimant, so that the other member claims as a single person - # (reg. 3(3)), is not a claimant. - not_a_claimant = other_member_of_single_claim(person, period) - person_amounts = (limited_capability & ~not_a_claimant) * p.amount - return benunit.sum(person_amounts) * MONTHS_IN_YEAR - - class uc_LCWRA_element(Variable): value_type = float entity = BenUnit @@ -37,4 +22,13 @@ class uc_LCWRA_element(Variable): ] def formula(benunit, period, parameters): - return lcwra_element_from_circumstances(benunit, period, parameters) + p = parameters(period).gov.dwp.universal_credit.elements.disabled + person = benunit.members + limited_capability = person("uc_limited_capability_for_WRA", period) + # The element is for "a claimant" with limited capability for work and + # work-related activity (reg. 27(1)). A partner who cannot be a joint + # claimant, so that the other member claims as a single person + # (reg. 3(3)), is not a claimant. + not_a_claimant = other_member_of_single_claim(person, period) + person_amounts = (limited_capability & ~not_a_claimant) * p.amount + return benunit.sum(person_amounts) * MONTHS_IN_YEAR From 5d543f1691845b3e50f02c987f04df1401afe94b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:43:31 -0400 Subject: [PATCH 11/12] Answer review r5: keep a model value the model's only while it is the value stored Review r5 found that an entered uc_LCWRA_element equal to the amount the UC rebalancing had stored as the model's was read as the model's after a rebuild (build_from_dataframe or build_from_situation on an existing simulation), because _model_values outlived the populations and delete_arrays and matched by equality. The entry then went through the model-only claimant gate and the family was capped. Simulation now keeps the record in step with what it stores: - build_from_populations forgets every model value; - delete_arrays forgets each model value whose stored value is gone; - set_input forgets the branch's model value for overlapping periods, so whatever is set there is supplied, even a value equal to the model's (set_model_value records its value after its own set_input); - clone gives each clone and branch its own copy of the record (r4 nit 3). apply_reform replays inputs through storage, so a model value stays the model's across a reform. Tests: an entry equal to the model value is an entry by set_input, after deleting, and through a rebuild from a situation or a frame; clones and branches keep their own record; and a Hypothesis property over random sequences of model stores, inputs, deletions, rebuilds and clones. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 25 +++ ...nefit_cap_specified_benefits_properties.py | 172 +++++++++++++++++- policyengine_uk/utils/supplied_inputs.py | 72 +++++++- 3 files changed, 265 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 2acef5aefe..4ccd586e70 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -36,7 +36,11 @@ from policyengine_uk.utils.dependencies import get_variable_dependencies from policyengine_uk.utils.supplied_inputs import ( SUPPLIED_INPUT_VARIABLES, + copy_model_values, + drop_missing_model_values, drop_missing_supplied_inputs, + forget_model_value, + forget_model_values, ) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( @@ -246,6 +250,27 @@ def delete_arrays(self, variable: str, period: Period = None) -> None: # Inspect storage after core's deletion to honour period containment # and the current branch, ancestor branches and default branch. drop_missing_supplied_inputs(self, variable) + # A value stored later in a period whose model value (set_model_value) + # is gone is supplied, even one equal to the deleted value. + drop_missing_model_values(self, variable) + + def build_from_populations(self, populations) -> None: + super().build_from_populations(populations) + # The new populations hold none of the values set_model_value stored, + # so an equal value a rebuild stores (from a situation or a frame) is + # supplied. + forget_model_values(self) + + def set_input(self, variable_name: str, period, value) -> None: + super().set_input(variable_name, period, value) + # What is set here is supplied, even a value equal to the model's own. + # set_model_value records its value as the model's after this call. + forget_model_value(self, variable_name, period) + + def clone(self, *args, **kwargs) -> "Simulation": + new = super().clone(*args, **kwargs) + copy_model_values(self, new) + return new def reset_calculations(self): for variable in self.tax_benefit_system.variables: 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 9ffc201492..655987fae8 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -73,7 +73,7 @@ from policyengine_core.reforms import Reform from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable -from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input YEAR = 2025 SINGLE_CAP, FAMILY_CAP = 14_753, 22_020 # Outside London, 2025-26. @@ -551,3 +551,173 @@ def test_the_rebalancing_adds_no_entry_and_keeps_one(): benunit = entered.populations["benunit"] assert supplied_input(benunit, "uc_LCWRA_element", period(year)) is not None assert entered.calculate("is_benefit_cap_exempt_health_disability", year)[0] + + +# An element is an entry only while the value stored is one: the model's own +# value (set_model_value) stops counting as the model's once anything else is +# stored for the period, so an entry equal to it is still an entry. + + +def element_situation(year, element=None): + """A claimant and a non-claimant adult with limited capability: the + model's formula gives the element, which does not lift the cap.""" + claimant, other = blank(role="claimant", age=40), blank(role="other", age=30) + other["uc_limited_capability_for_WRA"] = True + inputs = {"working_tax_credit": 0} + if element is not None: + inputs["uc_LCWRA_element"] = element + return situation([([claimant, other], inputs)], year) + + +def is_entered(simulation, year): + benunit = simulation.populations["benunit"] + return supplied_input(benunit, "uc_LCWRA_element", period(year)) is not None + + +def enter_with_set_input(simulation, year, amount): + simulation.set_input("uc_LCWRA_element", year, [amount]) + + +def enter_after_deleting(simulation, year, amount): + simulation.delete_arrays("uc_LCWRA_element", year) + simulation.set_input("uc_LCWRA_element", year, [amount]) + + +def enter_by_rebuilding_from_a_situation(simulation, year, amount): + simulation.build_from_situation(element_situation(year, amount)) + + +def enter_by_rebuilding_from_a_frame(simulation, year, amount): + frame = Simulation(situation=element_situation(year)).to_input_dataframe() + frame[f"uc_LCWRA_element__{year}"] = amount + simulation.build_from_dataframe(frame) + + +@pytest.mark.parametrize( + "enter", + [ + enter_with_set_input, + enter_after_deleting, + enter_by_rebuilding_from_a_situation, + enter_by_rebuilding_from_a_frame, + ], +) +def test_an_entry_equal_to_the_model_value_is_an_entry(enter): + """Reg 83(1)(a): an element entered for the award decides the exception, + even one equal to the amount the rebalancing stored as the model's in the + same simulation, however it is entered.""" + year = 2026 + without_entry = Simulation(situation=element_situation(year)) + assert not without_entry.calculate("is_benefit_cap_exempt_health_disability", year)[ + 0 + ] + simulation = Simulation(situation=element_situation(year)) + model_amount = float(simulation.calculate("uc_LCWRA_element", year)[0]) + assert model_amount > 0 + assert not is_entered(simulation, year) + + # The exemption is calculated only after the entry: the engine does not + # recalculate a cached value when an input it depends on changes. + enter(simulation, year, model_amount) + assert simulation.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + model_amount + ) + assert is_entered(simulation, year) + assert simulation.calculate("is_benefit_cap_exempt_health_disability", year)[0] + # As in a simulation built with the entry. + fresh = Simulation(situation=element_situation(year, model_amount)) + assert is_entered(fresh, year) + assert fresh.calculate("is_benefit_cap_exempt_health_disability", year)[0] + + +def test_clones_and_branches_keep_their_own_record_of_model_values(): + year = 2026 + simulation = Simulation(situation=element_situation(year)) + model_amount = float(simulation.calculate("uc_LCWRA_element", year)[0]) + + clone = simulation.clone() + assert not is_entered(clone, year) + set_model_value(clone, "uc_LCWRA_element", year, np.array([1_000.0])) + assert not is_entered(clone, year) + clone.set_input("uc_LCWRA_element", year, [model_amount]) + assert is_entered(clone, year) + + branch = simulation.get_branch("entry") + assert not is_entered(branch, year) + branch.set_input("uc_LCWRA_element", year, [model_amount]) + assert is_entered(branch, year) + + # Nothing the clone or the branch stored changes what the simulation reads. + assert not is_entered(simulation, year) + assert simulation.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + model_amount + ) + assert not simulation.calculate("is_benefit_cap_exempt_health_disability", year)[0] + + +@pytest.fixture(scope="module") +def rebalanced(): + year = 2026 + simulation = Simulation(situation=element_situation(year)) + return simulation, float(simulation.calculate("uc_LCWRA_element", year)[0]) + + +AMOUNT_NAMES = st.sampled_from(["model", "nil", "other"]) +OPERATIONS = st.lists( + st.one_of( + st.tuples(st.just("model"), AMOUNT_NAMES), + st.tuples(st.just("input"), AMOUNT_NAMES), + st.just(("delete",)), + st.just(("rebuild",)), + st.just(("clone",)), + ), + min_size=1, + max_size=6, +) + + +@settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(operations=OPERATIONS) +def test_an_element_is_entered_exactly_when_its_last_store_was_supplied( + rebalanced, operations +): + """Whatever the order of stores, deletions, rebuilds and clones, the + element is an entry exactly when its value was last set as an input, or + adjusted by the model after an input (set_model_value keeps an entry an + entry). Stores in a clone never change what the simulation it was cloned + from reads. The amounts include the model's own, so an entry equal to it + is drawn.""" + year = 2026 + base, model_amount = rebalanced + amounts = {"model": model_amount, "nil": 0.0, "other": 4_321.0} + simulation, entered = base.clone(), False + earlier = [] + for operation in operations: + event(operation[0]) + if operation[0] == "model": + set_model_value( + simulation, + "uc_LCWRA_element", + year, + np.array([amounts[operation[1]]]), + ) + elif operation[0] == "input": + simulation.set_input("uc_LCWRA_element", year, [amounts[operation[1]]]) + entered = True + elif operation[0] == "delete": + simulation.delete_arrays("uc_LCWRA_element", year) + entered = False + elif operation[0] == "rebuild": + simulation.build_from_situation(element_situation(year)) + entered = False + else: + earlier.append((simulation, entered)) + simulation = simulation.clone() + assert is_entered(simulation, year) == entered, operations + for clone_source, was_entered in earlier: + assert is_entered(clone_source, year) == was_entered, operations diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index ef3e43ffeb..da45588874 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -19,6 +19,13 @@ refill it, and the helpers only answer for those variables. ``Simulation.delete_arrays`` forgets such records too, for any variable it deletes. + +A scenario that stores a value the model calculated (``set_model_value``) +records it as the model's, and the helpers do not return it. The record of +such a value lasts only while it is the value stored: ``Simulation`` forgets +it when ``set_input`` stores another value for the period, when +``delete_arrays`` removes it, and when the populations are rebuilt, and a +clone or branch gets its own copy of the record. """ from typing import List, Optional @@ -160,9 +167,12 @@ def set_model_value(simulation, variable_name: str, period, value) -> None: recorded inputs when a reform is applied, so a scenario that adjusts a calculated amount must store it that way. ``supplied_input`` and ``supplied_input_periods`` do not return the stored value while it is - the one stored here; a later ``set_input`` of another value is supplied - again. If the value was supplied before (a scenario adjusting a user's - entry), the new value stays supplied.""" + the one stored here. A later ``Simulation.set_input`` for the period is + supplied, whatever its value, and so is any value stored after + ``delete_arrays`` removes this one or the populations are rebuilt (see + ``forget_model_value``, ``drop_missing_model_values`` and + ``forget_model_values``). If the value was supplied before (a scenario + adjusting a user's entry), the new value stays supplied.""" _check_registered(variable_name) period = periods.period(period) population = simulation.get_variable_population(variable_name) @@ -180,3 +190,59 @@ def set_model_value(simulation, variable_name: str, period, value) -> None: model_values = {} simulation._model_values = model_values model_values[(variable_name, branch_name, period)] = np.array(stored) + + +# ``Simulation`` keeps the record of model values in step with what it stores +# through the helpers below. Each simulation, clone or branch has its own +# record, as core gives each its own record of inputs. + + +def forget_model_value(simulation, variable_name: str, period) -> None: + """Forget that the model stored this branch's value of ``variable_name`` + for any period that overlaps ``period``: a value set there now is + supplied, even one equal to the model's.""" + model_values = getattr(simulation, "_model_values", None) + if not model_values: + return + period = periods.period(period) + for key in [ + key + for key in model_values + if key[0] == variable_name + and key[1] == simulation.branch_name + and (key[2].contains(period) or period.contains(key[2])) + ]: + del model_values[key] + + +def drop_missing_model_values(simulation, variable_name: str) -> None: + """Forget each model value of ``variable_name``, on this branch or one it + was made from, whose stored value is gone, so that a value stored there + later is supplied, even one equal to the model's.""" + model_values = getattr(simulation, "_model_values", None) + if not model_values: + return + branch_names = set(_visible_branch_names(simulation)) + holder = simulation.get_holder(variable_name) + for key in [ + key + for key in model_values + if key[0] == variable_name + and key[1] in branch_names + and holder._get_array_from_storage(key[2], key[1]) is None + ]: + del model_values[key] + + +def forget_model_values(simulation) -> None: + """Forget every model value: rebuilt populations hold none of them.""" + if getattr(simulation, "_model_values", None): + simulation._model_values = {} + + +def copy_model_values(simulation, clone) -> None: + """Give a clone of ``simulation`` its own copy of the record, so that what + either stores later does not change what the other reads as supplied.""" + model_values = getattr(simulation, "_model_values", None) + if model_values is not None: + clone._model_values = dict(model_values) From e249a42c76394aabff5291f5a5f48b82e7447111 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:45:03 -0400 Subject: [PATCH 12/12] Give the rebuild-from-a-frame test's frame its identifiers A situation's to_input_dataframe sets no person_id, benunit_id or household_id, and build_from_dataframe needs person_id. The test now adds them, with the two people in one benefit unit and household. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_benefit_cap_specified_benefits_properties.py | 5 +++++ 1 file changed, 5 insertions(+) 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 655987fae8..2373ea40ff 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -589,6 +589,11 @@ def enter_by_rebuilding_from_a_situation(simulation, year, amount): def enter_by_rebuilding_from_a_frame(simulation, year, amount): frame = Simulation(situation=element_situation(year)).to_input_dataframe() + # A situation sets no identifiers. The two people share a benefit unit + # and a household. + frame[f"person_id__{year}"] = [1, 2] + frame[f"benunit_id__{year}"] = 1 + frame[f"household_id__{year}"] = 1 frame[f"uc_LCWRA_element__{year}"] = amount simulation.build_from_dataframe(frame)