diff --git a/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md b/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md new file mode 100644 index 000000000..2850e8d98 --- /dev/null +++ b/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md @@ -0,0 +1 @@ +Use Scotland's working-age Council Tax Reduction young-person definition for the severe disability premium's non-dependant test within the applicant's benefit unit, retaining existing looked-after and other-benefit-unit treatment. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml new file mode 100644 index 000000000..8e67b3197 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml @@ -0,0 +1,167 @@ +# SSI2021/249 regs6,8(2)(a),Sch1para13. Existing2026 model controls: +# allowance95.55*52=4968.60; severe86.05*52=4474.60; withdrawal20%. +# PIP qualifies SDP but generic disability flag is separately supplied/defaultfalse. +# Pension12570:no tax; PIP disregarded; Child Benefit claim declined. +# CTR=2000-20%*(12570-allowance):479.72 or1374.64. +# Annual UC terminal-date input represents unavailable assessment-period detail. +# Reverse case supplies CB family status: input-contract composition, +# not a naturally derived eligibility pair or full statutory rate audit. +# Shared HB premium component and IS applicable amount are protected below. +# This household has no continuing HB claim, so its gated HB applicable +# amount is0; it is not used as a positive HB-payment regression oracle. +- name: 'Terminal-date input contract: CB young person after UC terminal date removes + Scottish CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 479.72 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 4968.6 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 0 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: Before terminal date both definitions retain Scottish CTR premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: 'Supplied-status contract: UC young person outside CB family restores Scottish + CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 0 + benefits_premiums: 0 + income_support_applicable_amount: 4968.6 diff --git a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py new file mode 100644 index 000000000..433413847 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -0,0 +1,141 @@ +"""CTR family-mask input contracts, separate from derived eligibility tests. + +SSI 2021/249 regs 6 and 8(2)(a), Sch 1 para 13 retain an applicant's +UC young person in their family. These finite grids deliberately supply the +two young-person statuses to exercise both directions independently of the +upstream eligibility rules. The policy YAML uses school/course inputs and +the annual terminal-date input, plus actual PIP, pension and council tax. + +Looked-after and other-benefit-unit treatment remains the existing model +baseline, not a certification of reg 8(2)(b) and its paragraph (3) overrides. +""" + +from itertools import product + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): + people, benunits, households = {}, {}, {} + for i, row in enumerate(rows): + applicant, student = f"a{i}", f"s{i}" + people[applicant] = { + "age": applicant_age, + "is_claimant_or_partner": True, + "pip_dl_category": "ENHANCED", + } + people[student] = { + "age": row.get("age", 19), + "is_claimant_or_partner": False, + "is_child_or_qualifying_young_person_for_child_benefit": row["cb"], + "is_qualifying_young_person_for_universal_credit": row["uc"], + "is_looked_after_by_local_authority": row.get("looked_after", False), + "is_blind": row.get("blind", False), + "pip_dl_category": "STANDARD" if row.get("benefit", False) else "NONE", + } + members = [applicant, student] + benunits[f"b{i}"] = { + "members": [applicant] if row.get("other_bu", False) else members, + "would_claim_uc": False, + "would_claim_IS": False, + "would_claim_pc": False, + } + if row.get("other_bu", False): + benunits[f"other{i}"] = {"members": [student]} + households[f"h{i}"] = {"members": members, "country": country} + situation = {"people": people, "benunits": benunits, "households": households} + # A situation's default period is the model default, not necessarily year. + for group in situation.values(): + for values in group.values(): + for name, value in list(values.items()): + if name != "members": + values[name] = {year: value} + return Simulation(situation=situation), [f"b{i}" for i in range(len(rows))] + + +def values(sim, variable, names, year=2026): + result = sim.calculate(variable, year) + ids = list(sim.populations["benunit"].ids) + return np.array([result[ids.index(name)] for name in names]) + + +@pytest.mark.parametrize( + "country,year,applicant_age,scottish_working_age", + [ + ("SCOTLAND", 2026, 45, True), + ("SCOTLAND", 2021, 45, False), + ("SCOTLAND", 2026, 70, False), + ("ENGLAND", 2026, 45, False), + ("WALES", 2026, 45, False), + ("NORTHERN_IRELAND", 2026, 45, False), + ], +) +def test_own_family_status_contract_by_applicant_scheme( + country, year, applicant_age, scottish_working_age +): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows, country, year, applicant_age) + expected = [ + not ( + row["uc"] if scottish_working_age and not row["looked_after"] else row["cb"] + ) + for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + year, + ).tolist() + == expected + ) + # HB and IS retain the original Child Benefit family classification. + assert values( + sim, "has_non_dependant_for_severe_disability_premium", names, year + ).tolist() == [not row["cb"] for row in rows] + + +def test_age_boundary_and_blind_or_benefit_exceptions(): + rows = [ + dict(age=age, cb=True, uc=False, blind=blind, benefit=benefit) + for age, blind, benefit in product( + [17.999, 18, 19, 20], [False, True], [False, True] + ) + ] + sim, names = input_grid(rows) + expected = [ + row["age"] >= 18 and not row["blind"] and not row["benefit"] for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).tolist() + == expected + ) + # The shared gate still disregards these own-BU Child Benefit young people. + assert not values( + sim, "has_non_dependant_for_severe_disability_premium", names + ).any() + + +def test_other_benefit_units_keep_existing_treatment(): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after, other_bu=True) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows) + assert values(sim, "has_non_dependant_for_severe_disability_premium", names).all() + assert values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).all() diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index a63c5c403..01e71f0cd 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -57,6 +57,10 @@ def formula(benunit, period, parameters): premiums = where( pensioner, benunit("pension_age_benefits_premiums", period), - benunit("working_age_benefits_premiums", period), + benunit("working_age_benefits_premiums", period) + + ( + benunit("council_tax_reduction_severe_disability_premium", period) + - benunit("severe_disability_premium", period) + ), ) return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py new file mode 100644 index 000000000..ba30175f1 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_severe_disability_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR severe disability premium" + documentation = ( + "The shared legacy premium's amount before its non-dependant test, " + "with the Council Tax Reduction-specific family test applied. This " + "allows either direction of a difference between the Universal Credit " + "and Child Benefit young-person definitions. The test retains the " + "existing classification for looked-after residents and other benefit " + "units; see has_non_dependant_for_council_tax_reduction_severe_disability_premium. " + "Pension-age Council Tax Reduction continues to use its separate premium." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + amount = benunit("severe_disability_premium_before_non_dependant_test", period) + no_non_dependant = ~benunit( + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + period, + ) + return amount * no_non_dependant diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py new file mode 100644 index 000000000..60c103752 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class has_non_dependant_for_council_tax_reduction_severe_disability_premium(Variable): + value_type = bool + entity = BenUnit + label = "Has a non-dependant who blocks the CTR severe disability premium" + documentation = ( + "Uses the applicant family's Council Tax Reduction young-person definition " + "for non-looked-after members of its own benefit unit in Scotland's " + "working-age scheme. The applicant's own pensioner status and the dated " + "young-person parameter select that scheme. All other schemes retain " + "the shared Housing Benefit/Income Support test. Blind residents and " + "qualifying-benefit recipients remain disregarded. Looked-after members " + "retain the exact existing Child Benefit family classification, and " + "other benefit units retain the existing treatment. Reg 8(2)(b), subject " + "to reg 8(3), also excludes some young people outside the applicant's " + "responsibility; the model lacks complete tenancy and responsibility " + "relations for those looked-after and other-benefit-unit cases. They " + "remain outside this partial correction. Other limitations of " + "has_non_dependant_for_severe_disability_premium also remain." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + counted = ( + (person("age", period) >= p.severe_non_dependant_age) + & ~person("receives_severe_disability_premium_qualifying_benefit", period) + & ~person("is_blind", period) + ) + child_or_young_person = where( + person("is_looked_after_by_local_authority", period), + person("is_child_or_qualifying_young_person_for_child_benefit", period), + person("is_child_or_young_person_for_council_tax_reduction", period), + ) + family = person("is_claimant_or_partner", period) | child_or_young_person + in_household = benunit.max(person.household.sum(counted)) + non_dependant = in_household > benunit.sum(counted & family) + scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.working_age + scottish_working_age = ( + (benunit.household("country", period) == Country.SCOTLAND) + & ~benunit("council_tax_reduction_pensioner", period) + & scotland.young_person_follows_universal_credit + ) + return where( + scottish_working_age, + non_dependant, + benunit("has_non_dependant_for_severe_disability_premium", period), + ) diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 051874309..d748f85fb 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -50,47 +50,8 @@ class severe_disability_premium(Variable): unit = GBP def formula(benunit, period, parameters): - # Whom a carer benefit is paid for caring for is attributed by - # is_cared_for_by_carer_benefit_recipient, which the Pension Credit - # severe disability addition shares. The hospital rules that keep a - # patient treated as in receipt (and then pay the single rate) are not - # modelled. - p = parameters(period).gov.dwp.disability_premia - person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - qualifies = person( - "receives_severe_disability_premium_qualifying_benefit", period - ) - qualifying_member = claimant_or_partner & qualifies - qualifying = benunit.sum(qualifying_member) - partner_treated_as_absent = benunit.any( - claimant_or_partner & ~qualifies & person("is_blind", period) - ) - cared_for = benunit.sum( - qualifying_member - & person("is_cared_for_by_carer_benefit_recipient", period) - ) - # Number of single rates payable: two for a couple who both qualify - # with no carer paid for either, one when a carer is paid for only - # one of them, and one for a single claimant (or a couple whose - # other partner is blind) with no carer paid for them. - couple = benunit("is_couple", period) - single_condition = where( - couple, - (qualifying == 1) & partner_treated_as_absent, - qualifying >= 1, - ) & (cared_for == 0) - rates = where( - couple & (qualifying >= 2), - max_(2 - cared_for, 0), - single_condition, - ) - weekly_amount = select( - [rates >= 2, rates == 1], - [p.severe_couple, p.severe_single], - default=0, - ) + amount = benunit("severe_disability_premium_before_non_dependant_test", period) no_non_dependant = ~benunit( "has_non_dependant_for_severe_disability_premium", period ) - return weekly_amount * WEEKS_IN_YEAR * no_non_dependant + return amount * no_non_dependant diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py new file mode 100644 index 000000000..eac30436c --- /dev/null +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py @@ -0,0 +1,63 @@ +from policyengine_uk.model_api import * + + +class severe_disability_premium_before_non_dependant_test(Variable): + value_type = float + entity = BenUnit + label = "Severe disability premium before the non-dependant test" + documentation = ( + "The shared legacy premium's annual amount after its qualifying-benefit, " + "partner and carer conditions, before its non-dependant condition. " + "This is a component, not a paid award. It retains the assumptions and " + "limitations documented by severe_disability_premium. Council Tax " + "Reduction can apply its own family definition to this amount." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", + ) + + def formula(benunit, period, parameters): + # Whom a carer benefit is paid for caring for is attributed by + # is_cared_for_by_carer_benefit_recipient, which the Pension Credit + # severe disability addition shares. The hospital rules that keep a + # patient treated as in receipt (and then pay the single rate) are not + # modelled. + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifies = person( + "receives_severe_disability_premium_qualifying_benefit", period + ) + qualifying_member = claimant_or_partner & qualifies + qualifying = benunit.sum(qualifying_member) + partner_treated_as_absent = benunit.any( + claimant_or_partner & ~qualifies & person("is_blind", period) + ) + cared_for = benunit.sum( + qualifying_member + & person("is_cared_for_by_carer_benefit_recipient", period) + ) + # Number of single rates payable: two for a couple who both qualify + # with no carer paid for either, one when a carer is paid for only + # one of them, and one for a single claimant (or a couple whose + # other partner is blind) with no carer paid for them. + couple = benunit("is_couple", period) + single_condition = where( + couple, + (qualifying == 1) & partner_treated_as_absent, + qualifying >= 1, + ) & (cared_for == 0) + rates = where( + couple & (qualifying >= 2), + max_(2 - cared_for, 0), + single_condition, + ) + weekly_amount = select( + [rates >= 2, rates == 1], + [p.severe_couple, p.severe_single], + default=0, + ) + return weekly_amount * WEEKS_IN_YEAR