From ae3e5e0eaacc6b5c4fd9e827bf569cc51b172d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:30:01 -0400 Subject: [PATCH 1/4] 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 ffa5ff531bf9e962d942051e7fb7a2fb105eaba5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:08:33 -0400 Subject: [PATCH 2/4] 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 7ac22effc3c459e74abbbdf84de973a6c1f93dd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:26:43 -0400 Subject: [PATCH 3/4] 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 9efdea22d09f3e69f619872f6382cfbf7635bc8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:20:49 -0400 Subject: [PATCH 4/4] 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)