From ae3e5e0eaacc6b5c4fd9e827bf569cc51b172d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:30:01 -0400 Subject: [PATCH 01/13] Assess each Council Tax Reduction claim under its own family's scheme and exemption In a shared-rent household every family liable for the rent claims, but the pensioner scheme and the non-dependant exemption were picked once per household from the head's family. SI 2012/2885 reg 3 (and SI 2012/2886 Sch para 3, SI 2013/3029 reg 3, SSI 2021/249 reg 3, SSI 2012/319 reg 12) define "pensioner" per person and partner, and the exemption applies where "the applicant or his partner" is blind or gets a listed benefit (SI 2012/2885 Sch 1 para 8(6) and equivalents). - council_tax_reduction_pensioner (BenUnit) replaces the household flag; the name and entity match #1944 and #1966, which extend the same definition. - council_tax_reduction_applicant_has_non_dep_exemption (BenUnit) replaces the household exemption; the councils' schemes apply it per claim. - council_tax_reduction_scheme_supported is per family, so each family falls back to its reported reduction only where its own scheme is unmodelled. - Councils' person-level non-dependant deductions no longer depend on the head's family; each claimant's own scheme decides whether to use them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ctr-per-family-scheme.fixed.md | 1 + .../claimant_family_scheme.yaml | 461 ++++++++++++++++++ .../claimant_household_head.yaml | 14 +- .../legacy_claimant_non_dep_exemption.yaml | 16 +- ...ction_claimant_family_scheme_properties.py | 300 ++++++++++++ .../variables/gov/dwp/council_tax_benefit.py | 2 +- .../council_tax_reduction/_legacy.py | 26 +- .../council_tax_reduction/config.py | 39 +- ...duction_applicant_has_non_dep_exemption.py | 43 ++ ...duction_household_has_non_dep_exemption.py | 32 -- ...l_tax_reduction_household_has_pensioner.py | 13 - .../council_tax_reduction_pensioner.py | 34 ++ .../council_tax_reduction_scheme_supported.py | 21 +- ...simulated_council_tax_reduction_benunit.py | 5 +- ...gston_upon_thames_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../merton_council_tax_reduction.py | 4 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../newham_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../oxford_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../westminster_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- 24 files changed, 944 insertions(+), 115 deletions(-) create mode 100644 changelog.d/ctr-per-family-scheme.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py diff --git a/changelog.d/ctr-per-family-scheme.fixed.md b/changelog.d/ctr-per-family-scheme.fixed.md new file mode 100644 index 0000000000..9ded365e0b --- /dev/null +++ b/changelog.d/ctr-per-family-scheme.fixed.md @@ -0,0 +1 @@ +Assess each family that claims Council Tax Reduction under its own scheme and its own non-dependant exemption, so in a shared-rent household a working-age sharer no longer takes the pensioner scheme because the household head's family is pension-age (or the reverse), and one claimant's disability no longer exempts another's claim from non-dependant deductions. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml new file mode 100644 index 0000000000..c364aad9fd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -0,0 +1,461 @@ +# Where families share the rent, each jointly liable family claims Council Tax +# Reduction on its own share, and its claim follows its own circumstances: +# - the scheme: a "pensioner" is defined per person and partner (SI 2012/2885 +# reg 3; SI 2012/2886 Sch para 3; SI 2013/3029 reg 3; SSI 2021/249 reg 3; +# SSI 2012/319 reg 12), so a working-age family takes the working-age rules +# even if the household head's family is pension-age, and the reverse; +# - the non-dependant exemption: no deduction if "the applicant or [their] +# partner" is blind or gets a listed disability benefit (SI 2012/2885 Sch 1 +# para 8(6); SI 2012/2886 Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and +# Sch 6 para 5(6); SSI 2021/249 reg 90(6); SSI 2012/319 reg 48(6)). +# Each family here claims half of a 1,800 bill: 900. + +- name: Oxford, pension-age head and working-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # 500 a week. + council_tax_reduction_applicable_income: 26_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_claimant_benunit: [true, true] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + # Oxford's working-age scheme: 500 a week is in the 75% band, 900 x 75%. + oxford_council_tax_reduction: [0, 675] + # The head takes the English pensioner scheme: no income, 900. + simulated_council_tax_reduction_benunit: [900, 675] + council_tax_benefit: [900, 675] + council_tax_reduction: 1_575 + +- name: Oxford, working-age head and pension-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 26_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + oxford_council_tax_reduction: [675, 0] + # The sharer takes the English pensioner scheme, not Oxford's income + # bands (which would give 100% at 288 a week): the single pension-age + # applicable amount is 13,312 a year (256.00 a week x 52), so + # 900 - 20% x (15,000 - 13,312) = 562.40. + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [675, 562.40] + council_tax_reduction: 1_237.40 + +- name: Merton, a non-dependant of both claimants is deducted under each claimant's own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MERTON + output: + council_tax_reduction_pensioner: [true, false, false] + council_tax_reduction_joint_liability_share: [0.5, 0.5, 1] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + # The son's gross 576.92 a week is in Merton's 485-605 band: 13.30 a week + # (691.60 a year), whatever the head's age. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + # The son is a non-dependant of both: each claim bears half of him. + merton_council_tax_reduction_non_dep_deductions: [345.80, 345.80, 0] + # The sharer's claim is under Merton's working-age scheme: + # 900 - 345.80 = 554.20. The head's is under the pensioner scheme, with + # the national non-dependant amounts. + merton_council_tax_reduction: [0, 554.20, 0] + +- name: An unmodelled council's working-age sharer keeps its reported reduction beside a pension-age head + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 300 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [true, false] + # Maidstone's working-age scheme is not modelled; the pensioner scheme is. + council_tax_reduction_scheme_supported: [true, false] + simulated_council_tax_reduction_benunit: [900, 0] + council_tax_benefit: [900, 300] + council_tax_reduction: 1_200 + +- name: An unmodelled council's working-age head keeps its reported reduction beside a pension-age sharer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] + simulated_council_tax_reduction_benunit: [0, 900] + council_tax_benefit: [300, 900] + council_tax_reduction: 1_200 + +- name: Newham, the head's disability exempts only the head's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 35 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + council_tax_reduction_pensioner: [false, false, false] + # The son's gross 576.92 a week is in Newham's top band, 22.61 a week + # (1,175.72 a year); the sharer bears half, the head none. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 0, 1_175.72] + newham_council_tax_reduction_non_dep_deductions: [0, 587.86, 0] + +- name: Newham, the sharer's disability exempts only the sharer's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 35 + pip_dl: 3_000 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + newham_council_tax_reduction_non_dep_deductions: [587.86, 0, 0] + +- name: Wales, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + attendance_allowance: 4_000 + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + # Each family's own applicable amount: pension-age 256.00 a week, the + # working-age single 25 or over 95.55 a week. + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + # 900 - 20% x (15,000 - 13,312) = 562.40; + # 900 - 20% x (8,000 - 4,968.60) = 293.72. + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Wales, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + attendance_allowance: 4_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 + +- name: Scotland, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + pip_dl: 3_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Scotland, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index e41a7d5eb3..69b7214a85 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -301,7 +301,9 @@ # The eldest adult is not the liable resident; the head is. benunit_contains_household_head: [true, false] council_tax_reduction_claimant_benunit: [true, false] - council_tax_reduction_household_has_pensioner: false + # The parent's family is pension-age, but it is not liable and claims + # nothing; the householder's claim follows their own family. + council_tax_reduction_pensioner: [false, true] council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] council_tax_reduction_non_dep_deductions: [200, 0] simulated_council_tax_reduction_benunit: [1_600, 0] @@ -334,13 +336,15 @@ savings: 0 output: # The claimant's family is working-age; Maidstone's working-age scheme is - # not modelled, so no simulated reduction. - council_tax_reduction_household_has_pensioner: false - council_tax_reduction_scheme_supported: false + # not modelled, so no simulated reduction. The parent's family would be on + # the pensioner scheme, but it is not liable and claims nothing. + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [0, 0] # A non-dependant's Attendance Allowance does not exempt the claimant # from non-dependant deductions (SI 2012/2885 Sch 1 para 8(6)). - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] - name: With two members flagged as head, the eldest flagged member's family claims period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml index ec6636d1fa..9367281a5e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml @@ -1,4 +1,4 @@ -- name: Dependent young person PIP does not exempt the claimant household +- name: Dependent young person PIP does not exempt the claimant's family period: 2025 input: people: @@ -21,9 +21,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] -- name: Under-18 partner PIP exempts the claimant household +- name: Under-18 partner PIP exempts the claimant's family period: 2025 input: people: @@ -50,9 +50,9 @@ output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. is_couple: [true, false] - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Claimant PIP continues to exempt the household +- name: Claimant PIP continues to exempt the claimant's family period: 2025 input: people: @@ -75,9 +75,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Dependent young person blindness does not exempt the claimant household +- name: Dependent young person blindness does not exempt the claimant's family period: 2025 input: people: @@ -100,4 +100,4 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py new file mode 100644 index 0000000000..e6bac255c1 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -0,0 +1,300 @@ +"""Property-based tests: each family that claims Council Tax Reduction is +assessed under its own scheme and its own non-dependant exemption. + +Where the household's rent is shared, every family liable for it claims, and +each claim follows the applicant's own circumstances. "Pensioner" is defined +per person and partner (SI 2012/2885 reg 3(1); SI 2012/2886 Sch para 3(2); +SI 2013/3029 reg 3(1); SSI 2021/249 reg 3; SSI 2012/319 reg 12). No +non-dependant deduction is made "if the applicant or his partner" is blind or +gets a listed disability benefit (SI 2012/2885 Sch 1 para 8(6); SI 2012/2886 +Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and Sch 6 para 5(6); SSI +2021/249 reg 90(6); SSI 2012/319 reg 48(6)). + +Invariants, for any generated population of households with a flagged head, +up to two sharer families and at most one non-dependant family: + +1. Own members: a family is a pensioner exactly when its claimant or partner + has reached State Pension age. It is exempt from non-dependant deductions + exactly when its claimant or partner is blind or gets Attendance + Allowance, the care component of Disability Living Allowance, the daily + living component of Personal Independence Payment or Armed Forces + Independence Payment. +2. As if alone: each family's pensioner status, exemption and whether its + scheme is simulated are what they would be if it lived alone. +3. Scheme: a family's scheme is simulated exactly when it lives in Scotland + or Wales, is a pensioner, or lives in a council whose working-age scheme + is modelled. In England only a pensioner family is paid by the national + pensioner scheme, and only a working-age family by a council's scheme. +4. Others do not matter: changing the ages and disability benefits of every + other family that claims leaves a family's pensioner status, exemption, + scheme and award unchanged. +5. Bounds: a family that cannot claim gets no simulated reduction, and the + household's reduction never exceeds its council tax. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +MODELLED_WORKING_AGE_SCHEMES = { + "MERTON", + "KINGSTON_UPON_THAMES", + "NEWHAM", + "WESTMINSTER", + "OXFORD", +} +LOCAL_SCHEMES = [ + "merton_council_tax_reduction", + "kingston_upon_thames_council_tax_reduction", + "newham_council_tax_reduction", + "westminster_council_tax_reduction", + "oxford_council_tax_reduction", +] +# Inputs that exempt the applicant from non-dependant deductions, as an +# annual amount (or a flag for blindness). +DISABILITY = { + "attendance_allowance": 5_000, + "dla_sc": 3_500, + "pip_dl": 4_000, + "armed_forces_independence_payment": 9_000, + "is_blind": True, +} +# Ages of 20 or more are never presumed to be a child of the family, so +# changing them never changes who is a claimant or partner. The sampled values +# sit on both sides of State Pension age. +adult_age = st.one_of( + st.sampled_from([20, 30, 45, 64, 65, 66, 67, 70, 85]), st.integers(20, 100) +) +disability = st.sampled_from([None, *DISABILITY]) +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def adults(draw, most): + n = draw(st.integers(1, most)) + return dict( + ages=draw(st.lists(adult_age, min_size=n, max_size=n)), + disabilities=draw(st.lists(disability, min_size=n, max_size=n)), + earnings=draw(st.lists(money, min_size=n, max_size=n)), + ) + + +@st.composite +def family(draw, role): + # A non-dependant here is one adult with no children. + alone = role == "non_dependant" + return dict( + role=role, + child_age=None if alone else draw(st.one_of(st.none(), st.integers(0, 15))), + **draw(adults(1 if alone else 2)), + ) + + +@st.composite +def households(draw): + families = [draw(family("head"))] + families += [draw(family("sharer")) for _ in range(draw(st.integers(0, 2)))] + if draw(st.booleans()): + families.append(draw(family("non_dependant"))) + claimants = sum(f["role"] != "non_dependant" for f in families) + return dict( + families=families, + scheme=draw(st.sampled_from(SCHEMES)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + rent=draw(money), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + target=draw(st.integers(0, claimants - 1)), + # Replacement circumstances for the other families that claim. + other_ages=draw(st.lists(adult_age, min_size=6, max_size=6)), + other_disabilities=draw(st.lists(disability, min_size=6, max_size=6)), + ) + + +population = st.lists(households(), min_size=1, max_size=6) + + +def person(age, disability_input, earnings): + attributes = {"age": age, "employment_income": earnings} + if disability_input is not None: + attributes[disability_input] = DISABILITY[disability_input] + return attributes + + +def build(population, alone=False, perturb_others=False): + """One situation for the whole population, plus per-family facts. + + alone: put every family in a household of its own, as its head. + perturb_others: give every family that claims, other than each + household's target, the household's replacement ages and disabilities. + """ + people, benunits, homes = {}, {}, {} + facts = dict(country=[], local_authority=[], role=[], target=[], house=[]) + for h, house in enumerate(population): + country, local_authority = house["scheme"] + home = dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + rent=house["rent"], + tenure_type="RENT_PRIVATELY", + savings=house["savings"], + ) + members, replacement = [], 0 + for f, fam in enumerate(house["families"]): + ids = [] + claims = fam["role"] != "non_dependant" + is_target = claims and f == house["target"] + for i, age in enumerate(fam["ages"]): + pid = f"h{h}_f{f}_adult_{i}" + disability_input = fam["disabilities"][i] + if perturb_others and claims and not is_target: + age = house["other_ages"][replacement] + disability_input = house["other_disabilities"][replacement] + replacement += 1 + people[pid] = person(age, disability_input, fam["earnings"][i]) + people[pid]["is_household_head"] = ( + alone or fam["role"] == "head" + ) and i == 0 + ids.append(pid) + if fam["child_age"] is not None: + pid = f"h{h}_f{f}_child" + people[pid] = {"age": fam["child_age"], "is_household_head": False} + ids.append(pid) + benunits[f"h{h}_f{f}"] = { + "members": ids, + "liable_for_share_of_household_rent": fam["role"] == "sharer" + and not alone, + "claims_all_entitled_benefits": True, + "would_claim_uc": True, + } + if alone: + homes[f"h{h}_f{f}"] = {"members": ids, **home} + members.extend(ids) + facts["country"].append(country) + facts["local_authority"].append(local_authority) + facts["role"].append(fam["role"]) + facts["target"].append(is_target) + facts["house"].append(h) + if not alone: + homes[f"h{h}"] = {"members": members, **home} + situation = { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", homes), + ) + } + return situation, {k: np.array(v) for k, v in facts.items()} + + +def calc(simulation, variable, map_to=None): + return np.asarray(simulation.calculate(variable, YEAR, map_to=map_to)) + + +def any_in_benunit(simulation, person_values): + return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 + + +FLAGS = [ + "council_tax_reduction_pensioner", + "council_tax_reduction_applicant_has_non_dep_exemption", + "council_tax_reduction_scheme_supported", +] + + +@PROPERTY_SETTINGS +@given(population) +def test_scheme_follows_own_family(population): + situation, facts = build(population) + sim = Simulation(situation=situation) + pensioner = calc(sim, "council_tax_reduction_pensioner") + exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") + supported = calc(sim, "council_tax_reduction_scheme_supported") + claimant = calc(sim, "council_tax_reduction_claimant_benunit") + + # 1. Own members. + claimant_or_partner = calc(sim, "is_claimant_or_partner") + assert np.array_equal( + pensioner, + any_in_benunit(sim, claimant_or_partner & calc(sim, "is_SP_age")), + ) + exempting = calc(sim, "is_blind").astype(bool) + for variable in DISABILITY: + if variable != "is_blind": + exempting |= calc(sim, variable) > 0 + assert np.array_equal(exempt, any_in_benunit(sim, claimant_or_partner & exempting)) + + # 2. As if alone. + alone_situation, alone_facts = build(population, alone=True) + alone = Simulation(situation=alone_situation) + assert np.array_equal(alone_facts["role"], facts["role"]) + for variable in FLAGS: + assert np.array_equal(calc(sim, variable), calc(alone, variable)), variable + + # 3. Scheme. + england = facts["country"] == "ENGLAND" + modelled = np.isin(facts["local_authority"], list(MODELLED_WORKING_AGE_SCHEMES)) + assert np.array_equal(supported, ~england | pensioner | modelled) + simulated = calc(sim, "simulated_council_tax_reduction_benunit") + local = sum(calc(sim, variable) for variable in LOCAL_SCHEMES) + national = simulated - local + assert not np.any((national > 0.005) & england & ~pensioner) + assert not np.any((local > 0.005) & pensioner) + assert not np.any((local > 0.005) & ~england) + + # 5. Bounds. (No family has a reported reduction to fall back on here.) + assert np.all(simulated[~claimant] == 0) + household_reduction = calc(sim, "council_tax_reduction") + assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + + +@PROPERTY_SETTINGS +@given(population) +def test_other_families_do_not_change_a_claim(population): + situation, facts = build(population) + perturbed_situation, _ = build(population, perturb_others=True) + before = Simulation(situation=situation) + after = Simulation(situation=perturbed_situation) + target = facts["target"] + # 4. Others do not matter. + for variable in FLAGS + LOCAL_SCHEMES: + assert np.array_equal( + calc(before, variable)[target], calc(after, variable)[target] + ), variable + for variable in ( + "council_tax_reduction_joint_liability_share", + "simulated_council_tax_reduction_benunit", + "council_tax_benefit", + ): + np.testing.assert_allclose( + calc(before, variable)[target], + calc(after, variable)[target], + atol=1e-6, + err_msg=variable, + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index d2861ead5b..b80f01b1f0 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -9,7 +9,7 @@ class council_tax_benefit(Variable): unit = GBP def formula(benunit, period, parameters): - supported = benunit.household("council_tax_reduction_scheme_supported", period) + supported = benunit("council_tax_reduction_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) return where(supported, simulated, reported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 966d230ff7..599c266543 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -91,17 +91,31 @@ def local_non_dep_deductions( # A non-dependant of two or more jointly liable people is apportioned # equally between them (SI 2012/2885 Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - return (deductions_in_household - deduction_for_benunit) * share + # No deduction from an applicant who, or whose partner, is blind or gets a + # qualifying disability benefit, whatever other claimants in the household + # get (the councils' schemes follow SI 2012/2886 Sch para 30(6)). + applicant_exempt = benunit( + "council_tax_reduction_applicant_has_non_dep_exemption", period + ) + return where( + applicant_exempt, 0, (deductions_in_household - deduction_for_benunit) * share + ) def normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=True, exempt_uc_no_earned_income=True, ): + """The deduction a non-dependant brings under one council's scheme. + + It depends on the non-dependant alone. Whether a claimant's award uses it + (the claimant's own scheme) and whether the claimant is exempt from + non-dependant deductions are decided per claimant family. + """ gross_income_components = [ "employment_income", "self_employment_income", @@ -128,9 +142,6 @@ def normal_gross_income_non_dep_deduction( ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), ctr.non_dep_deduction.amount.calc(0), ) - claimant_exempt = person.household( - "council_tax_reduction_household_has_non_dep_exemption", period - ) full_time_student = is_full_time_student_non_dep(person, period) income_based_benefit = ( (person.benunit("income_support", period) > 0) @@ -141,9 +152,8 @@ def normal_gross_income_non_dep_deduction( has_uc = person.benunit("universal_credit", period) > 0 no_earned_income = weekly_benunit_earned_income <= 0 exempt = ( - claimant_exempt - | full_time_student + full_time_student | (exempt_income_based_benefits & income_based_benefit) | (exempt_uc_no_earned_income & has_uc & no_earned_income) ) - return working_age * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) + return in_scheme_area * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index 1ee8ea5cbc..a28e7a3a68 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -2,6 +2,9 @@ from policyengine_uk.variables.household.demographic.country import Country from policyengine_uk.variables.household.demographic.locations import LocalAuthority +# Scheme selection takes has_pensioner per claim: whether the applicant's own +# family is a pensioner for Council Tax Reduction (council_tax_reduction_pensioner). + def is_england_pensioner_scheme(country, has_pensioner): return (country == Country.ENGLAND) & has_pensioner @@ -19,46 +22,60 @@ def is_merton(local_authority): return local_authority == LocalAuthority.MERTON +def is_merton_area(local_authority, country): + return (country == Country.ENGLAND) & is_merton(local_authority) + + def is_merton_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_merton(local_authority) + return is_merton_area(local_authority, country) & ~has_pensioner def is_kingston_upon_thames(local_authority): return local_authority == LocalAuthority.KINGSTON_UPON_THAMES +def is_kingston_upon_thames_area(local_authority, country): + return (country == Country.ENGLAND) & is_kingston_upon_thames(local_authority) + + def is_kingston_upon_thames_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_kingston_upon_thames(local_authority) - ) + return is_kingston_upon_thames_area(local_authority, country) & ~has_pensioner def is_newham(local_authority): return local_authority == LocalAuthority.NEWHAM +def is_newham_area(local_authority, country): + return (country == Country.ENGLAND) & is_newham(local_authority) + + def is_newham_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_newham(local_authority) + return is_newham_area(local_authority, country) & ~has_pensioner def is_westminster(local_authority): return local_authority == LocalAuthority.WESTMINSTER +def is_westminster_area(local_authority, country): + return (country == Country.ENGLAND) & is_westminster(local_authority) + + def is_westminster_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) & ~has_pensioner & is_westminster(local_authority) - ) + return is_westminster_area(local_authority, country) & ~has_pensioner def is_oxford(local_authority): return local_authority == LocalAuthority.OXFORD +def is_oxford_area(local_authority, country): + return (country == Country.ENGLAND) & is_oxford(local_authority) + + def is_oxford_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_oxford(local_authority) + return is_oxford_area(local_authority, country) & ~has_pensioner def is_supported_scheme(country, has_pensioner, local_authority): diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py new file mode 100644 index 0000000000..4a539258a2 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "No non-dependant deductions from this family's Council Tax Reduction" + documentation = ( + "Whether no deduction is made for any non-dependant from this family's " + "Council Tax Reduction because the applicant or partner is blind, or " + "receives in respect of themselves Attendance Allowance, the care " + "component of Disability Living Allowance, the daily living component " + "of Personal Independence Payment or Armed Forces Independence Payment. " + "The exemption belongs to each applicant: where families share the " + "rent and each claims, one family's disability does not exempt " + "another's claim. The model applies it in the English working-age " + "local schemes." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 Sch 1 para 8(6): "No deduction is to be made in respect + # of any non-dependants occupying an applicant's dwelling if the + # applicant or his partner is" blind or receiving one of the listed + # benefits "in respect of himself". + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + exempting = ( + person("is_blind", period) + | (person("attendance_allowance", period) > 0) + | (person("pip_dl", period) > 0) + | (person("dla_sc", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + ) + return benunit.any(claimant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py deleted file mode 100644 index 3ede96b204..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ /dev/null @@ -1,32 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_non_dep_exemption(Variable): - value_type = bool - entity = Household - label = "CTR household has a non-dependant deduction exemption" - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8" - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - claimant_or_partner = claimant_benunit & person( - "is_claimant_or_partner", period - ) - is_blind = person("is_blind", period) & claimant_or_partner - attendance_allowance = ( - person("attendance_allowance", period) > 0 - ) & claimant_or_partner - pip_daily_living = (person("pip_dl", period) > 0) & claimant_or_partner - dla_care = (person("dla_sc", period) > 0) & claimant_or_partner - armed_forces_independence_payment = ( - person("armed_forces_independence_payment", period) > 0 - ) & claimant_or_partner - return household.any( - is_blind - | attendance_allowance - | pip_daily_living - | dla_care - | armed_forces_independence_payment - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py deleted file mode 100644 index 555231a5ab..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py +++ /dev/null @@ -1,13 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_pensioner(Variable): - value_type = bool - entity = Household - label = "CTR claimant benefit unit has a pension-age member" - definition_period = YEAR - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - return household.any(claimant_benunit & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py new file mode 100644 index 0000000000..d958b9f680 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_pensioner(Variable): + value_type = bool + entity = BenUnit + label = "Pensioner for Council Tax Reduction" + documentation = ( + "Whether this family's claim falls under the pension-age Council Tax " + "Reduction rules: the claimant or partner has reached State Pension " + "age. Each applicant's scheme follows their own family, so where " + "families share the rent and each claims, a working-age family is " + "assessed under the working-age rules even if the household head's " + "family is pension-age, and the reverse. The further condition that " + "neither the claimant nor a partner is on Income Support, income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance or Universal Credit is not modelled here." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 reg 3(1)(a)(i): "he has attained the qualifying age for + # state pension credit". is_SP_age stands in for that age, as + # elsewhere in the model. + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(claimant_or_partner & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index 9fb0699fa0..c559fe9bb9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -6,14 +6,21 @@ class council_tax_reduction_scheme_supported(Variable): value_type = bool - entity = Household + entity = BenUnit label = "Supported CTR scheme is available" + documentation = ( + "Whether the model simulates the Council Tax Reduction scheme that " + "applies to this family's claim: the pension-age scheme in England, " + "the Scottish and Welsh schemes, and the working-age schemes of the " + "English councils it models. The scheme follows the family's own " + "pensioner status, so families in one household can fall under " + "different schemes. A family whose scheme is not simulated keeps its " + "reported reduction." + ) definition_period = YEAR - def formula(household, period, parameters): - country = household("country", period) - has_pensioner = household( - "council_tax_reduction_household_has_pensioner", period - ) - local_authority = household("local_authority", period) + def formula(benunit, period, parameters): + country = benunit.household("country", period) + has_pensioner = benunit("council_tax_reduction_pensioner", period) + local_authority = benunit.household("local_authority", period) return is_supported_scheme(country, has_pensioner, local_authority) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 6f3714fee8..91fdb6cf6f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -30,9 +30,8 @@ def formula(benunit, period, parameters): wales_ctr = local_authority_parameters.wales.council_tax_reduction country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + # Each claim's scheme follows the applicant's own family. + has_pensioner = benunit("council_tax_reduction_pensioner", period) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) scotland = is_scotland_scheme(country) wales = is_wales_scheme(country) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index 7f9b1b7663..46afbc7d86 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -23,7 +23,7 @@ def formula(benunit, period, parameters): working_age = is_kingston_upon_thames_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py index c8ae7f4df0..616274593b 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_working_age, + is_kingston_upon_thames_area, ) @@ -20,15 +20,15 @@ def formula(person, period, parameters): period ).gov.local_authorities.kingston_upon_thames.council_tax_reduction household = person.household - working_age = is_kingston_upon_thames_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_kingston_upon_thames_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py index 03012a5f8a..ef6ec3b1c5 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py @@ -18,9 +18,7 @@ def formula(benunit, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction local_authority = benunit.household("local_authority", period) country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + has_pensioner = benunit("council_tax_reduction_pensioner", period) working_age = is_merton_working_age(local_authority, country, has_pensioner) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py index 2907e2a700..8023cec88d 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_merton_working_age, + is_merton_area, ) @@ -18,15 +18,15 @@ class merton_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction household = person.household - working_age = is_merton_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_merton_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py index c35482476f..4abcbf6e62 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py @@ -20,7 +20,7 @@ def formula(benunit, period, parameters): working_age = is_newham_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py index 0e4b9db546..465b7b5a78 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_newham_working_age, + is_newham_area, ) @@ -18,15 +18,15 @@ class newham_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.newham.council_tax_reduction household = person.household - working_age = is_newham_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_newham_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index abeea551dc..208dab3929 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -18,7 +18,7 @@ def formula(benunit, period, parameters): working_age = is_oxford_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) is_household_head_benunit = benunit( "council_tax_reduction_claimant_benunit", period diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py index 61c80cecbb..64452ff088 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_oxford_working_age, + is_oxford_area, ) @@ -18,16 +18,16 @@ class oxford_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.oxford.council_tax_reduction household = person.household - working_age = is_oxford_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_oxford_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=False, exempt_uc_no_earned_income=False, ) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index 3c86931aee..eaad011382 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -21,7 +21,7 @@ def formula(benunit, period, parameters): working_age = is_westminster_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py index d5666a67a7..86533c51d7 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_westminster_working_age, + is_westminster_area, ) @@ -18,15 +18,15 @@ class westminster_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.westminster.council_tax_reduction household = person.household - working_age = is_westminster_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_westminster_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) From ffa5ff531bf9e962d942051e7fb7a2fb105eaba5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:08:33 -0400 Subject: [PATCH 02/13] Reconcile reported and simulated reductions within a household MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the scheme chosen per family, one household can hold a simulated claim and a family whose scheme is not simulated. Review r1 found that a family that cannot claim then kept its reported reduction beside a head whose simulated award already covered the whole bill: £3,000 against £1,800. - A family that cannot claim keeps a reported reduction only where no claim in its household is simulated (new household flag council_tax_reduction_household_has_simulated_claim). Without sharers this is the base behaviour. - A jointly liable claimant's reported reduction is limited to its own share of the council tax (SI 2012/2885 Sch 1 para 7(3)-(4)), the most any scheme could give it. Tests: three YAML cases; the property tests now generate reported reductions, check the fallback and the household bound, and toggle one claimant's exempting benefit to show it leaves the other claim unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 107 +++++++++++++ ...ction_claimant_family_scheme_properties.py | 148 +++++++++++++++++- .../variables/gov/dwp/council_tax_benefit.py | 32 +++- ...reduction_household_has_simulated_claim.py | 21 +++ .../council_tax_reduction_scheme_supported.py | 4 +- 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index c364aad9fd..e0e30352a3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -218,6 +218,113 @@ council_tax_benefit: [300, 900] council_tax_reduction: 1_200 +- name: A family that cannot claim keeps no reported reduction beside a simulated claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + adult_child: + age: 40 + council_tax_benefit_reported: 1_200 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + child_family: + members: [adult_child] + would_claim_uc: false + households: + home: + members: [head, adult_child] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_claimant_benunit: [true, false] + # The adult child's own scheme (Maidstone's working-age one) is not + # simulated, but the household's bill is: the head's pensioner claim + # covers all of it. The child's report cannot be added on top. + council_tax_reduction_scheme_supported: [true, false] + council_tax_reduction_household_has_simulated_claim: true + council_tax_benefit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Where no claim is simulated, every family keeps its reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + parent: + age: 70 + council_tax_benefit_reported: 200 + benunits: + head_family: + members: [head] + would_claim_uc: false + parent_family: + members: [parent] + would_claim_uc: false + households: + home: + members: [head, parent] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The head's working-age scheme is not modelled, so nothing in the + # household is simulated; the parent cannot claim but keeps its report. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: false + council_tax_benefit: [300, 200] + council_tax_reduction: 500 + +- name: A jointly liable claimant's reported reduction is limited to its share of the bill + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # SI 2012/2885 Sch 1 para 7(3)-(4): each of the two is liable for half, + # 900, so no scheme could reduce the sharer's bill by more than that. + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_scheme_supported: [true, false] + council_tax_benefit: [900, 900] + council_tax_reduction: 1_800 + - name: Newham, the head's disability exempts only the head's claim from non-dependant deductions period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index e6bac255c1..e863dcf8f1 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -27,9 +27,17 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and award unchanged. -5. Bounds: a family that cannot claim gets no simulated reduction, and the - household's reduction never exceeds its council tax. + scheme and award unchanged, reported reductions included. +5. Fallback: a claiming family gets its simulated reduction where its scheme + is simulated, and otherwise its reported one, limited to its share of the + council tax where it is jointly liable. A family that cannot claim gets + its reported reduction only where no claim in its household is simulated. +6. Bounds: a family that cannot claim gets no simulated reduction; a + household with a simulated claim never gets more than its council tax. +7. The exemption is the applicant's own: in a council's working-age scheme, + giving one claiming family an exempting benefit removes the non-dependant + deductions from its own reduction and leaves every other claim's + deductions and award unchanged. """ import numpy as np @@ -86,6 +94,7 @@ ) disability = st.sampled_from([None, *DISABILITY]) money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +reported = st.one_of(st.just(0.0), st.floats(0, 3_000, allow_nan=False)) @st.composite @@ -105,6 +114,7 @@ def family(draw, role): return dict( role=role, child_age=None if alone else draw(st.one_of(st.none(), st.integers(0, 15))), + reported=draw(reported), **draw(adults(1 if alone else 2)), ) @@ -171,6 +181,8 @@ def build(population, alone=False, perturb_others=False): disability_input = house["other_disabilities"][replacement] replacement += 1 people[pid] = person(age, disability_input, fam["earnings"][i]) + if i == 0: + people[pid]["council_tax_benefit_reported"] = fam["reported"] people[pid]["is_household_head"] = ( alone or fam["role"] == "head" ) and i == 0 @@ -268,10 +280,35 @@ def test_scheme_follows_own_family(population): assert not np.any((local > 0.005) & pensioner) assert not np.any((local > 0.005) & ~england) - # 5. Bounds. (No family has a reported reduction to fall back on here.) + # 5. Fallback. + benefit = calc(sim, "council_tax_benefit") + reported_amount = calc(sim, "council_tax_benefit_reported", map_to="benunit") + share = calc(sim, "council_tax_reduction_joint_liability_share") + # Index households directly: map_to splits a household value among members. + house = facts["house"] + bill = calc(sim, "council_tax")[house] + simulates = calc(sim, "council_tax_reduction_household_has_simulated_claim") + household_simulates = simulates[house].astype(bool) + expected = np.where( + claimant, + np.where( + supported, + simulated, + np.where( + share < 1, np.minimum(reported_amount, bill * share), reported_amount + ), + ), + np.where(household_simulates, 0, reported_amount), + ) + np.testing.assert_allclose(benefit, expected, atol=0.01) + simulated_claims = np.bincount(house, weights=claimant & supported) + assert np.array_equal(simulates.astype(bool), simulated_claims > 0) + + # 6. Bounds. assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") - assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + over = household_reduction > calc(sim, "council_tax") + 0.01 + assert not np.any(over & simulates) @PROPERTY_SETTINGS @@ -298,3 +335,104 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + + +@st.composite +def shared_council_homes(draw): + """A working-age head and sharer under one council's scheme, with a + non-dependant in remunerative work, and an exempting benefit for one of + the two claimants.""" + return dict( + local_authority=draw(st.sampled_from(sorted(MODELLED_WORKING_AGE_SCHEMES))), + ages=draw(st.lists(st.integers(20, 60), min_size=2, max_size=2)), + non_dependant_age=draw(st.integers(20, 60)), + non_dependant_earnings=draw(st.floats(10_000, 60_000, allow_nan=False)), + council_tax=draw(st.floats(1_000, 4_000, allow_nan=False)), + exempt_family=draw(st.sampled_from([0, 1])), + disability=draw(st.sampled_from(sorted(DISABILITY))), + ) + + +def council_situation(homes, with_disability): + people, benunits, households = {}, {}, {} + for h, home in enumerate(homes): + ids = [] + for f, age in enumerate(home["ages"]): + pid = f"h{h}_claimant_{f}" + disability_input = ( + home["disability"] + if with_disability and f == home["exempt_family"] + else None + ) + people[pid] = person(age, disability_input, 0) + people[pid]["is_household_head"] = f == 0 + benunits[f"h{h}_f{f}"] = { + "members": [pid], + "liable_for_share_of_household_rent": f == 1, + "claims_all_entitled_benefits": True, + "would_claim_uc": False, + } + ids.append(pid) + pid = f"h{h}_non_dependant" + people[pid] = person( + home["non_dependant_age"], None, home["non_dependant_earnings"] + ) + people[pid].update(is_household_head=False, weekly_hours=37.5) + benunits[f"h{h}_f2"] = {"members": [pid], "would_claim_uc": False} + ids.append(pid) + households[f"h{h}"] = dict( + members=ids, + country="ENGLAND", + local_authority=home["local_authority"], + council_tax=home["council_tax"], + rent=12_000, + tenure_type="RENT_PRIVATELY", + savings=0, + ) + return { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", households), + ) + } + + +@PROPERTY_SETTINGS +@given(st.lists(shared_council_homes(), min_size=1, max_size=6)) +def test_exemption_is_the_applicants_own(homes): + without = Simulation(situation=council_situation(homes, with_disability=False)) + with_ = Simulation(situation=council_situation(homes, with_disability=True)) + # Benefit units run head, sharer, non-dependant in each household. + family = np.tile([0, 1, 2], len(homes)) + exempt_family = np.repeat([home["exempt_family"] for home in homes], 3) + exempted = family == exempt_family + other = (family < 2) & ~exempted + variables = [v + "_non_dep_deductions" for v in LOCAL_SCHEMES] + + def total(sim_, names): + return sum(calc(sim_, name) for name in names) + + before_deductions = total(without, variables) + after_deductions = total(with_, variables) + # 7. The non-dependant is deducted from both claims without the benefit, + # so the test is not vacuous. + assert np.all(before_deductions[family < 2] > 0) + flag = "council_tax_reduction_applicant_has_non_dep_exemption" + assert not np.any(calc(without, flag)[family < 2]) + assert np.array_equal(calc(with_, flag)[family < 2], exempted[family < 2]) + assert np.all(after_deductions[exempted] == 0) + np.testing.assert_allclose( + after_deductions[other], before_deductions[other], atol=1e-6 + ) + for name in LOCAL_SCHEMES + ["council_tax_benefit"]: + np.testing.assert_allclose( + calc(with_, name)[other], calc(without, name)[other], atol=1e-6 + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index b80f01b1f0..bda53d9111 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -5,11 +5,41 @@ class council_tax_benefit(Variable): value_type = float entity = BenUnit label = "Council Tax Benefit" + documentation = ( + "A family that claims gets the simulated reduction where the model " + "simulates its own scheme, and its reported reduction otherwise. A " + "claimant jointly liable with others can get at most its own share " + "of the council tax, so its reported reduction is limited to that " + "share. A family that cannot claim keeps a reported reduction only " + "where no claim in its household is simulated." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/4", + ) def formula(benunit, period, parameters): supported = benunit("council_tax_reduction_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) - return where(supported, simulated, reported) + claimant = benunit("council_tax_reduction_claimant_benunit", period) + share = benunit("council_tax_reduction_joint_liability_share", period) + # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's + # maximum reduction is on its share of the council tax. + share_of_liability = ( + benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + * share + ) + reported_claim = where(share < 1, min_(reported, share_of_liability), reported) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) + not_claiming = where(household_simulates, 0, reported) + return where( + claimant, where(supported, simulated, reported_claim), not_claiming + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py new file mode 100644 index 0000000000..897e43016f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_simulated_claim(Variable): + value_type = bool + entity = Household + label = "A Council Tax Reduction claim in the household is simulated" + documentation = ( + "Whether any family that claims Council Tax Reduction for the " + "household falls under a scheme the model simulates. The simulated " + "awards then cover those families' shares of the council tax. A " + "reported reduction of a family the model does not treat as liable " + "cannot be reconciled with them, so it is not kept." + ) + definition_period = YEAR + + def formula(household, period, parameters): + person = household.members + claimant = person.benunit("council_tax_reduction_claimant_benunit", period) + supported = person.benunit("council_tax_reduction_scheme_supported", period) + return household.any(claimant & supported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index c559fe9bb9..915e814e52 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -14,8 +14,8 @@ class council_tax_reduction_scheme_supported(Variable): "the Scottish and Welsh schemes, and the working-age schemes of the " "English councils it models. The scheme follows the family's own " "pensioner status, so families in one household can fall under " - "different schemes. A family whose scheme is not simulated keeps its " - "reported reduction." + "different schemes. A claiming family whose scheme is not simulated " + "falls back to its reported reduction (council_tax_benefit)." ) definition_period = YEAR From 7ac22effc3c459e74abbbdf84de973a6c1f93dd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:26:43 -0400 Subject: [PATCH 03/13] Set a claim's scheme and exemption from the applicant and their partner #2009 now names the liable people directly. A household head can be liable without being the claimant or partner of their family (a grandparent heading a family formed around a young couple), so "the applicant or his partner" (SI 2012/2885 reg 3, Sch 1 para 8(6)) is not always the family's claimant and partner. New person-level council_tax_reduction_applicant_or_partner: the liable member, and the other member of the couple where the liable member is the claimant or partner, whatever the partner's age; in a family that cannot claim, its claimant and partner. The pensioner flag and the applicant exemption read it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 78 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 5 +- ...duction_applicant_has_non_dep_exemption.py | 6 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 +++++++++ .../council_tax_reduction_pensioner.py | 14 ++-- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index e0e30352a3..98c9a2afbe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -566,3 +566,81 @@ council_tax_reduction_applicable_amount: [4_968.60, 13_312] simulated_council_tax_reduction_benunit: [293.72, 562.40] council_tax_reduction: 856.12 + +- name: A pension-age household head applies as a pensioner although her family's claimant and partner are young parents + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + pip_dl: 3_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The grandmother is the liable resident, so she is the applicant; the + # young parents are the family's claimant and partner but not hers. + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_applicant_or_partner: [true, false, false, false] + # Her age puts the claim on the pensioner scheme (SI 2012/2885 reg 3). + council_tax_reduction_pensioner: [true] + council_tax_reduction_scheme_supported: [true] + # The young mother's PIP is not the applicant's or her partner's + # (Sch 1 para 8(6)). + council_tax_reduction_applicant_has_non_dep_exemption: [false] + simulated_council_tax_reduction_benunit: [1_800] + +- name: An applicant's partner under 18 still counts for the applicant's exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 17 + is_household_head: false + is_claimant_or_partner: true + pip_dl: 3_000 + benunits: + couple: + members: [head, partner] + would_claim_uc: false + households: + home: + members: [head, partner] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Only the head is liable (LGFA 1992 s.6(5)), but para 8(6) asks about + # "the applicant or his partner". + council_tax_reduction_liable_person: [true, false] + council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true] + council_tax_reduction_pensioner: [false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index e863dcf8f1..70c5e88af2 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -11,7 +11,10 @@ 2021/249 reg 90(6); SSI 2012/319 reg 48(6)). Invariants, for any generated population of households with a flagged head, -up to two sharer families and at most one non-dependant family: +up to two sharer families and at most one non-dependant family. Every adult +here is 20 or over and the head is a claimant, so each family's applicant and +partner are its claimant and partner (the YAML cases cover a head who is +not): 1. Own members: a family is a pensioner exactly when its claimant or partner has reached State Pension age. It is exempt from non-dependant deductions diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index 4a539258a2..f49ffa5ff2 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -32,7 +32,9 @@ def formula(benunit, period, parameters): # applicant or his partner is" blind or receiving one of the listed # benefits "in respect of himself". person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) exempting = ( person("is_blind", period) | (person("attendance_allowance", period) > 0) @@ -40,4 +42,4 @@ def formula(benunit, period, parameters): | (person("dla_sc", period) > 0) | (person("armed_forces_independence_payment", period) > 0) ) - return benunit.any(claimant_or_partner & exempting) + return benunit.any(applicant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..2fbadd516f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Applicant for Council Tax Reduction, or the applicant's partner" + documentation = ( + "The people whose circumstances set a family's Council Tax Reduction " + "scheme and its exemption from non-dependant deductions: the " + "applicant and the applicant's partner. In a family that claims, the " + "applicant is a member the model treats as liable for the council " + "tax, and the partner is the other member of the couple where the " + "applicant is the family's claimant or partner, whatever the " + "partner's age. A household head who is not the claimant or partner " + "of their family (a grandparent heading a family formed around a " + "young couple, say) applies alone. In a family that cannot claim, " + "these are the claimant and partner, who would apply if it could." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", + ) + + def formula(person, period, parameters): + liable = person("council_tax_reduction_liable_person", period) + claimant_or_partner = person("is_claimant_or_partner", period) + family_claims = person.benunit.any(liable) + # The couple, where one of them is liable; a partner under 18 is not + # liable but is still the applicant's partner. + couple_applies = person.benunit.any(liable & claimant_or_partner) + applicant_or_partner = liable | (couple_applies & claimant_or_partner) + return where(family_claims, applicant_or_partner, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index d958b9f680..3305026f80 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -7,12 +7,12 @@ class council_tax_reduction_pensioner(Variable): label = "Pensioner for Council Tax Reduction" documentation = ( "Whether this family's claim falls under the pension-age Council Tax " - "Reduction rules: the claimant or partner has reached State Pension " - "age. Each applicant's scheme follows their own family, so where " - "families share the rent and each claims, a working-age family is " + "Reduction rules: the applicant or the applicant's partner has reached " + "State Pension age. Each applicant's scheme follows their own family, " + "so where families share the rent and each claims, a working-age family is " "assessed under the working-age rules even if the household head's " "family is pension-age, and the reverse. The further condition that " - "neither the claimant nor a partner is on Income Support, income-based " + "neither the applicant nor a partner is on Income Support, income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance or Universal Credit is not modelled here." ) @@ -30,5 +30,7 @@ def formula(benunit, period, parameters): # state pension credit". is_SP_age stands in for that age, as # elsewhere in the model. person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - return benunit.any(claimant_or_partner & person("is_SP_age", period)) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) + return benunit.any(applicant_or_partner & person("is_SP_age", period)) From 9efdea22d09f3e69f619872f6382cfbf7635bc8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:20:49 -0400 Subject: [PATCH 04/13] Use #2078's CTR applicant-or-partner variable PolicyEngine/policyengine-uk#2078 (also stacked on #2009) adds is_council_tax_reduction_applicant_or_partner and council_tax_reduction_head_applies_alone for the CTR means test. They define the same people as this PR's council_tax_reduction_applicant_or_partner: the claimant and partner where the liable person is one of them, the liable head alone otherwise, and the claimant and partner in a family that cannot claim. Take #2078's two files byte for byte, so whichever PR lands second merges without conflict, and point the pensioner flag and the applicant exemption at it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 4 +- ...duction_applicant_has_non_dep_exemption.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 --------------- ...ouncil_tax_reduction_head_applies_alone.py | 38 +++++++++++++++ .../council_tax_reduction_pensioner.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 46 +++++++++++++++++++ 6 files changed, 88 insertions(+), 41 deletions(-) delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 98c9a2afbe..58c3fc2d7f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -603,7 +603,7 @@ # The grandmother is the liable resident, so she is the applicant; the # young parents are the family's claimant and partner but not hers. is_claimant_or_partner: [false, true, true, false] - council_tax_reduction_applicant_or_partner: [true, false, false, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] # Her age puts the claim on the pensioner scheme (SI 2012/2885 reg 3). council_tax_reduction_pensioner: [true] council_tax_reduction_scheme_supported: [true] @@ -641,6 +641,6 @@ # Only the head is liable (LGFA 1992 s.6(5)), but para 8(6) asks about # "the applicant or his partner". council_tax_reduction_liable_person: [true, false] - council_tax_reduction_applicant_or_partner: [true, true] + is_council_tax_reduction_applicant_or_partner: [true, true] council_tax_reduction_applicant_has_non_dep_exemption: [true] council_tax_reduction_pensioner: [false] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index f49ffa5ff2..53c4079bea 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -33,7 +33,7 @@ def formula(benunit, period, parameters): # benefits "in respect of himself". person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) exempting = ( person("is_blind", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py deleted file mode 100644 index 2fbadd516f..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py +++ /dev/null @@ -1,37 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_applicant_or_partner(Variable): - value_type = bool - entity = Person - label = "Applicant for Council Tax Reduction, or the applicant's partner" - documentation = ( - "The people whose circumstances set a family's Council Tax Reduction " - "scheme and its exemption from non-dependant deductions: the " - "applicant and the applicant's partner. In a family that claims, the " - "applicant is a member the model treats as liable for the council " - "tax, and the partner is the other member of the couple where the " - "applicant is the family's claimant or partner, whatever the " - "partner's age. A household head who is not the claimant or partner " - "of their family (a grandparent heading a family formed around a " - "young couple, say) applies alone. In a family that cannot claim, " - "these are the claimant and partner, who would apply if it could." - ) - definition_period = YEAR - reference = ( - "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", - "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", - "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", - "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", - "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", - ) - - def formula(person, period, parameters): - liable = person("council_tax_reduction_liable_person", period) - claimant_or_partner = person("is_claimant_or_partner", period) - family_claims = person.benunit.any(liable) - # The couple, where one of them is liable; a partner under 18 is not - # liable but is still the applicant's partner. - couple_applies = person.benunit.any(liable & claimant_or_partner) - applicant_or_partner = liable | (couple_applies & claimant_or_partner) - return where(family_claims, applicant_or_partner, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py new file mode 100644 index 0000000000..774c5b2853 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_head_applies_alone(Variable): + value_type = bool + entity = BenUnit + label = "Household head applies for Council Tax Reduction alone" + documentation = ( + "Whether this family's Council Tax Reduction applicant is its " + "household head alone, because the head is treated as liable for the " + "council tax but is not the benefit unit's claimant or partner. An " + "example is a grandmother who heads the household and shares a benefit " + "unit with a young couple and their baby: the couple are the benefit " + "unit's claimant and partner, but she is the person liable for the " + "council tax. The applicant's means test then covers the head's own " + "income and circumstances, as a single person with no partner or " + "children, since the model cannot tell whether any other member is " + "the head's partner or child. The benefit unit's means-tested " + "benefits (such as Universal Credit, Income Support and Child Benefit) " + "belong to its claimant and partner, not to the head." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(liable_head & ~claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 3305026f80..e3dc861173 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -31,6 +31,6 @@ def formula(benunit, period, parameters): # elsewhere in the model. person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) return benunit.any(applicant_or_partner & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..a54e31eba4 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class is_council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Council Tax Reduction applicant or partner" + documentation = ( + "Whether this person is the applicant, or the applicant's partner, " + "for the Council Tax Reduction means test of their family. The " + "applicant is the person liable for the council tax " + "(council_tax_reduction_liable_person), and the means test covers the " + "applicant's own income and their partner's. " + "Where the family's liable person is the benefit unit's claimant or " + "partner, this is the benefit unit's claimant and partner, whatever " + "their ages: a partner under 18 is not liable, but their income " + "counts. Where the liable person is a household head who is not the " + "benefit unit's claimant or partner, it is the head alone (see " + "council_tax_reduction_head_applies_alone). A family with no liable " + "person cannot claim; for it this is the benefit unit's claimant and " + "partner." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/26", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(person, period, parameters): + # SSI 2012/319 reg 21(1), SI 2012/2885 Sch 1 para 11 and the Welsh + # and 2021 Scottish equivalents: the income and capital of the + # applicant and of the applicant's partner. + head_applies_alone = person.benunit( + "council_tax_reduction_head_applies_alone", period + ) + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return where(head_applies_alone, liable_head, claimant_or_partner) From 35ca533b06cd9eb601e9b428fd65998d174214db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:51:09 -0400 Subject: [PATCH 05/13] Cap a reported reduction at the claimant's share only beside a simulated claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 found that capping every jointly liable claimant's reported reduction at its share could discard a lawful report: the model's divisor (#2006's joint-liability share) counts students that SI 2012/2885 Sch 1 para 7(5) leaves out. The cap exists to stop a reported claim and a simulated claim together exceeding the bill, so it now applies only where another claim in the household is simulated, on the share that claim uses. Where nothing is simulated, reports are kept as reported, as on the base. Tests: - YAML: a jointly liable claimant keeps its whole report where nothing is simulated; a pension-age grandmother in working-age applicants' family does not make their claim a pensioner's (an intended change under reg 3(1): the base paid £1,800 on the pensioner scheme). - Properties: the fallback oracle follows the narrowed cap, and property 4 compares council_tax_benefit wherever the household's reconciliation is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 83 ++++++++++++++++++- ...ction_claimant_family_scheme_properties.py | 32 +++++-- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++-- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 58c3fc2d7f..4a33287385 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -288,7 +288,7 @@ council_tax_benefit: [300, 200] council_tax_reduction: 500 -- name: A jointly liable claimant's reported reduction is limited to its share of the bill +- name: Beside a simulated claim, a jointly liable claimant's reported reduction is limited to its share of the bill period: 2026 absolute_error_margin: 0.01 input: @@ -644,3 +644,84 @@ is_council_tax_reduction_applicant_or_partner: [true, true] council_tax_reduction_applicant_has_non_dep_exemption: [true] council_tax_reduction_pensioner: [false] + +- name: Where no claim is simulated, a jointly liable claimant keeps its whole reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_200 + sharer: + age: 35 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_household_has_simulated_claim: false + # Nothing is simulated to reconcile with, so the report stands as on the + # base. The model's share is not used here: it does not yet leave out the + # students that SI 2012/2885 Sch 1 para 7(5) excludes from the divisor. + council_tax_benefit: [1_200, 0] + council_tax_reduction: 1_200 + +- name: A pension-age grandmother in the applicants' family does not make their claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 40 + is_household_head: true + is_parent: true + is_claimant_or_partner: true + council_tax_benefit_reported: 400 + father: + age: 42 + is_household_head: false + is_parent: true + is_claimant_or_partner: true + grandmother: + age: 80 + is_household_head: false + is_claimant_or_partner: false + benunits: + family: + members: [mother, father, grandmother] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [mother, father, grandmother] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # Intended change: the base put the claim on the pensioner scheme because + # any member of the head's family was over State Pension age (£1,800 + # simulated). A "pensioner" is the applicant or partner (SI 2012/2885 + # reg 3(1)), here the working-age parents, so the claim falls under + # Maidstone's unmodelled working-age scheme and keeps its report. + is_council_tax_reduction_applicant_or_partner: [true, true, false] + council_tax_reduction_pensioner: [false] + council_tax_reduction_scheme_supported: [false] + council_tax_benefit: [400] + council_tax_reduction: 400 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 70c5e88af2..7f5667b348 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -30,11 +30,15 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and award unchanged, reported reductions included. + scheme and simulated award unchanged. Its council_tax_benefit is unchanged + too, unless it falls back to a reported reduction and whether another + claim in its household is simulated changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one, limited to its share of the - council tax where it is jointly liable. A family that cannot claim gets - its reported reduction only where no claim in its household is simulated. + is simulated, and otherwise its reported one. Beside a simulated claim in + its household, a jointly liable claimant's reported reduction is limited + to its share of the council tax; otherwise it is kept as reported. A + family that cannot claim gets its reported reduction only where no claim + in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a household with a simulated claim never gets more than its council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, @@ -298,7 +302,9 @@ def test_scheme_follows_own_family(population): supported, simulated, np.where( - share < 1, np.minimum(reported_amount, bill * share), reported_amount + household_simulates & (share < 1), + np.minimum(reported_amount, bill * share), + reported_amount, ), ), np.where(household_simulates, 0, reported_amount), @@ -330,7 +336,6 @@ def test_other_families_do_not_change_a_claim(population): for variable in ( "council_tax_reduction_joint_liability_share", "simulated_council_tax_reduction_benunit", - "council_tax_benefit", ): np.testing.assert_allclose( calc(before, variable)[target], @@ -338,6 +343,21 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + # A reported fallback is reconciled against the household's simulated + # claims (property 5); compare it wherever that reconciliation is the same. + house = facts["house"] + simulates = "council_tax_reduction_household_has_simulated_claim" + same_reconciliation = ( + calc(before, simulates)[house] == calc(after, simulates)[house] + ) + supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + compare = target & (supported | same_reconciliation) + np.testing.assert_allclose( + calc(before, "council_tax_benefit")[compare], + calc(after, "council_tax_benefit")[compare], + atol=1e-6, + err_msg="council_tax_benefit", + ) @st.composite diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index bda53d9111..be06b98814 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -7,10 +7,13 @@ class council_tax_benefit(Variable): label = "Council Tax Benefit" documentation = ( "A family that claims gets the simulated reduction where the model " - "simulates its own scheme, and its reported reduction otherwise. A " - "claimant jointly liable with others can get at most its own share " - "of the council tax, so its reported reduction is limited to that " - "share. A family that cannot claim keeps a reported reduction only " + "simulates its own scheme, and its reported reduction otherwise. " + "Where another claim in the household is simulated on its share of " + "the council tax, a jointly liable claimant's reported reduction is " + "limited to its own share, so the two cannot together exceed the " + "bill; the share is the one the simulated claims use. Where no claim " + "in the household is simulated, reported reductions are kept as " + "reported. A family that cannot claim keeps a reported reduction only " "where no claim in its household is simulated." ) definition_period = YEAR @@ -27,17 +30,24 @@ def formula(benunit, period, parameters): reported = benunit("council_tax_benefit_reported", period) claimant = benunit("council_tax_reduction_claimant_benunit", period) share = benunit("council_tax_reduction_joint_liability_share", period) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's - # maximum reduction is on its share of the council tax. + # maximum reduction is on its share of the council tax. Applied only + # beside a simulated claim, which uses the same share; reports are + # otherwise kept, since the share does not yet exclude the students + # para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) * share ) - reported_claim = where(share < 1, min_(reported, share_of_liability), reported) - household_simulates = benunit.household( - "council_tax_reduction_household_has_simulated_claim", period + reported_claim = where( + household_simulates & (share < 1), + min_(reported, share_of_liability), + reported, ) not_claiming = where(household_simulates, 0, reported) return where( From cc4d9aeb952cbda653d46a513d460b3ad78b1dd2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 08:01:51 -0400 Subject: [PATCH 06/13] Apply the reported-reduction cap only beside a simulated claim that pays On the payer build, the only remaining change came from heads whose reported reduction was capped beside a sharer whose simulated claim paid nothing. With no simulated reduction competing for the bill there is nothing to reconcile, so the cap now applies only where the simulated claims in the household pay something. The household bound still holds: each simulated award is within its share, and a capped report within its own. Tests: a YAML case where the simulated claim beside a report pays nothing; the fallback oracle, the bound and property 4's comparison follow the paid condition. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 39 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 37 +++++++++++------- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++++++----- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 4a33287385..0bafe29a6b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -725,3 +725,42 @@ council_tax_reduction_scheme_supported: [false] council_tax_benefit: [400] council_tax_reduction: 400 + +- name: A jointly liable claimant keeps its whole report where the simulated claim beside it pays nothing + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_500 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # Too much income for any reduction. + council_tax_reduction_applicable_income: 40_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The sharer's pensioner claim is simulated but pays nothing, so no + # simulated reduction competes for the bill and the head's report stands. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: true + simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [1_500, 0] + council_tax_reduction: 1_500 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 7f5667b348..e18d192f40 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -31,16 +31,18 @@ 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, scheme and simulated award unchanged. Its council_tax_benefit is unchanged - too, unless it falls back to a reported reduction and whether another - claim in its household is simulated changes (property 5's reconciliation). + too, unless it falls back to a reported reduction and whether a simulated + claim in its household pays something changes (property 5's + reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one. Beside a simulated claim in - its household, a jointly liable claimant's reported reduction is limited - to its share of the council tax; otherwise it is kept as reported. A + is simulated, and otherwise its reported one. Beside a simulated claim + that pays something, a jointly liable claimant's reported reduction is + limited to its share of the council tax; otherwise it is kept as reported. A family that cannot claim gets its reported reduction only where no claim in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a - household with a simulated claim never gets more than its council tax. + household whose simulated claims pay something never gets more than its + council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, giving one claiming family an exempting benefit removes the non-dependant deductions from its own reduction and leaves every other claim's @@ -296,13 +298,15 @@ def test_scheme_follows_own_family(population): bill = calc(sim, "council_tax")[house] simulates = calc(sim, "council_tax_reduction_household_has_simulated_claim") household_simulates = simulates[house].astype(bool) + paid = np.bincount(house, weights=(claimant & supported) * simulated) + household_pays = paid[house] > 0 expected = np.where( claimant, np.where( supported, simulated, np.where( - household_simulates & (share < 1), + household_pays & (share < 1), np.minimum(reported_amount, bill * share), reported_amount, ), @@ -317,7 +321,7 @@ def test_scheme_follows_own_family(population): assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") over = household_reduction > calc(sim, "council_tax") + 0.01 - assert not np.any(over & simulates) + assert not np.any(over & (paid > 0)) @PROPERTY_SETTINGS @@ -343,14 +347,19 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) - # A reported fallback is reconciled against the household's simulated - # claims (property 5); compare it wherever that reconciliation is the same. + # A reported fallback is reconciled against the household's paid + # simulated claims (property 5); compare it wherever that is the same. house = facts["house"] - simulates = "council_tax_reduction_household_has_simulated_claim" - same_reconciliation = ( - calc(before, simulates)[house] == calc(after, simulates)[house] - ) supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + + def household_pays(sim_): + claims = calc(sim_, "council_tax_reduction_claimant_benunit").astype(bool) + supported_ = calc(sim_, "council_tax_reduction_scheme_supported").astype(bool) + simulated = calc(sim_, "simulated_council_tax_reduction_benunit") + paid = np.bincount(house, weights=(claims & supported_) * simulated) + return paid[house] > 0 + + same_reconciliation = household_pays(before) == household_pays(after) compare = target & (supported | same_reconciliation) np.testing.assert_allclose( calc(before, "council_tax_benefit")[compare], diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index be06b98814..649231b466 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -8,11 +8,11 @@ class council_tax_benefit(Variable): documentation = ( "A family that claims gets the simulated reduction where the model " "simulates its own scheme, and its reported reduction otherwise. " - "Where another claim in the household is simulated on its share of " - "the council tax, a jointly liable claimant's reported reduction is " - "limited to its own share, so the two cannot together exceed the " - "bill; the share is the one the simulated claims use. Where no claim " - "in the household is simulated, reported reductions are kept as " + "Where another claim in the household is simulated and pays a " + "reduction on its share of the council tax, a jointly liable " + "claimant's reported reduction is limited to its own share, so the " + "two cannot together exceed the bill; the share is the one the " + "simulated claims use. Otherwise reported reductions are kept as " "reported. A family that cannot claim keeps a reported reduction only " "where no claim in its household is simulated." ) @@ -33,11 +33,19 @@ def formula(benunit, period, parameters): household_simulates = benunit.household( "council_tax_reduction_household_has_simulated_claim", period ) + # Simulated reductions paid to claims in the household. + person = benunit.members + simulated_claim = (supported & claimant) * simulated + paid_in_household = benunit.max( + person.household.sum( + person("is_benunit_head", period) * benunit.project(simulated_claim) + ) + ) # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's # maximum reduction is on its share of the council tax. Applied only - # beside a simulated claim, which uses the same share; reports are - # otherwise kept, since the share does not yet exclude the students - # para 7(5) leaves out. + # beside a simulated claim that pays something, which uses the same + # share; reports are otherwise kept, since the share does not yet + # exclude the students para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period @@ -45,7 +53,7 @@ def formula(benunit, period, parameters): * share ) reported_claim = where( - household_simulates & (share < 1), + (paid_in_household > 0) & (share < 1), min_(reported, share_of_liability), reported, ) From 2b5ffe38d738ca3dc7832e006f6d45b623212706 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 14:23:50 -0400 Subject: [PATCH 07/13] Test the per-family pensioner flag against #1944's reg 3 conditions council_tax_reduction_pensioner is now #1944's SI 2012/2885 reg 3 test on this PR's members: the qualifying age for State Pension Credit, and no Income Support, income-based JSA, income-related ESA or Universal Credit award. Property 1's oracle computes that directly. A family's own Universal Credit award depends on the rent it pays, which changes when it lives alone, so property 2 compares the families whose own awards are the same either way (and checks there are some). Co-Authored-By: Claude Opus 5.5 --- ...ction_claimant_family_scheme_properties.py | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) 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 e18d192f40..12277858fe 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 @@ -17,13 +17,19 @@ 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 - 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. + has reached the qualifying age for State Pension Credit and neither of + them is on Income Support, income-based Jobseeker's Allowance or + income-related Employment and Support Allowance, or has a Universal Credit + award (which needs one of them under that age). It is exempt from + non-dependant deductions exactly when its claimant or partner is blind or + gets Attendance Allowance, the care component of Disability Living + Allowance, the daily living component of Personal Independence Payment or + Armed Forces Independence Payment. 2. As if alone: each family's pensioner status, exemption and whether its - scheme is simulated are what they would be if it lived alone. + scheme is simulated are what they would be if it lived alone, wherever its + own Universal Credit award is the same either way. (The award depends on + the rent the family pays, which changes when it lives alone, and the + pensioner test follows the award.) 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 @@ -259,11 +265,23 @@ def test_scheme_follows_own_family(population): supported = calc(sim, "council_tax_reduction_scheme_supported") claimant = calc(sim, "council_tax_reduction_claimant_benunit") - # 1. Own members. + # 1. Own members. SI 2012/2885 reg 3(1): the qualifying age for State + # Pension Credit, and no income-related benefit or Universal Credit award + # for the claimant or partner. A Universal Credit award needs a member + # under that age (UC Regs 2013 reg 3(2)(a)). claimant_or_partner = calc(sim, "is_claimant_or_partner") + attained = calc(sim, "has_attained_state_pension_credit_qualifying_age") + income_related = ( + sum(calc(sim, v) for v in ("income_support", "jsa_income", "esa_income")) > 0 + ) + universal_credit_award = calc(sim, "is_uc_entitled") & any_in_benunit( + sim, claimant_or_partner & ~attained + ) assert np.array_equal( pensioner, - any_in_benunit(sim, claimant_or_partner & calc(sim, "is_SP_age")), + any_in_benunit(sim, claimant_or_partner & attained) + & ~income_related + & ~universal_credit_award, ) exempting = calc(sim, "is_blind").astype(bool) for variable in DISABILITY: @@ -271,12 +289,19 @@ def test_scheme_follows_own_family(population): exempting |= calc(sim, variable) > 0 assert np.array_equal(exempt, any_in_benunit(sim, claimant_or_partner & exempting)) - # 2. As if alone. + # 2. As if alone, wherever the family's own Universal Credit award and + # income-related benefits are the same either way. alone_situation, alone_facts = build(population, alone=True) alone = Simulation(situation=alone_situation) assert np.array_equal(alone_facts["role"], facts["role"]) + same_awards = calc(sim, "is_uc_entitled") == calc(alone, "is_uc_entitled") + for v in ("income_support", "jsa_income", "esa_income"): + same_awards &= (calc(sim, v) > 0) == (calc(alone, v) > 0) + assert same_awards.any() for variable in FLAGS: - assert np.array_equal(calc(sim, variable), calc(alone, variable)), variable + assert np.array_equal( + calc(sim, variable)[same_awards], calc(alone, variable)[same_awards] + ), variable # 3. Scheme. england = facts["country"] == "ENGLAND" From 8f04337aa49d4151c56b75ee9f3047e62b06d7f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:27:37 -0400 Subject: [PATCH 08/13] Address the after-merge review: student share, head's own awards, P5 - council_tax_benefit: the share leaves out only people in higher education, so where a jointly liable person it counts is in other education (who may be a student SI 2012/2885 Sch 1 para 7(5) leaves out), the share may be too small; a report is then kept rather than capped. - council_tax_reduction_pensioner: where the household head applies alone, the benefit unit's Income Support, income-based JSA, income-related ESA and Universal Credit are its claimant and partner's, not the applicant's (as #2078 does for council_tax_reduction_relevant_income_based_benefit). - Two YAML regressions: a report beside a paying simulated claim where a further-education student is counted in the share, and a grandmother head who stays a pensioner when the young parents get Income Support. - Property test: the alone simulation is given each family's own awards instead of filtering; some generated adults are further-education students, and properties 5 and 6 follow the new rule. - test_benefit_cap_and_ctr_pension_age_properties P5 compares against a reform of the cap exception alone, so the CTR pensioner test (which also sets the applicable amount) is held fixed. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 92 +++++++++++++++++++ ...efit_cap_and_ctr_pension_age_properties.py | 28 ++++-- ...ction_claimant_family_scheme_properties.py | 90 +++++++++++++----- .../variables/gov/dwp/council_tax_benefit.py | 26 +++++- .../council_tax_reduction_pensioner.py | 13 ++- 5 files changed, 210 insertions(+), 39 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 0bafe29a6b..b7f5faf61b 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 @@ -764,3 +764,95 @@ simulated_council_tax_reduction_benunit: [0, 0] council_tax_benefit: [1_500, 0] council_tax_reduction: 1_500 + +- name: A report is kept beside a paying simulated claim where the share may count a student + # The share leaves out only people in higher education. A jointly liable + # full-time further-education student is counted, though para 7(5) may + # leave them out, so the share (a third) may be too small and the head's + # report is not capped at it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 700 + student: + age: 25 + current_education: POST_SECONDARY + in_HE: false + pensioner: + age: 80 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + pensioner_family: + members: [pensioner] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, student, pensioner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_joint_liability_share: [0.333333, 0.333333, 0.333333] + council_tax_reduction_scheme_supported: [false, false, true] + # The pensioner's claim pays 1,800 / 3 = 600 under the pensioner scheme. + simulated_council_tax_reduction_benunit: [0, 0, 600] + council_tax_benefit: [700, 0, 600] + council_tax_reduction: 1_300 + +- name: A head who applies alone stays a pensioner when her benefit unit's claimant and partner get Income Support + # The Income Support is the young parents' award, not the grandmother's + # or a partner's (SI 2012/2885 reg 3(1)(a)(ii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + would_claim_uc: false + income_support: 3_000 + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_relevant_income_based_benefit: [true] + council_tax_reduction_pensioner: [true] + simulated_council_tax_reduction_benunit: [1_800] diff --git a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py index f0cb8d1973..2e997d22cb 100644 --- a/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_and_ctr_pension_age_properties.py @@ -41,10 +41,12 @@ P4 The change only removes exemptions: exempt now implies exempt before, the cap is never higher, and Universal Credit and Housing Benefit never rise. A CTR pensioner now was a CTR pensioner before. -P5 The model's Welsh and Scottish CTR formulas do not route on pensioner - status, but they count Universal Credit as income. So their CTR changes - only through Universal Credit: unchanged where Universal Credit is - unchanged, and never lower where the cap has lowered Universal Credit. +P5 The Welsh and Scottish CTR schemes do not depend on pensioner status, + but they count Universal Credit as income. So with the CTR pensioner test + held as it is now (it also sets the pension-age applicable amount and + earnings disregard), the cap change moves their CTR only through Universal + Credit: unchanged where Universal Credit is unchanged, and never lower + where the cap has lowered Universal Credit. P6 Metamorphic: with a Universal Credit award, adding a member over State Pension age to a couple's ages (making it mixed-age) never exempts it from the cap. @@ -127,6 +129,15 @@ def apply(self): self.update_variable(council_tax_reduction_pensioner) +# The cap exception alone as it was, with today's pensioner test for Council +# Tax Reduction. council_tax_reduction_pensioner also sets the CTR applicable +# amount and earnings disregard, so P5 holds it fixed to isolate the route +# through Universal Credit. +class before_the_cap_change(Reform): + def apply(self): + self.update_variable(is_benefit_cap_exempt_other) + + @st.composite def families(draw): shape = draw(st.sampled_from(SHAPE_DRAWS)) @@ -233,7 +244,9 @@ def situation(units): def calculate(units, before=False): simulation = Simulation(situation=situation(units)) if before: - simulation.apply_reform(before_this_change) + simulation.apply_reform( + before_the_cap_change if before == "cap" else before_this_change + ) names = BEFORE else: names = BENUNIT + HOUSEHOLD @@ -257,6 +270,7 @@ def close(a, b): def test_benefit_cap_and_ctr_pension_age_invariants(units): now = calculate(units) before = calculate(units, before=True) + cap_only = calculate(units, before="cap") for i, unit in enumerate(units): on_uc = bool(now["is_uc_entitled"][i]) and has_working_age_claimant(unit) legacy = unit["legacy"] > 0 @@ -290,9 +304,9 @@ def test_benefit_cap_and_ctr_pension_age_invariants(units): if unit["country"] in ("WALES", "SCOTLAND"): ctr_now, ctr_before = ( now["council_tax_benefit"][i], - before["council_tax_benefit"][i], + cap_only["council_tax_benefit"][i], ) - if close(now["universal_credit"][i], before["universal_credit"][i]): + if close(now["universal_credit"][i], cap_only["universal_credit"][i]): assert close(ctr_now, ctr_before), unit else: assert ctr_now >= ctr_before - 0.01, unit diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 12277858fe..10e2026e06 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 @@ -26,10 +26,10 @@ 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, wherever its - own Universal Credit award is the same either way. (The award depends on - the rent the family pays, which changes when it lives alone, and the - pensioner test follows the award.) + scheme is simulated are what they would be if it lived alone with its own + benefit awards. (Its Universal Credit award depends on the rent it pays, + which changes when it lives alone, and the pensioner test follows the + award; so the alone simulation is given the family's awards.) 3. Scheme: a family's scheme is simulated exactly when it lives in Scotland or Wales, is a pensioner, or lives in a council whose working-age scheme is modelled. In England only a pensioner family is paid by the national @@ -43,12 +43,14 @@ 5. Fallback: a claiming family gets its simulated reduction where its scheme is simulated, and otherwise its reported one. Beside a simulated claim that pays something, a jointly liable claimant's reported reduction is - limited to its share of the council tax; otherwise it is kept as reported. A - family that cannot claim gets its reported reduction only where no claim - in its household is simulated. + limited to its share of the council tax, unless a jointly liable person + the share counts is in education (who may be a student the share should + leave out); otherwise it is kept as reported. A family that cannot claim + gets its reported reduction only where no claim in its household is + simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a - household whose simulated claims pay something never gets more than its - council tax. + household whose simulated claims pay something, and whose share counts no + one in education, 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 @@ -119,6 +121,13 @@ def adults(draw, most): ages=draw(st.lists(adult_age, min_size=n, max_size=n)), disabilities=draw(st.lists(disability, min_size=n, max_size=n)), earnings=draw(st.lists(money, min_size=n, max_size=n)), + # Some adults are in full-time further education, which the + # joint-liability share does not leave out. + in_education=draw( + st.lists( + st.sampled_from([False, False, False, True]), min_size=n, max_size=n + ) + ), ) @@ -157,22 +166,33 @@ def households(draw): population = st.lists(households(), min_size=1, max_size=6) -def person(age, disability_input, earnings): +def person(age, disability_input, earnings, in_education=False): attributes = {"age": age, "employment_income": earnings} if disability_input is not None: attributes[disability_input] = DISABILITY[disability_input] + if in_education: + attributes["current_education"] = "POST_SECONDARY" return attributes -def build(population, alone=False, perturb_others=False): +def build(population, alone=False, perturb_others=False, awards=None): """One situation for the whole population, plus per-family facts. alone: put every family in a household of its own, as its head. perturb_others: give every family that claims, other than each household's target, the household's replacement ages and disabilities. + awards: benefit-unit inputs per family, in family order (for example the + families' own benefit awards from another simulation). """ people, benunits, homes = {}, {}, {} - facts = dict(country=[], local_authority=[], role=[], target=[], house=[]) + facts = dict( + country=[], + local_authority=[], + role=[], + target=[], + house=[], + share_may_count_student=[], + ) for h, house in enumerate(population): country, local_authority = house["scheme"] home = dict( @@ -184,6 +204,13 @@ def build(population, alone=False, perturb_others=False): savings=house["savings"], ) members, replacement = [], 0 + # Everyone in the head's and the sharers' families is 20 or over, so + # all of them are jointly liable and counted in the share. + liable_in_education = any( + any(fam["in_education"]) + for fam in house["families"] + if fam["role"] != "non_dependant" + ) for f, fam in enumerate(house["families"]): ids = [] claims = fam["role"] != "non_dependant" @@ -195,7 +222,9 @@ def build(population, alone=False, perturb_others=False): age = house["other_ages"][replacement] disability_input = house["other_disabilities"][replacement] replacement += 1 - people[pid] = person(age, disability_input, fam["earnings"][i]) + people[pid] = person( + age, disability_input, fam["earnings"][i], fam["in_education"][i] + ) if i == 0: people[pid]["council_tax_benefit_reported"] = fam["reported"] people[pid]["is_household_head"] = ( @@ -213,6 +242,11 @@ def build(population, alone=False, perturb_others=False): "claims_all_entitled_benefits": True, "would_claim_uc": True, } + if awards is not None: + index = len(facts["role"]) + benunits[f"h{h}_f{f}"].update( + {name: float(values[index]) for name, values in awards.items()} + ) if alone: homes[f"h{h}_f{f}"] = {"members": ids, **home} members.extend(ids) @@ -221,6 +255,9 @@ def build(population, alone=False, perturb_others=False): facts["role"].append(fam["role"]) facts["target"].append(is_target) facts["house"].append(h) + facts["share_may_count_student"].append( + any(fam["in_education"]) if alone else liable_in_education + ) if not alone: homes[f"h{h}"] = {"members": members, **home} situation = { @@ -248,6 +285,13 @@ def any_in_benunit(simulation, person_values): return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 +# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3). +OWN_AWARDS = [ + "universal_credit_pre_benefit_cap", + "income_support", + "jsa_income", + "esa_income", +] FLAGS = [ "council_tax_reduction_pensioner", "council_tax_reduction_applicant_has_non_dep_exemption", @@ -289,19 +333,13 @@ def test_scheme_follows_own_family(population): exempting |= calc(sim, variable) > 0 assert np.array_equal(exempt, any_in_benunit(sim, claimant_or_partner & exempting)) - # 2. As if alone, wherever the family's own Universal Credit award and - # income-related benefits are the same either way. - alone_situation, alone_facts = build(population, alone=True) + # 2. As if alone, with the family's own benefit awards. + awards = {name: calc(sim, name) for name in OWN_AWARDS} + alone_situation, alone_facts = build(population, alone=True, awards=awards) alone = Simulation(situation=alone_situation) assert np.array_equal(alone_facts["role"], facts["role"]) - same_awards = calc(sim, "is_uc_entitled") == calc(alone, "is_uc_entitled") - for v in ("income_support", "jsa_income", "esa_income"): - same_awards &= (calc(sim, v) > 0) == (calc(alone, v) > 0) - assert same_awards.any() for variable in FLAGS: - assert np.array_equal( - calc(sim, variable)[same_awards], calc(alone, variable)[same_awards] - ), variable + assert np.array_equal(calc(sim, variable), calc(alone, variable)), variable # 3. Scheme. england = facts["country"] == "ENGLAND" @@ -325,13 +363,14 @@ def test_scheme_follows_own_family(population): household_simulates = simulates[house].astype(bool) paid = np.bincount(house, weights=(claimant & supported) * simulated) household_pays = paid[house] > 0 + may_count_student = facts["share_may_count_student"] expected = np.where( claimant, np.where( supported, simulated, np.where( - household_pays & (share < 1), + household_pays & (share < 1) & ~may_count_student, np.minimum(reported_amount, bill * share), reported_amount, ), @@ -346,7 +385,8 @@ def test_scheme_follows_own_family(population): assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") over = household_reduction > calc(sim, "council_tax") + 0.01 - assert not np.any(over & (paid > 0)) + counts_student = np.bincount(house, weights=may_count_student) > 0 + assert not np.any(over & (paid > 0) & ~counts_student) @PROPERTY_SETTINGS diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index 649231b466..0f0a810094 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) class council_tax_benefit(Variable): @@ -12,7 +15,10 @@ class council_tax_benefit(Variable): "reduction on its share of the council tax, a jointly liable " "claimant's reported reduction is limited to its own share, so the " "two cannot together exceed the bill; the share is the one the " - "simulated claims use. Otherwise reported reductions are kept as " + "simulated claims use. The share leaves out only the students the " + "model sees in higher education, so where a jointly liable person it " + "counts is in other education, and may be a student the law leaves " + "out, the report is kept. Otherwise reported reductions are kept as " "reported. A family that cannot claim keeps a reported reduction only " "where no claim in its household is simulated." ) @@ -44,16 +50,28 @@ def formula(benunit, period, parameters): # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's # maximum reduction is on its share of the council tax. Applied only # beside a simulated claim that pays something, which uses the same - # share; reports are otherwise kept, since the share does not yet - # exclude the students para 7(5) leaves out. + # share; reports are otherwise kept. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) * share ) + # Para 7(5) leaves students excluded from the scheme out of the + # divisor. The share leaves out people in higher education (in_HE); + # a jointly liable person it counts who is in other education may be + # such a student too, making the share too small, so the report is + # kept there. + counted_in_education = ( + person("council_tax_reduction_liable_person", period) + & ~person("in_HE", period) + & (person("current_education", period) != EducationType.NOT_IN_EDUCATION) + ) + share_may_count_student = ( + benunit.max(person.household.sum(counted_in_education)) > 0 + ) reported_claim = where( - (paid_in_household > 0) & (share < 1), + (paid_in_household > 0) & (share < 1) & ~share_may_count_student, min_(reported, share_of_liability), reported, ) 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 bf782d0cfa..32d33f3bdc 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -48,8 +48,13 @@ def formula(benunit, period, parameters): attained_qualifying_age = benunit.any( applicant_or_partner & over_qualifying_age ) - income_related_benefit = benunit( - "council_tax_reduction_relevant_income_based_benefit", period + # The benefit unit's Income Support, income-based JSA, income-related + # ESA and Universal Credit belong to its claimant and partner. Where + # the household head applies alone, they are not the applicant's. + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + income_related_benefit = ( + benunit("council_tax_reduction_relevant_income_based_benefit", period) + & ~head_applies_alone ) # The award before the benefit cap: the cap reduces an award # (UC Regs 2013 reg 81) rather than removing it. An award counts only @@ -69,7 +74,9 @@ def formula(benunit, period, parameters): # model those migrants. working_age_applicant = benunit.any(applicant_or_partner & ~over_qualifying_age) universal_credit_award = ( - benunit("is_uc_entitled", period) & working_age_applicant + benunit("is_uc_entitled", period) + & ~head_applies_alone + & working_age_applicant ) return ( attained_qualifying_age & ~income_related_benefit & ~universal_credit_award From 38364ca49f2af99671d502a3d08443c1f4ef6701 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:39:24 -0400 Subject: [PATCH 09/13] Hold the means test fixed in property 4 CI found that swapping the other claiming families' ages and disability benefits can move a family's simulated award through its applicable amount: the model's severe disability premium counts the other adults in the household (has_non_dependant_for_severe_disability_premium), joint occupiers included. That is main's means test, not the scheme or the exemption, so the perturbed run is given each family's applicable amount and income from the unperturbed one. Co-Authored-By: Claude Opus 5.5 --- ...ction_claimant_family_scheme_properties.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 10e2026e06..931a36dc1a 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 @@ -36,9 +36,12 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and simulated award unchanged. Its council_tax_benefit is unchanged - too, unless it falls back to a reported reduction and whether a simulated - claim in its household pays something changes (property 5's + scheme and simulated award unchanged, with its applicable amount and + income held as they were. (The model's severe disability premium counts + the other adults in the household, so their ages and benefits can change + a family's applicable amount whatever its scheme.) Its council_tax_benefit + is unchanged too, unless it falls back to a reported reduction and whether + a simulated claim in its household pays something changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme is simulated, and otherwise its reported one. Beside a simulated claim @@ -182,7 +185,7 @@ def build(population, alone=False, perturb_others=False, awards=None): perturb_others: give every family that claims, other than each household's target, the household's replacement ages and disabilities. awards: benefit-unit inputs per family, in family order (for example the - families' own benefit awards from another simulation). + families' own benefit awards or means test from another simulation). """ people, benunits, homes = {}, {}, {} facts = dict( @@ -292,6 +295,11 @@ def any_in_benunit(simulation, person_values): "jsa_income", "esa_income", ] +# A family's means test, held fixed in property 4. +MEANS_TEST = [ + "council_tax_reduction_applicable_amount", + "council_tax_reduction_applicable_income", +] FLAGS = [ "council_tax_reduction_pensioner", "council_tax_reduction_applicant_has_non_dep_exemption", @@ -393,8 +401,9 @@ def test_scheme_follows_own_family(population): @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) + means_test = {name: calc(before, name) for name in MEANS_TEST} + perturbed_situation, _ = build(population, perturb_others=True, awards=means_test) after = Simulation(situation=perturbed_situation) target = facts["target"] # 4. Others do not matter. From a9466b8710f99e1316d00efae1ac4c779b229d58 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:29:11 -0400 Subject: [PATCH 10/13] Read the student test and in_FE for the cap's student guard The re-review of 38364ca49 found that the guard missed in_FE, and that current_education's age default (tertiary at 18 and 19) turns it on with no education input. The guard now uses the model's student test for non-dependants (_legacy.is_full_time_student_non_dep) or in_FE, for a jointly liable person the share counts. The age default is kept deliberately: in the enhanced FRS current_education is an observed input, and the same test already treats an 18- or 19-year-old non-dependant as a student. The documentation says so, including that part-time students and students para 75(2) brings back in are treated the same way. Regressions: the student shown by in_FE (fails on 38364ca49), and a jointly liable sharer with no education input at 19 (report kept) and 20 (capped). The property generator makes students by current_education or by in_FE. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 116 ++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 33 +++-- .../variables/gov/dwp/council_tax_benefit.py | 35 +++--- 3 files changed, 158 insertions(+), 26 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index b7f5faf61b..793efe1508 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 @@ -816,6 +816,122 @@ council_tax_benefit: [700, 0, 600] council_tax_reduction: 1_300 +- name: The same, with the student shown by in_FE + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 700 + student: + age: 25 + in_FE: true + pensioner: + age: 80 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + student_family: + members: [student] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + pensioner_family: + members: [pensioner] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, student, pensioner] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + simulated_council_tax_reduction_benunit: [0, 0, 600] + council_tax_benefit: [700, 0, 600] + council_tax_reduction: 1_300 + +- name: A jointly liable 19-year-old with no education input keeps the cap off + # current_education defaults to tertiary education at 18 and 19, which the + # model's student test reads, so the share may count a student and the + # sharer's report is kept even beside the head's paying claim. The household + # gets more than its bill; the cap is deliberately conservative here. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 80 + is_household_head: true + sharer: + age: 19 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + current_education: [NOT_IN_EDUCATION, TERTIARY] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_benefit: [900, 1_500] + council_tax_reduction: 2_400 + +- name: At 20, with no education input, the same sharer's report is capped at its half + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 80 + is_household_head: true + sharer: + age: 20 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + current_education: [NOT_IN_EDUCATION, NOT_IN_EDUCATION] + council_tax_benefit: [900, 900] + council_tax_reduction: 1_800 + - name: A head who applies alone stays a pensioner when her benefit unit's claimant and partner get Income Support # The Income Support is the young parents' award, not the grandmother's # or a partner's (SI 2012/2885 reg 3(1)(a)(ii)). 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 931a36dc1a..5e78d19f03 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 @@ -47,13 +47,15 @@ is simulated, and otherwise its reported one. Beside a simulated claim that pays something, a jointly liable claimant's reported reduction is limited to its share of the council tax, unless a jointly liable person - the share counts is in education (who may be a student the share should - leave out); otherwise it is kept as reported. A family that cannot claim - gets its reported reduction only where no claim in its household is - simulated. + the share counts may be a student the share should leave out (enrolled in + education, or in_FE); otherwise it is kept as reported. A family that + cannot claim gets its reported reduction only where no claim in its + household is simulated. Every adult here is 20 or over, so + current_education's age default (tertiary at 18 and 19) never applies; + the YAML covers it. 6. Bounds: a family that cannot claim gets no simulated reduction; a household whose simulated claims pay something, and whose share counts no - one in education, never gets more than its council tax. + one who may be a student, never gets more than its council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, giving one claiming family an exempting benefit removes the non-dependant deductions from its own reduction and leaves every other claim's @@ -124,11 +126,14 @@ def adults(draw, most): ages=draw(st.lists(adult_age, min_size=n, max_size=n)), disabilities=draw(st.lists(disability, min_size=n, max_size=n)), earnings=draw(st.lists(money, min_size=n, max_size=n)), - # Some adults are in full-time further education, which the - # joint-liability share does not leave out. + # Some adults are enrolled in further education, shown by + # current_education or by in_FE; the joint-liability share does not + # leave them out. None means not in education. in_education=draw( st.lists( - st.sampled_from([False, False, False, True]), min_size=n, max_size=n + st.sampled_from([None, None, None, None, "current_education", "in_FE"]), + min_size=n, + max_size=n, ) ), ) @@ -169,12 +174,14 @@ def households(draw): population = st.lists(households(), min_size=1, max_size=6) -def person(age, disability_input, earnings, in_education=False): +def person(age, disability_input, earnings, in_education=None): attributes = {"age": age, "employment_income": earnings} if disability_input is not None: attributes[disability_input] = DISABILITY[disability_input] - if in_education: + if in_education == "current_education": attributes["current_education"] = "POST_SECONDARY" + elif in_education == "in_FE": + attributes["in_FE"] = True return attributes @@ -210,7 +217,7 @@ def build(population, alone=False, perturb_others=False, awards=None): # Everyone in the head's and the sharers' families is 20 or over, so # all of them are jointly liable and counted in the share. liable_in_education = any( - any(fam["in_education"]) + any(e is not None for e in fam["in_education"]) for fam in house["families"] if fam["role"] != "non_dependant" ) @@ -259,7 +266,9 @@ def build(population, alone=False, perturb_others=False, awards=None): facts["target"].append(is_target) facts["house"].append(h) facts["share_may_count_student"].append( - any(fam["in_education"]) if alone else liable_in_education + any(e is not None for e in fam["in_education"]) + if alone + else liable_in_education ) if not alone: homes[f"h{h}"] = {"members": members, **home} diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index 0f0a810094..91e83a656e 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -1,6 +1,6 @@ from policyengine_uk.model_api import * -from policyengine_uk.variables.household.demographic.highest_education import ( - EducationType, +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + is_full_time_student_non_dep, ) @@ -15,12 +15,16 @@ class council_tax_benefit(Variable): "reduction on its share of the council tax, a jointly liable " "claimant's reported reduction is limited to its own share, so the " "two cannot together exceed the bill; the share is the one the " - "simulated claims use. The share leaves out only the students the " - "model sees in higher education, so where a jointly liable person it " - "counts is in other education, and may be a student the law leaves " - "out, the report is kept. Otherwise reported reductions are kept as " - "reported. A family that cannot claim keeps a reported reduction only " - "where no claim in its household is simulated." + "simulated claims use. The share leaves out only people in higher " + "education (in_HE), so where a jointly liable person it counts may be " + "a student the law leaves out (the model's student test for " + "non-dependants, or in_FE), the report is kept. That test reads " + "current_education, which defaults to tertiary education at 18 and " + "19, so with no education input a jointly liable 18- or 19-year-old " + "keeps the cap off; part-time students and students the scheme brings " + "back in are treated the same way. Otherwise reported reductions are " + "kept as reported. A family that cannot claim keeps a reported " + "reduction only where no claim in its household is simulated." ) definition_period = YEAR unit = GBP @@ -59,16 +63,19 @@ def formula(benunit, period, parameters): ) # Para 7(5) leaves students excluded from the scheme out of the # divisor. The share leaves out people in higher education (in_HE); - # a jointly liable person it counts who is in other education may be - # such a student too, making the share too small, so the report is - # kept there. - counted_in_education = ( + # a jointly liable person it counts who may be a student (the + # model's student test for non-dependants, or in_FE) could be left + # out too, making the share too small, so the report is kept there. + # The test reads current_education, whose age default puts 18- and + # 19-year-olds in tertiary education; it does not tell full-time from + # part-time study or find the students para 75(2) brings back in. + counted_may_be_student = ( person("council_tax_reduction_liable_person", period) & ~person("in_HE", period) - & (person("current_education", period) != EducationType.NOT_IN_EDUCATION) + & (is_full_time_student_non_dep(person, period) | person("in_FE", period)) ) share_may_count_student = ( - benunit.max(person.household.sum(counted_in_education)) > 0 + benunit.max(person.household.sum(counted_may_be_student)) > 0 ) reported_claim = where( (paid_in_household > 0) & (share < 1) & ~share_may_count_student, From 94041e91100c13499a2c20d40ed63b3be6921063 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 19:47:22 -0400 Subject: [PATCH 11/13] Hold each family's own awards fixed in property 4 CI on d988725dc (run 37383946508) failed property 4 on a Newham household: a working-age head (47) claiming Universal Credit, a sharer couple aged 100 and 63 with earnings, and a non-dependant. Perturbing the head to 85 moves the non-dependant into the sharer's claim (uc_non_dependants_counted: the household's non-dependants count in one claim only, UC Regs 2013 Sch 4 para 9(2), the head family's where it claims). The sharer's size criteria rise from one bedroom to two, its LHA cap from 9,000 to 10,860 and its award from 0 to 1,288.29, and reg 3(1)(a)(ii) then makes it not a pensioner. That is the family's own award, which the pensioner test is meant to follow, so the change is intended. Property 4 now holds each family's own UC, IS, JSA and ESA awards fixed, as property 2 already does, along with its means test. The same population fails on a9466b871, so this predates the merge of main; CI on a9466b871 did not generate it. With the awards held, a household-level pensioner flag still fails property 4. Co-Authored-By: Claude Opus 5.5 --- ...ction_claimant_family_scheme_properties.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 5e78d19f03..889023d8d6 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 @@ -36,13 +36,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 simulated award unchanged, with its applicable amount and - income held as they were. (The model's severe disability premium counts - the other adults in the household, so their ages and benefits can change - a family's applicable amount whatever its scheme.) Its council_tax_benefit - is unchanged too, unless it falls back to a reported reduction and whether - a simulated claim in its household pays something changes (property 5's - reconciliation). + scheme and simulated award unchanged, with its applicable amount, its + income and its own benefit awards held as they were. (The model's severe + disability premium counts the other adults in the household, so their + ages and benefits can change a family's applicable amount whatever its + scheme. And the household's non-dependants count in one Universal Credit + claim only (UC Regs 2013 Sch 4 para 9(2)), which the model makes the head + family's where it claims, so a head's family reaching pension age can + move them into a sharer's claim and change the sharer's award, which the + pensioner test follows.) Its council_tax_benefit is unchanged too, unless + it falls back to a reported reduction and whether a simulated claim in + its household pays something changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme is simulated, and otherwise its reported one. Beside a simulated claim that pays something, a jointly liable claimant's reported reduction is @@ -297,7 +301,8 @@ def any_in_benunit(simulation, person_values): return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 -# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3). +# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3), +# held fixed in properties 2 and 4. OWN_AWARDS = [ "universal_credit_pre_benefit_cap", "income_support", @@ -411,8 +416,8 @@ def test_scheme_follows_own_family(population): def test_other_families_do_not_change_a_claim(population): situation, facts = build(population) before = Simulation(situation=situation) - means_test = {name: calc(before, name) for name in MEANS_TEST} - perturbed_situation, _ = build(population, perturb_others=True, awards=means_test) + held = {name: calc(before, name) for name in MEANS_TEST + OWN_AWARDS} + perturbed_situation, _ = build(population, perturb_others=True, awards=held) after = Simulation(situation=perturbed_situation) target = facts["target"] # 4. Others do not matter. From 08833ee7b41976b19a08928afd1940f555dd5571 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 21:36:27 -0400 Subject: [PATCH 12/13] Hold the household allocations, not the awards, in property 4 The review of 94041e911 found that holding every family's own awards still let property 4 fail through the same channel, and hid cross-family reads of awards: - In Newham, when the head's family stops claiming Universal Credit, the non-dependant moves into the sharer's claim and raises its uc_maximum_amount, which the four _legacy councils use as a UC family's applicable amount. The sharer's council award moved from 241.67 to 417.02 with its awards held. - Holding every family's awards also gives a perturbed 85-year-old head the award of the 47-year-old it replaced, and a mutant that read another family's UC survived. Property 4 now holds, with each family's CTR means test, the two household allocations through which other families reach a family's own awards: uc_non_dependants_counted (UC Regs 2013 Sch 4 para 9(2)) and has_non_dependant_for_severe_disability_premium. Each family's awards are computed. The body passes on CI's population, its minimal household and the reviewer's counterexample, and catches all five mutants the reviewer and author built. The generator also sets is_claimant_or_partner on every family's adults, so wide-gap couples stay couples once #2082 presumes a member 20 or more years younger to be a child. build() keeps each held value's own type. Co-Authored-By: Claude Opus 5.5 --- ...ction_claimant_family_scheme_properties.py | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) 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 889023d8d6..40794f881d 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 @@ -36,17 +36,21 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and simulated award unchanged, with its applicable amount, its - income and its own benefit awards held as they were. (The model's severe - disability premium counts the other adults in the household, so their - ages and benefits can change a family's applicable amount whatever its - scheme. And the household's non-dependants count in one Universal Credit - claim only (UC Regs 2013 Sch 4 para 9(2)), which the model makes the head - family's where it claims, so a head's family reaching pension age can - move them into a sharer's claim and change the sharer's award, which the - pensioner test follows.) Its council_tax_benefit is unchanged too, unless - it falls back to a reported reduction and whether a simulated claim in - its household pays something changes (property 5's reconciliation). + scheme and simulated award unchanged. Held as they were: its applicable + amount and income, and two household allocations through which other + families reach its own awards. The model's severe disability premium + counts the other adults in the household + (has_non_dependant_for_severe_disability_premium), so their ages and + benefits can change a family's applicable amounts whatever its scheme. + And the household's non-dependants count in one Universal Credit claim + only (UC Regs 2013 Sch 4 para 9(2); uc_non_dependants_counted), which the + model makes the head family's where it claims. So a head's family + reaching pension age can move them into a sharer's claim and change the + sharer's award, which the pensioner test follows. Each family's awards + are still computed, so a claim that read another family's awards would + fail. Its council_tax_benefit is unchanged too, unless it falls back to a + reported reduction and whether a simulated claim in its household pays + something changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme is simulated, and otherwise its reported one. Beside a simulated claim that pays something, a jointly liable claimant's reported reduction is @@ -244,6 +248,9 @@ def build(population, alone=False, perturb_others=False, awards=None): people[pid]["is_household_head"] = ( alone or fam["role"] == "head" ) and i == 0 + # A family's adults are its claimant and partner, however far + # apart their ages. + people[pid]["is_claimant_or_partner"] = True ids.append(pid) if fam["child_age"] is not None: pid = f"h{h}_f{f}_child" @@ -259,7 +266,7 @@ def build(population, alone=False, perturb_others=False, awards=None): if awards is not None: index = len(facts["role"]) benunits[f"h{h}_f{f}"].update( - {name: float(values[index]) for name, values in awards.items()} + {name: values[index].item() for name, values in awards.items()} ) if alone: homes[f"h{h}_f{f}"] = {"members": ids, **home} @@ -301,8 +308,7 @@ def any_in_benunit(simulation, person_values): return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 -# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3), -# held fixed in properties 2 and 4. +# A family's own awards that the pensioner test reads (SI 2012/2885 reg 3). OWN_AWARDS = [ "universal_credit_pre_benefit_cap", "income_support", @@ -314,6 +320,12 @@ def any_in_benunit(simulation, person_values): "council_tax_reduction_applicable_amount", "council_tax_reduction_applicable_income", ] +# Household allocations through which other families' circumstances reach a +# family's own awards, held fixed in property 4. +HOUSEHOLD_ALLOCATIONS = [ + "uc_non_dependants_counted", + "has_non_dependant_for_severe_disability_premium", +] FLAGS = [ "council_tax_reduction_pensioner", "council_tax_reduction_applicant_has_non_dep_exemption", @@ -416,7 +428,7 @@ def test_scheme_follows_own_family(population): def test_other_families_do_not_change_a_claim(population): situation, facts = build(population) before = Simulation(situation=situation) - held = {name: calc(before, name) for name in MEANS_TEST + OWN_AWARDS} + held = {name: calc(before, name) for name in MEANS_TEST + HOUSEHOLD_ALLOCATIONS} perturbed_situation, _ = build(population, perturb_others=True, awards=held) after = Simulation(situation=perturbed_situation) target = facts["target"] From af4269725436892b1df73fc3510061df9297ab8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 13:12:40 -0400 Subject: [PATCH 13/13] Hold the CTR capital in property 4 and pin its counterexamples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pension Credit's guarantee credit sets a family's CTR capital to nil, and the model lets other families reach that guarantee credit twice: the severe disability addition's residence condition counts every other adult in the household, and pension_credit_assessable_capital divides the household's savings among its pension-age adults. Property 4 held the CTR applicable amount and income but not the capital, so a head reaching pension age, or gaining Attendance Allowance, could move a pension-age sharer's award from nil to its £900 share of an £1,800 bill (review r5, finding 1). Hold council_tax_reduction_assessable_capital with the rest of the means test, name the two Pension Credit routes in the docstring, and pin as Hypothesis examples the four populations that broke earlier versions of the property: CI's Universal Credit non-dependant move, the uc_maximum_amount move, and the two Pension Credit routes. Co-Authored-By: Claude Opus 5.5 --- ...ction_claimant_family_scheme_properties.py | 150 ++++++++++++++++-- 1 file changed, 133 insertions(+), 17 deletions(-) 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 40794f881d..9849322beb 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 @@ -36,21 +36,28 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and simulated award unchanged. Held as they were: its applicable - amount and income, and two household allocations through which other - families reach its own awards. The model's severe disability premium - counts the other adults in the household - (has_non_dependant_for_severe_disability_premium), so their ages and - benefits can change a family's applicable amounts whatever its scheme. - And the household's non-dependants count in one Universal Credit claim - only (UC Regs 2013 Sch 4 para 9(2); uc_non_dependants_counted), which the - model makes the head family's where it claims. So a head's family - reaching pension age can move them into a sharer's claim and change the - sharer's award, which the pensioner test follows. Each family's awards - are still computed, so a claim that read another family's awards would - fail. Its council_tax_benefit is unchanged too, unless it falls back to a - reported reduction and whether a simulated claim in its household pays - something changes (property 5's reconciliation). + scheme and simulated award unchanged. Held as they were: its means test + (applicable amount, income and capital) and two household allocations, + because the model lets other families reach a family's own awards: + - its severe disability premium counts the other adults in the household + (has_non_dependant_for_severe_disability_premium), so their ages and + benefits can change a family's applicable amounts whatever its scheme; + - the household's non-dependants count in one Universal Credit claim + only (UC Regs 2013 Sch 4 para 9(2); uc_non_dependants_counted), which + the model makes the head family's where it claims, so a head's family + reaching pension age can move them into a sharer's claim and change the + sharer's award, which the pensioner test follows; + - Pension Credit's guarantee credit, which sets a family's CTR capital to + nil, depends on other families twice: the severe disability addition's + residence condition counts every other adult in the household, and + pension_credit_assessable_capital divides the household's savings among + its pension-age adults. + Each family's awards are still computed, so a claim that read another + family's awards would fail. Its council_tax_benefit is unchanged too, + unless it falls back to a reported reduction and whether a simulated + claim in its household pays something changes (property 5's + reconciliation). Populations that broke earlier versions of this + property are pinned as examples. 5. Fallback: a claiming family gets its simulated reduction where its scheme is simulated, and otherwise its reported one. Beside a simulated claim that pays something, a jointly liable claimant's reported reduction is @@ -71,7 +78,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -315,10 +322,12 @@ def any_in_benunit(simulation, person_values): "jsa_income", "esa_income", ] -# A family's means test, held fixed in property 4. +# A family's means test, held fixed in property 4. Its capital is nil where it +# gets guarantee credit, which other families can change in the model. MEANS_TEST = [ "council_tax_reduction_applicable_amount", "council_tax_reduction_applicable_income", + "council_tax_reduction_assessable_capital", ] # Household allocations through which other families' circumstances reach a # family's own awards, held fixed in property 4. @@ -423,8 +432,115 @@ def test_scheme_follows_own_family(population): assert not np.any(over & (paid > 0) & ~counts_student) +def pinned_family(role, ages, earnings, disabilities=None, in_education=None): + return dict( + role=role, + child_age=None, + reported=0.0, + ages=ages, + disabilities=disabilities or [None] * len(ages), + earnings=earnings, + in_education=in_education or [None] * len(ages), + ) + + +def pinned_household(local_authority, families, other_ages, **inputs): + """An English household whose second family is the target.""" + return dict( + families=families, + scheme=("ENGLAND", local_authority), + council_tax=inputs.get("council_tax", 1_800.0), + rent=inputs.get("rent", 12_000.0), + savings=inputs.get("savings", 0.0), + target=1, + other_ages=other_ages, + other_disabilities=inputs.get("other_disabilities", [None] * 6), + ) + + +# Populations that broke earlier versions of property 4, each through one of +# the routes its docstring lists. +# CI on d988725dc: the head's family reaches pension age, so the non-dependant +# moves into the sharer's Universal Credit claim and its award follows. +UC_NON_DEPENDANT_MOVES = [ + pinned_household( + "NEWHAM", + [ + pinned_family("head", [47], [0.0], ["attendance_allowance"], ["in_FE"]), + pinned_family( + "sharer", + [100, 63], + [27_200.0, 7_429.0], + None, + ["current_education", None], + ), + pinned_family("non_dependant", [66], [0.0], ["is_blind"]), + ], + [85, 36, 64, 70, 35, 67], + other_disabilities=[ + "attendance_allowance", + "attendance_allowance", + "pip_dl", + "attendance_allowance", + "attendance_allowance", + "is_blind", + ], + council_tax=2_865.0, + rent=33_635.0, + ) +] +# The same move raises the sharer's uc_maximum_amount, which the _legacy +# councils use as a Universal Credit family's applicable amount. +UC_MAXIMUM_MOVES = [ + pinned_household( + "NEWHAM", + [ + pinned_family("head", [30], [0.0]), + pinned_family("sharer", [40, 40], [2_000.0, 0.0]), + pinned_family("non_dependant", [30], [0.0]), + ], + [85] * 6, + council_tax=2_000.0, + rent=30_000.0, + ) +] +# The head reaching pension age halves the sharer's share of the household's +# savings for Pension Credit, so it gets guarantee credit and nil CTR capital. +PENSION_CREDIT_CAPITAL_SPLIT = [ + pinned_household( + "MAIDSTONE", + [ + pinned_family("head", [30], [30_000.0]), + pinned_family("sharer", [70], [12_000.0]), + ], + [85] * 6, + rent=6_000.0, + savings=17_000.0, + ) +] +# The head gaining Attendance Allowance meets the sharer's severe disability +# addition residence condition, with the same result. +PENSION_CREDIT_SEVERE_DISABILITY = [ + pinned_household( + "MAIDSTONE", + [ + pinned_family("head", [30], [30_000.0]), + pinned_family("sharer", [70], [12_000.0], ["attendance_allowance"]), + ], + [30] * 6, + other_disabilities=["attendance_allowance"] * 6, + rent=6_000.0, + savings=17_000.0, + ) +] + + @PROPERTY_SETTINGS @given(population) +@example(population=UC_NON_DEPENDANT_MOVES) +@example(population=UC_MAXIMUM_MOVES) +@example(population=PENSION_CREDIT_CAPITAL_SPLIT) +@example(population=PENSION_CREDIT_SEVERE_DISABILITY) def test_other_families_do_not_change_a_claim(population): situation, facts = build(population) before = Simulation(situation=situation)