From ae3e5e0eaacc6b5c4fd9e827bf569cc51b172d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:30:01 -0400 Subject: [PATCH 01/16] Assess each Council Tax Reduction claim under its own family's scheme and exemption In a shared-rent household every family liable for the rent claims, but the pensioner scheme and the non-dependant exemption were picked once per household from the head's family. SI 2012/2885 reg 3 (and SI 2012/2886 Sch para 3, SI 2013/3029 reg 3, SSI 2021/249 reg 3, SSI 2012/319 reg 12) define "pensioner" per person and partner, and the exemption applies where "the applicant or his partner" is blind or gets a listed benefit (SI 2012/2885 Sch 1 para 8(6) and equivalents). - council_tax_reduction_pensioner (BenUnit) replaces the household flag; the name and entity match #1944 and #1966, which extend the same definition. - council_tax_reduction_applicant_has_non_dep_exemption (BenUnit) replaces the household exemption; the councils' schemes apply it per claim. - council_tax_reduction_scheme_supported is per family, so each family falls back to its reported reduction only where its own scheme is unmodelled. - Councils' person-level non-dependant deductions no longer depend on the head's family; each claimant's own scheme decides whether to use them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ctr-per-family-scheme.fixed.md | 1 + .../claimant_family_scheme.yaml | 461 ++++++++++++++++++ .../claimant_household_head.yaml | 14 +- .../legacy_claimant_non_dep_exemption.yaml | 16 +- ...ction_claimant_family_scheme_properties.py | 300 ++++++++++++ .../variables/gov/dwp/council_tax_benefit.py | 2 +- .../council_tax_reduction/_legacy.py | 26 +- .../council_tax_reduction/config.py | 39 +- ...duction_applicant_has_non_dep_exemption.py | 43 ++ ...duction_household_has_non_dep_exemption.py | 32 -- ...l_tax_reduction_household_has_pensioner.py | 13 - .../council_tax_reduction_pensioner.py | 34 ++ .../council_tax_reduction_scheme_supported.py | 21 +- ...simulated_council_tax_reduction_benunit.py | 5 +- ...gston_upon_thames_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../merton_council_tax_reduction.py | 4 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../newham_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../oxford_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../westminster_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- 24 files changed, 944 insertions(+), 115 deletions(-) create mode 100644 changelog.d/ctr-per-family-scheme.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py 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/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..c364aad9fd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -0,0 +1,461 @@ +# 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: 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 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 e41a7d5eb3..69b7214a85 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 @@ -301,7 +301,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] @@ -334,13 +336,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/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_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..e6bac255c1 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -0,0 +1,300 @@ +"""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: + +1. Own members: a family is a pensioner exactly when its claimant or partner + has reached State Pension 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. +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 award unchanged. +5. Bounds: a family that cannot claim gets no simulated reduction, and the + household's reduction never exceeds its council tax. +""" + +import numpy as np +from hypothesis import HealthCheck, 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) + + +@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)), + ) + + +@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))), + **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): + attributes = {"age": age, "employment_income": earnings} + if disability_input is not None: + attributes[disability_input] = DISABILITY[disability_input] + return attributes + + +def build(population, alone=False, perturb_others=False): + """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. + """ + people, benunits, homes = {}, {}, {} + facts = dict(country=[], local_authority=[], role=[], target=[], house=[]) + 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 + 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]) + people[pid]["is_household_head"] = ( + alone or fam["role"] == "head" + ) and i == 0 + 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 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) + 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 + + +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. + claimant_or_partner = calc(sim, "is_claimant_or_partner") + assert np.array_equal( + pensioner, + any_in_benunit(sim, claimant_or_partner & calc(sim, "is_SP_age")), + ) + 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. + alone_situation, alone_facts = build(population, alone=True) + 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. Bounds. (No family has a reported reduction to fall back on here.) + assert np.all(simulated[~claimant] == 0) + household_reduction = calc(sim, "council_tax_reduction") + assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + + +@PROPERTY_SETTINGS +@given(population) +def test_other_families_do_not_change_a_claim(population): + situation, facts = build(population) + perturbed_situation, _ = build(population, perturb_others=True) + before = Simulation(situation=situation) + 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", + "council_tax_benefit", + ): + np.testing.assert_allclose( + calc(before, variable)[target], + calc(after, variable)[target], + atol=1e-6, + err_msg=variable, + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index d2861ead5b..b80f01b1f0 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -9,7 +9,7 @@ class council_tax_benefit(Variable): unit = GBP def formula(benunit, period, parameters): - supported = benunit.household("council_tax_reduction_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) 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 966d230ff7..599c266543 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 @@ -91,17 +91,31 @@ 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) - return (deductions_in_household - deduction_for_benunit) * share + # 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 + ) 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", @@ -128,9 +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), ) - claimant_exempt = person.household( - "council_tax_reduction_household_has_non_dep_exemption", period - ) full_time_student = is_full_time_student_non_dep(person, period) income_based_benefit = ( (person.benunit("income_support", period) > 0) @@ -141,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 new file mode 100644 index 0000000000..4a539258a2 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "No non-dependant deductions from this family's Council Tax Reduction" + 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 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 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 + claimant_or_partner = person("is_claimant_or_partner", period) + exempting = ( + person("is_blind", period) + | (person("attendance_allowance", period) > 0) + | (person("pip_dl", period) > 0) + | (person("dla_sc", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + ) + return benunit.any(claimant_or_partner & exempting) 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 555231a5ab..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py +++ /dev/null @@ -1,13 +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 has a pension-age member" - 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)) 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 new file mode 100644 index 0000000000..d958b9f680 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_pensioner(Variable): + value_type = bool + entity = BenUnit + label = "Pensioner for Council Tax Reduction" + documentation = ( + "Whether this family's claim falls under the pension-age Council Tax " + "Reduction rules: the claimant or partner has reached State Pension " + "age. 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 further condition that " + "neither the claimant nor a partner is on Income Support, income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance or Universal Credit is not modelled here." + ) + 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", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 reg 3(1)(a)(i): "he has attained the qualifying age for + # state pension credit". is_SP_age stands in for that age, as + # elsewhere in the model. + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(claimant_or_partner & person("is_SP_age", period)) 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..c559fe9bb9 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 family whose scheme is not simulated keeps its " + "reported reduction." + ) 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/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 6f3714fee8..91fdb6cf6f 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,8 @@ def formula(benunit, period, parameters): wales_ctr = local_authority_parameters.wales.council_tax_reduction country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + # Each claim's scheme follows the applicant's own family. + 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 7f9b1b7663..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), - household("council_tax_reduction_household_has_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 c8ae7f4df0..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 @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_working_age, + is_kingston_upon_thames_area, ) @@ -20,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), - household("council_tax_reduction_household_has_pensioner", 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 03012a5f8a..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,9 +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.household( - "council_tax_reduction_household_has_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 2907e2a700..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 @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_merton_working_age, + is_merton_area, ) @@ -18,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), - household("council_tax_reduction_household_has_pensioner", 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 c35482476f..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), - household("council_tax_reduction_household_has_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 0e4b9db546..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 @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_newham_working_age, + is_newham_area, ) @@ -18,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), - household("council_tax_reduction_household_has_pensioner", 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 abeea551dc..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), - household("council_tax_reduction_household_has_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 61c80cecbb..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 @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_oxford_working_age, + is_oxford_area, ) @@ -18,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), - household("council_tax_reduction_household_has_pensioner", 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 3c86931aee..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), - household("council_tax_reduction_household_has_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 d5666a67a7..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 @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_westminster_working_age, + is_westminster_area, ) @@ -18,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), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) From 45719004f8e0e34c402a026eb0b0096a959a29d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:03:54 -0400 Subject: [PATCH 02/16] WIP: assess the Council Tax Reduction applicant and partner in the means test Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 440 ++++++++++++++++++ ...ncil_tax_reduction_applicant_properties.py | 382 +++++++++++++++ .../council_tax_reduction/_legacy.py | 11 +- ...council_tax_reduction_applicable_amount.py | 30 +- ...council_tax_reduction_applicable_income.py | 43 +- ...ouncil_tax_reduction_head_applies_alone.py | 38 ++ ...duction_household_has_non_dep_exemption.py | 11 +- ...l_tax_reduction_household_has_pensioner.py | 19 +- .../council_tax_reduction_premiums.py | 51 ++ ...reduction_relevant_income_based_benefit.py | 14 +- ...ncil_tax_reduction_applicant_or_partner.py | 46 ++ ...gston_upon_thames_council_tax_reduction.py | 3 +- .../oxford_council_tax_reduction.py | 12 +- .../westminster_council_tax_reduction.py | 3 +- 14 files changed, 1077 insertions(+), 26 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml new file mode 100644 index 0000000000..bf25b60348 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -0,0 +1,440 @@ +# The Council Tax Reduction means test covers the applicant's own income and +# their partner's (SSI 2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 +# para 11; WSI 2013/3029 Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36). +# The applicant is the person liable for the council tax. Where that is a +# household head who is not the benefit unit's claimant or partner (here a +# grandmother sharing a benefit unit with a young couple and their baby), the +# means test is the head's own, as a single person; the benefit unit's +# benefits belong to the couple. None of these cases sets applicable income. +# +# Rates for 2026 (parameters): pension-age single personal allowance +# £256/week, working-age single aged 25 or over £95.55/week, couple aged 18 +# or over £150.15/week, disability premium (single) £44.85/week, severe +# disability premium (single) £86.05/week; 52 weeks. Scotland, Wales and the +# English pensioner scheme: maximum reduction 100%, withdrawal 20%. Newham: +# maximum 70%, withdrawal 25%. Personal allowance £12,570, basic rate 20%. + +- name: A grandmother head with a young couple in her benefit unit and no income of her own gets the full reduction + 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + council_tax_reduction_head_applies_alone: [true] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + # The couple's Child Benefit for the baby is theirs, not hers. + council_tax_reduction_applicable_income: [0] + # A single pensioner: 256 * 52. + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + council_tax_reduction: 1_800 + +- name: The same grandmother with a £100,000 private pension gets no reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 100_000 + 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_applicable_amount: [13_312] + # Her pension after tax is far above £13,312, and 20% of the excess is + # more than the £1,800 council tax. + simulated_council_tax_reduction_benunit: [0] + council_tax_reduction: 0 + +- name: In England a grandmother head's own £20,000 pension is withdrawn at 20% above her allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + employment_income: 10_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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_household_has_pensioner: true + # Her pension less income tax of (20,000 - 12,570) * 20% = 1,486. The + # young mother's earnings are not hers. + council_tax_reduction_applicable_income: [18_514] + council_tax_reduction_applicable_amount: [13_312] + # 1,800 - (18,514 - 13,312) * 20%. + simulated_council_tax_reduction_benunit: [759.60] + +- name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + jsa_income: 4_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_relevant_income_based_benefit: [false] + # Neither the couple's JSA nor their Child Benefit is her income. + council_tax_reduction_applicable_income: [18_514] + simulated_council_tax_reduction_benunit: [759.60] + +- name: A head who applies alone is a single person, with premiums on her own disability only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + is_disabled_for_benefits: true + is_carer_for_benefits: true + 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # Disability (44.85) and severe disability (86.05) premiums at the single + # rate: (44.85 + 86.05) * 52. The young mother's disability and caring + # do not give the grandmother a premium. + council_tax_reduction_premiums: [6_806.80] + # Single aged 25 or over, 95.55 * 52 = 4,968.60, plus the premiums. + council_tax_reduction_applicable_amount: [11_775.40] + +- name: A blind grandmother head who applies alone has no non-dependant deduction in Merton + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_blind: 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 + son: + age: 30 + is_household_head: false + employment_income: 30_000 + weekly_hours: 40 + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + son: + members: [son] + claims_all_entitled_benefits: false + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_household_has_pensioner: false + # The exemption is the applicant's or partner's (SI 2012/2885 Sch 1 para + # 8(6); the Merton scheme follows it). + council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, false, true] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + merton_council_tax_reduction: [1_800, 0] + +- name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_applicable_income: [0] + # Single aged 25 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: [4_968.60] + # No excess income: 1,800 * 70%. + newham_council_tax_reduction: [1_260] + +- name: A partner under 18 is not liable, but their income counts in the couple's means test + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_benunit_head: false + partner: + # The benefit unit's head, so the HBAI fallback does not read the + # 17-year-old as a dependent child. + age: 17 + is_household_head: false + is_benunit_head: true + employment_income: 10_000 + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [true, false] + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [true, true] + # Below the personal allowance and the National Insurance threshold. + council_tax_reduction_applicable_income: [10_000] + # Couple aged 18 or over: 150.15 * 52 = 7,807.80. + council_tax_reduction_applicable_amount: [7_807.80] + # 1,800 - (10,000 - 7,807.80) * 20%. + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A head under 18 with an adult partner claims through the partner, and the head's income counts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 17 + is_household_head: true + is_benunit_head: true + employment_income: 10_000 + partner: + age: 18 + is_household_head: false + is_benunit_head: false + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [false, true] + is_council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicable_income: [10_000] + council_tax_reduction_applicable_amount: [7_807.80] + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A pensioner who is not the applicant or partner does not make a young couple's claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: false + mother: + age: 19 + is_parent: true + is_household_head: true + father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, mother, father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The mother is the head and the couple are the benefit unit's claimant + # and partner, so they are the applicant and partner. + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [false, true, true, false] + council_tax_reduction_household_has_pensioner: false + # A working-age couple aged 18 or over: 150.15 * 52, not the pension-age + # couple rate. + council_tax_reduction_applicable_amount: [7_807.80] diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py new file mode 100644 index 0000000000..895d3b2607 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -0,0 +1,382 @@ +"""Property-based tests for whose circumstances the Council Tax Reduction +means test assesses. + +The means test covers the applicant's own income and their partner's (SSI +2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 para 11; WSI 2013/3029 +Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36), and the applicant is the +person liable for the council tax. The model takes the household head as +liable (council_tax_reduction_liable_person). Usually the head is the +benefit unit's claimant or partner, but a grandmother who heads the household +can share a benefit unit with a young couple who are its claimant and +partner (#1896's parent-couple rule). She then applies alone. + +Invariants, for any generated population of households: heads alone, in a +couple (with partners aged 16 and over), or sharing a benefit unit with two +parents aged 16 to 30 and their baby; another family, sometimes a sharer; +income-based Jobseeker's Allowance and Universal Credit supplied for the +head's benefit unit; disability and caring flags; all eight modelled schemes. + +1. Head's income counts: raising the liable household head's own private + pension by £5,000 raises their family's applicable income. Raising a + young parent's earnings by £5,000 leaves the family's applicable income + unchanged where the head applies alone. +2. Monotone: raising the head's pension never raises any family's simulated + reduction. +3. High income: with £250,000 more pension, the head's family gets no + simulated reduction, unless an income-based benefit passports it or its + applicant has a Universal Credit award (both supplied as inputs here; + those routes are outside this invariant). +4. Identity: a liable head is always an applicant or partner of their own + family. Where the head does not apply alone, the applicant and partner + are exactly the benefit unit's claimant and partner (ordinary families + are unchanged). Where the head applies alone, the head is the only one. +5. Differential: a head over State Pension age sharing a benefit unit with + parents aged 16 or 17 and their baby has the same applicable income, + applicable amount, premiums, scheme, exemption and simulated reduction as + the same head entered as her own benefit unit, with the young family as + another. The two inputs describe the same people, so the law gives the + same answer. +""" + +import numpy as np +from hypothesis import HealthCheck, 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"), +] +SHAPES = ["alone", "couple", "young_parents"] +FLAGS = [ + "is_disabled_for_benefits", + "is_severely_disabled_for_benefits", + "is_carer_for_benefits", + "is_blind", +] +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +head_age = st.one_of( + st.sampled_from([18, 25, 40, 60, 66, 68, 80]), st.integers(18, 100) +) +PENSION_STEP = 5_000 +HIGH_PENSION = 250_000 + + +@st.composite +def households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + shape=draw(st.sampled_from(SHAPES)), + head_age=draw(head_age), + head_pension=draw(money), + head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), + partner_age=draw(st.integers(16, 100)), + parent_age=draw(st.integers(16, 30)), + other_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, None, 3_000])), + universal_credit=draw(st.sampled_from([None, None, 5_000])), + other_family=draw(st.sampled_from([None, "non_dependant", "sharer"])), + other_age=draw(st.integers(16, 90)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + ) + + +def person_input(values): + return {key: {YEAR: value} for key, value in values.items()} + + +def add_household(people, benunits, homes, name, house, variant): + """Add one household. variant: "base", "pension", "high_pension" or + "parent_earnings" (the young mother's earnings raised).""" + country, local_authority = house["scheme"] + pension = house["head_pension"] + { + "pension": PENSION_STEP, + "high_pension": HIGH_PENSION, + }.get(variant, 0) + head = f"{name}_head" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=pension, + **house["head_flags"], + ) + ) + family = [head] + if house["shape"] == "couple": + people[f"{name}_partner"] = person_input( + dict( + age=house["partner_age"], + is_household_head=False, + employment_income=house["other_earnings"][0], + ) + ) + family.append(f"{name}_partner") + elif house["shape"] == "young_parents": + for i, parent in enumerate(["mother", "father"]): + earnings = house["other_earnings"][i] + if variant == "parent_earnings" and parent == "mother": + earnings += PENSION_STEP + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=earnings, + ) + ) + family.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + family.append(f"{name}_baby") + head_benunit = dict( + members=family, + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + ) + for benefit in ["jsa_income", "universal_credit"]: + if house[benefit] is not None: + head_benunit[benefit] = {YEAR: house[benefit]} + benunits[f"{name}_family"] = head_benunit + members = list(family) + if house["other_family"] is not None: + other = f"{name}_other" + people[other] = person_input( + dict( + age=house["other_age"], + is_household_head=False, + employment_income=house["other_earnings"][1], + ) + ) + benunits[f"{name}_other_family"] = dict( + members=[other], + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + liable_for_share_of_household_rent={ + YEAR: house["other_family"] == "sharer" + }, + ) + members.append(other) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = members + + +VARIANTS = ["base", "pension", "high_pension", "parent_earnings"] + + +def simulate(population): + """One simulation holding every household in every variant.""" + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for variant in VARIANTS: + add_household(people, benunits, homes, f"h{h}_{variant}", house, variant) + return Simulation( + situation=dict(people=people, benunits=benunits, households=homes) + ) + + +def by_benunit(sim, variable, names): + """A benefit-unit variable for the named benefit units.""" + values = sim.calculate(variable, YEAR) + ids = list(sim.populations["benunit"].ids) + return np.array([values[ids.index(name)] for name in names]) + + +@given(st.lists(households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_council_tax_reduction_assesses_the_liable_head(population): + sim = simulate(population) + head_family = { + variant: [f"h{h}_{variant}_family" for h in range(len(population))] + for variant in VARIANTS + } + + def family(variable, variant): + return by_benunit(sim, variable, head_family[variant]) + + head_alone = family("council_tax_reduction_head_applies_alone", "base") + + # 1. The head's pension counts; a young parent's earnings do not where + # the head applies alone. + income = family("council_tax_reduction_applicable_income", "base") + assert (family("council_tax_reduction_applicable_income", "pension") > income).all() + parent_income = family("council_tax_reduction_applicable_income", "parent_earnings") + assert np.allclose(parent_income[head_alone], income[head_alone]) + + # 2. Monotone in the head's pension, for every family. + all_ids = list(sim.populations["benunit"].ids) + simulated = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + + def every_family(variant): + return np.array( + [simulated[all_ids.index(name)] for name in _all_names(population, variant)] + ) + + base_ctr = every_family("base") + for variant in ["pension", "high_pension"]: + assert (every_family(variant) <= base_ctr + 1e-6).all() + + # 3. No reduction on a very high income, outside the passport and UC + # routes. + passported = family("council_tax_reduction_relevant_income_based_benefit", "base") + universal_credit = family("universal_credit", "base") > 0 + high = family("simulated_council_tax_reduction_benunit", "high_pension") + assessed_on_income = ~passported & ~(universal_credit & ~head_alone) + assert (high[assessed_on_income] == 0).all() + + # 4. Identity. + liable = sim.calculate("council_tax_reduction_liable_person", YEAR) + head = sim.calculate("council_tax_reduction_household_head", YEAR) + applicant = sim.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) + alone = sim.populations["person"].benunit( + "council_tax_reduction_head_applies_alone", YEAR + ) + assert (applicant[liable & head]).all() + assert (applicant[~alone] == claimant_or_partner[~alone]).all() + assert (applicant[alone] == (liable & head)[alone]).all() + + +def _all_names(population, variant): + """Every benefit unit of every household in one variant, in order.""" + names = [] + for h, house in enumerate(population): + names.append(f"h{h}_{variant}_family") + if house["other_family"] is not None: + names.append(f"h{h}_{variant}_other_family") + return names + + +@st.composite +def grandmother_households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + head_age=draw(st.integers(68, 100)), + head_pension=draw(money), + head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), + parent_age=draw(st.integers(16, 17)), + parent_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, 3_000])), + universal_credit=draw(st.sampled_from([None, 5_000])), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + ) + + +def add_grandmother_household(people, benunits, homes, name, house, split): + country, local_authority = house["scheme"] + head = f"{name}_grandmother" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=house["head_pension"], + **house["head_flags"], + ) + ) + young = [] + for i, parent in enumerate(["mother", "father"]): + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=house["parent_earnings"][i], + ) + ) + young.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + young.append(f"{name}_baby") + young_benefits = { + benefit: {YEAR: house[benefit]} + for benefit in ["jsa_income", "universal_credit"] + if house[benefit] is not None + } + common = dict( + claims_all_entitled_benefits={YEAR: True}, would_claim_uc={YEAR: False} + ) + if split: + benunits[f"{name}_family"] = dict(members=[head], **common) + benunits[f"{name}_young"] = dict(members=young, **common, **young_benefits) + else: + benunits[f"{name}_family"] = dict( + members=[head] + young, **common, **young_benefits + ) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = [head] + young + + +DIFFERENTIAL_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_reduction_premiums", + "council_tax_reduction_relevant_income_based_benefit", + "simulated_council_tax_reduction_benunit", +] + + +@given(st.lists(grandmother_households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_head_applying_alone_matches_her_own_benefit_unit(population): + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for split in [False, True]: + add_grandmother_household( + people, + benunits, + homes, + f"h{h}_{'split' if split else 'merged'}", + house, + split, + ) + sim = Simulation(situation=dict(people=people, benunits=benunits, households=homes)) + merged = [f"h{h}_merged_family" for h in range(len(population))] + split = [f"h{h}_split_family" for h in range(len(population))] + assert by_benunit(sim, "council_tax_reduction_head_applies_alone", merged).all() + assert not by_benunit(sim, "council_tax_reduction_head_applies_alone", split).any() + for variable in DIFFERENTIAL_VARIABLES: + assert np.allclose( + by_benunit(sim, variable, merged), by_benunit(sim, variable, split) + ), variable + household_ids = list(sim.populations["household"].ids) + for variable in [ + "council_tax_reduction_household_has_pensioner", + "council_tax_reduction_household_has_non_dep_exemption", + "council_tax_reduction", + ]: + values = sim.calculate(variable, YEAR) + merged_values = [ + values[household_ids.index(f"h{h}_merged")] for h in range(len(population)) + ] + split_values = [ + values[household_ids.index(f"h{h}_split")] for h in range(len(population)) + ] + assert np.allclose(merged_values, split_values), variable 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 966d230ff7..f2efdff84c 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,6 +10,15 @@ def is_full_time_student_non_dep(person, period): ) +def applicant_has_universal_credit(benunit, period): + """Whether the Council Tax Reduction applicant has the benefit unit's + Universal Credit award. The award belongs to the benefit unit's claimant + and partner, so not to a household head who applies alone.""" + return (benunit("universal_credit", period) > 0) & ~benunit( + "council_tax_reduction_head_applies_alone", period + ) + + def legacy_council_tax_reduction( benunit, period, @@ -25,7 +34,7 @@ def legacy_council_tax_reduction( applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) uc_applicable_amount = benunit("uc_maximum_amount", period) uc_applicable_income = ( benunit("uc_earned_income", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index 8d64c045ab..1ca4fe5bd1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -5,26 +5,44 @@ class council_tax_reduction_applicable_amount(Variable): value_type = float entity = BenUnit label = "applicable Council Tax Reduction amount" + documentation = ( + "The applicant's personal allowance and premiums. Ages, couple status " + "and State Pension age are those of the Council Tax Reduction " + "applicant and partner (is_council_tax_reduction_applicant_or_partner). " + "Where the household head applies alone, the head is a single person " + "with no partner or children, whatever the benefit unit's claimant " + "and partner are." + ) definition_period = YEAR unit = GBP reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7", "https://www.legislation.gov.uk/ssi/2021/249/schedule/1", ) def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.allowances - any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) - eldest_age = benunit("eldest_claimant_or_partner_age", period) + person = benunit.members + applicant = person("is_council_tax_reduction_applicant_or_partner", period) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + any_over_SP_age = benunit.any(applicant & person("is_SP_age", period)) + eldest_age = benunit.max(where(applicant, person("age", period), -np.inf)) older_age_threshold = p.age_threshold.older younger_age_threshold = p.age_threshold.younger u_18 = eldest_age < younger_age_threshold u_25 = eldest_age < older_age_threshold o_25 = (eldest_age >= older_age_threshold) & ~any_over_SP_age o_18 = (eldest_age >= younger_age_threshold) & ~any_over_SP_age - single = benunit("is_single_person", period) - couple = benunit("is_couple", period) - lone_parent = benunit("is_lone_parent", period) + # A head applying alone has no partner, and the benefit unit's + # children are its claimant's and partner's. + single = where(head_applies_alone, True, benunit("is_single_person", period)) + couple = where(head_applies_alone, False, benunit("is_couple", period)) + lone_parent = where( + head_applies_alone, False, benunit("is_lone_parent", period) + ) single_personal_allowance = ( u_25 * p.single.younger + o_25 * p.single.older @@ -45,5 +63,5 @@ def formula(benunit, period, parameters): + couple * couple_personal_allowance + lone_parent * lone_parent_personal_allowance ) * WEEKS_IN_YEAR - premiums = benunit("benefits_premiums", period) + premiums = benunit("council_tax_reduction_premiums", period) return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 444ebecc09..ca46d9fd3b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -5,18 +5,40 @@ class council_tax_reduction_applicable_income(Variable): value_type = float entity = BenUnit label = "relevant income for Council Tax Reduction means test" + documentation = ( + "The income of the Council Tax Reduction applicant and partner " + "(is_council_tax_reduction_applicant_or_partner), with, as the model " + "did before, the income of the family's children or young persons. " + "The benefit unit's own benefits (Child Benefit, Income Support, " + "income-based Jobseeker's Allowance, income-related Employment and " + "Support Allowance, Universal Credit and tax credits) count only where " + "the applicant is the benefit unit's claimant: where the household " + "head applies alone, those awards and children belong to the benefit " + "unit's claimant and partner, not to the head." + ) definition_period = YEAR unit = GBP + reference = ( + "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): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (CTR (Prescribed Requirements) (England) - # Regs 2012 Sch 1 para 11); dropping dependants' own income is a follow-up. - # Anyone else in the benefit unit does not count. + # Members whose income counts: the applicant and partner and, as the model + # did before, the programme's own children or young persons. The regulations + # count only the applicant's and partner's (CTR (Prescribed Requirements) + # (England) Regs 2012 Sch 1 para 11; SSI 2012/319 reg 21(3) and SSI + # 2021/249 reg 36(2) exclude a child's); dropping dependants' own income is + # a follow-up. Anyone else in the benefit unit does not count. person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_young_person_for_legacy_benefits", period + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + applicant_family = ~head_applies_alone + members = person("is_council_tax_reduction_applicant_or_partner", period) | ( + person("is_child_or_young_person_for_legacy_benefits", period) + & benunit.project(applicant_family) ) benunit_means_tested_benefits = [ "child_benefit", @@ -42,14 +64,15 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - benefits = add_for_members( - benunit, period, benunit_means_tested_benefits, members + # The benefit unit's awards are its claimant's and partner's. + benefits = applicant_family * add( + benunit, period, benunit_means_tested_benefits ) income = add_for_members(benunit, period, income_components, members) personal_benefit_income = add_for_members( benunit, period, personal_benefits, members ) - credits = add_for_members(benunit, period, ["tax_credits"], members) + credits = applicant_family * add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefit_income + credits + benefits if not bi.interactions.include_in_means_tests: 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..774c5b2853 --- /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( + "council_tax_reduction_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 index 3ede96b204..d895972685 100644 --- 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 @@ -5,14 +5,23 @@ class council_tax_reduction_household_has_non_dep_exemption(Variable): value_type = bool entity = Household label = "CTR household has a non-dependant deduction exemption" + documentation = ( + "Whether no non-dependant deduction is made from the household head's " + "family's Council Tax Reduction because the applicant or partner " + "(is_council_tax_reduction_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." + ) 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) + # SI 2012/2885 Sch 1 para 8(6): "the applicant or his partner". claimant_or_partner = claimant_benunit & person( - "is_claimant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) is_blind = person("is_blind", period) & claimant_or_partner attendance_allowance = ( 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 index 555231a5ab..8d4cb9116d 100644 --- 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 @@ -4,10 +4,25 @@ class council_tax_reduction_household_has_pensioner(Variable): value_type = bool entity = Household - label = "CTR claimant benefit unit has a pension-age member" + label = "CTR household head's family has a pension-age applicant or partner" + documentation = ( + "Whether the Council Tax Reduction applicant or partner " + "(is_council_tax_reduction_applicant_or_partner) in the household " + "head's family is over State Pension age, which stands in for the " + "qualifying age for State Pension Credit." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + ) 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)) + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + return household.any( + claimant_benunit & applicant_or_partner & person("is_SP_age", period) + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py new file mode 100644 index 0000000000..2510681121 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_premiums(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction disability and carer premiums" + documentation = ( + "The disability and carer premiums in the Council Tax Reduction " + "applicable amount, which are the applicant's. Where the applicant is " + "the benefit unit's claimant, these are the benefit unit's premiums " + "(benefits_premiums). Where the household head applies alone " + "(council_tax_reduction_head_applies_alone), they are the premiums a " + "single claimant would get on the head's own circumstances: the " + "single rates of the disability, enhanced disability, severe " + "disability and carer premiums, each on the same person-level " + "condition benefits_premiums uses." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", + ) + + def formula(benunit, period, parameters): + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + person = benunit.members + head = person( + "is_council_tax_reduction_applicant_or_partner", period + ) & benunit.project(head_applies_alone) + + def head_qualifies(condition): + return benunit.any(head & person(condition, period)) + + disability = parameters(period).gov.dwp.disability_premia + carer = parameters(period).gov.dwp.carer_premium + weekly_single_premiums = ( + head_qualifies("is_disabled_for_benefits") * disability.disability_single + + head_qualifies("is_enhanced_disabled_for_benefits") + * disability.enhanced_single + + head_qualifies("is_severely_disabled_for_benefits") + * disability.severe_single + + head_qualifies("is_carer_for_benefits") * carer.single + ) + return where( + head_applies_alone, + weekly_single_premiums * WEEKS_IN_YEAR, + benunit("benefits_premiums", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py index 74df5864f3..0fb48ca319 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -5,7 +5,19 @@ class council_tax_reduction_relevant_income_based_benefit(Variable): value_type = bool entity = BenUnit label = "CTR claimant has an income-based passporting benefit" + documentation = ( + "Whether the Council Tax Reduction applicant or partner is on Income " + "Support, income-based Jobseeker's Allowance or income-related " + "Employment and Support Allowance. These are the benefit unit's " + "awards, which belong to its claimant and partner, so they do not " + "passport a household head who applies alone " + "(council_tax_reduction_head_applies_alone)." + ) definition_period = YEAR def formula(benunit, period, parameters): - return add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + income_based_benefit = ( + add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + ) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + return income_based_benefit & ~head_applies_alone 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..a54e31eba4 --- /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( + "council_tax_reduction_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/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 7f9b1b7663..4890a6e409 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -25,7 +26,7 @@ def formula(benunit, period, parameters): household("country", period), household("council_tax_reduction_household_has_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) 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 abeea551dc..ad8b1daf16 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 @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * import numpy as np +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, +) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_oxford_working_age, ) @@ -24,8 +27,7 @@ def formula(benunit, period, parameters): "council_tax_reduction_claimant_benunit", period ) would_claim = benunit("would_claim_council_tax_reduction", period) - universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) capital_eligible = capital <= ctr.means_test.capital_limit @@ -35,7 +37,11 @@ def formula(benunit, period, parameters): ) annual_income = benunit("council_tax_reduction_applicable_income", period) - child_benefit = benunit("child_benefit", period) + # Applicable income counts Child Benefit only where the applicant is + # the benefit unit's claimant, so only then is it taken off again. + child_benefit = benunit("child_benefit", period) * ~benunit( + "council_tax_reduction_head_applies_alone", period + ) weekly_income = max_(0, annual_income - child_benefit) / WEEKS_IN_YEAR weekly_income += where(has_uc_award, 0, weekly_tariff_income) relevant_income_based_benefit = benunit( 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 3c86931aee..437ffc5295 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -23,7 +24,7 @@ def formula(benunit, period, parameters): household("country", period), household("council_tax_reduction_household_has_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) From ffa5ff531bf9e962d942051e7fb7a2fb105eaba5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:08:33 -0400 Subject: [PATCH 03/16] Reconcile reported and simulated reductions within a household MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the scheme chosen per family, one household can hold a simulated claim and a family whose scheme is not simulated. Review r1 found that a family that cannot claim then kept its reported reduction beside a head whose simulated award already covered the whole bill: £3,000 against £1,800. - A family that cannot claim keeps a reported reduction only where no claim in its household is simulated (new household flag council_tax_reduction_household_has_simulated_claim). Without sharers this is the base behaviour. - A jointly liable claimant's reported reduction is limited to its own share of the council tax (SI 2012/2885 Sch 1 para 7(3)-(4)), the most any scheme could give it. Tests: three YAML cases; the property tests now generate reported reductions, check the fallback and the household bound, and toggle one claimant's exempting benefit to show it leaves the other claim unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 107 +++++++++++++ ...ction_claimant_family_scheme_properties.py | 148 +++++++++++++++++- .../variables/gov/dwp/council_tax_benefit.py | 32 +++- ...reduction_household_has_simulated_claim.py | 21 +++ .../council_tax_reduction_scheme_supported.py | 4 +- 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py 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 index c364aad9fd..e0e30352a3 100644 --- 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 @@ -218,6 +218,113 @@ 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: 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 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 index e6bac255c1..e863dcf8f1 100644 --- 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 @@ -27,9 +27,17 @@ 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 award unchanged. -5. Bounds: a family that cannot claim gets no simulated reduction, and the - household's reduction never exceeds its council tax. + scheme and award unchanged, reported reductions included. +5. Fallback: a claiming family gets its simulated reduction where its scheme + is simulated, and otherwise its reported one, limited to its share of the + council tax where it is jointly liable. A family that cannot claim gets + its reported reduction only where no claim in its household is simulated. +6. Bounds: a family that cannot claim gets no simulated reduction; a + household with a simulated claim 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 @@ -86,6 +94,7 @@ ) 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 @@ -105,6 +114,7 @@ def family(draw, role): 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)), ) @@ -171,6 +181,8 @@ def build(population, alone=False, perturb_others=False): disability_input = house["other_disabilities"][replacement] replacement += 1 people[pid] = person(age, disability_input, fam["earnings"][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 @@ -268,10 +280,35 @@ def test_scheme_follows_own_family(population): assert not np.any((local > 0.005) & pensioner) assert not np.any((local > 0.005) & ~england) - # 5. Bounds. (No family has a reported reduction to fall back on here.) + # 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) + expected = np.where( + claimant, + np.where( + supported, + simulated, + np.where( + share < 1, 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") - assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + over = household_reduction > calc(sim, "council_tax") + 0.01 + assert not np.any(over & simulates) @PROPERTY_SETTINGS @@ -298,3 +335,104 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + + +@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/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index b80f01b1f0..bda53d9111 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -5,11 +5,41 @@ 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. A " + "claimant jointly liable with others can get at most its own share " + "of the council tax, so its reported reduction is limited to that " + "share. 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_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) + # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's + # maximum reduction is on its share of the council tax. + share_of_liability = ( + benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + * share + ) + reported_claim = where(share < 1, min_(reported, share_of_liability), reported) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) + 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/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_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index c559fe9bb9..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 @@ -14,8 +14,8 @@ class council_tax_reduction_scheme_supported(Variable): "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 family whose scheme is not simulated keeps its " - "reported reduction." + "different schemes. A claiming family whose scheme is not simulated " + "falls back to its reported reduction (council_tax_benefit)." ) definition_period = YEAR From 905d0d9c164db1f27ca5f666b2a7e8c3ea504cd7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:25:32 -0400 Subject: [PATCH 04/16] Changelog for the CTR applicant means test Co-Authored-By: Claude Opus 5.5 --- changelog.d/ctr-applicant-means-test.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/ctr-applicant-means-test.fixed.md diff --git a/changelog.d/ctr-applicant-means-test.fixed.md b/changelog.d/ctr-applicant-means-test.fixed.md new file mode 100644 index 0000000000..010dc6840c --- /dev/null +++ b/changelog.d/ctr-applicant-means-test.fixed.md @@ -0,0 +1 @@ +Means-test the Council Tax Reduction applicant and their partner rather than the benefit unit's claimant and partner, so a household head who is liable for the council tax but shares a benefit unit with a young couple is assessed on her own income, allowance, premiums and exemption. From 7ac22effc3c459e74abbbdf84de973a6c1f93dd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:26:43 -0400 Subject: [PATCH 05/16] Set a claim's scheme and exemption from the applicant and their partner #2009 now names the liable people directly. A household head can be liable without being the claimant or partner of their family (a grandparent heading a family formed around a young couple), so "the applicant or his partner" (SI 2012/2885 reg 3, Sch 1 para 8(6)) is not always the family's claimant and partner. New person-level council_tax_reduction_applicant_or_partner: the liable member, and the other member of the couple where the liable member is the claimant or partner, whatever the partner's age; in a family that cannot claim, its claimant and partner. The pensioner flag and the applicant exemption read it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 78 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 5 +- ...duction_applicant_has_non_dep_exemption.py | 6 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 +++++++++ .../council_tax_reduction_pensioner.py | 14 ++-- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py 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 index e0e30352a3..98c9a2afbe 100644 --- 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 @@ -566,3 +566,81 @@ 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] + 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] + council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true] + council_tax_reduction_pensioner: [false] 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 index e863dcf8f1..70c5e88af2 100644 --- 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 @@ -11,7 +11,10 @@ 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: +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 State Pension age. It is exempt from non-dependant deductions 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 4a539258a2..f49ffa5ff2 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 @@ -32,7 +32,9 @@ def formula(benunit, period, parameters): # applicant or his partner is" blind or receiving one of the listed # benefits "in respect of himself". person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) exempting = ( person("is_blind", period) | (person("attendance_allowance", period) > 0) @@ -40,4 +42,4 @@ def formula(benunit, period, parameters): | (person("dla_sc", period) > 0) | (person("armed_forces_independence_payment", period) > 0) ) - return benunit.any(claimant_or_partner & exempting) + return benunit.any(applicant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..2fbadd516f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Applicant for Council Tax Reduction, or the applicant's partner" + documentation = ( + "The people whose circumstances set a family's Council Tax Reduction " + "scheme and its exemption from non-dependant deductions: the " + "applicant and the applicant's partner. In a family that claims, the " + "applicant is a member the model treats as liable for the council " + "tax, and the partner is the other member of the couple where the " + "applicant is the family's claimant or partner, whatever the " + "partner's age. A household head who is not the claimant or partner " + "of their family (a grandparent heading a family formed around a " + "young couple, say) applies alone. In a family that cannot claim, " + "these are the claimant and partner, who would apply if it could." + ) + 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/8", + "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", + ) + + def formula(person, period, parameters): + liable = person("council_tax_reduction_liable_person", period) + claimant_or_partner = person("is_claimant_or_partner", period) + family_claims = person.benunit.any(liable) + # The couple, where one of them is liable; a partner under 18 is not + # liable but is still the applicant's partner. + couple_applies = person.benunit.any(liable & claimant_or_partner) + applicant_or_partner = liable | (couple_applies & claimant_or_partner) + return where(family_claims, applicant_or_partner, claimant_or_partner) 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 d958b9f680..3305026f80 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 @@ -7,12 +7,12 @@ class council_tax_reduction_pensioner(Variable): label = "Pensioner for Council Tax Reduction" documentation = ( "Whether this family's claim falls under the pension-age Council Tax " - "Reduction rules: the claimant or partner has reached State Pension " - "age. Each applicant's scheme follows their own family, so where " - "families share the rent and each claims, a working-age family is " + "Reduction rules: the applicant or the applicant's partner has reached " + "State Pension age. 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 further condition that " - "neither the claimant nor a partner is on Income Support, income-based " + "neither the applicant nor a partner is on Income Support, income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance or Universal Credit is not modelled here." ) @@ -30,5 +30,7 @@ def formula(benunit, period, parameters): # state pension credit". is_SP_age stands in for that age, as # elsewhere in the model. person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - return benunit.any(claimant_or_partner & person("is_SP_age", period)) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) + return benunit.any(applicant_or_partner & person("is_SP_age", period)) From 6d3f11f2260773787ab8209ff1ca3a5a8bdd6c5f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:35:37 -0400 Subject: [PATCH 06/16] Widen the CTR applicant differential to working-age heads and the local schemes Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 5 ++++- ...ncil_tax_reduction_applicant_properties.py | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index bf25b60348..8fc7284064 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -290,6 +290,7 @@ age: 19 is_parent: true is_household_head: false + employment_income: 30_000 baby: age: 0 is_household_head: false @@ -308,10 +309,12 @@ savings: 0 output: council_tax_reduction_head_applies_alone: [true] + # The young father's earnings and the couple's award are not hers. council_tax_reduction_applicable_income: [0] # Single aged 25 or over: 95.55 * 52. council_tax_reduction_applicable_amount: [4_968.60] - # No excess income: 1,800 * 70%. + # No excess income: 1,800 * 70%. On the Universal Credit route the + # couple's earnings and award would leave nothing. newham_council_tax_reduction: [1_260] - name: A partner under 18 is not liable, but their income counts in the couple's means test diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 895d3b2607..dcbd9f3e00 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -30,12 +30,16 @@ family. Where the head does not apply alone, the applicant and partner are exactly the benefit unit's claimant and partner (ordinary families are unchanged). Where the head applies alone, the head is the only one. -5. Differential: a head over State Pension age sharing a benefit unit with +5. Differential: a head of any age from 18 sharing a benefit unit with parents aged 16 or 17 and their baby has the same applicable income, - applicable amount, premiums, scheme, exemption and simulated reduction as - the same head entered as her own benefit unit, with the young family as - another. The two inputs describe the same people, so the law gives the - same answer. + applicable amount, premiums, passport, scheme, exemption and simulated + reduction as the same head entered as her own benefit unit, with the young + family as another. The two inputs describe the same people, so the law + gives the same answer. Two limits keep the inputs comparable: no one + claims Universal Credit or Income Support by take-up (a head inside + another couple's benefit unit cannot be given her own award), and the + parents are under 18 (older ones would be her non-dependants, which the + model charges only when they are in another benefit unit). """ import numpy as np @@ -272,7 +276,7 @@ def _all_names(population, variant): def grandmother_households(draw): return dict( scheme=draw(st.sampled_from(SCHEMES)), - head_age=draw(st.integers(68, 100)), + head_age=draw(head_age), head_pension=draw(money), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), parent_age=draw(st.integers(16, 17)), @@ -314,7 +318,9 @@ def add_grandmother_household(people, benunits, homes, name, house, split): if house[benefit] is not None } common = dict( - claims_all_entitled_benefits={YEAR: True}, would_claim_uc={YEAR: False} + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + would_claim_IS={YEAR: False}, ) if split: benunits[f"{name}_family"] = dict(members=[head], **common) From ae79d747e1e0f9084bce83c8f72e6c70361d428a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:55:41 -0400 Subject: [PATCH 07/16] Weight the CTR applicant properties to part-withdrawn awards; pin Kingston's tariff income Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 41 +++++++++++++++++++ ...ncil_tax_reduction_applicant_properties.py | 17 +++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index 8fc7284064..55b15004e8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -441,3 +441,44 @@ # A working-age couple aged 18 or over: 150.15 * 52, not the pension-age # couple rate. council_tax_reduction_applicable_amount: [7_807.80] + +- name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + private_pension_income: 5_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 10_000 + output: + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_applicable_income: [5_000] + council_tax_reduction_applicable_amount: [4_968.60] + # Tariff income ceil((10,000 - 6,000) / 250) = £16 a week, 832 a year, + # since she has no award. 1,800 - (5,000 + 832 - 4,968.60) * 20%. + kingston_upon_thames_council_tax_reduction: [1_627.32] diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index dcbd9f3e00..dec9ccab80 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -73,6 +73,11 @@ "is_blind", ] money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +# Incomes, bills and savings around the means-test thresholds, so awards are +# often part-withdrawn rather than nil or full in both representations. +pension = st.one_of(st.sampled_from([0, 5_000, 8_000, 12_000, 20_000]), money) +council_tax = st.one_of(st.just(1_800), st.floats(0, 4_000, allow_nan=False)) +savings = st.one_of(st.just(0), st.floats(0, 20_000, allow_nan=False)) head_age = st.one_of( st.sampled_from([18, 25, 40, 60, 66, 68, 80]), st.integers(18, 100) ) @@ -86,7 +91,7 @@ def households(draw): scheme=draw(st.sampled_from(SCHEMES)), shape=draw(st.sampled_from(SHAPES)), head_age=draw(head_age), - head_pension=draw(money), + head_pension=draw(pension), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), partner_age=draw(st.integers(16, 100)), parent_age=draw(st.integers(16, 30)), @@ -95,8 +100,8 @@ def households(draw): universal_credit=draw(st.sampled_from([None, None, 5_000])), other_family=draw(st.sampled_from([None, "non_dependant", "sharer"])), other_age=draw(st.integers(16, 90)), - council_tax=draw(st.floats(0, 4_000, allow_nan=False)), - savings=draw(st.floats(0, 20_000, allow_nan=False)), + council_tax=draw(council_tax), + savings=draw(savings), ) @@ -277,14 +282,14 @@ def grandmother_households(draw): return dict( scheme=draw(st.sampled_from(SCHEMES)), head_age=draw(head_age), - head_pension=draw(money), + head_pension=draw(pension), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), parent_age=draw(st.integers(16, 17)), parent_earnings=draw(st.lists(money, min_size=2, max_size=2)), jsa_income=draw(st.sampled_from([None, 3_000])), universal_credit=draw(st.sampled_from([None, 5_000])), - council_tax=draw(st.floats(0, 4_000, allow_nan=False)), - savings=draw(st.floats(0, 20_000, allow_nan=False)), + council_tax=draw(council_tax), + savings=draw(savings), ) From 9efdea22d09f3e69f619872f6382cfbf7635bc8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:20:49 -0400 Subject: [PATCH 08/16] Use #2078's CTR applicant-or-partner variable PolicyEngine/policyengine-uk#2078 (also stacked on #2009) adds is_council_tax_reduction_applicant_or_partner and council_tax_reduction_head_applies_alone for the CTR means test. They define the same people as this PR's council_tax_reduction_applicant_or_partner: the claimant and partner where the liable person is one of them, the liable head alone otherwise, and the claimant and partner in a family that cannot claim. Take #2078's two files byte for byte, so whichever PR lands second merges without conflict, and point the pensioner flag and the applicant exemption at it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 4 +- ...duction_applicant_has_non_dep_exemption.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 --------------- ...ouncil_tax_reduction_head_applies_alone.py | 38 +++++++++++++++ .../council_tax_reduction_pensioner.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 46 +++++++++++++++++++ 6 files changed, 88 insertions(+), 41 deletions(-) delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py 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 index 98c9a2afbe..58c3fc2d7f 100644 --- 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 @@ -603,7 +603,7 @@ # 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] - council_tax_reduction_applicant_or_partner: [true, false, false, 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] @@ -641,6 +641,6 @@ # 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] - council_tax_reduction_applicant_or_partner: [true, true] + is_council_tax_reduction_applicant_or_partner: [true, true] council_tax_reduction_applicant_has_non_dep_exemption: [true] council_tax_reduction_pensioner: [false] 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 f49ffa5ff2..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 @@ -33,7 +33,7 @@ def formula(benunit, period, parameters): # benefits "in respect of himself". person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) exempting = ( person("is_blind", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py deleted file mode 100644 index 2fbadd516f..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py +++ /dev/null @@ -1,37 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_applicant_or_partner(Variable): - value_type = bool - entity = Person - label = "Applicant for Council Tax Reduction, or the applicant's partner" - documentation = ( - "The people whose circumstances set a family's Council Tax Reduction " - "scheme and its exemption from non-dependant deductions: the " - "applicant and the applicant's partner. In a family that claims, the " - "applicant is a member the model treats as liable for the council " - "tax, and the partner is the other member of the couple where the " - "applicant is the family's claimant or partner, whatever the " - "partner's age. A household head who is not the claimant or partner " - "of their family (a grandparent heading a family formed around a " - "young couple, say) applies alone. In a family that cannot claim, " - "these are the claimant and partner, who would apply if it could." - ) - 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/8", - "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", - ) - - def formula(person, period, parameters): - liable = person("council_tax_reduction_liable_person", period) - claimant_or_partner = person("is_claimant_or_partner", period) - family_claims = person.benunit.any(liable) - # The couple, where one of them is liable; a partner under 18 is not - # liable but is still the applicant's partner. - couple_applies = person.benunit.any(liable & claimant_or_partner) - applicant_or_partner = liable | (couple_applies & claimant_or_partner) - return where(family_claims, applicant_or_partner, claimant_or_partner) 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..774c5b2853 --- /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( + "council_tax_reduction_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_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 3305026f80..e3dc861173 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 @@ -31,6 +31,6 @@ def formula(benunit, period, parameters): # elsewhere in the model. person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) return benunit.any(applicant_or_partner & person("is_SP_age", period)) 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..a54e31eba4 --- /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( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return where(head_applies_alone, liable_head, claimant_or_partner) From 35ca533b06cd9eb601e9b428fd65998d174214db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:51:09 -0400 Subject: [PATCH 09/16] Cap a reported reduction at the claimant's share only beside a simulated claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 found that capping every jointly liable claimant's reported reduction at its share could discard a lawful report: the model's divisor (#2006's joint-liability share) counts students that SI 2012/2885 Sch 1 para 7(5) leaves out. The cap exists to stop a reported claim and a simulated claim together exceeding the bill, so it now applies only where another claim in the household is simulated, on the share that claim uses. Where nothing is simulated, reports are kept as reported, as on the base. Tests: - YAML: a jointly liable claimant keeps its whole report where nothing is simulated; a pension-age grandmother in working-age applicants' family does not make their claim a pensioner's (an intended change under reg 3(1): the base paid £1,800 on the pensioner scheme). - Properties: the fallback oracle follows the narrowed cap, and property 4 compares council_tax_benefit wherever the household's reconciliation is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 83 ++++++++++++++++++- ...ction_claimant_family_scheme_properties.py | 32 +++++-- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++-- 3 files changed, 126 insertions(+), 15 deletions(-) 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 index 58c3fc2d7f..4a33287385 100644 --- 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 @@ -288,7 +288,7 @@ council_tax_benefit: [300, 200] council_tax_reduction: 500 -- name: A jointly liable claimant's reported reduction is limited to its share of the bill +- 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: @@ -644,3 +644,84 @@ 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 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 index 70c5e88af2..7f5667b348 100644 --- 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 @@ -30,11 +30,15 @@ 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 award unchanged, reported reductions included. + scheme and simulated award unchanged. Its council_tax_benefit is unchanged + too, unless it falls back to a reported reduction and whether another + claim in its household is simulated changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one, limited to its share of the - council tax where it is jointly liable. A family that cannot claim gets - its reported reduction only where no claim in its household is simulated. + is simulated, and otherwise its reported one. Beside a simulated claim in + its household, a jointly liable claimant's reported reduction is limited + to its share of the council tax; otherwise it is kept as reported. A + family that cannot claim gets its reported reduction only where no claim + in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a household with a simulated claim never gets more than its council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, @@ -298,7 +302,9 @@ def test_scheme_follows_own_family(population): supported, simulated, np.where( - share < 1, np.minimum(reported_amount, bill * share), reported_amount + household_simulates & (share < 1), + np.minimum(reported_amount, bill * share), + reported_amount, ), ), np.where(household_simulates, 0, reported_amount), @@ -330,7 +336,6 @@ def test_other_families_do_not_change_a_claim(population): for variable in ( "council_tax_reduction_joint_liability_share", "simulated_council_tax_reduction_benunit", - "council_tax_benefit", ): np.testing.assert_allclose( calc(before, variable)[target], @@ -338,6 +343,21 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + # A reported fallback is reconciled against the household's simulated + # claims (property 5); compare it wherever that reconciliation is the same. + house = facts["house"] + simulates = "council_tax_reduction_household_has_simulated_claim" + same_reconciliation = ( + calc(before, simulates)[house] == calc(after, simulates)[house] + ) + supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + 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 diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index bda53d9111..be06b98814 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -7,10 +7,13 @@ class council_tax_benefit(Variable): 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. A " - "claimant jointly liable with others can get at most its own share " - "of the council tax, so its reported reduction is limited to that " - "share. A family that cannot claim keeps a reported reduction only " + "simulates its own scheme, and its reported reduction otherwise. " + "Where another claim in the household is simulated 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. Where no claim " + "in the household is simulated, 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 @@ -27,17 +30,24 @@ def formula(benunit, period, parameters): reported = benunit("council_tax_benefit_reported", period) 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 + ) # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's - # maximum reduction is on its share of the council tax. + # maximum reduction is on its share of the council tax. Applied only + # beside a simulated claim, which uses the same share; reports are + # otherwise kept, since the share does not yet exclude the students + # para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) * share ) - reported_claim = where(share < 1, min_(reported, share_of_liability), reported) - household_simulates = benunit.household( - "council_tax_reduction_household_has_simulated_claim", period + reported_claim = where( + household_simulates & (share < 1), + min_(reported, share_of_liability), + reported, ) not_claiming = where(household_simulates, 0, reported) return where( From cc4d9aeb952cbda653d46a513d460b3ad78b1dd2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 08:01:51 -0400 Subject: [PATCH 10/16] Apply the reported-reduction cap only beside a simulated claim that pays On the payer build, the only remaining change came from heads whose reported reduction was capped beside a sharer whose simulated claim paid nothing. With no simulated reduction competing for the bill there is nothing to reconcile, so the cap now applies only where the simulated claims in the household pay something. The household bound still holds: each simulated award is within its share, and a capped report within its own. Tests: a YAML case where the simulated claim beside a report pays nothing; the fallback oracle, the bound and property 4's comparison follow the paid condition. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 39 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 37 +++++++++++------- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++++++----- 3 files changed, 79 insertions(+), 23 deletions(-) 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 index 4a33287385..0bafe29a6b 100644 --- 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 @@ -725,3 +725,42 @@ 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 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 index 7f5667b348..e18d192f40 100644 --- 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 @@ -31,16 +31,18 @@ 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. Its council_tax_benefit is unchanged - too, unless it falls back to a reported reduction and whether another - claim in its household is simulated changes (property 5's reconciliation). + too, unless it falls back to a reported reduction and whether a simulated + claim in its household pays something changes (property 5's + reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one. Beside a simulated claim in - its household, a jointly liable claimant's reported reduction is limited - to its share of the council tax; otherwise it is kept as reported. A + 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; otherwise it is kept as reported. A family that cannot claim gets its reported reduction only where no claim in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a - household with a simulated claim never gets more than its council tax. + household whose simulated claims pay something 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 @@ -296,13 +298,15 @@ def test_scheme_follows_own_family(population): 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 expected = np.where( claimant, np.where( supported, simulated, np.where( - household_simulates & (share < 1), + household_pays & (share < 1), np.minimum(reported_amount, bill * share), reported_amount, ), @@ -317,7 +321,7 @@ def test_scheme_follows_own_family(population): assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") over = household_reduction > calc(sim, "council_tax") + 0.01 - assert not np.any(over & simulates) + assert not np.any(over & (paid > 0)) @PROPERTY_SETTINGS @@ -343,14 +347,19 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) - # A reported fallback is reconciled against the household's simulated - # claims (property 5); compare it wherever that reconciliation is the same. + # A reported fallback is reconciled against the household's paid + # simulated claims (property 5); compare it wherever that is the same. house = facts["house"] - simulates = "council_tax_reduction_household_has_simulated_claim" - same_reconciliation = ( - calc(before, simulates)[house] == calc(after, simulates)[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], diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index be06b98814..649231b466 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -8,11 +8,11 @@ class council_tax_benefit(Variable): 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 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. Where no claim " - "in the household is simulated, reported reductions are kept as " + "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. 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." ) @@ -33,11 +33,19 @@ def formula(benunit, period, parameters): 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, which uses the same share; reports are - # otherwise kept, since the share does not yet exclude the students - # para 7(5) leaves out. + # beside a simulated claim that pays something, which uses the same + # share; reports are otherwise kept, since the share does not yet + # exclude the students para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period @@ -45,7 +53,7 @@ def formula(benunit, period, parameters): * share ) reported_claim = where( - household_simulates & (share < 1), + (paid_in_household > 0) & (share < 1), min_(reported, share_of_liability), reported, ) From 7f704ac2b2a88495d01564086d7882decb666d5a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:53:23 -0400 Subject: [PATCH 11/16] Expect the young parents' Income Support not to passport a lone-applying head Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/claimant_family_scheme.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 793efe1508..005c41c6f2 100644 --- 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 @@ -969,6 +969,7 @@ 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] + # Nor does it passport her: the passport is the applicant's award. + council_tax_reduction_relevant_income_based_benefit: [false] council_tax_reduction_pensioner: [true] simulated_council_tax_reduction_benunit: [1_800] From 623d48d546e48cdeb3f98564777a3b972ac8da45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:40:09 -0400 Subject: [PATCH 12/16] Test a disabled pensioner head applying alone; mark 19-year-old cases provisional on #2017 Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index b5af6ad004..ec6d85dc67 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -140,6 +140,10 @@ simulated_council_tax_reduction_benunit: [759.60] - name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. period: 2026 absolute_error_margin: 0.01 input: @@ -319,6 +323,10 @@ merton_council_tax_reduction: [1_800, 0] - name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. period: 2026 absolute_error_margin: 0.01 input: @@ -487,6 +495,10 @@ council_tax_reduction_applicable_amount: [7_807.80] - name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. period: 2026 absolute_error_margin: 0.01 input: @@ -589,3 +601,84 @@ # Each claim covers half the £1,800; neither has income above its # allowance. merton_council_tax_reduction: [900, 554.20, 0] + +- name: A disabled pensioner head who applies alone gets no working-age disability premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # The pension-age schedule has no adult disability premium, and its + # enhanced disability premium is for a child or young person (SI 2012/2885 + # Sch 2 Part 3, paras 6-9). + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + +- name: A pensioner head who applies alone and receives Attendance Allowance gets the pension-age severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + attendance_allowance: 4_000 + 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The single severe disability premium only: 86.05 * 52 = 4,474.60. The + # parents are 17, so not non-dependants who bar it. + council_tax_reduction_premiums: [4_474.60] + council_tax_reduction_applicable_amount: [17_786.60] From 8b6d819bedcacb257d08dfd04d00105226d257b4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:56:44 -0400 Subject: [PATCH 13/16] Exclude Pension Credit families from the liable-head income invariant main's pensioner schemes disregard a guarantee credit recipient's whole income and use Pension Credit's assessment in savings-credit-only cases (SI 2012/2885 Sch 1 paras 13-14), so raising the head's pension need not raise their applicable income there. Co-Authored-By: Claude Opus 5.5 --- ...ncil_tax_reduction_applicant_properties.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 565a69e74f..82450619df 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -18,9 +18,10 @@ Allowance and blindness; all eight modelled schemes. 1. Head's income counts: raising the liable household head's own private - pension by £5,000 raises their family's applicable income. Raising a - young parent's earnings by £5,000 leaves the family's applicable income - unchanged where the head applies alone. + pension by £5,000 raises their family's applicable income, unless the + family is on Pension Credit, whose assessment the pensioner schemes use + instead. Raising a young parent's earnings by £5,000 leaves the family's + applicable income unchanged where the head applies alone. 2. Monotone: raising the head's pension never raises any family's simulated reduction. 3. High income: with £250,000 more pension, the head's family gets no @@ -236,9 +237,17 @@ def family(variable, variant): head_alone = family("council_tax_reduction_head_applies_alone", "base") # 1. The head's pension counts; a young parent's earnings do not where - # the head applies alone. + # the head applies alone. A family on Pension Credit is assessed through + # Pension Credit instead (guarantee credit: its whole income disregarded; + # savings credit only: Pension Credit's assessment of income; SI 2012/2885 + # Sch 1 paras 13-14), so it is outside the first part. income = family("council_tax_reduction_applicable_income", "base") - assert (family("council_tax_reduction_applicable_income", "pension") > income).all() + on_pension_credit = np.zeros(len(population), dtype=bool) + for variant in ["base", "pension"]: + on_pension_credit |= family("in_receipt_of_guarantee_credit", variant) + on_pension_credit |= family("in_receipt_of_savings_credit_only", variant) + raised = family("council_tax_reduction_applicable_income", "pension") + assert (raised > income)[~on_pension_credit].all() parent_income = family("council_tax_reduction_applicable_income", "parent_earnings") assert np.allclose(parent_income[head_alone], income[head_alone]) From e6b2e12d8cbd8c65887fc38c4345395b4040280e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:59:12 -0400 Subject: [PATCH 14/16] Label the CTR applicant change in the legacy award readers property Council Tax Reduction reads the applicant and partner (#2078), and the applicant is the liable household head. These generated families flag no head, so an added member older than the claimant becomes the head, applies alone, and changes the applicant: intended. The two CTR readers are left out of the with/without comparison for those families only, which must have the added member as the resolved household head; the silent/as-drawn comparison still checks that the member's own reports never count. Co-Authored-By: Claude Opus 5.5 --- .../test_legacy_award_readers_properties.py | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 62efc0e89e..ef44e02ecc 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -36,8 +36,12 @@ member reports never changes any of these readers for the claimant's family or for its existing members; - adding such a member changes a reader only by ending the family's severe - disability premium, which a non-dependant aged 18 or over does (IS Regs - 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). That + disability premium, or, for the two Council Tax Reduction readers, by + becoming the household head (these families flag none, so the eldest + member is the head) who is liable and applies alone, so that the applicant + changes (CTR_APPLICANT_READERS). A non-dependant aged 18 or over ends the + premium (IS Regs 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para + 14(2)(a)(ii)). That rule is subject to para 13(3), which ignores a non-dependant on a qualifying disability benefit, a blind one and some carers; the added members here are none of those. The family's Income Support then falls by @@ -244,6 +248,40 @@ def other_members(draw): } +# Council Tax Reduction reads the applicant and partner, and the applicant is +# the person liable for the council tax, whom the model takes to be the +# household head (is_resolved_household_head; PolicyEngine/policyengine-uk#2009 +# and #2078). These families flag no head, so the head is the eldest member: an +# added member older than the claimant and partner becomes the liable head, +# who then applies alone (council_tax_reduction_head_applies_alone). Their +# presence then changes the applicant, which is intended, so those families +# are left out of the comparison with the family on its own for these two +# readers. What the added member reports still never changes them: the first +# property compares a silent added member with the member as drawn for every +# family. +CTR_APPLICANT_READERS = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_relevant_income_based_benefit", +] + + +def became_ctr_applicant(sim, k, starts, sizes): + """For each of the first k units, whether the member added in unit k + i + made it a family whose household head applies alone, and checks that this + happens only where that member is the resolved household head.""" + head_alone = sim.calculate("council_tax_reduction_head_applies_alone", YEAR) + head = sim.calculate("is_resolved_household_head", YEAR) + changed = [] + for i in range(k): + became = bool(head_alone[k + i]) and not bool(head_alone[i]) + if became: + added = starts[k + i] + sizes[k + i] - 1 + assert head[added], i + event("added member became the Council Tax Reduction applicant") + changed.append(became) + return changed + + def situation(units, year=YEAR, benunit_inputs=None): people, benunits, households = {}, {}, {} for i, (adults, non_couple, household, other) in enumerate(units): @@ -387,6 +425,7 @@ def test_other_members_awards_never_change_the_readers(drawn): fall = income_support[i] - income_support[k + i] lost = premium[i] - premium[k + i] assert -0.01 <= fall <= lost + 0.01, drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): @@ -395,6 +434,8 @@ def test_other_members_awards_never_change_the_readers(drawn): variable, drawn[i], ) + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue # Their presence changes one only by ending the premium, and # never the awards the family's own members report. if not premium_ended[i] or variable in PREMIUM_FREE_READERS: @@ -589,9 +630,12 @@ def _family_readers_never_change(drawn, enter_is_mixed_age_couple=False): k = len(drawn) for i in range(k): assert not flags[starts[k + i] + sizes[k + i] - 1], drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue assert np.isclose(values[i], values[k + i], atol=0.01), ( variable, drawn[i], From 7d2ad8c851f52938e3e3a98b76e42f5e1f324a70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 04:39:56 -0400 Subject: [PATCH 15/16] Pin the head-alone carer, earnings-disregard and Pension Credit paths; document the carer-attribution limit Review r3 (APPROVE at 47c525cef): - P3-2: four YAML cases pin the head-alone carer condition, her own single earnings disregards and additional-disregard test, and the gated Pension Credit income and capital routes; each catches its mutation. - P2-3: the under-18-head couple case moves from Cardiff to Edinburgh, whose working-age scheme pays the flat couple rate the model uses. - P2-1/P3-1: council_tax_reduction_premiums documents that a carer outside a lone-applying head's benefit unit does not end her severe disability premium (inherited attribution). Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 168 +++++++++++++++++- .../council_tax_reduction_premiums.py | 9 +- 2 files changed, 173 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index ec6d85dc67..a4704094d1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -409,7 +409,7 @@ is_council_tax_reduction_applicant_or_partner: [true, true] # Below the personal allowance and the National Insurance threshold. council_tax_reduction_applicable_income: [10_000] - # Couple aged 18 or over: 150.15 * 52 = 7,807.80. + # The couple rate where the elder is 18 or over: 150.15 * 52 = 7,807.80. council_tax_reduction_applicable_amount: [7_807.80] # 1,800 - (10,000 - 7,807.80) * 20%. simulated_council_tax_reduction_benunit: [1_361.56] @@ -441,8 +441,8 @@ households: home: members: [head, partner] - country: WALES - local_authority: CARDIFF + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH council_tax: 1_800 savings: 0 output: @@ -682,3 +682,165 @@ # parents are 17, so not non-dependants who bar it. council_tax_reduction_premiums: [4_474.60] council_tax_reduction_applicable_amount: [17_786.60] + +- name: A carer paid for caring for a lone-applying head ends her severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + # Paid for caring for the grandmother. + carers_allowance: 4_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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + # A carer benefit is paid for caring for her, so no severe disability + # premium (SI 2012/2885 Sch 2 para 6(2)(a)(iii)), and she is not a carer + # herself. + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + +- name: A working pensioner head who applies alone has the single earnings disregards on her own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + employment_income: 10_000 + weekly_hours: 30 + 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] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # Her own earnings, below the personal allowance and with no National + # Insurance over State Pension age. + council_tax_reduction_net_earnings: [10_000] + # The single disregard of £5 a week plus the additional £17.10, which she + # earns by working 30 hours a week at 25 or over (SI 2012/2885 Sch 4 + # paras 2 and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not + # the young couple's work conditions. + council_tax_reduction_pensioner_earnings_disregard: [1_149.20] + council_tax_reduction_applicable_income: [8_850.80] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + private_pension_income: 100_000 + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The benefit unit's claimant and partner, not the head, get + # guarantee credit. + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true] + council_tax_reduction_head_applies_alone: [true] + # Her own £100,000 pension is assessed, not disregarded as a guarantee + # credit recipient's would be (SI 2012/2885 Sch 1 para 13). + simulated_council_tax_reduction_benunit: [0] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's capital + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 20_000 + output: + council_tax_reduction_head_applies_alone: [true] + # Household savings stand in for her capital, above the £16,000 limit; + # a guarantee credit recipient's capital would be disregarded. + council_tax_reduction_assessable_capital: [20_000] + simulated_council_tax_reduction_benunit: [0] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index abde570ea5..a3a7b1633d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -21,7 +21,14 @@ class council_tax_reduction_premiums(Variable): "paid for caring for her, and no other resident aged 18 or over is " "her non-dependant (everyone else in the household, since she has no " "partner or children, unless they receive a qualifying benefit or are " - "blind); and the carer premium if she is entitled to a carer benefit." + "blind); and the carer premium if she is entitled to a carer benefit. " + "Whom a carer benefit is paid for caring for comes from " + "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " + "own benefit unit to her but a carer in another benefit unit only to " + "that unit's claimants and partners. So a carer outside her benefit " + "unit (who must then be blind or on a qualifying benefit, or they " + "would bar the premium as her non-dependant) does not end her severe " + "disability premium, though in law they would." ) definition_period = YEAR unit = GBP From 7142a403f5eca9ad3d88677b47ad05e24236d335 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 07:04:12 -0400 Subject: [PATCH 16/16] Qualify the carer-attribution note for under-18 carers; cite Sch 4 para 8(a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r4 P3-1: a carer under 18 in another benefit unit also leaves a head who applies alone with her severe disability premium. P3-2: the £5 single earnings disregard is SI 2012/2885 Sch 4 para 8(a), not para 2. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/applicant_or_partner.yaml | 2 +- .../council_tax_reduction_premiums.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index a4704094d1..405fc1ffc3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -765,7 +765,7 @@ council_tax_reduction_net_earnings: [10_000] # The single disregard of £5 a week plus the additional £17.10, which she # earns by working 30 hours a week at 25 or over (SI 2012/2885 Sch 4 - # paras 2 and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not + # paras 8(a) and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not # the young couple's work conditions. council_tax_reduction_pensioner_earnings_disregard: [1_149.20] council_tax_reduction_applicable_income: [8_850.80] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index a3a7b1633d..d6bc318621 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -26,9 +26,10 @@ class council_tax_reduction_premiums(Variable): "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " "own benefit unit to her but a carer in another benefit unit only to " "that unit's claimants and partners. So a carer outside her benefit " - "unit (who must then be blind or on a qualifying benefit, or they " - "would bar the premium as her non-dependant) does not end her severe " - "disability premium, though in law they would." + "unit does not end her severe disability premium, though in law they " + "would. Such a carer is under 18, blind or on a qualifying benefit, " + "since any other resident aged 18 or over already bars the premium as " + "her non-dependant." ) definition_period = YEAR unit = GBP