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..6f92e40f0c --- /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`: 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 new file mode 100644 index 0000000000..f3c46e56c9 --- /dev/null +++ b/changelog.d/benefit-cap-75f-claimant-partner.fixed.md @@ -0,0 +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 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/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..9d5849c547 --- /dev/null +++ b/changelog.d/benefit-cap-75f-claimant-partner.removed.md @@ -0,0 +1,2 @@ +- 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/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 7f2e0d6738..1577c2b454 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**: only the claimants' income counts: the single claimant, or the two joint claimants (`is_uc_assessed_claimant`; UC Regulations 2013 reg 22(1)). The earnings and unearned income of a child, a qualifying young person or anyone else in the benefit unit are theirs, not the claimant's, so the Universal Credit means test does not count them. Who the claimants are comes from `is_uc_claimant` (the claimant and partner); a member flagged there is taken as a claimant whatever their age. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant in gainful self-employment outside a start-up period who would otherwise be subject to all work-related requirements is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor does not apply to a claimant in the no work-related requirements, work-focused interview or work preparation group (`uc_work_related_group_apart_from_earnings`; Welfare Reform Act 2012 ss 19 to 21): for example one with limited capability for work-related activity, a carer, a claimant over State Pension Credit qualifying age, or the responsible carer of a child under 3. Only one member of a couple is the responsible carer (`uc_is_responsible_carer`); where the nomination is not supplied the model takes the claimant who works fewer hours (`hours_worked`), then the elder. `hours_worked` defaults to 0, so for a couple entered without hours the default falls to the elder; supply `uc_is_responsible_carer` where the nomination is known. The floor is the minimum wage for their age for their expected hours (`uc_expected_hours`: 35 a week, or for the responsible carer of a child under 13 the hours DWP uses: 30 from February 2025, from April 2017 until then 16 for a child under compulsory school age and 25 for an older child, and 25 for any child under 13 before April 2017; regs 88 and 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, and never above it (reg 62(3)). The couple threshold is the sum of both partners' thresholds (reg 90(3)): 16 hours for a partner in the work-focused interview or work preparation group, nothing for a partner with no work-related requirements for a reason other than earnings, and 35 hours at the national living wage (the adult rate before April 2016) for a partner who cannot be a joint claimant (reg 90(3)(b)). The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the claimants' combined earnings above the work allowance (`uc_work_allowance`), and by all of the claimants' unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. 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 (the claimants earning enough, having a qualifying disability benefit, etc.). The earnings exception reads the claimants' earnings only (reg 82(1)(a)), the capped total counts only the claimants' own contributory benefits (WRA 2012 s.96(1)), and the childcare work condition tests only the claimants (reg 32(1)).\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**: only the claimants' income counts: the single claimant, or the two joint claimants (`is_uc_assessed_claimant`; UC Regulations 2013 reg 22(1)). The earnings and unearned income of a child, a qualifying young person or anyone else in the benefit unit are theirs, not the claimant's, so the Universal Credit means test does not count them. Who the claimants are comes from `is_uc_claimant` (the claimant and partner); a member flagged there is taken as a claimant whatever their age. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant in gainful self-employment outside a start-up period who would otherwise be subject to all work-related requirements is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor does not apply to a claimant in the no work-related requirements, work-focused interview or work preparation group (`uc_work_related_group_apart_from_earnings`; Welfare Reform Act 2012 ss 19 to 21): for example one with limited capability for work-related activity, a carer, a claimant over State Pension Credit qualifying age, or the responsible carer of a child under 3. Only one member of a couple is the responsible carer (`uc_is_responsible_carer`); where the nomination is not supplied the model takes the claimant who works fewer hours (`hours_worked`), then the elder. `hours_worked` defaults to 0, so for a couple entered without hours the default falls to the elder; supply `uc_is_responsible_carer` where the nomination is known. The floor is the minimum wage for their age for their expected hours (`uc_expected_hours`: 35 a week, or for the responsible carer of a child under 13 the hours DWP uses: 30 from February 2025, from April 2017 until then 16 for a child under compulsory school age and 25 for an older child, and 25 for any child under 13 before April 2017; regs 88 and 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, and never above it (reg 62(3)). The couple threshold is the sum of both partners' thresholds (reg 90(3)): 16 hours for a partner in the work-focused interview or work preparation group, nothing for a partner with no work-related requirements for a reason other than earnings, and 35 hours at the national living wage (the adult rate before April 2016) for a partner who cannot be a joint claimant (reg 90(3)(b)). The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the claimants' combined earnings above the work allowance (`uc_work_allowance`), and by all of the claimants' unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. 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 claimants earn enough (the earnings exception reads the claimants' earnings only, reg 82(1)(a)) or the family gets working tax credit, or where a claimant or, for some benefits, a child or qualifying 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, and nor do those of a partner who cannot be a joint claimant under reg 3(3) (`is_benefit_cap_exempt`). The capped total counts only the welfare benefits to which the claimant or couple is entitled (WRA 2012 s.96(1); reg 80(1)), and the childcare work condition tests only the claimants (reg 32(1)).\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/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index de15332e5a..de6d0233d6 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 @@ -156,7 +157,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/simulation.py b/policyengine_uk/simulation.py index 26bbbe18f7..d29136ce6e 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -39,7 +39,12 @@ 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_values, + forget_replaced_model_values, + model_value_input_records, ) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( @@ -249,6 +254,32 @@ 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: + # What is set here is supplied, even a value equal to the model's own, + # so forget the model value (set_model_value) of each period the call + # stores a value for, and only those. set_model_value records its + # value as the model's after this call. + records = model_value_input_records(self, variable_name) + try: + super().set_input(variable_name, period, value) + finally: + forget_replaced_model_values(self, variable_name, records) + + 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/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 c48cc616df..47bd050415 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 @@ -338,14 +338,32 @@ 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. This PR leaves the cap's reading of the -# benefit unit's ESA unchanged: scoping the exemption alone would cap the -# claimant with another member's own award in the capped sum -# (benefit_cap_reduction), which is out of scope here. The claimant's own -# award still exempts the family. +# ESA with the support component (HB Regs 2006 reg 75F(1)(a)). -- name: The claimant's own income-related ESA exempts the family from the benefit cap +- name: Another member's income-related ESA does not exempt the family from the benefit cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + 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] + working_tax_credit: 0 + households: + household: + members: [claimant, other] + output: + is_benefit_cap_exempt_health_disability: false + +- name: The claimant's own income-related ESA with the support component exempts the family from the benefit cap period: 2025 input: people: @@ -353,6 +371,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/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/policy/baseline/gov/dwp/benefit_cap.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap.yaml index 19ed1f92a6..9a6cf469ae 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_capped_total.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml new file mode 100644 index 0000000000..2cccf22483 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_capped_total.yaml @@ -0,0 +1,296 @@ +# 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 + 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 + 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 + +# 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: 25_000 + housing_benefit_pre_benefit_cap: 0 + households: + home: + members: [claimant, adult] + region: NORTH_EAST + output: + is_uc_assessed_claimant: [true, 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 + # 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 + 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: 25_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 + 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 + 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), so + # 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 new file mode 100644 index 0000000000..a7171a05e5 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -0,0 +1,1543 @@ +# 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, 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) --- + +- 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: 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 + # 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 + 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, payable to them (HB Regs 2006 reg 2(3A)); + # 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: [false, true] + 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 and the disability flag has LCWRA + period: 2025 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 7_000 + esa_includes_support_component: true + is_disabled_for_benefits: true + 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: 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: + 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: + # 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, flagged disabled, 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 + is_disabled_for_benefits: true + 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 is theirs, with the support component + period: 2025 + input: + people: + claimant: + age: 40 + is_disabled_for_benefits: true + 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: + 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: + # Payable to the claimant who heads the family (HB Regs 2006 reg 2(3A)). + is_on_income_related_esa: [true, false] + 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 + +- 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: 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: + 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 + +- 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 + 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 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: + 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 + +- 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 new file mode 100644 index 0000000000..3b1c383785 --- /dev/null +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -0,0 +1,814 @@ +"""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)): an + award, or the model's Carer's Allowance conditions, 35 hours of care a + week, whether or not it is paid (is_entitled_to_carer_benefit), 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)). An element + entered for the award decides it alone: included when positive, whatever + 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)). + +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, 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). 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)), so the draws give the member + State Pension age or earnings at times. +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. 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 +import pytest +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.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 set_model_value, supplied_input + +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"] +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 +) +# 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"}, + "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, + **{report: 0 for report in CAPPED_REPORTS.values()}, + "care_hours": 0, + "employment_income": 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 in CAPPED_REPORTS: + person[CAPPED_REPORTS[circumstance]] = 2_000 + elif circumstance == "caring": + person["care_hours"] = 35 + else: + person["is_disabled_for_benefits"] = True + + +@st.composite +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(role="claimant", age=draw(st.integers(25, 60)))] + if draw(st.booleans()): + 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"): + 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( + 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}") + 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()) + 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 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, enter_elements), min_size=1, max_size=6)) + + +def situation(units, year=YEAR, extra_benunit=None): + people, benunits, households = {}, {}, {} + for i, (members, benunit_inputs) 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, + **{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}"] = { + "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, 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"] + 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): + # The couple's income-related award is payable to the member who + # reports it (HB Regs 2006 reg 2(3A)). + receiving = m["esa_contrib_reported"] > 0 or ( + couple_award and m["esa_income_reported"] > 0 + ) + return receiving and support_component(m) + + def lcwra(m): + # 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 entitled_to_carer_benefit(m): + # "Entitled to" Carer's Allowance or Carer Support Payment (75F(1)(h), + # (ha); 83(1)(i), (ia)): an award, or the 35 hours of care a week that + # the model's Carer's Allowance conditions test (in England, where + # every family here lives, and for anyone who would claim, the + # default). Those conditions do not apply the full-time education + # exclusion (SSCBA 1992 s.70(3)), so a young person in education who + # cares 35 hours counts as entitled. + return ( + m["care_hours"] >= 35 + or m["carers_allowance"] > 0 + or m["carer_support_payment"] > 0 + ) + + def carer(m): + # Caring responsibilities for the UC carer element (UC Regs 2013 reg + # 30): the same 35 hours, or a carer benefit. + return entitled_to_carer_benefit(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) + or any_receives(claimants + children + young_persons, CHILD_OR_YOUNG_PERSON) + 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 element_included("uc_LCWRA_element", lcwra) + or element_included("uc_carer_element", carer) + or benunit_inputs["working_tax_credit"] > 0 + ) + + +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", "sixteen_own_right") + for m in 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, + 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, 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. + 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) + 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", + "benefit_cap_reduction", + "universal_credit", + ]: + 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], 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, inputs) in enumerate(units): + expected = reference_exempt(members, inputs) + 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], 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)) + 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(role="claimant", age=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) + 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, no_entries) + ] + 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() + + +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(role="claimant", age=40), blank(role="other", age=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] + # 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(role="claimant", age=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] + + +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] + + +# 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_the_calculated_array(simulation, year, amount): + # The array the engine returns for the period, stored back as it is. + simulation.set_input( + "uc_LCWRA_element", year, simulation.calculate("uc_LCWRA_element", year) + ) + + +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() + # 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) + + +@pytest.mark.parametrize( + "enter", + [ + enter_with_set_input, + enter_the_calculated_array, + 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]) + + +REBALANCED_YEARS = [2026, 2027, 2028, 2029] + + +@pytest.mark.parametrize( + "input_period, amount, entered_years", + [ + # Stored under the longer period, which no single year reads. + ("year:2026:2", "model", []), + ("year:2026:2", 10_000.0, []), + ("year:2025:2", 999.0, []), + ("year:2029:2", 999.0, []), + # Consistent with the year's value, so nothing is stored. + ("2026-01", "model", []), + ("month:2026-01:12", "model", []), + # Overlaps 2026 without being a year: nothing stored there. + ("year:2026-04", 999.0, []), + # Stored for the year itself. + ("2027", 999.0, [2027]), + ("2026", "model", [2026]), + ], +) +def test_an_input_makes_the_element_an_entry_only_in_years_it_stores( + rebalanced, input_period, amount, entered_years +): + """The rebalancing stores the model's element for 2026-2029. An input + for another period that leaves a year's stored value in place leaves it + the model's, so the non-claimant's limited capability still does not + lift the cap that year; an input stored for the year is an entry, even + the model's own amount.""" + base, model_amount = rebalanced + simulation = base.clone() + for year in REBALANCED_YEARS: + assert not is_entered(simulation, year) + value = model_amount if amount == "model" else amount + simulation.set_input("uc_LCWRA_element", input_period, [value]) + for year in REBALANCED_YEARS: + entered = year in entered_years + assert is_entered(simulation, year) == entered, year + assert ( + simulation.calculate("is_benefit_cap_exempt_health_disability", year)[0] + == entered + ), year + + +def test_an_inconsistent_input_for_part_of_a_year_keeps_the_model_value( + rebalanced, +): + base, model_amount = rebalanced + simulation = base.clone() + with pytest.raises(ValueError, match="Inconsistent input"): + simulation.set_input("uc_LCWRA_element", "2026-01", [model_amount + 1]) + assert not is_entered(simulation, 2026) + + +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.tuples(st.just("longer"), AMOUNT_NAMES), + st.tuples(st.just("month"), 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). An input for a longer period, or for part of the year, that + stores nothing for the year changes nothing. 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} + # The reference: whether the year's value is an entry, and the value + # stored for the year (None once nothing is). + simulation, entered, stored = base.clone(), False, model_amount + 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]]]), + ) + stored = amounts[operation[1]] + elif operation[0] == "input": + simulation.set_input("uc_LCWRA_element", year, [amounts[operation[1]]]) + entered, stored = True, amounts[operation[1]] + elif operation[0] == "longer": + # Stored under year:2026:2, which a calculation for 2026 does not + # read. + simulation.set_input( + "uc_LCWRA_element", f"year:{year}:2", [amounts[operation[1]]] + ) + elif operation[0] == "month": + amount = amounts[operation[1]] + if stored is None: + # A year with no input takes the month's value. + simulation.set_input("uc_LCWRA_element", f"{year}-01", [amount]) + entered, stored = True, amount + elif amount == stored: + # Consistent with the year's value: nothing is stored. + simulation.set_input("uc_LCWRA_element", f"{year}-01", [amount]) + else: + with pytest.raises(ValueError, match="Inconsistent input"): + simulation.set_input("uc_LCWRA_element", f"{year}-01", [amount]) + elif operation[0] == "delete": + simulation.delete_arrays("uc_LCWRA_element", year) + entered, stored = False, None + elif operation[0] == "rebuild": + simulation.build_from_situation(element_situation(year)) + entered, stored = False, None + 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/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 5cba93171c..fae54a3349 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -15,6 +15,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 reg 71(1)(h)(iii), through reg 61(2)(b) and HB Regs 2006 reg 56(2)(a) (the @@ -81,6 +83,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/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/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/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 39371fa0cc..65eefa4c99 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -19,11 +19,20 @@ 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 a value for its period, whatever the value (an +input set for a longer or shorter period that stores nothing there leaves +it), 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 import numpy as np +from policyengine_core import periods from policyengine_core.periods import Period @@ -37,6 +46,8 @@ "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", + "uc_carer_element", + "uc_LCWRA_element", } ) @@ -96,6 +107,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, ) @@ -120,6 +136,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 @@ -129,3 +151,134 @@ 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 ``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_replaced_model_values``, ``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) + 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) + + +# ``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 model_value_input_records(simulation, variable_name: str) -> dict: + """For each model value of ``variable_name`` on this branch, the entry + policyengine-core keeps for the input stored for its period, to pass to + ``forget_replaced_model_values`` after a ``set_input``. + + Core replaces a period's entry (``Holder._user_input_storage``) each time + it stores an input there, even an equal value, so a changed entry shows + the call stored one.""" + model_values = getattr(simulation, "_model_values", None) + if not model_values: + return {} + keys = [ + key + for key in model_values + if key[0] == variable_name and key[1] == simulation.branch_name + ] + if not keys: + return {} + holder = simulation.get_holder(variable_name) + records = getattr(holder, "_user_input_storage", None) or {} + return {key: records.get(_input_slot(holder, key)) for key in keys} + + +def forget_replaced_model_values(simulation, variable_name: str, before) -> None: + """Forget each model value whose period a ``set_input`` stored a value + for since ``before`` (``model_value_input_records``) was taken: a value + set there is supplied, even one equal to the model's. + + An input set for another period can leave the model value in place, and + then it stays the model's. Core stores an input for a longer period of + the variable's own unit (such as ``year:2026:2``) under that period, + which a calculation for one year does not read. For a period of another + unit, ``set_input_divide_by_period`` and ``set_input_dispatch_by_period`` + store only in years that hold no input, so an input for part of a year, + or for twelve months, stores nothing in a year that holds a model value + (``set_input_divide_by_period`` raises an error if it is inconsistent + with that value).""" + if not before: + return + holder = simulation.get_holder(variable_name) + records = getattr(holder, "_user_input_storage", None) or {} + model_values = simulation._model_values + for key, record in before.items(): + if records.get(_input_slot(holder, key)) is not record: + model_values.pop(key, None) + + +def _input_slot(holder, key): + """The key of core's entry for the input stored for a model value.""" + _, branch_name, period = key + return (branch_name, str(holder._storage_period(period))) + + +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) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 51088a4ea5..56081b1500 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, can_claim=None): +def is_payee_of_couple_award(person, period, reported, can_claim=None, 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 @@ -16,8 +16,14 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None): can_claim, if given, limits the payee to the members of the couple who can be the claimant (for Pension Credit, those who have attained the qualifying age). Where neither can, both are considered, so a positive - award entered for the couple is still paid to one of them.""" - claimant_or_partner = person("is_claimant_or_partner", period) + award entered for the couple is still paid to one of them. + + couple, if given (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 + ) if can_claim is not None: able = claimant_or_partner & can_claim claimant_or_partner = where(person.benunit.any(able), able, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py index 7d021f0b6f..e67feb233b 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_reduction.py @@ -1,30 +1,79 @@ 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 +# 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", + "income_support", + "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. 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 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/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): - CAPPED_BENEFITS = [ - "child_benefit", - "child_tax_credit", + # 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", - "income_support", + "jsa_income_reported", + income_related_jsa_award, + couple, + ) + award_of_members( + benunit, + period, "esa_income", - "universal_credit_pre_benefit_cap", - "housing_benefit_pre_benefit_cap", - "jsa_contrib", - "incapacity_benefit", - "esa_contrib", - "sda", - ] - # The cap applies to "the welfare benefits to which a single person or - # couple is entitled" (WRA 2012 s. 96(1); UC Regs 2013 reg. 80(1)), so - # a dependant's own contributory benefit is not part of the total. - claimants = benunit.members("is_uc_assessed_claimant", period) - total = add_for_members(benunit, period, CAPPED_BENEFITS, claimants) - return max_(total - benunit("benefit_cap", period), 0) + "esa_income_reported", + income_related_esa_award, + couple, + ) + capped = income_based + add_for_members( + benunit, + period, + CLAIMANT_OR_PARTNER_BENEFITS + FAMILY_BENEFITS, + 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/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..f474fe3561 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py @@ -0,0 +1,37 @@ +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) + # 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_family & attributed) 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 1bdb797d6a..76e7dcf27c 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,20 +4,13 @@ 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): person = benunit.members - # 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 Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, @@ -34,31 +27,4 @@ def formula(benunit, period, parameters): * claimant ) 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 69e1116338..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,85 +1,254 @@ +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 +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.utils.benefit_unit import add_for_members +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 ( + uc_carer_element, +) +from policyengine_uk.variables.gov.dwp.universal_credit.disability_element.limited_work_ability.uc_LCWRA_element import ( + uc_LCWRA_element, +) + +# 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)). Carer's allowance and carer +# support payment count by entitlement, not payment: (h)-(ha) and (i)-(ia) +# name a person "entitled to" them, even if an overlapping benefit reduces +# the payment to nil. +YOUNG_PERSON_BENEFITS = [ + "pip", + "is_entitled_to_carer_benefit", +] + +# 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", +] + + +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 - 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, " + "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)). 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." + ) 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.legislation.gov.uk/uksi/2013/376/regulation/3", + "https://www.gov.uk/benefit-cap/when-youre-not-affected", + ) def formula(benunit, period, parameters): person = benunit.members + # 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 + # claimant: their own benefits and limited capability do not lift the + # cap. Housing Benefit has no such claim, so a family that stays on + # legacy benefits keeps both members. + claimant = claimant_or_partner & ~other_member_of_single_claim_in_shared_rules( + person, period + ) - # The exceptions read "a claimant" (UC Regs 2013 reg. 83(1)). A - # partner who cannot be a joint claimant, so that the other member - # claims Universal Credit as a single person (reg. 3(3)), is not a - # claimant: their own disability benefits and limited capability do - # not lift the cap. - not_a_claimant = other_member_of_single_claim_in_shared_rules(person, period) - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any( - person("uc_limited_capability_for_WRA", period) & ~not_a_claimant + # 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_or_partner & person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) + uc_sixteen = ( + (age >= 16) + & (age < 17) + & ~claimant_or_partner + & ~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 ) - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 + def received_by(benefits, members): + return add_for_members(benunit, period, benefits, members) > 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) + # 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"; UC reg 83(1)(a) "the claimant". + # Contributory and income-related allowances both carry it; another + # 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 ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - # Entitlement, not payment: HB Regs 2006 reg 75F(1)(h)-(ha) and UC - # Regs 2013 reg 83(1)(i)-(ia) exempt a person "entitled to" Carer's - # Allowance or Carer Support Payment, even if an overlapping benefit - # reduces it to nil. - "is_entitled_to_carer_benefit", - "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_for_members( - benunit, period, QUAL_PERSONAL_BENEFITS, ~not_a_claimant + esa_support_component = benunit.any( + claimant & receiving_esa & person("esa_includes_support_component", period) ) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - # Check for Armed Forces Compensation Scheme payments - afcs = add_for_members(benunit, period, ["afcs"], ~not_a_claimant) > 0 + # 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). 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) + 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 - # ESA contribution-based with support component - esa_support_component = ( - add_for_members(benunit, period, ["esa_contrib"], ~not_a_claimant) > 0 + lcwra_element = element_included( + "uc_LCWRA_element", + uc_LCWRA_element, + person("uc_limited_capability_for_WRA", period), ) + carer_element = element_included( + "uc_carer_element", + uc_carer_element, + person("is_carer_for_benefits", period), + ) + + # 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 b6a3608a2b..a15f25e9f2 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 @@ -1,14 +1,20 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.uc_work_related_requirements import ( - other_member_of_single_claim_in_shared_rules, -) -from policyengine_uk.utils.benefit_unit import add_for_members 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 the family's Housing Benefit falls under the pension-age " + "regulations (HB Regs 2006 reg 5), which have no benefit cap. " + "Universal Credit has no age exception apart from SI 2014/1230 reg " + "60C, for a claim where every claimant has reached the qualifying age " + "for State Pension Credit. " + "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", @@ -20,7 +26,6 @@ class is_benefit_cap_exempt_other(Variable): ) def formula(benunit, period, parameters): - person = benunit.members # The benefit cap applies to a Universal Credit award whatever the # claimants' ages (UC Regs 2013 regs 79, 82 and 83), including a # mixed-age couple's joint award (reg 3(2)(a)). The one age-based @@ -30,21 +35,4 @@ def formula(benunit, period, parameters): # the working-age regulations (HB Regs 2006 Part 8A). Housing Benefit # under the pension-age regulations, which never apply to a family on # Universal Credit, has no cap. - pension_age_housing_benefit = benunit( - "housing_benefit_pension_age_regulations_apply", period - ) - - # The AFCS and ESA exceptions read "a claimant" (UC Regs 2013 - # reg. 83(1)(a) and (e)); a partner who cannot be a joint claimant - # (reg. 3(3)) is not one. - not_a_claimant = other_member_of_single_claim_in_shared_rules(person, period) - - # Check for Armed Forces Compensation Scheme payments - afcs = add_for_members(benunit, period, ["afcs"], ~not_a_claimant) > 0 - - # ESA contribution-based with support component - esa_support_component = ( - add_for_members(benunit, period, ["esa_contrib"], ~not_a_claimant) > 0 - ) - - return pension_age_housing_benefit | afcs | esa_support_component + return benunit("housing_benefit_pension_age_regulations_apply", period) 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..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 @@ -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,8 +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. 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 " @@ -33,9 +38,24 @@ 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) + 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 + ) 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/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_WRA.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py index 964b1780aa..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 @@ -5,8 +5,32 @@ 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. 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 = ( + "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) + 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 new file mode 100644 index 0000000000..f7e27613a1 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_work.py @@ -0,0 +1,35 @@ +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), 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 " + "not infer that yet, because the work allowance still reads every " + "member of the benefit unit, not only the claimants." + ) + 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", + "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): + return person("uc_limited_capability_for_WRA", period) | person( + "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 01670b64a7..7675aabfec 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,9 +15,10 @@ 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 - assessment supported by medical evidence under regulation 8(1)(a)-(b). + uc_limited_capability_for_work is a proxy (limited capability for work and + 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, underlying entitlement to either benefit, or at least the Carer's Allowance qualifying hours of care (currently 35 weekly). @@ -42,7 +43,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 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))