From ae3e5e0eaacc6b5c4fd9e827bf569cc51b172d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:30:01 -0400 Subject: [PATCH 01/11] 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 000000000..9ded365e0 --- /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 000000000..c364aad9f --- /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 e41a7d5eb..69b7214a8 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 ec6636d1f..9367281a5 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 000000000..e6bac255c --- /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 d2861ead5..b80f01b1f 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 966d230ff..599c26654 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 1ee8ea5cb..a28e7a3a6 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 000000000..4a539258a --- /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 3ede96b20..000000000 --- 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 555231a5a..000000000 --- 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 000000000..d958b9f68 --- /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 9fb0699fa..c559fe9bb 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 6f3714fee..91fdb6cf6 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 7f9b1b766..46afbc7d8 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 c8ae7f4df..616274593 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 03012a5f8..ef6ec3b1c 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 2907e2a70..8023cec88 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 c35482476..4abcbf6e6 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 0e4b9db54..465b7b5a7 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 abeea551d..208dab392 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 61c80cecb..64452ff08 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 3c86931ae..eaad01138 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 d5666a67a..86533c51d 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/11] 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 000000000..bf25b6034 --- /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 000000000..895d3b260 --- /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 966d230ff..f2efdff84 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 8d64c045a..1ca4fe5bd 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 444ebecc0..ca46d9fd3 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 000000000..774c5b285 --- /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 3ede96b20..d89597268 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 555231a5a..8d4cb9116 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 000000000..251068112 --- /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 74df5864f..0fb48ca31 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 000000000..a54e31eba --- /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 7f9b1b766..4890a6e40 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 abeea551d..ad8b1daf1 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 3c86931ae..437ffc529 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/11] 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 c364aad9f..e0e30352a 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 e6bac255c..e863dcf8f 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 b80f01b1f..bda53d911 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 000000000..897e43016 --- /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 c559fe9bb..915e814e5 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/11] 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 000000000..010dc6840 --- /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/11] 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 e0e30352a..98c9a2afb 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 e863dcf8f..70c5e88af 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 4a539258a..f49ffa5ff 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 000000000..2fbadd516 --- /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 d958b9f68..3305026f8 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/11] 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 bf25b6034..8fc728406 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 895d3b260..dcbd9f3e0 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/11] 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 8fc728406..55b15004e 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 dcbd9f3e0..dec9ccab8 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/11] 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 98c9a2afb..58c3fc2d7 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 f49ffa5ff..53c4079be 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 2fbadd516..000000000 --- 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 000000000..774c5b285 --- /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 3305026f8..e3dc86117 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 000000000..a54e31eba --- /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/11] 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 58c3fc2d7..4a3328738 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 70c5e88af..7f5667b34 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 bda53d911..be06b9881 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/11] 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 4a3328738..0bafe29a6 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 7f5667b34..e18d192f4 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 be06b9881..649231b46 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/11] 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 793efe150..005c41c6f 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]