diff --git a/changelog.d/ctr-per-family-scheme.fixed.md b/changelog.d/ctr-per-family-scheme.fixed.md new file mode 100644 index 0000000000..9ded365e0b --- /dev/null +++ b/changelog.d/ctr-per-family-scheme.fixed.md @@ -0,0 +1 @@ +Assess each family that claims Council Tax Reduction under its own scheme and its own non-dependant exemption, so in a shared-rent household a working-age sharer no longer takes the pensioner scheme because the household head's family is pension-age (or the reverse), and one claimant's disability no longer exempts another's claim from non-dependant deductions. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml index e0d14375c4..e993dfbfb0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/state_pension_credit_qualifying_age.yaml @@ -38,7 +38,7 @@ is_SP_age: false is_pension_credit_eligible: true is_uc_eligible: false - council_tax_reduction_household_has_pensioner: true + council_tax_reduction_pensioner: true is_benefit_cap_exempt_other: true # SI 2000/729 reg 2(1)(i): 200 for a person under 80 not in residential care. winter_fuel_allowance: 200 @@ -160,7 +160,7 @@ is_SP_age: false is_pension_credit_eligible: false is_uc_eligible: true - council_tax_reduction_household_has_pensioner: false + council_tax_reduction_pensioner: false is_benefit_cap_exempt_other: false winter_fuel_allowance: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml new file mode 100644 index 0000000000..793efe1508 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -0,0 +1,974 @@ +# Where families share the rent, each jointly liable family claims Council Tax +# Reduction on its own share, and its claim follows its own circumstances: +# - the scheme: a "pensioner" is defined per person and partner (SI 2012/2885 +# reg 3; SI 2012/2886 Sch para 3; SI 2013/3029 reg 3; SSI 2021/249 reg 3; +# SSI 2012/319 reg 12), so a working-age family takes the working-age rules +# even if the household head's family is pension-age, and the reverse; +# - the non-dependant exemption: no deduction if "the applicant or [their] +# partner" is blind or gets a listed disability benefit (SI 2012/2885 Sch 1 +# para 8(6); SI 2012/2886 Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and +# Sch 6 para 5(6); SSI 2021/249 reg 90(6); SSI 2012/319 reg 48(6)). +# Each family here claims half of a 1,800 bill: 900. + +- name: Oxford, pension-age head and working-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # 500 a week. + council_tax_reduction_applicable_income: 26_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_claimant_benunit: [true, true] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + # Oxford's working-age scheme: 500 a week is in the 75% band, 900 x 75%. + oxford_council_tax_reduction: [0, 675] + # The head takes the English pensioner scheme: no income, 900. + simulated_council_tax_reduction_benunit: [900, 675] + council_tax_benefit: [900, 675] + council_tax_reduction: 1_575 + +- name: Oxford, working-age head and pension-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 26_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + oxford_council_tax_reduction: [675, 0] + # The sharer takes the English pensioner scheme, not Oxford's income + # bands (which would give 100% at 288 a week): the single pension-age + # applicable amount is 13,312 a year (256.00 a week x 52), so + # 900 - 20% x (15,000 - 13,312) = 562.40. + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [675, 562.40] + council_tax_reduction: 1_237.40 + +- name: Merton, a non-dependant of both claimants is deducted under each claimant's own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MERTON + output: + council_tax_reduction_pensioner: [true, false, false] + council_tax_reduction_joint_liability_share: [0.5, 0.5, 1] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + # The son's gross 576.92 a week is in Merton's 485-605 band: 13.30 a week + # (691.60 a year), whatever the head's age. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + # The son is a non-dependant of both: each claim bears half of him. + merton_council_tax_reduction_non_dep_deductions: [345.80, 345.80, 0] + # The sharer's claim is under Merton's working-age scheme: + # 900 - 345.80 = 554.20. The head's is under the pensioner scheme, with + # the national non-dependant amounts. + merton_council_tax_reduction: [0, 554.20, 0] + +- name: An unmodelled council's working-age sharer keeps its reported reduction beside a pension-age head + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 300 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [true, false] + # Maidstone's working-age scheme is not modelled; the pensioner scheme is. + council_tax_reduction_scheme_supported: [true, false] + simulated_council_tax_reduction_benunit: [900, 0] + council_tax_benefit: [900, 300] + council_tax_reduction: 1_200 + +- name: An unmodelled council's working-age head keeps its reported reduction beside a pension-age sharer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] + simulated_council_tax_reduction_benunit: [0, 900] + council_tax_benefit: [300, 900] + council_tax_reduction: 1_200 + +- name: A family that cannot claim keeps no reported reduction beside a simulated claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + adult_child: + age: 40 + council_tax_benefit_reported: 1_200 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + child_family: + members: [adult_child] + would_claim_uc: false + households: + home: + members: [head, adult_child] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_claimant_benunit: [true, false] + # The adult child's own scheme (Maidstone's working-age one) is not + # simulated, but the household's bill is: the head's pensioner claim + # covers all of it. The child's report cannot be added on top. + council_tax_reduction_scheme_supported: [true, false] + council_tax_reduction_household_has_simulated_claim: true + council_tax_benefit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Where no claim is simulated, every family keeps its reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + parent: + age: 70 + council_tax_benefit_reported: 200 + benunits: + head_family: + members: [head] + would_claim_uc: false + parent_family: + members: [parent] + would_claim_uc: false + households: + home: + members: [head, parent] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The head's working-age scheme is not modelled, so nothing in the + # household is simulated; the parent cannot claim but keeps its report. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: false + council_tax_benefit: [300, 200] + council_tax_reduction: 500 + +- name: Beside a simulated claim, a jointly liable claimant's reported reduction is limited to its share of the bill + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # SI 2012/2885 Sch 1 para 7(3)-(4): each of the two is liable for half, + # 900, so no scheme could reduce the sharer's bill by more than that. + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_scheme_supported: [true, false] + council_tax_benefit: [900, 900] + council_tax_reduction: 1_800 + +- name: Newham, the head's disability exempts only the head's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 35 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + council_tax_reduction_pensioner: [false, false, false] + # The son's gross 576.92 a week is in Newham's top band, 22.61 a week + # (1,175.72 a year); the sharer bears half, the head none. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 0, 1_175.72] + newham_council_tax_reduction_non_dep_deductions: [0, 587.86, 0] + +- name: Newham, the sharer's disability exempts only the sharer's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 35 + pip_dl: 3_000 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + newham_council_tax_reduction_non_dep_deductions: [587.86, 0, 0] + +- name: Wales, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + attendance_allowance: 4_000 + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + # Each family's own applicable amount: pension-age 256.00 a week, the + # working-age single 25 or over 95.55 a week. + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + # 900 - 20% x (15,000 - 13,312) = 562.40; + # 900 - 20% x (8,000 - 4,968.60) = 293.72. + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Wales, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + attendance_allowance: 4_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 + +- name: Scotland, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + pip_dl: 3_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Scotland, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 + +- name: A pension-age household head applies as a pensioner although her family's claimant and partner are young parents + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + pip_dl: 3_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The grandmother is the liable resident, so she is the applicant; the + # young parents are the family's claimant and partner but not hers. + is_claimant_or_partner: [false, true, true, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + # Her age puts the claim on the pensioner scheme (SI 2012/2885 reg 3). + council_tax_reduction_pensioner: [true] + council_tax_reduction_scheme_supported: [true] + # The young mother's PIP is not the applicant's or her partner's + # (Sch 1 para 8(6)). + council_tax_reduction_applicant_has_non_dep_exemption: [false] + simulated_council_tax_reduction_benunit: [1_800] + +- name: An applicant's partner under 18 still counts for the applicant's exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 17 + is_household_head: false + is_claimant_or_partner: true + pip_dl: 3_000 + benunits: + couple: + members: [head, partner] + would_claim_uc: false + households: + home: + members: [head, partner] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Only the head is liable (LGFA 1992 s.6(5)), but para 8(6) asks about + # "the applicant or his partner". + council_tax_reduction_liable_person: [true, false] + is_council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true] + council_tax_reduction_pensioner: [false] + +- name: Where no claim is simulated, a jointly liable claimant keeps its whole reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_200 + sharer: + age: 35 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_household_has_simulated_claim: false + # Nothing is simulated to reconcile with, so the report stands as on the + # base. The model's share is not used here: it does not yet leave out the + # students that SI 2012/2885 Sch 1 para 7(5) excludes from the divisor. + council_tax_benefit: [1_200, 0] + council_tax_reduction: 1_200 + +- name: A pension-age grandmother in the applicants' family does not make their claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 40 + is_household_head: true + is_parent: true + is_claimant_or_partner: true + council_tax_benefit_reported: 400 + father: + age: 42 + is_household_head: false + is_parent: true + is_claimant_or_partner: true + grandmother: + age: 80 + is_household_head: false + is_claimant_or_partner: false + benunits: + family: + members: [mother, father, grandmother] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [mother, father, grandmother] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # Intended change: the base put the claim on the pensioner scheme because + # any member of the head's family was over State Pension age (£1,800 + # simulated). A "pensioner" is the applicant or partner (SI 2012/2885 + # reg 3(1)), here the working-age parents, so the claim falls under + # Maidstone's unmodelled working-age scheme and keeps its report. + is_council_tax_reduction_applicant_or_partner: [true, true, false] + council_tax_reduction_pensioner: [false] + council_tax_reduction_scheme_supported: [false] + council_tax_benefit: [400] + council_tax_reduction: 400 + +- name: A jointly liable claimant keeps its whole report where the simulated claim beside it pays nothing + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_500 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # Too much income for any reduction. + council_tax_reduction_applicable_income: 40_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The sharer's pensioner claim is simulated but pays nothing, so no + # simulated reduction competes for the bill and the head's report stands. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: true + simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [1_500, 0] + council_tax_reduction: 1_500 + +- name: A report is kept beside a paying simulated claim where the share may count a student + # The share leaves out only people in higher education. A jointly liable + # full-time further-education student is counted, though para 7(5) may + # leave them out, so the share (a third) may be too small and the head's + # report is not capped at it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 700 + student: + age: 25 + current_education: POST_SECONDARY + in_HE: false + pensioner: + age: 80 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + pensioner_family: + members: [pensioner] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, student, pensioner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_joint_liability_share: [0.333333, 0.333333, 0.333333] + council_tax_reduction_scheme_supported: [false, false, true] + # The pensioner's claim pays 1,800 / 3 = 600 under the pensioner scheme. + simulated_council_tax_reduction_benunit: [0, 0, 600] + council_tax_benefit: [700, 0, 600] + council_tax_reduction: 1_300 + +- name: The same, with the student shown by in_FE + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 700 + student: + age: 25 + in_FE: true + pensioner: + age: 80 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + pensioner_family: + members: [pensioner] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, student, pensioner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + simulated_council_tax_reduction_benunit: [0, 0, 600] + council_tax_benefit: [700, 0, 600] + council_tax_reduction: 1_300 + +- name: A jointly liable 19-year-old with no education input keeps the cap off + # current_education defaults to tertiary education at 18 and 19, which the + # model's student test reads, so the share may count a student and the + # sharer's report is kept even beside the head's paying claim. The household + # gets more than its bill; the cap is deliberately conservative here. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 80 + is_household_head: true + sharer: + age: 19 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + current_education: [NOT_IN_EDUCATION, TERTIARY] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_benefit: [900, 1_500] + council_tax_reduction: 2_400 + +- name: At 20, with no education input, the same sharer's report is capped at its half + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 80 + is_household_head: true + sharer: + age: 20 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + current_education: [NOT_IN_EDUCATION, NOT_IN_EDUCATION] + council_tax_benefit: [900, 900] + council_tax_reduction: 1_800 + +- name: A head who applies alone stays a pensioner when her benefit unit's claimant and partner get Income Support + # The Income Support is the young parents' award, not the grandmother's + # or a partner's (SI 2012/2885 reg 3(1)(a)(ii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + would_claim_uc: false + income_support: 3_000 + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_relevant_income_based_benefit: [true] + council_tax_reduction_pensioner: [true] + simulated_council_tax_reduction_benunit: [1_800] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index 089272e357..78dfcecba1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -304,7 +304,9 @@ # The eldest adult is not the liable resident; the head is. benunit_contains_household_head: [true, false] council_tax_reduction_claimant_benunit: [true, false] - council_tax_reduction_household_has_pensioner: false + # The parent's family is pension-age, but it is not liable and claims + # nothing; the householder's claim follows their own family. + council_tax_reduction_pensioner: [false, true] council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] council_tax_reduction_non_dep_deductions: [200, 0] simulated_council_tax_reduction_benunit: [1_600, 0] @@ -337,13 +339,15 @@ savings: 0 output: # The claimant's family is working-age; Maidstone's working-age scheme is - # not modelled, so no simulated reduction. - council_tax_reduction_household_has_pensioner: false - council_tax_reduction_scheme_supported: false + # not modelled, so no simulated reduction. The parent's family would be on + # the pensioner scheme, but it is not liable and claims nothing. + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [0, 0] # A non-dependant's Attendance Allowance does not exempt the claimant # from non-dependant deductions (SI 2012/2885 Sch 1 para 8(6)). - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] - name: With two members flagged as head, the eldest flagged member's family claims period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml index 10e31d77bf..e791a4029a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.yaml @@ -35,7 +35,6 @@ savings: 0 output: council_tax_reduction_pensioner: false - council_tax_reduction_household_has_pensioner: false council_tax_reduction_scheme_supported: false simulated_council_tax_reduction_benunit: 0 council_tax_benefit: 700 @@ -64,7 +63,6 @@ output: is_uc_entitled: false council_tax_reduction_pensioner: true - council_tax_reduction_household_has_pensioner: true council_tax_reduction_scheme_supported: true # No income, so the full liability. council_tax_benefit: 2_000 @@ -96,7 +94,6 @@ output: jsa_income: 4_000 council_tax_reduction_pensioner: false - council_tax_reduction_household_has_pensioner: false council_tax_benefit: 700 - name: An English couple over State Pension age is a pensioner couple @@ -120,7 +117,6 @@ savings: 0 output: council_tax_reduction_pensioner: true - council_tax_reduction_household_has_pensioner: true council_tax_reduction_scheme_supported: true - name: A Newham mixed-age couple on Universal Credit gets Newham's working-age scheme @@ -144,7 +140,7 @@ council_tax: 2_000 savings: 0 output: - council_tax_reduction_household_has_pensioner: false + council_tax_reduction_pensioner: false council_tax_reduction_scheme_supported: true # Newham's scheme on the Universal Credit route. The pensioner scheme # would give the full 2,000 (income 10,000 is below the pension-age @@ -182,7 +178,6 @@ output: benunit_contains_household_head: [true, false] council_tax_reduction_pensioner: [false, false] - council_tax_reduction_household_has_pensioner: false newham_council_tax_reduction_non_dep_deductions: [392.08, 0] council_tax_benefit: [508.83, 0] @@ -213,7 +208,6 @@ output: is_uc_entitled: true council_tax_reduction_pensioner: true - council_tax_reduction_household_has_pensioner: true council_tax_reduction_scheme_supported: true - name: A pensioner aged 70 with an 18-year-old in education and no is_parent keeps pensioner CTR @@ -249,7 +243,6 @@ is_benefit_cap_exempt: true benefit_cap_reduction: 0 council_tax_reduction_pensioner: true - council_tax_reduction_household_has_pensioner: true council_tax_reduction_scheme_supported: true # The pensioner scheme meets the 2,000 in full. council_tax_benefit: 2_000 @@ -276,7 +269,6 @@ savings: 0 output: council_tax_reduction_pensioner: false - council_tax_reduction_household_has_pensioner: false council_tax_reduction_scheme_supported: true - name: A Scottish mixed-age couple on Universal Credit is not a pensioner couple @@ -300,7 +292,6 @@ savings: 0 output: council_tax_reduction_pensioner: false - council_tax_reduction_household_has_pensioner: false council_tax_reduction_scheme_supported: true - name: A capped Welsh mixed-age UC award does not use pensioner CTR allowances diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml index b8886db438..b6a6a07a24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/joint_liability.yaml @@ -298,8 +298,8 @@ # working-age scheme, which the model does not simulate, so keeps the # reported reduction (none here). council_tax_reduction_claims_are_joint: true - council_tax_reduction_claim_pensioner: [true, false] - council_tax_reduction_claim_scheme_supported: [true, false] + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, false] simulated_council_tax_reduction_benunit: [1_000, 0] council_tax_benefit: [1_000, 0] @@ -340,7 +340,6 @@ # 2,000 / 3 = 666.67 for her claim. The couple falls under the # working-age scheme, which is not simulated in Maidstone. council_tax_reduction_pensioner: [false, true] - council_tax_reduction_claim_pensioner: [false, true] council_tax_reduction_joint_liability_share: [0.333333, 0.333333] simulated_council_tax_reduction_benunit: [0, 666.67] council_tax_benefit: [0, 666.67] @@ -382,7 +381,6 @@ # Universal Credit award makes it working-age; that does not carry over # to the single pensioner's claim. council_tax_reduction_pensioner: [true, false] - council_tax_reduction_claim_pensioner: [true, false] simulated_council_tax_reduction_benunit: [666.67, 0] council_tax_benefit: [666.67, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml index ec6636d1fa..9367281a5e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml @@ -1,4 +1,4 @@ -- name: Dependent young person PIP does not exempt the claimant household +- name: Dependent young person PIP does not exempt the claimant's family period: 2025 input: people: @@ -21,9 +21,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] -- name: Under-18 partner PIP exempts the claimant household +- name: Under-18 partner PIP exempts the claimant's family period: 2025 input: people: @@ -50,9 +50,9 @@ output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. is_couple: [true, false] - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Claimant PIP continues to exempt the household +- name: Claimant PIP continues to exempt the claimant's family period: 2025 input: people: @@ -75,9 +75,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Dependent young person blindness does not exempt the claimant household +- name: Dependent young person blindness does not exempt the claimant's family period: 2025 input: people: @@ -100,4 +100,4 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index ea0ece7cc3..2e997d22cb 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -41,10 +41,12 @@ P4 The change only removes exemptions: exempt now implies exempt before, the cap is never higher, and Universal Credit and Housing Benefit never rise. A CTR pensioner now was a CTR pensioner before. -P5 The model's Welsh and Scottish CTR formulas do not route on pensioner - status, but they count Universal Credit as income. So their CTR changes - only through Universal Credit: unchanged where Universal Credit is - unchanged, and never lower where the cap has lowered Universal Credit. +P5 The Welsh and Scottish CTR schemes do not depend on pensioner status, + but they count Universal Credit as income. So with the CTR pensioner test + held as it is now (it also sets the pension-age applicable amount and + earnings disregard), the cap change moves their CTR only through Universal + Credit: unchanged where Universal Credit is unchanged, and never lower + where the cap has lowered Universal Credit. P6 Metamorphic: with a Universal Credit award, adding a member over State Pension age to a couple's ages (making it mixed-age) never exempts it from the cap. @@ -106,22 +108,34 @@ def formula(benunit, period, parameters): return has_pensioner | afcs | esa_support_component -class council_tax_reduction_household_has_pensioner(Variable): +class council_tax_reduction_pensioner(Variable): value_type = bool - entity = Household + entity = BenUnit label = "CTR pensioner test before this change" definition_period = YEAR - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - return household.any(claimant_benunit & person("is_SP_age", period)) + # The age test alone, on each claim's own applicant and partner. + def formula(benunit, period, parameters): + person = benunit.members + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + return benunit.any(applicant_or_partner & person("is_SP_age", period)) class before_this_change(Reform): def apply(self): self.update_variable(is_benefit_cap_exempt_other) - self.update_variable(council_tax_reduction_household_has_pensioner) + self.update_variable(council_tax_reduction_pensioner) + + +# The cap exception alone as it was, with today's pensioner test for Council +# Tax Reduction. council_tax_reduction_pensioner also sets the CTR applicable +# amount and earnings disregard, so P5 holds it fixed to isolate the route +# through Universal Credit. +class before_the_cap_change(Reform): + def apply(self): + self.update_variable(is_benefit_cap_exempt_other) @st.composite @@ -215,14 +229,14 @@ def situation(units): "afcs", "esa_contrib", ] -HOUSEHOLD = ["council_tax_reduction_household_has_pensioner", "household_net_income"] +HOUSEHOLD = ["household_net_income"] BEFORE = [ "is_benefit_cap_exempt", "benefit_cap", "universal_credit", "housing_benefit", "council_tax_benefit", - "council_tax_reduction_household_has_pensioner", + "council_tax_reduction_pensioner", "household_net_income", ] @@ -230,7 +244,9 @@ def situation(units): def calculate(units, before=False): simulation = Simulation(situation=situation(units)) if before: - simulation.apply_reform(before_this_change) + simulation.apply_reform( + before_the_cap_change if before == "cap" else before_this_change + ) names = BEFORE else: names = BENUNIT + HOUSEHOLD @@ -254,6 +270,7 @@ def close(a, b): def test_benefit_cap_and_ctr_pension_age_invariants(units): now = calculate(units) before = calculate(units, before=True) + cap_only = calculate(units, before="cap") for i, unit in enumerate(units): on_uc = bool(now["is_uc_entitled"][i]) and has_working_age_claimant(unit) legacy = unit["legacy"] > 0 @@ -281,15 +298,15 @@ def test_benefit_cap_and_ctr_pension_age_invariants(units): assert now["benefit_cap"][i] <= before["benefit_cap"][i], unit assert now["universal_credit"][i] <= before["universal_credit"][i] + 0.01 assert now["housing_benefit"][i] <= before["housing_benefit"][i] + 0.01 - if now["council_tax_reduction_household_has_pensioner"][i]: - assert before["council_tax_reduction_household_has_pensioner"][i], unit + if now["council_tax_reduction_pensioner"][i]: + assert before["council_tax_reduction_pensioner"][i], unit # P5 if unit["country"] in ("WALES", "SCOTLAND"): ctr_now, ctr_before = ( now["council_tax_benefit"][i], - before["council_tax_benefit"][i], + cap_only["council_tax_benefit"][i], ) - if close(now["universal_credit"][i], before["universal_credit"][i]): + if close(now["universal_credit"][i], cap_only["universal_credit"][i]): assert close(ctr_now, ctr_before), unit else: assert ctr_now >= ctr_before - 0.01, unit diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py new file mode 100644 index 0000000000..9849322beb --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -0,0 +1,686 @@ +"""Property-based tests: each family that claims Council Tax Reduction is +assessed under its own scheme and its own non-dependant exemption. + +Where the household's rent is shared, every family liable for it claims, and +each claim follows the applicant's own circumstances. "Pensioner" is defined +per person and partner (SI 2012/2885 reg 3(1); SI 2012/2886 Sch para 3(2); +SI 2013/3029 reg 3(1); SSI 2021/249 reg 3; SSI 2012/319 reg 12). No +non-dependant deduction is made "if the applicant or his partner" is blind or +gets a listed disability benefit (SI 2012/2885 Sch 1 para 8(6); SI 2012/2886 +Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and Sch 6 para 5(6); SSI +2021/249 reg 90(6); SSI 2012/319 reg 48(6)). + +Invariants, for any generated population of households with a flagged head, +up to two sharer families and at most one non-dependant family. Every adult +here is 20 or over and the head is a claimant, so each family's applicant and +partner are its claimant and partner (the YAML cases cover a head who is +not): + +1. Own members: a family is a pensioner exactly when its claimant or partner + has reached the qualifying age for State Pension Credit and neither of + them is on Income Support, income-based Jobseeker's Allowance or + income-related Employment and Support Allowance, or has a Universal Credit + award (which needs one of them under that age). It is exempt from + non-dependant deductions exactly when its claimant or partner is blind or + gets Attendance Allowance, the care component of Disability Living + Allowance, the daily living component of Personal Independence Payment or + Armed Forces Independence Payment. +2. As if alone: each family's pensioner status, exemption and whether its + scheme is simulated are what they would be if it lived alone with its own + benefit awards. (Its Universal Credit award depends on the rent it pays, + which changes when it lives alone, and the pensioner test follows the + award; so the alone simulation is given the family's awards.) +3. Scheme: a family's scheme is simulated exactly when it lives in Scotland + or Wales, is a pensioner, or lives in a council whose working-age scheme + is modelled. In England only a pensioner family is paid by the national + pensioner scheme, and only a working-age family by a council's scheme. +4. Others do not matter: changing the ages and disability benefits of every + other family that claims leaves a family's pensioner status, exemption, + scheme and simulated award unchanged. Held as they were: its means test + (applicable amount, income and capital) and two household allocations, + because the model lets other families reach a family's own awards: + - its severe disability premium counts the other adults in the household + (has_non_dependant_for_severe_disability_premium), so their ages and + benefits can change a family's applicable amounts whatever its scheme; + - the household's non-dependants count in one Universal Credit claim + only (UC Regs 2013 Sch 4 para 9(2); uc_non_dependants_counted), which + the model makes the head family's where it claims, so a head's family + reaching pension age can move them into a sharer's claim and change the + sharer's award, which the pensioner test follows; + - Pension Credit's guarantee credit, which sets a family's CTR capital to + nil, depends on other families twice: the severe disability addition's + residence condition counts every other adult in the household, and + pension_credit_assessable_capital divides the household's savings among + its pension-age adults. + Each family's awards are still computed, so a claim that read another + family's awards would fail. Its council_tax_benefit is unchanged too, + unless it falls back to a reported reduction and whether a simulated + claim in its household pays something changes (property 5's + reconciliation). Populations that broke earlier versions of this + property are pinned as examples. +5. Fallback: a claiming family gets its simulated reduction where its scheme + is simulated, and otherwise its reported one. Beside a simulated claim + that pays something, a jointly liable claimant's reported reduction is + limited to its share of the council tax, unless a jointly liable person + the share counts may be a student the share should leave out (enrolled in + education, or in_FE); otherwise it is kept as reported. A family that + cannot claim gets its reported reduction only where no claim in its + household is simulated. Every adult here is 20 or over, so + current_education's age default (tertiary at 18 and 19) never applies; + the YAML covers it. +6. Bounds: a family that cannot claim gets no simulated reduction; a + household whose simulated claims pay something, and whose share counts no + one who may be a student, never gets more than its council tax. +7. The exemption is the applicant's own: in a council's working-age scheme, + giving one claiming family an exempting benefit removes the non-dependant + deductions from its own reduction and leaves every other claim's + deductions and award unchanged. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +MODELLED_WORKING_AGE_SCHEMES = { + "MERTON", + "KINGSTON_UPON_THAMES", + "NEWHAM", + "WESTMINSTER", + "OXFORD", +} +LOCAL_SCHEMES = [ + "merton_council_tax_reduction", + "kingston_upon_thames_council_tax_reduction", + "newham_council_tax_reduction", + "westminster_council_tax_reduction", + "oxford_council_tax_reduction", +] +# Inputs that exempt the applicant from non-dependant deductions, as an +# annual amount (or a flag for blindness). +DISABILITY = { + "attendance_allowance": 5_000, + "dla_sc": 3_500, + "pip_dl": 4_000, + "armed_forces_independence_payment": 9_000, + "is_blind": True, +} +# Ages of 20 or more are never presumed to be a child of the family, so +# changing them never changes who is a claimant or partner. The sampled values +# sit on both sides of State Pension age. +adult_age = st.one_of( + st.sampled_from([20, 30, 45, 64, 65, 66, 67, 70, 85]), st.integers(20, 100) +) +disability = st.sampled_from([None, *DISABILITY]) +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +reported = st.one_of(st.just(0.0), st.floats(0, 3_000, allow_nan=False)) + + +@st.composite +def adults(draw, most): + n = draw(st.integers(1, most)) + return dict( + ages=draw(st.lists(adult_age, min_size=n, max_size=n)), + disabilities=draw(st.lists(disability, min_size=n, max_size=n)), + earnings=draw(st.lists(money, min_size=n, max_size=n)), + # Some adults are enrolled in further education, shown by + # current_education or by in_FE; the joint-liability share does not + # leave them out. None means not in education. + in_education=draw( + st.lists( + st.sampled_from([None, None, None, None, "current_education", "in_FE"]), + min_size=n, + max_size=n, + ) + ), + ) + + +@st.composite +def family(draw, role): + # A non-dependant here is one adult with no children. + alone = role == "non_dependant" + return dict( + role=role, + child_age=None if alone else draw(st.one_of(st.none(), st.integers(0, 15))), + reported=draw(reported), + **draw(adults(1 if alone else 2)), + ) + + +@st.composite +def households(draw): + families = [draw(family("head"))] + families += [draw(family("sharer")) for _ in range(draw(st.integers(0, 2)))] + if draw(st.booleans()): + families.append(draw(family("non_dependant"))) + claimants = sum(f["role"] != "non_dependant" for f in families) + return dict( + families=families, + scheme=draw(st.sampled_from(SCHEMES)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + rent=draw(money), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + target=draw(st.integers(0, claimants - 1)), + # Replacement circumstances for the other families that claim. + other_ages=draw(st.lists(adult_age, min_size=6, max_size=6)), + other_disabilities=draw(st.lists(disability, min_size=6, max_size=6)), + ) + + +population = st.lists(households(), min_size=1, max_size=6) + + +def person(age, disability_input, earnings, in_education=None): + attributes = {"age": age, "employment_income": earnings} + if disability_input is not None: + attributes[disability_input] = DISABILITY[disability_input] + if in_education == "current_education": + attributes["current_education"] = "POST_SECONDARY" + elif in_education == "in_FE": + attributes["in_FE"] = True + return attributes + + +def build(population, alone=False, perturb_others=False, awards=None): + """One situation for the whole population, plus per-family facts. + + alone: put every family in a household of its own, as its head. + perturb_others: give every family that claims, other than each + household's target, the household's replacement ages and disabilities. + awards: benefit-unit inputs per family, in family order (for example the + families' own benefit awards or means test from another simulation). + """ + people, benunits, homes = {}, {}, {} + facts = dict( + country=[], + local_authority=[], + role=[], + target=[], + house=[], + share_may_count_student=[], + ) + for h, house in enumerate(population): + country, local_authority = house["scheme"] + home = dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + rent=house["rent"], + tenure_type="RENT_PRIVATELY", + savings=house["savings"], + ) + members, replacement = [], 0 + # Everyone in the head's and the sharers' families is 20 or over, so + # all of them are jointly liable and counted in the share. + liable_in_education = any( + any(e is not None for e in fam["in_education"]) + for fam in house["families"] + if fam["role"] != "non_dependant" + ) + for f, fam in enumerate(house["families"]): + ids = [] + claims = fam["role"] != "non_dependant" + is_target = claims and f == house["target"] + for i, age in enumerate(fam["ages"]): + pid = f"h{h}_f{f}_adult_{i}" + disability_input = fam["disabilities"][i] + if perturb_others and claims and not is_target: + age = house["other_ages"][replacement] + disability_input = house["other_disabilities"][replacement] + replacement += 1 + people[pid] = person( + age, disability_input, fam["earnings"][i], fam["in_education"][i] + ) + if i == 0: + people[pid]["council_tax_benefit_reported"] = fam["reported"] + people[pid]["is_household_head"] = ( + alone or fam["role"] == "head" + ) and i == 0 + # A family's adults are its claimant and partner, however far + # apart their ages. + people[pid]["is_claimant_or_partner"] = True + ids.append(pid) + if fam["child_age"] is not None: + pid = f"h{h}_f{f}_child" + people[pid] = {"age": fam["child_age"], "is_household_head": False} + ids.append(pid) + benunits[f"h{h}_f{f}"] = { + "members": ids, + "liable_for_share_of_household_rent": fam["role"] == "sharer" + and not alone, + "claims_all_entitled_benefits": True, + "would_claim_uc": True, + } + if awards is not None: + index = len(facts["role"]) + benunits[f"h{h}_f{f}"].update( + {name: values[index].item() for name, values in awards.items()} + ) + if alone: + homes[f"h{h}_f{f}"] = {"members": ids, **home} + members.extend(ids) + facts["country"].append(country) + facts["local_authority"].append(local_authority) + facts["role"].append(fam["role"]) + facts["target"].append(is_target) + facts["house"].append(h) + facts["share_may_count_student"].append( + any(e is not None for e in fam["in_education"]) + if alone + else liable_in_education + ) + if not alone: + homes[f"h{h}"] = {"members": members, **home} + situation = { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", homes), + ) + } + return situation, {k: np.array(v) for k, v in facts.items()} + + +def calc(simulation, variable, map_to=None): + return np.asarray(simulation.calculate(variable, YEAR, map_to=map_to)) + + +def any_in_benunit(simulation, person_values): + return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 + + +# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3). +OWN_AWARDS = [ + "universal_credit_pre_benefit_cap", + "income_support", + "jsa_income", + "esa_income", +] +# A family's means test, held fixed in property 4. Its capital is nil where it +# gets guarantee credit, which other families can change in the model. +MEANS_TEST = [ + "council_tax_reduction_applicable_amount", + "council_tax_reduction_applicable_income", + "council_tax_reduction_assessable_capital", +] +# Household allocations through which other families' circumstances reach a +# family's own awards, held fixed in property 4. +HOUSEHOLD_ALLOCATIONS = [ + "uc_non_dependants_counted", + "has_non_dependant_for_severe_disability_premium", +] +FLAGS = [ + "council_tax_reduction_pensioner", + "council_tax_reduction_applicant_has_non_dep_exemption", + "council_tax_reduction_scheme_supported", +] + + +@PROPERTY_SETTINGS +@given(population) +def test_scheme_follows_own_family(population): + situation, facts = build(population) + sim = Simulation(situation=situation) + pensioner = calc(sim, "council_tax_reduction_pensioner") + exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") + supported = calc(sim, "council_tax_reduction_scheme_supported") + claimant = calc(sim, "council_tax_reduction_claimant_benunit") + + # 1. Own members. SI 2012/2885 reg 3(1): the qualifying age for State + # Pension Credit, and no income-related benefit or Universal Credit award + # for the claimant or partner. A Universal Credit award needs a member + # under that age (UC Regs 2013 reg 3(2)(a)). + claimant_or_partner = calc(sim, "is_claimant_or_partner") + attained = calc(sim, "has_attained_state_pension_credit_qualifying_age") + income_related = ( + sum(calc(sim, v) for v in ("income_support", "jsa_income", "esa_income")) > 0 + ) + universal_credit_award = calc(sim, "is_uc_entitled") & any_in_benunit( + sim, claimant_or_partner & ~attained + ) + assert np.array_equal( + pensioner, + any_in_benunit(sim, claimant_or_partner & attained) + & ~income_related + & ~universal_credit_award, + ) + exempting = calc(sim, "is_blind").astype(bool) + for variable in DISABILITY: + if variable != "is_blind": + exempting |= calc(sim, variable) > 0 + assert np.array_equal(exempt, any_in_benunit(sim, claimant_or_partner & exempting)) + + # 2. As if alone, with the family's own benefit awards. + awards = {name: calc(sim, name) for name in OWN_AWARDS} + alone_situation, alone_facts = build(population, alone=True, awards=awards) + alone = Simulation(situation=alone_situation) + assert np.array_equal(alone_facts["role"], facts["role"]) + for variable in FLAGS: + assert np.array_equal(calc(sim, variable), calc(alone, variable)), variable + + # 3. Scheme. + england = facts["country"] == "ENGLAND" + modelled = np.isin(facts["local_authority"], list(MODELLED_WORKING_AGE_SCHEMES)) + assert np.array_equal(supported, ~england | pensioner | modelled) + simulated = calc(sim, "simulated_council_tax_reduction_benunit") + local = sum(calc(sim, variable) for variable in LOCAL_SCHEMES) + national = simulated - local + assert not np.any((national > 0.005) & england & ~pensioner) + assert not np.any((local > 0.005) & pensioner) + assert not np.any((local > 0.005) & ~england) + + # 5. Fallback. + benefit = calc(sim, "council_tax_benefit") + reported_amount = calc(sim, "council_tax_benefit_reported", map_to="benunit") + share = calc(sim, "council_tax_reduction_joint_liability_share") + # Index households directly: map_to splits a household value among members. + house = facts["house"] + bill = calc(sim, "council_tax")[house] + simulates = calc(sim, "council_tax_reduction_household_has_simulated_claim") + household_simulates = simulates[house].astype(bool) + paid = np.bincount(house, weights=(claimant & supported) * simulated) + household_pays = paid[house] > 0 + may_count_student = facts["share_may_count_student"] + expected = np.where( + claimant, + np.where( + supported, + simulated, + np.where( + household_pays & (share < 1) & ~may_count_student, + np.minimum(reported_amount, bill * share), + reported_amount, + ), + ), + np.where(household_simulates, 0, reported_amount), + ) + np.testing.assert_allclose(benefit, expected, atol=0.01) + simulated_claims = np.bincount(house, weights=claimant & supported) + assert np.array_equal(simulates.astype(bool), simulated_claims > 0) + + # 6. Bounds. + assert np.all(simulated[~claimant] == 0) + household_reduction = calc(sim, "council_tax_reduction") + over = household_reduction > calc(sim, "council_tax") + 0.01 + counts_student = np.bincount(house, weights=may_count_student) > 0 + assert not np.any(over & (paid > 0) & ~counts_student) + + +def pinned_family(role, ages, earnings, disabilities=None, in_education=None): + return dict( + role=role, + child_age=None, + reported=0.0, + ages=ages, + disabilities=disabilities or [None] * len(ages), + earnings=earnings, + in_education=in_education or [None] * len(ages), + ) + + +def pinned_household(local_authority, families, other_ages, **inputs): + """An English household whose second family is the target.""" + return dict( + families=families, + scheme=("ENGLAND", local_authority), + council_tax=inputs.get("council_tax", 1_800.0), + rent=inputs.get("rent", 12_000.0), + savings=inputs.get("savings", 0.0), + target=1, + other_ages=other_ages, + other_disabilities=inputs.get("other_disabilities", [None] * 6), + ) + + +# Populations that broke earlier versions of property 4, each through one of +# the routes its docstring lists. +# CI on d988725dc: the head's family reaches pension age, so the non-dependant +# moves into the sharer's Universal Credit claim and its award follows. +UC_NON_DEPENDANT_MOVES = [ + pinned_household( + "NEWHAM", + [ + pinned_family("head", [47], [0.0], ["attendance_allowance"], ["in_FE"]), + pinned_family( + "sharer", + [100, 63], + [27_200.0, 7_429.0], + None, + ["current_education", None], + ), + pinned_family("non_dependant", [66], [0.0], ["is_blind"]), + ], + [85, 36, 64, 70, 35, 67], + other_disabilities=[ + "attendance_allowance", + "attendance_allowance", + "pip_dl", + "attendance_allowance", + "attendance_allowance", + "is_blind", + ], + council_tax=2_865.0, + rent=33_635.0, + ) +] +# The same move raises the sharer's uc_maximum_amount, which the _legacy +# councils use as a Universal Credit family's applicable amount. +UC_MAXIMUM_MOVES = [ + pinned_household( + "NEWHAM", + [ + pinned_family("head", [30], [0.0]), + pinned_family("sharer", [40, 40], [2_000.0, 0.0]), + pinned_family("non_dependant", [30], [0.0]), + ], + [85] * 6, + council_tax=2_000.0, + rent=30_000.0, + ) +] +# The head reaching pension age halves the sharer's share of the household's +# savings for Pension Credit, so it gets guarantee credit and nil CTR capital. +PENSION_CREDIT_CAPITAL_SPLIT = [ + pinned_household( + "MAIDSTONE", + [ + pinned_family("head", [30], [30_000.0]), + pinned_family("sharer", [70], [12_000.0]), + ], + [85] * 6, + rent=6_000.0, + savings=17_000.0, + ) +] +# The head gaining Attendance Allowance meets the sharer's severe disability +# addition residence condition, with the same result. +PENSION_CREDIT_SEVERE_DISABILITY = [ + pinned_household( + "MAIDSTONE", + [ + pinned_family("head", [30], [30_000.0]), + pinned_family("sharer", [70], [12_000.0], ["attendance_allowance"]), + ], + [30] * 6, + other_disabilities=["attendance_allowance"] * 6, + rent=6_000.0, + savings=17_000.0, + ) +] + + +@PROPERTY_SETTINGS +@given(population) +@example(population=UC_NON_DEPENDANT_MOVES) +@example(population=UC_MAXIMUM_MOVES) +@example(population=PENSION_CREDIT_CAPITAL_SPLIT) +@example(population=PENSION_CREDIT_SEVERE_DISABILITY) +def test_other_families_do_not_change_a_claim(population): + situation, facts = build(population) + before = Simulation(situation=situation) + held = {name: calc(before, name) for name in MEANS_TEST + HOUSEHOLD_ALLOCATIONS} + perturbed_situation, _ = build(population, perturb_others=True, awards=held) + after = Simulation(situation=perturbed_situation) + target = facts["target"] + # 4. Others do not matter. + for variable in FLAGS + LOCAL_SCHEMES: + assert np.array_equal( + calc(before, variable)[target], calc(after, variable)[target] + ), variable + for variable in ( + "council_tax_reduction_joint_liability_share", + "simulated_council_tax_reduction_benunit", + ): + np.testing.assert_allclose( + calc(before, variable)[target], + calc(after, variable)[target], + atol=1e-6, + err_msg=variable, + ) + # A reported fallback is reconciled against the household's paid + # simulated claims (property 5); compare it wherever that is the same. + house = facts["house"] + supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + + def household_pays(sim_): + claims = calc(sim_, "council_tax_reduction_claimant_benunit").astype(bool) + supported_ = calc(sim_, "council_tax_reduction_scheme_supported").astype(bool) + simulated = calc(sim_, "simulated_council_tax_reduction_benunit") + paid = np.bincount(house, weights=(claims & supported_) * simulated) + return paid[house] > 0 + + same_reconciliation = household_pays(before) == household_pays(after) + compare = target & (supported | same_reconciliation) + np.testing.assert_allclose( + calc(before, "council_tax_benefit")[compare], + calc(after, "council_tax_benefit")[compare], + atol=1e-6, + err_msg="council_tax_benefit", + ) + + +@st.composite +def shared_council_homes(draw): + """A working-age head and sharer under one council's scheme, with a + non-dependant in remunerative work, and an exempting benefit for one of + the two claimants.""" + return dict( + local_authority=draw(st.sampled_from(sorted(MODELLED_WORKING_AGE_SCHEMES))), + ages=draw(st.lists(st.integers(20, 60), min_size=2, max_size=2)), + non_dependant_age=draw(st.integers(20, 60)), + non_dependant_earnings=draw(st.floats(10_000, 60_000, allow_nan=False)), + council_tax=draw(st.floats(1_000, 4_000, allow_nan=False)), + exempt_family=draw(st.sampled_from([0, 1])), + disability=draw(st.sampled_from(sorted(DISABILITY))), + ) + + +def council_situation(homes, with_disability): + people, benunits, households = {}, {}, {} + for h, home in enumerate(homes): + ids = [] + for f, age in enumerate(home["ages"]): + pid = f"h{h}_claimant_{f}" + disability_input = ( + home["disability"] + if with_disability and f == home["exempt_family"] + else None + ) + people[pid] = person(age, disability_input, 0) + people[pid]["is_household_head"] = f == 0 + benunits[f"h{h}_f{f}"] = { + "members": [pid], + "liable_for_share_of_household_rent": f == 1, + "claims_all_entitled_benefits": True, + "would_claim_uc": False, + } + ids.append(pid) + pid = f"h{h}_non_dependant" + people[pid] = person( + home["non_dependant_age"], None, home["non_dependant_earnings"] + ) + people[pid].update(is_household_head=False, weekly_hours=37.5) + benunits[f"h{h}_f2"] = {"members": [pid], "would_claim_uc": False} + ids.append(pid) + households[f"h{h}"] = dict( + members=ids, + country="ENGLAND", + local_authority=home["local_authority"], + council_tax=home["council_tax"], + rent=12_000, + tenure_type="RENT_PRIVATELY", + savings=0, + ) + return { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", households), + ) + } + + +@PROPERTY_SETTINGS +@given(st.lists(shared_council_homes(), min_size=1, max_size=6)) +def test_exemption_is_the_applicants_own(homes): + without = Simulation(situation=council_situation(homes, with_disability=False)) + with_ = Simulation(situation=council_situation(homes, with_disability=True)) + # Benefit units run head, sharer, non-dependant in each household. + family = np.tile([0, 1, 2], len(homes)) + exempt_family = np.repeat([home["exempt_family"] for home in homes], 3) + exempted = family == exempt_family + other = (family < 2) & ~exempted + variables = [v + "_non_dep_deductions" for v in LOCAL_SCHEMES] + + def total(sim_, names): + return sum(calc(sim_, name) for name in names) + + before_deductions = total(without, variables) + after_deductions = total(with_, variables) + # 7. The non-dependant is deducted from both claims without the benefit, + # so the test is not vacuous. + assert np.all(before_deductions[family < 2] > 0) + flag = "council_tax_reduction_applicant_has_non_dep_exemption" + assert not np.any(calc(without, flag)[family < 2]) + assert np.array_equal(calc(with_, flag)[family < 2], exempted[family < 2]) + assert np.all(after_deductions[exempted] == 0) + np.testing.assert_allclose( + after_deductions[other], before_deductions[other], atol=1e-6 + ) + for name in LOCAL_SCHEMES + ["council_tax_benefit"]: + np.testing.assert_allclose( + calc(with_, name)[other], calc(without, name)[other], atol=1e-6 + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 4b9e0b176c..e6d1de97c7 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -259,6 +259,7 @@ def situation( "savings_credit", "pension_credit_income", "pension_credit_assessable_capital", + "council_tax_reduction_pensioner", ] # council_tax_reduction_applicable_income on main before this change (1c5b4d04): # these incomes and benefits, less income tax, National Insurance and half of @@ -293,7 +294,6 @@ def situation( PENSIONER_ADDITIONAL_DISREGARD = 17.10 HOUSEHOLD_VARIABLES = [ "council_tax_reduction_maximum_eligible_liability", - "council_tax_reduction_household_has_pensioner", "savings", ] @@ -366,12 +366,11 @@ def benunit_total(names): - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), ) + # Each claim's scheme follows its own family's pensioner status. values["national"] = np.array( [ - unit["country"] != "ENGLAND" or has_pensioner - for unit, has_pensioner in zip( - units, values["council_tax_reduction_household_has_pensioner"] - ) + unit["country"] != "ENGLAND" or pensioner + for unit, pensioner in zip(units, values["council_tax_reduction_pensioner"]) ] ) return values diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index 441cf8f460..ccebd466a4 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -355,10 +355,12 @@ def test_meals_and_council_tax(population): assert shared.any() assert np.all(total[shared] <= 1 + 1e-6) # Each claim there follows its own family's pensioner status, not the - # household head's family's (SI 2012/2885 reg 3). + # household head's family's (SI 2012/2885 reg 3). These households are in + # Maidstone, whose working-age scheme the model does not simulate, so a + # family's scheme is simulated exactly when it is a pensioner. in_shared = of_household(a, "council_tax_reduction_claims_are_joint") assert np.array_equal( - calc(a, "council_tax_reduction_claim_pensioner")[in_shared], + calc(a, "council_tax_reduction_scheme_supported")[in_shared], calc(a, "council_tax_reduction_pensioner")[in_shared], ) # The simulated reductions of the household's claims never exceed its @@ -416,17 +418,16 @@ def test_no_op_without_sharers_boarders_or_lodgers(population): head_bedrooms[head_claims], (bedrooms_alone + adults_outside)[head_claims], ) - # Council Tax Reduction: one claim, on the household's scheme, simulated - # or reported as the household's scheme is. + # Council Tax Reduction: one claim, the household head's family's, on its + # own scheme; every family's reduction is simulated or reported as that + # claim's scheme is. assert not calc(sim, "council_tax_reduction_claims_are_joint").any() - assert np.array_equal( - calc(sim, "council_tax_reduction_claim_pensioner"), - of_household(sim, "council_tax_reduction_household_has_pensioner"), - ) - supported = of_household(sim, "council_tax_reduction_scheme_supported") - assert np.array_equal( - calc(sim, "council_tax_reduction_claim_scheme_supported"), supported + assert np.array_equal(calc(sim, "council_tax_reduction_claimant_benunit"), head) + head_scheme = ( + per_household(sim, calc(sim, "council_tax_reduction_scheme_supported") & head) + > 0 ) + supported = sim.map_result(head_scheme, "household", "benunit") > 0 assert np.allclose( calc(sim, "council_tax_benefit"), np.where( diff --git a/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py index d537262b94..a1eea4e249 100644 --- a/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py +++ b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py @@ -182,7 +182,7 @@ def assert_consumers_follow_the_qualifying_age(births, male, year): assert np.array_equal(sim.calculate("is_pension_credit_eligible", year), attained) assert np.array_equal(sim.calculate("is_uc_eligible", year), ~attained) assert np.array_equal( - sim.calculate("council_tax_reduction_household_has_pensioner", year), attained + sim.calculate("council_tax_reduction_pensioner", year), attained ) assert np.array_equal(sim.calculate("is_benefit_cap_exempt_other", year), attained) aged = sim.tax_benefit_system.parameters( diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index 39dd37d8f8..91e83a656e 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -1,15 +1,88 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + is_full_time_student_non_dep, +) class council_tax_benefit(Variable): value_type = float entity = BenUnit label = "Council Tax Benefit" + documentation = ( + "A family that claims gets the simulated reduction where the model " + "simulates its own scheme, and its reported reduction otherwise. " + "Where another claim in the household is simulated and pays a " + "reduction on its share of the council tax, a jointly liable " + "claimant's reported reduction is limited to its own share, so the " + "two cannot together exceed the bill; the share is the one the " + "simulated claims use. The share leaves out only people in higher " + "education (in_HE), so where a jointly liable person it counts may be " + "a student the law leaves out (the model's student test for " + "non-dependants, or in_FE), the report is kept. That test reads " + "current_education, which defaults to tertiary education at 18 and " + "19, so with no education input a jointly liable 18- or 19-year-old " + "keeps the cap off; part-time students and students the scheme brings " + "back in are treated the same way. Otherwise reported reductions are " + "kept as reported. A family that cannot claim keeps a reported " + "reduction only where no claim in its household is simulated." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/4", + ) def formula(benunit, period, parameters): - supported = benunit("council_tax_reduction_claim_scheme_supported", period) + supported = benunit("council_tax_reduction_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) - return where(supported, simulated, reported) + claimant = benunit("council_tax_reduction_claimant_benunit", period) + share = benunit("council_tax_reduction_joint_liability_share", period) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) + # Simulated reductions paid to claims in the household. + person = benunit.members + simulated_claim = (supported & claimant) * simulated + paid_in_household = benunit.max( + person.household.sum( + person("is_benunit_head", period) * benunit.project(simulated_claim) + ) + ) + # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's + # maximum reduction is on its share of the council tax. Applied only + # beside a simulated claim that pays something, which uses the same + # share; reports are otherwise kept. + share_of_liability = ( + benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + * share + ) + # Para 7(5) leaves students excluded from the scheme out of the + # divisor. The share leaves out people in higher education (in_HE); + # a jointly liable person it counts who may be a student (the + # model's student test for non-dependants, or in_FE) could be left + # out too, making the share too small, so the report is kept there. + # The test reads current_education, whose age default puts 18- and + # 19-year-olds in tertiary education; it does not tell full-time from + # part-time study or find the students para 75(2) brings back in. + counted_may_be_student = ( + person("council_tax_reduction_liable_person", period) + & ~person("in_HE", period) + & (is_full_time_student_non_dep(person, period) | person("in_FE", period)) + ) + share_may_count_student = ( + benunit.max(person.household.sum(counted_may_be_student)) > 0 + ) + reported_claim = where( + (paid_in_household > 0) & (share < 1) & ~share_may_count_student, + min_(reported, share_of_liability), + reported, + ) + not_claiming = where(household_simulates, 0, reported) + return where( + claimant, where(supported, simulated, reported_claim), not_claiming + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index c6bf7bb2c0..824e70fdbe 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -10,20 +10,6 @@ def is_full_time_student_non_dep(person, period): ) -def single_claim_is_pensioner(household, period): - """Whether the household's single claim is a pensioner's. - - A non-dependant brings no deduction under a council's working-age scheme - where the household's one claim is a pensioner's. Where families claim on - their shares, each claim's own scheme decides whether its award uses the - deduction (council_tax_reduction_claim_pensioner), so none is ruled out - here. - """ - return household( - "council_tax_reduction_household_has_pensioner", period - ) & ~household("council_tax_reduction_claims_are_joint", period) - - def legacy_council_tax_reduction( benunit, period, @@ -105,14 +91,12 @@ def local_non_dep_deductions( # A non-dependant of two or more jointly liable people is apportioned # equally between them (SI 2012/2885 Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - # Where families claim on their shares, no deduction from an applicant - # who, or whose partner, is blind or gets a qualifying disability benefit, - # whatever the other claimants get (the councils' schemes follow - # SI 2012/2886 Sch para 30(6)). A single claim's exemption is applied to - # each non-dependant's deduction. - applicant_exempt = benunit.household( - "council_tax_reduction_claims_are_joint", period - ) & benunit("council_tax_reduction_applicant_has_non_dep_exemption", period) + # No deduction from an applicant who, or whose partner, is blind or gets a + # qualifying disability benefit, whatever other claimants in the household + # get (the councils' schemes follow SI 2012/2886 Sch para 30(6)). + applicant_exempt = benunit( + "council_tax_reduction_applicant_has_non_dep_exemption", period + ) return where( applicant_exempt, 0, (deductions_in_household - deduction_for_benunit) * share ) @@ -122,10 +106,16 @@ def normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=True, exempt_uc_no_earned_income=True, ): + """The deduction a non-dependant brings under one council's scheme. + + It depends on the non-dependant alone. Whether a claimant's award uses it + (the claimant's own scheme) and whether the claimant is exempt from + non-dependant deductions are decided per claimant family. + """ gross_income_components = [ "employment_income", "self_employment_income", @@ -152,11 +142,6 @@ def normal_gross_income_non_dep_deduction( ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), ctr.non_dep_deduction.amount.calc(0), ) - # The exemption of a household's single claim. Joint claims are exempted - # one by one, in local_non_dep_deductions. - claimant_exempt = person.household( - "council_tax_reduction_household_has_non_dep_exemption", period - ) & ~person.household("council_tax_reduction_claims_are_joint", period) full_time_student = is_full_time_student_non_dep(person, period) income_based_benefit = ( (person.benunit("income_support", period) > 0) @@ -167,9 +152,8 @@ def normal_gross_income_non_dep_deduction( has_uc = person.benunit("universal_credit", period) > 0 no_earned_income = weekly_benunit_earned_income <= 0 exempt = ( - claimant_exempt - | full_time_student + full_time_student | (exempt_income_based_benefits & income_based_benefit) | (exempt_uc_no_earned_income & has_uc & no_earned_income) ) - return working_age * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) + return in_scheme_area * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index 1ee8ea5cbc..a28e7a3a68 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -2,6 +2,9 @@ from policyengine_uk.variables.household.demographic.country import Country from policyengine_uk.variables.household.demographic.locations import LocalAuthority +# Scheme selection takes has_pensioner per claim: whether the applicant's own +# family is a pensioner for Council Tax Reduction (council_tax_reduction_pensioner). + def is_england_pensioner_scheme(country, has_pensioner): return (country == Country.ENGLAND) & has_pensioner @@ -19,46 +22,60 @@ def is_merton(local_authority): return local_authority == LocalAuthority.MERTON +def is_merton_area(local_authority, country): + return (country == Country.ENGLAND) & is_merton(local_authority) + + def is_merton_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_merton(local_authority) + return is_merton_area(local_authority, country) & ~has_pensioner def is_kingston_upon_thames(local_authority): return local_authority == LocalAuthority.KINGSTON_UPON_THAMES +def is_kingston_upon_thames_area(local_authority, country): + return (country == Country.ENGLAND) & is_kingston_upon_thames(local_authority) + + def is_kingston_upon_thames_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_kingston_upon_thames(local_authority) - ) + return is_kingston_upon_thames_area(local_authority, country) & ~has_pensioner def is_newham(local_authority): return local_authority == LocalAuthority.NEWHAM +def is_newham_area(local_authority, country): + return (country == Country.ENGLAND) & is_newham(local_authority) + + def is_newham_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_newham(local_authority) + return is_newham_area(local_authority, country) & ~has_pensioner def is_westminster(local_authority): return local_authority == LocalAuthority.WESTMINSTER +def is_westminster_area(local_authority, country): + return (country == Country.ENGLAND) & is_westminster(local_authority) + + def is_westminster_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) & ~has_pensioner & is_westminster(local_authority) - ) + return is_westminster_area(local_authority, country) & ~has_pensioner def is_oxford(local_authority): return local_authority == LocalAuthority.OXFORD +def is_oxford_area(local_authority, country): + return (country == Country.ENGLAND) & is_oxford(local_authority) + + def is_oxford_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_oxford(local_authority) + return is_oxford_area(local_authority, country) & ~has_pensioner def is_supported_scheme(country, has_pensioner, local_authority): diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index ec510f0006..53c4079bea 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -8,27 +8,33 @@ class council_tax_reduction_applicant_has_non_dep_exemption(Variable): documentation = ( "Whether no deduction is made for any non-dependant from this family's " "Council Tax Reduction because the applicant or partner is blind, or " - "receives Attendance Allowance, the care component of Disability " - "Living Allowance, the daily living component of Personal " - "Independence Payment or Armed Forces Independence Payment. The " - "exemption belongs to each applicant: where families share the rent " - "and each claims, one family's disability does not exempt another's " - "claim. The model applies it in the English working-age local " - "schemes; a household with a single claim uses " - "council_tax_reduction_household_has_non_dep_exemption." + "receives in respect of themselves Attendance Allowance, the care " + "component of Disability Living Allowance, the daily living component " + "of Personal Independence Payment or Armed Forces Independence Payment. " + "The exemption belongs to each applicant: where families share the " + "rent and each claims, one family's disability does not exempt " + "another's claim. The model applies it in the English working-age " + "local schemes." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", ) def formula(benunit, period, parameters): - # SI 2012/2885 Sch 1 para 8(6): no deduction for non-dependants - # "if the applicant or his partner is" blind or receiving one of the - # listed benefits. + # SI 2012/2885 Sch 1 para 8(6): "No deduction is to be made in respect + # of any non-dependants occupying an applicant's dwelling if the + # applicant or his partner is" blind or receiving one of the listed + # benefits "in respect of himself". person = benunit.members - applicant_or_partner = person("is_claimant_or_partner", period) + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) exempting = ( person("is_blind", period) | (person("attendance_allowance", period) > 0) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py deleted file mode 100644 index 6e3980a8eb..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_pensioner.py +++ /dev/null @@ -1,31 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_claim_pensioner(Variable): - value_type = bool - entity = BenUnit - label = "This family's Council Tax Reduction claim is a pensioner's" - documentation = ( - "Whether this family's Council Tax Reduction claim falls under the " - "pension-age rules. A person is a pensioner on their own and their " - "partner's circumstances (SI 2012/2885 reg 3), so where families " - "share the rent and each claims on its part, each claim follows the " - "applicant's own family (council_tax_reduction_pensioner): a " - "working-age sharer is not a pensioner because another family is, " - "and a pensioner sharer stays one beside a mixed-age couple on " - "Universal Credit. Where the household has a single claim, this is " - "that claim's status (council_tax_reduction_household_has_pensioner)." - ) - definition_period = YEAR - reference = ( - "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", - "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", - ) - - def formula(benunit, period, parameters): - household = benunit.household - return where( - household("council_tax_reduction_claims_are_joint", period), - benunit("council_tax_reduction_pensioner", period), - household("council_tax_reduction_household_has_pensioner", period), - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py deleted file mode 100644 index 4b6376b35e..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claim_scheme_supported.py +++ /dev/null @@ -1,42 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_supported_scheme, -) - - -class council_tax_reduction_claim_scheme_supported(Variable): - value_type = bool - entity = BenUnit - label = "The model simulates this family's Council Tax Reduction" - documentation = ( - "Whether the family's Council Tax Reduction is simulated rather than " - "taken as reported (council_tax_benefit). Where the household has a " - "single claim, this is council_tax_reduction_scheme_supported. Where " - "families share the rent and each claims on its part, a claiming " - "family is simulated if the model simulates the scheme its own claim " - "falls under, so a working-age sharer in a council whose working-age " - "scheme is not modelled keeps a reported reduction beside a pensioner " - "whose reduction is simulated. A family that cannot claim there is " - "simulated, at nil, where any claim in the household is, since the " - "simulated claims cover those families' shares of the bill." - ) - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3" - - def formula(benunit, period, parameters): - household = benunit.household - own_scheme = is_supported_scheme( - household("country", period), - benunit("council_tax_reduction_claim_pensioner", period), - household("local_authority", period), - ) - claimant = benunit("council_tax_reduction_claimant_benunit", period) - person = benunit.members - any_claim_simulated = benunit.any( - person.household.any(benunit.project(claimant & own_scheme)) - ) - return where( - household("council_tax_reduction_claims_are_joint", period), - where(claimant, own_scheme, any_claim_simulated), - household("council_tax_reduction_scheme_supported", period), - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py index cb41eb3f43..56c1694e0e 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claims_are_joint.py @@ -8,9 +8,9 @@ class council_tax_reduction_claims_are_joint(Variable): documentation = ( "Whether the household's rent is shared between families, so that " "each family liable for it is jointly liable for the council tax and " - "claims Council Tax Reduction on its own part. Each such claim is " - "then assessed on the applicant's own family: its scheme " - "(council_tax_reduction_claim_pensioner) and its exemption from " + "claims Council Tax Reduction on its own part. Each claim, joint or " + "not, is assessed on the applicant's own family: its scheme " + "(council_tax_reduction_pensioner) and its exemption from " "non-dependant deductions " "(council_tax_reduction_applicant_has_non_dep_exemption)." ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py new file mode 100644 index 0000000000..f2c664d5ba --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_head_applies_alone(Variable): + value_type = bool + entity = BenUnit + label = "Household head applies for Council Tax Reduction alone" + documentation = ( + "Whether this family's Council Tax Reduction applicant is its " + "household head alone, because the head is treated as liable for the " + "council tax but is not the benefit unit's claimant or partner. An " + "example is a grandmother who heads the household and shares a benefit " + "unit with a young couple and their baby: the couple are the benefit " + "unit's claimant and partner, but she is the person liable for the " + "council tax. The applicant's means test then covers the head's own " + "income and circumstances, as a single person with no partner or " + "children, since the model cannot tell whether any other member is " + "the head's partner or child. The benefit unit's means-tested " + "benefits (such as Universal Credit, Income Support and Child Benefit) " + "belong to its claimant and partner, not to the head." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + liable_head = person("council_tax_reduction_liable_person", period) & person( + "is_resolved_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(liable_head & ~claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py deleted file mode 100644 index 3ede96b204..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ /dev/null @@ -1,32 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_non_dep_exemption(Variable): - value_type = bool - entity = Household - label = "CTR household has a non-dependant deduction exemption" - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8" - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - claimant_or_partner = claimant_benunit & person( - "is_claimant_or_partner", period - ) - is_blind = person("is_blind", period) & claimant_or_partner - attendance_allowance = ( - person("attendance_allowance", period) > 0 - ) & claimant_or_partner - pip_daily_living = (person("pip_dl", period) > 0) & claimant_or_partner - dla_care = (person("dla_sc", period) > 0) & claimant_or_partner - armed_forces_independence_payment = ( - person("armed_forces_independence_payment", period) > 0 - ) & claimant_or_partner - return household.any( - is_blind - | attendance_allowance - | pip_daily_living - | dla_care - | armed_forces_independence_payment - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py deleted file mode 100644 index 26931bda3c..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py +++ /dev/null @@ -1,15 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_pensioner(Variable): - value_type = bool - entity = Household - label = "CTR claimant benefit unit is a pensioner" - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3" - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - pensioner = person.benunit("council_tax_reduction_pensioner", period) - return household.any(claimant_benunit & pensioner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py new file mode 100644 index 0000000000..897e43016f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_simulated_claim(Variable): + value_type = bool + entity = Household + label = "A Council Tax Reduction claim in the household is simulated" + documentation = ( + "Whether any family that claims Council Tax Reduction for the " + "household falls under a scheme the model simulates. The simulated " + "awards then cover those families' shares of the council tax. A " + "reported reduction of a family the model does not treat as liable " + "cannot be reconciled with them, so it is not kept." + ) + definition_period = YEAR + + def formula(household, period, parameters): + person = household.members + claimant = person.benunit("council_tax_reduction_claimant_benunit", period) + supported = person.benunit("council_tax_reduction_scheme_supported", period) + return household.any(claimant & supported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 8c6115ce04..32d33f3bdc 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -6,19 +6,24 @@ class council_tax_reduction_pensioner(Variable): entity = BenUnit label = "Pensioner for Council Tax Reduction" documentation = ( - "Whether the family's claim falls under the pension-age Council Tax " - "Reduction rules: the claimant or partner has reached the qualifying " - "age for State Pension Credit, and neither of them is on " - "Income Support, income-based Jobseeker's Allowance or income-related " - "Employment and Support Allowance, or has an award of Universal Credit. " - "A mixed-age couple on Universal Credit is therefore not a pensioner in " - "England, Wales or Scotland. The shared applicable allowance uses this " - "test, as does English scheme selection; Welsh and Scottish scheme " - "selection retains its existing approximations." + "Whether this family's claim falls under the pension-age Council Tax " + "Reduction rules: the applicant or the applicant's partner has reached " + "the qualifying age for State Pension Credit, and neither of them is " + "on Income Support, income-based Jobseeker's Allowance or " + "income-related Employment and Support Allowance, or has an award of " + "Universal Credit. A mixed-age couple on Universal Credit is therefore " + "not a pensioner in England, Wales or Scotland. Each applicant's " + "scheme follows their own family, so where families share the rent " + "and each claims, a working-age family is assessed under the " + "working-age rules even if the household head's family is " + "pension-age, and the reverse. The shared applicable allowance uses " + "this test, as does English scheme selection; Welsh and Scottish " + "scheme selection retains its existing approximations." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/3", "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", @@ -27,7 +32,12 @@ class council_tax_reduction_pensioner(Variable): def formula(benunit, period, parameters): person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) + # The applicant and partner whose claim this is + # (is_council_tax_reduction_applicant_or_partner), not the household + # head's family. + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) # Either member of a couple can claim, so the older one can claim as a # pensioner: "has attained the qualifying age for state pension credit" # (SI 2012/2885 reg 3(1)(a)(i), WSI 2013/3029 reg 3(1)(a)(i), @@ -35,27 +45,38 @@ def formula(benunit, period, parameters): over_qualifying_age = person( "has_attained_state_pension_credit_qualifying_age", period ) - attained_qualifying_age = benunit.any(claimant_or_partner & over_qualifying_age) - income_related_benefit = benunit( - "council_tax_reduction_relevant_income_based_benefit", period + attained_qualifying_age = benunit.any( + applicant_or_partner & over_qualifying_age + ) + # The benefit unit's Income Support, income-based JSA, income-related + # ESA and Universal Credit belong to its claimant and partner. Where + # the household head applies alone, they are not the applicant's. + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + income_related_benefit = ( + benunit("council_tax_reduction_relevant_income_based_benefit", period) + & ~head_applies_alone ) # The award before the benefit cap: the cap reduces an award # (UC Regs 2013 reg 81) rather than removing it. An award counts only - # where a claimant or partner is under the qualifying age, which + # where the applicant or partner is under the qualifying age, which # Universal Credit needs (UC Regs 2013 reg 3(2)(a)). A dependant is - # neither: is_claimant_or_partner presumes a member under 20 and at - # least 16 years younger than the claimant to be their child, so a - # pensioner's 18- or 19-year-old does not count, whether or not - # is_parent identifies the pensioner as the parent. + # neither: outside a head who applies alone, the applicant and partner + # are the benefit unit's claimant and partner, and + # is_claimant_or_partner presumes a member under 20 and at least 16 + # years younger than the claimant to be their child, so a pensioner's + # 18- or 19-year-old does not count, whether or not is_parent + # identifies the pensioner as the parent. # This also stands in for the rules that disregard an award held after # both members reach the qualifying age (SI 2012/2885 reg 3(2); # WSI 2013/3029 reg 3(2); SSI 2021/249 reg 3(2)). England, and Wales # for schemes from 2026-27, also disregard a tax credit migrant's award # (UC (TP) Regs 2014 reg 60A) and Scotland does not; the model does not # model those migrants. - working_age_claimant = benunit.any(claimant_or_partner & ~over_qualifying_age) + working_age_applicant = benunit.any(applicant_or_partner & ~over_qualifying_age) universal_credit_award = ( - benunit("is_uc_entitled", period) & working_age_claimant + benunit("is_uc_entitled", period) + & ~head_applies_alone + & working_age_applicant ) return ( attained_qualifying_age & ~income_related_benefit & ~universal_credit_award diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index 9fb0699fa0..915e814e52 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -6,14 +6,21 @@ class council_tax_reduction_scheme_supported(Variable): value_type = bool - entity = Household + entity = BenUnit label = "Supported CTR scheme is available" + documentation = ( + "Whether the model simulates the Council Tax Reduction scheme that " + "applies to this family's claim: the pension-age scheme in England, " + "the Scottish and Welsh schemes, and the working-age schemes of the " + "English councils it models. The scheme follows the family's own " + "pensioner status, so families in one household can fall under " + "different schemes. A claiming family whose scheme is not simulated " + "falls back to its reported reduction (council_tax_benefit)." + ) definition_period = YEAR - def formula(household, period, parameters): - country = household("country", period) - has_pensioner = household( - "council_tax_reduction_household_has_pensioner", period - ) - local_authority = household("local_authority", period) + def formula(benunit, period, parameters): + country = benunit.household("country", period) + has_pensioner = benunit("council_tax_reduction_pensioner", period) + local_authority = benunit.household("local_authority", period) return is_supported_scheme(country, has_pensioner, local_authority) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..1e8eddfa46 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class is_council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Council Tax Reduction applicant or partner" + documentation = ( + "Whether this person is the applicant, or the applicant's partner, " + "for the Council Tax Reduction means test of their family. The " + "applicant is the person liable for the council tax " + "(council_tax_reduction_liable_person), and the means test covers the " + "applicant's own income and their partner's. " + "Where the family's liable person is the benefit unit's claimant or " + "partner, this is the benefit unit's claimant and partner, whatever " + "their ages: a partner under 18 is not liable, but their income " + "counts. Where the liable person is a household head who is not the " + "benefit unit's claimant or partner, it is the head alone (see " + "council_tax_reduction_head_applies_alone). A family with no liable " + "person cannot claim; for it this is the benefit unit's claimant and " + "partner." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/26", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(person, period, parameters): + # SSI 2012/319 reg 21(1), SI 2012/2885 Sch 1 para 11 and the Welsh + # and 2021 Scottish equivalents: the income and capital of the + # applicant and of the applicant's partner. + head_applies_alone = person.benunit( + "council_tax_reduction_head_applies_alone", period + ) + liable_head = person("council_tax_reduction_liable_person", period) & person( + "is_resolved_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return where(head_applies_alone, liable_head, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index dd830ab861..f660dc749c 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -30,9 +30,9 @@ def formula(benunit, period, parameters): wales_ctr = local_authority_parameters.wales.council_tax_reduction country = benunit.household("country", period) - # Each claim's scheme follows the applicant's own family where - # families claim on their shares (SI 2012/2885 reg 3). - has_pensioner = benunit("council_tax_reduction_claim_pensioner", period) + # Each claim's scheme follows the applicant's own family (SI 2012/2885 + # reg 3). + has_pensioner = benunit("council_tax_reduction_pensioner", period) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) scotland = is_scotland_scheme(country) wales = is_wales_scheme(country) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index cb9d26b20c..46afbc7d86 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -23,7 +23,7 @@ def formula(benunit, period, parameters): working_age = is_kingston_upon_thames_working_age( household("local_authority", period), household("country", period), - benunit("council_tax_reduction_claim_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py index 72118c4ea1..616274593b 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, - single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_working_age, + is_kingston_upon_thames_area, ) @@ -21,15 +20,15 @@ def formula(person, period, parameters): period ).gov.local_authorities.kingston_upon_thames.council_tax_reduction household = person.household - working_age = is_kingston_upon_thames_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_kingston_upon_thames_area( household("local_authority", period), household("country", period), - single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py index 28cefbb8fe..ef6ec3b1c5 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py @@ -18,7 +18,7 @@ def formula(benunit, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction local_authority = benunit.household("local_authority", period) country = benunit.household("country", period) - has_pensioner = benunit("council_tax_reduction_claim_pensioner", period) + has_pensioner = benunit("council_tax_reduction_pensioner", period) working_age = is_merton_working_age(local_authority, country, has_pensioner) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py index cdcc1d683e..8023cec88d 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, - single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_merton_working_age, + is_merton_area, ) @@ -19,15 +18,15 @@ class merton_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction household = person.household - working_age = is_merton_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_merton_area( household("local_authority", period), household("country", period), - single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py index 426f9df32e..4abcbf6e62 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py @@ -20,7 +20,7 @@ def formula(benunit, period, parameters): working_age = is_newham_working_age( household("local_authority", period), household("country", period), - benunit("council_tax_reduction_claim_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py index 836bc636a5..465b7b5a78 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, - single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_newham_working_age, + is_newham_area, ) @@ -19,15 +18,15 @@ class newham_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.newham.council_tax_reduction household = person.household - working_age = is_newham_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_newham_area( household("local_authority", period), household("country", period), - single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index ef2ba846ec..208dab3929 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -18,7 +18,7 @@ def formula(benunit, period, parameters): working_age = is_oxford_working_age( household("local_authority", period), household("country", period), - benunit("council_tax_reduction_claim_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) is_household_head_benunit = benunit( "council_tax_reduction_claimant_benunit", period diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py index bbd2c28f6c..64452ff088 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, - single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_oxford_working_age, + is_oxford_area, ) @@ -19,16 +18,16 @@ class oxford_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.oxford.council_tax_reduction household = person.household - working_age = is_oxford_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_oxford_area( household("local_authority", period), household("country", period), - single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=False, exempt_uc_no_earned_income=False, ) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index 82ad02822b..eaad011382 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -21,7 +21,7 @@ def formula(benunit, period, parameters): working_age = is_westminster_working_age( household("local_authority", period), household("country", period), - benunit("council_tax_reduction_claim_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py index 4326e47f16..86533c51d7 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( normal_gross_income_non_dep_deduction, - single_claim_is_pensioner, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_westminster_working_age, + is_westminster_area, ) @@ -19,15 +18,15 @@ class westminster_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.westminster.council_tax_reduction household = person.household - working_age = is_westminster_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_westminster_area( household("local_authority", period), household("country", period), - single_claim_is_pensioner(household, period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, )